diff --git a/src/tracing.ts b/src/tracing.ts index 561051540..de34c6033 100644 --- a/src/tracing.ts +++ b/src/tracing.ts @@ -142,3 +142,31 @@ export function tracingPlugin(traceOpts?: TracingPluginOptions): H3Plugin { return h3; }; } + +/** + * Wraps an event handler so its execution is traced via the `h3.request` + * diagnostics channel with `type: "route"`. Intended to be called once per + * handler at initialization time (e.g. during codegen or module load), not + * per request. + * + * Returns the handler unchanged when `diagnostics_channel` is unavailable + * or the handler is already traced. + */ +export function wrapHandlerWithTracing(handler: EventHandler): EventHandler { + const { tracingChannel } = globalThis.process?.getBuiltinModule?.("diagnostics_channel") ?? {}; + if (!tracingChannel) { + return handler; + } + if ((handler as MaybeTracedEventHandler).__traced__) { + return handler; + } + const channel = tracingChannel("h3.request"); + const wrapped: MaybeTracedEventHandler = (...args) => { + return channel.tracePromise(async () => handler(...args), { + event: args[0], + type: "route", + } satisfies TracingRequestEvent); + }; + wrapped.__traced__ = true; + return wrapped; +} diff --git a/test/tracing.test.ts b/test/tracing.test.ts index 652924e19..dff06d8cd 100644 --- a/test/tracing.test.ts +++ b/test/tracing.test.ts @@ -1157,3 +1157,80 @@ describe("tracing channels for H3Core instances", () => { } }); }); + +describe("wrapHandlerWithTracing", () => { + it("wraps a handler so route traces are emitted", async () => { + const listener = createTracingListener(); + const { H3Core } = await import("../src/h3.ts"); + const { wrapHandlerWithTracing } = await import("../src/tracing.ts"); + const { H3Event } = await import("../src/event.ts"); + + try { + const app = new H3Core(); + const routeHandler = wrapHandlerWithTracing(() => "traced response"); + + app["~findRoute"] = (event: any) => { + if (event.url.pathname === "/test" && event.req.method === "GET") { + return { data: { method: "GET", route: "/test", handler: routeHandler }, params: {} }; + } + return undefined; + }; + + const request = new Request("http://localhost/test", { method: "GET" }); + const event = new H3Event(request, undefined, app as any); + + await app.handler(event); + await new Promise((resolve) => setTimeout(resolve, 10)); + + const routeEvents = listener.events.filter((e) => e.asyncStart?.data.type === "route"); + expect(routeEvents.some((e) => e.asyncStart?.data.event.url.pathname === "/test")).toBe(true); + } finally { + listener.cleanup(); + } + }); + + it("is idempotent — wrapping twice returns the same wrapper", async () => { + const { wrapHandlerWithTracing } = await import("../src/tracing.ts"); + + const handler = () => "ok"; + const wrapped = wrapHandlerWithTracing(handler); + const doubleWrapped = wrapHandlerWithTracing(wrapped); + + expect(doubleWrapped).toBe(wrapped); + expect((wrapped as any).__traced__).toBe(true); + }); + + it("emits exactly one trace per request on a pre-wrapped handler", async () => { + const listener = createTracingListener(); + const { H3Core } = await import("../src/h3.ts"); + const { wrapHandlerWithTracing } = await import("../src/tracing.ts"); + const { H3Event } = await import("../src/event.ts"); + + try { + const app = new H3Core(); + const routeHandler = wrapHandlerWithTracing(() => "ok"); + + app["~findRoute"] = () => ({ + data: { method: "GET" as const, route: "/test", handler: routeHandler }, + params: {}, + }); + + const run = async () => { + const request = new Request("http://localhost/test", { method: "GET" }); + const event = new H3Event(request, undefined, app as any); + await app.handler(event); + }; + + await run(); + await run(); + await run(); + + await new Promise((resolve) => setTimeout(resolve, 10)); + + const routeAsyncStarts = listener.events.filter((e) => e.asyncStart?.data.type === "route"); + expect(routeAsyncStarts.length).toBe(3); + } finally { + listener.cleanup(); + } + }); +});