Initial Commit
This commit is contained in:
@@ -0,0 +1,313 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user