@devopsplaybook.io/otel-utils-fastify 1.2.1-beta.33.4459dff → 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 +144 -48
- package/dist/src/StandardTracerFastify.d.ts +39 -6
- package/dist/src/StandardTracerFastify.js +109 -24
- package/package.json +18 -5
- package/.github/workflows/main-build.yml +0 -16
- package/.github/workflows/pr-check.yml +0 -25
- package/index.ts +0 -1
- package/jest.config.js +0 -17
- package/prettierrc.json +0 -5
- package/src/StandardTracerFastify.spec.ts +0 -308
- package/src/StandardTracerFastify.ts +0 -144
- package/tsconfig.json +0 -14
- package/tsconfig.spec.json +0 -8
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
|
-
|
|
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 {
|
|
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
|
|
82
|
+
Registers five Fastify hooks:
|
|
59
83
|
|
|
60
|
-
| Hook
|
|
61
|
-
|
|
|
62
|
-
| `onRequest`
|
|
63
|
-
| `onResponse`
|
|
64
|
-
| `onError`
|
|
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
|
|
71
|
-
| `ignoreList` | `string[]?` | — | Exact span names to skip. Format: `"METHOD-/
|
|
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
|
-
|
|
84
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
|
117
|
-
| `@opentelemetry/
|
|
118
|
-
| `@opentelemetry/
|
|
119
|
-
| `@opentelemetry/
|
|
120
|
-
| `
|
|
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
|
|
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
|
|
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
|
|
16
|
-
* Format: `"METHOD-/
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
* -
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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.
|
|
68
|
-
|
|
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.
|
|
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
|
-
"
|
|
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,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
|
-
}
|