UNPKG

autotel-cloudflare

Version:

The #1 OpenTelemetry package for Cloudflare Workers - complete bindings coverage, native CF OTel integration, advanced sampling

729 lines (649 loc) 22.3 kB
/** * Handler instrumentation for Cloudflare Workers * * Note: This file uses Cloudflare Workers types (ExportedHandler, Request, Response, etc.) * which are globally available via @cloudflare/workers-types when listed in tsconfig.json. * These types are devDependencies only - they're not runtime dependencies. * At runtime, Cloudflare Workers runtime provides the actual implementations. * * Provides automatic OpenTelemetry tracing for: * - HTTP handlers (fetch) * - Scheduled/cron handlers * - Queue handlers (with message tracking) * - Email handlers * - Auto-instrumentation of Cloudflare bindings (KV, R2, D1, Service Bindings) * - Global fetch and cache instrumentation * - Post-processor support for span customization * - Tail sampling support * - Cold start tracking */ import { trace, context as api_context, propagation, SpanStatusCode, SpanKind, } from '@opentelemetry/api'; import { resourceFromAttributes } from '@opentelemetry/resources'; import type { ConfigurationOption, ResolvedEdgeConfig, Trigger, HandlerInstrumentation, InitialSpanInfo, ReadableSpan, RouteServiceConfig, } from 'autotel-edge'; import { createInitialiser, setConfig, getActiveConfig, getServiceForPath, shouldInstrumentPath, type Initialiser, WorkerTracerProvider, WorkerTracer, } from 'autotel-edge'; import { proxyExecutionContext, unwrap, wrap, type PromiseTracker } from '../bindings/common'; import { instrumentGlobalFetch } from '../global/fetch'; import { instrumentGlobalCache } from '../global/cache'; import { instrumentBindings } from '../bindings/bindings'; import type { Attributes, Span, TextMapGetter } from '@opentelemetry/api'; // Web API Headers objects require .get() — property access via defaultTextMapGetter won't work const headersGetter: TextMapGetter<Headers> = { get: (carrier, key) => carrier.get(key) ?? undefined, keys: (carrier) => [...carrier.keys()], }; type FetchHandler = ( request: Request, env: any, ctx: ExecutionContext, ) => Response | Promise<Response>; type ScheduledHandler = ( event: ScheduledController, env: any, ctx: ExecutionContext, ) => void | Promise<void>; type QueueHandler = ( batch: MessageBatch, env: any, ctx: ExecutionContext, ) => void | Promise<void>; type EmailHandler = ( message: ForwardableEmailMessage, env: any, ctx: ExecutionContext, ) => void | Promise<void>; /** * Create fetch handler instrumentation with config support for postProcess */ /** * Extract Cloudflare-specific attributes from a request */ function extractCfAttributes(request: Request): Record<string, string | number | boolean> { const cf = (request as any).cf; if (!cf) return {}; const attrs: Record<string, string | number | boolean> = {}; const set = (key: string, value: unknown) => { if (value !== undefined && value !== null) { attrs[key] = value as string | number | boolean; } }; set('cloudflare.colo', cf.colo); const ray = request.headers.get('cf-ray'); if (ray) attrs['cloudflare.ray_id'] = ray; set('cloudflare.country', cf.country); set('cloudflare.city', cf.city); set('cloudflare.region', cf.region); set('cloudflare.continent', cf.continent); set('cloudflare.timezone', cf.timezone); set('cloudflare.latitude', cf.latitude); set('cloudflare.longitude', cf.longitude); set('cloudflare.asn', cf.asn); set('cloudflare.as_organization', cf.asOrganization); set('cloudflare.http_protocol', cf.httpProtocol); set('cloudflare.tls_version', cf.tlsVersion); set('cloudflare.client_tcp_rtt', cf.clientTcpRtt); return attrs; } function createFetchInstrumentation( config: ResolvedEdgeConfig, ): HandlerInstrumentation<Request, Response> { return { getInitialSpanInfo: (request: Request): InitialSpanInfo => { const url = new URL(request.url); const routeService = getServiceForPath( url.pathname, config.handlers.fetch.routes as Record<string, RouteServiceConfig> | undefined, ); const cfAttrs = (config as any).extractCfAttributes === false ? {} : extractCfAttributes(request); return { name: `${request.method} ${url.pathname}`, options: { kind: SpanKind.SERVER, attributes: { 'http.request.method': request.method, 'url.full': request.url, ...(routeService ? { 'service.name': routeService, 'autotel.route.service': routeService } : {}), ...cfAttrs, }, }, context: propagation.extract(api_context.active(), request.headers, headersGetter), }; }, getAttributesFromResult: (response: Response) => ({ 'http.response.status_code': response.status, }), executionSucces: (span: Span, trigger: Request, result: Response) => { // Override span status for server errors (5xx) if (result.status >= 500) { span.setStatus({ code: SpanStatusCode.ERROR }); } // Call postProcess callback if configured if (config.handlers.fetch.postProcess) { const readableSpan = span as unknown as ReadableSpan; config.handlers.fetch.postProcess(span, { request: trigger, response: result, readable: readableSpan, }); } }, }; } /** * Scheduled handler instrumentation */ const scheduledInstrumentation: HandlerInstrumentation<ScheduledController, void> = { getInitialSpanInfo: (event: ScheduledController): InitialSpanInfo => { return { name: `scheduledHandler ${event.cron || 'unknown'}`, options: { kind: SpanKind.INTERNAL, attributes: { 'faas.trigger': 'timer', 'faas.cron': event.cron || 'unknown', 'faas.scheduled_time': new Date(event.scheduledTime).toISOString(), }, }, }; }, }; /** * Tracks message status counts for queue processing */ class MessageStatusCount { succeeded = 0; failed = 0; implicitly_acked = 0; implicitly_retried = 0; readonly total: number; constructor(total: number) { this.total = total; } ack() { this.succeeded = this.succeeded + 1; } ackRemaining() { this.implicitly_acked = this.total - this.succeeded - this.failed; this.succeeded = this.total - this.failed; } retry() { this.failed = this.failed + 1; } retryRemaining() { this.implicitly_retried = this.total - this.succeeded - this.failed; this.failed = this.total - this.succeeded; } toAttributes(): Attributes { return { 'queue.messages_count': this.total, 'queue.messages_success': this.succeeded, 'queue.messages_failed': this.failed, 'queue.batch_success': this.succeeded === this.total, 'queue.implicitly_acked': this.implicitly_acked, 'queue.implicitly_retried': this.implicitly_retried, }; } } /** * Add event to active span */ function addQueueEvent(name: string, msg?: Message, delaySeconds?: number) { const attrs: Attributes = {}; if (msg) { attrs['queue.message_id'] = msg.id; attrs['queue.message_timestamp'] = msg.timestamp.toISOString(); // Add attempts if available (from Cloudflare Queues API) if ('attempts' in msg && typeof msg.attempts === 'number') { attrs['queue.message_attempts'] = msg.attempts; } } if (delaySeconds !== undefined) { attrs['queue.retry_delay_seconds'] = delaySeconds; } trace.getActiveSpan()?.addEvent(name, attrs); } /** * Proxy a queue message to track ack/retry operations */ function proxyQueueMessage<Q>(msg: Message<Q>, count: MessageStatusCount): Message<Q> { const msgHandler: ProxyHandler<Message<Q>> = { get: (target, prop) => { if (prop === 'ack') { const ackFn = Reflect.get(target, prop); return new Proxy(ackFn, { apply: (fnTarget) => { addQueueEvent('messageAck', msg); count.ack(); Reflect.apply(fnTarget, msg, []); }, }); } else if (prop === 'retry') { const retryFn = Reflect.get(target, prop); return new Proxy(retryFn, { apply: (fnTarget, _thisArg, args) => { // Extract delay and content type from retry options if provided const retryOptions = args[0] as | { delaySeconds?: number; contentType?: string } | undefined; const delaySeconds = retryOptions?.delaySeconds; addQueueEvent('messageRetry', msg, delaySeconds); // Add content type attribute if provided if (retryOptions?.contentType) { const span = trace.getActiveSpan(); if (span) { span.setAttribute('queue.message.content_type', retryOptions.contentType); } } count.retry(); const result = Reflect.apply(fnTarget, msg, args); return result; }, }); } else { return Reflect.get(target, prop, msg); } }, }; return wrap(msg, msgHandler); } /** * Proxy MessageBatch to track ackAll/retryAll operations */ function proxyMessageBatch(batch: MessageBatch, count: MessageStatusCount): MessageBatch { const batchHandler: ProxyHandler<MessageBatch> = { get: (target, prop) => { if (prop === 'messages') { const messages = Reflect.get(target, prop); const messagesHandler: ProxyHandler<MessageBatch['messages']> = { get: (target, prop) => { if (typeof prop === 'string' && !isNaN(parseInt(prop))) { const message = Reflect.get(target, prop); return proxyQueueMessage(message, count); } else { return Reflect.get(target, prop); } }, }; return wrap(messages, messagesHandler); } else if (prop === 'ackAll') { const ackFn = Reflect.get(target, prop); return new Proxy(ackFn, { apply: (fnTarget) => { addQueueEvent('ackAll'); count.ackRemaining(); Reflect.apply(fnTarget, batch, []); }, }); } else if (prop === 'retryAll') { const retryFn = Reflect.get(target, prop); return new Proxy(retryFn, { apply: (fnTarget, _thisArg, args) => { // Extract delay from retryAll options if provided const retryOptions = args[0] as { delaySeconds?: number } | undefined; const delaySeconds = retryOptions?.delaySeconds; addQueueEvent('retryAll', undefined, delaySeconds); count.retryRemaining(); Reflect.apply(fnTarget, batch, args); }, }); } return Reflect.get(target, prop); }, }; return wrap(batch, batchHandler); } /** * Queue handler instrumentation with message tracking */ class QueueInstrumentation implements HandlerInstrumentation<MessageBatch, void> { private count?: MessageStatusCount; getInitialSpanInfo(batch: MessageBatch): InitialSpanInfo { return { name: `queueHandler ${batch.queue || 'unknown'}`, options: { kind: SpanKind.CONSUMER, attributes: { 'faas.trigger': 'pubsub', 'queue.name': batch.queue || 'unknown', }, }, }; } instrumentTrigger(batch: MessageBatch): MessageBatch { this.count = new MessageStatusCount(batch.messages.length); return proxyMessageBatch(batch, this.count); } executionSucces(span: Span, _trigger: MessageBatch, _result: void) { if (this.count) { this.count.ackRemaining(); span.setAttributes(this.count.toAttributes()); } } executionFailed(span: Span, _trigger: MessageBatch, _error?: any) { if (this.count) { this.count.retryRemaining(); span.setAttributes(this.count.toAttributes()); } } } /** * Converts email headers into OpenTelemetry attributes. * When dataSafety.emailHeaderAllowlist is configured, only allowed headers are captured. */ function headerAttributes(message: { headers: Headers }): Record<string, string> { const attrs: Record<string, string> = {}; if (message.headers instanceof Headers) { const config = getActiveConfig(); const allowlist: string[] | undefined = config?.dataSafety?.emailHeaderAllowlist; for (const [key, value] of message.headers.entries()) { if (allowlist && !allowlist.includes(key.toLowerCase())) { continue; } attrs[`email.header.${key}`] = value; } } return attrs; } /** * Email handler instrumentation */ const emailInstrumentation: HandlerInstrumentation<ForwardableEmailMessage, void> = { getInitialSpanInfo: (message: ForwardableEmailMessage): InitialSpanInfo => { const attributes: Record<string, string> = { 'faas.trigger': 'other', 'messaging.destination.name': message.to || 'unknown', }; // Add message ID if available if ('headers' in message && message.headers instanceof Headers) { const messageId = message.headers.get('Message-Id'); if (messageId) { attributes['rpc.message.id'] = messageId; } // Add all headers as attributes Object.assign(attributes, headerAttributes(message)); } return { name: `emailHandler ${message.to || 'unknown'}`, options: { kind: SpanKind.CONSUMER, attributes, }, }; }, }; /** * Export spans after request completes */ async function exportSpans( traceId: string, tracker: PromiseTracker | undefined, ctx: ExecutionContext, ) { const tracer = trace.getTracer('autotel-edge'); if (tracer instanceof WorkerTracer) { try { // scheduler is available on ExecutionContext at runtime const ctxWithScheduler = ctx as ExecutionContext & { scheduler?: { wait(ms: number): Promise<void> } }; if (ctxWithScheduler.scheduler) { await ctxWithScheduler.scheduler.wait(1); } await tracker?.wait(); await tracer.forceFlush(traceId); } catch (error) { // Silently handle exporter errors to prevent worker crashes // Exporter failures should not affect the worker's ability to process requests // In production, consider logging to a monitoring service console.error('[autotel-edge] Failed to export spans:', error); } } } /** * Create handler flow with instrumentation */ function createHandlerFlow<T extends Trigger, E, R>( instrumentation: HandlerInstrumentation<T, R>, ) { return ( handlerFn: (trigger: T, env: E, ctx: ExecutionContext) => R | Promise<R>, [trigger, env, context]: [T, E, ExecutionContext], ) => { const { ctx: proxiedCtx, tracker } = proxyExecutionContext(context); const tracer = trace.getTracer('autotel-edge') as WorkerTracer; const { name, options, context: spanContext } = instrumentation.getInitialSpanInfo(trigger); // Add cold start tracking if (options.attributes) { options.attributes['faas.coldstart'] = coldStart; } else { options.attributes = { 'faas.coldstart': coldStart }; } coldStart = false; const parentContext = spanContext || api_context.active(); // Instrument trigger if supported (e.g., for queue handler) const instrumentedTrigger = instrumentation.instrumentTrigger ? instrumentation.instrumentTrigger(trigger) : trigger; return tracer.startActiveSpan(name, options, parentContext, async (span) => { try { const result = await handlerFn(instrumentedTrigger, env, proxiedCtx); if (instrumentation.getAttributesFromResult) { const attributes = instrumentation.getAttributesFromResult(result); span.setAttributes(attributes); } // Set default OK status; executionSucces may override (e.g. HTTP 5xx) span.setStatus({ code: SpanStatusCode.OK }); if (instrumentation.executionSucces) { instrumentation.executionSucces(span, trigger, result); } return result; } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); if (instrumentation.executionFailed) { instrumentation.executionFailed(span, trigger, error); } throw error; } finally { span.end(); context.waitUntil(exportSpans(span.spanContext().traceId, tracker, context)); } }); }; } /** * Create handler proxy */ function createHandlerProxy<T extends Trigger, E, R>( _handler: unknown, handlerFn: (trigger: T, env: E, ctx: ExecutionContext) => R | Promise<R>, initialiser: Initialiser, instrumentation: HandlerInstrumentation<T, R>, ): (trigger: T, env: E, ctx: ExecutionContext) => ReturnType<typeof handlerFn> { return (trigger: T, env: E, ctx: ExecutionContext) => { const config = initialiser(env, trigger); // Check if instrumentation is disabled (useful for local dev) if (config.instrumentation.disabled) { // Return handler as-is without instrumentation return handlerFn(trigger, env, ctx); } // Auto-instrument Cloudflare bindings in the environment const instrumentedEnv = instrumentBindings(env as Record<string, any>) as E; const configContext = setConfig(config); // Initialize provider on first call initProvider(config); const flowFn = createHandlerFlow<T, E, R>(instrumentation); // Execute the handler flow within the config context return api_context.with(configContext, () => { return flowFn(handlerFn, [trigger, instrumentedEnv, ctx]) as ReturnType<typeof handlerFn>; }); }; } /** * Create handler proxy with dynamic instrumentation (for fetch with postProcess) */ function createHandlerProxyWithConfig<T extends Trigger, E, R>( _handler: unknown, handlerFn: (trigger: T, env: E, ctx: ExecutionContext) => R | Promise<R>, initialiser: Initialiser, createInstrumentation: (config: ResolvedEdgeConfig) => HandlerInstrumentation<T, R>, ): (trigger: T, env: E, ctx: ExecutionContext) => ReturnType<typeof handlerFn> { return (trigger: T, env: E, ctx: ExecutionContext) => { const config = initialiser(env, trigger); // Check if instrumentation is disabled (useful for local dev) if (config.instrumentation.disabled) { // Return handler as-is without instrumentation return handlerFn(trigger, env, ctx); } if (trigger instanceof Request) { const pathname = new URL(trigger.url).pathname; const fetchCfg = config.handlers.fetch; if ( !shouldInstrumentPath(pathname, { include: fetchCfg.include, exclude: fetchCfg.exclude, }) ) { return handlerFn(trigger, env, ctx) as ReturnType<typeof handlerFn>; } } // Auto-instrument Cloudflare bindings in the environment const instrumentedEnv = instrumentBindings(env as Record<string, any>) as E; const configContext = setConfig(config); // Initialize provider on first call initProvider(config); // Create instrumentation with config const instrumentation = createInstrumentation(config); const flowFn = createHandlerFlow<T, E, R>(instrumentation); // Execute the handler flow within the config context return api_context.with(configContext, () => { return flowFn(handlerFn, [trigger, instrumentedEnv, ctx]) as ReturnType<typeof handlerFn>; }); }; } let providerInitialized = false; let coldStart = true; /** * Initialize the tracer provider */ function initProvider(config: ResolvedEdgeConfig): void { if (providerInitialized) return; // Install global instrumentations if (config.instrumentation.instrumentGlobalFetch) { instrumentGlobalFetch(); } if (config.instrumentation.instrumentGlobalCache) { instrumentGlobalCache(); } // Set up propagator propagation.setGlobalPropagator(config.propagator); // Create resource const resource = resourceFromAttributes({ 'service.name': config.service.name, 'service.version': config.service.version, 'service.namespace': config.service.namespace, 'cloud.provider': 'cloudflare', 'cloud.platform': 'cloudflare.workers', 'telemetry.sdk.name': 'autotel-edge', 'telemetry.sdk.language': 'js', }); // Create and register provider const provider = new WorkerTracerProvider(config.spanProcessors, resource); provider.register(); // Set head sampler on tracer const tracer = trace.getTracer('autotel-edge') as WorkerTracer; tracer.setHeadSampler(config.sampling.headSampler); providerInitialized = true; } /** * Instrument a Cloudflare Workers handler * * @example * ```typescript * import { instrument } from 'autotel-edge' * * const handler = { * async fetch(request, env, ctx) { * return new Response('Hello World') * } * } * * export default instrument(handler, { * exporter: { * url: env.OTLP_ENDPOINT, * headers: { 'x-api-key': env.API_KEY } * }, * service: { name: 'my-worker' } * }) * ``` */ export function instrument<E, Q = any, C = any>( handler: ExportedHandler<E, Q, C>, config: ConfigurationOption, ): ExportedHandler<E, Q, C> { const initialiser = createInitialiser(config); if (handler.fetch) { const fetcher = unwrap(handler.fetch) as FetchHandler; // Create fetch instrumentation with config support handler.fetch = createHandlerProxyWithConfig( handler, fetcher, initialiser, createFetchInstrumentation, ); } if (handler.scheduled) { const scheduled = unwrap(handler.scheduled) as ScheduledHandler; handler.scheduled = createHandlerProxy( handler, scheduled, initialiser, scheduledInstrumentation, ); } if (handler.queue) { const queue = unwrap(handler.queue) as QueueHandler; handler.queue = createHandlerProxy( handler, queue, initialiser, new QueueInstrumentation(), ); } if (handler.email) { const email = unwrap(handler.email) as EmailHandler; handler.email = createHandlerProxy( handler, email, initialiser, emailInstrumentation, ); } return handler; }