@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 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