autotel
Version:
Write Once, Observe Anywhere
540 lines (491 loc) • 17.7 kB
text/typescript
/**
* Trace context types and utilities
*/
import type {
Span,
SpanStatusCode,
BaggageEntry,
Context,
Link,
TimeInput,
} from '@opentelemetry/api';
import { context, propagation } from '@opentelemetry/api';
import { AsyncLocalStorage } from 'node:async_hooks';
type AsyncLocalBox<T> = {
value: T;
};
/**
* AsyncLocalStorage for storing the active context with baggage
* This allows setters to update the context and have it persist
*/
const contextStorage = new AsyncLocalStorage<AsyncLocalBox<Context>>();
/**
* Get the context storage instance (for initialization in functional.ts)
*/
export function getContextStorage(): AsyncLocalStorage<AsyncLocalBox<Context>> {
return contextStorage;
}
/**
* Get the active context, checking our stored context first
* This ensures baggage setters work with OpenTelemetry's propagation
*/
export function getActiveContextWithBaggage(): Context {
// Check stored context first (from setters), then fall back to active context
// This ensures ctx.setBaggage() changes are visible to OpenTelemetry operations
const stored = contextStorage.getStore()?.value;
return stored ?? context.active();
}
/**
* Set a value in AsyncLocalStorage, preferring enterWith() when available
* (Node.js) and falling back to run() for environments that only support
* run() (e.g. Cloudflare Workers).
*
* On runtimes without enterWith() we mutate the existing run() scope when one
* exists. This is what allows baggage/correlation updates to remain visible
* for the rest of the traced callback in Workers.
*/
export function enterOrRun<T>(
storage: AsyncLocalStorage<AsyncLocalBox<T>>,
value: T,
): void {
const existingStore = storage.getStore();
if (existingStore) {
existingStore.value = value;
return;
}
const boxedValue = { value };
try {
storage.enterWith(boxedValue);
} catch {
// Cloudflare Workers define enterWith but throw at runtime
storage.run(boxedValue, () => {});
}
}
/**
* Try to keep OpenTelemetry's context manager in sync with baggage updates
*/
type ContextManagerLike = {
with?: (ctx: Context, fn: () => void) => void;
_asyncLocalStorage?: { enterWith?: (ctx: Context) => void };
};
function updateActiveContext(newContext: Context): void {
// Update our storage first so any helper reads see the new context
enterOrRun(contextStorage, newContext);
const contextWithManager = context as unknown as {
_getContextManager?: () => ContextManagerLike;
};
const manager = contextWithManager._getContextManager?.();
if (!manager) return;
const asyncLocal =
(manager as { _asyncLocalStorage?: { enterWith?: (ctx: Context) => void } })
._asyncLocalStorage ?? undefined;
if (asyncLocal?.enterWith) {
asyncLocal.enterWith(newContext);
return;
}
if (typeof manager.with === 'function') {
manager.with(newContext, () => {});
}
}
/**
* Base trace context containing trace identifiers
*/
export interface TraceContextBase {
traceId: string;
spanId: string;
correlationId: string;
}
/**
* Attribute value types following OpenTelemetry specification.
* Supports primitive values and arrays of homogeneous primitives.
*/
export type AttributeValue =
| string
| number
| boolean
| string[]
| number[]
| boolean[];
/**
* Span methods available on trace context
*/
export interface SpanMethods {
/** Set a single attribute on the span */
setAttribute(key: string, value: AttributeValue): void;
/** Set multiple attributes on the span */
setAttributes(attrs: Record<string, AttributeValue>): void;
/** Set the status of the span */
setStatus(status: { code: SpanStatusCode; message?: string }): void;
/** Record an exception on the span */
recordException(exception: Error, time?: TimeInput): void;
/** Add an event to the span (for logging milestones/checkpoints) */
addEvent(
name: string,
attributesOrStartTime?: Record<string, AttributeValue> | TimeInput,
startTime?: TimeInput,
): void;
/** Add a link to another span */
addLink(link: Link): void;
/** Add multiple links to other spans */
addLinks(links: Link[]): void;
/** Update the span name dynamically */
updateName(name: string): void;
/** Check if the span is recording */
isRecording(): boolean;
}
/**
* Baggage methods available on trace context
*
* @template TBaggage - Optional type for typed baggage (defaults to undefined for untyped)
*/
export interface BaggageMethods<
TBaggage extends Record<string, unknown> | undefined = undefined,
> {
/**
* Get a baggage entry by key
* @param key - Baggage key
* @returns Baggage entry value or undefined
*/
getBaggage(key: string): string | undefined;
/**
* Set a baggage entry
*
* Note: OpenTelemetry contexts are immutable. For proper scoping across async
* boundaries, use withBaggage() instead. This method updates baggage in the
* current context which may not propagate to all child operations.
*
* @param key - Baggage key
* @param value - Baggage value
* @returns The baggage value that was set (for chaining)
*
* @example Using withBaggage() (recommended)
* ```typescript
* await withBaggage({ baggage: { 'key': 'value' }, fn: async () => {
* // Baggage is available here and in child spans
* });
* ```
*/
setBaggage(key: string, value: string): string;
/**
* Delete a baggage entry
*
* Note: OpenTelemetry contexts are immutable. For proper scoping across async
* boundaries, use withBaggage() with only the entries you want instead.
*
* @param key - Baggage key
*/
deleteBaggage(key: string): void;
/**
* Get all baggage entries
* @returns Map of all baggage entries
*/
getAllBaggage(): Map<string, BaggageEntry>;
/**
* Get typed baggage (only available when TBaggage is defined)
* This is used internally by defineBaggageSchema()
*
* @internal
*/
getTypedBaggage?: TBaggage extends Record<string, unknown>
? <T extends TBaggage>(namespace?: string) => Partial<T> | undefined
: never;
/**
* Set typed baggage (only available when TBaggage is defined)
* This is used internally by defineBaggageSchema()
*
* @internal
*/
setTypedBaggage?: TBaggage extends Record<string, unknown>
? <T extends TBaggage>(
namespace: string | undefined,
value: Partial<T>,
) => void
: never;
}
/**
* Complete trace context that merges base context, span methods, and baggage methods
*
* This is the ctx parameter passed to factory functions in trace().
* It provides access to trace IDs, span manipulation methods, and baggage operations.
*
* @template TBaggage - Optional type for typed baggage support
*
* @example Untyped (default)
* ```typescript
* export const handler = trace((ctx) => async () => {
* ctx.getBaggage('key'); // returns string | undefined
* });
* ```
*
* @example Typed baggage
* ```typescript
* type TenantBaggage = { tenantId: string; region?: string };
*
* export const handler = trace<TenantBaggage>((ctx) => async () => {
* // Use typed schema helper for type-safe access
* const schema = defineBaggageSchema<TenantBaggage>('tenant');
* const tenant = schema.get(ctx); // Partial<TenantBaggage> | undefined
* });
* ```
*/
export type TraceContext<
TBaggage extends Record<string, unknown> | undefined = undefined,
> = TraceContextBase & SpanMethods & BaggageMethods<TBaggage>;
/**
* Create a TraceContext from an OpenTelemetry Span
*
* This utility extracts trace context information from a span
* and provides span manipulation methods and baggage operations in a consistent format.
*
* Note: Baggage methods always operate on the currently active context,
* which may differ from the context when createTraceContext was called.
*/
export function createTraceContext<
TBaggage extends Record<string, unknown> | undefined = undefined,
>(span: Span): TraceContext<TBaggage> {
const spanContext = span.spanContext();
// Store the current active context in AsyncLocalStorage so baggage setters can update it
// This ensures ctx.setBaggage() changes persist and are visible to OpenTelemetry operations
// IMPORTANT: Only initialize if not already set (preserve baggage updates from parent spans)
const existingStored = contextStorage.getStore()?.value;
if (!existingStored) {
const activeContext = context.active();
enterOrRun(contextStorage, activeContext);
}
// Baggage helpers that always use the current active context
// This ensures baggage operations work correctly even if context changes
const baggageHelpers: BaggageMethods<TBaggage> = {
getBaggage(key: string): string | undefined {
// Check active context first (from withBaggage, context.with, etc.)
// Then check stored context (from setters)
// This ensures both withBaggage() and ctx.setBaggage() work correctly
const activeCtx = context.active();
let baggage = propagation.getBaggage(activeCtx);
if (!baggage) {
const storedContext = contextStorage.getStore()?.value;
if (storedContext) {
baggage = propagation.getBaggage(storedContext);
}
}
return baggage?.getEntry(key)?.value;
},
setBaggage(key: string, value: string): string {
// OpenTelemetry contexts are immutable, so we create a new context with updated baggage
// Check active context first (may have baggage from withBaggage), then stored context
const activeCtx = context.active();
const storedContext = contextStorage.getStore()?.value;
const currentContext = storedContext ?? activeCtx;
const baggage =
propagation.getBaggage(currentContext) ?? propagation.createBaggage();
const updated = baggage.setEntry(key, { value });
const newContext = propagation.setBaggage(currentContext, updated);
updateActiveContext(newContext);
return value;
},
deleteBaggage(key: string): void {
// Check active context first, then stored context
const activeCtx = context.active();
const storedContext = contextStorage.getStore()?.value;
const currentContext = storedContext ?? activeCtx;
const baggage = propagation.getBaggage(currentContext);
if (baggage) {
const updated = baggage.removeEntry(key);
const newContext = propagation.setBaggage(currentContext, updated);
updateActiveContext(newContext);
}
},
getAllBaggage(): Map<string, BaggageEntry> {
// Check active context first, then stored context
const activeCtx = context.active();
let baggage = propagation.getBaggage(activeCtx);
if (!baggage) {
const storedContext = contextStorage.getStore()?.value;
if (storedContext) {
baggage = propagation.getBaggage(storedContext);
}
}
if (!baggage) {
return new Map();
}
// Convert baggage entries to a Map
const entries = new Map<string, BaggageEntry>();
for (const [key, entry] of baggage.getAllEntries()) {
entries.set(key, entry);
}
return entries;
},
// Typed baggage helpers (used by defineBaggageSchema)
getTypedBaggage: (<T extends Record<string, unknown>>(
namespace?: string,
) => {
// Check active context first, then stored context
const activeCtx = context.active();
let baggage = propagation.getBaggage(activeCtx);
if (!baggage) {
const storedContext = contextStorage.getStore()?.value;
if (storedContext) {
baggage = propagation.getBaggage(storedContext);
}
}
if (!baggage) return;
const prefix = namespace ? `${namespace}.` : '';
const result: Record<string, unknown> = {};
for (const [key, entry] of baggage.getAllEntries()) {
if (namespace && key.startsWith(prefix)) {
const fieldName = key.slice(prefix.length);
result[fieldName] = entry.value;
} else if (!namespace) {
result[key] = entry.value;
}
}
return Object.keys(result).length > 0
? (result as Partial<T>)
: undefined;
}) as TBaggage extends Record<string, unknown>
? <T extends TBaggage>(namespace?: string) => Partial<T> | undefined
: never,
setTypedBaggage: (<T extends Record<string, unknown>>(
namespace: string | undefined,
value: Partial<T>,
) => {
// Check active context first, then stored context
const activeCtx = context.active();
const storedContext = contextStorage.getStore()?.value;
const currentContext = storedContext ?? activeCtx;
let baggage =
propagation.getBaggage(currentContext) ?? propagation.createBaggage();
const prefix = namespace ? `${namespace}.` : '';
for (const [key, val] of Object.entries(value)) {
if (val !== undefined) {
const baggageKey = `${prefix}${key}`;
baggage = baggage.setEntry(baggageKey, { value: String(val) });
}
}
const newContext = propagation.setBaggage(currentContext, baggage);
updateActiveContext(newContext);
}) as TBaggage extends Record<string, unknown>
? <T extends TBaggage>(
namespace: string | undefined,
value: Partial<T>,
) => void
: never,
};
return {
traceId: spanContext.traceId,
spanId: spanContext.spanId,
correlationId: spanContext.traceId.slice(0, 16),
setAttribute: span.setAttribute.bind(span),
setAttributes: span.setAttributes.bind(span),
setStatus: span.setStatus.bind(span),
recordException: span.recordException.bind(span),
addEvent: span.addEvent.bind(span),
addLink: span.addLink.bind(span),
addLinks: span.addLinks.bind(span),
updateName: span.updateName.bind(span),
isRecording: span.isRecording.bind(span),
...baggageHelpers,
};
}
/**
* Define a typed baggage schema for type-safe baggage operations
*
* This helper provides a type-safe API for working with baggage entries.
* The namespace parameter is optional and prefixes all keys to avoid collisions.
*
* @template T - The baggage schema type (all fields are treated as optional)
* @param namespace - Optional namespace to prefix baggage keys
*
* @example Basic usage
* ```typescript
* type TenantBaggage = { tenantId: string; region?: string };
* const tenantBaggage = defineBaggageSchema<TenantBaggage>('tenant');
*
* export const handler = trace<TenantBaggage>((ctx) => async () => {
* // Get typed baggage
* const tenant = tenantBaggage.get(ctx);
* if (tenant?.tenantId) {
* console.log('Tenant:', tenant.tenantId);
* }
*
* // Set typed baggage
* tenantBaggage.set(ctx, { tenantId: 't1', region: 'us-east-1' });
* });
* ```
*
* @example With withBaggage helper
* ```typescript
* const tenantBaggage = defineBaggageSchema<TenantBaggage>('tenant');
*
* export const handler = trace<TenantBaggage>((ctx) => async () => {
* return await tenantBaggage.with(ctx, { tenantId: 't1' }, async () => {
* // Baggage is available here and in child spans
* const tenant = tenantBaggage.get(ctx);
* });
* });
* ```
*/
export function defineBaggageSchema<T extends Record<string, unknown>>(
namespace?: string,
) {
return {
/**
* Get typed baggage from context
* @param ctx - Trace context
* @returns Partial baggage object or undefined if no baggage is set
*/
get: (ctx: TraceContext<T>): Partial<T> | undefined => {
if (!ctx.getTypedBaggage) return undefined;
return ctx.getTypedBaggage<T>(namespace);
},
/**
* Set typed baggage in context
*
* Note: For proper scoping across async boundaries, use the `with` method instead
*
* @param ctx - Trace context
* @param value - Partial baggage object to set
*/
set: (ctx: TraceContext<T>, value: Partial<T>): void => {
if (!ctx.setTypedBaggage) return;
ctx.setTypedBaggage<T>(namespace, value);
},
/**
* Run a function with typed baggage properly scoped
*
* This is the recommended way to set baggage as it ensures proper
* scoping across async boundaries.
*
* @param ctx - Trace context (can be omitted, will use active context)
* @param value - Partial baggage object to set
* @param fn - Function to execute with the baggage
*/
with: <R>(
ctxOrValue: TraceContext<T> | Partial<T>,
valueOrFn: Partial<T> | (() => R | Promise<R>),
maybeFn?: () => R | Promise<R>,
): R | Promise<R> => {
// Support both with(ctx, value, fn) and with(value, fn)
const value = maybeFn
? (valueOrFn as Partial<T>)
: (ctxOrValue as Partial<T>);
const fn = maybeFn || (valueOrFn as () => R | Promise<R>);
// Serialize typed baggage to flat key-value pairs
const prefix = namespace ? `${namespace}.` : '';
const flatBaggage: Record<string, string> = {};
for (const [key, val] of Object.entries(value)) {
if (val !== undefined) {
flatBaggage[`${prefix}${key}`] = String(val);
}
}
// Use the existing withBaggage helper
const currentContext = context.active();
let baggage =
propagation.getBaggage(currentContext) ?? propagation.createBaggage();
for (const [key, val] of Object.entries(flatBaggage)) {
baggage = baggage.setEntry(key, { value: val });
}
const newContext = propagation.setBaggage(currentContext, baggage);
return context.with(newContext, fn);
},
};
}