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.
Log events with listener()
Section titled “Log events with listener()”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.
Set the instrumentation
Section titled “Set the instrumentation”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.
Execution info
Section titled “Execution info”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' },})Write an instrumentation
Section titled “Write an instrumentation”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.attemptis the 1-based attempt number. Callrun()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.
When an instrumentation fails
Section titled “When an instrumentation fails”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. runand therunofattemptcall your function at most once. A second call rejects with aTypeError, which is reported.- The caller always gets the outcome of
run, whatever the hook returns. - An
execution()orattempt()hook that settles without callingrunis reported, and the execution runs without it. - Events emitted after the execution settled are dropped.
A hook that never settles still blocks the execution.
What it costs
Section titled “What it costs”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.