Skip to content

Instrumentation

An instrumentation observes executions. Tenace calls it around each execution and each attempt, and passes it the events the strategies report: retries, timeouts, rejections, fallbacks. The OpenTelemetry package, listener(), and the diagnostics_channel publisher all implement the same Instrumentation interface.

listener(onEvent) builds an instrumentation that receives the events only:

import { listener, Tenace } from '@julr/tenace'
Tenace.instrument(listener((event, info) => logger.info({ operation: info.name, event })))

info describes the execution. See Execution info.

Tenace.instrument(...instrumentations) sets the default instrumentation of every policy. With several arguments, it combines them. It returns a function that removes this setting. The default is the latest setting not removed yet, so restore functions can run in any order:

import { listener, Tenace } from '@julr/tenace'
import { openTelemetry } from '@julr/tenace-otel'
const restore = Tenace.instrument(
openTelemetry(),
listener(event => logger.debug(event)),
)

The instrumentation option of a policy adds an instrumentation for that policy. The default one still runs and wraps it: its execution() starts first and ends last. Passing the default instrumentation itself to a policy runs it once, but two separate openTelemetry() instances record every execution twice. Set instrumentation: false to turn the default one off for a policy.

import { listener, Tenace } from '@julr/tenace'
const policy = Tenace.policy({
name: 'users.get',
instrumentation: listener(event => logger.info(event)),
})

The executions are also published on the diagnostics channels.

The default lives on globalThis, so it applies to every copy of Tenace loaded in the process.

Every hook receives an ExecutionInfo:

Field Description
name The name option of execute(), then the name of the policy, then 'tenace'. Keep it low cardinality.
strategies The strategy names, from the outermost to the innermost.
attributes The attributes option of execute(), {} by default. Values are strings, numbers, or booleans.
import { Tenace } from '@julr/tenace'
const policy = Tenace.policy({ name: 'users.get' }).withTimeout('1s')
await policy.execute(() => getUser(id), {
name: 'users.get_by_id',
attributes: { 'user.tier': 'premium' },
})

The interface has one method:

interface Instrumentation {
execution(
info: ExecutionInfo,
run: (scope: ExecutionScope) => Promise<AnyResult>,
): Promise<AnyResult>
}
interface ExecutionScope {
attempt(info: AttemptInfo, run: () => Promise<AnyResult>): Promise<AnyResult>
event(event: TenaceEvent): void
}

execution() wraps the whole execution. It calls run(scope) once, and returns what run resolves with: the final Result of the execution. run rejects only when a strategy or a callback throws, or with signal.reason when the caller aborts.

The scope you pass to run receives the rest:

  • attempt(info, run) wraps each call of your function. info.attempt is the 1-based attempt number. Call run() inside the context your function should see, such as an active span.
  • event(event) receives each event the strategies report. The event reference lists them.

This instrumentation logs the duration and outcome of each execution, and makes the attempt number available to the code of your function through AsyncLocalStorage:

import { AsyncLocalStorage } from 'node:async_hooks'
import { type Instrumentation, Tenace } from '@julr/tenace'
export const currentAttempt = new AsyncLocalStorage<number>()
const timing: Instrumentation = {
async execution(info, run) {
const started = performance.now()
const result = await run({
attempt: (attempt, fn) => currentAttempt.run(attempt.attempt, fn),
event: event => logger.debug({ operation: info.name, event: event.type }),
})
logger.info({ operation: info.name, ok: result.ok, ms: performance.now() - started })
return result
},
}
Tenace.instrument(timing)

combine(...instrumentations) merges several into one. The first wraps the others: its execution() starts first and ends last. An event handler that throws does not keep the event from the others.

A bug in an instrumentation never changes the outcome of an execution. Tenace enforces the contract:

  • An error thrown by a hook or an event handler is reported with globalThis.reportError. Where it is missing, as on Node.js, the error is thrown from a microtask, so it becomes an uncaught exception, which stops the process by default.
  • run and the run of attempt call your function at most once. A second call rejects with a TypeError, which is reported.
  • The caller always gets the outcome of run, whatever the hook returns.
  • An execution() or attempt() hook that settles without calling run is reported, and the execution runs without it.
  • Events emitted after the execution settled are dropped.

A hook that never settles still blocks the execution.

When no instrumentation is set and nothing subscribes to the diagnostics channels, executions skip every hook. A strategy can check context.observed to skip building an event nobody receives.