Skip to content

Architecture

trace wraps, trace.run runs. Every trace(...) form returns a wrapper and executes nothing, so importing a module full of them has no side effects. trace.run(...) is the only form that runs the operation.

Call Returns Use for
trace(fn) wrapper, name inferred a reusable function
trace(name, fn) wrapper, name explicit a reusable function, stable name
trace(name)(fn) wrapper, curried applying one config to many fns
trace.run(name, ctx => r) the operation’s result one operation, run right here
instrument({ key, fn }) wrapper, name explicit the options form of the wrapper
withTracing(opts)(f) wrapper from a ctx factory reusable config, ctx in scope
import { trace, ctx } from 'autotel';
// Reusable, name inferred from the binding
export const getUser = trace(async (id) => {
return await db.users.findById(id);
});
// Reusable, explicit name — reach the span through the ambient ctx
export const createUser = trace('user.create', async (data) => {
ctx.setAttribute('user.id', data.id);
return await db.users.create(data);
});
// One operation, run right here
const user = await trace.run('user.create', async (ctx) => {
ctx.setAttribute('user.id', input.id);
return await db.users.create(input);
});
// Factory pattern (ctx bound for the whole wrapper)
export const updateUser = withTracing({})((ctx) => async (data) => {
ctx.setAttribute('user.id', data.id);
return await db.users.update(data);
});

Because trace and trace.run are separate names, no call shape is ambiguous and nothing inspects a callback’s parameter name.

Inside any traced body, the ambient ctx import resolves to the active span — including in helpers several frames down, which a context parameter cannot reach without being threaded through every call.

trace() never inspects or executes the function during wrapper construction. The explicit factory shape belongs to withTracing() and the semantic, messaging, and workflow helpers.

Trace names are inferred automatically with the following priority:

  1. Explicit name (from an immediate trace('customName', operation), a withTracing({ name }) factory, or an instrument() key)
  2. Named function expressions (e.g., withTracing({})((ctx) => async function createUser() {}))
  3. Variable name from assignment (e.g., const processDocuments = trace(...) → “processDocuments”)
  4. Factory function name (if the outer function is named)
// Arrow function with inferred name from const assignment
export const processDocuments = withTracing({})((ctx) => async (data: string) => {
ctx.setAttribute('document.count', data.length);
return data.toUpperCase();
});
// Trace name: "processDocuments" (inferred from const)
// Named function expression (takes precedence)
export const processDocuments = withTracing({})((ctx) => async function processData(data: string) => {
return data.toUpperCase();
});
// Trace name: "processData" (from named function, not "processDocuments")

Limitations:

  • Minified/obfuscated code may prevent name inference
  • Edge runtimes without file system access will fall back to unnamed spans
  • Results are cached per source location for performance
  • Production/minified reusable functions should use trace(name, fn), instrument({ key, fn }), or withTracing({ name }) for stable names

Events use an async queue to prevent blocking the main execution path:

  • Events are queued immediately and returned
  • Background worker processes queue and sends to all configured adapters
  • Adapters can implement batching/buffering independently
  • Shutdown waits for queue to drain

Two separate config systems serve different purposes:

  1. Init Config (init.ts): Global OpenTelemetry SDK setup (resource, exporters, instrumentations)
  2. Runtime Config (config.ts): Per-operation configuration (sampling rates, rate limits, circuit breaker thresholds)

Implements deferred sampling decisions:

  • Spans are buffered in-memory during execution
  • Sampling decision made after span ends (can inspect attributes, status, duration)
  • Default AdaptiveSampler: 10% baseline, 100% errors, 100% slow requests
  • Custom samplers can implement Sampler interface

The autotel/attributes module provides type-safe OpenTelemetry attribute builders following semantic conventions:

Module Structure:

  • builders.ts - Key builders (attrs.user.id()) and object builders (attrs.user.data())
  • attachers.ts - Signal helpers that know WHERE to attach attributes (setUser(), httpServer(), etc.)
  • domains.ts - Domain-specific helpers (transaction()) that bundle multiple attribute groups
  • validators.ts - PII detection, guardrails, and deprecated attribute warnings
  • utils.ts - safeSetAttributes() and mergeAttrs() utilities
  • types.ts - TypeScript types for all attribute domains
  • registry.ts - Semantic convention constants

