autotel
Version:
Write Once, Observe Anywhere
1,659 lines (1,449 loc) • 76.5 kB
text/typescript
/**
* 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> {
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 await db.users.create(data)
* })
* ```
*/
// Sync overloads - Ordered from most specific to most generic for better type inference
// Single