@devopsplaybook.io/otel-utils-fastify 1.3.1 → 1.4.0-beta.36.6702e0d
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 +58 -23
- package/dist/src/StandardTracerFastify.d.ts +38 -6
- package/dist/src/StandardTracerFastify.js +198 -58
- package/package.json +8 -1
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
|
|
87
|
-
| `onResponse` |
|
|
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
|
|
97
|
-
|
|
|
98
|
-
| `rootApiPath`
|
|
99
|
-
| `ignoreList`
|
|
100
|
-
| `ignoreListPrefix`
|
|
101
|
-
| `ignoreListSuffix`
|
|
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
|
|
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.
|
|
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
|
|
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
|
|
44
|
-
* (path only, no query string) and
|
|
45
|
-
*
|
|
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
|
|
25
|
-
* (path only, no query string) and
|
|
26
|
-
*
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
68
|
-
span.
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
"version": "1.4.0-beta.36.6702e0d",
|
|
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
|
],
|