@saidsef/tracing-node 4.4.0 → 5.0.1
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 +26 -85
- package/libs/index.mjs +35 -21
- package/libs/index.test.mjs +91 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,32 +1,18 @@
|
|
|
1
|
-
#
|
|
1
|
+
# OpenTelemetry Wrapper for Tracing Node Applications
|
|
2
2
|
|
|
3
|
-
[](
|
|
4
|
-
[](
|
|
3
|
+
[](https://github.com/saidsef/tracing-node/actions/workflows/pr.yml)
|
|
4
|
+
[](https://github.com/saidsef/tracing-node/actions/workflows/release.yml)
|
|
5
|
+
[](https://tracing-node.readthedocs.io/en/latest/)
|
|
5
6
|

|
|
6
7
|
 
|
|
7
8
|

|
|
8
9
|

|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
| Feature | Description |
|
|
16
|
-
|---------|-------------|
|
|
17
|
-
| HTTP/HTTPS instrumentation | Automatic service detection |
|
|
18
|
-
| fetch/undici instrumentation | Outgoing `globalThis.fetch` calls |
|
|
19
|
-
| Express.js support | Framework instrumentation |
|
|
20
|
-
| Elasticsearch client | Database instrumentation |
|
|
21
|
-
| IORedis client | Cache instrumentation |
|
|
22
|
-
| AWS SDK | Cloud service instrumentation |
|
|
23
|
-
| Pino logger | Integration with trace/span IDs |
|
|
24
|
-
| Node runtime metrics | Event loop, garbage collection, heap |
|
|
25
|
-
| Log export | Pino records over OTLP, correlated by trace |
|
|
26
|
-
| RED metrics | Request duration histograms over OTLP |
|
|
27
|
-
| DNS/FS instrumentation | Optional monitoring |
|
|
28
|
-
| Resource detection | Host, OS, process, container |
|
|
29
|
-
| W3C Trace Context | Standard propagation |
|
|
11
|
+
**Traces, metrics and logs from one function call.** Add two lines to a service, and its requests, its calls to Redis, Elasticsearch, AWS and other services, its runtime counters and its Pino log records all arrive at your collector, already correlated by trace id and stitched into a service graph.
|
|
12
|
+
|
|
13
|
+
`@saidsef/tracing-node` wraps the OpenTelemetry Node SDK. One call to `setupTracing` builds the tracer, meter and logger providers, registers them globally, and turns on a fixed set of instrumentations, so an application gets all three signals without assembling exporters, span processors, resource detectors and instrumentation packages itself. A second call logs a warning and returns the tracer that already exists, which makes initialisation idempotent.
|
|
14
|
+
|
|
15
|
+
Full documentation: [tracing-node.readthedocs.io](https://tracing-node.readthedocs.io/).
|
|
30
16
|
|
|
31
17
|
## Prerequisites
|
|
32
18
|
- NodeJS
|
|
@@ -34,87 +20,42 @@ Effortlessly supercharge your applications with world-class distributed tracing!
|
|
|
34
20
|
- ...
|
|
35
21
|
- Profit?
|
|
36
22
|
|
|
37
|
-
##
|
|
38
|
-
|
|
39
|
-
`setupTracing` exports OTLP over gRPC, so any OpenTelemetry-compatible collector or backend will take it - point `url` at yours.
|
|
40
|
-
|
|
41
|
-
If you do not have one yet, [grafana-loki-on-k8s](https://github.com/saidsef/grafana-loki-on-k8s) is a companion project that deploys the full LGTM+ stack - Grafana, Prometheus, Mimir, Loki, Tempo, Pyroscope, Alloy and Beyla - to Kubernetes with `kubectl apply -k ./deployment`, broken into small composable manifests rather than a single opaque chart. Send traces to its Alloy OTLP receiver and they land in Tempo, with the metrics-generator turning them into RED and service-graph metrics in Mimir:
|
|
42
|
-
|
|
43
|
-
```javascript
|
|
44
|
-
setupTracing({serviceName: 'my-service', url: 'http://alloy:4317'});
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
The W3C Trace Context propagation this library registers is what lets Tempo pair a caller's client span with the callee's server span, which is what a service graph is built from.
|
|
48
|
-
|
|
49
|
-
Metrics go to the same endpoint by default and land in Mimir. They are recorded before the sampler runs, so they stay complete however far trace volume is turned down.
|
|
50
|
-
|
|
51
|
-
Pino log records go to the same endpoint and land in Loki, each carrying the trace and span id of the request that wrote it. No log agent or file scraping sits in between.
|
|
52
|
-
|
|
53
|
-
## Instalation
|
|
23
|
+
## Installation
|
|
54
24
|
|
|
55
|
-
```
|
|
25
|
+
```shell
|
|
56
26
|
npm install @saidsef/tracing-node --save
|
|
57
27
|
```
|
|
58
28
|
|
|
59
|
-
## Upgrading
|
|
60
|
-
|
|
61
|
-
Breaking changes and the attribute renames they bring are recorded in the [release notes](https://github.com/saidsef/tracing-node/releases) for the version concerned.
|
|
62
|
-
|
|
63
29
|
## Usage
|
|
64
30
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
Env vars:
|
|
68
|
-
| Environment Variable | Description | Required |
|
|
69
|
-
|-----------------------|----------------------------| --------- |
|
|
70
|
-
| CONTAINER_NAME/HOSTNAME| Container or pod hostname | No |
|
|
71
|
-
| ENDPOINT | Tracing collector endpoint | Yes |
|
|
72
|
-
| SERVICE_NAME | Service/application name | Yes |
|
|
73
|
-
|
|
74
|
-
Function args
|
|
75
|
-
```
|
|
31
|
+
```javascript
|
|
76
32
|
import { setupTracing } from '@saidsef/tracing-node';
|
|
33
|
+
|
|
77
34
|
setupTracing({hostname: 'hostname', serviceName: 'service_name', url: 'endpoint'});
|
|
78
35
|
```
|
|
79
36
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
| Name | Type | Description| Required | Default |
|
|
83
|
-
|----- | ---- | ------------- | ----- | ---- |
|
|
84
|
-
| hostname | string | container / pod hostname | No | `hostname` |
|
|
85
|
-
| serviceName | string | service / application name | Yes | `n/a` |
|
|
86
|
-
| url | string | tracing endpoint i.e. `<schema>://<host>:<port>` | Yes | `n/a` |
|
|
87
|
-
| enableFsInstrumentation | boolean | enable FS instrumentation | No | `false` |
|
|
88
|
-
| enableDnsInstrumentation | boolean | enable DNS instrumentation | No | `false` |
|
|
89
|
-
| enableMetrics | boolean | export metrics as well as traces | No | `true` |
|
|
90
|
-
| metricsUrl | string | metrics endpoint, when it differs from `url` | No | `url` |
|
|
91
|
-
| metricExportIntervalMillis | number | how often metrics are exported | No | `60000` |
|
|
92
|
-
| enableLogs | boolean | send Pino log records over OTLP | No | `true` |
|
|
93
|
-
| logsUrl | string | logs endpoint, when it differs from `url` | No | `url` |
|
|
37
|
+
`serviceName` and `url` are required, and both fall back to the `SERVICE_NAME` and `ENDPOINT` environment variables. `setupTracing` has to run before the application imports the libraries being traced.
|
|
94
38
|
|
|
95
39
|
## Documentation
|
|
96
40
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
Live docs: [tracing-node.readthedocs.io](https://tracing-node.readthedocs.io/)
|
|
41
|
+
The pages below are the manual. Their sources are in [`docs/`](./docs), and `npm run build-docs` renders the site into `site/`.
|
|
100
42
|
|
|
101
43
|
| Page | Contents |
|
|
102
44
|
|------|----------|
|
|
103
|
-
| [
|
|
104
|
-
| [
|
|
105
|
-
| [
|
|
106
|
-
| [
|
|
107
|
-
| [
|
|
108
|
-
| [
|
|
109
|
-
|
|
110
|
-
Build them locally with `npm run build-docs`, which renders the site into `site/`.
|
|
45
|
+
| [Overview](https://tracing-node.readthedocs.io/en/latest/) | What the library does, the feature set and the requirements |
|
|
46
|
+
| [Architecture](https://tracing-node.readthedocs.io/en/latest/architecture/) | The pipeline `setupTracing` builds, and how the service graph is fed |
|
|
47
|
+
| [Configuration](https://tracing-node.readthedocs.io/en/latest/usage/) | Every option, the environment variables, initialisation order and shutdown |
|
|
48
|
+
| [Instrumentation](https://tracing-node.readthedocs.io/en/latest/instrumentation/) | Each instrumentation, and the attributes it emits |
|
|
49
|
+
| [Deployment](https://tracing-node.readthedocs.io/en/latest/deployment/) | Running instrumented services in containers and Kubernetes |
|
|
50
|
+
| [Testing](https://tracing-node.readthedocs.io/en/latest/testing/) | The unit tests and the end to end harness |
|
|
51
|
+
| [Troubleshooting](https://tracing-node.readthedocs.io/en/latest/troubleshooting/) | Symptoms, causes and fixes |
|
|
111
52
|
|
|
112
|
-
##
|
|
53
|
+
## Upgrading
|
|
113
54
|
|
|
114
|
-
|
|
55
|
+
Breaking changes and the attribute renames they bring are recorded in the [release notes](https://github.com/saidsef/tracing-node/releases) for the version concerned.
|
|
115
56
|
|
|
116
57
|
## Contributing
|
|
117
58
|
|
|
118
|
-
|
|
59
|
+
Our latest and greatest source of `tracing-node` can be found on [GitHub](https://github.com/saidsef/tracing-node/fork). Fork us!
|
|
119
60
|
|
|
120
|
-
Please read the official [Contribution Guide](./CONTRIBUTING.md) for more information on how you can contribute.
|
|
61
|
+
We would :heart: you to contribute by making a [pull request](https://github.com/saidsef/tracing-node/pulls). Please read the official [Contribution Guide](./CONTRIBUTING.md) for more information on how you can contribute.
|
package/libs/index.mjs
CHANGED
|
@@ -21,7 +21,7 @@ import {diag, DiagConsoleLogger, DiagLogLevel, metrics} from '@opentelemetry/api
|
|
|
21
21
|
import {HttpInstrumentation} from '@opentelemetry/instrumentation-http';
|
|
22
22
|
import {DnsInstrumentation} from '@opentelemetry/instrumentation-dns';
|
|
23
23
|
import {ElasticsearchInstrumentation} from 'opentelemetry-instrumentation-elasticsearch';
|
|
24
|
-
import {ExpressInstrumentation} from '@opentelemetry/instrumentation-express';
|
|
24
|
+
import {ExpressInstrumentation, ExpressLayerType} from '@opentelemetry/instrumentation-express';
|
|
25
25
|
import {logs} from '@opentelemetry/api-logs';
|
|
26
26
|
import {NodeTracerProvider} from '@opentelemetry/sdk-trace-node';
|
|
27
27
|
import {OTLPLogExporter} from '@opentelemetry/exporter-logs-otlp-grpc';
|
|
@@ -72,6 +72,32 @@ const setPeerService = (span, host) => {
|
|
|
72
72
|
}
|
|
73
73
|
};
|
|
74
74
|
|
|
75
|
+
// The express hook runs once per layer span - every middleware, every router,
|
|
76
|
+
// and the request handler. Only the request handler carries the matched route,
|
|
77
|
+
// so the rest return before serialising anything.
|
|
78
|
+
const expressRequestHook = (span, info) => {
|
|
79
|
+
// info is ExpressRequestInfo: { request, route, layerType }
|
|
80
|
+
if (info?.layerType !== ExpressLayerType.REQUEST_HANDLER) return;
|
|
81
|
+
|
|
82
|
+
const request = info.request;
|
|
83
|
+
if (info.route) {
|
|
84
|
+
span.setAttribute('express.route', info.route);
|
|
85
|
+
}
|
|
86
|
+
if (request?.params && Object.keys(request.params).length > 0) {
|
|
87
|
+
span.setAttribute('express.params', JSON.stringify(request.params));
|
|
88
|
+
}
|
|
89
|
+
// Names only. Query values carry tokens and personal data, and the span
|
|
90
|
+
// attribute value length limit defaults to unbounded.
|
|
91
|
+
const queryKeys = request?.query ? Object.keys(request.query) : [];
|
|
92
|
+
if (queryKeys.length > 0) {
|
|
93
|
+
span.setAttribute('express.query_keys', queryKeys.sort());
|
|
94
|
+
}
|
|
95
|
+
// Add user context if available
|
|
96
|
+
if (request?.user?.id) {
|
|
97
|
+
span.setAttribute('user.id', request.user.id);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
75
101
|
let tracerProvider = null; // Declare provider in module scope for access in stopTracing
|
|
76
102
|
let meterProvider = null;
|
|
77
103
|
let loggerProvider = null;
|
|
@@ -247,26 +273,7 @@ export function setupTracing(options = {}) {
|
|
|
247
273
|
requestHook: (span, request) => setPeerService(span, request?.origin),
|
|
248
274
|
}),
|
|
249
275
|
new ExpressInstrumentation({
|
|
250
|
-
requestHook:
|
|
251
|
-
// info is ExpressRequestInfo: { request, route, layerType }
|
|
252
|
-
const request = info.request;
|
|
253
|
-
if (info.route) {
|
|
254
|
-
span.setAttribute('express.route', info.route);
|
|
255
|
-
if (request?.method) {
|
|
256
|
-
span.updateName(`${request.method} ${info.route}`);
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
if (request?.params && Object.keys(request.params).length > 0) {
|
|
260
|
-
span.setAttribute('express.params', JSON.stringify(request.params));
|
|
261
|
-
}
|
|
262
|
-
if (request?.query && Object.keys(request.query).length > 0) {
|
|
263
|
-
span.setAttribute('express.query', JSON.stringify(request.query));
|
|
264
|
-
}
|
|
265
|
-
// Add user context if available
|
|
266
|
-
if (request?.user?.id) {
|
|
267
|
-
span.setAttribute('user.id', request.user.id);
|
|
268
|
-
}
|
|
269
|
-
},
|
|
276
|
+
requestHook: expressRequestHook,
|
|
270
277
|
}),
|
|
271
278
|
new PinoInstrumentation({
|
|
272
279
|
// Log sending is on by default, and every record is parsed and rebuilt as
|
|
@@ -417,6 +424,13 @@ export async function stopTracing() {
|
|
|
417
424
|
}
|
|
418
425
|
}
|
|
419
426
|
|
|
427
|
+
/**
|
|
428
|
+
* @internal
|
|
429
|
+
* The express request hook, exposed so it can be driven without Express.
|
|
430
|
+
* DO NOT use in production code.
|
|
431
|
+
*/
|
|
432
|
+
export const __expressRequestHookForTesting = expressRequestHook;
|
|
433
|
+
|
|
420
434
|
/**
|
|
421
435
|
* @internal
|
|
422
436
|
* Resets the tracer provider for testing purposes.
|
package/libs/index.test.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { metrics } from '@opentelemetry/api';
|
|
|
5
5
|
import { logs } from '@opentelemetry/api-logs';
|
|
6
6
|
import { MeterProvider } from '@opentelemetry/sdk-metrics';
|
|
7
7
|
import { LoggerProvider } from '@opentelemetry/sdk-logs';
|
|
8
|
-
import { setupTracing, stopTracing, __resetTracingForTesting } from './index.mjs';
|
|
8
|
+
import { setupTracing, stopTracing, __resetTracingForTesting, __expressRequestHookForTesting } from './index.mjs';
|
|
9
9
|
|
|
10
10
|
describe('setupTracing', () => {
|
|
11
11
|
// Clear environment and reset tracing state before each test
|
|
@@ -172,3 +172,93 @@ describe('setupTracing', () => {
|
|
|
172
172
|
assert.ok(metrics.getMeterProvider() instanceof MeterProvider, 'a later setup should register again');
|
|
173
173
|
});
|
|
174
174
|
});
|
|
175
|
+
|
|
176
|
+
// The instrumentation calls the hook once per layer span, so the cheap path
|
|
177
|
+
// through it matters as much as what it records.
|
|
178
|
+
describe('express request hook', () => {
|
|
179
|
+
const fakeSpan = () => {
|
|
180
|
+
const attributes = {};
|
|
181
|
+
return {
|
|
182
|
+
attributes,
|
|
183
|
+
names: [],
|
|
184
|
+
setAttribute(key, value) {
|
|
185
|
+
attributes[key] = value;
|
|
186
|
+
},
|
|
187
|
+
updateName(name) {
|
|
188
|
+
this.names.push(name);
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const requestHandler = (request, route = '/work/:id') => ({
|
|
194
|
+
request,
|
|
195
|
+
route,
|
|
196
|
+
layerType: 'request_handler',
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should record route and params on a request handler layer', () => {
|
|
200
|
+
const span = fakeSpan();
|
|
201
|
+
__expressRequestHookForTesting(span, requestHandler({
|
|
202
|
+
method: 'GET',
|
|
203
|
+
params: {id: '42'},
|
|
204
|
+
query: {},
|
|
205
|
+
}));
|
|
206
|
+
assert.strictEqual(span.attributes['express.route'], '/work/:id');
|
|
207
|
+
assert.strictEqual(span.attributes['express.params'], '{"id":"42"}');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should ignore middleware and router layers', () => {
|
|
211
|
+
for (const layerType of ['middleware', 'router']) {
|
|
212
|
+
const span = fakeSpan();
|
|
213
|
+
__expressRequestHookForTesting(span, {
|
|
214
|
+
request: {method: 'GET', params: {id: '42'}, query: {page: '1'}},
|
|
215
|
+
route: '/work/:id',
|
|
216
|
+
layerType,
|
|
217
|
+
});
|
|
218
|
+
assert.deepStrictEqual(span.attributes, {}, `${layerType} layer should record nothing`);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
// The HTTP instrumentation renames the server span from http.route already.
|
|
223
|
+
// Renaming here would relabel every middleware span with the same string.
|
|
224
|
+
it('should not rename the span', () => {
|
|
225
|
+
const span = fakeSpan();
|
|
226
|
+
__expressRequestHookForTesting(span, requestHandler({
|
|
227
|
+
method: 'GET',
|
|
228
|
+
params: {id: '42'},
|
|
229
|
+
query: {},
|
|
230
|
+
}));
|
|
231
|
+
assert.deepStrictEqual(span.names, [], 'the hook should not rename a span');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// A query string carries tokens and personal data, and the span attribute
|
|
235
|
+
// value length limit is unbounded by default.
|
|
236
|
+
it('should record query key names without their values', () => {
|
|
237
|
+
const span = fakeSpan();
|
|
238
|
+
__expressRequestHookForTesting(span, requestHandler({
|
|
239
|
+
method: 'GET',
|
|
240
|
+
params: {},
|
|
241
|
+
query: {token: 'sensitive-value', page: '2'},
|
|
242
|
+
}));
|
|
243
|
+
assert.deepStrictEqual(span.attributes['express.query_keys'], ['page', 'token']);
|
|
244
|
+
assert.strictEqual(span.attributes['express.query'], undefined, 'query values should not be recorded');
|
|
245
|
+
assert.ok(!JSON.stringify(span.attributes).includes('sensitive-value'), 'no query value should reach the span');
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('should record the user id when the application sets one', () => {
|
|
249
|
+
const span = fakeSpan();
|
|
250
|
+
__expressRequestHookForTesting(span, requestHandler({
|
|
251
|
+
method: 'GET',
|
|
252
|
+
params: {},
|
|
253
|
+
query: {},
|
|
254
|
+
user: {id: 'user-7'},
|
|
255
|
+
}));
|
|
256
|
+
assert.strictEqual(span.attributes['user.id'], 'user-7');
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should tolerate a layer with no request', () => {
|
|
260
|
+
const span = fakeSpan();
|
|
261
|
+
assert.doesNotThrow(() => __expressRequestHookForTesting(span, {layerType: 'request_handler'}));
|
|
262
|
+
assert.deepStrictEqual(span.attributes, {});
|
|
263
|
+
});
|
|
264
|
+
});
|