diff --git a/README.md b/README.md index 91492d19..3d8bc609 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ $ bunx --bun srvx | `jsx` | [examples/jsx](https://github.com/h3js/srvx/tree/main/examples/jsx/) | `npx giget gh:h3js/srvx/examples/jsx srvx-jsx` | | `node-handler` | [examples/node-handler](https://github.com/h3js/srvx/tree/main/examples/node-handler/) | `npx giget gh:h3js/srvx/examples/node-handler srvx-node-handler` | | `service-worker` | [examples/service-worker](https://github.com/h3js/srvx/tree/main/examples/service-worker/) | `npx giget gh:h3js/srvx/examples/service-worker srvx-service-worker` | +| `tracing` | [examples/tracing](https://github.com/h3js/srvx/tree/main/examples/tracing/) | `npx giget gh:h3js/srvx/examples/tracing srvx-tracing` | | `websocket` | [examples/websocket](https://github.com/h3js/srvx/tree/main/examples/websocket/) | `npx giget gh:h3js/srvx/examples/websocket srvx-websocket` | diff --git a/build.config.mjs b/build.config.mjs index d2670f8d..91c90070 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/docs/1.guide/1.index.md b/docs/1.guide/1.index.md index 3eda0aa9..a22ce65b 100644 --- a/docs/1.guide/1.index.md +++ b/docs/1.guide/1.index.md @@ -98,6 +98,7 @@ bun run server.mjs | `jsx` | [examples/jsx](https://github.com/h3js/srvx/tree/main/examples/jsx/) | `npx giget gh:h3js/srvx/examples/jsx srvx-jsx` | | `node-handler` | [examples/node-handler](https://github.com/h3js/srvx/tree/main/examples/node-handler/) | `npx giget gh:h3js/srvx/examples/node-handler srvx-node-handler` | | `service-worker` | [examples/service-worker](https://github.com/h3js/srvx/tree/main/examples/service-worker/) | `npx giget gh:h3js/srvx/examples/service-worker srvx-service-worker` | +| `tracing` | [examples/tracing](https://github.com/h3js/srvx/tree/main/examples/tracing/) | `npx giget gh:h3js/srvx/examples/tracing srvx-tracing` | | `websocket` | [examples/websocket](https://github.com/h3js/srvx/tree/main/examples/websocket/) | `npx giget gh:h3js/srvx/examples/websocket srvx-websocket` | diff --git a/examples/tracing/package.json b/examples/tracing/package.json new file mode 100644 index 00000000..75a735b2 --- /dev/null +++ b/examples/tracing/package.json @@ -0,0 +1,12 @@ +{ + "name": "srvx-examples-tracing", + "version": "0.0.0", + "type": "module", + "scripts": { + "start": "srvx --prod", + "dev": "srvx" + }, + "devDependencies": { + "srvx": "latest" + } +} diff --git a/examples/tracing/server.ts b/examples/tracing/server.ts new file mode 100644 index 00000000..ec26720f --- /dev/null +++ b/examples/tracing/server.ts @@ -0,0 +1,45 @@ +import { tracingPlugin } from "srvx/tracing"; + +export default { + plugins: [tracingPlugin()], + fetch(req: Request) { + return Response.json({ hello: "world!" }); + }, +}; + +// --- debug tracing channels --- + +debugChannel("srvx.middleware"); +debugChannel("srvx.fetch"); + +function debugChannel(name: string) { + const { tracingChannel } = process.getBuiltinModule( + "node:diagnostics_channel", + ); + + const log = (...args: unknown[]) => console.log(`[tracing:${name}]`, ...args); + const noop = () => {}; + const serializeData = (data: any) => + Object.entries(data) + .map(([key, value]) => { + if (key === "request") { + return `request(url=${(value as Request).url})`; + } + if (key === "server") { + return "server"; + } + if (key === "result") { + return `result(status=${(value as Response).status})`; + } + return `${key}=${value}`; + }) + .join(", "); + + tracingChannel(name).subscribe({ + start: noop, + end: noop, + asyncStart: (data) => log("asyncStart", serializeData(data)), + asyncEnd: (data) => log("asyncEnd", serializeData(data)), + error: (data) => log("error", serializeData(data)), + }); +} diff --git a/package.json b/package.json index fc88a60a..e89e1cad 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/pnpm-lock.yaml b/pnpm-lock.yaml index c4c80a9f..bb4ef1a8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -183,6 +183,12 @@ importers: specifier: link:../.. version: link:../.. + examples/tracing: + devDependencies: + srvx: + specifier: link:../.. + version: link:../.. + examples/websocket: devDependencies: crossws: diff --git a/src/tracing.ts b/src/tracing.ts new file mode 100644 index 00000000..c148c140 --- /dev/null +++ b/src/tracing.ts @@ -0,0 +1,84 @@ +import type { + Server, + ServerRequest, + ServerPlugin, + ServerMiddleware, +} from "./types.ts"; + +export type RequestEvent = { + server: Server; + request: ServerRequest; + middleware?: { + index: number; + handler: ServerMiddleware; + }; +}; + +/** + * Tracing plugin that adds diagnostics channel tracing to middleware and fetch handlers. + * + * This plugin wraps all middleware and the fetch handler with tracing instrumentation, + * allowing you to subscribe to `srvx.fetch` and `srvx.middleware` tracing channels. + * + * @example + * ```ts + * import { serve } from "srvx"; + * import { tracingPlugin } from "srvx/tracing"; + * + * const server = serve({ + * fetch: (req) => new Response("OK"), + * middleware: [myMiddleware], + * plugins: [tracingPlugin()], + * }); + * ``` + */ +export function tracingPlugin( + opts: { middleware?: boolean; fetch?: boolean } = {}, +): ServerPlugin { + return (server) => { + // No-op if tracingChannel is not available + const { tracingChannel } = + globalThis.process?.getBuiltinModule?.("node:diagnostics_channel") || {}; + if (!tracingChannel) { + return; + } + + // Wrap the fetch handler with tracing + if (opts.fetch !== false) { + const fetchChannel = tracingChannel("srvx.fetch"); + const originalFetch = server.options.fetch; + server.options.fetch = (request) => { + return fetchChannel.tracePromise( + async () => await originalFetch(request), + { request, server }, + ); + }; + } + + // Wrap middleware with tracing + if (opts.middleware !== false) { + const middlewareChannel = tracingChannel( + "srvx.middleware", + ); + const originalMiddleware = server.options.middleware; + const wrappedMiddleware: ServerMiddleware[] = originalMiddleware.map( + (handler, index) => { + const middleware = Object.freeze({ index, handler }); + return (request, next) => { + return middlewareChannel.tracePromise( + async () => await handler(request, next), + { request, server, middleware }, + ); + }; + }, + ); + + // Replace middleware array with wrapped versions + server.options.middleware.splice( + 0, + server.options.middleware.length, + ...wrappedMiddleware, + ); + } + }; +} diff --git a/test/tracing.test.ts b/test/tracing.test.ts new file mode 100644 index 00000000..83a4245b --- /dev/null +++ b/test/tracing.test.ts @@ -0,0 +1,443 @@ +import { describe, it, expect, afterEach } from "vitest"; +import { tracingChannel } from "node:diagnostics_channel"; +import { serve } from "../src/adapters/node.ts"; +import { tracingPlugin } from "../src/tracing.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"), + plugins: [tracingPlugin()], + 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.middleware.handler.name, + index: data.middleware.index, + }); + }; + + const endHandler = (data: any) => { + events.push({ + type: "middleware.end", + name: data.middleware.handler.name, + index: data.middleware.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], + plugins: [tracingPlugin()], + 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], + plugins: [tracingPlugin()], + 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], + plugins: [tracingPlugin()], + 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], + plugins: [tracingPlugin()], + 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.middleware.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.middleware.handler.name }); + }; + + const endHandler = (data: any) => { + events.push({ type: "end", name: data.middleware.handler.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], + plugins: [tracingPlugin()], + 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"), + plugins: [tracingPlugin()], + 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], + plugins: [tracingPlugin()], + 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); + }); +});