@devopsplaybook.io/otel-utils-fastify 1.2.1 → 1.3.0-beta.34.170b681

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
@@ -8,7 +8,14 @@ Fastify integration for `@devopsplaybook.io/otel-utils`. Automatically creates a
8
8
  npm install @devopsplaybook.io/otel-utils-fastify
9
9
  ```
10
10
 
11
- Requires `@devopsplaybook.io/otel-utils` as a peer dependency — it must be installed and configured in the consuming project.
11
+ Peer dependencies that must be installed and configured in the consuming project:
12
+
13
+ | Peer dependency | Version |
14
+ | ------------------------------- | -------- |
15
+ | `@devopsplaybook.io/otel-utils` | `^1.3.0` |
16
+ | `fastify` | `^5.0.0` |
17
+
18
+ Requires Node.js >= 22.
12
19
 
13
20
  ## Usage
14
21
 
@@ -18,12 +25,15 @@ import {
18
25
  StandardMeter,
19
26
  StandardTracer,
20
27
  } from "@devopsplaybook.io/otel-utils";
21
- import { StandardTracerFastifyRegisterHooks } from "@devopsplaybook.io/otel-utils-fastify";
28
+ import {
29
+ OTelRequestContext,
30
+ OTelRequestSpan,
31
+ StandardTracerFastifyRegisterHooks,
32
+ } from "@devopsplaybook.io/otel-utils-fastify";
33
+ import { context } from "@opentelemetry/api";
22
34
  import Fastify from "fastify";
23
35
 
24
- const config = {
25
- /* ... ConfigOTelInterface ... */
26
- };
36
+ const config = {/* ... ConfigOTelInterface ... */};
27
37
 
28
38
  const tracer = new StandardTracer(config);
29
39
  const meter = new StandardMeter(config);
@@ -32,7 +42,7 @@ logger.initOTel(config);
32
42
 
33
43
  const fastify = Fastify();
34
44
 
35
- // Register hooks once at startup
45
+ // Register hooks once at startup, at the root of the Fastify instance
36
46
  StandardTracerFastifyRegisterHooks(fastify, tracer, logger, {
37
47
  rootApiPath: "/api",
38
48
  ignoreList: ["GET-/api/health"],
@@ -49,41 +59,114 @@ fastify.get("/api/files/:id", async (req, res) => {
49
59
  }
50
60
  // ...
51
61
  });
62
+
63
+ // Or run handler work inside the request context so everything created
64
+ // within it automatically becomes a child of the HTTP span:
65
+ fastify.get("/api/files", async (req, res) => {
66
+ const ctx = OTelRequestContext(req);
67
+ if (!ctx) {
68
+ return res.send({ files: [] });
69
+ }
70
+ return context.with(ctx, async () => {
71
+ const childSpan = tracer.startSpan("load-files");
72
+ // ... do work ...
73
+ childSpan.end();
74
+ });
75
+ });
52
76
  ```
53
77
 
54
78
  ## Exported API
55
79
 
56
80
  ### `StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLogger, options?)`
57
81
 
58
- Registers three Fastify hooks:
82
+ Registers five Fastify hooks:
59
83
 
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. |
84
+ | Hook | Behavior |
85
+ | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
86
+ | `onRequest` | Extracts W3C trace context from incoming headers. Creates a `SpanKind.SERVER` span named after the route (`METHOD-<route template>`, path fallback when no route matched) and stores it plus its context in internal `WeakMap`s. Skips OPTIONS requests, paths outside `rootApiPath`, and ignored span names. |
87
+ | `onResponse` | Sets span status (OK for status <= 299, ERROR otherwise), records `http.response.status_code`, ends the span, and removes it from the `WeakMap`s. |
88
+ | `onError` | Sets span status to ERROR, records `error.type` and the exception, and logs the error via `ModuleLogger` with trace context. Non-`Error` throws are normalized to an `Error` first. |
89
+ | `onRequestAbort` | Ends the span with ERROR status and `error.type = "client_abort"` when the client aborts the request. |
90
+ | `onTimeout` | Ends the span with ERROR status and `error.type = "timeout"` when the connection times out. |
91
+
92
+ Registration is idempotent per Fastify instance: a second registration on the same instance is ignored with a warning. Hooks are registered globally and cannot be removed — register them **once**, at the root of the Fastify instance.
65
93
 
66
94
  **Options:**
