UNPKG

autotel-cloudflare

Version:

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

412 lines (336 loc) 13.8 kB
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { instrumentWorkflow } from './workflows'; import { getWorkflowLogger } from '../execution-logger'; import { trace, SpanStatusCode, SpanKind } from '@opentelemetry/api'; describe('Workflow Instrumentation', () => { let mockTracer: any; let mockSpan: any; let getTracerSpy: any; beforeEach(() => { mockSpan = { spanContext: () => ({ traceId: 'test-trace-id', spanId: 'test-span-id', traceFlags: 1, }), setAttribute: vi.fn(), setAttributes: vi.fn(), setStatus: vi.fn(), recordException: vi.fn(), end: vi.fn(), isRecording: () => true, updateName: vi.fn(), addEvent: vi.fn(), addLink: vi.fn(), addLinks: vi.fn(), }; mockTracer = { startActiveSpan: vi.fn((name, options, context, fn) => { if (typeof options === 'function') return options(mockSpan); if (typeof context === 'function') return context(mockSpan); if (typeof fn === 'function') return fn(mockSpan); return Promise.resolve(); }), }; getTracerSpy = vi.spyOn(trace, 'getTracer').mockReturnValue(mockTracer as any); }); afterEach(() => { getTracerSpy.mockRestore(); }); describe('instrumentWorkflow()', () => { it('should wrap workflow class constructor', () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run() {} } const Instrumented = instrumentWorkflow(TestWorkflow, 'test-workflow', { service: { name: 'test' }, }); expect(Instrumented).toBeDefined(); expect(typeof Instrumented).toBe('function'); }); it('should create workflow instance with instrumented run()', () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run() { return 'done'; } } const Instrumented = instrumentWorkflow(TestWorkflow, 'test-workflow', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); expect(instance).toBeDefined(); expect(typeof instance.run).toBe('function'); }); it('should accept static config', () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run() {} } const Instrumented = instrumentWorkflow(TestWorkflow, 'test-workflow', { service: { name: 'test', version: '1.0.0' }, exporter: { url: 'http://localhost:4318/v1/traces' }, }); expect(Instrumented).toBeDefined(); }); it('should accept config function', () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run() {} } interface Env { OTLP_ENDPOINT: string; } const Instrumented = instrumentWorkflow(TestWorkflow, 'test-workflow', (env: Env) => ({ service: { name: 'test' }, exporter: { url: env.OTLP_ENDPOINT }, })); expect(Instrumented).toBeDefined(); }); }); describe('run() instrumentation', () => { it('should preserve run() return value', async () => { const output = { status: 'ok', orderId: 'ord-123' }; class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) { return output; } } const Instrumented = instrumentWorkflow(TestWorkflow, 'return-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-return' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn() }; await expect(instance.run(event, step)).resolves.toEqual(output); }); it('should create span for run() with workflow attributes', async () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) {} } const Instrumented = instrumentWorkflow(TestWorkflow, 'test-workflow', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: { foo: 'bar' }, timestamp: new Date(), instanceId: 'wf-123' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn() }; await instance.run(event, step); expect(mockTracer.startActiveSpan).toHaveBeenCalled(); const spanName = mockTracer.startActiveSpan.mock.calls[0][0]; expect(spanName).toBe('Workflow test-workflow: run'); const options = mockTracer.startActiveSpan.mock.calls[0][1]; expect(options.kind).toBe(SpanKind.INTERNAL); expect(options.attributes['workflow.name']).toBe('test-workflow'); expect(options.attributes['workflow.instance_id']).toBe('wf-123'); expect(options.attributes['faas.trigger']).toBe('workflow'); }); it('should track cold starts', async () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) {} } const Instrumented = instrumentWorkflow(TestWorkflow, 'cold-test', { service: { name: 'test' }, }); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-1' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn() }; const instance1 = new Instrumented({}, {}); await instance1.run(event, step); const firstOptions = mockTracer.startActiveSpan.mock.calls[0][1]; expect(firstOptions.attributes['faas.coldstart']).toBe(true); const instance2 = new Instrumented({}, {}); await instance2.run(event, step); const secondOptions = mockTracer.startActiveSpan.mock.calls[1][1]; expect(secondOptions.attributes['faas.coldstart']).toBe(false); }); it('should handle run() errors', async () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run() { throw new Error('Workflow failed'); } } const Instrumented = instrumentWorkflow(TestWorkflow, 'error-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-err' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn() }; await expect(instance.run(event, step)).rejects.toThrow('Workflow failed'); expect(mockSpan.recordException).toHaveBeenCalled(); expect(mockSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR, message: 'Workflow failed', }); expect(mockSpan.end).toHaveBeenCalled(); }); it('should make getWorkflowLogger() available inside run()', async () => { const getActiveSpanSpy = vi .spyOn(trace, 'getActiveSpan') .mockReturnValue(mockSpan as any); class TestWorkflow { constructor(public ctx: any, public env: any) {} async run() { const log = getWorkflowLogger(); log.set({ workflow: { id: 'wf-logger' } }); log.info('workflow started', { provider: { name: 'curvepay' } }); return log.emitNow({ outcome: 'running' }); } } const Instrumented = instrumentWorkflow(TestWorkflow, 'logger-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-logger' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn() }; const snapshot = await instance.run(event, step); expect(snapshot).toMatchObject({ traceId: 'test-trace-id', spanId: 'test-span-id', context: { workflow: { id: 'wf-logger' }, outcome: 'running', }, }); expect(mockSpan.setAttributes).toHaveBeenCalledWith({ 'workflow.id': 'wf-logger', }); expect(mockSpan.addEvent).toHaveBeenCalledWith('log.info', { message: 'workflow started', 'provider.name': 'curvepay', }); getActiveSpanSpy.mockRestore(); }); }); describe('step.do() instrumentation', () => { it('should create span for step.do() calls', async () => { const stepDoResult = { paymentId: 'pay_123' }; const mockStepDo = vi.fn().mockResolvedValue(stepDoResult); class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) { return await step.do('process payment', async () => stepDoResult); } } const Instrumented = instrumentWorkflow(TestWorkflow, 'step-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-step' }; const step = { do: vi.fn(async (_name: string, cb: () => Promise<any>) => cb()), sleep: vi.fn(), sleepUntil: vi.fn(), }; await instance.run(event, step); // Should have spans for both run() and step.do() expect(mockTracer.startActiveSpan.mock.calls.length).toBeGreaterThanOrEqual(2); const stepSpanCall = mockTracer.startActiveSpan.mock.calls[1]; expect(stepSpanCall[0]).toBe('Workflow step-test: process payment'); expect(stepSpanCall[1].attributes['workflow.step.name']).toBe('process payment'); expect(stepSpanCall[1].attributes['workflow.name']).toBe('step-test'); }); it('should handle step.do() errors', async () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) { await step.do('failing step', async () => { throw new Error('Step failed'); }); } } const Instrumented = instrumentWorkflow(TestWorkflow, 'step-err', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-step-err' }; const step = { do: vi.fn(async (_name: string, cb: () => Promise<any>) => cb()), sleep: vi.fn(), sleepUntil: vi.fn(), }; await expect(instance.run(event, step)).rejects.toThrow('Step failed'); expect(mockSpan.recordException).toHaveBeenCalled(); expect(mockSpan.end).toHaveBeenCalled(); }); }); describe('step.sleep() instrumentation', () => { it('should create span for step.sleep() calls', async () => { class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) { await step.sleep('wait for settlement', '2 hours'); } } const Instrumented = instrumentWorkflow(TestWorkflow, 'sleep-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-sleep' }; const step = { do: vi.fn(), sleep: vi.fn().mockResolvedValue(undefined), sleepUntil: vi.fn(), }; await instance.run(event, step); const sleepSpanCall = mockTracer.startActiveSpan.mock.calls[1]; expect(sleepSpanCall[0]).toBe('Workflow sleep-test: sleep wait for settlement'); expect(sleepSpanCall[1].attributes['workflow.sleep.name']).toBe('wait for settlement'); expect(sleepSpanCall[1].attributes['workflow.sleep.duration']).toBe('2 hours'); expect(sleepSpanCall[1].attributes['workflow.name']).toBe('sleep-test'); }); it('should create span for step.sleepUntil() calls', async () => { const wakeAt = new Date('2026-04-09T10:00:00.000Z'); class TestWorkflow { constructor(public ctx: any, public env: any) {} async run(event: any, step: any) { await step.sleepUntil('wait until tomorrow', wakeAt); } } const Instrumented = instrumentWorkflow(TestWorkflow, 'sleep-until-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-sleep-until' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn().mockResolvedValue(undefined), }; await instance.run(event, step); const sleepUntilSpanCall = mockTracer.startActiveSpan.mock.calls[1]; expect(sleepUntilSpanCall[0]).toBe( 'Workflow sleep-until-test: sleepUntil wait until tomorrow', ); expect(sleepUntilSpanCall[1].attributes['workflow.sleep.name']).toBe( 'wait until tomorrow', ); expect(sleepUntilSpanCall[1].attributes['workflow.sleep.until']).toBe( '2026-04-09T10:00:00.000Z', ); expect(sleepUntilSpanCall[1].attributes['workflow.name']).toBe( 'sleep-until-test', ); }); }); describe('this binding', () => { it('should preserve this context in run()', async () => { class TestWorkflow { private value = 42; constructor(public ctx: any, public env: any) {} async run(event: any, step: any) { return this.value; } } const Instrumented = instrumentWorkflow(TestWorkflow, 'this-test', { service: { name: 'test' }, }); const instance = new Instrumented({}, {}); const event = { payload: {}, timestamp: new Date(), instanceId: 'wf-this' }; const step = { do: vi.fn(), sleep: vi.fn(), sleepUntil: vi.fn() }; // Should not throw — this.value should be accessible await expect(instance.run(event, step)).resolves.not.toThrow(); }); }); });