Key APIs:

import {
attrs,
setUser,
safeSetAttributes,
transaction,
} from 'autotel/attributes';
// Key builders - single attributes
ctx.setAttributes(attrs.user.id('user-123'));
ctx.setAttributes(attrs.http.request.method('GET'));
// Object builders - multiple related attributes
ctx.setAttributes(attrs.user.data({ id: '123', email: 'user@example.com' }));
// Attachers - know WHERE to attach + apply guardrails
setUser(ctx, { id: '123', email: 'user@example.com' }); // Auto-redacts PII
httpServer(ctx, { method: 'GET', route: '/api/users', statusCode: 200 });
// Safe attributes with guardrails
safeSetAttributes(ctx, attrs.user.data({ email: 'pii@example.com' }), {
guardrails: { pii: 'hash' }, // Options: 'allow', 'redact', 'hash', 'block'
});
// Domain helpers - bundle attributes for common scenarios
transaction(ctx, { user: { id: '123' }, method: 'POST', route: '/api/orders' });

Guardrails Options:

  • pii: 'allow' | 'redact' | 'hash' | 'block' - How to handle PII (default: ‘redact’)
  • maxLength: number - Truncate long values (default: 255)
  • warnDeprecated: boolean - Log warnings for deprecated attributes (default: true)

Resource Merging:

import { mergeServiceResource } from 'autotel/attributes';
// Resource.attributes is readonly - use merge to add service attributes
const enrichedResource = mergeServiceResource(resource, {
name: 'my-service',
version: '1.0.0',
});

All packages are configured for aggressive tree-shaking:

  • Use "sideEffects": false in package.json
  • Export all public APIs explicitly in package.json exports field
  • Keep dependencies minimal (especially in autotel-edge)
  • External dependencies (pino, winston) are marked as peer/optional

The codebase uses TypeScript 5.0+ decorators (not experimental legacy decorators). Test execution uses tsx which supports the new decorator syntax. The decorators.ts module provides @Trace decorator for class methods.

The library uses standard OpenTelemetry context propagation:

  • Active context is stored in AsyncLocalStorage (Node.js) or async context (edge)
  • trace() automatically creates child spans in the active context
  • Use withNewContext() to create isolated trace trees
  • Context includes custom attributes via runInOperationContext()

Dynamic Module Loading (CJS/ESM Compatibility)

Section titled “Dynamic Module Loading (CJS/ESM Compatibility)”

Never use await import() for dynamic module loading. Instead, use the node-require helper functions:

// ❌ DON'T: Using async import()
const mod = await import('some-module');
// ✅ DO: Use node-require helpers
import { safeRequire, requireModule } from './node-require';
// For optional dependencies (returns undefined if missing)
const traceloop = safeRequire('@traceloop/node-server-sdk');
if (traceloop) {
traceloop.initialize({ ... });
}
// For required dependencies (throws if missing)
const fs = requireModule<typeof import('node:fs')>('node:fs');
const content = fs.readFileSync('file.txt', 'utf8');

Why?

  • init() and other core functions must remain synchronous
  • await import() makes functions async, breaking the API contract
  • The node-require helper uses createRequire() pattern for ESM compatibility
  • Works in both CJS and ESM builds (tsdown handles the differences)
  • Consistent, synchronous module loading across the codebase

Implementation: See packages/autotel/src/node-require.ts for details.

All components implement graceful shutdown:

  • shutdown() function flushes pending spans/metrics/logs
  • Events queue drains before shutdown completes
  • Adapters track pending requests and wait for completion
  • Use flush() for intermediate flushing without shutdown

Autotel’s getRequestLogger() implements the canonical log lines pattern: one structured snapshot per request with all context, instead of scattered console.log calls.

