UNPKG

autotel

Version:
1,655 lines (1,446 loc) 77.1 kB
/** * Functional API for non-class code * * Three approaches for different use cases: * 1. trace() - Zero-ceremony HOF for single functions * 2. withTracing() - Middleware-style composable wrapper * 3. instrument() - Batch auto-instrumentation for modules * * @example trace() - Single function * ```typescript * export const createUser = trace(ctx => async (data) => { * ctx.setAttribute('user.id', data.id) * return await db.users.create(data) * }) * ``` * * @example withTracing() - Composable middleware * ```typescript * export const createUser = withTracing({ * name: 'user.create' * })(ctx => async (data) => { * ctx.setAttribute('user.id', data.id) * return await db.users.create(data) * }) * ``` * * @example instrument() - Batch instrumentation * ```typescript * export default instrument({ * createUser: async (data) => { }, * updateUser: async (id, data) => { } * }, { serviceName: 'user' }) * ``` */ import { SpanStatusCode, trace as otelTrace, context, propagation, type Span, } from '@opentelemetry/api'; import { getConfig } from './config'; import { getConfig as getInitConfig, getSdk } from './init'; import { type Sampler, type SamplingContext, AlwaysSampler, AUTOTEL_SAMPLING_TAIL_KEEP, AUTOTEL_SAMPLING_TAIL_EVALUATED, } from './sampling'; import { getEventQueue } from './track'; import type { TraceContext } from './trace-context'; import { createTraceContext, enterOrRun, getActiveContextWithBaggage, getContextStorage, } from './trace-context'; import { setSpanName } from './trace-helpers'; import { runInOperationContext } from './operation-context'; import { inferVariableNameFromCallStack } from './variable-name-inference'; /** * Complete trace context containing trace identifiers and span methods * * The ctx parameter in trace() functions provides: * - traceId, spanId, correlationId from the active span * - Span manipulation methods (setAttribute, setAttributes, setStatus, recordException) * * For custom context, access it directly in your functions (standard OpenTelemetry pattern). * * @example * ```typescript * import { trace } from 'autotel' * * export const createUser = trace(ctx => async (data: CreateUserData) => { * // Get custom context directly (standard OTel approach) * const userId = getCurrentUserId() * const tenantId = getCurrentTenant() * * // Use ctx for span operations and trace IDs * ctx.setAttribute('user.id', data.id) * ctx.setAttribute('user.tenant', tenantId) * console.log(ctx.traceId) // Trace IDs available * }) * ``` */ export type { TraceContext } from './trace-context'; /** * Helper type to extract function signature from factory pattern * This helps TypeScript infer types correctly for factory functions */ type ExtractFunctionSignature<T> = T extends (ctx: TraceContext) => infer F ? F extends (...args: infer Args) => infer Return ? (...args: Args) => Return : never : never; /** * Helper type to exclude functions that return functions from immediate execution overloads */ type ExcludeFactoryReturn<T> = T extends (ctx: TraceContext) => infer F ? // eslint-disable-next-line @typescript-eslint/no-explicit-any F extends (...args: any[]) => any ? never : T : T; type GenericFunction = (...args: unknown[]) => unknown; const FACTORY_NAME_HINTS = new Set([ 'ctx', '_ctx', 'context', 'tracecontext', 'tracectx', ]); const TRACE_FACTORY_SET = new WeakSet<object>(); const SINGLE_LINE_COMMENT_REGEX = /\/\/.*$/gm; const MULTI_LINE_COMMENT_REGEX = /\/\*[\s\S]*?\*\//gm; const PARAM_TOKEN_SANITIZE_REGEX = new RegExp(String.raw`[{}\[\]\s]`, 'g'); function markAsTraceFactory(fn: object): void { TRACE_FACTORY_SET.add(fn); } function hasFactoryMark(fn: object): boolean { return TRACE_FACTORY_SET.has(fn); } function sanitizeParameterToken(token: string): string { const [firstToken] = token.split('='); return (firstToken ?? '').replaceAll(PARAM_TOKEN_SANITIZE_REGEX, '').trim(); } function getFirstParameterToken(fn: GenericFunction): string | null { let source = Function.prototype.toString.call(fn); source = source .replaceAll(MULTI_LINE_COMMENT_REGEX, '') .replaceAll(SINGLE_LINE_COMMENT_REGEX, '') .trim(); // Arrow functions const arrowMatch = source.match( /^(?:async\s*)?(?:\(([^)]*)\)|([^=()]+))\s*=>/, ); if (arrowMatch) { const params = (arrowMatch[1] ?? arrowMatch[2] ?? '').split(','); const first = params[0]?.trim(); if (first) { return sanitizeParameterToken(first); } return null; } // Function declarations/expressions const functionMatch = source.match(/^[^(]*\(([^)]*)\)/); if (functionMatch) { const params = functionMatch[1]?.split(','); const first = params?.[0]?.trim(); if (first) { return sanitizeParameterToken(first); } } return null; } function looksLikeTraceFactory(fn: GenericFunction): boolean { if (hasFactoryMark(fn)) { return true; } if (fn.length === 0) { if (!isAsyncFunction(fn)) { try { const result = fn(); return typeof result === 'function'; } catch { return false; } } return false; } const firstParam = getFirstParameterToken(fn); if (!firstParam) { return false; } const normalized = firstParam.toLowerCase(); if ( FACTORY_NAME_HINTS.has(normalized) || normalized.startsWith('ctx') || normalized.startsWith('_ctx') || normalized.startsWith('trace') || normalized.endsWith('ctx') || // Match baseCtx, spanCtx, etc. normalized.includes('context') // Match traceContext, spanContext, etc. ) { return true; } return false; } /** * Check if a function that takes ctx returns another function (factory pattern) * vs returning a value directly (immediate execution pattern) * * IMPORTANT: For async functions, we skip probing entirely and assume immediate execution. * This is because: * - Factory pattern: `(ctx) => async (...args) => result` - outer function is SYNC * - Immediate execution: `async (ctx) => result` - function itself is ASYNC * * Probing async functions by executing them causes side effects (like creating orphan spans) * because the async function starts executing synchronously until the first await. */ function isFactoryReturningFunction( fnWithCtx: (ctx: TraceContext) => unknown, ): boolean { // Async functions with ctx parameter are always immediate execution // because factory patterns have a sync outer function that returns the async inner if (isAsyncFunction(fnWithCtx)) { return false; } try { const result = fnWithCtx(createDummyCtx()); return typeof result === 'function'; } catch { // If the function throws when called with dummy ctx, assume it's immediate execution // since factory functions typically just return a function and don't execute logic return false; } } function isTraceFactoryFunction<TArgs extends unknown[], TReturn>( fn: | ((...args: TArgs) => TReturn) | ((ctx: TraceContext) => (...args: TArgs) => TReturn), ): fn is (ctx: TraceContext) => (...args: TArgs) => TReturn { if (typeof fn !== 'function') { return false; } if (hasFactoryMark(fn)) { return true; } if (looksLikeTraceFactory(fn as GenericFunction)) { markAsTraceFactory(fn); return true; } return false; } function ensureTraceFactory<TArgs extends unknown[], TReturn>( fnOrFactory: | ((...args: TArgs) => TReturn | Promise<TReturn>) | ((ctx: TraceContext) => (...args: TArgs) => TReturn | Promise<TReturn>), ): (ctx: TraceContext) => (...args: TArgs) => TReturn | Promise<TReturn> { if (isTraceFactoryFunction(fnOrFactory)) { return fnOrFactory; } const plainFn = fnOrFactory as (...args: TArgs) => TReturn | Promise<TReturn>; const factory = (ctx: TraceContext) => { void ctx; return plainFn; }; markAsTraceFactory(factory); return factory; } type WrappedFunction<TArgs extends unknown[], TReturn> = ( ...args: TArgs ) => TReturn | Promise<TReturn>; function wrapFactoryWithTracing<TArgs extends unknown[], TReturn>( fnOrFactory: | ((...args: TArgs) => TReturn | Promise<TReturn>) | ((ctx: TraceContext) => (...args: TArgs) => TReturn | Promise<TReturn>), options: TracingOptions<TArgs, TReturn>, variableName?: string, ): WrappedFunction<TArgs, TReturn> { const factory = ensureTraceFactory(fnOrFactory); // Get the inner function (the actual function being traced) const sampleFn = factory(createDummyCtx()); // Infer function name with priority: // 1. Explicit variable name (from instrument() or explicit name parameter) // 2. Inner function name (named function expressions - e.g., "async function createUser") // 3. Variable name from call stack (inferred from const assignment, for arrow functions) // 4. Factory function name (for cases where factory itself is named) const innerFunctionName = inferFunctionName( sampleFn as InstrumentableFunction, ); const callStackVariableName = innerFunctionName ? undefined : inferVariableNameFromCallStack(); // Only infer from call stack if no inner function name const factoryName = inferFunctionName(factory as InstrumentableFunction); const effectiveVariableName = variableName || innerFunctionName || callStackVariableName || factoryName; const useAsyncWrapper = isAsyncFunction(sampleFn); if (useAsyncWrapper) { return wrapWithTracing( factory as (ctx: TraceContext) => (...args: TArgs) => Promise<TReturn>, options, effectiveVariableName, ) as WrappedFunction<TArgs, TReturn>; } return wrapWithTracingSync( factory as (ctx: TraceContext) => (...args: TArgs) => TReturn, options, effectiveVariableName, ) as WrappedFunction<TArgs, TReturn>; } /** * Common options for functional tracing */ export interface TracingOptions< TArgs extends unknown[] = unknown[], TReturn = unknown, > { /** * Span name (highest priority) * If provided, this is used as the span name */ name?: string; /** * Service name (used to compose final span name) * If name not provided, span name becomes: ${serviceName}.${functionName} */ serviceName?: string; /** * Sampling strategy * @default AlwaysSampler */ sampler?: Sampler; /** * Enable metrics collection (counter, histogram) * @default false */ withMetrics?: boolean; /** * Extract attributes from function arguments */ attributesFromArgs?: (args: TArgs) => Record<string, unknown>; /** * Extract attributes from function result */ attributesFromResult?: (result: TReturn) => Record<string, unknown>; /** * Start a new root span instead of creating a child * Useful for serverless entry points * @default false */ startNewRoot?: boolean; /** * Flush events queue when span ends * Only flushes on root spans (to avoid excessive flushing) * @default true */ flushOnRootSpanEnd?: boolean; /** * Span kind for semantic convention compliance * Used for messaging (PRODUCER/CONSUMER), HTTP (CLIENT/SERVER), etc. * @default SpanKind.INTERNAL */ spanKind?: import('@opentelemetry/api').SpanKind; } /** * Options for instrument() batch instrumentation */ export interface InstrumentOptions< T extends Record<string, InstrumentableFunction> = Record< string, InstrumentableFunction >, > extends TracingOptions { /** Functions to instrument */ functions: T; /** * Per-function configuration overrides */ overrides?: Record<string, Partial<TracingOptions>>; /** * Functions to skip (won't be instrumented) * Supports: * - String keys: 'functionName' * - RegExp: /^_internal/ * - Predicate: (key, fn) => boolean * * By default, functions starting with _ are skipped */ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type skip?: (string | RegExp | ((key: string, fn: Function) => boolean))[]; } // Maximum error message length to prevent span bloat const MAX_ERROR_MESSAGE_LENGTH = 500; function createDummyCtx< TBaggage extends Record<string, unknown> | undefined = undefined, >(): TraceContext<TBaggage> { // `recordException` / `addEvent` are no-op shims kept for the same // compatibility window as `createTraceContext` (see trace-context.ts). return { traceId: '', spanId: '', correlationId: '', setAttribute: () => {}, setAttributes: () => {}, setStatus: () => {}, recordException: () => {}, addEvent: () => {}, addLink: () => {}, addLinks: () => {}, updateName: () => {}, isRecording: () => false, getBaggage: () => {}, setBaggage: () => '', deleteBaggage: () => {}, getAllBaggage: () => new Map(), } as unknown as TraceContext<TBaggage>; } function isAsyncFunction(fn: unknown): boolean { return typeof fn === 'function' && fn.constructor?.name === 'AsyncFunction'; } // Symbol to prevent double-instrumentation (idempotency flag) const INSTRUMENTED_SYMBOL = Symbol.for('autotel.functional.instrumented'); type InstrumentedFlag = { [INSTRUMENTED_SYMBOL]?: true; }; function hasInstrumentationFlag(value: unknown): value is InstrumentedFlag { return ( (typeof value === 'function' || typeof value === 'object') && value !== null && Boolean((value as InstrumentedFlag)[INSTRUMENTED_SYMBOL]) ); } /** * Truncate error message to prevent span bloat */ function truncateErrorMessage(message: string): string { if (message.length <= MAX_ERROR_MESSAGE_LENGTH) { return message; } return `${message.slice(0, MAX_ERROR_MESSAGE_LENGTH)}... (truncated)`; } type InstrumentableFunction< TArgs extends unknown[] = unknown[], TReturn = unknown, > = ((...args: TArgs) => TReturn | Promise<TReturn>) & { displayName?: string; name?: string; }; /** * Try to infer function name from function properties * Checks for displayName, name, or other metadata that might be set */ function inferFunctionName< TArgs extends unknown[] = unknown[], TReturn = unknown, >(fn: InstrumentableFunction<TArgs, TReturn>): string | undefined { // Check for displayName property (sometimes set by bundlers) const displayName = (fn as { displayName?: string }).displayName; if (displayName) { return displayName; } // Check function.name (works for named functions and modern arrow function assignment) // Note: Empty string is falsy, so this handles both undefined and '' if (fn.name && fn.name !== 'anonymous' && fn.name !== '') { return fn.name; } // Try to extract name from function source (for function declarations) const source = Function.prototype.toString.call(fn); const match = source.match(/function\s+([^(\s]+)/); if (match && match[1] && match[1] !== 'anonymous') { return match[1]; } return undefined; } /** * Determine span name using priority: * 1. Explicit name option * 2. serviceName + functionName * 3. Inferred from function/variable name (including stack trace fallback) * 4. Fallback to 'unknown' */ function getSpanName<TArgs extends unknown[], TReturn>( options: TracingOptions<TArgs, TReturn>, fn: InstrumentableFunction<TArgs, TReturn>, variableName?: string, ): string { // 1. Explicit name if (options.name) { return options.name; } // 2. Try variable name, function name, or function properties let fnName = variableName || inferFunctionName(fn); // Default to 'anonymous' if still no name fnName = fnName || 'anonymous'; // 2. serviceName + functionName if (options.serviceName) { return `${options.serviceName}.${fnName}`; } // 3. Inferred from function name if (fnName && fnName !== 'anonymous') { return fnName; } // 4. Fallback return 'unknown'; } /** * Check if function should be skipped */ function shouldSkip( key: string, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type fn: Function, // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type skip?: (string | RegExp | ((key: string, fn: Function) => boolean))[], ): boolean { // Default: skip functions starting with _ if (key.startsWith('_')) { return true; } if (!skip || skip.length === 0) { return false; } for (const rule of skip) { if (typeof rule === 'string' && key === rule) { return true; } else if (rule instanceof RegExp && rule.test(key)) { return true; } else if (typeof rule === 'function' && rule(key, fn)) { return true; } } return false; } /** * Get current trace context value (internal helper) * * Returns base context (trace IDs) + span methods from the active span. */ function getCtxValue< TBaggage extends Record<string, unknown> | undefined = undefined, >(): TraceContext<TBaggage> | null { const activeSpan = otelTrace.getActiveSpan(); if (!activeSpan) return null; // Use shared utility to create trace context return createTraceContext<TBaggage>(activeSpan); } /** * Context object that lazily evaluates the active span on property access * * Access trace context directly without function call syntax. * * @example * ```typescript * import { trace, ctx } from 'autotel' * * export const createUser = trace(async (data) => { * // Direct property access - no function call! * if (ctx.traceId) { * ctx.setAttribute('user.id', data.id) * console.log('Trace:', ctx.traceId) * } * }) * ``` */ export const ctx = new Proxy( {}, { get(_target, prop) { const ctxValue = getCtxValue(); if (!ctxValue) { return; } return ctxValue[prop as keyof typeof ctxValue]; }, has(_target, prop) { const ctxValue = getCtxValue(); if (!ctxValue) { return false; } return prop in ctxValue; }, ownKeys() { const ctxValue = getCtxValue(); if (!ctxValue) { return []; } return Object.keys(ctxValue); }, getOwnPropertyDescriptor(_target, prop) { const ctxValue = getCtxValue(); if (!ctxValue) { return; } return Object.getOwnPropertyDescriptor(ctxValue, prop); }, }, ); /** * Core tracing wrapper for async functions (internal implementation) */ function wrapWithTracing<TArgs extends unknown[], TReturn>( fnFactory: ( ctx: TraceContext, ) => (...args: TArgs) => TReturn | Promise<TReturn>, options: TracingOptions<TArgs, TReturn>, variableName?: string, ): (...args: TArgs) => Promise<TReturn> { // Idempotency check: if already instrumented, return as-is if (hasInstrumentationFlag(fnFactory)) { // Already instrumented - proceed } const config = getConfig(); const tracer = config.tracer; const meter = config.meter; const sampler = options.sampler || new AlwaysSampler(); const tempFn = fnFactory(createDummyCtx()); const spanName = getSpanName(options, tempFn, variableName); const callCounter = options.withMetrics ? meter.createCounter(`${spanName}.calls`, { description: `Call count for ${spanName}`, unit: '1', }) : undefined; const durationHistogram = options.withMetrics ? meter.createHistogram(`${spanName}.duration`, { description: `Duration for ${spanName}`, unit: 'ms', }) : undefined; const wrappedFunction = async function wrappedFunction( this: unknown, ...args: TArgs ): Promise<TReturn> { const samplingContext: SamplingContext = { operationName: spanName, args, metadata: {}, }; const shouldSample = sampler.shouldSample(samplingContext); const needsTailSampling = 'needsTailSampling' in sampler && typeof sampler.needsTailSampling === 'function' ? sampler.needsTailSampling() : false; if (!shouldSample && !needsTailSampling) { const fn = fnFactory(createDummyCtx()); return await fn.call(this, ...args); } const startTime = performance.now(); const isRootSpan = options.startNewRoot || otelTrace.getActiveSpan() === undefined; const shouldAutoFlush = options.flushOnRootSpanEnd ?? getInitConfig()?.flushOnRootSpanEnd ?? true; const shouldAutoFlushSpans = getInitConfig()?.forceFlushOnShutdown ?? false; const flushIfNeeded = async () => { if (!shouldAutoFlush || !isRootSpan) return; try { // Flush events queue const queue = getEventQueue(); if (queue && queue.size() > 0) { await queue.flush(); } // Flush OpenTelemetry spans if enabled if (shouldAutoFlushSpans) { const sdk = getSdk(); if (sdk) { try { // Type assertion needed as getTracerProvider is not in the public NodeSDK interface // eslint-disable-next-line @typescript-eslint/no-explicit-any const sdkAny = sdk as any; if (typeof sdkAny.getTracerProvider === 'function') { const tracerProvider = sdkAny.getTracerProvider(); if ( tracerProvider && typeof tracerProvider.forceFlush === 'function' ) { await tracerProvider.forceFlush(); } } } catch { // Ignore errors when accessing tracer provider (may not be available in test mocks) } } } } catch (error) { const initConfig = getInitConfig(); const logger = initConfig?.logger; if (logger?.error) { logger.error( { err: error instanceof Error ? error : undefined, }, `[autotel] Auto-flush failed${error instanceof Error ? '' : `: ${String(error)}`}`, ); } } }; // Build span options including root and kind const spanOptions: import('@opentelemetry/api').SpanOptions = {}; if (options.startNewRoot) { spanOptions.root = true; } if (options.spanKind !== undefined) { spanOptions.kind = options.spanKind; } const parentContext = getActiveContextWithBaggage(); return tracer.startActiveSpan( spanName, spanOptions, parentContext, async (span) => { // Run within operation context so events can auto-capture operation.name return runInOperationContext(spanName, async () => { let shouldKeepSpan = true; setSpanName(span, spanName); // Initialize context storage with the active context BEFORE creating trace context const initialContext = context.active(); const contextStorage = getContextStorage(); if (!contextStorage.getStore()) { enterOrRun(contextStorage, initialContext); } const ctxValue = createTraceContext(span); const fn = fnFactory(ctxValue); const argsAttributes = options.attributesFromArgs ? options.attributesFromArgs(args) : {}; const handleTailSampling = ( success: boolean, duration: number, error?: unknown, ) => { if ( needsTailSampling && 'shouldKeepTrace' in sampler && typeof sampler.shouldKeepTrace === 'function' ) { shouldKeepSpan = sampler.shouldKeepTrace(samplingContext, { success, duration, error, }); span.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, shouldKeepSpan); span.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true); } }; const onSuccess = async (result: TReturn) => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'success', }); durationHistogram?.record(duration, { operation: spanName, status: 'success', }); const resultAttributes = options.attributesFromResult ? options.attributesFromResult(result) : {}; span.setStatus({ code: SpanStatusCode.OK }); span.setAttributes({ ...argsAttributes, ...resultAttributes, 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': true, }); handleTailSampling(true, duration); span.end(); await flushIfNeeded(); return result; }; const onError = async (error: unknown): Promise<never> => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'error', }); durationHistogram?.record(duration, { operation: spanName, status: 'error', }); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const truncatedMessage = truncateErrorMessage(errorMessage); span.setStatus({ code: SpanStatusCode.ERROR, message: truncatedMessage, }); span.setAttributes({ ...argsAttributes, 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': false, error: true, 'exception.type': error instanceof Error ? error.constructor.name : 'Error', 'exception.message': truncatedMessage, }); if (error instanceof Error && error.stack) { span.setAttribute( 'exception.stack', error.stack.slice(0, MAX_ERROR_MESSAGE_LENGTH), ); } span.recordException( error instanceof Error ? error : new Error(String(error)), ); handleTailSampling(false, duration, error); span.end(); await flushIfNeeded(); throw error; }; try { callCounter?.add(1, { operation: spanName, status: 'started', }); // Execute the user's function with the updated context // This ensures ctx.setBaggage() changes are visible to OpenTelemetry operations // (like BaggageSpanProcessor, child spans, etc.) // We use getActiveContextWithBaggage() which checks the stored context, // so if baggage is set during execution, it will be picked up const executeWithContext = async () => { // Get the current context (may have been updated by ctx.setBaggage()) const currentContext = getActiveContextWithBaggage(); // Establish the context in OpenTelemetry's context manager return context.with(currentContext, async () => { return fn.call(this, ...args); }); }; const result = await executeWithContext(); return await onSuccess(result); } catch (error) { await onError(error); throw error; } }); }, ); }; // Mark as instrumented to prevent double-wrapping (wrappedFunction as InstrumentedFlag)[INSTRUMENTED_SYMBOL] = true; Object.defineProperty(wrappedFunction, 'name', { value: tempFn.name || 'trace', configurable: true, }); return wrappedFunction; } /** * Core tracing wrapper for sync functions (internal implementation) */ function wrapWithTracingSync<TArgs extends unknown[], TReturn>( fnFactory: (ctx: TraceContext) => (...args: TArgs) => TReturn, options: TracingOptions<TArgs, TReturn>, variableName?: string, ): (...args: TArgs) => TReturn { // Idempotency check: if already instrumented, return as-is if (hasInstrumentationFlag(fnFactory)) { // If already instrumented, we need to extract the original factory // For now, we'll just proceed - this edge case is handled by the wrapped function check } const config = getConfig(); const tracer = config.tracer; const meter = config.meter; const sampler = options.sampler || new AlwaysSampler(); // We need to get a reference function name for span naming // Create a minimal dummy context just for extracting the function name // This won't affect actual tracing - we use the real context inside the span const tempFn = fnFactory(createDummyCtx()); const spanName = getSpanName(options, tempFn, variableName); // Metrics setup (if enabled) const callCounter = options.withMetrics ? meter.createCounter(`${spanName}.calls`, { description: `Call count for ${spanName}`, unit: '1', }) : undefined; const durationHistogram = options.withMetrics ? meter.createHistogram(`${spanName}.duration`, { description: `Duration for ${spanName}`, unit: 'ms', }) : undefined; // Return wrapped function function wrappedFunction( this: unknown, ...args: TArgs ): TReturn | Promise<TReturn> { const samplingContext: SamplingContext = { operationName: spanName, args, metadata: {}, }; const shouldSample = sampler.shouldSample(samplingContext); const needsTailSampling = 'needsTailSampling' in sampler && typeof sampler.needsTailSampling === 'function' ? sampler.needsTailSampling() : false; // If not sampling and no tail sampling, execute without tracing if (!shouldSample && !needsTailSampling) { const fn = fnFactory(createDummyCtx()); return fn.call(this, ...args); } const startTime = performance.now(); // Track if this is a root span for auto-flush const isRootSpan = options.startNewRoot || otelTrace.getActiveSpan() === undefined; const shouldAutoFlush = options.flushOnRootSpanEnd ?? getInitConfig()?.flushOnRootSpanEnd ?? true; const shouldAutoFlushSpans = getInitConfig()?.forceFlushOnShutdown ?? false; // Note: This is intentionally fire-and-forget (void) for synchronous functions. // Synchronous functions cannot await flush completion without blocking execution. // The forceFlushOnShutdown guarantee only applies to async functions. const flushIfNeeded = () => { if (!shouldAutoFlush || !isRootSpan) return; // Flush events queue const queue = getEventQueue(); if (queue && queue.size() > 0) { void queue.flush().catch((error) => { const initConfig = getInitConfig(); const logger = initConfig?.logger; if (logger?.error) { logger.error( { err: error instanceof Error ? error : undefined, }, `[autotel] Auto-flush failed${error instanceof Error ? '' : `: ${String(error)}`}`, ); } }); } // Flush OpenTelemetry spans if enabled if (shouldAutoFlushSpans) { const sdk = getSdk(); if (sdk) { try { // Type assertion needed as getTracerProvider is not in the public NodeSDK interface // eslint-disable-next-line @typescript-eslint/no-explicit-any const sdkAny = sdk as any; if (typeof sdkAny.getTracerProvider === 'function') { const tracerProvider = sdkAny.getTracerProvider(); if ( tracerProvider && typeof tracerProvider.forceFlush === 'function' ) { void tracerProvider.forceFlush().catch((error: unknown) => { const initConfig = getInitConfig(); const logger = initConfig?.logger; if (logger?.error) { logger.error( { err: error instanceof Error ? error : undefined, }, `[autotel] Span flush failed${error instanceof Error ? '' : `: ${String(error)}`}`, ); } }); } } } catch { // Ignore errors when accessing tracer provider (may not be available in test mocks) } } } }; // Build span options including root and kind const spanOptions: import('@opentelemetry/api').SpanOptions = {}; if (options.startNewRoot) { spanOptions.root = true; } if (options.spanKind !== undefined) { spanOptions.kind = options.spanKind; } const parentContext = getActiveContextWithBaggage(); return tracer.startActiveSpan( spanName, spanOptions, parentContext, (span) => { // Run within operation context so events can auto-capture operation.name return runInOperationContext(spanName, () => { let shouldKeepSpan = true; // Store span name for trace context helpers setSpanName(span, spanName); // Create trace context for this span using shared utility const ctxValue = createTraceContext(span); // Get the actual function from the factory const fn = fnFactory(ctxValue); // Extract attributes only when actually tracing // This avoids expensive preprocessing when sampling rejects the trace const argsAttributes = options.attributesFromArgs ? options.attributesFromArgs(args) : {}; const handleTailSampling = ( success: boolean, duration: number, error?: unknown, ) => { if ( needsTailSampling && 'shouldKeepTrace' in sampler && typeof sampler.shouldKeepTrace === 'function' ) { shouldKeepSpan = sampler.shouldKeepTrace(samplingContext, { success, duration, error, }); span.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, shouldKeepSpan); span.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true); } }; const onSuccess = (result: TReturn) => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'success', }); durationHistogram?.record(duration, { operation: spanName, status: 'success', }); const resultAttributes = options.attributesFromResult ? options.attributesFromResult(result) : {}; span.setStatus({ code: SpanStatusCode.OK }); span.setAttributes({ ...argsAttributes, ...resultAttributes, 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': true, }); handleTailSampling(true, duration); span.end(); void flushIfNeeded(); return result; }; const onError = (error: unknown): never => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'error', }); durationHistogram?.record(duration, { operation: spanName, status: 'error', }); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const truncatedMessage = truncateErrorMessage(errorMessage); span.setStatus({ code: SpanStatusCode.ERROR, message: truncatedMessage, }); span.setAttributes({ ...argsAttributes, 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': false, error: true, 'exception.type': error instanceof Error ? error.constructor.name : 'Error', 'exception.message': truncatedMessage, }); span.recordException( error instanceof Error ? error : new Error(String(error)), ); handleTailSampling(false, duration, error); span.end(); void flushIfNeeded(); throw error; }; try { callCounter?.add(1, { operation: spanName, status: 'started', }); const result = fn.call(this, ...args); if (result instanceof Promise) { return result.then(onSuccess, onError); } return onSuccess(result); } catch (error) { return onError(error); } }); }, ); } // Mark as instrumented to prevent double-wrapping (wrappedFunction as InstrumentedFlag)[INSTRUMENTED_SYMBOL] = true; // Preserve function name for better debugging // Use the same tempFn we created earlier for span naming Object.defineProperty(wrappedFunction, 'name', { value: tempFn.name || 'trace', configurable: true, }); return wrappedFunction as unknown as (...args: TArgs) => TReturn; } /** * Execute a function immediately within a trace span * Used for the immediate execution pattern: trace((ctx) => result) */ function executeImmediately<TReturn = unknown>( fn: (ctx: TraceContext) => TReturn | Promise<TReturn>, options: TracingOptions<unknown[], unknown>, ): TReturn | Promise<TReturn> { const config = getConfig(); const tracer = config.tracer; const meter = config.meter; const sampler = options.sampler || new AlwaysSampler(); // Get span name from options or use 'anonymous' const spanName = options.name || 'anonymous'; const samplingContext: SamplingContext = { operationName: spanName, args: [], metadata: {}, }; const shouldSample = sampler.shouldSample(samplingContext); const needsTailSampling = 'needsTailSampling' in sampler && typeof sampler.needsTailSampling === 'function' ? sampler.needsTailSampling() : false; if (!shouldSample && !needsTailSampling) { return fn(createDummyCtx()); } const startTime = performance.now(); const isRootSpan = options.startNewRoot || otelTrace.getActiveSpan() === undefined; const shouldAutoFlush = options.flushOnRootSpanEnd ?? getInitConfig()?.flushOnRootSpanEnd ?? true; const shouldAutoFlushSpans = getInitConfig()?.forceFlushOnShutdown ?? false; const callCounter = options.withMetrics ? meter.createCounter(`${spanName}.calls`, { description: `Call count for ${spanName}`, unit: '1', }) : undefined; const durationHistogram = options.withMetrics ? meter.createHistogram(`${spanName}.duration`, { description: `Duration for ${spanName}`, unit: 'ms', }) : undefined; const flushIfNeeded = async () => { if (!shouldAutoFlush || !isRootSpan) return; try { // Flush events queue const queue = getEventQueue(); if (queue && queue.size() > 0) { await queue.flush(); } // Flush OpenTelemetry spans if enabled if (shouldAutoFlushSpans) { const sdk = getSdk(); if (sdk) { try { // Type assertion needed as getTracerProvider is not in the public NodeSDK interface // eslint-disable-next-line @typescript-eslint/no-explicit-any const sdkAny = sdk as any; if (typeof sdkAny.getTracerProvider === 'function') { const tracerProvider = sdkAny.getTracerProvider(); if ( tracerProvider && typeof tracerProvider.forceFlush === 'function' ) { await tracerProvider.forceFlush(); } } } catch { // Ignore errors when accessing tracer provider (may not be available in test mocks) } } } } catch (error) { const initConfig = getInitConfig(); const logger = initConfig?.logger; if (logger?.error) { logger.error( { err: error instanceof Error ? error : undefined, }, `[autotel] Auto-flush failed${error instanceof Error ? '' : `: ${String(error)}`}`, ); } } }; // Build span options including root and kind const spanOptions: import('@opentelemetry/api').SpanOptions = {}; if (options.startNewRoot) { spanOptions.root = true; } if (options.spanKind !== undefined) { spanOptions.kind = options.spanKind; } const parentContext = getActiveContextWithBaggage(); return tracer.startActiveSpan( spanName, spanOptions, parentContext, (span) => { return runInOperationContext(spanName, () => { let shouldKeepSpan = true; setSpanName(span, spanName); const ctxValue = createTraceContext(span); const handleTailSampling = ( success: boolean, duration: number, error?: unknown, ) => { if ( needsTailSampling && 'shouldKeepTrace' in sampler && typeof sampler.shouldKeepTrace === 'function' ) { shouldKeepSpan = sampler.shouldKeepTrace(samplingContext, { success, duration, error, }); span.setAttribute(AUTOTEL_SAMPLING_TAIL_KEEP, shouldKeepSpan); span.setAttribute(AUTOTEL_SAMPLING_TAIL_EVALUATED, true); } }; // Sync handlers for synchronous results (can't await) // NOTE: forceFlushOnShutdown will NOT block for synchronous trace() calls // Flush is fire-and-forget, so spans may be dropped if process exits immediately const onSuccessSync = (result: TReturn) => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'success', }); durationHistogram?.record(duration, { operation: spanName, status: 'success', }); span.setStatus({ code: SpanStatusCode.OK }); span.setAttributes({ 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': true, }); handleTailSampling(true, duration); span.end(); void flushIfNeeded(); return result; }; const onErrorSync = (error: unknown): never => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'error', }); durationHistogram?.record(duration, { operation: spanName, status: 'error', }); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const truncatedMessage = truncateErrorMessage(errorMessage); span.setStatus({ code: SpanStatusCode.ERROR, message: truncatedMessage, }); span.setAttributes({ 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': false, error: true, 'exception.type': error instanceof Error ? error.constructor.name : 'Error', 'exception.message': truncatedMessage, }); if (error instanceof Error && error.stack) { span.setAttribute( 'exception.stack', error.stack.slice(0, MAX_ERROR_MESSAGE_LENGTH), ); } span.recordException( error instanceof Error ? error : new Error(String(error)), ); handleTailSampling(false, duration, error); span.end(); void flushIfNeeded(); throw error; }; // Async handlers for Promise results (await flush) const onSuccessAsync = async (result: TReturn) => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'success', }); durationHistogram?.record(duration, { operation: spanName, status: 'success', }); span.setStatus({ code: SpanStatusCode.OK }); span.setAttributes({ 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': true, }); handleTailSampling(true, duration); span.end(); await flushIfNeeded(); return result; }; const onErrorAsync = async (error: unknown): Promise<never> => { const duration = performance.now() - startTime; callCounter?.add(1, { operation: spanName, status: 'error', }); durationHistogram?.record(duration, { operation: spanName, status: 'error', }); const errorMessage = error instanceof Error ? error.message : 'Unknown error'; const truncatedMessage = truncateErrorMessage(errorMessage); span.setStatus({ code: SpanStatusCode.ERROR, message: truncatedMessage, }); span.setAttributes({ 'operation.name': spanName, 'code.function': spanName, 'operation.duration': duration, 'operation.success': false, error: true, 'exception.type': error instanceof Error ? error.constructor.name : 'Error', 'exception.message': truncatedMessage, }); if (error instanceof Error && error.stack) { span.setAttribute( 'exception.stack', error.stack.slice(0, MAX_ERROR_MESSAGE_LENGTH), ); } span.recordException( error instanceof Error ? error : new Error(String(error)), ); handleTailSampling(false, duration, error); span.end(); await flushIfNeeded(); throw error; }; try { callCounter?.add(1, { operation: spanName, status: 'started', }); const result = fn(ctxValue); // Check if result is a Promise - use async handlers to await flush if (result instanceof Promise) { return result.then(onSuccessAsync, onErrorAsync); } // Synchronous result - use sync handlers return onSuccessSync(result); } catch (error) { return onErrorSync(error); } }); }, ); } /** * Approach 1: trace() - Zero-ceremony HOF * * Wrap a single function with automatic tracing. * The function receives a context object as the first parameter. * * Supports two patterns: * 1. **Factory pattern** - Returns a traced function: `trace(ctx => (...args) => result)` * 2. **Immediate execution** - Executes immediately with tracing: `trace(ctx => result)` * * @example Auto-inferred name - Plain function * ```typescript * export const createUser = trace(async (data) => { * return await db.users.create(data) * }) * // → Traced as "createUser" * ``` * * @example Auto-inferred name - Factory pattern (with ctx access) * ```typescript * export const createUser = trace(ctx => async (data) => { * ctx.setAttribute('user.id', data.id) * return await db.users.create(data) * }) * // → Traced as "createUser", returns wrapped function * ``` * * @example Immediate execution - Execute once with tracing * ```typescript * // Wraps an existing function and executes immediately * function timed<T>(fn: () => Promise<T>): Promise<T> { * return trace(async (ctx) => { * ctx.setAttribute('operation', 'timed') * return await fn() * }) * } * // → Executes immediately, returns result directly * ``` * * @example Custom name - Plain function * ```typescript * export const createUser = trace('user.create', async (data) => { * return await db.users.create(data) * }) * // → Traced as "user.create" * ``` * * @example Custom name - Factory pattern * ```typescript * export const createUser = trace('user.create', ctx => async (data) => { * ctx.setAttribute('user.id', data.id) * return await db.users.create(data) * }) * // → Traced as "user.create" * ``` * * @example Custom name - Immediate execution * ```typescript * const result = trace('fetch.user', async (ctx) => { * ctx.setAttribute('userId', '123') * return await fetchUser('123') * }) * // → Executes immediately with span name "fetch.user" * ``` * * @example Full options - Plain function * ```typescript * export const createUser = trace({ * name: 'user.create', * sampler: new AdaptiveSampler(), * withMetrics: true * }, async (data) => { * return await db.users.create(data) * }) * ``` * * @example Full options - Factory pattern * ```typescript * export const createUser = trace({ * name: 'user.create', * sampler: new AdaptiveSampler(), * withMetrics: true * }, ctx => async (data) => { * ctx.setAttribute('user.id', data.id) * return