Architecture
Functional API Pattern
Section titled “Functional API Pattern”The core trace() function uses a factory pattern to detect if the user is passing a function that needs a context parameter:
// Factory pattern (receives ctx)export const createUser = trace((ctx) => async (data) => { ctx.setAttribute('user.id', data.id); return await db.users.create(data);});
// Direct pattern (no ctx needed)export const getUser = trace(async (id) => { return await db.users.findById(id);});The implementation auto-detects the pattern by analyzing the function signature and checking the first parameter name against known hints (ctx, context, tracecontext, etc.).
Trace Name Inference
Section titled “Trace Name Inference”Trace names are inferred automatically with the following priority:
- Explicit name (from
trace('customName', ...)orinstrument()key) - Named function expressions (e.g.,
trace((ctx) => async function createUser() {})) - Variable name from assignment (e.g.,
const processDocuments = trace(...)→ "processDocuments") - Factory function name (if the outer function is named)
// Arrow function with inferred name from const assignmentexport const processDocuments = trace((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 = trace((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
Events Queue Pattern
Section titled “Events Queue Pattern”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
Configuration Layering
Section titled “Configuration Layering”Two separate config systems serve different purposes:
- Init Config (
init.ts): Global OpenTelemetry SDK setup (resource, exporters, instrumentations) - Runtime Config (
config.ts): Per-operation configuration (sampling rates, rate limits, circuit breaker thresholds)
Tail Sampling Processor
Section titled “Tail Sampling Processor”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
Samplerinterface
Type-Safe Attributes
Section titled “Type-Safe Attributes”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 groupsvalidators.ts- PII detection, guardrails, and deprecated attribute warningsutils.ts-safeSetAttributes()andmergeAttrs()utilitiestypes.ts- TypeScript types for all attribute domainsregistry.ts- Semantic convention constants
Key APIs:
import { attrs, setUser, safeSetAttributes, transaction,} from 'autotel/attributes';
// Key builders - single attributesctx.setAttributes(attrs.user.id('user-123'));ctx.setAttributes(attrs.http.request.method('GET'));
// Object builders - multiple related attributesctx.setAttributes(attrs.user.data({ id: '123', email: 'user@example.com' }));
// Attachers - know WHERE to attach + apply guardrailssetUser(ctx, { id: '123', email: 'user@example.com' }); // Auto-redacts PIIhttpServer(ctx, { method: 'GET', route: '/api/users', statusCode: 200 });
// Safe attributes with guardrailssafeSetAttributes(ctx, attrs.user.data({ email: 'pii@example.com' }), { guardrails: { pii: 'hash' }, // Options: 'allow', 'redact', 'hash', 'block'});
// Domain helpers - bundle attributes for common scenariostransaction(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 attributesconst enrichedResource = mergeServiceResource(resource, { name: 'my-service', version: '1.0.0',});Important Patterns & Conventions
Section titled “Important Patterns & Conventions”Tree-Shaking & Bundle Size
Section titled “Tree-Shaking & Bundle Size”All packages are configured for aggressive tree-shaking:
- Use
"sideEffects": falsein package.json - Export all public APIs explicitly in package.json
exportsfield - Keep dependencies minimal (especially in autotel-edge)
- External dependencies (pino, winston) are marked as peer/optional
TypeScript Decorators
Section titled “TypeScript Decorators”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.
OpenTelemetry Context Propagation
Section titled “OpenTelemetry Context Propagation”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 helpersimport { 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 synchronousawait import()makes functions async, breaking the API contract- The
node-requirehelper usescreateRequire()pattern for ESM compatibility - Works in both CJS and ESM builds (tsup handles the differences)
- Consistent, synchronous module loading across the codebase
Implementation: See packages/autotel/src/node-require.ts for details.
Graceful Shutdown
Section titled “Graceful Shutdown”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
Canonical Log Lines (Wide Events)
Section titled “Canonical Log Lines (Wide Events)”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, getRequestLogger } from 'autotel';
export const postCheckout = trace((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.logcalls 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.
Known Constraints
Section titled “Known Constraints”Edge Runtime Limitations
Section titled “Edge Runtime Limitations”- 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
Auto-Instrumentation Requirements
Section titled “Auto-Instrumentation Requirements”ESM Setup (Node 18.19+) — Recommended:
For ESM apps using auto-instrumentation (Pino, Express, HTTP, etc.), you need to:
- Install
@opentelemetry/auto-instrumentations-nodeas a direct dependency in your app - Import
autotel/registerfirst to register the ESM loader hooks - Pass instrumentations directly to
init()usinggetNodeAutoInstrumentations()
Option A: With instrumentation.mjs (explicit init, full control):
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 }, }),});tsx --import ./instrumentation.mjs src/index.tsOption B: Zero-config (reads from env vars):
OTEL_SERVICE_NAME=my-app tsx --import autotel/auto src/index.tsEnv vars: OTEL_SERVICE_NAME, OTEL_EXPORTER_OTLP_ENDPOINT, AUTOTEL_AUTO_INSTRUMENTATIONS (comma-separated or 'true'), AUTOTEL_DEBUG
Legacy (Node 18.0-18.18):
NODE_OPTIONS="--experimental-loader=@opentelemetry/instrumentation/hook.mjs --import ./instrumentation.mjs" tsx src/index.tsCommonJS:
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.
Peer Dependencies
Section titled “Peer 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
Examples
Section titled “Examples”example-canonical-logs— Canonical log lines (wide events) vs scattered logging.example-effect— Effect TypeScript effect system with autotel for OTel export.awaitly-example— awaitly workflows with autotel instrumentation and Mermaid visualization.
Logger Integration Examples
Section titled “Logger Integration Examples”example-pino— Pino as first-class logger withinit({ 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.
Build Outputs
Section titled “Build Outputs”- ESM-first with CJS fallback for index.ts only
- Type definitions (.d.ts) generated from ESM build
- Source maps enabled for debugging
- Use
tsupfor bundling (not tsc directly)