Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
215 changes: 215 additions & 0 deletions src/__tests__/tracing-ergonomics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/**
* Ergonomics tests for worker-observability#3 and #4:
* - Span.startChild()
* - Tracer.runWithSpan + getActiveSpan()
* - honoTracing() middleware (shape + behavior without a real Hono app)
*/

import { describe, it, expect } from 'vitest';
import {
Tracer,
Span,
getActiveSpan,
honoTracing,
type SpanExporter,
} from '../tracing';
import type { TraceSpan } from '../types';

class CollectingExporter implements SpanExporter {
public spans: TraceSpan[] = [];
async export(spans: TraceSpan[]): Promise<void> {
this.spans.push(...spans);
}
}

function makeTracer(exporter: CollectingExporter): Tracer {
return new Tracer({ service: 'test', export: exporter });
}

describe('Span.startChild', () => {
it('creates a child span that inherits the parent trace context', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const root = tracer.startTrace('root');
const child = root.startChild('child', { foo: 'bar' });

expect(child.getContext().traceId).toBe(root.getContext().traceId);
expect(child.getContext().spanId).not.toBe(root.getContext().spanId);

child.end();
root.end();
await tracer.flush();

expect(exporter.spans).toHaveLength(2);
const recordedChild = exporter.spans.find(s => s.operationName === 'child');
expect(recordedChild?.parentSpanId).toBe(root.getContext().spanId);
expect(recordedChild?.attributes?.foo).toBe('bar');
});
});

describe('getActiveSpan + runWithSpan', () => {
it('returns undefined outside a runWithSpan scope', () => {
expect(getActiveSpan()).toBeUndefined();
});

it('exposes the scoped span to synchronous callers', () => {
const tracer = makeTracer(new CollectingExporter());
const root = tracer.startTrace('root');
let observed: Span | undefined;
tracer.runWithSpan(root, () => {
observed = getActiveSpan();
});
expect(observed).toBe(root);
});

it('exposes the scoped span across async boundaries', async () => {
const tracer = makeTracer(new CollectingExporter());
const root = tracer.startTrace('root');
let observed: Span | undefined;
await tracer.runWithSpan(root, async () => {
await Promise.resolve();
await new Promise(r => setTimeout(r, 1));
observed = getActiveSpan();
});
expect(observed).toBe(root);
});

it('deep code can create child spans without threading context', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const root = tracer.startTrace('root');

async function deeplyNested() {
const child = getActiveSpan()?.startChild('deep');
child?.setAttributes({ depth: 5 });
child?.end();
}

await tracer.runWithSpan(root, async () => {
await deeplyNested();
});
root.end();
await tracer.flush();

const deep = exporter.spans.find(s => s.operationName === 'deep');
expect(deep).toBeDefined();
expect(deep?.parentSpanId).toBe(root.getContext().spanId);
expect(deep?.attributes?.depth).toBe(5);
});
});

describe('honoTracing', () => {
function fakeContext(opts: {
method?: string;
path?: string;
status?: number;
throws?: boolean;
} = {}): any {
const stored = new Map<string, unknown>();
const waitUntilPromises: Array<Promise<unknown>> = [];
const headers = new Headers();
const ctx = {
req: {
method: opts.method ?? 'GET',
url: `https://test.example/${(opts.path ?? '/x').replace(/^\//, '')}`,
raw: { headers },
header: (name: string) => headers.get(name) ?? undefined,
},
res: { status: opts.status ?? 200, headers: new Headers() },
set: (k: string, v: unknown) => stored.set(k, v),
get: (k: string) => stored.get(k),
executionCtx: {
waitUntil: (p: Promise<unknown>) => {
waitUntilPromises.push(p);
},
},
stored,
waitUntilPromises,
};
return ctx;
}

it('skips tracing when monitoring.tracer is missing', async () => {
let called = false;
const mw = honoTracing(null);
await mw(fakeContext(), async () => {
called = true;
});
expect(called).toBe(true);
});

it('skip predicate shortcircuits without creating a span', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const mw = honoTracing({ tracer }, { skip: () => true });
await mw(fakeContext({ path: '/health' }), async () => {});
expect(exporter.spans).toHaveLength(0);
});

it('publishes the root span to c.get(rootSpan) and makes it active', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const mw = honoTracing({ tracer });
const c = fakeContext({ path: '/run' });

let activeInside: Span | undefined;
let rootFromContext: Span | undefined;
await mw(c, async () => {
rootFromContext = c.get('rootSpan') as Span;
activeInside = getActiveSpan();
});

expect(rootFromContext).toBeDefined();
expect(activeInside).toBe(rootFromContext);
// waitUntil captured the flush
expect(c.waitUntilPromises.length).toBe(1);
await Promise.all(c.waitUntilPromises);
expect(exporter.spans).toHaveLength(1);
expect(exporter.spans[0]!.operationName).toBe('GET /run');
});

it('records errors and sets error status on thrown exceptions', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const mw = honoTracing({ tracer });
const c = fakeContext();

await expect(
mw(c, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');

await Promise.all(c.waitUntilPromises);
expect(exporter.spans[0]!.status).toBe('error');
const errorEvent = exporter.spans[0]!.events?.find(e => e.name === 'error');
expect(errorEvent?.attributes?.['error.message']).toBe('boom');
});

it('flags 5xx responses as error status', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const mw = honoTracing({ tracer });
const c = fakeContext({ status: 503 });
await mw(c, async () => {});
await Promise.all(c.waitUntilPromises);
expect(exporter.spans[0]!.status).toBe('error');
});

it('respects custom spanNamer + extra attributes', async () => {
const exporter = new CollectingExporter();
const tracer = makeTracer(exporter);
const mw = honoTracing(
{ tracer },
{
spanNamer: () => 'custom.name',
attributes: () => ({ 'tenant.id': 't-42' }),
},
);
const c = fakeContext();
await mw(c, async () => {});
await Promise.all(c.waitUntilPromises);
expect(exporter.spans[0]!.operationName).toBe('custom.name');
expect(exporter.spans[0]!.attributes?.['tenant.id']).toBe('t-42');
});
});
8 changes: 6 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,16 @@ export {
CloudflareTraceExporter,
ConsoleTraceExporter,
tracingMiddleware,
trace
trace,
honoTracing,
getActiveSpan
} from './tracing';
export type {
TracerOptions,
SpanContext,
SpanExporter
SpanExporter,
HonoTracingMonitoring,
HonoTracingOptions
} from './tracing';

// Export error tracking utilities
Expand Down
Loading