import { trace, withTracing, getRequestLogger } from 'autotel';
export const postCheckout = withTracing({})((ctx) => async (req, res) => {
const log = getRequestLogger(ctx);
// Accumulate context throughout the request
log.set({ userId: req.user.id });
log.set({ cart: { items: req.body.items.length } });
const result = await processCheckout(req.body);
log.set({ orderId: result.id, total: result.total });
// Emit ONE wide event at the end — all context in one snapshot
log.emitNow();
return res.json(result);
});

Why this matters:

  • Before: 10 scattered console.log calls per request: hard to correlate, noisy
  • After: 1 wide event with all context: easy to search, filter, and analyze in Grafana/Honeycomb/Datadog

The request logger collects attributes via .set(), then .emitNow() attaches them all to the active span as attributes and emits a single log event. This pairs with OTel’s span-based export so the wide event is queryable in any backend.

  • No Node.js APIs (fs, net, process) in autotel-edge
  • Bundle size must stay under 1MB for Cloudflare Workers free tier
  • Some OpenTelemetry features unavailable (auto-instrumentations, resource detectors)
  • Context propagation uses minimal AsyncLocalStorage polyfill

ESM Setup (Node 18.19+), Recommended:

For ESM apps using auto-instrumentation (Pino, Express, HTTP, etc.), you need to:

  1. Install @opentelemetry/auto-instrumentations-node as a direct dependency in your app
  2. Import autotel/register first to register the ESM loader hooks
  3. Pass instrumentations directly to init() using getNodeAutoInstrumentations()

Option A: With instrumentation.mjs (explicit init, full control):

instrumentation.mjs
import 'autotel/register'; // MUST be first import!
import { init } from 'autotel';
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node';
init({
service: 'my-app',
instrumentations: getNodeAutoInstrumentations({
'@opentelemetry/instrumentation-pino': { enabled: true },
'@opentelemetry/instrumentation-http': { enabled: true },
}),
});
Terminal window
tsx --import ./instrumentation.mjs src/index.ts

Option B: Zero-config (reads from env vars):

Terminal window
OTEL_SERVICE_NAME=my-app tsx --import autotel/auto src/index.ts

Env vars: OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, AUTOTEL_AUTO_INSTRUMENTATIONS (comma-separated or ‘true’), AUTOTEL_DEBUG

Legacy (Node 18.0-18.18):

Terminal window
NODE_OPTIONS="--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./instrumentation.mjs" tsx src/index.ts

CommonJS: No loader hooks required, just use --require ./instrumentation.js

Why ESM requires direct dependency: OpenTelemetry’s ESM instrumentation uses import-in-the-middle to hook into module loading. For this to work, the auto-instrumentations package must be resolvable from your app’s node_modules, not just from autotel’s dependencies.

  • @opentelemetry/auto-instrumentations-node - optional peer dependency (install in your app for ESM instrumentation)
  • Logger integrations (pino, winston) are optional peer dependencies
  • OpenLLMetry integration (@traceloop/node-server-sdk) is optional peer dependency
  • gRPC exporters are optional peer dependencies
  • Missing optional peer dependencies gracefully degrade with helpful error messages
  • example-pino: Pino as first-class logger with init({ logger }) and trace context injection.
  • example-bunyan: Bunyan with auto-instrumentation for trace context injection.
  • example-winston: Winston with auto-instrumentation for trace context injection.
  • example-otel-tui: otel-tui terminal UI with Docker Compose for trace-to-log correlation.
  • example-collector-pipeline: OTTL redaction, health-check filtering, count-before-sampling, and tail sampling in front of the backend.
  • example-genai-metrics: canonical GenAI metrics derived from gen_ai.* spans with the signal_to_metrics connector, charted in Grafana LGTM.
  • example-genai-evals: brevity, groundedness and prompt-injection evaluators scoring sampled answers into gen_ai.evaluation.result events, with an alertable pass rate in Loki.
  • ESM-first with CJS fallback for index.ts only
  • Type definitions (.d.ts) generated from ESM build
  • Source maps enabled for debugging
  • Use tsdown for bundling (not tsc directly)