314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
import { trace } from "@opentelemetry/api";
|
|
import { describe, expect, it } from "@suaveplan/testing/runner";
|
|
import {
|
|
addAttributes,
|
|
buildSpanContextObject,
|
|
getTraceContext,
|
|
recordException,
|
|
withSpan,
|
|
} from "./span-utils.js";
|
|
|
|
describe("span-utils", () => {
|
|
const tracer = trace.getTracer("test-tracer");
|
|
|
|
describe("withSpan", () => {
|
|
it("calls fn and returns its result", { timeout: 5000 }, async () => {
|
|
const result = await withSpan(
|
|
tracer,
|
|
"test.span",
|
|
async (_span) => {
|
|
return 42;
|
|
}
|
|
);
|
|
expect(result).toBe(42);
|
|
});
|
|
|
|
it("passes the active span to fn", { timeout: 5000 }, async () => {
|
|
let receivedSpan: unknown = null;
|
|
await withSpan(tracer, "test.span", async (span) => {
|
|
receivedSpan = span;
|
|
});
|
|
expect(receivedSpan).not.toBeNull();
|
|
});
|
|
|
|
it("re-throws errors thrown by fn", { timeout: 5000 }, async () => {
|
|
const error = new Error("test failure");
|
|
await expect(
|
|
withSpan(tracer, "test.span", async () => {
|
|
throw error;
|
|
})
|
|
).rejects.toThrow("test failure");
|
|
});
|
|
|
|
it("records exception on the span when fn throws", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const recordedExceptions: unknown[] = [];
|
|
const fakeSpan = {
|
|
recordException: (err: unknown) => {
|
|
recordedExceptions.push(err);
|
|
},
|
|
setStatus: () => {},
|
|
end: () => {},
|
|
setAttribute: () => {},
|
|
};
|
|
|
|
const patchedTracer = {
|
|
startActiveSpan<T>(
|
|
_name: string,
|
|
_opts: unknown,
|
|
fn: (span: typeof fakeSpan) => T
|
|
): T {
|
|
return fn(fakeSpan);
|
|
},
|
|
} as unknown as typeof tracer;
|
|
|
|
const error = new TypeError("boom");
|
|
await expect(
|
|
withSpan(patchedTracer, "err.span", async () => {
|
|
throw error;
|
|
})
|
|
).rejects.toThrow("boom");
|
|
|
|
expect(recordedExceptions).toHaveLength(1);
|
|
expect(recordedExceptions[0]).toBe(error);
|
|
});
|
|
|
|
it("sets initial attributes from options", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const setAttrs: Record<string, unknown> = {};
|
|
const fakeSpan = {
|
|
recordException: () => {},
|
|
setStatus: () => {},
|
|
end: () => {},
|
|
setAttribute: (k: string, v: unknown) => {
|
|
setAttrs[k] = v;
|
|
},
|
|
};
|
|
|
|
const patchedTracer = {
|
|
startActiveSpan<T>(
|
|
_name: string,
|
|
opts: { attributes?: Record<string, unknown> },
|
|
fn: (span: typeof fakeSpan) => T
|
|
): T {
|
|
for (const [k, v] of Object.entries(
|
|
opts.attributes ?? {}
|
|
)) {
|
|
fakeSpan.setAttribute(k, v);
|
|
}
|
|
return fn(fakeSpan);
|
|
},
|
|
} as unknown as typeof tracer;
|
|
|
|
await withSpan(patchedTracer, "attr.span", async () => "ok", {
|
|
attributes: { "test.key": "test-value" },
|
|
});
|
|
|
|
expect(setAttrs["test.key"]).toBe("test-value");
|
|
});
|
|
});
|
|
|
|
describe("recordException", () => {
|
|
it("sets span status to ERROR and records the exception for Error instances", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const events: unknown[] = [];
|
|
let statusCode: unknown = null;
|
|
|
|
const span = {
|
|
recordException: (err: unknown) => {
|
|
events.push(err);
|
|
},
|
|
setStatus: (s: { code: unknown }) => {
|
|
statusCode = s.code;
|
|
},
|
|
} as Parameters<typeof recordException>[0];
|
|
|
|
const err = new RangeError("out of range");
|
|
recordException(span, err);
|
|
|
|
expect(events).toHaveLength(1);
|
|
expect(events[0]).toBe(err);
|
|
expect(statusCode).toBe(2); // SpanStatusCode.ERROR === 2
|
|
});
|
|
|
|
it("does not throw for non-Error values", { timeout: 5000 }, () => {
|
|
const span = {
|
|
recordException: () => {},
|
|
setStatus: () => {},
|
|
} as Parameters<typeof recordException>[0];
|
|
|
|
expect(() => recordException(span, "string error")).not.toThrow();
|
|
expect(() => recordException(span, 42)).not.toThrow();
|
|
expect(() => recordException(span, null)).not.toThrow();
|
|
expect(() => recordException(span, undefined)).not.toThrow();
|
|
expect(() => recordException(span, { code: 500 })).not.toThrow();
|
|
});
|
|
|
|
it("wraps non-Error values in an Error before recording", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const events: unknown[] = [];
|
|
const span = {
|
|
recordException: (err: unknown) => {
|
|
events.push(err);
|
|
},
|
|
setStatus: () => {},
|
|
} as Parameters<typeof recordException>[0];
|
|
|
|
recordException(span, "plain string");
|
|
expect(events[0]).toBeInstanceOf(Error);
|
|
expect((events[0] as Error).message).toBe("plain string");
|
|
});
|
|
});
|
|
|
|
describe("addAttributes", () => {
|
|
it("sets all attributes on the span", { timeout: 5000 }, () => {
|
|
const attrs: Record<string, unknown> = {};
|
|
const span = {
|
|
setAttribute: (k: string, v: unknown) => {
|
|
attrs[k] = v;
|
|
},
|
|
} as Parameters<typeof addAttributes>[0];
|
|
|
|
addAttributes(span, {
|
|
"http.method": "GET",
|
|
"http.status_code": 200,
|
|
"http.url": "https://example.com",
|
|
});
|
|
|
|
expect(attrs["http.method"]).toBe("GET");
|
|
expect(attrs["http.status_code"]).toBe(200);
|
|
expect(attrs["http.url"]).toBe("https://example.com");
|
|
});
|
|
|
|
it("skips undefined values", { timeout: 5000 }, () => {
|
|
const keys: string[] = [];
|
|
const span = {
|
|
setAttribute: (k: string) => {
|
|
keys.push(k);
|
|
},
|
|
} as Parameters<typeof addAttributes>[0];
|
|
|
|
addAttributes(span, {
|
|
"defined.key": "value",
|
|
"undefined.key": undefined,
|
|
});
|
|
|
|
expect(keys).toContain("defined.key");
|
|
expect(keys).not.toContain("undefined.key");
|
|
});
|
|
|
|
it("handles empty attributes map without throwing", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const span = {
|
|
setAttribute: () => {},
|
|
} as Parameters<typeof addAttributes>[0];
|
|
|
|
expect(() => addAttributes(span, {})).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("getTraceContext", () => {
|
|
it("returns empty object when no span is active (no-op tracer)", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const ctx = getTraceContext();
|
|
expect(ctx).toEqual({});
|
|
});
|
|
|
|
it("returns an object (never throws)", { timeout: 5000 }, () => {
|
|
expect(() => getTraceContext()).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("buildSpanContextObject", () => {
|
|
it("returns empty object for undefined span context", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
expect(buildSpanContextObject(undefined)).toEqual({});
|
|
});
|
|
|
|
it("populates traceId when present", { timeout: 5000 }, () => {
|
|
const result = buildSpanContextObject({
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: 1,
|
|
});
|
|
expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
|
});
|
|
|
|
it("populates spanId when present", { timeout: 5000 }, () => {
|
|
const result = buildSpanContextObject({
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: 1,
|
|
});
|
|
expect(result.spanId).toBe("00f067aa0ba902b7");
|
|
});
|
|
|
|
it("populates traceFlags when present", { timeout: 5000 }, () => {
|
|
const result = buildSpanContextObject({
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: 1,
|
|
});
|
|
expect(result.traceFlags).toBe(1);
|
|
});
|
|
|
|
it("omits traceId when undefined in span context", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const result = buildSpanContextObject({
|
|
traceId: undefined as unknown as string,
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: 1,
|
|
});
|
|
expect("traceId" in result).toBe(false);
|
|
expect(result.spanId).toBe("00f067aa0ba902b7");
|
|
expect(result.traceFlags).toBe(1);
|
|
});
|
|
|
|
it("omits spanId when undefined in span context", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const result = buildSpanContextObject({
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: undefined as unknown as string,
|
|
traceFlags: 1,
|
|
});
|
|
expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
|
expect("spanId" in result).toBe(false);
|
|
expect(result.traceFlags).toBe(1);
|
|
});
|
|
|
|
it("omits traceFlags when undefined in span context", {
|
|
timeout: 5000,
|
|
}, () => {
|
|
const result = buildSpanContextObject({
|
|
traceId: "4bf92f3577b34da6a3ce929d0e0e4736",
|
|
spanId: "00f067aa0ba902b7",
|
|
traceFlags: undefined as unknown as number,
|
|
});
|
|
expect(result.traceId).toBe("4bf92f3577b34da6a3ce929d0e0e4736");
|
|
expect(result.spanId).toBe("00f067aa0ba902b7");
|
|
expect("traceFlags" in result).toBe(false);
|
|
});
|
|
|
|
it("getTraceContext accepts a span for testability", {
|
|
timeout: 5000,
|
|
}, async () => {
|
|
const tracer = trace.getTracer("test");
|
|
await withSpan(tracer, "ctx-test", async (span) => {
|
|
// The no-op span context has traceId/spanId/traceFlags set to
|
|
// their zero values; verify getTraceContext accepts it
|
|
const ctx = getTraceContext(span);
|
|
expect(typeof ctx).toBe("object");
|
|
});
|
|
});
|
|
});
|
|
});
|