67
95
 
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`) |
96
+ | Field | Type | Default | Description |
97
+ | ------------------ | ----------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
98
+ | `rootApiPath` | `string?` | `"/api"` | Only trace the path itself and paths under `"<rootApiPath>/"` (boundary check: `/apiary` is **not** traced, `/apiary/...` neither). `"/"` traces everything. Trailing slashes are ignored. |
99
+ | `ignoreList` | `string[]?` | — | Exact span names to skip. Format: `"METHOD-/route"`, using the route template for parameterized routes (e.g. `"GET-/api/files/_id"`). |
100
+ | `ignoreListPrefix` | `string[]?` | — | Skip when span name **starts with** any of these (native `startsWith`). |
101
+ | `ignoreListSuffix` | `string[]?` | — | Skip when span name **ends with** any of these (native `endsWith`). |
74
102
 
75
103
  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
104
 
105
+ ### Span naming and attributes
106
+
107
+ Span names follow `METHOD-<route>`:
108
+
109
+ - Matched routes use the **route template**: `GET /api/files/:id` → span name `GET-/api/files/_id`.
110
+ - Unmatched routes (e.g. 404) fall back to the request path: `GET /api/unknown` → `GET-/api/unknown`.
111
+ - The query string is never part of the span name.
112
+ - Span names are sanitized with the same regex used by `@devopsplaybook.io/otel-utils` (`[^a-zA-Z0-9-_/]` → `_`), so `ignoreList` entries match the exported span name.
113
+ - Spans are created with `SpanKind.SERVER` and the usual synthetic `StandardTracer` attributes (`http.request_method=BACKEND`, synthetic `http.route`) are **not** added — real HTTP attributes are set instead.
114
+
115
+ Attributes set on each HTTP span:
116
+
117
+ | Attribute | Value |
118
+ | --------------------------- | ------------------------------------------------------------------------ |
119
+ | `http.request.method` | Request method (`GET`, `POST`, ...) |
120
+ | `url.path` | Request path, **without** query string |
121
+ | `http.route` | Route template — only when the request matched a route |
122
+ | `http.response.status_code` | Response status code |
123
+ | `error.type` | Error name, or `client_abort` / `timeout` for aborted/timed out requests |
124
+
125
+ The query string is not recorded (`url.query` is not set).
126
+
77
127
  ### `OTelRequestSpan(req)`
78
128
 
79
- Retrieves the active span for a Fastify request from the internal WeakMap.
129
+ Retrieves the active span for a Fastify request from the internal `WeakMap`.
130
+
131
+ | | |
132
+ | ------------- | -------------------------------------------------------------------------------------- |
133
+ | **Parameter** | `req: FastifyRequest` |
134
+ | **Returns** | `Span \| undefined` — `undefined` when the request was skipped or after the span ended |
135
+
136
+ Use it to parent spans created in route handlers:
80
137
 
81
- | | |
82
- | ------------- | ------------------------------------------------------------------------------------------------------------------- |
83
- | **Parameter** | `req: FastifyRequest` |
84
- | **Returns** | `Span \| undefined` — `undefined` when the request was skipped (OPTIONS, outside `rootApiPath`, or in `ignoreList`) |
138
+ ```typescript
139
+ const childSpan = tracer.startSpan("my-work", OTelRequestSpan(req));
140
+ ```
141
+
142
+ ### `OTelRequestContext(req)`
143
+
144
+ Retrieves the OpenTelemetry `Context` for a Fastify request: the incoming W3C trace context with the HTTP span set as active span.
85
145
 
86
- Used in route handlers to access the current span for custom attributes or sub-spans.
146
+ | | |
147
+ | ------------- | ----------------------------------------------------------------------------------------- |
148
+ | **Parameter** | `req: FastifyRequest` |
149
+ | **Returns** | `Context \| undefined` — `undefined` when the request was skipped or after the span ended |
150
+
151
+ Because Fastify runs hooks and handlers in separate async contexts, the span cannot be active automatically after the `onRequest` hook returns. Wrapping handler work in `context.with(OTelRequestContext(req), ...)` makes any span created inside it a child of the HTTP span:
152
+
153
+ ```typescript
154
+ fastify.get("/api/files/:id", async (req, res) => {
155
+ const ctx = OTelRequestContext(req);
156
+ if (!ctx) {
157
+ return res.send({});
158
+ }
159
+ return context.with(ctx, async () => {
160
+ const childSpan = tracer.startSpan("load-file"); // child of the HTTP span
161
+ // ...
162
+ childSpan.end();
163
+ });
164
+ });
165
+ ```
166
+
167
+ ### Deprecated: `req.tracerSpanApi`
168
+
169
+ The span is also assigned to `req.tracerSpanApi` as a deprecated compatibility alias. Use `OTelRequestSpan(req)` instead.
87
170
 
88
171
  ## Architecture
89
172
 
@@ -91,36 +174,49 @@ Used in route handlers to access the current span for custom attributes or sub-s
91
174
  Incoming Request
92
175
  │
93
176
  ▼
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)
177
+ onRequest hook
178
+ ├── propagator.extract(headers) ← W3C trace context from caller
179
+ ├── context.with(ctx, () => { ... })
180
+ │ └── standardTracer.startSpan(spanName, undefined, { kind: SERVER })
181
+ │ └── WeakMap<req, span> + WeakMap<req, context>
182
+ └── Route handler
183
+ └── OTelRequestSpan(req)
184
+ or context.with(OTelRequestContext(req), () => ...)
185
+ onResponse / onError / onRequestAbort / onTimeout
186
+ └── WeakMap.get(req) → span
187
+ ├── span.setStatus({ code })
188
+ ├── span.setAttribute(...)
189
+ ├── span.end() / span.recordException(error)
190
+ └── WeakMap.delete(req)
107
191
  ```
108
192
 
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.
193
+ The span and context are stored in `WeakMap`s rather than as properties on the request object, avoiding type pollution and allowing natural garbage collection. The `tracerSpanApi` alias is deleted when the span ends (it is a plain property, so it must be removed explicitly).
194
+
195
+ ## Behavior changes in 1.3.0
196
+
197
+ - Span names now use the **route template** for matched routes and the path fallback otherwise (previously the raw path, including dynamic segments), and are sanitized like `StandardTracer` span names. Update `ignoreList` entries accordingly (e.g. `"GET-/api/files/_id"`).
198
+ - `url.path` is now recorded **without** the query string.
199
+ - Spans are `SpanKind.SERVER`; synthetic `BACKEND` attributes from `StandardTracer` are no longer added.
200
+ - Client aborts and connection timeouts end the span with ERROR status and `error.type`.
201
+ - `rootApiPath` uses a segment boundary check (`/apiary` is no longer traced).
202
+ - Requires `@devopsplaybook.io/otel-utils` `^1.3.0` (uses `StandardTracer.startSpan(name, parentSpan?, options?)`).
110
203
 
111
204
  ## Dependencies
