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