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