@devopsplaybook.io/otel-utils-fastify 1.3.1 → 1.4.0

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
@@ -48,6 +48,10 @@ StandardTracerFastifyRegisterHooks(fastify, tracer, logger, {
48
48
  ignoreList: ["GET-/api/health"],
49
49
  ignoreListPrefix: ["GET-/api/public/"],
50
50
  ignoreListSuffix: ["/metrics", "/health"],
51
+ // Optional: record the http.server.request.duration histogram
52
+ standardMeter: meter,
53
+ // Optional: keep span-name cardinality bounded for unmatched routes (404s)
54
+ // unmatchedRouteSpanName: "method",
51
55
  });
52
56
 
53
57
  // In route handlers, retrieve the current span for manual instrumentation
@@ -81,24 +85,28 @@ fastify.get("/api/files", async (req, res) => {
81
85
 
82
86
  Registers five Fastify hooks:
83
87
 
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. |
88
+ | Hook | Behavior |
89
+ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
90
+ | `onRequest` | Extracts W3C trace context from incoming headers. Creates a `SpanKind.SERVER` span named after the route (`METHOD-<route template>`; path or method fallback when no route matched, see `unmatchedRouteSpanName`) and stores it plus its context in internal `WeakMap`s. Skips OPTIONS requests, paths outside `rootApiPath`, and ignored span names. |
91
+ | `onResponse` | Ends the span: records `http.response.status_code` and sets the span status per the current OTel HTTP semantic conventions for server spans — **unset for 1xx–4xx, ERROR for 5xx** — then ends the span and removes it from the `WeakMap`s. A span on which `onError` already recorded an error keeps its ERROR status. |
92
+ | `onError` | Sets span status to ERROR, records `error.type` and the exception, and logs the error via `ModuleLogger` with trace context: **`warn` (no stack) for client errors** (`statusCode < 500`, e.g. validation 400 or 403), `error` otherwise. Non-`Error` throws are normalized to an `Error` first. |
93
+ | `onRequestAbort` | Ends the span with ERROR status and `error.type = "client_abort"` when the client aborts the request. |
94
+ | `onTimeout` | Ends the span with ERROR status and `error.type = "timeout"` when the connection times out. |
91
95
 
92
96
  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.
93
97
 
98
+ Tracing is **fail-open**: a fault in any hook (including a throwing tracer, span or logger) is caught, logged once at warn level through the `ModuleLogger`, and the request continues untraced — instrumentation never fails the request.
99
+
94
100
  **Options:**
95
101
 
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`). |
102
+ | Field | Type | Default | Description |
103
+ | ------------------------ | --------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
104
+ | `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. |
105
+ | `ignoreList` | `string[]?` | — | Exact span names to skip. Format: `"METHOD-/route"`, using the route template for parameterized routes (e.g. `"GET-/api/files/_id"`). |
106
+ | `ignoreListPrefix` | `string[]?` | — | Skip when span name **starts with** any of these (native `startsWith`). |
107
+ | `ignoreListSuffix` | `string[]?` | — | Skip when span name **ends with** any of these (native `endsWith`). |
108
+ | `unmatchedRouteSpanName` | `"path" \| "method"?` | `"path"` | Span-name policy for requests that did not match a route (e.g. 404s). `"path"`: sanitized request path — today's behavior; note the unbounded cardinality from scanner/probe traffic and that attacker payloads end up as span names. `"method"`: method only (e.g. `GET`), keeping cardinality bounded (current semconv recommends a low-cardinality name when available); ignore lists then match `"GET"` for unmatched requests. The full path is always kept in `url.path`; matched routes are unaffected. |
109
+ | `standardMeter` | `StandardMeter?` | — | When provided, records the `http.server.request.duration` histogram (seconds) for traced requests. See [Metrics](#metrics-http-server-requestduration) below. |
102
110
 
103
111
  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.
104
112
 
@@ -107,23 +115,40 @@ All three ignore lists are checked **in order** (exact → prefix → suffix) wi
107
115
  Span names follow `METHOD-<route>`:
108
116
 
109
117
  - 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`.
118
+ - Unmatched routes (e.g. 404) fall back to the request path: `GET /api/unknown` → `GET-/api/unknown`, unless `unmatchedRouteSpanName: "method"` is set, in which case the span name is the method only (`GET`).
111
119
  - The query string is never part of the span name.
112
120
  - 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
121
  - 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
122
 
115
123
  Attributes set on each HTTP span:
116
124
 
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 |
125
+ | Attribute | Value |
126
+ | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
127
+ | `http.request.method` | Request method (`GET`, `POST`, ...) |
128
+ | `url.path` | Request path, **without** query string |
129
+ | `http.route` | Route template — only when the request matched a route |
130
+ | `http.response.status_code` | Response status code |
131
+ | `error.type` | `error.name` when it is not the generic `Error`, otherwise `error.code` (e.g. `FST_ERR_VALIDATION` for Fastify errors), otherwise `Error` — or `client_abort` / `timeout` for aborted/timed out requests |
124
132
 
125
133
  The query string is not recorded (`url.query` is not set).
126
134
 
135
+ ### Metrics: `http.server.request.duration`
136
+
137
+ When a [`StandardMeter`](https://github.com/devopsplaybookio/otel-utils) is passed in `standardMeter`, the hooks record one histogram observation per traced request at span end:
138
+
139
+ - Name: `http.server.request.duration` (seconds). `StandardMeter` prefixes the service id, so it is exported as `<SERVICE_ID>.http.server.request.duration`.
140
+ - Attributes: `http.request.method`, `http.route` (only when a route matched) and `http.response.status_code` — or `error.type` (`client_abort` / `timeout`) instead of a status code for aborted/timed-out requests.
141
+ - The same `rootApiPath` / ignore-list filtering applies: only traced requests record, and the histogram counts are the traced request counts.
142
+
143
+ ```typescript
144
+ const meter = new StandardMeter(config);
145
+ StandardTracerFastifyRegisterHooks(fastify, tracer, logger, {
146
+ standardMeter: meter,
147
+ });
148
+ ```
149
+
150
+ When the option is omitted (default), no metrics are recorded.
151
+
127
152
  ### `OTelRequestSpan(req)`
128
153
 
129
154
  Retrieves the active span for a Fastify request from the internal `WeakMap`.
@@ -166,7 +191,7 @@ fastify.get("/api/files/:id", async (req, res) => {
166
191
 
167
192
  ### Deprecated: `req.tracerSpanApi`
168
193
 
169
- The span is also assigned to `req.tracerSpanApi` as a deprecated compatibility alias. Use `OTelRequestSpan(req)` instead.
194
+ The span is also assigned to `req.tracerSpanApi` as a deprecated compatibility alias. **Deprecated in favor of `OTelRequestSpan(req)`**: it keeps working for the whole 1.x line and is **planned for removal in 2.0.0**, only after the known consumers (`common-utils`, `kubernetes-web-lightclient`) have migrated to `OTelRequestSpan(req)`. New code must use `OTelRequestSpan(req)`.
170
195
 
171
196
  ## Architecture
172
197
 
@@ -190,7 +215,17 @@ onResponse / onError / onRequestAbort / onTimeout
190
215
  └── WeakMap.delete(req)
191
216
  ```
192
217
 
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).
218
+ 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 set to `undefined` when the span ends (it is a plain property, so it must be cleaned up explicitly).
219
+
220
+ ## Behavior changes in 1.4.0
221
+
222
+ - **Span status policy aligns with the current OTel HTTP semantic conventions** (consumer-visible): span status is left **unset for 1xx–4xx** responses (previously OK for ≤ 299 and ERROR for 4xx+) and stays ERROR for 5xx, exceptions, `client_abort` and `timeout`. Status-based dashboards will see fewer ERROR spans (routine 404/4xx traffic no longer counts as server errors) and no more OK statuses.
223
+ - **Client errors are logged at warn level**: `onError` logs errors with `statusCode < 500` via `logger.warn` (no stack), all others via `logger.error` as before — routine validation/auth failures no longer inflate error logs/alerts.
224
+ - **`error.type` is more specific**: `error.name` when not the generic `Error`, otherwise `error.code` (e.g. `FST_ERR_VALIDATION`), otherwise `Error`.
225
+ - **Tracing is fail-open**: a fault in a hook (throwing tracer/span/logger) is caught, logged once at warn level, and the request continues untraced instead of failing with a 500.
226
+ - **Absolute-form request targets** (`GET http://host/api/x HTTP/1.1`) are now traced with the parsed pathname (`url.path=/api/x`); previously they were silently not traced.
227
+ - New opt-in options (defaults preserve previous behavior): `unmatchedRouteSpanName` and `standardMeter`.
228
+ - A span on which `onError` recorded an error is never downgraded by a 2xx response produced by a custom error handler (regression fix).
194
229
 
195
230
  ## Behavior changes in 1.3.0
196
231
 
@@ -1,4 +1,4 @@
1
- import { StandardLogger, StandardTracer } from "@devopsplaybook.io/otel-utils";
1
+ import { StandardLogger, StandardMeter, StandardTracer } from "@devopsplaybook.io/otel-utils";
2
2
  import { Context } from "@opentelemetry/api";
3
3
  import { Span } from "@opentelemetry/sdk-trace-base";
4
4
  import { FastifyInstance, FastifyRequest } from "fastify";
@@ -32,6 +32,32 @@ export interface StandardTracerFastifyRegisterHooksOptions {
32
32
  * Example: `["/health", "/metrics"]` ignores all methods targeting those paths.
33
33
  */
34
34
  ignoreListSuffix?: string[];
35
+ /**
36
+ * Span-name policy for requests that did not match a route (e.g. 404s).
37
+ *
38
+ * - `"path"` (default): span name falls back to the sanitized request path.
39
+ * Unbounded span-name cardinality from scanner/probe traffic and attack
40
+ * payloads recorded as span names are possible; the current HTTP semantic
41
+ * conventions recommend a low-cardinality name when available.
42
+ * - `"method"`: span name is the request method only (e.g. `GET`), keeping
43
+ * cardinality bounded. Note that `ignoreList`/`ignoreListPrefix`/
44
+ * `ignoreListSuffix` then match `"GET"` for unmatched requests.
45
+ *
46
+ * The full request path is always kept in the `url.path` attribute.
47
+ * Matched routes are unaffected.
48
+ */
49
+ unmatchedRouteSpanName?: "path" | "method";
50
+ /**
51
+ * Optional {@link StandardMeter} used to record the
52
+ * `http.server.request.duration` histogram (in seconds) for traced requests.
53
+ * Recorded attributes: `http.request.method`, `http.route` (only when a
54
+ * route matched) and `http.response.status_code` — or `error.type` instead
55
+ * of a status code for aborted/timed-out requests. Note that
56
+ * {@link StandardMeter} prefixes the metric name with the service id, so it
57
+ * is exported as `<SERVICE_ID>.http.server.request.duration`.
58
+ * When omitted, no metrics are recorded.
59
+ */
60
+ standardMeter?: StandardMeter;
35
61
  }
36
62
  /**
37
63
  * Registers Fastify lifecycle hooks that automatically create and manage
@@ -39,12 +65,18 @@ export interface StandardTracerFastifyRegisterHooksOptions {
39
65
  *
40
66
  * - Extracts incoming W3C trace context from request headers for distributed tracing.
41
67
  * - 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.
45
- * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
68
+ * matched routes, e.g. `GET-/api/files/_id` for `/api/files/:id`; path or
69
+ * method fallback when no route matched, see `unmatchedRouteSpanName`), with
70
+ * `http.request.method`, `url.path` (path only, no query string) and
71
+ * `http.route` (route template) attributes.
72
+ * - Sets the span status according to the current HTTP semantic conventions for
73
+ * server spans: unset for 1xx-4xx responses, ERROR for 5xx responses and
74
+ * exceptions. A span that already recorded an error is never downgraded.
46
75
  * - Ends the span on response, handler error, client abort or connection timeout.
47
- * - Logs errors via the provided {@link StandardLogger} with trace context.
76
+ * - Logs errors via the provided {@link StandardLogger} with trace context;
77
+ * client errors (`statusCode < 500`) are logged at warn level.
78
+ * - Tracing is fail-open: a fault in any hook is caught, logged once at warn
79
+ * level and the request continues untraced.
48
80
  * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
49
81
  * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
50
82
  * — checked in that order with short-circuit evaluation.
@@ -11,8 +11,11 @@ const propagator = new core_1.W3CTraceContextPropagator();
11
11
  // `ignoreList` entries are matched against the exported span name.
12
12
  const SPAN_NAME_SANITIZE_RE = /[^a-zA-Z0-9-_/]/g;
13
13
  const DEFAULT_ROOT_API_PATH = "/api";
14
+ const INTERNAL_URL_BASE = "http://internal";
14
15
  const requestSpans = new WeakMap();
15
16
  const requestContexts = new WeakMap();
17
+ const requestStartTimes = new WeakMap();
18
+ const requestsWithRecordedErrors = new WeakSet();
16
19
  const registeredFastifyInstances = new WeakSet();
17
20
  /**
18
21
  * Registers Fastify lifecycle hooks that automatically create and manage
@@ -20,12 +23,18 @@ const registeredFastifyInstances = new WeakSet();
20
23
  *
21
24
  * - Extracts incoming W3C trace context from request headers for distributed tracing.
22
25
  * - 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.
26
- * - Marks spans as ERROR on 4xx/5xx responses or when exceptions occur.
26
+ * matched routes, e.g. `GET-/api/files/_id` for `/api/files/:id`; path or
27
+ * method fallback when no route matched, see `unmatchedRouteSpanName`), with
28
+ * `http.request.method`, `url.path` (path only, no query string) and
29
+ * `http.route` (route template) attributes.
30
+ * - Sets the span status according to the current HTTP semantic conventions for
31
+ * server spans: unset for 1xx-4xx responses, ERROR for 5xx responses and
32
+ * exceptions. A span that already recorded an error is never downgraded.
27
33
  * - Ends the span on response, handler error, client abort or connection timeout.
28
- * - Logs errors via the provided {@link StandardLogger} with trace context.
34
+ * - Logs errors via the provided {@link StandardLogger} with trace context;
35
+ * client errors (`statusCode < 500`) are logged at warn level.
36
+ * - Tracing is fail-open: a fault in any hook is caught, logged once at warn
37
+ * level and the request continues untraced.
29
38
  * - Skips OPTIONS requests and requests outside the configured `rootApiPath`.
30
39
  * - Additional filtering via `ignoreList` (exact), `ignoreListPrefix`, `ignoreListSuffix`
31
40
  * — checked in that order with short-circuit evaluation.
@@ -45,6 +54,8 @@ const registeredFastifyInstances = new WeakSet();
45
54
  * @param options - Optional path filtering and ignore lists.
46
55
  */
47
56
  function StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLogger, options) {
57
+ var _a;
58
+ var _b;
48
59
  const logger = standardLogger.createModuleLogger("Fastify");
49
60
  if (registeredFastifyInstances.has(fastify)) {
50
61
  logger.warn("StandardTracerFastifyRegisterHooks is already registered on this Fastify instance: ignoring the duplicate registration.");
@@ -52,80 +63,176 @@ function StandardTracerFastifyRegisterHooks(fastify, standardTracer, standardLog
52
63
  }
53
64
  registeredFastifyInstances.add(fastify);
54
65
  const rootApiPath = normalizeRootApiPath(options === null || options === void 0 ? void 0 : options.rootApiPath);
66
+ const unmatchedRouteSpanName = (_b = options === null || options === void 0 ? void 0 : options.unmatchedRouteSpanName) !== null && _b !== void 0 ? _b : "path";
67
+ const requestDurationHistogram = (_a = options === null || options === void 0 ? void 0 : options.standardMeter) === null || _a === void 0 ? void 0 : _a.createHistogram(semantic_conventions_1.METRIC_HTTP_SERVER_REQUEST_DURATION);
68
+ // Per-registration guard against per-request log flooding when tracing fails.
69
+ let hookFailureLogged = false;
70
+ const logHookFailureOnce = (hook, error) => {
71
+ if (hookFailureLogged) {
72
+ return;
73
+ }
74
+ hookFailureLogged = true;
75
+ try {
76
+ logger.warn(`Tracing hook "${hook}" failed (${error instanceof Error ? error.message : String(error)}): continuing without tracing.`);
77
+ }
78
+ catch {
79
+ // A failing logger must not affect the request either.
80
+ }
81
+ };
82
+ const clearRequestState = (req) => {
83
+ requestSpans.delete(req);
84
+ requestContexts.delete(req);
85
+ requestStartTimes.delete(req);
86
+ requestsWithRecordedErrors.delete(req);
87
+ // Deprecated alias: assigning undefined avoids a V8 hidden-class
88
+ // transition per request; the tests assert `!== undefined`.
89
+ req.tracerSpanApi = undefined;
90
+ };
55
91
  const endSpan = (req, end = {}) => {
56
92
  const span = requestSpans.get(req);
57
93
  if (!span) {
58
94
  return;
59
95
  }
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);
96
+ const errorRecorded = requestsWithRecordedErrors.has(req);
97
+ const startTime = requestStartTimes.get(req);
98
+ clearRequestState(req);
99
+ try {
100
+ if (end.errorType) {
101
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
102
+ span.setAttribute(semantic_conventions_1.ATTR_ERROR_TYPE, end.errorType);
103
+ }
104
+ else if (end.statusCode !== undefined) {
105
+ // Status policy per the current OTel HTTP semantic conventions for
106
+ // server spans: leave the status unset for 1xx-4xx, ERROR for 5xx.
107
+ // A span on which onError already recorded an error is never
108
+ // downgraded (defense in depth; onError set ERROR already).
109
+ if (!errorRecorded && end.statusCode >= 500) {
110
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
111
+ }
112
+ span.setAttribute(semantic_conventions_1.ATTR_HTTP_RESPONSE_STATUS_CODE, end.statusCode);
113
+ }
114
+ if (requestDurationHistogram) {
115
+ // The start time is always set in onRequest when the histogram is
116
+ // configured (same guard), so the lookup cannot miss here.
117
+ const durationSeconds = Number(process.hrtime.bigint() - startTime) / 1e9;
118
+ const attributes = {
119
+ [semantic_conventions_1.ATTR_HTTP_REQUEST_METHOD]: req.method,
120
+ };
121
+ const routeTemplate = req.routeOptions.url;
122
+ if (routeTemplate) {
123
+ attributes[semantic_conventions_1.ATTR_HTTP_ROUTE] = routeTemplate;
124
+ }
125
+ if (end.errorType) {
126
+ attributes[semantic_conventions_1.ATTR_ERROR_TYPE] = end.errorType;
127
+ }
128
+ else if (end.statusCode !== undefined) {
129
+ attributes[semantic_conventions_1.ATTR_HTTP_RESPONSE_STATUS_CODE] = end.statusCode;
130
+ }
131
+ requestDurationHistogram.record(durationSeconds, attributes);
132
+ }
66
133
  }
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);
134
+ finally {
135
+ span.end();
72
136
  }
73
- span.end();
74
137
  };
75
138
  fastify.addHook("onRequest", async (req) => {
76
139
  var _a, _b, _c;
77
- if (req.method === "OPTIONS") {
78
- return;
140
+ try {
141
+ if (req.method === "OPTIONS") {
142
+ return;
143
+ }
144
+ const path = getRequestPath(req.url);
145
+ if (rootApiPath !== "/" &&
146
+ path !== rootApiPath &&
147
+ !path.startsWith(`${rootApiPath}/`)) {
148
+ return;
149
+ }
150
+ const routeTemplate = req.routeOptions.url;
151
+ let spanName;
152
+ if (routeTemplate) {
153
+ spanName = sanitizeSpanName(`${req.method}-${routeTemplate}`);
154
+ }
155
+ else if (unmatchedRouteSpanName === "method") {
156
+ spanName = sanitizeSpanName(req.method);
157
+ }
158
+ else {
159
+ spanName = sanitizeSpanName(`${req.method}-${path}`);
160
+ }
161
+ if (((_a = options === null || options === void 0 ? void 0 : options.ignoreList) === null || _a === void 0 ? void 0 : _a.includes(spanName)) ||
162
+ ((_b = options === null || options === void 0 ? void 0 : options.ignoreListPrefix) === null || _b === void 0 ? void 0 : _b.some((p) => spanName.startsWith(p))) ||
163
+ ((_c = options === null || options === void 0 ? void 0 : options.ignoreListSuffix) === null || _c === void 0 ? void 0 : _c.some((s) => spanName.endsWith(s)))) {
164
+ return;
165
+ }
166
+ const callerContext = propagator.extract(api_1.ROOT_CONTEXT, req.headers, api_1.defaultTextMapGetter);
167
+ // The span is created inside the extracted context so that it becomes a
168
+ // child of the caller span when the request carries a `traceparent`.
169
+ const span = api_1.context.with(callerContext, () => standardTracer.startSpan(spanName, undefined, {
170
+ kind: api_1.SpanKind.SERVER,
171
+ }));
172
+ span.setAttribute(semantic_conventions_1.ATTR_HTTP_REQUEST_METHOD, req.method);
173
+ span.setAttribute(semantic_conventions_1.ATTR_URL_PATH, path);
174
+ if (routeTemplate) {
175
+ span.setAttribute(semantic_conventions_1.ATTR_HTTP_ROUTE, routeTemplate);
176
+ }
177
+ requestSpans.set(req, span);
178
+ requestContexts.set(req, api_1.trace.setSpan(callerContext, span));
179
+ if (requestDurationHistogram) {
180
+ requestStartTimes.set(req, process.hrtime.bigint());
181
+ }
182
+ // Deprecated alias kept for consumers that reimplemented the accessor
183
+ // (`req.tracerSpanApi` before 1.1.0); use OTelRequestSpan(req) instead.
184
+ req.tracerSpanApi = span;
79
185
  }
80
- const path = req.url.split("?")[0];
81
- if (rootApiPath !== "/" &&
82
- path !== rootApiPath &&
83
- !path.startsWith(`${rootApiPath}/`)) {
84
- return;
186
+ catch (error) {
187
+ clearRequestState(req);
188
+ logHookFailureOnce("onRequest", error);
85
189
  }
86
- const routeTemplate = req.routeOptions.url;
87
- const spanName = sanitizeSpanName(`${req.method}-${routeTemplate || path}`);
88
- if (((_a = options === null || options === void 0 ? void 0 : options.ignoreList) === null || _a === void 0 ? void 0 : _a.includes(spanName)) ||
89
- ((_b = options === null || options === void 0 ? void 0 : options.ignoreListPrefix) === null || _b === void 0 ? void 0 : _b.some((p) => spanName.startsWith(p))) ||
90
- ((_c = options === null || options === void 0 ? void 0 : options.ignoreListSuffix) === null || _c === void 0 ? void 0 : _c.some((s) => spanName.endsWith(s)))) {
91
- return;
92
- }
93
- const callerContext = propagator.extract(api_1.ROOT_CONTEXT, req.headers, api_1.defaultTextMapGetter);
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;
109
190
  });
110
191
  fastify.addHook("onResponse", async (req, reply) => {
111
- endSpan(req, { statusCode: reply.statusCode });
192
+ try {
193
+ endSpan(req, { statusCode: reply.statusCode });
194
+ }
195
+ catch (error) {
196
+ logHookFailureOnce("onResponse", error);
197
+ }
112
198
  });
113
199
  fastify.addHook("onError", async (req, _reply, error) => {
114
- const span = requestSpans.get(req);
115
- if (!span) {
116
- return;
200
+ try {
201
+ const span = requestSpans.get(req);
202
+ if (!span) {
203
+ return;
204
+ }
205
+ requestsWithRecordedErrors.add(req);
206
+ const normalizedError = error instanceof Error ? error : new Error(String(error));
207
+ span.setStatus({ code: api_1.SpanStatusCode.ERROR });
208
+ span.setAttribute(semantic_conventions_1.ATTR_ERROR_TYPE, getErrorType(normalizedError));
209
+ span.recordException(normalizedError);
210
+ if (isClientError(normalizedError)) {
211
+ logger.warn(normalizedError.message, span);
212
+ }
213
+ else {
214
+ logger.error(normalizedError.message, normalizedError, span);
215
+ }
216
+ }
217
+ catch (hookError) {
218
+ logHookFailureOnce("onError", hookError);
117
219
  }
118
- const normalizedError = error instanceof Error ? error : new Error(String(error));
119
- span.setStatus({ code: api_1.SpanStatusCode.ERROR });
120
- span.setAttribute(semantic_conventions_1.ATTR_ERROR_TYPE, normalizedError.name);
121
- span.recordException(normalizedError);
122
- logger.error(normalizedError.message, normalizedError, span);
123
220
  });
124
221
  fastify.addHook("onRequestAbort", async (req) => {
125
- endSpan(req, { errorType: "client_abort" });
222
+ try {
223
+ endSpan(req, { errorType: "client_abort" });
224
+ }
225
+ catch (error) {
226
+ logHookFailureOnce("onRequestAbort", error);
227
+ }
126
228
  });
127
229
  fastify.addHook("onTimeout", async (req, _reply) => {
128
- endSpan(req, { errorType: "timeout" });
230
+ try {
231
+ endSpan(req, { errorType: "timeout" });
232
+ }
233
+ catch (error) {
234
+ logHookFailureOnce("onTimeout", error);
235
+ }
129
236
  });
130
237
  }
131
238
  function normalizeRootApiPath(rootApiPath) {
@@ -135,6 +242,39 @@ function normalizeRootApiPath(rootApiPath) {
135
242
  function sanitizeSpanName(name) {
136
243
  return name.replace(SPAN_NAME_SANITIZE_RE, "_");
137
244
  }
245
+ /**
246
+ * Extracts the pathname from a request target. Origin-form targets
247
+ * (`/path?query`, virtually all traffic) are split directly; absolute-form
248
+ * targets (e.g. `GET http://host/path HTTP/1.1`, allowed by HTTP/1.1 but rare
249
+ * in practice) are parsed with the URL parser. Unparseable targets cannot
250
+ * reach this function: the HTTP parser rejects them before the hooks run, and
251
+ * a residual parse failure is caught by the fail-open `onRequest` guard.
252
+ */
253
+ function getRequestPath(url) {
254
+ if (url.startsWith("/")) {
255
+ return url.split("?")[0];
256
+ }
257
+ return new URL(url, INTERNAL_URL_BASE).pathname;
258
+ }
259
+ function isClientError(error) {
260
+ const statusCode = error.statusCode;
261
+ return typeof statusCode === "number" && statusCode < 500;
262
+ }
263
+ /**
264
+ * Low-cardinality `error.type` value: the error name when it is not the
265
+ * generic `Error`, otherwise its `code` (e.g. `FST_ERR_VALIDATION` for Fastify
266
+ * validation errors), otherwise `"Error"`.
267
+ */
268
+ function getErrorType(error) {
269
+ if (error.name && error.name !== "Error") {
270
+ return error.name;
271
+ }
272
+ const code = error.code;
273
+ if (typeof code === "string" && code !== "") {
274
+ return code;
275
+ }
276
+ return "Error";
277
+ }
138
278
  /**
139
279
  * Retrieves the OpenTelemetry span associated with a Fastify request.
140
280
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@devopsplaybook.io/otel-utils-fastify",
3
- "version": "1.3.1",
3
+ "version": "1.4.0",
4
4
  "description": "Utility to simplify integration with Open Telemetry for Fastify API Server",
5
5
  "keywords": [
6
6
  "Open Telemetry",
@@ -14,6 +14,13 @@
14
14
  "type": "commonjs",
15
15
  "main": "dist/index.js",
16
16
  "types": "dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
17
24
  "files": [
18
25
  "dist"
19
26
  ],