112
205
 
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 |
206
+ | Package | Type | Purpose |
207
+ | ------------------------------------- | ---------- | ------------------------------------------- |
208
+ | `@devopsplaybook.io/otel-utils` | peer | StandardTracer and StandardLogger instances |
209
+ | `fastify` | peer | Fastify web framework (v5) |
210
+ | `@opentelemetry/api` | dependency | Context management, span status codes |
211
+ | `@opentelemetry/core` | dependency | W3C trace context propagator |
212
+ | `@opentelemetry/sdk-trace-base` | dependency | Span type |
213
+ | `@opentelemetry/semantic-conventions` | dependency | HTTP semantic attribute constants |
121
214
 
122
- ## Build
215
+ ## Build, lint, test
123
216
 
124
217
  ```bash
125
- npm run build # tsc → dist/, then type-checks the spec files (tsc --noEmit)
218
+ npm run build # tsc → dist/, then type-checks the spec files (tsc --noEmit)
219
+ npm run lint # oxlint index.ts src && prettier --check .
220
+ npm run format # prettier --write .
221
+ npm test # jest --coverage (coverage thresholds enforced)
126
222
  ```
@@ -1,4 +1,5 @@
1
1
  import { StandardLogger, StandardTracer } from "@devopsplaybook.io/otel-utils";
2
+ import { Context } from "@opentelemetry/api";
2
3
  import { Span } from "@opentelemetry/sdk-trace-base";
3
4
  import { FastifyInstance, FastifyRequest } from "fastify";
4
5
  /**
@@ -7,13 +8,16 @@ import { FastifyInstance, FastifyRequest } from "fastify";
7
8
  export interface StandardTracerFastifyRegisterHooksOptions {
8
9
  /**
9
10
  * Root path prefix for API routes.
10
- * Only requests starting with this path will be traced. Default `"/api"`.
11
+ * Only requests under this path (the path itself or paths starting with
12
+ * `"<rootApiPath>/"`) will be traced. Default `"/api"`.
11
13
  */
12
14
  rootApiPath?: string;
13
15
  /**
14
16
  * 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
+ * Checked first, with a linear scan over the array entries.
18
+ * Format: `"METHOD-/route"` — the same sanitized format used for span names,
19
+ * with the route template for parameterized routes (e.g.
20
+ * `"GET-/api/files/_id"` for `/api/files/:id`).
17
21
  */
18
22
  ignoreList?: string[];
19
23
  /**
@@ -34,12 +38,25 @@ export interface StandardTracerFastifyRegisterHooksOptions {
34
38
  * OpenTelemetry spans for each matching API request.
35
39
  *
36
40
  * - Extracts incoming W3C trace context from request headers for distributed tracing.
37
- * - Records HTTP method, URL path, and response status code as span attributes.
41
+ * - Creates a `SpanKind.SERVER` span named `METHOD-<route>` (route template for
42
+ * matched routes, e.g. `GET-/api/files/_id` for `/api/files/:id`; path
43
+ * fallback when no route matched), with `http.request.method`, `url.path`
44
+ * (path only, no query string) and `http.route` (route template) attributes.
38
45
  * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
46
+ * - Ends the span on response, handler error, client abort or connection timeout.
39
47
  * - Logs errors via the provided {@link StandardLogger} with trace context.
40
48
  * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
41
49
  * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
42
- * — checked in that order with short-circuit evaluation for maximum performance.
50
+ * — checked in that order with short-circuit evaluation.
51
+ *
52
+ * Register the hooks **once**, at the root of the Fastify instance: a second
53
+ * registration on the same instance is ignored (with a warning), and hooks
54
+ * registered inside an encapsulated plugin only see the requests routed
55
+ * through that plugin scope.
56
+ *
57
+ * Use {@link OTelRequestContext} (or {@link OTelRequestSpan} as an explicit
58
+ * parent) inside route handlers to attach the work of the request to the
59
+ * HTTP span.
43
60
  *
44
61
  * @param fastify - The Fastify instance to attach hooks to.
45
62
  * @param standardTracer - A configured {@link StandardTracer} instance.
@@ -52,9 +69,25 @@ export declare function StandardTracerFastifyRegisterHooks(fastify: FastifyInsta
52
69
  *
53
70
  * The span is created during the `onRequest` hook and stored in an internal
54
71
  * `WeakMap` keyed on the request object. Returns `undefined` when no span
55
- * exists (e.g., the request was skipped by filtering).
72
+ * exists (e.g., the request was skipped by filtering) or once the span ended.
73
+ *
74
+ * Use it to parent spans created in route handlers:
75
+ * `standardTracer.startSpan("my-work", OTelRequestSpan(req))`.
56
76
  *
57
77
  * @param req - The Fastify request object.
58
78
  * @returns The active span, or `undefined` if no span was created for this request.
59
79
  */
60
80
  export declare function OTelRequestSpan(req: FastifyRequest): Span | undefined;
81
+ /**
82
+ * Retrieves the OpenTelemetry context associated with a Fastify request.
83
+ *
84
+ * The context holds the incoming W3C trace context with the HTTP request span
85
+ * set as the active span, so spans created inside
86
+ * `context.with(OTelRequestContext(req), () => ...)` become children of the
87
+ * HTTP span. Returns `undefined` when no span was created (skipped request)
88
+ * or once the span ended.
89
+ *
90
+ * @param req - The Fastify request object.
91
+ * @returns The request context, or `undefined` if no span was created for this request.
92
+ */
93
+ export declare function OTelRequestContext(req: FastifyRequest): Context | undefined;
@@ -2,22 +2,42 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.StandardTracerFastifyRegisterHooks = StandardTracerFastifyRegisterHooks;
4
4
  exports.OTelRequestSpan = OTelRequestSpan;
5
+ exports.OTelRequestContext = OTelRequestContext;
5
6
  const api_1 = require("@opentelemetry/api");
6
7
  const core_1 = require("@opentelemetry/core");
