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
text/typescript
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();
});
});
});