autotel
Version:
Write Once, Observe Anywhere
711 lines (681 loc) • 21.3 kB
text/typescript
/**
* Trace context helpers - Core primitives for trace correlation
*
* These are the building blocks that allow users to bring their own logger
* (bunyan, log4js, custom, etc.) and add trace correlation.
*
* @example Using with bunyan
* ```typescript
* import bunyan from 'bunyan';
* import { enrichWithTraceContext } from 'autotel/trace-helpers';
*
* const bunyanLogger = bunyan.createLogger({ name: 'myapp' });
*
* const logger = {
* info: (msg: string, extra?: object) => {
* bunyanLogger.info(enrichWithTraceContext(extra || {}), msg);
* }
* };
* ```
*
* @example Using with log4js
* ```typescript
* import log4js from 'log4js';
* import { getTraceContext } from 'autotel/trace-helpers';
*
* const log4jsLogger = log4js.getLogger();
*
* function logWithTrace(level: string, msg: string, extra?: object) {
* const context = getTraceContext();
* log4jsLogger[level](msg, { ...extra, ...context });
* }
* ```
*/
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import type { Span, Tracer, Context } from '@opentelemetry/api';
import { requireModule } from './node-require';
/**
* WeakMap to store span names for active spans
* This allows us to retrieve the span name even though OpenTelemetry
* doesn't expose it through the public API
*/
const spanNameMap = new WeakMap<Span, string>();
/**
* Store span name for a given span
* Called internally when spans are created
*/
export function setSpanName(span: Span, name: string): void {
spanNameMap.set(span, name);
}
/**
* Trace context extracted from active span
*/
export interface TraceContext {
/** Full 32-character hex trace ID */
traceId: string;
/** 16-character hex span ID */
spanId: string;
/** First 16 characters of trace ID (for log grouping/correlation) */
correlationId: string;
/** Function/operation name (OpenTelemetry semantic convention: code.function) */
'code.function'?: string;
/** Datadog trace ID in decimal format (lower 64 bits) for log-trace correlation */
'dd.trace_id'?: string;
/** Datadog span ID in decimal format for log-trace correlation */
'dd.span_id'?: string;
}
/**
* Convert hex string to decimal string representation
* Handles 64-bit unsigned integers for Datadog correlation
*
* @param hex - Hex string (up to 16 characters for 64-bit)
* @returns Decimal string representation
*/
function hexToDecimal(hex: string): string {
// For 64-bit values, use BigInt to avoid precision loss
return BigInt('0x' + hex).toString(10);
}
/**
* Get current trace context from active span
*
* Returns null if no span is active (e.g., outside of trace operation)
*
* Includes both OpenTelemetry standard fields (hex) and Datadog-specific
* fields (decimal) for maximum compatibility.
*
* @returns Trace context with traceId, spanId, correlationId, and Datadog decimal IDs, or null
*
* @example
* ```typescript
* import { getTraceContext } from 'autotel/trace-helpers';
*
* const context = getTraceContext();
* if (context) {
* console.log('Current trace:', context.traceId);
* // Current trace: 4bf92f3577b34da6a3ce929d0e0e4736
* console.log('Datadog trace ID:', context['dd.trace_id']);
* // Datadog trace ID: 12007117331170166582 (decimal for log correlation)
* }
* ```
*/
export function getTraceContext(): TraceContext | null {
const span = trace.getActiveSpan();
if (!span) return null;
const spanContext = span.spanContext();
const traceId = spanContext.traceId;
const spanId = spanContext.spanId;
// Get span name from WeakMap (set when span is created)
// Map to OpenTelemetry semantic convention: code.function
const spanName = spanNameMap.get(span);
// Datadog uses the lower 64 bits of the 128-bit OpenTelemetry trace ID
// Convert from hex to decimal for Datadog's log-trace correlation
const traceIdLower64 = traceId.slice(-16); // Last 16 hex chars = lower 64 bits
const ddTraceId = hexToDecimal(traceIdLower64);
const ddSpanId = hexToDecimal(spanId);
return {
traceId,
spanId,
correlationId: traceId.slice(0, 16),
...(spanName && { 'code.function': spanName }),
// Datadog-specific fields for log-trace correlation
'dd.trace_id': ddTraceId,
'dd.span_id': ddSpanId,
};
}
/**
* Enrich object with trace context (traceId, spanId, correlationId, and Datadog fields)
*
* If no span is active, returns the object unchanged.
* This prevents "undefined" or "null" values in logs.
*
* Automatically adds both OpenTelemetry standard fields (hex) and Datadog-specific
* fields (decimal) for maximum compatibility with observability backends.
*
* @param obj - Object to enrich (e.g., log metadata)
* @returns Object with trace context merged in, or unchanged if no active span
*
* @example
* ```typescript
* import { enrichWithTraceContext } from 'autotel/trace-helpers';
*
* // Inside a trace operation:
* const enriched = enrichWithTraceContext({ userId: '123' });
* // {
* // userId: '123',
* // traceId: '4bf92f3577b34da6a3ce929d0e0e4736',
* // spanId: '00f067aa0ba902b7',
* // correlationId: '4bf92f3577b34da6',
* // 'dd.trace_id': '12007117331170166582', // Datadog decimal format
* // 'dd.span_id': '67667974448284583' // Datadog decimal format
* // }
*
* // Outside trace operation:
* const unchanged = enrichWithTraceContext({ userId: '123' });
* // { userId: '123' } - no trace fields added
* ```
*/
export function enrichWithTraceContext<T extends Record<string, unknown>>(
obj: T,
): T {
const context = getTraceContext();
return context ? ({ ...obj, ...context } as T) : obj;
}
/**
* Check if currently in a trace context
*
* Useful for conditional logic based on trace presence
*
* @returns true if active span exists, false otherwise
*
* @example
* ```typescript
* import { isTracing } from 'autotel/trace-helpers';
*
* if (isTracing()) {
* // Add expensive debug metadata only when tracing
* logger.debug('Detailed context', expensiveDebugData());
* }
* ```
*/
export function isTracing(): boolean {
return trace.getActiveSpan() !== undefined;
}
/**
* Get a tracer instance for creating custom spans
*
* Use this when you need low-level control over span lifecycle.
* For most use cases, prefer trace(), span(), or instrument() instead.
*
* @param name - Tracer name (usually your service or module name)
* @param version - Optional version string
* @returns OpenTelemetry Tracer instance
*
* @example Basic usage
* ```typescript
* import { getTracer } from 'autotel';
*
* const tracer = getTracer('my-service');
* const span = tracer.startSpan('custom.operation');
* try {
* // Your logic
* span.setAttribute('key', 'value');
* } finally {
* span.end();
* }
* ```
*
* @example With AI SDK
* ```typescript
* import { getTracer } from 'autotel';
* import { generateText } from 'ai';
*
* const tracer = getTracer('ai-agent');
* const result = await generateText({
* model: myModel,
* prompt: 'Hello',
* experimental_telemetry: {
* isEnabled: true,
* tracer,
* },
* });
* ```
*/
export function getTracer(name: string, version?: string): Tracer {
return trace.getTracer(name, version);
}
/**
* Get the currently active span
*
* Returns undefined if no span is currently active.
* Useful for adding attributes or events to the current span.
*
* @returns Active span or undefined
*
* @example Adding attributes to active span
* ```typescript
* import { getActiveSpan } from 'autotel';
*
* const span = getActiveSpan();
* if (span) {
* span.setAttribute('user.id', userId);
* span.addEvent('User action', { action: 'click' });
* }
* ```
*
* @example Checking span status
* ```typescript
* import { getActiveSpan, SpanStatusCode } from 'autotel';
*
* const span = getActiveSpan();
* if (span?.isRecording()) {
* span.setStatus({ code: SpanStatusCode.OK });
* }
* ```
*/
export function getActiveSpan(): Span | undefined {
return trace.getActiveSpan();
}
/**
* Get the currently active OpenTelemetry context
*
* The context contains the active span and any baggage.
* Useful for context propagation and custom instrumentation.
*
* @returns Current active context
*
* @example Propagating context
* ```typescript
* import { getActiveContext } from 'autotel';
*
* const currentContext = getActiveContext();
* // Pass context to another function or service
* ```
*
* @example With context injection
* ```typescript
* import { getActiveContext, injectTraceContext } from 'autotel';
*
* const headers = {};
* injectTraceContext(headers);
* // Headers now contain trace propagation data
* ```
*/
export function getActiveContext(): Context {
// Check stored context first (from baggage setters), then fall back to active context
// This ensures ctx.setBaggage() changes are visible to OpenTelemetry operations
try {
const { getActiveContextWithBaggage } = requireModule<{
getActiveContextWithBaggage: () => Context;
}>('./trace-context');
return getActiveContextWithBaggage();
} catch {
// Fallback if trace-context isn't available
return context.active();
}
}
/**
* Run a function with a specific span set as active
*
* This is a convenience wrapper around the two-step process of
* setting a span in context and running code within that context.
*
* @param span - The span to set as active
* @param fn - Function to execute with the span active
* @returns The return value of the function
*
* @example Running code with a custom span
* ```typescript
* import { getTracer, runWithSpan } from 'autotel';
*
* const tracer = getTracer('my-service');
* const span = tracer.startSpan('background.job');
*
* try {
* const result = await runWithSpan(span, async () => {
* // Any spans created here will be children of 'background.job'
* await processData();
* return { success: true };
* });
* console.log(result);
* } finally {
* span.end();
* }
* ```
*
* @example Testing with custom spans
* ```typescript
* import { runWithSpan, otelTrace } from 'autotel';
*
* const tracer = otelTrace.getTracer('test');
* const span = tracer.startSpan('test.operation');
*
* const result = runWithSpan(span, () => {
* // Code under test runs with this span as active
* return myFunction();
* });
*
* span.end();
* ```
*/
export function runWithSpan<T>(span: Span, fn: () => T): T {
const ctx = trace.setSpan(context.active(), span);
return context.with(ctx, fn);
}
/**
* Finalize a span with appropriate status and optional error recording
*
* This is a convenience function that:
* - Records exceptions if an error is provided
* - Sets span status to ERROR if error exists, OK otherwise
* - Ends the span
*
* @param span - The span to finalize
* @param error - Optional error to record
*
* @example Without error (success case)
* ```typescript
* import { getTracer, finalizeSpan } from 'autotel';
*
* const tracer = getTracer('my-service');
* const span = tracer.startSpan('operation');
*
* try {
* await doWork();
* finalizeSpan(span);
* } catch (error) {
* finalizeSpan(span, error);
* throw error;
* }
* ```
*
* @example With error
* ```typescript
* import { getTracer, finalizeSpan } from 'autotel';
*
* const tracer = getTracer('my-service');
* const span = tracer.startSpan('operation');
*
* try {
* await riskyOperation();
* finalizeSpan(span);
* } catch (error) {
* finalizeSpan(span, error); // Records exception and sets ERROR status
* throw error;
* }
* ```
*
* @example In instrumentation
* ```typescript
* import { getTracer, runWithSpan, finalizeSpan } from 'autotel';
*
* function instrumentedQuery(query: string) {
* const tracer = getTracer('db');
* const span = tracer.startSpan('db.query');
*
* return runWithSpan(span, () => {
* try {
* const result = executeQuery(query);
* finalizeSpan(span);
* return result;
* } catch (error) {
* finalizeSpan(span, error);
* throw error;
* }
* });
* }
* ```
*/
export function finalizeSpan(span: Span, error?: unknown): void {
if (error) {
if (error instanceof Error) {
span.recordException(error);
} else {
span.recordException(new Error(String(error)));
}
span.setStatus({ code: SpanStatusCode.ERROR });
} else {
span.setStatus({ code: SpanStatusCode.OK });
}
span.end();
}
/**
* Creates a deterministic trace ID from a seed string.
*
* Generates a consistent 128-bit trace ID (32 hex characters) from an input seed
* using SHA-256 hashing. Useful for correlating external system IDs (request IDs,
* order IDs, session IDs) with OpenTelemetry trace IDs.
*
* **Use Cases:**
* - Correlate external request IDs with traces
* - Link customer support tickets to trace data
* - Associate business entities (orders, sessions) with observability data
* - Debug specific user flows by deterministic trace lookup
*
* **Important:** Only use this when you need deterministic trace IDs for correlation.
* For normal tracing, let OpenTelemetry generate random trace IDs automatically.
*
* **Runtime Support:**
* - Node.js 15+ (native crypto.subtle)
* - All modern browsers
* - Edge runtimes (Cloudflare Workers, Deno, etc.)
*
* @param seed - Input string to generate trace ID from (e.g., request ID, order ID)
* @returns Promise resolving to a 32-character hex trace ID (128 bits)
*
* @example Correlate external request ID with trace
* ```typescript
* import { createDeterministicTraceId } from 'autotel/trace-helpers'
* import { trace, context } from '@opentelemetry/api'
*
* // In middleware or request handler
* const requestId = req.headers['x-request-id']
* const traceId = await createDeterministicTraceId(requestId)
*
* // Use with manual span creation (advanced - not needed with trace/span functions)
* const tracer = trace.getTracer('my-service')
* const spanContext = {
* traceId,
* spanId: '0123456789abcdef', // Still random
* traceFlags: 1
* }
* ```
*
* @example Link customer support tickets to traces
* ```typescript
* import { createDeterministicTraceId } from 'autotel/trace-helpers'
*
* // Support dashboard integration
* const ticketId = 'TICKET-12345'
* const traceId = await createDeterministicTraceId(ticketId)
*
* // Generate direct link to traces in observability backend
* const traceUrl = `https://your-otel-backend.com/traces/${traceId}`
* console.log(`View related traces: ${traceUrl}`)
* ```
*
* @example Session-based correlation
* ```typescript
* import { createDeterministicTraceId } from 'autotel/trace-helpers'
*
* // Track all operations for a user session
* const sessionId = req.session.id
* const traceId = await createDeterministicTraceId(sessionId)
*
* // All operations in this session share the same trace ID
* // Makes it easy to find all activity for a specific session
* ```
*
* @public
*/
export async function createDeterministicTraceId(
seed: string,
): Promise<string> {
// Encode seed string to bytes
const encoder = new TextEncoder();
const data = encoder.encode(seed);
// Generate SHA-256 hash (256 bits)
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
// Convert to hex string and truncate to 32 characters (128 bits)
// OpenTelemetry trace IDs are 128 bits (16 bytes, 32 hex characters)
const hashArray = new Uint8Array(hashBuffer);
return [...hashArray]
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('')
.slice(0, 32);
}
/**
* Flattens nested metadata objects into dot-notation span attributes.
*
* Converts complex nested objects into flat key-value pairs suitable for
* OpenTelemetry span attributes. Non-string values are JSON serialized.
* Handles serialization failures gracefully with a fallback value.
*
* **Use Cases:**
* - Structured metadata with nested objects
* - User context with multiple properties
* - Request/response metadata
* - Business entity attributes
*
* **Note:** Filters out null/undefined values automatically to keep spans clean.
*
* @param metadata - Nested metadata object to flatten
* @param prefix - Prefix for all attribute keys (default: 'metadata')
* @returns Flattened attributes as { [key: string]: string }
*
* @example Basic metadata flattening
* ```typescript
* import { flattenMetadata } from 'autotel/trace-helpers'
* import { trace } from 'autotel'
*
* export const processOrder = trace(ctx => async (orderId: string) => {
* const order = await getOrder(orderId)
*
* // Flatten complex order metadata
* const flattened = flattenMetadata({
* user: { id: order.userId, tier: 'premium' },
* payment: { method: 'card', processor: 'stripe' },
* items: order.items.length
* })
*
* ctx.setAttributes(flattened)
* // Results in:
* // {
* // 'metadata.user.id': 'user-123',
* // 'metadata.user.tier': 'premium',
* // 'metadata.payment.method': 'card',
* // 'metadata.payment.processor': 'stripe',
* // 'metadata.items': '5'
* // }
* })
* ```
*
* @example Custom prefix for semantic conventions
* ```typescript
* import { flattenMetadata } from 'autotel/trace-helpers'
* import { trace } from 'autotel'
*
* export const fetchUser = trace(ctx => async (userId: string) => {
* const user = await db.users.findOne({ id: userId })
*
* // Use semantic convention prefix
* const userAttrs = flattenMetadata(
* {
* id: user.id,
* email: user.email,
* plan: user.subscription.plan
* },
* 'user' // Custom prefix
* )
*
* ctx.setAttributes(userAttrs)
* // Results in:
* // {
* // 'user.id': 'user-123',
* // 'user.email': 'user@example.com',
* // 'user.plan': 'enterprise'
* // }
* })
* ```
*
* @example With complex objects (auto-serialized)
* ```typescript
* import { flattenMetadata } from 'autotel/trace-helpers'
* import { trace } from 'autotel'
*
* export const analyzeRequest = trace(ctx => async (req: Request) => {
* const metadata = flattenMetadata({
* headers: req.headers, // Object - will be JSON serialized
* query: req.query, // Object - will be JSON serialized
* timestamp: new Date() // Non-string - will be JSON serialized
* })
*
* ctx.setAttributes(metadata)
* // Results in:
* // {
* // 'metadata.headers': '{"accept":"application/json",...}',
* // 'metadata.query': '{"page":"1","limit":"10"}',
* // 'metadata.timestamp': '"2024-01-15T12:00:00.000Z"'
* // }
* })
* ```
*
* @example Error handling
* ```typescript
* import { flattenMetadata } from 'autotel/trace-helpers'
*
* // Objects with circular references are handled gracefully
* const circular: any = { a: 1 }
* circular.self = circular
*
* const flattened = flattenMetadata({ data: circular })
* // Results in:
* // { 'metadata.data': '<serialization-failed>' }
* ```
*
* @public
*/
/**
* Resolve a trace URL from a template string and trace ID.
*
* Templates use `{traceId}` as placeholder. Falls back to `OTEL_TRACE_URL_TEMPLATE` env var.
*
* @example
* resolveTraceUrl('https://grafana.example.com/explore?traceId={traceId}', 'abc123')
* // => 'https://grafana.example.com/explore?traceId=abc123'
*/
export function resolveTraceUrl(
template: string | undefined,
traceId: string,
): string | undefined {
const t = template ?? process.env.OTEL_TRACE_URL_TEMPLATE;
if (!t) return undefined;
return t.replace(/\{traceId\}/g, traceId);
}
export function flattenMetadata(
metadata: Record<string, unknown>,
prefix = 'metadata',
): Record<string, string> {
const flattened: Record<string, string> = {};
const seen = new WeakSet<object>(); // Track visited objects to detect cycles
function flatten(obj: Record<string, unknown>, currentPrefix: string): void {
for (const [key, value] of Object.entries(obj)) {
// Skip null/undefined values
if (value == null) continue;
const attributeKey = `${currentPrefix}.${key}`;
// Handle primitives directly (string, number, boolean)
if (typeof value === 'string') {
flattened[attributeKey] = value;
continue;
}
if (typeof value === 'number' || typeof value === 'boolean') {
flattened[attributeKey] = String(value);
continue;
}
// Recursively flatten plain objects (with cycle detection)
if (
typeof value === 'object' &&
value !== null &&
value.constructor === Object
) {
// Detect circular references
if (seen.has(value)) {
flattened[attributeKey] = '<circular-reference>';
continue;
}
// Mark as visited and recursively flatten
seen.add(value);
flatten(value as Record<string, unknown>, attributeKey);
continue;
}
// Serialize arrays and other non-plain objects to JSON
try {
flattened[attributeKey] = JSON.stringify(value);
} catch {
// Handle circular references or non-serializable objects
flattened[attributeKey] = '<serialization-failed>';
}
}
}
flatten(metadata, prefix);
return flattened;
}