7
8
  const semantic_conventions_1 = require("@opentelemetry/semantic-conventions");
8
9
  const propagator = new core_1.W3CTraceContextPropagator();
10
+ // Same sanitization as @devopsplaybook.io/otel-utils, applied here so that
11
+ // `ignoreList` entries are matched against the exported span name.
12
+ const SPAN_NAME_SANITIZE_RE = /[^a-zA-Z0-9-_/]/g;
13
+ const DEFAULT_ROOT_API_PATH = "/api";
9
14
  const requestSpans = new WeakMap();
15
+ const requestContexts = new WeakMap();
16
+ const registeredFastifyInstances = new WeakSet();
10
17
  /**
11
18
  * Registers Fastify lifecycle hooks that automatically create and manage
12
19
  * OpenTelemetry spans for each matching API request.
13
20
  *
14
21
  * - Extracts incoming W3C trace context from request headers for distributed tracing.
15
- * - Records HTTP method, URL path, and response status code as span attributes.
22
+ * - Creates a `SpanKind.SERVER` span named `METHOD-<route>` (route template for
23
+ * matched routes, e.g. `GET-/api/files/_id` for `/api/files/:id`; path
24
+ * fallback when no route matched), with `http.request.method`, `url.path`
25
+ * (path only, no query string) and `http.route` (route template) attributes.
16
26
  * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
27
+ * - Ends the span on response, handler error, client abort or connection timeout.
17
28
  * - Logs errors via the provided {@link StandardLogger} with trace context.
18
29
  * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
19
30
  * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
20
- * — checked in that order with short-circuit evaluation for maximum performance.
31
+ * — checked in that order with short-circuit evaluation.
32
+ *
33
+ * Register the hooks **once**, at the root of the Fastify instance: a second
34
+ * registration on the same instance is ignored (with a warning), and hooks
35
+ * registered inside an encapsulated plugin only see the requests routed
36
+ * through that plugin scope.
37
+ *
38
+ * Use {@link OTelRequestContext} (or {@link OTelRequestSpan} as an explicit
39
+ * parent) inside route handlers to attach the work of the request to the
40
+ * HTTP span.
21
41
  *
22
42
  * @param fastify - The Fastify instance to attach hooks to.
23
43
  * @param standardTracer - A configured {@link StandardTracer} instance.
@@ -26,54 +46,104 @@ const requestSpans = new WeakMap();
26
46
  */
