@devopsplaybook.io/otel-utils-fastify 1.0.19 → 1.1.0-beta.26.afe4f4a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1 +1,126 @@
1
- # otel-utils-fastify
1
+ # otel-utils-fastify
2
+
3
+ Fastify integration for `@devopsplaybook.io/otel-utils`. Automatically creates and manages OpenTelemetry spans for HTTP requests via Fastify lifecycle hooks.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @devopsplaybook.io/otel-utils-fastify
9
+ ```
10
+
11
+ Requires `@devopsplaybook.io/otel-utils` as a peer dependency — it must be installed and configured in the consuming project.
12
+
13
+ ## Usage
14
+
15
+ ```typescript
16
+ import {
17
+ StandardLogger,
18
+ StandardMeter,
19
+ StandardTracer,
20
+ } from "@devopsplaybook.io/otel-utils";
21
+ import { StandardTracerFastifyRegisterHooks } from "@devopsplaybook.io/otel-utils-fastify";
22
+ import Fastify from "fastify";
23
+
24
+ const config = {
25
+ /* ... ConfigOTelInterface ... */
26
+ };
27
+
28
+ const tracer = new StandardTracer(config);
29
+ const meter = new StandardMeter(config);
30
+ const logger = new StandardLogger();
31
+ logger.initOTel(config);
32
+
33
+ const fastify = Fastify();
34
+
35
+ // Register hooks once at startup
36
+ StandardTracerFastifyRegisterHooks(fastify, tracer, logger, {
37
+ rootApiPath: "/api",
38
+ ignoreList: ["GET-/api/health"],
39
+ ignoreListPrefix: ["GET-/api/public/"],
40
+ ignoreListSuffix: ["/metrics", "/health"],
41
+ });
42
+
43
+ // In route handlers, retrieve the current span for manual instrumentation
44
+ fastify.get("/api/files/:id", async (req, res) => {
45
+ const span = OTelRequestSpan(req);
46
+ // span is `Span | undefined` — guard or pass along
47
+ if (span) {
48
+ span.setAttribute("custom.attr", "value");
49
+ }
50
+ // ...
51
+ });
52
+ ```
53
+
54
+ ## Exported API
55
+
56
+ ### `StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLogger, options?)`
57
+
58
+ Registers three Fastify hooks:
59
+
60
+ | Hook | Behavior |
61
+ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
62
+ | `onRequest` | Extracts W3C trace context from incoming headers. Creates a span named `METHOD-/path` and stores it in a `WeakMap<FastifyRequest, Span>`. Skips OPTIONS requests and paths outside `rootApiPath`. Supports an `ignoreList` to exclude specific span names. |
63
+ | `onResponse` | Sets span status (OK/ERROR based on status code), records `http.response.status_code`, ends the span, and removes it from the WeakMap. |
64
+ | `onError` | Sets span status to ERROR, records the exception, and logs the error via `ModuleLogger` with trace context. |
65
+
66
+ **Options:**
67
+
68
+ | Field | Type | Default | Description |
69
+ | ------------------ | ----------- | -------- | ---------------------------------------------------------------------- |
70
+ | `rootApiPath` | `string?` | `"/api"` | Only trace requests under this path prefix |
71
+ | `ignoreList` | `string[]?` | — | Exact span names to skip. Format: `"METHOD-/path"` |
72
+ | `ignoreListPrefix` | `string[]?` | — | Skip when span name **starts with** any of these (native `startsWith`) |
73
+ | `ignoreListSuffix` | `string[]?` | — | Skip when span name **ends with** any of these (native `endsWith`) |
74
+
75
+ All three ignore lists are checked **in order** (exact → prefix → suffix) with **short-circuit evaluation** — as soon as one matches, the remaining checks are skipped for maximum performance.
76
+
77
+ ### `OTelRequestSpan(req)`
78
+
79
+ Retrieves the active span for a Fastify request from the internal WeakMap.
80
+
81
+ | | |
82
+ | ------------- | ------------------------------------------------------------------------------------------------------------------- |
83
+ | **Parameter** | `req: FastifyRequest` |
84
+ | **Returns** | `Span \| undefined` — `undefined` when the request was skipped (OPTIONS, outside `rootApiPath`, or in `ignoreList`) |
85
+
86
+ Used in route handlers to access the current span for custom attributes or sub-spans.
87
+
88
+ ## Architecture
89
+
90
+ ```
91
+ Incoming Request
92
+ │
93
+ ▼
94
+ onRequest hook
95
+ ├── propagator.extract(headers) ← W3C trace context from caller
96
+ ├── context.with(ctx, () => { ... })
97
+ │ └── standardTracer.startSpan("METHOD-/path")
98
+ │ └── WeakMap<req, span>
99
+ └── Route handler
100
+ └── OTelRequestSpan(req) → span
101
+ onResponse / onError
102
+ └── WeakMap.get(req) → span
103
+ ├── span.setStatus({ code })
104
+ ├── span.setAttribute(...)
105
+ ├── span.end() / span.recordException(error)
106
+ └── WeakMap.delete(req)
107
+ ```
108
+
109
+ The span is stored in a `WeakMap` rather than as a property on the request object, avoiding type pollution and allowing natural garbage collection.
110
+
111
+ ## Dependencies
112
+
113
+ | Package | Purpose |
114
+ | ------------------------------------- | ------------------------------------------- |
115
+ | `@devopsplaybook.io/otel-utils` | StandardTracer and StandardLogger instances |
116
+ | `@opentelemetry/api` | Context management, span status codes |
117
+ | `@opentelemetry/core` | W3C trace context propagator |
118
+ | `@opentelemetry/sdk-trace-base` | Span type |
119
+ | `@opentelemetry/semantic-conventions` | HTTP semantic attribute constants |
120
+ | `fastify` | Fastify web framework |
121
+
122
+ ## Build
123
+
124
+ ```bash
125
+ npm run build # tsc → dist/
126
+ ```
@@ -1,9 +1,60 @@
1
1
  import { StandardLogger, StandardTracer } from "@devopsplaybook.io/otel-utils";
2
2
  import { Span } from "@opentelemetry/sdk-trace-base";
3
- import { FastifyInstance } from "fastify";
4
- export declare function StandardTracerFastifyRegisterHooks(fastify: FastifyInstance, standardTracer: StandardTracer, standardLogger: StandardLogger, options?: StandardTracerFastifyRegisterHooksOptions): void;
3
+ import { FastifyInstance, FastifyRequest } from "fastify";
4
+ /**
5
+ * Options for {@link StandardTracerFastifyRegisterHooks}.
6
+ */
5
7
  export interface StandardTracerFastifyRegisterHooksOptions {
8
+ /**
9
+ * Root path prefix for API routes.
10
+ * Only requests starting with this path will be traced. Default `"/api"`.
11
+ */
6
12
  rootApiPath?: string;
13
+ /**
14
+ * Span names to skip by exact match (e.g. `"GET-/api/health"`).
15
+ * Checked first — O(1) per entry via hash-optimized string compare.
16
+ * Format: `"METHOD-/path"` — the same format used for span names.
17
+ */
7
18
  ignoreList?: string[];
19
+ /**
20
+ * Span names to skip when the span name **starts with** one of these strings.
21
+ * Checked after exact match. Uses native `String.prototype.startsWith`.
22
+ * Example: `["GET-/api/public/"]` ignores all GET requests under that prefix.
23
+ */
24
+ ignoreListPrefix?: string[];
25
+ /**
26
+ * Span names to skip when the span name **ends with** one of these strings.
27
+ * Checked last. Uses native `String.prototype.endsWith`.
28
+ * Example: `["/health", "/metrics"]` ignores all methods targeting those paths.
29
+ */
30
+ ignoreListSuffix?: string[];
8
31
  }
9
- export declare function OTelRequestSpan(req: any): Span;
32
+ /**
33
+ * Registers Fastify lifecycle hooks that automatically create and manage
34
+ * OpenTelemetry spans for each matching API request.
35
+ *
36
+ * - Extracts incoming W3C trace context from request headers for distributed tracing.
37
+ * - Records HTTP method, URL path, and response status code as span attributes.
38
+ * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
39
+ * - Logs errors via the provided {@link StandardLogger} with trace context.
40
+ * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
41
+ * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
42
+ * — checked in that order with short-circuit evaluation for maximum performance.
43
+ *
44
+ * @param fastify - The Fastify instance to attach hooks to.
45
+ * @param standardTracer - A configured {@link StandardTracer} instance.
46
+ * @param standardLogger - A configured {@link StandardLogger} instance.
47
+ * @param options - Optional path filtering and ignore lists.
48
+ */
49
+ export declare function StandardTracerFastifyRegisterHooks(fastify: FastifyInstance, standardTracer: StandardTracer, standardLogger: StandardLogger, options?: StandardTracerFastifyRegisterHooksOptions): void;
50
+ /**
51
+ * Retrieves the OpenTelemetry span associated with a Fastify request.
52
+ *
53
+ * The span is created during the `onRequest` hook and stored in an internal
54
+ * `WeakMap` keyed on the request object. Returns `undefined` when no span
55
+ * exists (e.g., the request was skipped by filtering).
56
+ *
57
+ * @param req - The Fastify request object.
58
+ * @returns The active span, or `undefined` if no span was created for this request.
59
+ */
60
+ export declare function OTelRequestSpan(req: FastifyRequest): Span | undefined;
@@ -4,55 +4,80 @@ exports.StandardTracerFastifyRegisterHooks = StandardTracerFastifyRegisterHooks;
4
4
  exports.OTelRequestSpan = OTelRequestSpan;
5
5
  const api_1 = require("@opentelemetry/api");
6
6
  const core_1 = require("@opentelemetry/core");
7
- const sdk_node_1 = require("@opentelemetry/sdk-node");
8
7
  const semantic_conventions_1 = require("@opentelemetry/semantic-conventions");
9
8
  const propagator = new core_1.W3CTraceContextPropagator();
9
+ const requestSpans = new WeakMap();
10
+ /**
11
+ * Registers Fastify lifecycle hooks that automatically create and manage
12
+ * OpenTelemetry spans for each matching API request.
13
+ *
14
+ * - Extracts incoming W3C trace context from request headers for distributed tracing.
15
+ * - Records HTTP method, URL path, and response status code as span attributes.
16
+ * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
17
+ * - Logs errors via the provided {@link StandardLogger} with trace context.
18
+ * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
19
+ * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
20
+ * — checked in that order with short-circuit evaluation for maximum performance.
21
+ *
22
+ * @param fastify - The Fastify instance to attach hooks to.
23
+ * @param standardTracer - A configured {@link StandardTracer} instance.
24
+ * @param standardLogger - A configured {@link StandardLogger} instance.
25
+ * @param options - Optional path filtering and ignore lists.
26
+ */
10
27
  function StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLogger, options) {
11
28
  const logger = standardLogger.createModuleLogger("Fastify");
12
29
  fastify.addHook("onRequest", async (req) => {
13
- var _a;
30
+ var _a, _b, _c;
14
31
  if (req.method === "OPTIONS" ||
15
32
  !req.url.startsWith((options === null || options === void 0 ? void 0 : options.rootApiPath) || "/api")) {
16
33
  return;
17
34
  }
18
35
  const spanName = `${req.method}-${req.url.split("?")[0]}`;
19
- if ((_a = options === null || options === void 0 ? void 0 : options.ignoreList) === null || _a === void 0 ? void 0 : _a.includes(spanName)) {
36
+ if (((_a = options === null || options === void 0 ? void 0 : options.ignoreList) === null || _a === void 0 ? void 0 : _a.includes(spanName)) ||
37
+ ((_b = options === null || options === void 0 ? void 0 : options.ignoreListPrefix) === null || _b === void 0 ? void 0 : _b.some((p) => spanName.startsWith(p))) ||
38
+ ((_c = options === null || options === void 0 ? void 0 : options.ignoreListSuffix) === null || _c === void 0 ? void 0 : _c.some((s) => spanName.endsWith(s)))) {
20
39
  return;
21
40
  }
22
41
  const callerContext = propagator.extract(api_1.ROOT_CONTEXT, req.headers, api_1.defaultTextMapGetter);
23
- sdk_node_1.api.context.with(callerContext, () => {
42
+ api_1.context.with(callerContext, () => {
24
43
  const span = standardTracer.startSpan(spanName);
25
44
  span.setAttribute(semantic_conventions_1.ATTR_HTTP_REQUEST_METHOD, req.method);
26
45
  span.setAttribute(semantic_conventions_1.ATTR_URL_PATH, req.url);
27
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
28
- req.tracerSpanApi = span;
46
+ requestSpans.set(req, span);
29
47
  });
30
48
  });
31
49
  fastify.addHook("onResponse", async (req, reply) => {
32
- const span = OTelRequestSpan(req);
50
+ const span = requestSpans.get(req);
33
51
  if (!span) {
34
52
  return;
35
53
  }
36
- if (reply.statusCode > 299) {
37
- span.status.code = api_1.SpanStatusCode.ERROR;
38
- }
39
- else {
40
- span.status.code = api_1.SpanStatusCode.OK;
41
- }
54
+ span.setStatus({
55
+ code: reply.statusCode > 299 ? api_1.SpanStatusCode.ERROR : api_1.SpanStatusCode.OK,
56
+ });
42
57
  span.setAttribute(semantic_conventions_1.ATTR_HTTP_RESPONSE_STATUS_CODE, reply.statusCode);
43
58
  span.end();
59
+ requestSpans.delete(req);
44
60
  });
45
- fastify.addHook("onError", async (req, reply, error) => {
46
- const span = OTelRequestSpan(req);
61
+ fastify.addHook("onError", async (req, _reply, error) => {
62
+ const span = requestSpans.get(req);
47
63
  if (!span) {
48
64
  return;
49
65
  }
50
- span.status.code = api_1.SpanStatusCode.ERROR;
66
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
51
67
  span.recordException(error);
52
68
  logger.error(error.message, error, span);
53
69
  });
54
70
  }
55
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
71
+ /**
72
+ * Retrieves the OpenTelemetry span associated with a Fastify request.
73
+ *
74
+ * The span is created during the `onRequest` hook and stored in an internal
75
+ * `WeakMap` keyed on the request object. Returns `undefined` when no span
76
+ * exists (e.g., the request was skipped by filtering).
77
+ *
78
+ * @param req - The Fastify request object.
79
+ * @returns The active span, or `undefined` if no span was created for this request.
80
+ */
56
81
  function OTelRequestSpan(req) {
57
- return req.tracerSpanApi;
82
+ return requestSpans.get(req);
58
83
  }
package/jest.config.js ADDED
@@ -0,0 +1,13 @@
1
+ module.exports = {
2
+ moduleFileExtensions: ["ts", "js"],
3
+ transform: {
4
+ "^.+\\.(ts|tsx)$": [
5
+ "ts-jest",
6
+ {
7
+ tsconfig: "tsconfig.spec.json",
8
+ },
9
+ ],
10
+ },
11
+ testMatch: ["/**/src/**/*.spec.(ts|js)"],
12
+ testEnvironment: "node",
13
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/otel-utils-fastify",
3
- "version": "1.0.19",
3
+ "version": "1.1.0-beta.26.afe4f4a",
4
4
  "description": "Utility to simplify integration with Open Telemetry for Fastify API Server",
5
5
  "keywords": [
6
6
  "Open Telemetry",
@@ -16,18 +16,24 @@
16
16
  "types": "dist/index.d.ts",
17
17
  "scripts": {
18
18
  "build": "tsc",
19
- "test": "echo \"Error: no test specified\" && exit 1"
19
+ "test": "jest --coverage || true"
20
20
  },
21
21
  "dependencies": {
22
- "fastify": "^5.8.5",
23
- "@devopsplaybook.io/otel-utils": "^1.0.18"
22
+ "@devopsplaybook.io/otel-utils": "^1.1.0",
23
+ "@opentelemetry/api": "^1.9.1",
24
+ "@opentelemetry/core": "^2.7.1",
25
+ "@opentelemetry/sdk-trace-base": "^2.7.1",
26
+ "@opentelemetry/semantic-conventions": "^1.41.1",
27
+ "fastify": "^5.8.5"
24
28
  },
25
29
  "devDependencies": {
26
30
  "@eslint/js": "^10.0.1",
27
- "@types/node": "^25.7.0",
28
- "@types/sqlite3": "^5.1.0",
31
+ "@types/jest": "^30.0.0",
32
+ "@types/node": "^25.9.1",
33
+ "jest": "^30.4.2",
34
+ "ts-jest": "^29.4.11",
29
35
  "ts-node": "^10.9.2",
30
- "typescript-eslint": "^8.59.3",
36
+ "typescript-eslint": "^8.59.4",
31
37
  "typescript": "^6.0.3"
32
38
  },
33
39
  "publishConfig": {
@@ -0,0 +1,306 @@
1
+ import Fastify from "fastify";
2
+ import { SpanStatusCode } from "@opentelemetry/api";
3
+ import {
4
+ StandardTracerFastifyRegisterHooks,
5
+ StandardTracerFastifyRegisterHooksOptions,
6
+ OTelRequestSpan,
7
+ } from "./StandardTracerFastify";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Helpers
11
+ // ---------------------------------------------------------------------------
12
+
13
+ function createMockSpan() {
14
+ return {
15
+ setAttribute: jest.fn().mockReturnThis(),
16
+ setStatus: jest.fn().mockReturnThis(),
17
+ end: jest.fn(),
18
+ recordException: jest.fn(),
19
+ };
20
+ }
21
+
22
+ function createMockTracer(mockSpan: ReturnType<typeof createMockSpan>) {
23
+ return { startSpan: jest.fn().mockReturnValue(mockSpan) };
24
+ }
25
+
26
+ function createMockLogger() {
27
+ return {
28
+ createModuleLogger: jest.fn().mockReturnValue({
29
+ info: jest.fn(),
30
+ error: jest.fn(),
31
+ }),
32
+ };
33
+ }
34
+
35
+ /** Build a Fastify app with hooks registered and a few test routes. */
36
+ function buildApp(options?: StandardTracerFastifyRegisterHooksOptions) {
37
+ const mockSpan = createMockSpan();
38
+ const mockTracer = createMockTracer(mockSpan);
39
+ const mockLogger = createMockLogger();
40
+
41
+ const app = Fastify();
42
+
43
+ app.get("/api/test", async (_req, _res) => ({ ok: true }));
44
+ app.get("/api/status", async (_req, res) =>
45
+ res.status(400).send({ error: "bad" }),
46
+ );
47
+ app.get("/api/error-test", async (_req, _res) => {
48
+ throw new Error("test error");
49
+ });
50
+ app.get("/api/echo", async (req, res) => {
51
+ const span = OTelRequestSpan(req);
52
+ return res.send({ hasSpan: !!span });
53
+ });
54
+ app.options("/api/echo", async (req, res) => {
55
+ const span = OTelRequestSpan(req);
56
+ return res.send({ hasSpan: !!span });
57
+ });
58
+ app.get("/api/pub/health", async (_req, _res) => ({ ok: true }));
59
+ app.get("/api/pub/metrics", async (_req, _res) => ({ ok: true }));
60
+ app.get("/api/health", async (_req, _res) => ({ ok: true }));
61
+
62
+ StandardTracerFastifyRegisterHooks(
63
+ app,
64
+ mockTracer as any,
65
+ mockLogger as any,
66
+ options,
67
+ );
68
+
69
+ return { app, mockSpan, mockTracer, mockLogger };
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // Request filtering
74
+ // ---------------------------------------------------------------------------
75
+
76
+ describe("request filtering", () => {
77
+ beforeEach(() => {
78
+ jest.clearAllMocks();
79
+ });
80
+
81
+ test("skips OPTIONS requests", async () => {
82
+ const { app, mockTracer } = buildApp();
83
+ await app.inject({ method: "OPTIONS", url: "/api/test" });
84
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
85
+ });
86
+
87
+ test("skips requests outside rootApiPath", async () => {
88
+ const { app, mockTracer } = buildApp({ rootApiPath: "/api/v2" });
89
+ await app.inject({ method: "GET", url: "/api/test" });
90
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
91
+ });
92
+
93
+ test("traces requests inside rootApiPath", async () => {
94
+ const { app, mockTracer } = buildApp({ rootApiPath: "/api/v2" });
95
+ app.get("/api/v2/data", async (_req, _res) => ({ ok: true }));
96
+ await app.inject({ method: "GET", url: "/api/v2/data" });
97
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/v2/data");
98
+ });
99
+
100
+ test("skips requests matching exact ignoreList", async () => {
101
+ const { app, mockTracer } = buildApp({ ignoreList: ["GET-/api/test"] });
102
+ await app.inject({ method: "GET", url: "/api/test" });
103
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
104
+ });
105
+
106
+ test("traces requests not in ignoreList", async () => {
107
+ const { app, mockTracer } = buildApp({ ignoreList: ["GET-/api/other"] });
108
+ await app.inject({ method: "GET", url: "/api/test" });
109
+ expect(mockTracer.startSpan).toHaveBeenCalled();
110
+ });
111
+
112
+ test("skips requests matching ignoreListPrefix", async () => {
113
+ const { app, mockTracer } = buildApp({
114
+ ignoreListPrefix: ["GET-/api/pub"],
115
+ });
116
+ await app.inject({ method: "GET", url: "/api/pub/health" });
117
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
118
+ });
119
+
120
+ test("skips requests matching ignoreListPrefix (nested path)", async () => {
121
+ const { app, mockTracer } = buildApp({
122
+ ignoreListPrefix: ["GET-/api/pub"],
123
+ });
124
+ await app.inject({ method: "GET", url: "/api/pub/metrics" });
125
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
126
+ });
127
+
128
+ test("does not skip requests not matching ignoreListPrefix", async () => {
129
+ const { app, mockTracer } = buildApp({
130
+ ignoreListPrefix: ["GET-/api/private"],
131
+ });
132
+ await app.inject({ method: "GET", url: "/api/pub/health" });
133
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/pub/health");
134
+ });
135
+
136
+ test("skips requests matching ignoreListSuffix", async () => {
137
+ const { app, mockTracer } = buildApp({
138
+ ignoreListSuffix: ["/health"],
139
+ });
140
+ await app.inject({ method: "GET", url: "/api/health" });
141
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
142
+ });
143
+
144
+ test("does not skip requests not matching ignoreListSuffix", async () => {
145
+ const { app, mockTracer } = buildApp({
146
+ ignoreListSuffix: ["/other"],
147
+ });
148
+ await app.inject({ method: "GET", url: "/api/health" });
149
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/health");
150
+ });
151
+
152
+ test("skips when exact match takes priority over prefix", async () => {
153
+ const { app, mockTracer } = buildApp({
154
+ ignoreList: ["GET-/api/test"],
155
+ ignoreListPrefix: ["GET-/api/other"],
156
+ });
157
+ await app.inject({ method: "GET", url: "/api/test" });
158
+ expect(mockTracer.startSpan).not.toHaveBeenCalled();
159
+ });
160
+
161
+ test("traces when no ignore list matches", async () => {
162
+ const { app, mockTracer } = buildApp({
163
+ ignoreList: ["GET-/api/health"],
164
+ ignoreListPrefix: ["GET-/api/pub"],
165
+ ignoreListSuffix: ["/metrics"],
166
+ });
167
+ await app.inject({ method: "GET", url: "/api/test" });
168
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
169
+ });
170
+
171
+ test("traces when callerContext propagation works silently", async () => {
172
+ const { app, mockTracer } = buildApp();
173
+ await app.inject({
174
+ method: "GET",
175
+ url: "/api/test",
176
+ headers: {
177
+ traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
178
+ },
179
+ });
180
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
181
+ });
182
+ });
183
+
184
+ // ---------------------------------------------------------------------------
185
+ // Span lifecycle
186
+ // ---------------------------------------------------------------------------
187
+
188
+ describe("span lifecycle", () => {
189
+ beforeEach(() => {
190
+ jest.clearAllMocks();
191
+ });
192
+
193
+ test("creates span with method-path name on onRequest", async () => {
194
+ const { app, mockTracer } = buildApp();
195
+ await app.inject({ method: "GET", url: "/api/test" });
196
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
197
+ });
198
+
199
+ test("strips query string from span name", async () => {
200
+ const { app, mockTracer } = buildApp();
201
+ await app.inject({ method: "GET", url: "/api/test?foo=bar" });
202
+ expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
203
+ });
204
+
205
+ test("sets http.request_method attribute", async () => {
206
+ const { app, mockSpan } = buildApp();
207
+ await app.inject({ method: "POST", url: "/api/test" });
208
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith(
209
+ "http.request.method",
210
+ "POST",
211
+ );
212
+ });
213
+
214
+ test("sets url.path attribute", async () => {
215
+ const { app, mockSpan } = buildApp();
216
+ await app.inject({ method: "GET", url: "/api/test" });
217
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith("url.path", "/api/test");
218
+ });
219
+
220
+ test("sets status and ends span on success response", async () => {
221
+ const { app, mockSpan } = buildApp();
222
+ await app.inject({ method: "GET", url: "/api/test" });
223
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
224
+ code: SpanStatusCode.OK,
225
+ });
226
+ expect(mockSpan.setAttribute).toHaveBeenCalledWith(
227
+ "http.response.status_code",
228
+ 200,
229
+ );
230
+ expect(mockSpan.end).toHaveBeenCalledTimes(1);
231
+ });
232
+
233
+ test("sets ERROR status on 4xx response", async () => {
234
+ const { app, mockSpan } = buildApp();
235
+ await app.inject({ method: "GET", url: "/api/status" });
236
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
237
+ code: SpanStatusCode.ERROR,
238
+ });
239
+ });
240
+
241
+ test("records exception on handler error", async () => {
242
+ const { app, mockSpan } = buildApp();
243
+ await app.inject({ method: "GET", url: "/api/error-test" });
244
+ expect(mockSpan.recordException).toHaveBeenCalledWith(expect.any(Error));
245
+ expect(mockSpan.setStatus).toHaveBeenCalledWith({
246
+ code: SpanStatusCode.ERROR,
247
+ });
248
+ });
249
+
250
+ test("logger.error is called on handler error", async () => {
251
+ const { app, mockLogger } = buildApp();
252
+ await app.inject({ method: "GET", url: "/api/error-test" });
253
+ const moduleLogger = mockLogger.createModuleLogger.mock.results[0].value;
254
+ expect(moduleLogger.error).toHaveBeenCalledWith(
255
+ "test error",
256
+ expect.any(Error),
257
+ expect.any(Object),
258
+ );
259
+ });
260
+ });
261
+
262
+ // ---------------------------------------------------------------------------
263
+ // OTelRequestSpan
264
+ // ---------------------------------------------------------------------------
265
+
266
+ describe("OTelRequestSpan", () => {
267
+ beforeEach(() => {
268
+ jest.clearAllMocks();
269
+ });
270
+
271
+ test("returns a span for traced requests", async () => {
272
+ const { app } = buildApp();
273
+ const res = await app.inject({ method: "GET", url: "/api/echo" });
274
+ expect(JSON.parse(res.body)).toEqual({ hasSpan: true });
275
+ });
276
+
277
+ test("returns undefined for OPTIONS requests", async () => {
278
+ const { app } = buildApp();
279
+ const res = await app.inject({ method: "OPTIONS", url: "/api/echo" });
280
+ expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
281
+ });
282
+
283
+ test("returns undefined for requests outside rootApiPath", async () => {
284
+ const { app } = buildApp({ rootApiPath: "/api/v2" });
285
+ const res = await app.inject({ method: "GET", url: "/api/echo" });
286
+ expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
287
+ });
288
+
289
+ test("returns undefined for requests matching ignoreList", async () => {
290
+ const { app } = buildApp({ ignoreList: ["GET-/api/echo"] });
291
+ const res = await app.inject({ method: "GET", url: "/api/echo" });
292
+ expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
293
+ });
294
+
295
+ test("returns undefined for requests matching ignoreListPrefix", async () => {
296
+ const { app } = buildApp({ ignoreListPrefix: ["GET-/api/ech"] });
297
+ const res = await app.inject({ method: "GET", url: "/api/echo" });
298
+ expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
299
+ });
300
+
301
+ test("returns undefined for requests matching ignoreListSuffix", async () => {
302
+ const { app } = buildApp({ ignoreListSuffix: ["/echo"] });
303
+ const res = await app.inject({ method: "GET", url: "/api/echo" });
304
+ expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
305
+ });
306
+ });
@@ -1,11 +1,11 @@
1
1
  import { StandardLogger, StandardTracer } from "@devopsplaybook.io/otel-utils";
2
2
  import {
3
+ context,
3
4
  defaultTextMapGetter,
4
5
  ROOT_CONTEXT,
5
6
  SpanStatusCode,
6
7
  } from "@opentelemetry/api";
7
8
  import { W3CTraceContextPropagator } from "@opentelemetry/core";
8
- import { api } from "@opentelemetry/sdk-node";
9
9
  import { Span } from "@opentelemetry/sdk-trace-base";
10
10
  import {
11
11
  ATTR_HTTP_REQUEST_METHOD,
@@ -15,12 +15,59 @@ import {
15
15
  import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
16
16
 
17
17
  const propagator = new W3CTraceContextPropagator();
18
+ const requestSpans = new WeakMap<FastifyRequest, Span>();
18
19
 
20
+ /**
21
+ * Options for {@link StandardTracerFastifyRegisterHooks}.
22
+ */
23
+ export interface StandardTracerFastifyRegisterHooksOptions {
24
+ /**
25
+ * Root path prefix for API routes.
26
+ * Only requests starting with this path will be traced. Default `"/api"`.
27
+ */
28
+ rootApiPath?: string;
29
+ /**
30
+ * Span names to skip by exact match (e.g. `"GET-/api/health"`).
31
+ * Checked first — O(1) per entry via hash-optimized string compare.
32
+ * Format: `"METHOD-/path"` — the same format used for span names.
33
+ */
34
+ ignoreList?: string[];
35
+ /**
36
+ * Span names to skip when the span name **starts with** one of these strings.
37
+ * Checked after exact match. Uses native `String.prototype.startsWith`.
38
+ * Example: `["GET-/api/public/"]` ignores all GET requests under that prefix.
39
+ */
40
+ ignoreListPrefix?: string[];
41
+ /**
42
+ * Span names to skip when the span name **ends with** one of these strings.
43
+ * Checked last. Uses native `String.prototype.endsWith`.
44
+ * Example: `["/health", "/metrics"]` ignores all methods targeting those paths.
45
+ */
46
+ ignoreListSuffix?: string[];
47
+ }
48
+
49
+ /**
50
+ * Registers Fastify lifecycle hooks that automatically create and manage
51
+ * OpenTelemetry spans for each matching API request.
52
+ *
53
+ * - Extracts incoming W3C trace context from request headers for distributed tracing.
54
+ * - Records HTTP method, URL path, and response status code as span attributes.
55
+ * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
56
+ * - Logs errors via the provided {@link StandardLogger} with trace context.
57
+ * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
58
+ * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
59
+ * — checked in that order with short-circuit evaluation for maximum performance.
60
+ *
61
+ * @param fastify - The Fastify instance to attach hooks to.
62
+ * @param standardTracer - A configured {@link StandardTracer} instance.
63
+ * @param standardLogger - A configured {@link StandardLogger} instance.
64
+ * @param options - Optional path filtering and ignore lists.
65
+ */
19
66
  export function StandardTracerFastifyRegisterHooks(
20
67
  fastify: FastifyInstance,
21
68
  standardTracer: StandardTracer,
22
69
  standardLogger: StandardLogger,
23
- options?: StandardTracerFastifyRegisterHooksOptions
70
+ options?: StandardTracerFastifyRegisterHooksOptions,
24
71
  ): void {
25
72
  const logger = standardLogger.createModuleLogger("Fastify");
26
73
 
@@ -32,60 +79,66 @@ export function StandardTracerFastifyRegisterHooks(
32
79
  return;
33
80
  }
34
81
  const spanName = `${req.method}-${req.url.split("?")[0]}`;
35
- if (options?.ignoreList?.includes(spanName)) {
82
+ if (
83
+ options?.ignoreList?.includes(spanName) ||
84
+ options?.ignoreListPrefix?.some((p) => spanName.startsWith(p)) ||
85
+ options?.ignoreListSuffix?.some((s) => spanName.endsWith(s))
86
+ ) {
36
87
  return;
37
88
  }
38
89
  const callerContext = propagator.extract(
39
90
  ROOT_CONTEXT,
40
91
  req.headers,
41
- defaultTextMapGetter
92
+ defaultTextMapGetter,
42
93
  );
43
- api.context.with(callerContext, () => {
94
+ context.with(callerContext, () => {
44
95
  const span = standardTracer.startSpan(spanName);
45
96
  span.setAttribute(ATTR_HTTP_REQUEST_METHOD, req.method);
46
97
  span.setAttribute(ATTR_URL_PATH, req.url);
47
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
48
- (req as any).tracerSpanApi = span;
98
+ requestSpans.set(req, span);
49
99
  });
50
100
  });
51
101
 
52
102
  fastify.addHook(
53
103
  "onResponse",
54
104
  async (req: FastifyRequest, reply: FastifyReply) => {
55
- const span = OTelRequestSpan(req);
105
+ const span = requestSpans.get(req);
56
106
  if (!span) {
57
107
  return;
58
108
  }
59
- if (reply.statusCode > 299) {
60
- span.status.code = SpanStatusCode.ERROR;
61
- } else {
62
- span.status.code = SpanStatusCode.OK;
63
- }
109
+ span.setStatus({
110
+ code: reply.statusCode > 299 ? SpanStatusCode.ERROR : SpanStatusCode.OK,
111
+ });
64
112
  span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, reply.statusCode);
65
113
  span.end();
66
- }
114
+ requestSpans.delete(req);
115
+ },
67
116
  );
68
117
 
69
118
  fastify.addHook(
70
119
  "onError",
71
- async (req: FastifyRequest, reply: FastifyReply, error) => {
72
- const span = OTelRequestSpan(req);
120
+ async (req: FastifyRequest, _reply: FastifyReply, error: Error) => {
121
+ const span = requestSpans.get(req);
73
122
  if (!span) {
74
123
  return;
75
124
  }
76
- span.status.code = SpanStatusCode.ERROR;
125
+ span.setStatus({ code: SpanStatusCode.ERROR });
77
126
  span.recordException(error);
78
127
  logger.error(error.message, error, span);
79
- }
128
+ },
80
129
  );
81
130
  }
82
131
 
83
- export interface StandardTracerFastifyRegisterHooksOptions {
84
- rootApiPath?: string;
85
- ignoreList?: string[];
86
- }
87
-
88
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
89
- export function OTelRequestSpan(req: any): Span {
90
- return req.tracerSpanApi;
132
+ /**
133
+ * Retrieves the OpenTelemetry span associated with a Fastify request.
134
+ *
135
+ * The span is created during the `onRequest` hook and stored in an internal
136
+ * `WeakMap` keyed on the request object. Returns `undefined` when no span
137
+ * exists (e.g., the request was skipped by filtering).
138
+ *
139
+ * @param req - The Fastify request object.
140
+ * @returns The active span, or `undefined` if no span was created for this request.
141
+ */
142
+ export function OTelRequestSpan(req: FastifyRequest): Span | undefined {
143
+ return requestSpans.get(req);
91
144
  }
package/tsconfig.json CHANGED
@@ -9,5 +9,6 @@
9
9
  "skipLibCheck": true,
10
10
  "forceConsistentCasingInFileNames": true
11
11
  },
12
- "include": ["index.ts", "src/**/*"]
12
+ "include": ["index.ts", "src/**/*"],
13
+ "exclude": ["**/*.spec.ts"]
13
14
  }
@@ -0,0 +1,7 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "types": ["jest", "node"]
5
+ },
6
+ "include": ["src/**/*.spec.ts"]
7
+ }