diff --git a/build.config.mjs b/build.config.mjs index d2670f8..91c9007 100644 --- a/build.config.mjs +++ b/build.config.mjs @@ -12,6 +12,7 @@ export default defineBuildConfig({ "src/cli.ts", "src/static.ts", "src/log.ts", + "src/tracing.ts", ...[ "deno", "bun", diff --git a/package.json b/package.json index f1a8fe0..9ebefde 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "./cli": "./dist/cli.mjs", "./static": "./dist/static.mjs", "./log": "./dist/log.mjs", + "./tracing": "./dist/tracing.mjs", ".": { "types": "./dist/types.d.mts", "deno": "./dist/adapters/deno.mjs", diff --git a/src/_middleware.ts b/src/_middleware.ts index f2f70d7..e3f7e56 100644 --- a/src/_middleware.ts +++ b/src/_middleware.ts @@ -1,3 +1,4 @@ +import { traceCall } from "./tracing.ts"; import type { Server, ServerRequest, @@ -8,21 +9,45 @@ import type { export function wrapFetch(server: Server): ServerHandler { const fetchHandler = server.options.fetch; const middleware = server.options.middleware || []; - return middleware.length === 0 - ? fetchHandler - : (request) => callMiddleware(request, fetchHandler, middleware, 0); + + if (middleware.length === 0) { + return (request) => + traceCall("fetch", async () => await fetchHandler(request), { + request, + server, + }); + } + + return (request) => + callMiddleware(server, request, fetchHandler, middleware, 0); } function callMiddleware( + server: Server, request: ServerRequest, fetchHandler: ServerHandler, middleware: ServerMiddleware[], index: number, ): Response | Promise { if (index === middleware.length) { - return fetchHandler(request); + return traceCall("fetch", async () => await fetchHandler(request), { + request, + server, + }); } - return middleware[index](request, () => - callMiddleware(request, fetchHandler, middleware, index + 1), + + const currentMiddleware = middleware[index]; + const next = () => + callMiddleware(server, request, fetchHandler, middleware, index + 1); + + return traceCall( + "middleware", + async () => await currentMiddleware(request, next), + { + request, + server, + index, + name: currentMiddleware?.name, + }, ); } diff --git a/src/tracing.ts b/src/tracing.ts new file mode 100644 index 0000000..33248ab --- /dev/null +++ b/src/tracing.ts @@ -0,0 +1,42 @@ +import { tracingChannel, type TracingChannel } from "node:diagnostics_channel"; +import type { Server, ServerRequest } from "./types.ts"; + +export type TraceDataMap = { + fetch: { request: ServerRequest; server: Server }; + middleware: { + request: ServerRequest; + server: Server; + index: number; + name?: string; + }; +}; + +export type TraceChannelName = keyof TraceDataMap; + +const channels: Record< + TraceChannelName, + TracingChannel +> = { + fetch: tracingChannel("srvx.fetch"), + middleware: tracingChannel("srvx.middleware"), +}; + +export function traceCall< + TChannel extends TraceChannelName, + TReturn, + TData extends TraceDataMap[TChannel], +>( + channel: TChannel, + exec: () => Promise, + data: TData, +): Promise { + return channels[channel].tracePromise(exec, data); +} + +export function traceSync< + TChannel extends TraceChannelName, + TReturn, + TData extends TraceDataMap[TChannel], +>(channel: TChannel, exec: () => TReturn, data: TData): TReturn { + return channels[channel].traceSync(exec, data); +} diff --git a/test/tracing.test.ts b/test/tracing.test.ts new file mode 100644 index 0000000..8b394fe --- /dev/null +++ b/test/tracing.test.ts @@ -0,0 +1,434 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { tracingChannel } from "node:diagnostics_channel"; +import { serve } from "../src/adapters/node.ts"; +import type { ServerMiddleware } from "../src/types.ts"; + +// Helper to create no-op handlers for unused tracing events +const noop = () => {}; + +describe("tracing channels", () => { + const cleanupFns: Array<() => void> = []; + + afterEach(() => { + // Clean up all subscriptions after each test + for (const cleanup of cleanupFns) { + cleanup(); + } + cleanupFns.length = 0; + }); + + it("should emit fetch tracing events", async () => { + const events: Array<{ type: string; method?: string }> = []; + + const fetchChannel = tracingChannel("srvx.fetch"); + + const startHandler = (data: any) => { + events.push({ type: "fetch.start", method: data.request.method }); + }; + + const endHandler = (data: any) => { + events.push({ type: "fetch.end", method: data.request.method }); + }; + + fetchChannel.subscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + + cleanupFns.push(() => { + fetchChannel.unsubscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + }); + + const server = serve({ + fetch: () => new Response("OK"), + manual: true, + }); + + const request = new Request("http://localhost:3000/test"); + await server.fetch(request); + + expect(events).toContainEqual({ type: "fetch.start", method: "GET" }); + expect(events).toContainEqual({ type: "fetch.end", method: "GET" }); + }); + + it("should emit middleware tracing events", async () => { + const events: Array<{ type: string; name?: string; index?: number }> = []; + + const middlewareChannel = tracingChannel("srvx.middleware"); + + const startHandler = (data: any) => { + events.push({ + type: "middleware.start", + name: data.name, + index: data.index, + }); + }; + + const endHandler = (data: any) => { + events.push({ + type: "middleware.end", + name: data.name, + index: data.index, + }); + }; + + middlewareChannel.subscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + + cleanupFns.push(() => { + middlewareChannel.unsubscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + }); + + const middleware1: ServerMiddleware = async (request, next) => { + return next(); + }; + Object.defineProperty(middleware1, "name", { value: "middleware1" }); + + const middleware2: ServerMiddleware = async (request, next) => { + return next(); + }; + Object.defineProperty(middleware2, "name", { value: "middleware2" }); + + const server = serve({ + fetch: () => new Response("OK"), + middleware: [middleware1, middleware2], + manual: true, + }); + + const request = new Request("http://localhost:3000/"); + await server.fetch(request); + + // Check that all middleware events were emitted + expect(events).toContainEqual({ + type: "middleware.start", + name: "middleware1", + index: 0, + }); + expect(events).toContainEqual({ + type: "middleware.end", + name: "middleware1", + index: 0, + }); + expect(events).toContainEqual({ + type: "middleware.start", + name: "middleware2", + index: 1, + }); + expect(events).toContainEqual({ + type: "middleware.end", + name: "middleware2", + index: 1, + }); + }); + + it("should emit asyncStart and asyncEnd events", async () => { + const events: Array = []; + + const middlewareChannel = tracingChannel("srvx.middleware"); + + const asyncStartHandler = () => { + events.push("middleware.asyncStart"); + }; + + const asyncEndHandler = () => { + events.push("middleware.asyncEnd"); + }; + + middlewareChannel.subscribe({ + start: noop, + end: noop, + asyncStart: asyncStartHandler, + asyncEnd: asyncEndHandler, + error: noop, + }); + + cleanupFns.push(() => { + middlewareChannel.unsubscribe({ + start: noop, + end: noop, + asyncStart: asyncStartHandler, + asyncEnd: asyncEndHandler, + error: noop, + }); + }); + + const middleware: ServerMiddleware = async (request, next) => { + return next(); + }; + + const server = serve({ + fetch: () => new Response("OK"), + middleware: [middleware], + manual: true, + }); + + const request = new Request("http://localhost:3000/"); + await server.fetch(request); + + expect(events).toContain("middleware.asyncStart"); + expect(events).toContain("middleware.asyncEnd"); + }); + + it("should emit error events on middleware errors", async () => { + const events: Array<{ type: string; error?: string }> = []; + + const middlewareChannel = tracingChannel("srvx.middleware"); + + const errorHandler = (data: any) => { + events.push({ type: "middleware.error", error: data.error?.message }); + }; + + middlewareChannel.subscribe({ + start: noop, + end: noop, + asyncStart: noop, + asyncEnd: noop, + error: errorHandler, + }); + + cleanupFns.push(() => { + middlewareChannel.unsubscribe({ + start: noop, + end: noop, + asyncStart: noop, + asyncEnd: noop, + error: errorHandler, + }); + }); + + const middleware: ServerMiddleware = async () => { + throw new Error("Test error"); + }; + + const server = serve({ + fetch: () => new Response("OK"), + middleware: [middleware], + manual: true, + }); + + const request = new Request("http://localhost:3000/"); + + // Expect the fetch to throw + await expect(server.fetch(request)).rejects.toThrow("Test error"); + + expect(events).toContainEqual({ + type: "middleware.error", + error: "Test error", + }); + }); + + it("should include request and server data in events", async () => { + let capturedData: any = null; + + const middlewareChannel = tracingChannel("srvx.middleware"); + + const startHandler = (data: any) => { + capturedData = data; + }; + + middlewareChannel.subscribe({ + start: startHandler, + end: noop, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + + cleanupFns.push(() => { + middlewareChannel.unsubscribe({ + start: startHandler, + end: noop, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + }); + + const middleware: ServerMiddleware = async (request, next) => { + return next(); + }; + + const server = serve({ + fetch: () => new Response("OK"), + middleware: [middleware], + manual: true, + port: 3000, + }); + + const request = new Request("http://localhost:3000/test"); + await server.fetch(request); + + expect(capturedData).toBeDefined(); + expect(capturedData.request).toBeDefined(); + expect(capturedData.server).toBeDefined(); + expect(capturedData.index).toBe(0); + expect(capturedData.server.options.port).toBe(3000); + }); + + it("should emit events for multiple middleware in sequence", async () => { + const events: Array<{ type: string; name: string }> = []; + + const middlewareChannel = tracingChannel("srvx.middleware"); + + const startHandler = (data: any) => { + events.push({ type: "start", name: data.name }); + }; + + const endHandler = (data: any) => { + events.push({ type: "end", name: data.name }); + }; + + middlewareChannel.subscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + + cleanupFns.push(() => { + middlewareChannel.unsubscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + }); + + const mw1: ServerMiddleware = async (req, next) => next(); + Object.defineProperty(mw1, "name", { value: "mw1" }); + + const mw2: ServerMiddleware = async (req, next) => next(); + Object.defineProperty(mw2, "name", { value: "mw2" }); + + const mw3: ServerMiddleware = async (req, next) => next(); + Object.defineProperty(mw3, "name", { value: "mw3" }); + + const server = serve({ + fetch: () => new Response("OK"), + middleware: [mw1, mw2, mw3], + manual: true, + }); + + const request = new Request("http://localhost:3000/"); + await server.fetch(request); + + // Verify all start and end events were emitted + const startEvents = events.filter((e) => e.type === "start"); + const endEvents = events.filter((e) => e.type === "end"); + + expect(startEvents).toHaveLength(3); + expect(endEvents).toHaveLength(3); + + expect(startEvents.map((e) => e.name)).toEqual(["mw1", "mw2", "mw3"]); + expect(endEvents.map((e) => e.name)).toEqual(["mw3", "mw2", "mw1"]); + }); + + it("should emit fetch events when no middleware present", async () => { + const events: Array = []; + + const fetchChannel = tracingChannel("srvx.fetch"); + + const startHandler = () => { + events.push("fetch.start"); + }; + + const endHandler = () => { + events.push("fetch.end"); + }; + + fetchChannel.subscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + + cleanupFns.push(() => { + fetchChannel.unsubscribe({ + start: startHandler, + end: endHandler, + asyncStart: noop, + asyncEnd: noop, + error: noop, + }); + }); + + const server = serve({ + fetch: () => new Response("OK"), + manual: true, + }); + + const request = new Request("http://localhost:3000/"); + await server.fetch(request); + + expect(events).toContain("fetch.start"); + expect(events).toContain("fetch.end"); + }); + + it("should provide result in asyncEnd events", async () => { + const results: Array = []; + + const middlewareChannel = tracingChannel("srvx.middleware"); + + const asyncEndHandler = (data: any) => { + results.push(data.result); + }; + + middlewareChannel.subscribe({ + start: noop, + end: noop, + asyncStart: noop, + asyncEnd: asyncEndHandler, + error: noop, + }); + + cleanupFns.push(() => { + middlewareChannel.unsubscribe({ + start: noop, + end: noop, + asyncStart: noop, + asyncEnd: asyncEndHandler, + error: noop, + }); + }); + + const middleware: ServerMiddleware = async (req, next) => next(); + + const server = serve({ + fetch: () => new Response("OK"), + middleware: [middleware], + manual: true, + }); + + const request = new Request("http://localhost:3000/"); + await server.fetch(request); + + // Result should be the Response object + expect(results).toHaveLength(1); + expect(results[0]).toBeDefined(); + expect(results[0]).toBeInstanceOf(Response); + }); +});