@nxgt/telemetry 0.1.0 → 0.2.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 +742 -0
- package/dist/attributes/attributes.d.ts +7 -0
- package/dist/attributes/attributes.d.ts.map +1 -1
- package/dist/context/current.d.ts +43 -0
- package/dist/context/current.d.ts.map +1 -0
- package/dist/export/console.d.ts +20 -0
- package/dist/export/console.d.ts.map +1 -0
- package/dist/export/exporter.d.ts +39 -0
- package/dist/export/exporter.d.ts.map +1 -0
- package/dist/export/file.d.ts +43 -0
- package/dist/export/file.d.ts.map +1 -0
- package/dist/export/json-lines.d.ts +17 -0
- package/dist/export/json-lines.d.ts.map +1 -0
- package/dist/export/pipeline.d.ts +64 -0
- package/dist/export/pipeline.d.ts.map +1 -0
- package/dist/export/rotation.d.ts +46 -0
- package/dist/export/rotation.d.ts.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +845 -33
- package/dist/index.js.map +18 -6
- package/dist/logger/event.d.ts +15 -0
- package/dist/logger/event.d.ts.map +1 -0
- package/dist/logger/logger.d.ts +46 -0
- package/dist/logger/logger.d.ts.map +1 -0
- package/dist/logger/standard-schema.d.ts +32 -0
- package/dist/logger/standard-schema.d.ts.map +1 -0
- package/dist/model/error.d.ts +21 -0
- package/dist/model/error.d.ts.map +1 -0
- package/dist/span/scope.d.ts +71 -0
- package/dist/span/scope.d.ts.map +1 -0
- package/dist/span/span.d.ts +13 -0
- package/dist/span/span.d.ts.map +1 -0
- package/dist/telemetry/telemetry.d.ts +83 -0
- package/dist/telemetry/telemetry.d.ts.map +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -24,6 +24,668 @@ vocabulary exactly: the same severities, span kinds, statuses, `traceparent`
|
|
|
24
24
|
rules, sampling rule and attribute names. A trace started in one and continued
|
|
25
25
|
in the other is one trace.
|
|
26
26
|
|
|
27
|
+
## Usage
|
|
28
|
+
|
|
29
|
+
The whole life of a service, in the order you write it. Each step links to the
|
|
30
|
+
section that explains it.
|
|
31
|
+
|
|
32
|
+
**1. Install one telemetry at startup**, before anything logs.
|
|
33
|
+
[The root](#the-root)
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
// telemetry.ts
|
|
37
|
+
import { consoleExporter, createTelemetry } from '@nxgt/telemetry';
|
|
38
|
+
|
|
39
|
+
export const telemetry = createTelemetry('checkout', {
|
|
40
|
+
version: '1.4.0',
|
|
41
|
+
environment: 'production',
|
|
42
|
+
exporters: [consoleExporter()],
|
|
43
|
+
}).install();
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
**2. Give each part of the code a logger.** A logger is cheap and has no
|
|
47
|
+
state; create it at module level. [Logging](#logging)
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
import { createLogger } from '@nxgt/telemetry';
|
|
51
|
+
|
|
52
|
+
const log = createLogger('CheckoutService');
|
|
53
|
+
|
|
54
|
+
log.info('checkout started', { cartSize: 3 });
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**3. Declare the events that matter**, so a log line carries only the fields
|
|
58
|
+
you chose. [Declared event](#declared-event)
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
import { event } from '@nxgt/telemetry';
|
|
62
|
+
import { z } from 'zod';
|
|
63
|
+
|
|
64
|
+
const Charged = event('checkout.charged', z.object({ orderId: z.string(), amount: z.number() }));
|
|
65
|
+
|
|
66
|
+
log.info(Charged({ orderId: 'o-1', amount: 42 }));
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**4. Wrap units of work in spans.** Everything inside a span, logs included,
|
|
70
|
+
carries its `traceId`. [Spans](#spans)
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
import { span } from '@nxgt/telemetry';
|
|
74
|
+
|
|
75
|
+
export async function checkout(orderId: string) {
|
|
76
|
+
return span('checkout', { attributes: { orderId } }, async (scope) => {
|
|
77
|
+
const order = await span('order.load', () => orders.find(orderId));
|
|
78
|
+
scope.attribute('order.total', order.total);
|
|
79
|
+
await span('payment.charge', { kind: 'client' }, () => payments.charge(order));
|
|
80
|
+
log.info(Charged({ orderId, amount: order.total })); // carries the trace
|
|
81
|
+
return order;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**5. Carry the trace across services.** Continue the caller's `traceparent` on
|
|
87
|
+
the way in and send the current one on the way out.
|
|
88
|
+
[Propagation](#propagation-and-traceparent)
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { continuing, currentTraceparent } from '@nxgt/telemetry';
|
|
92
|
+
|
|
93
|
+
// in: an HTTP handler
|
|
94
|
+
await continuing(request.headers.get('traceparent'), 'POST /checkout', { kind: 'server' }, () =>
|
|
95
|
+
checkout(orderId),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
// out: an HTTP call made inside a span
|
|
99
|
+
await fetch('https://stock.internal/reserve', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { traceparent: currentTraceparent() ?? '' },
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
With Hono and httpyz, the two integrations do both halves for you:
|
|
106
|
+
[`@nxgt/telemetry-hono`](https://www.npmjs.com/package/@nxgt/telemetry-hono)
|
|
107
|
+
and [`@nxgt/telemetry-httpyz`](https://www.npmjs.com/package/@nxgt/telemetry-httpyz).
|
|
108
|
+
|
|
109
|
+
**6. Put request-wide facts in scope once**, instead of passing them to every
|
|
110
|
+
log call. [Context](#context)
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { withAttributes } from '@nxgt/telemetry';
|
|
114
|
+
|
|
115
|
+
await withAttributes({ tenant: 'acme' }, () => checkout(orderId));
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
**7. Ship the signals somewhere.** Swap the console for a collector, a file or
|
|
119
|
+
MongoDB. The code from steps 2 to 6 does not change. [Exporters](#exporters)
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
import { otlpExporter } from '@nxgt/telemetry-otlp';
|
|
123
|
+
|
|
124
|
+
createTelemetry('checkout', {
|
|
125
|
+
sampler: ratioSampler(0.1), // keep one trace in ten; logs are never sampled
|
|
126
|
+
exporters: [otlpExporter({ endpoint: 'http://localhost:4318' })],
|
|
127
|
+
}).install();
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**8. Close it on shutdown, and await it**, or the last batch is lost.
|
|
131
|
+
[The root](#the-root)
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
process.on('SIGTERM', async () => {
|
|
135
|
+
await telemetry.close();
|
|
136
|
+
process.exit(0);
|
|
137
|
+
});
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**In a test**, give each suite its own telemetry and an exporter that keeps
|
|
141
|
+
what it receives, instead of installing one globally.
|
|
142
|
+
[Context](#context)
|
|
143
|
+
|
|
144
|
+
```ts
|
|
145
|
+
import type { Exporter, Signal } from '@nxgt/telemetry';
|
|
146
|
+
import { createTelemetry, withTelemetry } from '@nxgt/telemetry';
|
|
147
|
+
|
|
148
|
+
const received: Signal[] = [];
|
|
149
|
+
const collect: Exporter = { export: (_resource, batch) => void received.push(...batch) };
|
|
150
|
+
const telemetry = createTelemetry('test', { exporters: [collect] });
|
|
151
|
+
|
|
152
|
+
await withTelemetry(telemetry, () => checkout('o-1'));
|
|
153
|
+
await telemetry.close(); // flushes: `received` now holds the spans and logs
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## Concepts
|
|
157
|
+
|
|
158
|
+
Every word this library uses, once, with the smallest example that shows it.
|
|
159
|
+
The sections after this one go deeper; this is the glossary to come back to.
|
|
160
|
+
|
|
161
|
+
### Telemetry
|
|
162
|
+
|
|
163
|
+
The root object: one per service. It knows **who** is speaking (the resource),
|
|
164
|
+
**how much** to keep (the sampler, the minimum severity) and **where** signals go
|
|
165
|
+
(the exporters). Everything else — spans, loggers — finds it through the context,
|
|
166
|
+
or falls back to the one `install()`ed.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
const telemetry = createTelemetry('checkout', {
|
|
170
|
+
exporters: [consoleExporter()],
|
|
171
|
+
}).install();
|
|
172
|
+
|
|
173
|
+
await telemetry.close(); // ships what is left; must be awaited
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Resource
|
|
177
|
+
|
|
178
|
+
What every signal from one telemetry is about: the service, its version, its
|
|
179
|
+
environment, and any attributes you add. It is stamped once, not repeated by
|
|
180
|
+
each call site.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
createTelemetry('checkout', {
|
|
184
|
+
version: '1.4.0',
|
|
185
|
+
environment: 'production',
|
|
186
|
+
attributes: { 'deployment.region': 'eu-west-1' },
|
|
187
|
+
});
|
|
188
|
+
// telemetry.resource
|
|
189
|
+
// → { service: 'checkout', version: '1.4.0', environment: 'production',
|
|
190
|
+
// attributes: { 'deployment.region': 'eu-west-1' } }
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
### Signal
|
|
194
|
+
|
|
195
|
+
The unit an exporter receives. There are two kinds, told apart by `type`: a
|
|
196
|
+
**log record** and a **span record**. Metrics would be a third kind; nothing
|
|
197
|
+
else would have to change.
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
function describe(signal: Signal): string {
|
|
201
|
+
return signal.type === 'log'
|
|
202
|
+
? `${signal.severity} ${signal.name}`
|
|
203
|
+
: `${signal.name} took ${signal.endedAt - signal.startedAt} ms`;
|
|
204
|
+
}
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Trace
|
|
208
|
+
|
|
209
|
+
All the work done for one request, across every service it touched. It has no
|
|
210
|
+
object of its own: it is every span that shares one **trace id**.
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
await span('GET /orders/:id', { kind: 'server' }, async () => {
|
|
214
|
+
await span('load order', {}, async () => { /* … */ });
|
|
215
|
+
await span('price order', {}, async () => { /* … */ });
|
|
216
|
+
});
|
|
217
|
+
// three spans, one traceId: that is the trace
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### Span
|
|
221
|
+
|
|
222
|
+
One timed piece of work inside a trace: a name, a start, an end, a status, and
|
|
223
|
+
the span it happened inside (its **parent**). A span is a block you `await`, and
|
|
224
|
+
the block's lifetime *is* the span's.
|
|
225
|
+
|
|
226
|
+
```ts
|
|
227
|
+
const total = await span('price order', { attributes: { orderId } }, async (scope) => {
|
|
228
|
+
scope.attribute('items', order.items.length);
|
|
229
|
+
return computeTotal(order);
|
|
230
|
+
});
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
A span with no parent is a **root span**. The block's return value comes back
|
|
234
|
+
out; a thrown error goes back out too, after being recorded.
|
|
235
|
+
|
|
236
|
+
### Span scope
|
|
237
|
+
|
|
238
|
+
What the block receives: the handle on the span that is open. Through it you
|
|
239
|
+
name the span, add attributes and events, set or read the status, or record a
|
|
240
|
+
failure you caught and chose not to rethrow.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
await span('charge', {}, async (scope) => {
|
|
244
|
+
scope.name = `charge ${provider}`; // renamed once the provider is known
|
|
245
|
+
scope.event('gateway.called', { attempt: 1 });
|
|
246
|
+
try {
|
|
247
|
+
await gateway.charge(card);
|
|
248
|
+
} catch (failure) {
|
|
249
|
+
scope.fail(failure); // status → error, exception recorded
|
|
250
|
+
await queue.retryLater(card); // …and handled, not rethrown
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### Span kind
|
|
256
|
+
|
|
257
|
+
What role the span played: `internal` (the default), `server` (it answered a
|
|
258
|
+
request), `client` (it made one), `producer` and `consumer` (it sent or received
|
|
259
|
+
a message). A backend draws the arrows between services from `server` and
|
|
260
|
+
`client`.
|
|
261
|
+
|
|
262
|
+
```ts
|
|
263
|
+
await span('GET /orders/:id', { kind: 'server' }, handle);
|
|
264
|
+
await span('GET inventory', { kind: 'client' }, () => fetch(url));
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
### Span status
|
|
268
|
+
|
|
269
|
+
How the work ended: `ok`, `error`, or `cancelled`. A thrown error makes it
|
|
270
|
+
`error`; an `AbortError` or `TimeoutError` makes it `cancelled`, because a
|
|
271
|
+
shutdown or a timeout is not a bug somebody should be paged for.
|
|
272
|
+
|
|
273
|
+
```ts
|
|
274
|
+
await span('import', {}, async () => {
|
|
275
|
+
throw new Error('bad row'); // status: 'error', rethrown
|
|
276
|
+
}).catch(() => {});
|
|
277
|
+
|
|
278
|
+
await span('poll', {}, async () => {
|
|
279
|
+
await fetch(url, { signal: AbortSignal.timeout(10) }); // status: 'cancelled'
|
|
280
|
+
}).catch(() => {});
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Span event
|
|
284
|
+
|
|
285
|
+
Something that happened at an instant during a span, with its own attributes —
|
|
286
|
+
a retry, a cache miss, a state change. Cheaper than a child span, and attached
|
|
287
|
+
to the work it describes.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
await span('charge', {}, async (scope) => {
|
|
291
|
+
scope.event('retry', { attempt: 2, reason: 'timeout' });
|
|
292
|
+
});
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
### Span context
|
|
296
|
+
|
|
297
|
+
A span's place in a trace, as it travels: the trace id, the span id, whether the
|
|
298
|
+
trace is **sampled**, and whether it arrived from another process (**remote**).
|
|
299
|
+
It is what a `traceparent` header carries, and what a log attaches.
|
|
300
|
+
|
|
301
|
+
```ts
|
|
302
|
+
currentSpan();
|
|
303
|
+
// → { traceId: '4bf92f35…0e4736', spanId: '00f067aa0ba902b7', sampled: true, remote: false }
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Ids are lowercase hex — 32 characters for a trace, 16 for a span — and typed as
|
|
307
|
+
`TraceId` and `SpanId`, so the compiler refuses one where the other belongs.
|
|
308
|
+
|
|
309
|
+
### Detached context
|
|
310
|
+
|
|
311
|
+
What a span carries when **no telemetry is installed**: all-zero ids, not
|
|
312
|
+
sampled. The block runs, nothing is emitted. It is what lets a library use
|
|
313
|
+
`span()` inside an application that has never heard of this package.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
await span('work', {}, async (scope) => {
|
|
317
|
+
isDetached(scope.context); // true when nothing is installed
|
|
318
|
+
});
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Propagation and `traceparent`
|
|
322
|
+
|
|
323
|
+
How a trace crosses a process boundary: the caller writes its span context into
|
|
324
|
+
a W3C `traceparent` header, and the callee **continues** the trace from it
|
|
325
|
+
instead of starting a new one.
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
// the caller
|
|
329
|
+
await fetch(url, { headers: { traceparent: currentTraceparent() ?? '' } });
|
|
330
|
+
|
|
331
|
+
// the callee
|
|
332
|
+
await continuing(request.headers.get('traceparent'), 'GET /orders', async () => {
|
|
333
|
+
// same traceId as the caller; this span's parent is the caller's span
|
|
334
|
+
});
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
`@nxgt/telemetry-httpyz` and `@nxgt/telemetry-hono` do both halves for you.
|
|
338
|
+
|
|
339
|
+
### Context
|
|
340
|
+
|
|
341
|
+
Where the current span, the current telemetry and the inherited attributes
|
|
342
|
+
live, so that nothing has to be passed by hand. It is an `AsyncLocalStorage`:
|
|
343
|
+
it follows your work through every `await`, timer and promise, and two
|
|
344
|
+
concurrent requests never see each other's.
|
|
345
|
+
|
|
346
|
+
```ts
|
|
347
|
+
await span('request', {}, async () => {
|
|
348
|
+
await Promise.all([a(), b()]); // both see 'request' as their current span
|
|
349
|
+
});
|
|
350
|
+
currentSpan(); // undefined: out here, nothing is open
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
### Attributes
|
|
354
|
+
|
|
355
|
+
Key–value pairs describing a signal, where a value is a **scalar or a list of
|
|
356
|
+
scalars** — what a backend can filter and group by. Names follow OpenTelemetry's
|
|
357
|
+
conventions where one exists (`http.route`, `db.system.name`).
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
span('charge', { attributes: { orderId: 'o-1', amount: 4200, retried: false } }, block);
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Attribute inheritance
|
|
364
|
+
|
|
365
|
+
Attributes given to `span()` or `withAttributes()` are **inherited** by every log
|
|
366
|
+
and span inside the block. Those set with `scope.attribute()` belong to that one
|
|
367
|
+
span. When two names clash, the inner one wins.
|
|
368
|
+
|
|
369
|
+
```ts
|
|
370
|
+
await withAttributes({ tenant: 'acme' }, async () => {
|
|
371
|
+
await span('charge', { attributes: { orderId: 'o-1' } }, async (scope) => {
|
|
372
|
+
scope.attribute('provider', 'stripe'); // this span only
|
|
373
|
+
log.info('charged'); // carries tenant and orderId
|
|
374
|
+
});
|
|
375
|
+
});
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### Logger and source
|
|
379
|
+
|
|
380
|
+
A logger is named for the component that writes through it — its **source** —
|
|
381
|
+
and every record it produces carries that name. Loggers are cheap and can be
|
|
382
|
+
created at module level: they find the telemetry when they write, not when they
|
|
383
|
+
are made.
|
|
384
|
+
|
|
385
|
+
```ts
|
|
386
|
+
const log = createLogger('CheckoutService');
|
|
387
|
+
log.info('order stored', { orderId });
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
### Severity
|
|
391
|
+
|
|
392
|
+
How much a log matters: `debug`, `info`, `warn`, `error`. There is no `trace`.
|
|
393
|
+
The telemetry's **minimum** is the floor: below it a log is never built.
|
|
394
|
+
|
|
395
|
+
```ts
|
|
396
|
+
createTelemetry('checkout', { minimum: 'warn' });
|
|
397
|
+
log.info('ignored'); // below the floor: not built, not emitted
|
|
398
|
+
log.warn('kept');
|
|
399
|
+
```
|
|
400
|
+
|
|
401
|
+
### Log record
|
|
402
|
+
|
|
403
|
+
What a log call produces: when, how severe, a name, the source, attributes, the
|
|
404
|
+
span it was written in (if any) and the failure it is about (if any).
|
|
405
|
+
|
|
406
|
+
```ts
|
|
407
|
+
log.error('charge failed', new Error('card refused'), { orderId: 'o-1' });
|
|
408
|
+
// → { type: 'log', severity: 'error', name: 'charge failed',
|
|
409
|
+
// source: 'CheckoutService', attributes: { orderId: 'o-1' },
|
|
410
|
+
// span: { traceId, spanId, … }, error: { type: 'Error', message: 'card refused', stackTrace } }
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Declared event
|
|
414
|
+
|
|
415
|
+
A log whose fields are **declared by a schema**, so only what the schema names
|
|
416
|
+
is written. It is how a log stops leaking the field someone adds to an object
|
|
417
|
+
next quarter.
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
const Charged = event('checkout.charged', z.object({ orderId: z.string(), amount: z.number() }));
|
|
421
|
+
|
|
422
|
+
log.info(Charged({ orderId: 'o-1', amount: 4200, card: '4242…' }));
|
|
423
|
+
// → name 'checkout.charged', attributes { orderId: 'o-1', amount: 4200 } — no card
|
|
424
|
+
```
|
|
425
|
+
|
|
426
|
+
Any [Standard Schema](https://standardschema.dev) works. A value the schema
|
|
427
|
+
refuses is still logged, marked `telemetry.event.invalid`, never thrown.
|
|
428
|
+
|
|
429
|
+
### Error info
|
|
430
|
+
|
|
431
|
+
A failure flattened into something that can cross a wire: its type (the class
|
|
432
|
+
name), its message, and its stack as text. Logs and spans carry it the same way,
|
|
433
|
+
and OTLP turns it into `exception.type`, `exception.message` and
|
|
434
|
+
`exception.stacktrace`.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
class ChargeRefused extends Error {}
|
|
438
|
+
errorInfo(new ChargeRefused('insufficient funds'));
|
|
439
|
+
// → { type: 'ChargeRefused', message: 'insufficient funds', stackTrace: '…' }
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
### Sampler and sampling
|
|
443
|
+
|
|
444
|
+
The decision to **keep a trace or not**, taken once by the root span and carried
|
|
445
|
+
by every child and every `traceparent`. It is a function of the trace id, so two
|
|
446
|
+
services at the same ratio agree. Logs are never sampled.
|
|
447
|
+
|
|
448
|
+
```ts
|
|
449
|
+
createTelemetry('checkout', { sampler: ratioSampler(0.1) }); // keep a tenth of traces
|
|
450
|
+
|
|
451
|
+
const custom: Sampler = { sample: (traceId) => traceId.endsWith('0') };
|
|
452
|
+
```
|
|
453
|
+
|
|
454
|
+
### Exporter
|
|
455
|
+
|
|
456
|
+
Where signals go: one object with `export(resource, batch)` and an optional
|
|
457
|
+
`close()`. It is called by one consumer at a time, in order, and a failure in it
|
|
458
|
+
never reaches the code that wrote the signal.
|
|
459
|
+
|
|
460
|
+
```ts
|
|
461
|
+
const counting: Exporter = {
|
|
462
|
+
export(resource, batch) {
|
|
463
|
+
console.log(`${resource.service}: ${batch.length} signals`);
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
createTelemetry('checkout', { exporters: [counting, consoleExporter()] });
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Built in: `consoleExporter`, `jsonLinesExporter`, `fileExporter`. Elsewhere:
|
|
470
|
+
`otlpExporter` (`@nxgt/telemetry-otlp`), `mongoExporter`
|
|
471
|
+
(`@nxgt/telemetry-mongo`), `winstonExporter` (`@nxgt/telemetry-logging`).
|
|
472
|
+
|
|
473
|
+
### Pipeline and batch
|
|
474
|
+
|
|
475
|
+
Between the code that writes a signal and the exporters: a queue that **never
|
|
476
|
+
blocks and never throws** at the writer. Signals are grouped into a **batch**,
|
|
477
|
+
flushed when `batch` signals are waiting or `linger` ms after the first one, and
|
|
478
|
+
drained by `close()`.
|
|
479
|
+
|
|
480
|
+
```ts
|
|
481
|
+
createTelemetry('checkout', {
|
|
482
|
+
batch: 512, // flush at this many
|
|
483
|
+
linger: 1_000, // …or this long after the first
|
|
484
|
+
drainTimeout: 10_000,
|
|
485
|
+
onExportError: (failure) => console.error('export failed', failure),
|
|
486
|
+
exporters: [otlpExporter({ endpoint })],
|
|
487
|
+
});
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
## The root
|
|
491
|
+
|
|
492
|
+
```ts
|
|
493
|
+
import { createTelemetry, consoleExporter, ratioSampler } from '@nxgt/telemetry';
|
|
494
|
+
|
|
495
|
+
const telemetry = createTelemetry('checkout', {
|
|
496
|
+
version: '1.4.0',
|
|
497
|
+
environment: 'production',
|
|
498
|
+
sampler: ratioSampler(0.1),
|
|
499
|
+
exporters: [consoleExporter()],
|
|
500
|
+
}).install();
|
|
501
|
+
|
|
502
|
+
process.on('SIGTERM', async () => {
|
|
503
|
+
await telemetry.close();
|
|
504
|
+
process.exit(0);
|
|
505
|
+
});
|
|
506
|
+
```
|
|
507
|
+
|
|
508
|
+
`service` has no default: it is the key everything groups by, and a service
|
|
509
|
+
called `unknown` is a dashboard nobody can read. `install()` makes this the
|
|
510
|
+
telemetry a logger or a span finds when there is none in scope, and returns the
|
|
511
|
+
instance.
|
|
512
|
+
|
|
513
|
+
**`close()` must be awaited.** JavaScript cannot block, so unlike its JVM
|
|
514
|
+
counterpart this one returns a promise: a process that exits without waiting
|
|
515
|
+
loses its last batch, which is the batch that explains the shutdown. Handing the
|
|
516
|
+
promise to a listener that discards it is the same mistake in a smaller
|
|
517
|
+
disguise — the handler above awaits it before exiting. `await using` works too.
|
|
518
|
+
|
|
519
|
+
`drainTimeout` bounds **the whole close**, the exporters' own `close` included,
|
|
520
|
+
so neither a collector that stopped answering nor an exporter that will not let
|
|
521
|
+
go of its socket becomes the reason a process will not exit. A drain that runs
|
|
522
|
+
out of time is reported to `onExportError`, and is the one case where an
|
|
523
|
+
exporter's `close` may be called while an `export` is still in flight.
|
|
524
|
+
|
|
525
|
+
| option | default | |
|
|
526
|
+
| --- | --- | --- |
|
|
527
|
+
| `version`, `environment`, `attributes` | — | stamped on the resource, so every signal carries them |
|
|
528
|
+
| `sampler` | `alwaysSample` | asked once, for a root span |
|
|
529
|
+
| `minimum` | `'info'` | logs below this are never built. Spans are unaffected |
|
|
530
|
+
| `stackTraces` | `true` | whether a recorded failure carries its stack |
|
|
531
|
+
| `batch` | `512` | flush once this many signals are waiting |
|
|
532
|
+
| `linger` | `1000` ms | flush this long after the first signal of a batch |
|
|
533
|
+
| `drainTimeout` | `10000` ms | how long `close` waits for the backlog |
|
|
534
|
+
| `onExportError` | `console.error` | a failing exporter is reported here |
|
|
535
|
+
| `exporters` | `[]` | in order; a batch reaches them one after the other |
|
|
536
|
+
|
|
537
|
+
## Spans
|
|
538
|
+
|
|
539
|
+
```ts
|
|
540
|
+
import { span, continuing } from '@nxgt/telemetry';
|
|
541
|
+
|
|
542
|
+
await span('charge', { attributes: { orderId } }, async (scope) => {
|
|
543
|
+
scope.event('gateway.called', { attempt });
|
|
544
|
+
await payments.charge(card);
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// on the way in, continuing whatever the caller started
|
|
548
|
+
await continuing(request.headers.get('traceparent'), 'GET /orders', async (scope) => {
|
|
549
|
+
scope.name = `GET ${route}`; // routing knows the template last
|
|
550
|
+
scope.attribute('http.route', route);
|
|
551
|
+
});
|
|
552
|
+
```
|
|
553
|
+
|
|
554
|
+
The block runs whether or not a telemetry is installed: a library that traces
|
|
555
|
+
must work inside an application that has never heard of this one. With none, the
|
|
556
|
+
scope carries a detached context and nothing is emitted.
|
|
557
|
+
|
|
558
|
+
**A span never swallows.** A failure marks it `error` — or `cancelled`, for an
|
|
559
|
+
`AbortError` or a `TimeoutError`, because a shutdown and a timeout are not
|
|
560
|
+
failures — records the exception, and rethrows. A span observes; it does not
|
|
561
|
+
handle.
|
|
562
|
+
|
|
563
|
+
Attributes given to `span()` are inherited by every log and span inside it;
|
|
564
|
+
`scope.attribute()` belongs to that span alone. Both appear on the span's own
|
|
565
|
+
record.
|
|
566
|
+
|
|
567
|
+
`continuing` takes a header from a stranger, so an unusable one is not an error:
|
|
568
|
+
it starts a fresh trace, exactly as `span` would.
|
|
569
|
+
|
|
570
|
+
## Logging
|
|
571
|
+
|
|
572
|
+
```ts
|
|
573
|
+
import { createLogger, event } from '@nxgt/telemetry';
|
|
574
|
+
import { z } from 'zod';
|
|
575
|
+
|
|
576
|
+
const Charged = event('checkout.charged', z.object({ orderId: z.string(), amount: z.number() }));
|
|
577
|
+
const log = createLogger('CheckoutService');
|
|
578
|
+
|
|
579
|
+
log.info(Charged({ orderId, amount })); // a declared event
|
|
580
|
+
log.warn('charge refused', { orderId, code }); // ad hoc, for what has no type yet
|
|
581
|
+
log.debug(() => `state: ${expensive()}`); // built only if debug is on
|
|
582
|
+
log.error('charge failed', failure, { orderId });
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
A line written inside a span carries that span's `traceId` and `spanId` without
|
|
586
|
+
being told, and the attributes the span and `withAttributes` put in scope.
|
|
587
|
+
|
|
588
|
+
**Declaring an event is the point.** Logging an object as it is logs the field
|
|
589
|
+
added next quarter — the card number included — and nobody finds out, because a
|
|
590
|
+
log that says too much still looks like a working log. The schema is the
|
|
591
|
+
declaration, and what it returns is what is emitted: an object schema's unknown
|
|
592
|
+
keys are gone. Any [Standard Schema](https://standardschema.dev) will do — Zod,
|
|
593
|
+
Valibot, ArkType — and none of them is a dependency here.
|
|
594
|
+
|
|
595
|
+
**Nothing on this path can fail.** No telemetry installed, a schema that refuses
|
|
596
|
+
the input, a schema that answers asynchronously, a lazy message that throws: the
|
|
597
|
+
line still comes out, marked, or is dropped in silence. `log.info` is a total,
|
|
598
|
+
synchronous function, callable from a constructor or from a `catch`.
|
|
599
|
+
|
|
600
|
+
The lazy form is `debug` and `info` only, and the failure form is `warn` and
|
|
601
|
+
`error` only — at those levels a message is always built, so a thunk would hide
|
|
602
|
+
only the cost of building it.
|
|
603
|
+
|
|
604
|
+
## Context
|
|
605
|
+
|
|
606
|
+
```ts
|
|
607
|
+
import { currentSpan, currentTraceparent, withAttributes, withTelemetry } from '@nxgt/telemetry';
|
|
608
|
+
|
|
609
|
+
await withAttributes({ tenant: 'acme' }, async () => {
|
|
610
|
+
// every log and span in here carries tenant=acme
|
|
611
|
+
await fetch(url, { headers: { traceparent: currentTraceparent() ?? '' } });
|
|
612
|
+
});
|
|
613
|
+
```
|
|
614
|
+
|
|
615
|
+
The current span lives in an `AsyncLocalStorage`, and that is the whole design.
|
|
616
|
+
It propagates through every `await`, every timer and every promise chain: into
|
|
617
|
+
everything started inside a span, out of nothing, and correct after any number
|
|
618
|
+
of suspensions. A module-level variable would look right in development and
|
|
619
|
+
start attributing one request's spans to another under concurrency — a bug with
|
|
620
|
+
no stack trace and no failing test, in the tool meant to make such bugs visible.
|
|
621
|
+
|
|
622
|
+
It is also readable **synchronously**, which is what lets `log.info()` stay a
|
|
623
|
+
plain function: a log written from a constructor, from a `catch` in ordinary
|
|
624
|
+
code or from a callback still has to come out.
|
|
625
|
+
|
|
626
|
+
`withTelemetry(telemetry, fn)` puts a different one in scope for the block.
|
|
627
|
+
Scope wins over the installed default, which is what lets two suites in one
|
|
628
|
+
process each collect their own signals.
|
|
629
|
+
|
|
630
|
+
## Exporters
|
|
631
|
+
|
|
632
|
+
An exporter is one function:
|
|
633
|
+
|
|
634
|
+
```ts
|
|
635
|
+
import type { Exporter } from '@nxgt/telemetry';
|
|
636
|
+
|
|
637
|
+
const exporter: Exporter = {
|
|
638
|
+
export(resource, batch) { /* … */ },
|
|
639
|
+
async close() { /* optional */ },
|
|
640
|
+
};
|
|
641
|
+
```
|
|
642
|
+
|
|
643
|
+
The pipeline guarantees it is called from **one consumer, never concurrently**,
|
|
644
|
+
so there is nothing to synchronise and a batch's order is the order things
|
|
645
|
+
happened in. It may take as long as it wants; nothing that writes a signal is
|
|
646
|
+
waiting on it. If it throws, the failure goes to `onExportError` and the next
|
|
647
|
+
exporter still receives the batch — a collector being down is not a reason for
|
|
648
|
+
a request to fail.
|
|
649
|
+
|
|
650
|
+
### The ones built in
|
|
651
|
+
|
|
652
|
+
```ts
|
|
653
|
+
import { consoleExporter, jsonLinesExporter, fileExporter } from '@nxgt/telemetry';
|
|
654
|
+
|
|
655
|
+
consoleExporter() // one readable line per signal
|
|
656
|
+
jsonLinesExporter() // one JSON object per line, on stdout
|
|
657
|
+
fileExporter({ path: 'logs/telemetry.jsonl' }) // the same, appended, with rotation
|
|
658
|
+
```
|
|
659
|
+
|
|
660
|
+
`fileExporter` **appends**: a restart continues the current file, and the period
|
|
661
|
+
is read from that file's modification time rather than from when the process
|
|
662
|
+
started, so a service that restarts hourly still rolls once a day. Rotation is
|
|
663
|
+
epoch-aligned — `every: 24h` rolls at UTC midnight, not 24 hours after a
|
|
664
|
+
restart — and an **empty file is never rolled**, so an idle service does not
|
|
665
|
+
accumulate a directory of empty archives. `close()` rolls nothing: a rolled file
|
|
666
|
+
is a finished period, and a shutdown is not one.
|
|
667
|
+
|
|
668
|
+
| option | default | |
|
|
669
|
+
| --- | --- | --- |
|
|
670
|
+
| `path` | — | the file. Its directory is created if it is missing |
|
|
671
|
+
| `maxSize` | `64 MiB` | roll at this size. `0` disables it |
|
|
672
|
+
| `every` | `24h` | roll when this period changes, in ms. `0` disables it |
|
|
673
|
+
| `keep` | `7` | how many rolled files to keep |
|
|
674
|
+
| `compress` | `false` | gzip a rolled file |
|
|
675
|
+
|
|
676
|
+
**This exporter owns its path.** It is the one stateful exporter here — it
|
|
677
|
+
remembers the file's size and age instead of asking the filesystem on every
|
|
678
|
+
batch — so give each path exactly one `fileExporter`. Concurrent batches are
|
|
679
|
+
serialised internally, and any failure throws away what it remembered, so an
|
|
680
|
+
external `logrotate`, a truncation or a full disk costs the batch it happened on
|
|
681
|
+
and nothing after it.
|
|
682
|
+
|
|
683
|
+
Neither line format carries the resource: a file belongs to one service, so
|
|
684
|
+
repeating its name on every line would be noise. An exporter that writes
|
|
685
|
+
somewhere shared — `@nxgt/telemetry-mongo` — stamps it instead.
|
|
686
|
+
|
|
687
|
+
`@nxgt/telemetry-otlp` and `@nxgt/telemetry-mongo` are the others.
|
|
688
|
+
|
|
27
689
|
## Trace identity
|
|
28
690
|
|
|
29
691
|
```ts
|
|
@@ -92,6 +754,57 @@ somebody wrote it.
|
|
|
92
754
|
|
|
93
755
|
## API
|
|
94
756
|
|
|
757
|
+
### The root
|
|
758
|
+
|
|
759
|
+
| | |
|
|
760
|
+
| --- | --- |
|
|
761
|
+
| `createTelemetry(service, options?)` | builds one. See the option table above |
|
|
762
|
+
| `Telemetry` | `resource`, `sampler`, `minimum`, `stackTraces`, `emit(signal)`, `install()`, `close()` |
|
|
763
|
+
| `installedTelemetry()` | the installed default, if there is one |
|
|
764
|
+
| `uninstallTelemetry(telemetry?)` | for a test that wants the process back as it found it |
|
|
765
|
+
| `TELEMETRY_DEFAULTS` | the defaults, which are `stx-telemetry`'s |
|
|
766
|
+
|
|
767
|
+
### Context
|
|
768
|
+
|
|
769
|
+
| | |
|
|
770
|
+
| --- | --- |
|
|
771
|
+
| `currentSpan()`, `currentTraceparent()`, `currentAttributes()` | what is open right here |
|
|
772
|
+
| `withTelemetry(telemetry, fn)` | a different telemetry for the block; scope beats the installed default |
|
|
773
|
+
| `withAttributes(record, fn)` | attributes inherited by every log and span inside |
|
|
774
|
+
| `currentContext()`, `runWithContext(context, fn)`, `resolveTelemetry()` | the lower level, for an integration |
|
|
775
|
+
| `TelemetryContext` | `{ telemetry, span?, attributes }` |
|
|
776
|
+
|
|
777
|
+
### Exporting
|
|
778
|
+
|
|
779
|
+
| | |
|
|
780
|
+
| --- | --- |
|
|
781
|
+
| `Exporter` | `{ export(resource, batch), close?() }` |
|
|
782
|
+
| `consoleExporter(options?)` | one readable line per signal; `write` and `stackTraces` |
|
|
783
|
+
| `jsonLinesExporter(options?)` | one JSON object per line; `write` |
|
|
784
|
+
| `fileExporter(options)` | the same, appended to a file, with rotation |
|
|
785
|
+
| `DEFAULT_MAX_SIZE`, `DEFAULT_ROTATION_PERIOD` | 64 MiB and a UTC day |
|
|
786
|
+
| `rotationDue`, `rolledName`, `rolledOf`, `prunable`, `RotationPolicy` | the rotation decisions, for an exporter that writes its own files |
|
|
787
|
+
| `PipelineOptions` | what a `Telemetry` configures its queue with |
|
|
788
|
+
|
|
789
|
+
### Spans
|
|
790
|
+
|
|
791
|
+
| | |
|
|
792
|
+
| --- | --- |
|
|
793
|
+
| `span(name, options?, block)` | opens one. `options` is `{ attributes?, kind? }`, kind `internal` by default |
|
|
794
|
+
| `continuing(traceparent, name, options?, block)` | the same, continuing an inbound trace. Kind `server` by default |
|
|
795
|
+
| `SpanScope` | `context`, `traceId`, `spanId`, writable `name` and `status`, `traceparent()`, `attribute()`, `attributes()`, `event()` |
|
|
796
|
+
| `SpanOptions`, `SpanBlock` | |
|
|
797
|
+
|
|
798
|
+
### Logging
|
|
799
|
+
|
|
800
|
+
| | |
|
|
801
|
+
| --- | --- |
|
|
802
|
+
| `createLogger(source)` | `source` becomes the OTLP instrumentation scope |
|
|
803
|
+
| `Logger` | `enabled(severity)`, `debug`, `info`, `warn`, `error` |
|
|
804
|
+
| `event(name, schema?)` | declares an event type; the result is called with its fields |
|
|
805
|
+
| `TelemetryEvent`, `isTelemetryEvent(value)`, `INVALID_EVENT_ATTRIBUTE` | |
|
|
806
|
+
| `errorInfo(failure, stackTraces?)`, `isAbort(failure)` | how a thrown value is flattened, and what counts as cancelled |
|
|
807
|
+
|
|
95
808
|
### Trace identity
|
|
96
809
|
|
|
97
810
|
| | |
|
|
@@ -150,6 +863,35 @@ somebody wrote it.
|
|
|
150
863
|
- **`ratioSampler` throws on a bad ratio**, at construction. That is the one
|
|
151
864
|
place in this library that refuses an argument, and it is deliberate: it is
|
|
152
865
|
not on the path that writes a signal.
|
|
866
|
+
- **`exception.type` is the class name, not `error.name`.**
|
|
867
|
+
`class ChargeRefused extends Error {}` is recorded as `ChargeRefused`, because
|
|
868
|
+
`name` is inherited unless a subclass assigns it and the type is what a
|
|
869
|
+
dashboard groups by. An assigned `name` still wins.
|
|
870
|
+
- **A detached scope's `traceparent()` is `00-0…0-0…0-00`**, which this
|
|
871
|
+
library's own parser rejects. That happens only when nothing is installed, and
|
|
872
|
+
`isDetached(scope.context)` is the guard before injecting a header.
|
|
873
|
+
- **A rolled file is named for the instant it was rolled**, not for the period
|
|
874
|
+
it covers: `telemetry-20260915-000100.jsonl` holds the 14th. `keep` orders
|
|
875
|
+
archives by the stamp and collision number it parses out of the name, not by
|
|
876
|
+
the name as text — inside one second, `-9` is newer than `-12` as text, and
|
|
877
|
+
the unsuffixed name is the oldest of the three.
|
|
878
|
+
- **A log is never sampled, a span is.** A span of an unsampled trace is not
|
|
879
|
+
emitted at all — the block still runs — while its logs come out as usual,
|
|
880
|
+
carrying the `traceId`. Do not read "no span" as "nothing happened".
|
|
881
|
+
- **`log.warn(message, x)` reads `x` as a failure unless it is a plain object.**
|
|
882
|
+
An `Error`, a string, an array or a class instance is the failure; `{ code:
|
|
883
|
+
51 }` is attributes. Pass both explicitly when it matters.
|
|
884
|
+
- **`close()` has to be awaited**, and a `process.exit()` before it resolves
|
|
885
|
+
loses the last batch. Nothing can block the event loop to save you from that.
|
|
886
|
+
- **The queue is unbounded.** An application that outruns its collector grows an
|
|
887
|
+
array, which a heap profile shows, rather than dropping the evidence of what
|
|
888
|
+
it was doing. A bounded queue would answer back-pressure by losing signals or
|
|
889
|
+
by blocking the application, and neither is an answer.
|
|
890
|
+
- **`node:async_hooks` is how the context travels**, and it is imported
|
|
891
|
+
statically. A bundler that shims the builtin to an empty module gets a
|
|
892
|
+
fallback: everything works, but the context stops propagating across `await`,
|
|
893
|
+
so pass the span explicitly there. A bundler that refuses to resolve the
|
|
894
|
+
builtin at all cannot load this package — alias it to an empty module.
|
|
153
895
|
|
|
154
896
|
## License
|
|
155
897
|
|