Files
puter/src/backend/util/span.test.ts
T

160 lines
5.1 KiB
TypeScript

import { SpanStatusCode, trace } from '@opentelemetry/api';
import {
BasicTracerProvider,
InMemorySpanExporter,
SimpleSpanProcessor,
} from '@opentelemetry/sdk-trace-base';
import { afterAll, beforeEach, describe, expect, it } from 'vitest';
import { Span, withSpan } from './span';
const exporter = new InMemorySpanExporter();
const provider = new BasicTracerProvider({
spanProcessors: [new SimpleSpanProcessor(exporter)],
});
trace.setGlobalTracerProvider(provider);
afterAll(async () => {
await provider.shutdown();
trace.disable();
});
beforeEach(() => {
exporter.reset();
});
describe('withSpan', () => {
it('records a span around a sync function and returns its value', () => {
const result = withSpan('sync.op', { a: 1 }, () => 42);
expect(result).toBe(42);
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].name).toBe('sync.op');
expect(spans[0].attributes).toMatchObject({ a: 1 });
expect(spans[0].status.code).toBe(SpanStatusCode.OK);
});
it('records a span around an async function', async () => {
const result = await withSpan('async.op', {}, async () => 'ok');
expect(result).toBe('ok');
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].name).toBe('async.op');
expect(spans[0].status.code).toBe(SpanStatusCode.OK);
});
it('marks the span errored and rethrows on async rejection', async () => {
await expect(
withSpan('async.fail', {}, async () => {
throw new Error('boom');
}),
).rejects.toThrow('boom');
const spans = exporter.getFinishedSpans();
expect(spans).toHaveLength(1);
expect(spans[0].status.code).toBe(SpanStatusCode.ERROR);
expect(spans[0].events.some((e) => e.name === 'exception')).toBe(true);
});
it('resolves an attribute factory lazily', () => {
withSpan(
'factory.op',
() => ({ computed: 'yes' }),
() => null,
);
expect(exporter.getFinishedSpans()[0].attributes).toMatchObject({
computed: 'yes',
});
});
});
describe('Span decorator', () => {
it('defaults the span name to Class.method', async () => {
class Widget {
@Span()
async fetch() {
return 'data';
}
}
expect(await new Widget().fetch()).toBe('data');
expect(exporter.getFinishedSpans()[0].name).toBe('Widget.fetch');
});
it('uses an explicit name and static attributes', async () => {
class Widget {
@Span('custom.name', { kind: 'static' })
async fetch() {
return 1;
}
}
await new Widget().fetch();
const [span] = exporter.getFinishedSpans();
expect(span.name).toBe('custom.name');
expect(span.attributes).toMatchObject({ kind: 'static' });
});
it('builds attributes from the call arguments via a factory', async () => {
class Db {
@Span('db.read', (query: string) => ({ 'db.statement': query }))
async read(query: string, _params: unknown[] = []) {
return [query];
}
}
await new Db().read('SELECT 1', []);
const [span] = exporter.getFinishedSpans();
expect(span.name).toBe('db.read');
expect(span.attributes).toMatchObject({ 'db.statement': 'SELECT 1' });
});
it('records errors thrown by the decorated method', async () => {
class Db {
@Span('db.read')
async read() {
throw new Error('nope');
}
}
await expect(new Db().read()).rejects.toThrow('nope');
expect(exporter.getFinishedSpans()[0].status.code).toBe(
SpanStatusCode.ERROR,
);
});
});
describe('withSpan — failure paths', () => {
it('records the error and rethrows when a sync function throws', () => {
expect(() =>
withSpan('sync.boom', {}, () => {
throw new Error('sync failure');
}),
).toThrow('sync failure');
const [span] = exporter.getFinishedSpans();
expect(span.name).toBe('sync.boom');
expect(span.status.code).toBe(SpanStatusCode.ERROR);
expect(span.status.message).toBe('sync failure');
expect(span.events.map((e) => e.name)).toContain('exception');
});
it('wraps a non-Error throw so the span still carries a message', async () => {
await expect(
withSpan('async.nonerror', {}, async () => {
throw 'just a string';
}),
).rejects.toBe('just a string');
const [span] = exporter.getFinishedSpans();
expect(span.status.code).toBe(SpanStatusCode.ERROR);
expect(span.status.message).toBe('just a string');
});
it('mirrors the span name into a filterable attribute', () => {
withSpan('named.op', {}, () => undefined);
expect(exporter.getFinishedSpans()[0].attributes).toMatchObject({
span_name: 'named.op',
});
});
});