opinionated-telemetry
v0.2.0
Published
Opinionated OpenTelemetry: We've thought a lot about Telemetry in Node.js, so you don't have to
Readme
opinionated-telemetry
We've thought a lot about Telemetry in Node.js, so you don't have to
Opinionated OpenTelemetry patterns extracted from years of learnings from real-world o11y, to help Node.js projects eveywhere. Sensible defaults, tools, and hooks to cut out the noise and cost from default "instrument everything" implementations, and really drill down and understand what's most important.
The defaults included may or may not be right for you, so be sure to read the options carefully. Comes with powerful hooks for sampling spans, dropping spans, collapsing intermediate spans, globally "auto-instrumenting" important bits of your own code, as well as some nice helpers for some libraries I've used in the past.
Contributions & feedback are welcome, especially if you have ideas or other libraries with sensible default hooks that you'd like to share.
Best suited for & meant use with Honeycomb.io. Virtually unlimited attributes on a span for no additional cost is so powerful, I can't believe I don't hear its praises more often.
Features
- Stuck span detection: detects in-flight spans exceeding a threshold and exports early "diagnostic" span snapshots, so you can understand what might be broken sooner (long queries, hung requests, etc.)
- Sync span dropping: automatically drops non-root "synchronous" spans that start and end in the same tick by default (root spans are always kept). Configurable to allow for keeping specific spans e.g. keeping sync spans >
100ms, or spans with a certain name, etc. - Span collapsing: allows us to drop intermediate spans (e.g. knex, graphql) and merge their attributes into child spans. No use having both a
knexspan that has a single nestedpgormysqlspan underneath, unless there is an error in theknexspan or something - Conditional span dropping: tail-based conditional dropping with child buffering — register a
shouldDropcallback at span start, evaluate at span end based on duration/error/attributes, with automatic child reparenting. Supports both simple drop (no attribute inheritance) and collapse mode (inherit attributes into children) - Baggage propagation: propagates baggage entries as span attributes on all child spans by default, with outbound baggage suppressed to prevent leaking sensitive data to external APIs (opt-in allowlisting by host and key)
- Memory & Memory delta tracking: captures RSS (or detailed heap, configurable) memory & memory deltas on root spans.
- Auto-instrumentation: opt-in hooks wraps exported async functions from your own codebase with auto-spans based on the function or method name, configured via
ESMorModule._loadpatching - Head & Tail Based Sampling: Includes sensible approaches to dealing with Head & Tail based sampling out of the box
- Burst Protection: Along with the sampling, includes some conventions for preventing a simple coding mistake that generates an infinite loop spawning thousands of spans per second from doing too much damage.
- Span aggregation: collapses N parallel sibling spans with the same name (dataloader batches, S3 multi-get, parallel DB queries) into a single aggregate span with summary statistics, while preserving individual error spans. Configurable via per-scope options or a root-level predicate, with optional custom attribute stats (min, max, avg, median, uniq, etc.)
- Metric sources: pick which sources you export (HTTP, runtime, processor) with simple on/off toggles. Honeycomb stores metrics natively, so they're sent as-is over OTLP — no flattening.
- Node runtime metrics: focused runtime health metrics (event loop delay + utilization, heap, GC, CPU, active resources, memory) using built-in Node.js APIs — replaces noisy
@opentelemetry/host-metricsandinstrumentation-runtime-nodewith a focused, actionable set (heap/memory/CPU/event-loop + GC as sub-interval-sampled histograms so peaks aren't averaged away). - Metric filtering: drop/allow metrics by name using glob patterns, regex, or predicates — cut metric noise and cost without learning the OTel Views API
- Integration helpers: knex, graphql, bull, socket.io, express
- Event loop utilization: captures event loop utilization (0-1) on all spans. Useful for alerting on situation where expensive spans are blocking the event loop. Particularly useful when dealing with things that are synchronous, like
fscalls orbetter-sqlite3queries
Install
npm install opinionated-telemetryQuick start
import { opinionatedTelemetryInit } from 'opinionated-telemetry'
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
import { KnexInstrumentation } from '@opentelemetry/instrumentation-knex'
const { sdk, getTracer, shutdown } = opinionatedTelemetryInit({
serviceName: 'my-service',
traceExporter: new OTLPTraceExporter({
url: 'https://api.honeycomb.io:443/v1/traces', // or wherever you want these to go. We recommend Honeycomb
headers: '...your headers...',
}),
instrumentations: [
new HttpInstrumentation(),
new KnexInstrumentation({ maxQueryLength: 10000 }),
],
// Per-instrumentation hooks, keyed by instrumentation scope name
instrumentationHooks: {
'@opentelemetry/instrumentation-knex': {
collapse: true,
},
},
})Core API
opinionatedTelemetryInit(config)
Initializes the OTel SDK with opinionated defaults.
opinionatedTelemetryInit({
serviceName: string,
resourceAttributes?: Record<string, AttributeValue>,
traceExporter?: SpanExporter,
metricReaders?: MetricReader[],
resourceDetectors?: ResourceDetector[],
autoDetectResources?: boolean,
idGenerator?: IdGenerator,
spanLimits?: SpanLimits,
dropSyncSpans?: true | ((span) => boolean), // default: true (root spans always kept)
baggageToAttributes?: boolean, // default: true
memory?: boolean | MemoryConfig, // default: true (rss only)
memoryDelta?: boolean | MemoryConfig, // default: true (rss only)
eventLoopUtilization?: boolean | 'root', // default: true (all spans)
stuckSpanDetection?: boolean | StuckSpanConfig, // default: true
onSpanAfterShutdown?: (span) => void, // default: logger.warn
shutdownSignal?: string | null, // default: 'SIGTERM'
aggregateSpan?: (span) => boolean | AggregateConfig, // default: undefined
onDroppedSpan?: (span, reason, durationMs?) => void, // called on dropped/sampled/stuck spans
onCapturedSpan?: (span, durationMs) => void, // called on accepted/exported spans
instrumentations: Instrumentation[],
instrumentationHooks?: Record<string, OpinionatedOptions>,
globalHooks?: GlobalHooks, // hooks for all spans
additionalSpanProcessors?: SpanProcessor[],
batchProcessorConfig?: BufferConfig | false, // false disables batching
baggagePropagation?: BaggagePropagationConfig, // default: suppress all outbound
runtimeMetrics?: NodeRuntimeMetricsConfig | false, // default: enabled
processorMetrics?: boolean, // default: true — see docs/PROCESSOR_METRICS.md
metricSources?: { http?, runtime?, processor? }, // high-level source toggles — see Metric Sources
disableRuntimeNodeInstrumentation?: boolean, // default: auto — dedupe instrumentation-runtime-node
metricExporter?: PushMetricExporter, // shorthand: we create the MetricReader for you
metricExportInterval?: number, // default: 60_000 (used with metricExporter)
metricFilter?: { drop?: MetricPattern[], allow?: MetricPattern[] }, // see Metric Filtering
metricResourceAttributes?: { drop?: (string|RegExp)[], keep?: (string|RegExp)[] }, // trim resource attrs from metrics
logger?: OpinionatedLogger, // default: console
})Returns { sdk, getTracer, shutdown }.
memory
Captures memory usage root spans under opin_tel.memory.* attribute.
Set to true (default) for RSS-only via the fast process.memoryUsage.rss() path, or pass a MemoryConfig object to pick specific fields (rss, heapTotal, heapUsed, external, arrayBuffers) which uses the full process.memoryUsage() call. Set to false to disable.
memoryDelta
Captures memory usage deltas on root spans under opin_tel.memory_delta.* attribute.
Set to true (default) for RSS-only via the fast process.memoryUsage.rss() path, or pass a MemoryConfig object to pick specific fields (rss, heapTotal, heapUsed, external, arrayBuffers) which uses the full process.memoryUsage() call. Set to false to disable.
eventLoopUtilization
Captures event loop utilization (0-1 ratio) via performance.eventLoopUtilization(). The opin_tel.event_loop.utilization attribute tells you how saturated the event loop was during the span's lifetime. Set to true (default) for all spans, 'root' for root spans only, or false to disable.
stuckSpanDetection
Detects spans that remain in-flight longer than a threshold and exports diagnostic snapshots. Enabled by default (60s threshold, 5s check interval). Pass a StuckSpanConfig object to customize:
stuckSpanDetection: {
thresholdMs: 60_000, // how long before a span is "stuck"
intervalMs: 5_000, // how often to check
onStuckSpan: (span, durationMs, reportedCount) => {
// Called on every report (not just first detection).
// reportedCount is 0 on first detection, 1 on second, etc.
//
// Return values:
// void / true — export snapshot, keep tracking (default)
// false — skip snapshot, keep tracking
// 'evict' — export snapshot, then remove span from all tracking
// 'drop' — remove span from all tracking WITHOUT exporting snapshot
// Example: evict after 3 reports to prevent memory leaks
if (reportedCount >= 2) return 'evict'
},
}Stuck span snapshots are exported with the original span's trace ID, a unique snapshot span ID (to avoid backend deduplication), an (incomplete) name suffix, and attributes:
| Attribute | Description |
| ------------------------------- | ----------------------------------------------------- |
| opin_tel.stuck.duration_ms | How long the span has been in-flight |
| opin_tel.stuck.is_snapshot | true on snapshots, false on the real span |
| opin_tel.stuck.reported_count | Number of times this span has been reported (1-based) |
| opin_tel.stuck.source_span_id | The original span's ID (links snapshot to source) |
Snapshots also receive memory delta, ELU, and instrumentation hook enrichment. Re-reports are throttled — after first detection, the next report waits a full thresholdMs from the last report time.
Eviction ('evict' / 'drop'): Removes the span from all internal tracking (_allSpans, _activeSpanIds, _reportedStuckSpans, collapse state, conditional drop buffers). This prevents memory leaks from spans whose .end() is never called. If the span later has .end() called, it flows through the normal export path (the eviction is a no-op for cleanup). 'drop' also fires onDroppedSpan with reason 'stuck'.
Interaction with collapse and shouldDrop: If a span is marked for collapse or has a shouldDrop callback, but is detected as stuck, the stuck status takes precedence — the span is exported as a snapshot. When the real span finally ends, it's exported with opin_tel.stuck.is_snapshot = false to indicate it was previously reported as stuck.
batchProcessorConfig
Overrides for the internal BatchSpanProcessor. Opinionated defaults differ from OTel's out-of-the-box values:
| Option | OTel Default | Opinionated Default |
| ---------------------- | ------------ | ------------------- |
| scheduledDelayMillis | 5000 | 2000 |
| exportTimeoutMillis | 30000 | 10000 |
| maxExportBatchSize | 512 | 512 |
| maxQueueSize | 2048 | 2048 |
Pass a BufferConfig object to override any of these:
opinionatedTelemetryInit({
batchProcessorConfig: {
scheduledDelayMillis: 1000, // flush every second
maxQueueSize: 4096,
},
})Set to false to disable batching entirely and use a SimpleSpanProcessor instead. Spans will be exported immediately as they complete, which is useful for development, testing, or low-volume services:
opinionatedTelemetryInit({
batchProcessorConfig: false,
})baggagePropagation
By default, OpenTelemetry's W3CBaggagePropagator injects all baggage entries into a baggage HTTP header on every outgoing request — including requests to third-party APIs. This means any data you set as baggage (request headers, user IDs, tokens) can silently leak to external services.
opinionated-telemetry ships a FilteredBaggagePropagator that suppresses all outbound baggage by default. Inbound baggage extraction always works — this only affects what gets sent on outgoing requests.
To allow baggage propagation to specific internal services:
opinionatedTelemetryInit({
// ...
baggagePropagation: {
// Only propagate baggage to these hosts
allowedHosts: [
'*.internal.example.com', // wildcard subdomain match
'partner-api.trusted.com', // exact match
],
// Only propagate these baggage keys (required — omit or [] to block all)
allowedKeys: [
'requestId',
'app.*', // wildcard prefix match
// '*', // uncomment to allow all keys
],
},
})| Option | Description |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| allowedHosts | Host patterns where baggage is propagated. Supports exact match and *.domain.com wildcards. Default: [] (suppress all) |
| allowedKeys | Baggage key patterns to include. Supports exact match, prefix.* wildcards, and * for all keys. Default: [] (block all) |
How host matching works: The propagator reads server.address from the active client span (set by instrumentation-http before injection). If there's no active span or no server.address attribute, baggage is suppressed (safe fallback).
Note: If the
OTEL_PROPAGATORSenvironment variable is set, NodeSDK ignores the filtered propagator and uses the env-configured propagators instead. A warning is logged when this happens.
The FilteredBaggagePropagator class is also exported for standalone use if you need to configure propagators manually.
Span Aggregation
When a parent span fires off many parallel child spans with the same name (dataloader batches, S3 multi-get, parallel DB queries), the result is N nearly-identical sibling spans that add volume without proportional signal. Span aggregation collapses them into a single aggregate span with summary statistics.
How it works: Spans are grouped by ${parentSpanId}:${spanName}. By default (emit: 'onInflightZero'), the group tracks in-flight count; when it drops to zero, the batch is complete and an aggregate span is emitted. If the same parent later starts another batch with the same name, it's a new group.
Error handling: By default, error spans are exported individually (full attributes, events, stack trace preserved) and counted in opin_tel.agg.error_count. Set keepErrors: false to consume error spans into the aggregate instead.
Single-span optimization: If only one non-error span arrives in a group (and no errors), it's exported as-is — no aggregate wrapper.
Emission modes
| Mode | Behavior | Best for |
| ---------------------------- | ---------------------------------------------------- | --------------------------------------------- |
| 'onInflightZero' (default) | Emits when all started spans in the group have ended | Parallel patterns (Promise.all) |
| 'onParentEnd' | Defers emission until the parent span ends | Sequential loops (for/while with await) |
With the default onInflightZero, sequential spans each start and end before the next — inflight goes 1→0 every iteration, producing N separate aggregates of count=1. Use emit: 'onParentEnd' to keep the group open and aggregate all sequential children into one span.
Chunking
The chunk option emits intermediate aggregate spans at regular intervals, useful for long-running loops where you want periodic visibility without waiting for the parent to end.
- Number — emit every N spans:
chunk: 100 - Predicate — emit when the function returns true:
chunk: (span, stats) => stats.totalDurationMs > 5000
The predicate receives the current span and an AggregateGroupStats object (count, errorCount, nonErrorCount, totalDurationMs, minDurationMs, maxDurationMs).
Each chunk gets an opin_tel.agg.chunk_index attribute (0-based). After a chunk emits, the group stats reset for the next chunk. Any remaining spans are emitted when the group closes (parent end or inflight zero).
Chunking works with both emission modes.
Root-level predicate
opinionatedTelemetryInit({
// Return true for default aggregation, or an AggregateConfig for custom stats
aggregateSpan: (span) => {
if (span.name.startsWith('S3.')) return true
if (span.name === 'redis.cmd')
return {
attributes: {
response_bytes: {
attribute: 'redis.response_size_bytes',
options: ['min', 'max', 'avg'],
},
all_statements: {
attribute: 'db.statement',
options: 'uniq',
},
},
}
// Sequential loop — aggregate all iterations under the parent
if (span.name === 'process.record')
return { emit: 'onParentEnd', chunk: 100 }
return false
},
})Per-instrumentation
instrumentationHooks: {
'@opentelemetry/instrumentation-dataloader': {
aggregate: true,
},
// Or with custom attribute stats:
'@opentelemetry/instrumentation-redis': {
aggregate: {
keepErrors: false,
attributes: {
sizes: {
attribute: 'redis.response_size_bytes',
options: ['min', 'max', 'range'],
},
},
},
},
}Aggregate span attributes
Every aggregate span has opin_tel.meta.is_aggregate = true and the following built-in stats:
| Attribute | Description |
| -------------------------------- | ------------------------------------------------------------- |
| opin_tel.agg.count | Total spans in the group (including errors) |
| opin_tel.agg.error_count | Number of spans with ERROR status |
| opin_tel.agg.min_duration_ms | Shortest non-error span duration |
| opin_tel.agg.max_duration_ms | Longest non-error span duration |
| opin_tel.agg.avg_duration_ms | Mean non-error span duration |
| opin_tel.agg.total_duration_ms | Sum of non-error span durations |
| opin_tel.agg.chunk_index | 0-based chunk index (present only when chunk is configured) |
Custom attribute stat options
When you configure attributes, each entry maps an output key to a source attribute and one or more stat options. Stats are emitted as opin_tel.agg.{outputKey}.{stat}.
| Option | Input type | Description |
| -------- | ---------- | ----------------------------------------------------- |
| uniq | any | Array of unique values (converted to strings) |
| count | any | Number of spans that had this attribute |
| sum | numeric | Sum of values |
| min | numeric | Minimum value |
| max | numeric | Maximum value |
| range | numeric | max - min |
| avg | numeric | Mean value |
| median | numeric | Median (average of two middle values for even counts) |
instrumentationHooks
Per-instrumentation hooks keyed by instrumentation scope name. Allows you to customize span behavior for specific instrumentations without wrapping them.
opinionatedTelemetryInit({
instrumentations: [
new HttpInstrumentation(),
new KnexInstrumentation(),
new DataloaderInstrumentation(),
],
instrumentationHooks: {
'@opentelemetry/instrumentation-knex': {
collapse: true,
},
'@opentelemetry/instrumentation-dataloader': {
aggregate: true,
},
'@opentelemetry/instrumentation-http': {
onEnd: (span, durationMs) =>
span.updateName(
`${span.attributes['http.method']} ${span.attributes['http.route']} (${Math.round(durationMs)}ms)`,
),
},
},
})Options per hook:
collapse— drop this span, merge attrs into children, reparent children to grandparentaggregate—trueor anAggregateConfigto collapse parallel sibling spans into a single aggregateonStart(span)— called during span start; usespan.updateName()to rename,span.setAttribute()to enrich. Can return{ collapse: true }to collapse this specific span, or{ shouldDrop }to register conditional span dropping. If both are returned,collapsetakes precedence.onEnd(span, durationMs)— called during span end (before export);durationMsis the span duration in milliseconds. Same span mutation APIs available
A warning is logged (via console.warn by default) if any hook key doesn't match a registered instrumentation name. Pass a custom logger to redirect or suppress these warnings.
globalHooks
Global hooks that fire for every span, regardless of instrumentation scope. Useful for cross-cutting concerns like conditional span dropping based on span attributes.
opinionatedTelemetryInit({
// ...
globalHooks: {
onStart: (span) => {
// Called on every span start
// Can return { collapse: true } for per-span collapsing
// Can return { shouldDrop } to register conditional dropping
},
onEnd: (span, durationMs) => {
// Called on every span end, after enrichment
},
},
})Both globalHooks.onStart and instrumentationHooks[scope].onStart can return { collapse: true } for per-span collapsing, or { shouldDrop } to register conditional span dropping — see below.
Conditional Span Dropping
Tail-based conditional span dropping allows you to register a shouldDrop callback at span start, then decide whether to drop the span when it ends — based on duration, error status, or any other span data.
Child spans are buffered while waiting for the parent's decision. When a span is dropped, its children are reparented to the grandparent. When kept, all buffered children are flushed normally.
Register via globalHooks.onStart or instrumentationHooks[scope].onStart:
opinionatedTelemetryInit({
// ...
globalHooks: {
onStart: (span) => {
// Drop pg-pool.connect spans unless they're slow or errored
if (span.name === 'pg-pool.connect') {
return {
shouldDrop: (span, durationMs) => {
if (span.status.code === SpanStatusCode.ERROR) return false // keep errors
if (durationMs > 50) return false // keep slow connections
return true // drop fast, successful connections
},
}
}
},
},
})shouldDrop return values
The shouldDrop callback returns a value controlling how the span is dropped:
| Return value | Behavior |
| ------------ | -------------------------------------------------------------------------------------- |
| false | Keep the span and all buffered children |
| true | Drop the span, reparent children to grandparent (no attribute inheritance) |
| 'collapse' | Drop the span like collapse: inherit attributes into children, reparent to grandparent |
true is the default drop mode — children are reparented but don't inherit the dropped span's attributes. Use 'collapse' when the dropped span has useful context that should flow down to children (similar to the collapse instrumentation hook option).
Nested conditional drops
When multiple ancestors have shouldDrop registered, decisions cascade: if a parent is dropped, its reparented children wait for the next ancestor's decision before being flushed. This works correctly with any combination of true and 'collapse' return values.
Interaction with collapse
If a span has both collapse and shouldDrop — whether from instrumentationHooks[scope].collapse: true, or from onStart returning both { collapse: true, shouldDrop } — collapse takes priority and shouldDrop is ignored. The span is dropped immediately with attribute inheritance, and any conditional buffer is flushed with reparenting to the collapse target.
Trace Counters
Root spans automatically receive counter attributes tracking drops and sampling decisions within their trace:
| Attribute | Description |
| ------------------------------------ | --------------------------------------- |
| opin_tel.trace.started_span_count | Total child spans started in this trace |
| opin_tel.trace.captured_span_count | Child spans exported in this trace |
| opin_tel.dropped.sync_count | Spans dropped by sync span detection |
| opin_tel.dropped.conditional_count | Spans dropped by conditional dropping |
| opin_tel.dropped.aggregated_count | Spans consumed by aggregation |
| opin_tel.sampled.head_count | Spans dropped by head sampling |
| opin_tel.sampled.tail_count | Spans dropped by tail sampling |
| opin_tel.sampled.burst_count | Spans dropped by burst protection |
Counters are best-effort — they reflect drops that occurred before the root span ended. Only non-zero counters are written.
FilteringSpanProcessor
Span processor that handles sync span dropping, baggage propagation, span collapsing, conditional dropping, instrumentation hooks, and custom lifecycle hooks. Used internally by opinionatedTelemetryInit but can be used standalone. Accepts instrumentationHooks and globalHooks in its config.
Baggage utilities
import { withBaggage, getBaggageValue } from 'opinionated-telemetry'
// Set baggage and run in that context
withBaggage({ 'app.account_id': '123' }, () => {
// Read baggage
const accountId = getBaggageValue('app.account_id')
})Trace-level context
Set attributes that apply to all spans in a trace. Useful for enriching spans with information discovered mid-trace (e.g. after user authentication), or when wrapping with baggage isn't feasible. Trace context attributes never overwrite existing span attributes — span-level values always take precedence.
When using tail-based sampling, trace context is applied retroactively to buffered spans before they are exported.
import { setTraceContext } from 'opinionated-telemetry'
// Inside any active span — attributes will appear on every span in this trace
setTraceContext({ 'user.id': userId, 'user.role': 'admin' })getRootSpan()
Returns the root span of the active trace from anywhere in the call stack. The OTel API only provides trace.getActiveSpan() which returns the current span — there's no built-in way to reach the root from a child context.
Use this when you want to set attributes on the root span specifically, rather than on every span in the trace. Common use cases:
- Post-auth enrichment: after identifying a user in auth middleware, tag the root request span with user info so it shows up in the top-level trace view
- Request-level metadata: set final response status, resolved route, or feature flags on the root span from deeper in the call stack
- Computed summaries: add a summary attribute to the root after the work is done (e.g. total items processed)
import { getRootSpan } from 'opinionated-telemetry'
// In auth middleware, after identifying the user:
const root = getRootSpan()
if (root) {
root.setAttribute('user.id', userId)
root.setAttribute('app.plan', plan)
}Returns undefined if there is no active span or the root span has already ended.
Auto-instrumentation
Auto-instrumentation hooks are opt-in imports, separate from the main entry point:
CJS (Module._load patching):
import { createAutoInstrumentHookCJS } from 'opinionated-telemetry/auto-instrument'
createAutoInstrumentHookCJS({
// tracer is optional — defaults to trace.getTracer('opin_tel.auto')
instrumentPaths: [
{ base: '/app/src', dirs: ['controllers', 'helpers', 'lib'] },
],
ignoreRules: [
'helpers/health-check',
{ file: 'helpers/utils', exports: ['internalFn'] },
],
})ESM (requires --import @opentelemetry/instrumentation/hook.mjs):
import { createAutoInstrumentHookESM } from 'opinionated-telemetry/auto-instrument-esm'
const unhook = createAutoInstrumentHookESM({
instrumentPaths: [
{ base: '/app/src', dirs: ['controllers', 'helpers', 'lib'] },
],
})Filtering function wrapping
By default, all async function exports are wrapped. Use functionInstrumentation to customize:
createAutoInstrumentHookCJS({
instrumentPaths: [{ base: '/app/src', dirs: ['controllers'] }],
functionInstrumentation: {
// Override the default AsyncFunction check (e.g. wrap promise-returning non-async fns)
shouldWrap: (fn, name, filename) => fn.constructor.name === 'AsyncFunction',
// Filter which exports to instrument: string[], RegExp, or function
include: ['getUser', 'createOrder'],
},
})Class method instrumentation
Class method wrapping is opt-in via classInstrumentation. Spans are named filename/ClassName.methodName.
createAutoInstrumentHookCJS({
instrumentPaths: [{ base: '/app/src', dirs: ['services'] }],
classInstrumentation: {
// Which classes to instrument (string[], RegExp, or function)
includeClass: /Service$/,
// Which methods to instrument (string[], RegExp, or function). Default: all async methods.
includeMethod: (methodName) => !methodName.startsWith('_'),
// Override the default AsyncFunction check
shouldWrap: (fn, name, filename) => fn.constructor.name === 'AsyncFunction',
},
})Auto-instrument hooks
Both CJS and ESM hooks accept an optional hooks object with onStart and onEnd callbacks. These are called on every auto-instrumented function invocation with access to the function's arguments and return value, allowing you to enrich spans with call-specific context.
The context object is a discriminated union — check context.type to determine whether it's a function or method call:
createAutoInstrumentHookCJS({
instrumentPaths: [{ base: '/app/src', dirs: ['controllers', 'services'] }],
hooks: {
onStart: (span, context) => {
if (context.type === 'function') {
// context.fnName, context.filename, context.args
if (context.fnName === 'getUser') {
span.setAttribute('app.user_id', context.args[0])
}
} else {
// context.type === 'method'
// context.className, context.methodName, context.filename, context.args
span.setAttribute('app.class', context.className)
}
},
onEnd: (span, context) => {
// context also has: returnValue?, error?
if (context.returnValue?.rows) {
span.setAttribute('app.row_count', context.returnValue.rows.length)
}
},
},
})| Callback | Context fields (function) | Context fields (method) |
| --------- | ------------------------------------------------ | --------------------------------------------------------------- |
| onStart | type: 'function', args, fnName, filename | type: 'method', args, className, methodName, filename |
| onEnd | Same as onStart + returnValue?, error? | Same as onStart + returnValue?, error? |
These hooks are for span enrichment only — use globalHooks or instrumentationHooks on the FilteringSpanProcessor for span lifecycle control (collapse, conditional dropping, etc.).
Honeycomb Quick Start
The opinionated-telemetry/honeycomb entrypoint wires up OTLP trace and metric exporters pointed at Honeycomb with sensible defaults, so you don't need to configure exporters manually.
import { honeycombInit } from 'opinionated-telemetry/honeycomb'
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'
const { sdk, getTracer, shutdown, runtimeMetrics } = honeycombInit({
serviceName: 'my-service',
apiKey: process.env.HONEYCOMB_API_KEY!,
instrumentations: [new HttpInstrumentation()],
// ... other options
})This sets up:
- Trace export to
https://api.honeycomb.io/v1/traceswith dataset =serviceName - Metric export — native OTLP metrics to
https://api.honeycomb.io/v1/metricswith dataset =${serviceName}_metrics, collected every 60s, with delta temporality (each export carries that interval's counts/distribution — Honeycomb sums the raw points instead of differencing cumulative series, which also makes the per-interval histogram peaks meaningful) - Runtime metrics — focused Node.js health metrics (event loop delay + utilization, heap, GC, CPU, active resources, memory) auto-started unless
runtimeMetrics: false
By default honeycombInit is conservative about what metrics it ships: only runtime (node.*) and your own custom metrics. HTTP and processor-diagnostic metric sources are off (metricSources: { http: false, processor: false }), and a duplicate instrumentation-runtime-node is disabled (disableRuntimeNodeInstrumentation: true). Opt any of them back in explicitly — e.g. metricSources: { http: true }.
All other opinionatedTelemetryInit options are supported. traceExporter and metricExporter can be overridden if you need custom exporter configuration.
| Option | Type | Default | Description |
| ------------------------ | ----------------------------------- | ------------------------ | ------------------------------------------------------------------------------------ |
| apiKey | string | required | Honeycomb API key |
| enableMetricCollection | boolean | true | Set to false to disable metric collection |
| metricExportInterval | number | 60_000 | Metric export interval in milliseconds |
| metricsDataset | string | ${serviceName}_metrics | Dataset for metrics (required by Honeycomb; routed by this header, not service.name) |
| traceExporter | SpanExporter | — | Override the default OTLP trace exporter |
| metricExporter | PushMetricExporter | — | Override the default OTLP metric exporter |
| runtimeMetrics | NodeRuntimeMetricsConfig \| false | {} | Configure or disable runtime metrics (see section below) |
The entrypoint also re-exports everything from the main opinionated-telemetry package, so you can use it as your sole import.
Metric Sources
Honeycomb stores metrics natively in a dedicated metrics dataset — gauges, sums, and histograms are first-class, dimensions are preserved, and billing is by data points (not unique time series). So metrics are sent as-is over OTLP with no flattening or transformation.
The job, then, is simply choosing which sources you export. There are two layers that compose:
metricSources— coarse on/off switches for the built-in sourcesmetricFilter— fine-grained name-based include/exclude (globs, regex, predicates — see Metric Filtering)
opinionatedTelemetryInit({
serviceName: 'my-service',
traceExporter,
instrumentations: [new HttpInstrumentation()],
metricExporter, // e.g. new OTLPMetricExporter({ ... })
metricSources: {
http: true, // HTTP server/client metrics (instrumentation-http)
runtime: true, // Node runtime metrics (event loop, heap, GC, CPU, memory)
processor: false, // processor diagnostics (opin_tel.processor.*)
},
})| Source | Metrics (prefix) | Disabling effect |
| ----------- | --------------------------------- | ------------------------------------------------------------------- |
| http | http.server.* / http.client.* | Adds zero-overhead DROP Views — instruments are never collected |
| runtime | node.* | Stops the runtime collector at the source (no observation overhead) |
| processor | opin_tel.processor.* | Skips registering the processor's diagnostic instruments |
Each defaults to enabled. metricSources.runtime/processor compose with the dedicated runtimeMetrics/processorMetrics options — either one disabling turns the source off. Reach for metricFilter when you need to keep part of a source (e.g. drop node.gc.* but keep node.heap.*).
Avoiding duplicate runtime metrics
getNodeAutoInstrumentations() bundles @opentelemetry/instrumentation-runtime-node, which emits nodejs.* / v8js.* metrics that duplicate the built-in node.* runtime metrics. Whenever the built-in runtime metrics are active, opinionated-telemetry automatically disables that instrumentation if it finds it in your instrumentations array (and logs a one-time warning) — so you don't pay for both.
opinionatedTelemetryInit({
instrumentations: getNodeAutoInstrumentations(), // runtime-node auto-disabled
// runtimeMetrics on by default → node.* emitted, nodejs.*/v8js.* suppressed
})Control it with disableRuntimeNodeInstrumentation:
- unset (default) — auto: disabled whenever the built-in runtime metrics are on.
false— keep both (the upstream config can't signal this itself: an explicit{ enabled: true }is indistinguishable from the defaultenabled: true, so the opt-out lives here).true— always disable it, even if you've turned the built-in runtime metrics off.
To use the upstream instrumentation instead of the built-in metrics, set runtimeMetrics: false (or metricSources: { runtime: false }) — that leaves runtime-node alone.
Trimming resource attributes from metrics
Honeycomb flattens resource attributes (host.*, process.*, service.*, detected by the SDK's resource detectors) onto every metric row. Verbose ones like process.command_args or process.executable.path are noise on a metric. metricResourceAttributes strips them from metrics only — traces keep the full resource:
opinionatedTelemetryInit({
metricExporter, // required — applied on the exporter path
metricResourceAttributes: {
drop: ['process.command_args', /^process\.executable/],
// or keep: ['service.*', 'host.name'] to allow-list instead
},
})honeycombInit applies a sensible default deny-list (DEFAULT_METRIC_RESOURCE_DROP — the verbose process.command* / executable.* / owner / runtime.description keys) while keeping service.*, host.*, process.pid, and process.runtime.{name,version}. Pass your own (or {} to keep everything) to override. Billing-wise this is a clarity/payload win, not a cost one — Honeycomb bills metrics by data points, not attributes.
Node Runtime Metrics
OTel's default runtime metrics packages (@opentelemetry/host-metrics + @opentelemetry/instrumentation-runtime-node) emit 24+ metrics with per-heap-space breakdowns, per-core CPU, per-NIC network stats, and all GC types. Most of this is noise.
NodeRuntimeMetrics replaces them with a focused, actionable set using built-in Node.js APIs, exposed through the OTel Meter API so they flow through the normal metric pipeline.
When using the honeycombInit entrypoint, runtime metrics are auto-started by default. For standalone use:
import { NodeRuntimeMetrics } from 'opinionated-telemetry/metrics'
const rtm = new NodeRuntimeMetrics({
// All options are optional
prefix: 'node', // metric name prefix (default: 'node')
eventLoopDelayResolution: 20, // event loop delay monitor resolution in ms (default: 20)
sampleIntervalMs: 2000, // how often heap/memory/cpu/elu are sampled into histograms (default: 2000)
enable: {
// selectively disable groups (all default true)
eventLoopDelay: true,
eventLoopUtilization: true,
heap: true,
gc: true,
activeResources: true,
cpu: true,
memory: true,
},
})
rtm.start() // idempotent
// ... later
rtm.stop() // idempotent — disconnects observers, clears the samplerMetrics
| Group | Metric | Type | Source |
| ---------- | ------------------------------- | --------- | ------------------------------------------------------------- |
| Event Loop | node.eventloop.delay.p50 | gauge | monitorEventLoopDelay().percentile(50) (ms) |
| | node.eventloop.delay.p99 | gauge | .percentile(99) (ms) |
| | node.eventloop.delay.max | gauge | .max (ms) |
| | node.eventloop.utilization | histogram | performance.eventLoopUtilization() delta (0-1), sampled |
| Heap | node.heap.used_mib | histogram | process.memoryUsage().heapUsed, sampled |
| | node.heap.total_mib | histogram | .heapTotal, sampled |
| | node.heap.used_pct | histogram | heapUsed / v8 heap_size_limit * 100, sampled |
| GC | node.gc.major.duration | histogram | PerformanceObserver('gc') major pauses (ms) |
| Resources | node.active_resources | gauge | process.getActiveResourcesInfo() counted by resource.type |
| CPU | node.cpu.user_pct | histogram | process.cpuUsage() delta, sampled |
| | node.cpu.system_pct | histogram | delta, sampled |
| | node.cpu.total_pct | histogram | user + system, sampled |
| Memory | node.memory.rss_mib | histogram | process.memoryUsage().rss, sampled |
| | node.memory.external_mib | histogram | .external, sampled |
| | node.memory.array_buffers_mib | histogram | .arrayBuffers, sampled |
Metrics carry zero attributes (the one exception is node.active_resources, which breaks down by resource.type) — cheap and easy to query in Honeycomb. Most use native histograms so the backend derives count/avg/max/percentiles (and aggregates correctly across instances) instead of us picking stats up front:
- GC pauses — each major GC duration is recorded as it happens.
- Heap, memory, CPU, event-loop utilization — sampled every
sampleIntervalMs(default 2s) rather than once per export. A plain gauge only captures the value at the 60s collection tick and would miss an intra-interval spike (heap climbing toward the limit, a CPU/event-loop burst) — often exactly the signal you want. The histogram's max is that peak; queryMAX/P99/AVGin Honeycomb. (used_pctis measured against the V8heap_size_limit, so it's a true "distance to the OOM ceiling".)
Each histogram declares explicit bucket boundaries scaled to its range (GC in ms, memory in MiB, percentages 0–100, event-loop utilization 0–1) — OTel's defaults are tuned for second-scale HTTP latency and would, for example, drop every ELU value into one bucket. Override them with OTel Views if our choices don't fit your workload.
The exceptions are gauges: active resources is a point-in-time count of what's keeping the event loop alive (by type — handy for spotting leaks like a climbing TCPSocketWrap), and event loop delay percentiles come straight from libuv's monitorEventLoopDelay() histogram (reset each observation) — already aggregated at the C level, so it stays gauges rather than a re-measured OTel histogram.
Single reader assumption: event-loop-delay percentiles and the processor's
.max/.minwatermark metrics reset their state on each collection, so they assume oneMetricReader. With multiple readers (e.g. OTLP + Prometheus) those specific metrics will be split across readers; everything else (the sampled histograms, GC, counters, plain gauges) is unaffected.opinionatedTelemetryInitwarns if it sees more than one reader.
Processor Diagnostic Metrics
The FilteringSpanProcessor exposes its internal state as OTel metrics — active spans, active traces, tail buffer depth, throughput counters, and drop breakdowns. These help detect span leaks, understand throughput, and monitor pipeline health.
Enabled by default. Set processorMetrics: false to disable.
opinionatedTelemetryInit({
processorMetrics: false, // disable if not needed
})Metrics include interval watermarks (current + max + min since last observation) for active spans and traces, snapshot gauges for stuck spans and aggregate groups, and monotonic counters for throughput (spans.started, spans.exported, and spans.dropped broken down by a drop.type attribute) — the backend derives per-interval rates, so there's no lossy client-side reset.
See docs/PROCESSOR_METRICS.md for the full metric reference.
Metric Filtering
OTel's View API is the standard way to drop metrics, but it's verbose and limited to glob wildcards. opinionated-telemetry provides two simpler approaches that can be used independently or together.
Quick: metricFilter config
The simplest path — provide metricExporter instead of metricReaders and add a metricFilter:
import { OTLPMetricExporter } from '@opentelemetry/exporter-metrics-otlp-proto'
opinionatedTelemetryInit({
serviceName: 'my-service',
traceExporter: new OTLPTraceExporter(),
instrumentations: [new HttpInstrumentation()],
metricExporter: new OTLPMetricExporter({
url: 'https://api.honeycomb.io:443/v1/metrics',
headers: { 'x-honeycomb-team': process.env.HONEYCOMB_API_KEY },
}),
metricFilter: {
drop: [
'http.server.*', // glob pattern
/^node\.gc/, // regex
(name) => name.endsWith('.p99'), // predicate
],
// OR allowlist — only matching metrics are kept:
allow: ['opin_tel.*', 'node.heap.*', 'node.cpu.*'],
},
})metricExporter is a shorthand that creates the PeriodicExportingMetricReader for you. Use metricExportInterval to override the default 60s interval. Mutually exclusive with metricReaders.
Pattern types (MetricPattern = string | RegExp | ((name: string) => boolean)):
| Type | Example | Behavior |
| ------------- | --------------------------------- | --------------------------------------- |
| String (glob) | 'http.server.*' | * matches any sequence of characters |
| RegExp | /^node\.gc/ | Standard regex test against metric name |
| Predicate | (name) => name.endsWith('.p99') | Return true to match |
drop vs allow:
drop— metrics matching any pattern are excludedallow— only metrics matching at least one pattern are kept- Both can be combined:
allowfilters first, thendropremoves from the allowed set
How it works: String glob patterns in
dropgenerate efficient DROP Views (metrics are never collected — zero overhead). Regex, predicates, andallowpatterns are applied at the exporter level viaFilteringMetricExporter, which requiresmetricExporterfor automatic wiring.
Standalone: dropMetrics() helper
If you use metricReaders directly and just need to drop a few metrics, dropMetrics() generates ViewOptions[] with DROP aggregation:
import { dropMetrics } from 'opinionated-telemetry/metrics'
opinionatedTelemetryInit({
metricReaders: [myReader],
views: [...dropMetrics('http.server.*', 'http.client.*', 'node.gc.*')],
})This is the most efficient approach (zero collection overhead) but only supports string glob patterns.
Standalone: FilteringMetricExporter
For full control, wrap any PushMetricExporter with FilteringMetricExporter. Supports all pattern types and both drop/allow:
import { FilteringMetricExporter } from 'opinionated-telemetry/metrics'
import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'
const reader = new PeriodicExportingMetricReader({
exporter: new FilteringMetricExporter({
exporter: otlpExporter,
drop: [/^node\.gc/],
allow: ['opin_tel.*', 'node.heap.*', 'node.cpu.*'],
}),
})Sampling
opinionated-telemetry includes built-in sampling with three composable modes: head-based, tail-based, and EMA burst protection. See SAMPLING.md for detailed documentation on the philosophy, algorithms, composition, and design decisions.
opinionatedTelemetryInit({
serviceName: 'my-service',
traceExporter: new OTLPTraceExporter(),
instrumentations: [new HttpInstrumentation()],
sampling: {
// Head-based: lightweight, immediate drop/keep at trace start
head: {
sample: (attrs, spanName) => {
if (spanName.startsWith('health-check')) return 100 // keep 1-in-100
return 1 // keep all
},
mustKeepSpan: (span, durationMs) =>
span.status.code === SpanStatusCode.ERROR,
},
// Tail-based: buffer all spans, decide with full trace context
tail: {
sample: (rootAttrs, trace) => {
if (trace.hasError) return 1 // keep all errors
if (trace.durationMs > 5000) return 1 // keep slow traces
return 10 // sample 1-in-10 otherwise
},
mustKeepSpan: (span, durationMs) =>
span.status.code === SpanStatusCode.ERROR,
maxTraces: 1000, // max buffered traces (default: 1000)
maxAgeMs: 120_000, // max buffer age (default: 120s)
maxSpansPerTrace: 500, // flush large traces early (default: 500)
},
// Burst protection: EMA-based per-key throttling
burstProtection: {
keyFn: (span) => span.name, // grouping key (default: span.name)
halfLifeMs: 10_000, // EMA responsiveness (default: 10s)
rateThreshold: 100, // spans/sec before throttling (default: 100)
maxSampleRate: 100, // max sample rate cap (default: 100)
},
},
})Head-based sampling calls sample once per root span and returns a 1-in-N rate. Spans are dropped immediately if sampled out. mustKeepSpan(span, durationMs) can rescue individual important spans (e.g. errors, slow spans) by reparenting them to the root with SampleRate=1.
Tail-based sampling buffers all spans until the root ends, then calls sample with a TraceSummary containing error counts, duration, and span count. mustKeepSpan(span, durationMs) flags a trace for guaranteed keeping without short-circuiting the buffer. Safety valves (maxTraces, maxAgeMs, maxSpansPerTrace) prevent unbounded memory growth.
Burst protection uses an exponential moving average to detect per-key span rate spikes and automatically applies a sample rate when throughput exceeds the threshold. No manual rate configuration needed.
When combined, rates compose multiplicatively. Tail overrides head for the base decision. mustKeepSpan always clamps the rate to 1. Kept spans receive a SampleRate=N attribute following the Honeycomb convention.
onDroppedSpan
Called whenever a span is dropped due to sync span dropping, conditional dropping, sampling, burst protection, or stuck span eviction via 'drop'. Useful for writing sampled-out spans to a compressed ndjson file or other secondary storage for later retrieval.
opinionatedTelemetryInit({
// ...
onDroppedSpan: (span, reason, durationMs) => {
// reason: 'head' | 'tail' | 'burst' | 'sync' | 'conditional' | 'stuck'
// durationMs: provided for 'tail', 'burst', 'conditional', and 'stuck' drops
droppedSpanLog.write(
JSON.stringify({
name: span.name,
traceId: span.spanContext().traceId,
reason,
attributes: span.attributes,
durationMs,
}) + '\n',
)
},
})onCapturedSpan
Called whenever a span is accepted and forwarded to the underlying exporter. The mirror of onDroppedSpan — useful for logging, metrics, or debugging which spans make it through filtering and sampling.
opinionatedTelemetryInit({
// ...
onCapturedSpan: (span, durationMs) => {
capturedSpanLog.write(
JSON.stringify({
name: span.name,
traceId: span.spanContext().traceId,
durationMs,
attributes: span.attributes,
}) + '\n',
)
},
})SQL Utilities
Reusable helpers for normalizing SQL queries and generating CRC32 etags for caching/dedup. Handles multiple binding styles (?, $1, ?::type, $1::type, DEFAULT).
import {
stabilizeQuery,
queryRequestTag,
stableQueryTag,
queryResponseTag,
stableQueryHash,
} from 'opinionated-telemetry'stabilizeQuery(sql): StableQueryResult
Regex-normalizes SQL by collapsing variadic binding groups into stable placeholders. Single groups like (?, ?, ?) become (_), multiple consecutive groups like (_), (_), (_) become (_+).
const result = stabilizeQuery(
'INSERT INTO t (a, b) VALUES (?, ?), (?, ?), (?, ?)',
)
// result.stableQuery === 'INSERT INTO t (a, b) VALUES (_+)'
// result.groupCount === 3
// result.groupedBindingCount === 6Returns { stableQuery: string, groupCount: number, groupedBindingCount: number }.
queryRequestTag(sql, bindings?): string
CRC32 hash of the raw SQL + actual bindings — exact cache key for dedup of identical invocations.
stableQueryTag(sql, bindings?): string
CRC32 of the stabilized SQL + sanitized binding types — groups queries by shape. Same structure with same binding types produces the same tag, regardless of actual values.
queryResponseTag(result, maxBytes?): string
CRC32 hash of JSON.stringify(result). Optional maxBytes truncates the serialized result before hashing.
stableQueryHash(sql): string
CRC32 of just the stabilized query text — for grouping queries by shape regardless of binding count or values.
Integration Helpers
Each integration is a separate entry point to avoid pulling unnecessary dependencies.
Express
Middleware that enriches the active OTel span with request metadata (method, path, query string, headers) and optionally propagates select values as baggage so they appear on all downstream child spans.
import { otelCreateExpressMiddleware } from 'opinionated-telemetry/integrations/express'
app.use(
otelCreateExpressMiddleware({
captureHeaders: ['user-agent', 'x-request-id'],
baggageHeaders: ['x-request-id'],
captureQueryParams: ['page', 'search'],
baggageQueryParams: ['page'],
requestHook: (req, { setAttribute, setAsBaggage }) => {
if (req.user?.id) setAsBaggage('app.user.id', req.user.id)
},
}),
)| Option | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
| captureHeaders | Request headers to capture as req.header.<name> span attributes |
| baggageHeaders | Subset of captureHeaders to also propagate as baggage |
| captureQueryParams | Query params to capture as req.query.<name> span attributes |
| baggageQueryParams | Subset of captureQueryParams to also propagate as baggage |
| requestHook | Custom hook with setAttribute and setAsBaggage helpers for extracting additional request context (e.g. user ID from auth) |
| enabled | Enable/disable the middleware. Default: true |
Security:
baggageHeadersandbaggageQueryParamsvalues are propagated as OTel baggage. With the defaultbaggagePropagationconfig, outbound baggage is suppressed. If you configureallowedHosts, be careful not to include sensitive headers (e.g.authorization,cookie) inbaggageHeaders— they would be sent to those hosts in thebaggageHTTP header.
Knex
Listens to knex query events and enriches the active span with connection ID, transaction ID, pool stats, and sanitized query bindings. Bindings are sanitized to avoid leaking sensitive data: strings become string<length> and class instances become <<Object#ClassName>>. A few non-sensitive values are kept as-is for correlation — Dates are serialized to ISO strings and UUID strings pass through unredacted.
Returns a cleanup function to remove the listener.
import { otelInitKnex } from 'opinionated-telemetry/integrations/knex'
import { queryRequestTag } from 'opinionated-telemetry'
const cleanup = otelInitKnex(knexInstance, {
captureBindings: true,
capturePoolStats: true,
// Override sanitization for specific values; return undefined to fall through
// to the built-in behavior. Here: keep short enum-like strings readable.
sanitizeBindingFn: (value) =>
typeof value === 'string' && value.length <= 12 ? value : undefined,
queryHook: ({ sql, bindings, stable, setAttribute }) => {
setAttribute('db.query.request_tag', queryRequestTag(sql, bindings))
setAttribute('db.query.method', sql.split(' ')[0].toUpperCase())
},
})
// Later: cleanup() to remove the listener| Option | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| captureBindings | Capture sanitized bindings as db.query.sanitized_bindings and a CRC32 hash as db.query.hash. Default: true |
| capturePoolStats | Capture connection pool stats (db.pool.used_count, db.pool.free_count, etc.). Default: true |
| hashFn | Custom hash function for query+bindings. Default: CRC32 via node:zlib |
| sanitizeBindingFn | Custom per-binding sanitizer, applied recursively to every value (including nested array/object elements). Return a string to override the default sanitization; return anything else (e.g. undefined) to fall through to the built-in behavior. |
| sanitizeBindingsFn | Custom function to sanitize the whole bindings array into a string. Replaces the default entirely; receives the per-binding sanitizeBindingFn as a second argument if you want to reuse it. |
| queryHook | Custom hook called per query with { sql, bindings, stable, setAttribute }. Use to set additional attributes like request tags. |
Span attributes set: db.connection.id, db.tx.id, db.timeout, db.query.stable (stabilized SQL shape), db.query.stable_hash (CRC32 of stable shape), db.query.group_count, db.query.grouped_binding_count, db.query.sanitized_bindings, db.query.hash, db.pool.*.
GraphQL
Wraps custom resolve functions in a GraphQL schema with OTel context propagation. For each resolver call, extracts graphql.* attributes from the active span and propagates them as baggage, so downstream spans (e.g. database queries triggered by a resolver) inherit the GraphQL operation context.
Skips fields using the default field resolver (i.e. simple property access).
import { defaultFieldResolver } from 'graphql'
import { otelInitGraphql } from 'opinionated-telemetry/integrations/graphql'
otelInitGraphql(schema, {
fieldResolver: defaultFieldResolver,
shouldWrapResolver: ({ typeName, fieldName }) => {
return typeName !== 'InternalType'
},
})| Option | Description |
| -------------------- | ---------------------------------------------------------------------------------------------------------------------- |
| fieldResolver | Required. Pass defaultFieldResolver from graphql — used to identify which fields have custom resolvers |
| shouldWrapResolver | Optional filter. Return false to skip wrapping a resolver. Receives { field, fieldName, type, typeName, resolver } |
Bull
Patches Bull.prototype to trace job processing with span links connecting producers to consumers across separate traces. This avoids creating artificially long parent-child traces for async job queues.
.add()captures the current span context and stores it in the job data under__otelLink(disable withaddJobLink: false).process()creates a new root span with a span link back to the enqueuing span, and strips__otelLinkback out of the job data so your processor receives the payload it enqueued.on()wraps async event handlers with spans for lifecycle events
It also emits queue metrics (unless metrics: false):
| Metric | Type | Attributes | Meaning |
| --------------------- | --------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
| bull.queue.jobs | gauge | bull.queue.name, bull.job.state (waiting/active/delayed/paused) | Current backlog via getJobCounts() (one Redis round-trip per queue per collection) |
| bull.jobs.processed | counter | bull.queue.name, bull.job.status (completed/failed) | Processing attempts — throughput + error rate
