UNPKG

autotel-cloudflare

Version:

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

346 lines (313 loc) 10.4 kB
/** * Cloudflare Workflows instrumentation for autotel-edge * * Instruments WorkflowEntrypoint classes to automatically trace workflow execution, * step operations, retries, and sleeps. * * Based on Cloudflare Workflows API: * https://developers.cloudflare.com/workflows/ */ import { trace, context as api_context, SpanStatusCode, SpanKind, } from '@opentelemetry/api'; import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'; import type { ConfigurationOption, WorkflowTrigger } from 'autotel-edge'; import { createInitialiser, setConfig, WorkerTracer } from 'autotel-edge'; import { wrap } from '../bindings/common'; type WorkflowRunFn = ( event: Readonly<WorkflowEvent<unknown>>, step: WorkflowStep, ) => Promise<unknown> | void; /** * Track cold starts per Workflow class */ const coldStarts = new WeakMap<object, boolean>(); function isColdStart(workflowClass: object): boolean { if (!coldStarts.has(workflowClass)) { coldStarts.set(workflowClass, true); return true; } return false; } /** * Proxy the step object to instrument step.do(), step.sleep(), and step.sleepUntil() calls */ function instrumentWorkflowStep( step: WorkflowStep, workflowName: string, ): WorkflowStep { const stepHandler: ProxyHandler<WorkflowStep> = { get(target, prop) { const value = Reflect.get(target, prop); // Instrument step.do() to create spans for each workflow step if (prop === 'do' && typeof value === 'function') { return new Proxy(value, { apply: (fnTarget, thisArg, args) => { const [stepName] = args as [string, ...unknown[]]; const tracer = trace.getTracer('autotel-edge') as WorkerTracer; return tracer.startActiveSpan( `Workflow ${workflowName}: ${stepName}`, { kind: SpanKind.INTERNAL, attributes: { 'workflow.step.name': stepName, 'workflow.name': workflowName, }, }, async (span) => { try { const result = await Reflect.apply(fnTarget, thisArg, args); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); throw error; } finally { span.end(); } }, ); }, }); } // Instrument step.sleep() to track workflow delays if (prop === 'sleep' && typeof value === 'function') { return new Proxy(value, { apply: (fnTarget, thisArg, args) => { const [sleepName, duration] = args as [string, string | number]; const tracer = trace.getTracer('autotel-edge') as WorkerTracer; return tracer.startActiveSpan( `Workflow ${workflowName}: sleep ${sleepName}`, { kind: SpanKind.INTERNAL, attributes: { 'workflow.sleep.name': sleepName, 'workflow.sleep.duration': String(duration), 'workflow.name': workflowName, }, }, async (span) => { try { const result = await Reflect.apply(fnTarget, thisArg, args); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); throw error; } finally { span.end(); } }, ); }, }); } if (prop === 'sleepUntil' && typeof value === 'function') { return new Proxy(value, { apply: (fnTarget, thisArg, args) => { const [sleepName, timestamp] = args as [string, Date | number]; const tracer = trace.getTracer('autotel-edge') as WorkerTracer; const wakeAt = timestamp instanceof Date ? timestamp.toISOString() : new Date(timestamp).toISOString(); return tracer.startActiveSpan( `Workflow ${workflowName}: sleepUntil ${sleepName}`, { kind: SpanKind.INTERNAL, attributes: { 'workflow.sleep.name': sleepName, 'workflow.sleep.until': wakeAt, 'workflow.name': workflowName, }, }, async (span) => { try { const result = await Reflect.apply(fnTarget, thisArg, args); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); throw error; } finally { span.end(); } }, ); }, }); } // Pass through other step methods if (typeof value === 'function') { return value.bind(target); } return value; }, }; return wrap(step, stepHandler); } /** * Instrument a Workflow run method */ function instrumentWorkflowRun( runFn: WorkflowRunFn, workflowName: string, workflowClass: object, ): WorkflowRunFn { return async function instrumentedRun( this: unknown, event: Readonly<WorkflowEvent<unknown>>, step: WorkflowStep, ): Promise<unknown> { const tracer = trace.getTracer('autotel-edge') as WorkerTracer; // Instrument the step object to track individual operations const instrumentedStep = instrumentWorkflowStep(step, workflowName); const spanName = `Workflow ${workflowName}: run`; return tracer.startActiveSpan( spanName, { kind: SpanKind.INTERNAL, attributes: { 'workflow.name': workflowName, 'workflow.instance_id': event.instanceId, 'faas.trigger': 'workflow', 'faas.coldstart': isColdStart(workflowClass), }, }, async (span) => { try { const result = await runFn.call(this, event, instrumentedStep); span.setStatus({ code: SpanStatusCode.OK }); return result; } catch (error) { span.recordException(error as Error); span.setStatus({ code: SpanStatusCode.ERROR, message: error instanceof Error ? error.message : String(error), }); throw error; } finally { span.end(); } }, ); }; } /** * Instrument a Workflow instance */ function instrumentWorkflowInstance( workflowInstance: Record<string, unknown>, workflowName: string, workflowClass: object, ): Record<string, unknown> { const instanceHandler: ProxyHandler<Record<string, unknown>> = { get(target, prop) { const value = Reflect.get(target, prop); if (prop === 'run' && typeof value === 'function') { return instrumentWorkflowRun( value.bind(target) as WorkflowRunFn, workflowName, workflowClass, ); } // Bind other methods to the target if (typeof value === 'function') { return value.bind(target); } return value; }, }; return wrap(workflowInstance, instanceHandler); } /** * Instrument a Cloudflare Workflow class * * This wraps the WorkflowEntrypoint class to automatically trace workflow execution, * step operations, retries, and sleeps. * * **Usage:** * ```typescript * import { WorkflowEntrypoint } from 'cloudflare:workers' * import { instrumentWorkflow } from 'autotel-cloudflare/handlers' * * class MyWorkflow extends WorkflowEntrypoint { * async run(event, step) { * await step.do('submit payment', async () => { * return await submitToPaymentProcessor(event.payload.payment) * }) * * await step.sleep('wait for feedback', '2 days') * * await step.do('send feedback email', sendFeedbackEmail) * } * } * * export const CheckoutWorkflow = instrumentWorkflow( * MyWorkflow, * 'checkout-workflow', * (env: Env) => ({ * exporter: { * url: env.OTLP_ENDPOINT, * headers: { 'x-api-key': env.API_KEY } * }, * service: { * name: 'checkout-workflow', * version: '1.0.0' * } * }) * ) * ``` * * @param workflowClass - The WorkflowEntrypoint class to instrument * @param workflowName - The name of the workflow (used in span names) * @param config - Configuration or configuration function * @returns Instrumented Workflow class */ export function instrumentWorkflow< C extends new (...args: any[]) => any, >( workflowClass: C, workflowName: string, config: ConfigurationOption, ): C { const initialiser = createInitialiser(config); const classHandler: ProxyHandler<C> = { construct(target, args: any[]) { // Extract env from constructor args (typically last arg) const env = args[args.length - 1] || {}; const trigger: WorkflowTrigger = { type: 'workflow', name: workflowName }; const workflowConfig = initialiser(env, trigger); const context = setConfig(workflowConfig); // Create the workflow instance within the config context const workflowInstance = api_context.with(context, () => { return new target(...args); }); // Instrument the instance return instrumentWorkflowInstance( workflowInstance, workflowName, workflowClass, ); }, }; return wrap(workflowClass, classHandler); }