Skip to content

Commit 9d18de8

Browse files
authored
feat: update version to 0.2.3 and enhance OpenTelemetry tracing with new handle (#17)
1 parent a167fa2 commit 9d18de8

File tree

6 files changed

+431
-3
lines changed

6 files changed

+431
-3
lines changed

apps/web/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "@leader/web",
33
"private": true,
4-
"version": "0.2.2",
4+
"version": "0.2.3",
55
"type": "module",
66
"scripts": {
77
"dev": "vite dev",

apps/web/src/hooks.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { and, db, eq, runMigrations, schema, withRLS } from "@leader/db";
1313
import { configureLogging, getLogger } from "@leader/logging";
1414
import { ensureInitialUserWithOrganization } from "$lib/server/bootstrap";
1515
import { getOtelSink } from "$lib/server/telemetry";
16+
import { tracingHandle } from "$lib/server/tracing-handle";
1617
import { randomUUIDv7 } from "bun";
1718

1819
const ALLOW_SIGN_UP = process.env.ALLOW_SIGN_UP === "true";
@@ -253,6 +254,7 @@ const wideEventHandle: Handle = async ({ event, resolve }) => {
253254
};
254255

255256
export const handle = sequence(
257+
tracingHandle,
256258
wideEventHandle,
257259
requestLocaleHandle,
258260
sessionHandle,

apps/web/src/lib/server/telemetry.ts

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { getOpenTelemetrySink } from "@logtape/otel";
22
import type { OpenTelemetrySink } from "@logtape/otel";
3+
import { SpanKind, trace } from "@opentelemetry/api";
4+
import type { Span as ApiSpan } from "@opentelemetry/api";
35
import { OTLPLogExporter } from "@opentelemetry/exporter-logs-otlp-proto";
46
import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
57
import { HttpInstrumentation } from "@opentelemetry/instrumentation-http";
@@ -9,11 +11,58 @@ import {
911
LoggerProvider,
1012
} from "@opentelemetry/sdk-logs";
1113
import { NodeSDK } from "@opentelemetry/sdk-node";
14+
import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-node";
15+
import type { ReadableSpan, SpanProcessor } from "@opentelemetry/sdk-trace-node";
1216

1317
let sdk: NodeSDK | null = null;
1418
let loggerProvider: LoggerProvider | null = null;
1519
let otelSink: OpenTelemetrySink | null = null;
1620

21+
// ── SvelteKit root-span promotion ───────────────────────────────────
22+
// SvelteKit's built-in tracing creates `sveltekit.handle.root` as
23+
// SpanKind.INTERNAL. Dynatrace (and most APM backends) require a
24+
// SpanKind.SERVER entry-point span to detect and link services.
25+
//
26+
// This processor intercepts root spans on start and overwrites their
27+
// kind to SERVER. The handle hook (`tracingHandle`) later enriches
28+
// the same span with HTTP semantic-convention attributes.
29+
30+
const rootSpansByTrace = new Map<string, ApiSpan>();
31+
32+
const svelteKitServerSpanProcessor: SpanProcessor = {
33+
onStart(span) {
34+
const name = (span as unknown as ReadableSpan).name;
35+
if (name === "sveltekit.handle.root") {
36+
// `kind` is declared `readonly` in TS but is a plain JS property.
37+
Object.defineProperty(span, "kind", {
38+
value: SpanKind.SERVER,
39+
writable: true,
40+
configurable: true,
41+
});
42+
rootSpansByTrace.set(span.spanContext().traceId, span);
43+
}
44+
},
45+
onEnd(span) {
46+
if (span.name === "sveltekit.handle.root") {
47+
rootSpansByTrace.delete(span.spanContext().traceId);
48+
}
49+
},
50+
async shutdown() {
51+
rootSpansByTrace.clear();
52+
},
53+
async forceFlush() {},
54+
};
55+
56+
/**
57+
* Return the promoted root SERVER span for the current trace, or
58+
* `undefined` when telemetry is disabled or the span is unavailable.
59+
*/
60+
export function getRootServerSpan(): ApiSpan | undefined {
61+
const active = trace.getActiveSpan();
62+
if (!active) return undefined;
63+
return rootSpansByTrace.get(active.spanContext().traceId);
64+
}
65+
1766
/**
1867
* Initialise OpenTelemetry for traces and logs.
1968
*
@@ -54,7 +103,10 @@ export function configureTelemetry(): void {
54103

55104
sdk = new NodeSDK({
56105
resource,
57-
traceExporter,
106+
spanProcessors: [
107+
svelteKitServerSpanProcessor,
108+
new BatchSpanProcessor(traceExporter),
109+
],
58110
instrumentations: [new HttpInstrumentation()],
59111
});
60112
sdk.start();
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import {
2+
describe,
3+
it,
4+
expect,
5+
mock,
6+
beforeAll,
7+
beforeEach,
8+
afterAll,
9+
} from "bun:test";
10+
import { trace, SpanKind, SpanStatusCode } from "@opentelemetry/api";
11+
import type { Span as ApiSpan } from "@opentelemetry/api";
12+
import {
13+
NodeTracerProvider,
14+
InMemorySpanExporter,
15+
SimpleSpanProcessor,
16+
} from "@opentelemetry/sdk-trace-node";
17+
import type { ReadableSpan } from "@opentelemetry/sdk-trace-node";
18+
import type { Handle } from "@sveltejs/kit";
19+
20+
// ── mock $lib/server/telemetry ──────────────────────────────────────
21+
// We provide a fake `getRootServerSpan` that returns a real OTel span
22+
// created by the test tracer. This simulates the promoted root span
23+
// that the SvelteKitServerSpanProcessor would provide in production.
24+
25+
let fakeRootSpan: ApiSpan | undefined;
26+
27+
mock.module("$lib/server/telemetry", () => ({
28+
getRootServerSpan: () => fakeRootSpan,
29+
}));
30+
31+
// ── OTel test provider ──────────────────────────────────────────────
32+
33+
const exporter = new InMemorySpanExporter();
34+
const provider = new NodeTracerProvider({
35+
spanProcessors: [new SimpleSpanProcessor(exporter)],
36+
});
37+
38+
function getFinishedSpans(): ReadableSpan[] {
39+
return exporter.getFinishedSpans();
40+
}
41+
42+
// ── fake SvelteKit errors (duck-typed) ──────────────────────────────
43+
44+
function fakeRedirect(status: number, location: string) {
45+
return Object.assign(new Error("redirect"), { status, location });
46+
}
47+
48+
function fakeHttpError(status: number, message: string) {
49+
return Object.assign(new Error(message), {
50+
status,
51+
body: { message },
52+
});
53+
}
54+
55+
// ── mock event builder ──────────────────────────────────────────────
56+
57+
interface MockEventOptions {
58+
method?: string;
59+
pathname?: string;
60+
search?: string;
61+
routeId?: string | null;
62+
userAgent?: string | null;
63+
port?: string;
64+
}
65+
66+
function mockEvent(overrides: MockEventOptions = {}) {
67+
const {
68+
method = "GET",
69+
pathname = "/test",
70+
search = "",
71+
routeId = "/test",
72+
userAgent = "TestAgent/1.0",
73+
port = "",
74+
} = overrides;
75+
76+
return {
77+
request: {
78+
method,
79+
headers: new Headers(userAgent ? { "user-agent": userAgent } : {}),
80+
},
81+
url: new URL(
82+
`https://localhost${port ? `:${port}` : ""}${pathname}${search}`,
83+
),
84+
route: { id: routeId },
85+
};
86+
}
87+
88+
// ── typed wrapper ───────────────────────────────────────────────────
89+
90+
type HandleInput = Parameters<Handle>[0];
91+
92+
function callHandle(
93+
event: ReturnType<typeof mockEvent>,
94+
resolve: () => Response | Promise<Response>,
95+
) {
96+
return tracingHandle({
97+
event: event as unknown as HandleInput["event"],
98+
resolve: resolve as unknown as HandleInput["resolve"],
99+
});
100+
}
101+
102+
// ── tests ────────────────────────────────────────────────────────────
103+
104+
let tracingHandle: Handle;
105+
let tracer: ReturnType<typeof trace.getTracer>;
106+
107+
describe("tracingHandle", () => {
108+
beforeAll(async () => {
109+
provider.register();
110+
tracer = trace.getTracer("test");
111+
({ tracingHandle } = await import("./tracing-handle"));
112+
});
113+
114+
afterAll(async () => {
115+
await provider.shutdown();
116+
trace.disable();
117+
});
118+
119+
beforeEach(() => {
120+
exporter.reset();
121+
// Create a fresh "root" span that simulates sveltekit.handle.root
122+
// after promotion by SvelteKitServerSpanProcessor.
123+
fakeRootSpan = tracer.startSpan("sveltekit.handle.root", {
124+
kind: SpanKind.SERVER,
125+
});
126+
});
127+
128+
it("enriches the root span with HTTP attributes for a normal response", async () => {
129+
const response = await callHandle(
130+
mockEvent(),
131+
() => new Response("ok", { status: 200 }),
132+
);
133+
fakeRootSpan!.end();
134+
135+
expect(response.status).toBe(200);
136+
137+
const spans = getFinishedSpans();
138+
const root = spans.find((s) => s.name === "GET /test")!;
139+
expect(root).toBeDefined();
140+
expect(root.kind).toBe(SpanKind.SERVER);
141+
expect(root.attributes["http.request.method"]).toBe("GET");
142+
expect(root.attributes["url.path"]).toBe("/test");
143+
expect(root.attributes["url.scheme"]).toBe("https");
144+
expect(root.attributes["server.address"]).toBe("localhost");
145+
expect(root.attributes["http.route"]).toBe("/test");
146+
expect(root.attributes["user_agent.original"]).toBe("TestAgent/1.0");
147+
expect(root.attributes["http.response.status_code"]).toBe(200);
148+
expect(root.status.code).toBe(SpanStatusCode.UNSET);
149+
});
150+
151+
it("marks the root span as ERROR for 5xx responses", async () => {
152+
await callHandle(mockEvent(), () => new Response("fail", { status: 503 }));
153+
fakeRootSpan!.end();
154+
155+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
156+
expect(root.attributes["http.response.status_code"]).toBe(503);
157+
expect(root.status.code).toBe(SpanStatusCode.ERROR);
158+
});
159+
160+
it("does not mark 4xx as ERROR", async () => {
161+
await callHandle(
162+
mockEvent(),
163+
() => new Response("not found", { status: 404 }),
164+
);
165+
fakeRootSpan!.end();
166+
167+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
168+
expect(root.attributes["http.response.status_code"]).toBe(404);
169+
expect(root.status.code).toBe(SpanStatusCode.UNSET);
170+
});
171+
172+
it("records redirect status code without marking ERROR", async () => {
173+
await expect(
174+
callHandle(mockEvent(), () => {
175+
throw fakeRedirect(303, "/other");
176+
}),
177+
).rejects.toThrow();
178+
fakeRootSpan!.end();
179+
180+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
181+
expect(root.attributes["http.response.status_code"]).toBe(303);
182+
expect(root.status.code).toBe(SpanStatusCode.UNSET);
183+
});
184+
185+
it("records HttpError status and marks ERROR for 5xx", async () => {
186+
await expect(
187+
callHandle(mockEvent(), () => {
188+
throw fakeHttpError(502, "Bad Gateway");
189+
}),
190+
).rejects.toThrow();
191+
fakeRootSpan!.end();
192+
193+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
194+
expect(root.attributes["http.response.status_code"]).toBe(502);
195+
expect(root.status.code).toBe(SpanStatusCode.ERROR);
196+
expect(root.status.message).toBe("Bad Gateway");
197+
});
198+
199+
it("records HttpError status without ERROR for 4xx", async () => {
200+
await expect(
201+
callHandle(mockEvent(), () => {
202+
throw fakeHttpError(403, "Forbidden");
203+
}),
204+
).rejects.toThrow();
205+
fakeRootSpan!.end();
206+
207+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
208+
expect(root.attributes["http.response.status_code"]).toBe(403);
209+
expect(root.status.code).toBe(SpanStatusCode.UNSET);
210+
});
211+
212+
it("records status 500 and ERROR for unknown thrown errors", async () => {
213+
await expect(
214+
callHandle(mockEvent(), () => {
215+
throw new Error("unexpected");
216+
}),
217+
).rejects.toThrow("unexpected");
218+
fakeRootSpan!.end();
219+
220+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
221+
expect(root.attributes["http.response.status_code"]).toBe(500);
222+
expect(root.status.code).toBe(SpanStatusCode.ERROR);
223+
expect(root.status.message).toBe("unexpected");
224+
});
225+
226+
it("uses pathname as span name when route id is null", async () => {
227+
await callHandle(
228+
mockEvent({ routeId: null, pathname: "/static/file.css" }),
229+
() => new Response("ok", { status: 200 }),
230+
);
231+
fakeRootSpan!.end();
232+
233+
const root = getFinishedSpans().find(
234+
(s) => s.name === "GET /static/file.css",
235+
)!;
236+
expect(root).toBeDefined();
237+
expect(root.attributes["http.route"]).toBeUndefined();
238+
});
239+
240+
it("sets server.port when present", async () => {
241+
await callHandle(
242+
mockEvent({ port: "3000" }),
243+
() => new Response("ok", { status: 200 }),
244+
);
245+
fakeRootSpan!.end();
246+
247+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
248+
expect(root.attributes["server.port"]).toBe(3000);
249+
});
250+
251+
it("does not set server.port when empty", async () => {
252+
await callHandle(mockEvent(), () => new Response("ok", { status: 200 }));
253+
fakeRootSpan!.end();
254+
255+
const root = getFinishedSpans().find((s) => s.name === "GET /test")!;
256+
expect(root.attributes["server.port"]).toBeUndefined();
257+
});
258+
259+
it("is a no-op when no root span is available", async () => {
260+
fakeRootSpan = undefined;
261+
262+
const response = await callHandle(
263+
mockEvent(),
264+
() => new Response("ok", { status: 200 }),
265+
);
266+
267+
expect(response.status).toBe(200);
268+
expect(getFinishedSpans()).toHaveLength(0);
269+
});
270+
});

0 commit comments

Comments
 (0)