27
47
  function StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLogger, options) {
28
48
  const logger = standardLogger.createModuleLogger("Fastify");
49
+ if (registeredFastifyInstances.has(fastify)) {
50
+ logger.warn("StandardTracerFastifyRegisterHooks is already registered on this Fastify instance: ignoring the duplicate registration.");
51
+ return;
52
+ }
53
+ registeredFastifyInstances.add(fastify);
54
+ const rootApiPath = normalizeRootApiPath(options === null || options === void 0 ? void 0 : options.rootApiPath);
55
+ const endSpan = (req, end = {}) => {
56
+ const span = requestSpans.get(req);
57
+ if (!span) {
58
+ return;
59
+ }
60
+ requestSpans.delete(req);
61
+ requestContexts.delete(req);
62
+ delete req.tracerSpanApi;
63
+ if (end.errorType) {
64
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
65
+ span.setAttribute(semantic_conventions_1.ATTR_ERROR_TYPE, end.errorType);
66
+ }
67
+ else if (end.statusCode !== undefined) {
68
+ span.setStatus({
69
+ code: end.statusCode > 299 ? api_1.SpanStatusCode.ERROR : api_1.SpanStatusCode.OK,
70
+ });
71
+ span.setAttribute(semantic_conventions_1.ATTR_HTTP_RESPONSE_STATUS_CODE, end.statusCode);
72
+ }
73
+ span.end();
74
+ };
29
75
  fastify.addHook("onRequest", async (req) => {
30
76
  var _a, _b, _c;
31
- if (req.method === "OPTIONS" ||
32
- !req.url.startsWith((options === null || options === void 0 ? void 0 : options.rootApiPath) || "/api")) {
77
+ if (req.method === "OPTIONS") {
78
+ return;
79
+ }
80
+ const path = req.url.split("?")[0];
81
+ if (rootApiPath !== "/" &&
82
+ path !== rootApiPath &&
83
+ !path.startsWith(`${rootApiPath}/`)) {
33
84
  return;
34
85
  }
35
- const spanName = `${req.method}-${req.url.split("?")[0]}`;
86
+ const routeTemplate = req.routeOptions.url;
87
+ const spanName = sanitizeSpanName(`${req.method}-${routeTemplate || path}`);
36
88
  if (((_a = options === null || options === void 0 ? void 0 : options.ignoreList) === null || _a === void 0 ? void 0 : _a.includes(spanName)) ||
37
89
  ((_b = options === null || options === void 0 ? void 0 : options.ignoreListPrefix) === null || _b === void 0 ? void 0 : _b.some((p) => spanName.startsWith(p))) ||
38
90
  ((_c = options === null || options === void 0 ? void 0 : options.ignoreListSuffix) === null || _c === void 0 ? void 0 : _c.some((s) => spanName.endsWith(s)))) {
39
91
  return;
40
92
  }
41
93
  const callerContext = propagator.extract(api_1.ROOT_CONTEXT, req.headers, api_1.defaultTextMapGetter);
42
- api_1.context.with(callerContext, () => {
43
- const span = standardTracer.startSpan(spanName);
44
- span.setAttribute(semantic_conventions_1.ATTR_HTTP_REQUEST_METHOD, req.method);
45
- span.setAttribute(semantic_conventions_1.ATTR_URL_PATH, req.url);
46
- requestSpans.set(req, span);
47
- });
94
+ // The span is created inside the extracted context so that it becomes a
95
+ // child of the caller span when the request carries a `traceparent`.
96
+ const span = api_1.context.with(callerContext, () => standardTracer.startSpan(spanName, undefined, {
97
+ kind: api_1.SpanKind.SERVER,
98
+ }));
99
+ span.setAttribute(semantic_conventions_1.ATTR_HTTP_REQUEST_METHOD, req.method);
100
+ span.setAttribute(semantic_conventions_1.ATTR_URL_PATH, path);
101
+ if (routeTemplate) {
102
+ span.setAttribute(semantic_conventions_1.ATTR_HTTP_ROUTE, routeTemplate);
103
+ }
104
+ requestSpans.set(req, span);
105
+ requestContexts.set(req, api_1.trace.setSpan(callerContext, span));
106
+ // Deprecated alias kept for consumers that reimplemented the accessor
107
+ // (`req.tracerSpanApi` before 1.1.0); use OTelRequestSpan(req) instead.
108
+ req.tracerSpanApi = span;
48
109
  });
49
110
  fastify.addHook("onResponse", async (req, reply) => {
50
- const span = requestSpans.get(req);
51
- if (!span) {
52
- return;
53
- }
54
- span.setStatus({
55
- code: reply.statusCode > 299 ? api_1.SpanStatusCode.ERROR : api_1.SpanStatusCode.OK,
56
- });
57
- span.setAttribute(semantic_conventions_1.ATTR_HTTP_RESPONSE_STATUS_CODE, reply.statusCode);
58
- span.end();
59
- requestSpans.delete(req);
111
+ endSpan(req, { statusCode: reply.statusCode });
60
112
  });
61
113
  fastify.addHook("onError", async (req, _reply, error) => {
62
114
  const span = requestSpans.get(req);
63
115
  if (!span) {
64
116
  return;
65
117
  }
118
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
66
119
  span.setStatus({ code: api_1.SpanStatusCode.ERROR });
67
- span.recordException(error);
68
- logger.error(error.message, error, span);
120
+ span.setAttribute(semantic_conventions_1.ATTR_ERROR_TYPE, normalizedError.name);
121
+ span.recordException(normalizedError);
122
+ logger.error(normalizedError.message, normalizedError, span);
69
123
  });
124
+ fastify.addHook("onRequestAbort", async (req) => {
125
+ endSpan(req, { errorType: "client_abort" });
126
+ });
127
+ fastify.addHook("onTimeout", async (req, _reply) => {
128
+ endSpan(req, { errorType: "timeout" });
129
+ });
130
+ }
131
+ function normalizeRootApiPath(rootApiPath) {
132
+ const normalized = (rootApiPath || DEFAULT_ROOT_API_PATH).replace(/\/+$/, "");
133
+ return normalized === "" ? "/" : normalized;
134
+ }
135
+ function sanitizeSpanName(name) {
136
+ return name.replace(SPAN_NAME_SANITIZE_RE, "_");
70
137
  }
71
138
  /**
72
139
  * Retrieves the OpenTelemetry span associated with a Fastify request.
73
140
  *
74
141
  * The span is created during the `onRequest` hook and stored in an internal
75
142
  * `WeakMap` keyed on the request object. Returns `undefined` when no span
76
- * exists (e.g., the request was skipped by filtering).
143
+ * exists (e.g., the request was skipped by filtering) or once the span ended.
144
+ *
145
+ * Use it to parent spans created in route handlers:
146
+ * `standardTracer.startSpan("my-work", OTelRequestSpan(req))`.
77
147
  *
78
148
  * @param req - The Fastify request object.
79
149
  * @returns The active span, or `undefined` if no span was created for this request.
@@ -81,3 +151,18 @@ function StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLog
81
151
  function OTelRequestSpan(req) {
82
152
  return requestSpans.get(req);
83
153
  }
154
+ /**
155
+ * Retrieves the OpenTelemetry context associated with a Fastify request.
156
+ *
157
+ * The context holds the incoming W3C trace context with the HTTP request span
158
+ * set as the active span, so spans created inside
159
+ * `context.with(OTelRequestContext(req), () => ...)` become children of the
160
+ * HTTP span. Returns `undefined` when no span was created (skipped request)
161
+ * or once the span ended.
162
+ *
163
+ * @param req - The Fastify request object.
164
+ * @returns The request context, or `undefined` if no span was created for this request.
165
+ */
166
+ function OTelRequestContext(req) {
167
+ return requestContexts.get(req);
168
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/otel-utils-fastify",
3
- "version": "1.2.1",
3
+ "version": "1.3.0-beta.34.170b681",
4
4
  "description": "Utility to simplify integration with Open Telemetry for Fastify API Server",
5
5
  "keywords": [
6
6
  "Open Telemetry",
@@ -14,26 +14,39 @@
14
14
  "type": "commonjs",
15
15
  "main": "dist/index.js",
16
16
  "types": "dist/index.d.ts",
17
+ "files": [
18
+ "dist"
19
+ ],
20
+ "engines": {
21
+ "node": ">=22"
22
+ },
17
23
  "scripts": {
18
24
  "build": "tsc && tsc -p tsconfig.spec.json --noEmit",
19
- "lint": "oxlint src",
25
+ "format": "prettier --write .",
26
+ "lint": "oxlint index.ts src && prettier --check .",
20
27
  "test": "jest --coverage"
21
28
  },
29
+ "peerDependencies": {
30
+ "@devopsplaybook.io/otel-utils": "^1.3.0",
31
+ "fastify": "^5.0.0"
32
+ },
22
33
  "dependencies": {
23
- "@devopsplaybook.io/otel-utils": "^1.2.1",
24
34
  "@opentelemetry/api": "^1.9.1",
25
35
  "@opentelemetry/core": "^2.11.0",
26
36
  "@opentelemetry/sdk-trace-base": "^2.11.0",
27
- "@opentelemetry/semantic-conventions": "^1.43.0",
28
- "fastify": "^5.12.5"
37
+ "@opentelemetry/semantic-conventions": "^1.43.0"
29
38
  },
30
39
  "devDependencies": {
40
+ "@devopsplaybook.io/otel-utils": "^1.3.0",
41
+ "@opentelemetry/sdk-trace-node": "^2.11.0",
31
42
  "@swc/core": "^1.16.2",
32
43
  "@swc/jest": "^0.2.39",
33
44
  "@types/jest": "^30.0.0",
34
45
  "@types/node": "^26.6.2",
46
+ "fastify": "^5.12.5",
35
47
  "jest": "^30.5.2",
36
48
  "oxlint": "^1.85.0",
49
+ "prettier": "^3.9.9",
37
50
  "typescript": "^7.0.2"
38
51
  },
39
52
  "publishConfig": {
@@ -1,16 +0,0 @@
1
- name: Main Build
2
-
3
- on:
4
- push:
5
- branches: ["main"]
6
-
7
- workflow_dispatch:
8
-
9
- jobs:
10
- npm-merge:
11
- uses: devopsplaybook-io/common-utils/.github/workflows/reusable-npm-merge.yml@main
12
- with:
13
- npm_package_name: "@devopsplaybook.io/otel-utils-fastify"
14
- node_version: "22"
15
- secrets:
16
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -1,25 +0,0 @@
1
- name: PR Check
2
-
3
- on:
4
- pull_request:
5
- branches: ["main"]
6
-
7
- workflow_dispatch:
8
-
9
- concurrency:
10
- group: pr-${{ github.event.pull_request.number || github.ref }}
11
- cancel-in-progress: true
12
-
13
- permissions:
14
- contents: read
15
- pull-requests: write
16
- issues: write
17
-
18
- jobs:
19
- npm-pr:
20
- uses: devopsplaybook-io/common-utils/.github/workflows/reusable-npm-pr.yml@main
21
- with:
22
- npm_package_name: "@devopsplaybook.io/otel-utils-fastify"
23
- node_version: "22"
24
- secrets:
25
- NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
package/index.ts DELETED
@@ -1 +0,0 @@
1
- export * from "./src/StandardTracerFastify";
package/jest.config.js DELETED
@@ -1,17 +0,0 @@
1
- module.exports = {
2
- moduleFileExtensions: ["ts", "js"],
3
- transform: {
4
- "^.+\\.(ts|tsx)$": [
5
- "@swc/jest",
6
- {
7
- jsc: {
8
- target: "es2019",
9
- },
10
- },
11
- ],
12
- },
13
- coverageProvider: "v8",
14
- testMatch: ["/**/src/**/*.spec.(ts|js)"],
15
- testPathIgnorePatterns: ["/node_modules/", "/dist/"],
16
- testEnvironment: "node",
17
- };
package/prettierrc.json DELETED
@@ -1,5 +0,0 @@
1
- {
2
- "tabWidth": 2,
3
- "semi": true,
4
- "singleQuote": false
5
- }
@@ -1,308 +0,0 @@
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 () => ({ 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 () => {
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 () => ({ ok: true }));
59
- app.get("/api/pub/metrics", async () => ({ ok: true }));
60
- app.get("/api/health", async () => ({ ok: true }));
61
-
62
- StandardTracerFastifyRegisterHooks(
63
- app,
64
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
65
- mockTracer as any,
66
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
- mockLogger as any,
68
- options,
69
- );
70
-
71
- return { app, mockSpan, mockTracer, mockLogger };
72
- }
73
-
74
- // ---------------------------------------------------------------------------
75
- // Request filtering
76
- // ---------------------------------------------------------------------------
77
-
78
- describe("request filtering", () => {
79
- beforeEach(() => {
80
- jest.clearAllMocks();
81
- });
82
-
83
- test("skips OPTIONS requests", async () => {
84
- const { app, mockTracer } = buildApp();
85
- await app.inject({ method: "OPTIONS", url: "/api/test" });
86
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
87
- });
88
-
89
- test("skips requests outside rootApiPath", async () => {
90
- const { app, mockTracer } = buildApp({ rootApiPath: "/api/v2" });
91
- await app.inject({ method: "GET", url: "/api/test" });
92
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
93
- });
94
-
95
- test("traces requests inside rootApiPath", async () => {
96
- const { app, mockTracer } = buildApp({ rootApiPath: "/api/v2" });
97
- app.get("/api/v2/data", async () => ({ ok: true }));
98
- await app.inject({ method: "GET", url: "/api/v2/data" });
99
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/v2/data");
100
- });
101
-
102
- test("skips requests matching exact ignoreList", async () => {
103
- const { app, mockTracer } = buildApp({ ignoreList: ["GET-/api/test"] });
104
- await app.inject({ method: "GET", url: "/api/test" });
105
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
106
- });
107
-
108
- test("traces requests not in ignoreList", async () => {
109
- const { app, mockTracer } = buildApp({ ignoreList: ["GET-/api/other"] });
110
- await app.inject({ method: "GET", url: "/api/test" });
111
- expect(mockTracer.startSpan).toHaveBeenCalled();
112
- });
113
-
114
- test("skips requests matching ignoreListPrefix", async () => {
115
- const { app, mockTracer } = buildApp({
116
- ignoreListPrefix: ["GET-/api/pub"],
117
- });
118
- await app.inject({ method: "GET", url: "/api/pub/health" });
119
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
120
- });
121
-
122
- test("skips requests matching ignoreListPrefix (nested path)", async () => {
123
- const { app, mockTracer } = buildApp({
124
- ignoreListPrefix: ["GET-/api/pub"],
125
- });
126
- await app.inject({ method: "GET", url: "/api/pub/metrics" });
127
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
128
- });
129
-
130
- test("does not skip requests not matching ignoreListPrefix", async () => {
131
- const { app, mockTracer } = buildApp({
132
- ignoreListPrefix: ["GET-/api/private"],
133
- });
134
- await app.inject({ method: "GET", url: "/api/pub/health" });
135
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/pub/health");
136
- });
137
-
138
- test("skips requests matching ignoreListSuffix", async () => {
139
- const { app, mockTracer } = buildApp({
140
- ignoreListSuffix: ["/health"],
141
- });
142
- await app.inject({ method: "GET", url: "/api/health" });
143
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
144
- });
145
-
146
- test("does not skip requests not matching ignoreListSuffix", async () => {
147
- const { app, mockTracer } = buildApp({
148
- ignoreListSuffix: ["/other"],
149
- });
150
- await app.inject({ method: "GET", url: "/api/health" });
151
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/health");
152
- });
153
-
154
- test("skips when exact match takes priority over prefix", async () => {
155
- const { app, mockTracer } = buildApp({
156
- ignoreList: ["GET-/api/test"],
157
- ignoreListPrefix: ["GET-/api/other"],
158
- });
159
- await app.inject({ method: "GET", url: "/api/test" });
160
- expect(mockTracer.startSpan).not.toHaveBeenCalled();
161
- });
162
-
163
- test("traces when no ignore list matches", async () => {
164
- const { app, mockTracer } = buildApp({
165
- ignoreList: ["GET-/api/health"],
166
- ignoreListPrefix: ["GET-/api/pub"],
167
- ignoreListSuffix: ["/metrics"],
168
- });
169
- await app.inject({ method: "GET", url: "/api/test" });
170
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
171
- });
172
-
173
- test("traces when callerContext propagation works silently", async () => {
174
- const { app, mockTracer } = buildApp();
175
- await app.inject({
176
- method: "GET",
177
- url: "/api/test",
178
- headers: {
179
- traceparent: "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
180
- },
181
- });
182
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
183
- });
184
- });
185
-
186
- // ---------------------------------------------------------------------------
187
- // Span lifecycle
188
- // ---------------------------------------------------------------------------
189
-
190
- describe("span lifecycle", () => {
191
- beforeEach(() => {
192
- jest.clearAllMocks();
193
- });
194
-
195
- test("creates span with method-path name on onRequest", async () => {
196
- const { app, mockTracer } = buildApp();
197
- await app.inject({ method: "GET", url: "/api/test" });
198
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
199
- });
200
-
201
- test("strips query string from span name", async () => {
202
- const { app, mockTracer } = buildApp();
203
- await app.inject({ method: "GET", url: "/api/test?foo=bar" });
204
- expect(mockTracer.startSpan).toHaveBeenCalledWith("GET-/api/test");
205
- });
206
-
207
- test("sets http.request_method attribute", async () => {
208
- const { app, mockSpan } = buildApp();
209
- await app.inject({ method: "POST", url: "/api/test" });
210
- expect(mockSpan.setAttribute).toHaveBeenCalledWith(
211
- "http.request.method",
212
- "POST",
213
- );
214
- });
215
-
216
- test("sets url.path attribute", async () => {
217
- const { app, mockSpan } = buildApp();
218
- await app.inject({ method: "GET", url: "/api/test" });
219
- expect(mockSpan.setAttribute).toHaveBeenCalledWith("url.path", "/api/test");
220
- });
221
-
222
- test("sets status and ends span on success response", async () => {
223
- const { app, mockSpan } = buildApp();
224
- await app.inject({ method: "GET", url: "/api/test" });
225
- expect(mockSpan.setStatus).toHaveBeenCalledWith({
226
- code: SpanStatusCode.OK,
227
- });
228
- expect(mockSpan.setAttribute).toHaveBeenCalledWith(
229
- "http.response.status_code",
230
- 200,
231
- );
232
- expect(mockSpan.end).toHaveBeenCalledTimes(1);
233
- });
234
-
235
- test("sets ERROR status on 4xx response", async () => {
236
- const { app, mockSpan } = buildApp();
237
- await app.inject({ method: "GET", url: "/api/status" });
238
- expect(mockSpan.setStatus).toHaveBeenCalledWith({
239
- code: SpanStatusCode.ERROR,
240
- });
241
- });
242
-
243
- test("records exception on handler error", async () => {
244
- const { app, mockSpan } = buildApp();
245
- await app.inject({ method: "GET", url: "/api/error-test" });
246
- expect(mockSpan.recordException).toHaveBeenCalledWith(expect.any(Error));
247
- expect(mockSpan.setStatus).toHaveBeenCalledWith({
248
- code: SpanStatusCode.ERROR,
249
- });
250
- });
251
-
252
- test("logger.error is called on handler error", async () => {
253
- const { app, mockLogger } = buildApp();
254
- await app.inject({ method: "GET", url: "/api/error-test" });
255
- const moduleLogger = mockLogger.createModuleLogger.mock.results[0].value;
256
- expect(moduleLogger.error).toHaveBeenCalledWith(
257
- "test error",
258
- expect.any(Error),
259
- expect.any(Object),
260
- );
261
- });
262
- });
263
-
264
- // ---------------------------------------------------------------------------
265
- // OTelRequestSpan
266
- // ---------------------------------------------------------------------------
267
-
268
- describe("OTelRequestSpan", () => {
269
- beforeEach(() => {
270
- jest.clearAllMocks();
271
- });
272
-
273
- test("returns a span for traced requests", async () => {
274
- const { app } = buildApp();
275
- const res = await app.inject({ method: "GET", url: "/api/echo" });
276
- expect(JSON.parse(res.body)).toEqual({ hasSpan: true });
277
- });
278
-
279
- test("returns undefined for OPTIONS requests", async () => {
280
- const { app } = buildApp();
281
- const res = await app.inject({ method: "OPTIONS", url: "/api/echo" });
282
- expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
283
- });
284
-
285
- test("returns undefined for requests outside rootApiPath", async () => {
286
- const { app } = buildApp({ rootApiPath: "/api/v2" });
287
- const res = await app.inject({ method: "GET", url: "/api/echo" });
288
- expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
289
- });
290
-
291
- test("returns undefined for requests matching ignoreList", async () => {
292
- const { app } = buildApp({ ignoreList: ["GET-/api/echo"] });
293
- const res = await app.inject({ method: "GET", url: "/api/echo" });
294
- expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
295
- });
296
-
297
- test("returns undefined for requests matching ignoreListPrefix", async () => {
298
- const { app } = buildApp({ ignoreListPrefix: ["GET-/api/ech"] });
299
- const res = await app.inject({ method: "GET", url: "/api/echo" });
300
- expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
301
- });
302
-
303
- test("returns undefined for requests matching ignoreListSuffix", async () => {
304
- const { app } = buildApp({ ignoreListSuffix: ["/echo"] });
305
- const res = await app.inject({ method: "GET", url: "/api/echo" });
306
- expect(JSON.parse(res.body)).toEqual({ hasSpan: false });
307
- });
308
- });
@@ -1,144 +0,0 @@
1
- import { StandardLogger, StandardTracer } from "@devopsplaybook.io/otel-utils";
2
- import {
3
- context,
4
- defaultTextMapGetter,
5
- ROOT_CONTEXT,
6
- SpanStatusCode,
7
- } from "@opentelemetry/api";
8
- import { W3CTraceContextPropagator } from "@opentelemetry/core";
9
- import { Span } from "@opentelemetry/sdk-trace-base";
10
- import {
11
- ATTR_HTTP_REQUEST_METHOD,
12
- ATTR_HTTP_RESPONSE_STATUS_CODE,
13
- ATTR_URL_PATH,
14
- } from "@opentelemetry/semantic-conventions";
15
- import { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
16
-
17
- const propagator = new W3CTraceContextPropagator();
18
- const requestSpans = new WeakMap<FastifyRequest, Span>();
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
- */
66
- export function StandardTracerFastifyRegisterHooks(
67
- fastify: FastifyInstance,
68
- standardTracer: StandardTracer,
69
- standardLogger: StandardLogger,
70
- options?: StandardTracerFastifyRegisterHooksOptions,
71
- ): void {
72
- const logger = standardLogger.createModuleLogger("Fastify");
73
-
74
- fastify.addHook("onRequest", async (req: FastifyRequest) => {
75
- if (
76
- req.method === "OPTIONS" ||
77
- !req.url.startsWith(options?.rootApiPath || "/api")
78
- ) {
79
- return;
80
- }
81
- const spanName = `${req.method}-${req.url.split("?")[0]}`;
82
- if (
83
- options?.ignoreList?.includes(spanName) ||
84
- options?.ignoreListPrefix?.some((p) => spanName.startsWith(p)) ||
85
- options?.ignoreListSuffix?.some((s) => spanName.endsWith(s))
86
- ) {
87
- return;
88
- }
89
- const callerContext = propagator.extract(
90
- ROOT_CONTEXT,
91
- req.headers,
92
- defaultTextMapGetter,
93
- );
94
- context.with(callerContext, () => {
95
- const span = standardTracer.startSpan(spanName);
96
- span.setAttribute(ATTR_HTTP_REQUEST_METHOD, req.method);
97
- span.setAttribute(ATTR_URL_PATH, req.url);
98
- requestSpans.set(req, span);
99
- });
100
- });
101
-
102
- fastify.addHook(
103
- "onResponse",
104
- async (req: FastifyRequest, reply: FastifyReply) => {
105
- const span = requestSpans.get(req);
106
- if (!span) {
107
- return;
108
- }
109
- span.setStatus({
110
- code: reply.statusCode > 299 ? SpanStatusCode.ERROR : SpanStatusCode.OK,
111
- });
112
- span.setAttribute(ATTR_HTTP_RESPONSE_STATUS_CODE, reply.statusCode);
113
- span.end();
114
- requestSpans.delete(req);
115
- },
116
- );
117
-
118
- fastify.addHook(
119
- "onError",
120
- async (req: FastifyRequest, _reply: FastifyReply, error: Error) => {
121
- const span = requestSpans.get(req);
122
- if (!span) {
123
- return;
124
- }
125
- span.setStatus({ code: SpanStatusCode.ERROR });
126
- span.recordException(error);
127
- logger.error(error.message, error, span);
128
- },
129
- );
130
- }
131
-
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);
144
- }
package/tsconfig.json DELETED
@@ -1,14 +0,0 @@
1
- {
2
- "compilerOptions": {
3
- "target": "ES2019",
4
- "module": "commonjs",
5
- "declaration": true,
6
- "outDir": "./dist",
7
- "strict": true,
8
- "esModuleInterop": true,
9
- "skipLibCheck": true,
10
- "forceConsistentCasingInFileNames": true
11
- },
12
- "include": ["index.ts", "src/**/*"],
13
- "exclude": ["**/*.spec.ts"]
14
- }
@@ -1,8 +0,0 @@
1
- {
2
- "extends": "./tsconfig.json",
3
- "compilerOptions": {
4
- "types": ["jest", "node"]
5
- },
6
- "include": ["src/**/*.spec.ts"],
7
- "exclude": []
8
- }