@dudousxd/nestjs-diagnostics 0.5.0 → 0.6.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/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # @dudousxd/nestjs-diagnostics
2
2
 
3
+ ## 0.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#14](https://github.com/DavideCarvalho/nestjs-diagnostics/pull/14) [`cced7fc`](https://github.com/DavideCarvalho/nestjs-diagnostics/commit/cced7fc843c84eaf7b51686321294355bf2dd346) Thanks [@DavideCarvalho](https://github.com/DavideCarvalho)! - Ship TanStack Intent agent skills (SKILL.md) inside the package.
8
+
9
+ ## 0.6.0
10
+
11
+ ### Minor Changes
12
+
13
+ - [`16bd3a5`](https://github.com/DavideCarvalho/nestjs-diagnostics/commit/16bd3a5ae975edb8e8b9b8ea59d52e7a9a0b470f) - Diagnostics events can carry an optional `durationMs`. `emit(lib, event, payload, { durationMs })` stamps a wall-clock duration onto the envelope, and the generic Telescope watcher forwards it to the recorded entry's `durationMs`. This lets an observer (e.g. the Telescope OTel exporter) turn an `aviary:<lib>:<event>` stream into a **duration histogram** (p95/p99) instead of only a counter — so latency metrics can move onto the diagnostics→telescope path without losing their distribution.
14
+
3
15
  ## 0.5.0
4
16
 
5
17
  ### Minor Changes
package/README.md CHANGED
@@ -33,10 +33,19 @@ interface DiagnosticEvent<TPayload = unknown> {
33
33
 
34
34
  ## Emitting
35
35
 
36
+ Call `emit` from your provider wherever something interesting happens:
37
+
36
38
  ```ts
39
+ import { Injectable } from '@nestjs/common';
37
40
  import { emit } from '@dudousxd/nestjs-diagnostics';
38
41
 
39
- emit('billing', 'invoice-paid', { invoiceId: 'inv_123', amount: 4200 });
42
+ @Injectable()
43
+ export class BillingService {
44
+ async markInvoicePaid(invoiceId: string, amount: number) {
45
+ // … your domain logic …
46
+ emit('billing', 'invoice-paid', { invoiceId, amount });
47
+ }
48
+ }
40
49
  ```
41
50
 
42
51
  `emit` builds and publishes the envelope **only when the channel has
@@ -44,10 +53,11 @@ subscribers** (`channel.hasSubscribers`), so a production process with no
44
53
  observer attached pays essentially nothing per call. It never throws —
45
54
  observability must not break the emitting code path.
46
55
 
47
- Need an explicit trace id? Pass it in `opts`:
56
+ Need an explicit trace id? Pass it in `opts` — e.g. inside a handler that
57
+ already holds a correlation id:
48
58
 
49
59
  ```ts
50
- emit('billing', 'invoice-paid', payload, { traceId: req.id });
60
+ emit('billing', 'invoice-paid', payload, { traceId });
51
61
  ```
52
62
 
53
63
  ## Trace correlation
@@ -1 +1 @@
1
- {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAA4B,EAAE,KAAK,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAG7E,OAAO,KAAK,EAAmB,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE1F,gEAAgE;AAChE,eAAO,MAAM,cAAc,WAAW,CAAC;AAEvC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE9D;AAqBD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAc9D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,IAAI,CAAC,IAAI,SAAS,KAAK,EAAE,MAAM,SAAS,OAAO,CAAC,IAAI,CAAC,EACnE,GAAG,EAAE,IAAI,EACT,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,EAChC,IAAI,CAAC,EAAE,WAAW,GACjB,IAAI,CAoBN"}
1
+ {"version":3,"file":"channel.d.ts","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAA4B,EAAE,KAAK,OAAO,EAAE,MAAM,0BAA0B,CAAC;AAG7E,OAAO,KAAK,EAAmB,WAAW,EAAE,OAAO,EAAE,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAE1F,gEAAgE;AAChE,eAAO,MAAM,cAAc,WAAW,CAAC;AAEvC;;;;;GAKG;AACH,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE9D;AAqBD;;;;;;;;;;GAUG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAc9D;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,IAAI,CAAC,IAAI,SAAS,KAAK,EAAE,MAAM,SAAS,OAAO,CAAC,IAAI,CAAC,EACnE,GAAG,EAAE,IAAI,EACT,KAAK,EAAE,MAAM,EACb,OAAO,EAAE,SAAS,CAAC,IAAI,EAAE,MAAM,CAAC,EAChC,IAAI,CAAC,EAAE,WAAW,GACjB,IAAI,CAuBN"}
package/dist/channel.js CHANGED
@@ -105,6 +105,9 @@ export function emit(lib, event, payload, opts) {
105
105
  event,
106
106
  traceId,
107
107
  payload,
108
+ // Only set the key when a duration was provided — omitting it keeps the
109
+ // envelope shape monomorphic (no undefined-valued key on common events).
110
+ ...(opts?.durationMs !== undefined && { durationMs: opts.durationMs }),
108
111
  };
109
112
  channel.publish(envelope);
110
113
  }
@@ -1 +1 @@
1
- {"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,mBAAqC,MAAM,0BAA0B,CAAC;AAC7E,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGjE,gEAAgE;AAChE,MAAM,CAAC,MAAM,cAAc,GAAG,QAAQ,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC;AAEhC;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,GAAW,EAAE,KAAa;IACpD,OAAO,GAAG,cAAc,IAAI,GAAG,IAAI,KAAK,EAAE,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,YAAY,GAAG,IAAI,GAAG,EAAgC,CAAC;AAE7D,8EAA8E;AAC9E,6EAA6E;AAC7E,eAAe,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC;AAE5C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CAAC,GAAW,EAAE,KAAa;IACnD,IAAI,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAClC,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO,MAAM,CAAC;IAC1C,CAAC;SAAM,CAAC;QACN,OAAO,GAAG,IAAI,GAAG,EAAmB,CAAC;QACrC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;IACD,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACrC,MAAM,OAAO,GAAG,mBAAmB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,eAAe,CAAC,IAAI,CAAC,CAAC;IACtB,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAC5B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,IAAI,CAClB,GAAS,EACT,KAAa,EACb,OAAgC,EAChC,IAAkB;IAElB,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACvC,IAAI,CAAC,OAAO,CAAC,cAAc;QAAE,OAAO;IACpC,IAAI,CAAC;QACH,6EAA6E;QAC7E,4EAA4E;QAC5E,IAAI,IAAI,EAAE,MAAM,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE;YAAE,OAAO;QACzD,MAAM,OAAO,GAAG,IAAI,EAAE,OAAO,IAAI,cAAc,EAAE,CAAC;QAClD,MAAM,QAAQ,GAAoB;YAChC,CAAC,EAAE,cAAc;YACjB,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE;YACd,GAAG;YACH,KAAK;YACL,OAAO;YACP,OAAO;SACR,CAAC;QACF,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,yDAAyD;IAC3D,CAAC;AACH,CAAC"}
1
+ {"version":3,"file":"channel.js","sourceRoot":"","sources":["../src/channel.ts"],"names":[],"mappings":"AAAA,OAAO,mBAAqC,MAAM,0BAA0B,CAAC;AAC7E,OAAO,EAAE,cAAc,EAAE,MAAM,uBAAuB,CAAC;AACvD,OAAO,EAAE,eAAe,EAAE,eAAe,EAAE,MAAM,eAAe,CAAC;AAGjE,gEAAgE;AAChE,MAAM,CAAC,MAAM,cAAc,GAAG,QAAQ,CAAC;AAEvC;;;;;GAKG;AACH,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,CAAC;AAEhC;;;GAGG;AACH,MAAM,UAAU,WAAW,CAAC,GAAW,EAAE,KAAa;IACpD,OAAO,GAAG,cAAc,IAAI,GAAG,IAAI,KAAK,EAAE,CAAC;AAC7C,CAAC;AAED;;;;;;;;;;;;GAYG;AACH,MAAM,YAAY,GAAG,IAAI,GAAG,EAAgC,CAAC;AAE7D,8EAA8E;AAC9E,6EAA6E;AAC7E,eAAe,CAAC,GAAG,EAAE,CAAC,YAAY,CAAC,KAAK,EAAE,CAAC,CAAC;AAE5C;;;;;;;;;;GAUG;AACH,MAAM,UAAU,UAAU,CAAC,GAAW,EAAE,KAAa;IACnD,IAAI,OAAO,GAAG,YAAY,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;IACpC,IAAI,OAAO,KAAK,SAAS,EAAE,CAAC;QAC1B,MAAM,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QAClC,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO,MAAM,CAAC;IAC1C,CAAC;SAAM,CAAC;QACN,OAAO,GAAG,IAAI,GAAG,EAAmB,CAAC;QACrC,YAAY,CAAC,GAAG,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;IACjC,CAAC;IACD,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACrC,MAAM,OAAO,GAAG,mBAAmB,CAAC,OAAO,CAAC,IAAI,CAAC,CAAC;IAClD,eAAe,CAAC,IAAI,CAAC,CAAC;IACtB,OAAO,CAAC,GAAG,CAAC,KAAK,EAAE,OAAO,CAAC,CAAC;IAC5B,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,MAAM,UAAU,IAAI,CAClB,GAAS,EACT,KAAa,EACb,OAAgC,EAChC,IAAkB;IAElB,MAAM,OAAO,GAAG,UAAU,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;IACvC,IAAI,CAAC,OAAO,CAAC,cAAc;QAAE,OAAO;IACpC,IAAI,CAAC;QACH,6EAA6E;QAC7E,4EAA4E;QAC5E,IAAI,IAAI,EAAE,MAAM,KAAK,SAAS,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE;YAAE,OAAO;QACzD,MAAM,OAAO,GAAG,IAAI,EAAE,OAAO,IAAI,cAAc,EAAE,CAAC;QAClD,MAAM,QAAQ,GAAoB;YAChC,CAAC,EAAE,cAAc;YACjB,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE;YACd,GAAG;YACH,KAAK;YACL,OAAO;YACP,OAAO;YACP,wEAAwE;YACxE,yEAAyE;YACzE,GAAG,CAAC,IAAI,EAAE,UAAU,KAAK,SAAS,IAAI,EAAE,UAAU,EAAE,IAAI,CAAC,UAAU,EAAE,CAAC;SACvE,CAAC;QACF,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IAC5B,CAAC;IAAC,MAAM,CAAC;QACP,yDAAyD;IAC3D,CAAC;AACH,CAAC"}
package/dist/types.d.ts CHANGED
@@ -28,6 +28,13 @@ export interface DiagnosticEvent<TPayload = unknown> {
28
28
  traceId?: string | undefined;
29
29
  /** The library-defined payload. Opaque to this package and to observers. */
30
30
  payload: TPayload;
31
+ /**
32
+ * Wall-clock duration of the operation this event describes, in milliseconds,
33
+ * when known. Lets observers build duration histograms (e.g. OTel histogram
34
+ * instruments) instead of only counters. Omit when the event is not tied to a
35
+ * timed operation. Stamped from {@link EmitOptions.durationMs} by `emit`.
36
+ */
37
+ durationMs?: number;
31
38
  }
32
39
  /**
33
40
  * The optional, consumer-augmented typed channel registry: a `lib → event →
@@ -156,6 +163,13 @@ export interface EmitOptions {
156
163
  * cheap probabilistic predicate, e.g. `{ sample: () => Math.random() < 0.1 }`.
157
164
  */
158
165
  sample?: () => boolean;
166
+ /**
167
+ * Wall-clock duration of the operation this event describes, in milliseconds.
168
+ * When provided, `emit` stamps it onto the {@link DiagnosticEvent} envelope as
169
+ * `durationMs`, letting downstream observers (e.g. the Telescope OTel exporter)
170
+ * build duration histograms instead of only counters.
171
+ */
172
+ durationMs?: number;
159
173
  }
160
174
  /** Optional per-`trace` overrides. */
161
175
  export interface TraceOptions {
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,eAAe,CAAC,QAAQ,GAAG,OAAO;IACjD;;;;;;OAMG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,8EAA8E;IAC9E,EAAE,EAAE,MAAM,CAAC;IACX,sFAAsF;IACtF,GAAG,EAAE,MAAM,CAAC;IACZ,uFAAuF;IACvF,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,4EAA4E;IAC5E,OAAO,EAAE,QAAQ,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,WAAW,eAAe;CAAG;AAEnC;;;;;GAKG;AACH,KAAK,WAAW,GAAG,MAAM,GAAG,EAAE,CAAC;AAE/B;;;;GAIG;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,WAAW,CAAC;AAEnE;;;;GAIG;AACH,MAAM,MAAM,OAAO,CAAC,IAAI,SAAS,MAAM,IAAI,IAAI,SAAS,MAAM,eAAe,GACzE,CAAC,MAAM,eAAe,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,WAAW,GACpD,MAAM,CAAC;AAEX;;;;;GAKG;AACH,MAAM,MAAM,SAAS,CACnB,IAAI,SAAS,MAAM,EACnB,MAAM,SAAS,MAAM,IACnB,IAAI,SAAS,MAAM,eAAe,GAClC,MAAM,SAAS,MAAM,eAAe,CAAC,IAAI,CAAC,GACxC,eAAe,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,GAC7B,OAAO,GACT,OAAO,CAAC;AAEZ;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,KAAK,GAAG,YAAY,GAAG,UAAU,GAAG,OAAO,CAAC;AAE9E;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,SAAS,CAAC,QAAQ,GAAG,OAAO,EAAE,OAAO,GAAG,OAAO;IAC9D;;;;;OAKG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,uEAAuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,KAAK,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,iEAAiE;IACjE,OAAO,CAAC,EAAE,QAAQ,CAAC;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,mCAAmC;AACnC,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC;CACxB;AAED,sCAAsC;AACtC,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,MAAM,WAAW,eAAe,CAAC,QAAQ,GAAG,OAAO;IACjD;;;;;;OAMG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,8EAA8E;IAC9E,EAAE,EAAE,MAAM,CAAC;IACX,sFAAsF;IACtF,GAAG,EAAE,MAAM,CAAC;IACZ,uFAAuF;IACvF,KAAK,EAAE,MAAM,CAAC;IACd;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,4EAA4E;IAC5E,OAAO,EAAE,QAAQ,CAAC;IAClB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,WAAW,eAAe;CAAG;AAEnC;;;;;GAKG;AACH,KAAK,WAAW,GAAG,MAAM,GAAG,EAAE,CAAC;AAE/B;;;;GAIG;AACH,MAAM,MAAM,KAAK,GAAG,CAAC,MAAM,eAAe,GAAG,MAAM,CAAC,GAAG,WAAW,CAAC;AAEnE;;;;GAIG;AACH,MAAM,MAAM,OAAO,CAAC,IAAI,SAAS,MAAM,IAAI,IAAI,SAAS,MAAM,eAAe,GACzE,CAAC,MAAM,eAAe,CAAC,IAAI,CAAC,GAAG,MAAM,CAAC,GAAG,WAAW,GACpD,MAAM,CAAC;AAEX;;;;;GAKG;AACH,MAAM,MAAM,SAAS,CACnB,IAAI,SAAS,MAAM,EACnB,MAAM,SAAS,MAAM,IACnB,IAAI,SAAS,MAAM,eAAe,GAClC,MAAM,SAAS,MAAM,eAAe,CAAC,IAAI,CAAC,GACxC,eAAe,CAAC,IAAI,CAAC,CAAC,MAAM,CAAC,GAC7B,OAAO,GACT,OAAO,CAAC;AAEZ;;;;GAIG;AACH,MAAM,MAAM,SAAS,GAAG,OAAO,GAAG,KAAK,GAAG,YAAY,GAAG,UAAU,GAAG,OAAO,CAAC;AAE9E;;;;;;;;;;;;;;;GAeG;AACH,MAAM,WAAW,SAAS,CAAC,QAAQ,GAAG,OAAO,EAAE,OAAO,GAAG,OAAO;IAC9D;;;;;OAKG;IACH,CAAC,CAAC,EAAE,MAAM,CAAC;IACX,uEAAuE;IACvE,EAAE,EAAE,MAAM,CAAC;IACX,oEAAoE;IACpE,GAAG,EAAE,MAAM,CAAC;IACZ,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;IACd,uCAAuC;IACvC,KAAK,EAAE,SAAS,CAAC;IACjB;;;OAGG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;OAGG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,SAAS,CAAC;IAC7B,iEAAiE;IACjE,OAAO,CAAC,EAAE,QAAQ,CAAC;IACnB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,iFAAiF;IACjF,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB;;;OAGG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,mCAAmC;AACnC,MAAM,WAAW,WAAW;IAC1B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB;;;;;;;OAOG;IACH,MAAM,CAAC,EAAE,MAAM,OAAO,CAAC;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED,sCAAsC;AACtC,MAAM,WAAW,YAAY;IAC3B;;;;OAIG;IACH,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-diagnostics",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "A standard convention for @dudousxd/nestjs-* libs to emit observability events over node:diagnostics_channel.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -43,7 +43,8 @@
43
43
  "files": [
44
44
  "dist/",
45
45
  "README.md",
46
- "CHANGELOG.md"
46
+ "CHANGELOG.md",
47
+ "skills"
47
48
  ],
48
49
  "//sideEffects": "NOT false: registry.ts and context-accessor.ts register process-global state on globalThis via Symbol.for AT IMPORT TIME (the cross-copy-stable channel registry + context accessor), and channel.ts/trace.ts register a reset hook at module load. A bundler told sideEffects:false could tree-shake these initializers away, breaking cross-copy global stability. The array lists exactly the side-effectful modules (matching both src and published dist paths); pure modules (index, types) stay tree-shakeable.",
49
50
  "sideEffects": [
@@ -75,7 +76,8 @@
75
76
  "diagnostics-channel",
76
77
  "observability",
77
78
  "telemetry",
78
- "events"
79
+ "events",
80
+ "tanstack-intent"
79
81
  ],
80
82
  "scripts": {
81
83
  "build": "tsc -p tsconfig.json",
@@ -0,0 +1,160 @@
1
+ ---
2
+ name: capabilities
3
+ description: Share cross-library DI tokens via the capability half of the @dudousxd/nestjs-diagnostics protocol. capability(lib, name) mints a Symbol.for('@dudousxd/nestjs-<lib>:<name>') token producer and consumer resolve identically without importing each other; InjectCapability(lib, name) from /nestjs optionally injects a peer's capability; assertCapabilityNaming guards naming drift in contract tests; CapabilityRegistry/CapabilityOf give typed tokens via declaration merging. Mirrors channelName for events.
4
+ license: MIT
5
+ metadata:
6
+ type: core
7
+ library: "@dudousxd/nestjs-diagnostics"
8
+ library_version: 0.6.0
9
+ framework: nestjs
10
+ ---
11
+
12
+ # Cross-library capabilities (DI tokens)
13
+
14
+ `@dudousxd/nestjs-diagnostics` carries two halves of one ecosystem protocol: the
15
+ **event** transport (`emit` / channels) and **capabilities** — stable DI tokens
16
+ two `@dudousxd/nestjs-*` libraries share **without importing each other**. A
17
+ capability token is `Symbol.for('@dudousxd/nestjs-<lib>:<name>')`; because it goes
18
+ through the global symbol registry, producer and consumer resolve the SAME symbol.
19
+ It is the dependency-injection mirror of `channelName(lib, event)` for events.
20
+
21
+ ## Setup
22
+
23
+ ```bash
24
+ pnpm add @dudousxd/nestjs-diagnostics
25
+ # InjectCapability requires @nestjs/common
26
+ ```
27
+
28
+ A producer library binds its capability under the shared token:
29
+
30
+ ```ts
31
+ import { Module } from '@nestjs/common';
32
+ import { capability } from '@dudousxd/nestjs-diagnostics';
33
+ import { ContextAccessorImpl } from './accessor.js';
34
+
35
+ // token === Symbol.for('@dudousxd/nestjs-context:accessor')
36
+ @Module({
37
+ providers: [
38
+ ContextAccessorImpl,
39
+ { provide: capability('context', 'accessor'), useExisting: ContextAccessorImpl },
40
+ ],
41
+ exports: [capability('context', 'accessor')],
42
+ })
43
+ export class ContextModule {}
44
+ ```
45
+
46
+ Source: `packages/core/src/capability.ts`, `packages/core/src/inject-capability.ts`.
47
+
48
+ ## Core patterns
49
+
50
+ ### 1. capability(lib, name) — the shared token
51
+
52
+ `capability('context', 'accessor')` returns
53
+ `Symbol.for('@dudousxd/nestjs-context:accessor')`. Any code in any package that
54
+ calls it with the same `(lib, name)` gets the identical symbol, so a consumer can
55
+ inject a producer's capability with no static dependency between them.
56
+
57
+ ```ts
58
+ import { capability } from '@dudousxd/nestjs-diagnostics';
59
+ const TOKEN = capability('context', 'accessor');
60
+ ```
61
+
62
+ Source: `packages/core/src/capability.ts` (`capability`).
63
+
64
+ ### 2. InjectCapability(lib, name) — optional cross-lib injection
65
+
66
+ From the `/nestjs` subpath, `InjectCapability` is shorthand for
67
+ `@Optional() @Inject(capability(lib, name))` — when the producer library is
68
+ absent, the parameter is `undefined` instead of breaking injection:
69
+
70
+ ```ts
71
+ import { Injectable } from '@nestjs/common';
72
+ import { InjectCapability } from '@dudousxd/nestjs-diagnostics/nestjs';
73
+ import type { ContextAccessor } from '@dudousxd/nestjs-diagnostics';
74
+
75
+ @Injectable()
76
+ export class AuditService {
77
+ constructor(
78
+ @InjectCapability('context', 'accessor')
79
+ private readonly ctx?: ContextAccessor, // undefined when nestjs-context absent
80
+ ) {}
81
+ }
82
+ ```
83
+
84
+ You annotate the parameter type yourself (capability payload types are not auto-
85
+ inferred across repos).
86
+
87
+ Source: `packages/core/src/inject-capability.ts` (`InjectCapability`).
88
+
89
+ ### 3. assertCapabilityNaming — anti-drift guard for contract tests
90
+
91
+ Assert every exported token of a library follows the canonical
92
+ `@dudousxd/nestjs-<lib>:<name>` naming (i.e. was minted by `capability(lib, …)`).
93
+ It throws naming the offending export, turning drift into a red test:
94
+
95
+ ```ts
96
+ import { assertCapabilityNaming, capability } from '@dudousxd/nestjs-diagnostics';
97
+
98
+ const tokens = { ACCESSOR: capability('context', 'accessor') };
99
+
100
+ it('all capability tokens follow the naming convention', () => {
101
+ expect(() => assertCapabilityNaming('context', tokens)).not.toThrow();
102
+ });
103
+ ```
104
+
105
+ Source: `packages/core/src/conformance.ts` (`assertCapabilityNaming`).
106
+
107
+ ## Common mistakes
108
+
109
+ ### Minting the token with Symbol() instead of capability()
110
+
111
+ ```ts
112
+ // Wrong — a unique symbol; the consumer's capability('context','accessor') won't match it.
113
+ const TOKEN = Symbol('@dudousxd/nestjs-context:accessor');
114
+ ```
115
+
116
+ ```ts
117
+ // Correct — Symbol.for via capability() so all copies resolve the same token.
118
+ import { capability } from '@dudousxd/nestjs-diagnostics';
119
+ const TOKEN = capability('context', 'accessor');
120
+ ```
121
+
122
+ `Symbol(desc)` is always unique; only `Symbol.for(key)` (what `capability` uses)
123
+ returns a process-shared symbol that producer and consumer both resolve.
124
+ Source: `packages/core/src/capability.ts` (`capability`).
125
+
126
+ ### Injecting a capability without @Optional and crashing when the peer is absent
127
+
128
+ ```ts
129
+ // Wrong — hard @Inject throws "Nest can't resolve dependency" when the producer isn't loaded.
130
+ constructor(@Inject(capability('context', 'accessor')) private ctx: ContextAccessor) {}
131
+ ```
132
+
133
+ ```ts
134
+ // Correct — InjectCapability is already optional; ctx is undefined when absent.
135
+ constructor(@InjectCapability('context', 'accessor') private ctx?: ContextAccessor) {}
136
+ ```
137
+
138
+ `InjectCapability` applies `Optional()` then `Inject(token)`, so a missing peer
139
+ yields `undefined` rather than a resolution error — the whole point of an optional
140
+ cross-library capability.
141
+ Source: `packages/core/src/inject-capability.ts` (`Optional()` + `Inject(token)`).
142
+
143
+ ### Confusing capability tokens with event channels
144
+
145
+ ```ts
146
+ // Wrong — capability is a DI token, not a channel; you cannot subscribe to it.
147
+ import diagnostics_channel from 'node:diagnostics_channel';
148
+ diagnostics_channel.channel(capability('context', 'accessor') as any); // type/runtime error
149
+ ```
150
+
151
+ ```ts
152
+ // Correct — events go over channelName(); capabilities are injected.
153
+ import { channelName } from '@dudousxd/nestjs-diagnostics';
154
+ diagnostics_channel.channel(channelName('billing', 'invoice-paid')).subscribe(fn);
155
+ ```
156
+
157
+ Capabilities (`Symbol.for(...)` DI tokens) and channels (`aviary:<lib>:<event>`
158
+ strings) are the two distinct halves of the protocol — one is injected, the other
159
+ is published/subscribed.
160
+ Source: `packages/core/src/capability.ts`, `packages/core/src/channel.ts`.
@@ -0,0 +1,210 @@
1
+ ---
2
+ name: emit-diagnostics
3
+ description: Emit observability events from @dudousxd/nestjs-diagnostics with emit(lib, event, payload, opts) over node:diagnostics_channel on aviary:<lib>:<event>. Covers the DiagnosticEvent envelope (v, ts, lib, event, traceId, payload, durationMs), zero-overhead hasSubscribers gating, EmitOptions sampling and durationMs, typed ChannelRegistry declaration merging, and trace correlation via setContextAccessor / CONTEXT_ACCESSOR. No module to register; the core barrel is framework-agnostic.
4
+ license: MIT
5
+ metadata:
6
+ type: core
7
+ library: "@dudousxd/nestjs-diagnostics"
8
+ library_version: 0.6.0
9
+ framework: nestjs
10
+ ---
11
+
12
+ # Emit diagnostics events
13
+
14
+ `@dudousxd/nestjs-diagnostics` is a tiny convention for emitting observability
15
+ events over Node's built-in `node:diagnostics_channel`. A library calls
16
+ `emit('billing', 'invoice-paid', payload)` and is done — whoever wants to observe
17
+ subscribes. Emitting is effectively free when nothing is listening.
18
+
19
+ There is **nothing to register** to emit: no module, no provider. The main barrel
20
+ is a set of pure functions and is framework-agnostic (importable from non-Nest
21
+ code).
22
+
23
+ ## Setup
24
+
25
+ ```bash
26
+ pnpm add @dudousxd/nestjs-diagnostics
27
+ ```
28
+
29
+ Call `emit` from any provider where something interesting happens:
30
+
31
+ ```ts
32
+ import { Injectable } from '@nestjs/common';
33
+ import { emit } from '@dudousxd/nestjs-diagnostics';
34
+
35
+ @Injectable()
36
+ export class BillingService {
37
+ async markInvoicePaid(invoiceId: string, amount: number) {
38
+ // … your domain logic …
39
+ emit('billing', 'invoice-paid', { invoiceId, amount });
40
+ }
41
+ }
42
+ ```
43
+
44
+ Every event flows over a channel named `aviary:<lib>:<event>` and carries a
45
+ standard `DiagnosticEvent` envelope:
46
+
47
+ ```ts
48
+ interface DiagnosticEvent<TPayload = unknown> {
49
+ v?: number; // SCHEMA_VERSION stamped by emit (currently 1)
50
+ ts: number; // Date.now() at publish time
51
+ lib: string; // the <lib>, e.g. "billing"
52
+ event: string; // the <event>, e.g. "invoice-paid"
53
+ traceId?: string; // auto-filled from a context accessor when present
54
+ payload: TPayload; // your library-defined data
55
+ durationMs?: number; // set only when EmitOptions.durationMs is passed
56
+ }
57
+ ```
58
+
59
+ Source: `packages/core/src/channel.ts`, `packages/core/src/types.ts`.
60
+
61
+ ## Core patterns
62
+
63
+ ### 1. Zero-overhead emit (the central guarantee)
64
+
65
+ `emit` builds and publishes the envelope **only when the channel has
66
+ subscribers** (`channel.hasSubscribers`), so a production process with no
67
+ observer attached pays essentially nothing per call — just two `Map.get`s and a
68
+ boolean read. It also **never throws**: observability must never break the
69
+ emitting code path.
70
+
71
+ ```ts
72
+ // In prod with no Telescope/observer attached, this allocates no envelope.
73
+ emit('authz', 'decision', { subject, allow: true });
74
+ ```
75
+
76
+ Source: `packages/core/src/channel.ts` (`emit`, the `hasSubscribers` gate).
77
+
78
+ ### 2. EmitOptions: explicit traceId, sampling, durationMs
79
+
80
+ ```ts
81
+ import { emit } from '@dudousxd/nestjs-diagnostics';
82
+
83
+ // Explicit trace id (wins over the registered accessor):
84
+ emit('billing', 'invoice-paid', payload, { traceId });
85
+
86
+ // Load-shedding on a hot event — shed 90%, consulted only AFTER hasSubscribers
87
+ // and BEFORE the envelope is built (no allocation when skipped):
88
+ emit('authz', 'decision', payload, { sample: () => Math.random() < 0.1 });
89
+
90
+ // Stamp a duration so downstream observers can build histograms:
91
+ emit('cache', 'lookup', { key }, { durationMs: 4.2 });
92
+ ```
93
+
94
+ `sample` returning `false` (or throwing) sheds the event without allocating the
95
+ envelope. `durationMs` is set on the envelope only when provided, keeping the
96
+ common envelope shape monomorphic.
97
+
98
+ Source: `packages/core/src/types.ts` (`EmitOptions`), `packages/core/src/channel.ts`.
99
+
100
+ ### 3. Compile-time payload types via the typed ChannelRegistry
101
+
102
+ By default every `(lib, event)` pair takes `payload: unknown`. A library can opt
103
+ specific channels into compile-time payload checking by augmenting
104
+ `ChannelRegistry` through TypeScript declaration merging — the untyped path stays
105
+ open for every other channel:
106
+
107
+ ```ts
108
+ declare module '@dudousxd/nestjs-diagnostics' {
109
+ interface ChannelRegistry {
110
+ billing: {
111
+ 'invoice-paid': { invoiceId: string; amount: number };
112
+ };
113
+ }
114
+ }
115
+
116
+ emit('billing', 'invoice-paid', { invoiceId: 'inv_1', amount: 4200 }); // checked
117
+ emit('anything', 'else', whatever); // still unknown
118
+ ```
119
+
120
+ This is a purely type-level mechanism: no runtime registry of payload shapes,
121
+ nothing allocated.
122
+
123
+ Source: `packages/core/src/types.ts` (`ChannelRegistry`, `LibOf`, `EventOf`, `PayloadOf`).
124
+
125
+ ### 4. Auto-correlated traceId via setContextAccessor
126
+
127
+ If your app uses `@dudousxd/nestjs-context` (an optional peer — never imported by
128
+ this package), register its accessor once and every `emit` auto-fills `traceId`
129
+ from the current request:
130
+
131
+ ```ts
132
+ import { setContextAccessor, CONTEXT_ACCESSOR } from '@dudousxd/nestjs-diagnostics';
133
+ import { Inject, Optional, Injectable, type OnModuleInit } from '@nestjs/common';
134
+ import type { ContextAccessor } from '@dudousxd/nestjs-diagnostics';
135
+
136
+ @Injectable()
137
+ export class DiagnosticsContextBridge implements OnModuleInit {
138
+ constructor(
139
+ @Optional() @Inject(CONTEXT_ACCESSOR) private readonly accessor?: ContextAccessor,
140
+ ) {}
141
+
142
+ onModuleInit() {
143
+ if (this.accessor) setContextAccessor(this.accessor);
144
+ }
145
+ }
146
+ ```
147
+
148
+ `CONTEXT_ACCESSOR` is `Symbol.for('@dudousxd/nestjs-context:accessor')` — the same
149
+ token nestjs-context publishes under. Any object structurally matching the
150
+ `ContextAccessor` interface works. `resolveTraceId()` reads it, never throwing.
151
+
152
+ Source: `packages/core/src/context-accessor.ts`.
153
+
154
+ ## Common mistakes
155
+
156
+ ### Building the payload eagerly outside emit on a hot path
157
+
158
+ ```ts
159
+ // Wrong — serialize() runs every call even when nobody is listening.
160
+ const snapshot = serialize(hugeObject);
161
+ emit('jobs', 'tick', snapshot);
162
+ ```
163
+
164
+ ```ts
165
+ // Correct — gate expensive work behind hasSubscribers via getChannel.
166
+ import { getChannel, emit } from '@dudousxd/nestjs-diagnostics';
167
+ if (getChannel('jobs', 'tick').hasSubscribers) {
168
+ emit('jobs', 'tick', serialize(hugeObject));
169
+ }
170
+ ```
171
+
172
+ `emit` only skips allocating *its own* envelope when unsubscribed; it cannot skip
173
+ work you did before calling it. Read `hasSubscribers` yourself for expensive
174
+ payloads (or use `EmitOptions.sample` for cheap ones).
175
+ Source: `packages/core/src/channel.ts` (`emit`, `getChannel`).
176
+
177
+ ### Expecting emit to throw or return a delivery result
178
+
179
+ ```ts
180
+ // Wrong — emit never throws and returns void; there is no ack to await/catch.
181
+ try {
182
+ const ok = await emit('billing', 'invoice-paid', payload);
183
+ } catch (e) { /* never reached */ }
184
+ ```
185
+
186
+ ```ts
187
+ // Correct — fire-and-forget; do not branch on its result.
188
+ emit('billing', 'invoice-paid', payload);
189
+ ```
190
+
191
+ `emit` returns `void` and swallows every internal error so a broken observer can
192
+ never break the producer. There is no delivery guarantee, ordering, or replay.
193
+ Source: `packages/core/src/channel.ts` (`emit` `try/catch`).
194
+
195
+ ### Putting the prefix or colons into lib/event yourself
196
+
197
+ ```ts
198
+ // Wrong — emit already prepends 'aviary:' and joins with ':'.
199
+ emit('aviary:billing', 'invoice:paid', payload); // channel: aviary:aviary:billing:invoice:paid
200
+ ```
201
+
202
+ ```ts
203
+ // Correct — pass the bare lib and event segments.
204
+ emit('billing', 'invoice-paid', payload); // channel: aviary:billing:invoice-paid
205
+ ```
206
+
207
+ `channelName(lib, event)` builds `aviary:<lib>:<event>`; passing pre-joined names
208
+ produces a malformed channel that observers subscribed to `aviary:billing:*` will
209
+ miss.
210
+ Source: `packages/core/src/channel.ts` (`channelName`, `CHANNEL_PREFIX`).
@@ -0,0 +1,164 @@
1
+ ---
2
+ name: observe-channels
3
+ description: Build a custom consumer of @dudousxd/nestjs-diagnostics events (OpenTelemetry span, APM, logger, test assertion) using the channel registry. Covers registeredChannels(), onChannelRegistered(cb), getChannel(lib, event), channelName(lib, event) and CHANNEL_PREFIX. Explains why node:diagnostics_channel has no wildcard, the subscribe-current-plus-future pattern, that subscribing flips hasSubscribers so producers start emitting, and resetRegistry for tests.
4
+ license: MIT
5
+ metadata:
6
+ type: core
7
+ library: "@dudousxd/nestjs-diagnostics"
8
+ library_version: 0.6.0
9
+ framework: nestjs
10
+ ---
11
+
12
+ # Observe channels with the registry
13
+
14
+ Anyone can consume `aviary:<lib>:<event>` events — Telescope is just one consumer,
15
+ and so is an OpenTelemetry span exporter, an APM bridge, a logger, or a test
16
+ assertion. This skill covers the **low-level registry** you use to build your own
17
+ generic consumer. (For NestJS provider methods, prefer `@OnDiagnostic`; for
18
+ Telescope, use the telescope extension.)
19
+
20
+ ## Setup
21
+
22
+ ```bash
23
+ pnpm add @dudousxd/nestjs-diagnostics
24
+ ```
25
+
26
+ `node:diagnostics_channel` has **no wildcard subscription** — you can only
27
+ subscribe by exact channel name. So this package keeps a process-global registry
28
+ of every `aviary:<lib>:<event>` channel it has created. A generic consumer
29
+ subscribes to the current set and any registered later:
30
+
31
+ ```ts
32
+ import diagnostics_channel from 'node:diagnostics_channel';
33
+ import {
34
+ registeredChannels,
35
+ onChannelRegistered,
36
+ type DiagnosticEvent,
37
+ } from '@dudousxd/nestjs-diagnostics';
38
+
39
+ function record(name: string) {
40
+ diagnostics_channel.channel(name).subscribe((msg) => {
41
+ const e = msg as DiagnosticEvent;
42
+ console.log(`[${e.lib}:${e.event}]`, e.traceId, e.payload);
43
+ });
44
+ }
45
+
46
+ // 1. subscribe to all current channels…
47
+ for (const name of registeredChannels()) record(name);
48
+ // 2. …and any registered in the future
49
+ const off = onChannelRegistered(record);
50
+ ```
51
+
52
+ Source: `packages/core/src/registry.ts`, `packages/core/src/channel.ts` (`channelName`, `getChannel`).
53
+
54
+ ## Core patterns
55
+
56
+ ### 1. Current + future, never replay
57
+
58
+ `onChannelRegistered(cb)` fires once per channel registered **after** the call;
59
+ it does NOT replay existing channels. Always pair it with `registeredChannels()`
60
+ to cover both — exactly how the Telescope `DiagnosticWatcher` and the Redis relay
61
+ auto-subscribe. The returned function unsubscribes the listener.
62
+
63
+ ```ts
64
+ const off = onChannelRegistered(record);
65
+ // later, on shutdown:
66
+ off();
67
+ ```
68
+
69
+ Source: `packages/core/src/registry.ts` (`registeredChannels`, `onChannelRegistered`).
70
+
71
+ ### 2. Subscribing turns producers on
72
+
73
+ A channel is only registered the first time `getChannel`/`emit`/`trace` touches a
74
+ `(lib, event)` pair. Subscribing to a channel also flips its `hasSubscribers` to
75
+ `true`, which is precisely what makes `emit()` start building and publishing
76
+ envelopes (zero overhead when nobody listens). So your consumer existing is what
77
+ makes producers pay the cost.
78
+
79
+ ```ts
80
+ import { getChannel } from '@dudousxd/nestjs-diagnostics';
81
+ // channelName(lib, event) === `aviary:${lib}:${event}` (CHANNEL_PREFIX === 'aviary')
82
+ const ch = getChannel('billing', 'invoice-paid'); // registers the name + memoizes the Channel
83
+ ```
84
+
85
+ Source: `packages/core/src/channel.ts` (`getChannel`, `emit` `hasSubscribers` gate),
86
+ `packages/core/src/registry.ts` (`registerChannel`).
87
+
88
+ ### 3. Resetting the registry in tests
89
+
90
+ `resetRegistry()` is test-only: it forgets every registered channel and listener
91
+ (and clears the internal channel memo caches via reset hooks) so a suite can
92
+ assert registration in isolation. Do not call it in production code.
93
+
94
+ ```ts
95
+ import { resetRegistry, registeredChannels, emit } from '@dudousxd/nestjs-diagnostics';
96
+
97
+ beforeEach(() => resetRegistry());
98
+
99
+ it('registers the channel on first emit', () => {
100
+ emit('billing', 'invoice-paid', { invoiceId: 'x', amount: 1 });
101
+ expect(registeredChannels()).toContain('aviary:billing:invoice-paid');
102
+ });
103
+ ```
104
+
105
+ Source: `packages/core/src/registry.ts` (`resetRegistry`, `onRegistryReset`).
106
+
107
+ ## Common mistakes
108
+
109
+ ### Using onChannelRegistered alone and missing existing channels
110
+
111
+ ```ts
112
+ // Wrong — channels emitted before this line are never subscribed.
113
+ onChannelRegistered(record);
114
+ ```
115
+
116
+ ```ts
117
+ // Correct — replay the current set, then listen for future ones.
118
+ for (const name of registeredChannels()) record(name);
119
+ const off = onChannelRegistered(record);
120
+ ```
121
+
122
+ `onChannelRegistered` only fires for registrations that happen *after* it is
123
+ called; it deliberately does not replay history.
124
+ Source: `packages/core/src/registry.ts` (`onChannelRegistered` doc).
125
+
126
+ ### Trying to subscribe with a wildcard channel name
127
+
128
+ ```ts
129
+ // Wrong — node:diagnostics_channel has no wildcard; this is a literal name.
130
+ import diagnostics_channel from 'node:diagnostics_channel';
131
+ diagnostics_channel.channel('aviary:billing:*').subscribe(record); // never fires
132
+ ```
133
+
134
+ ```ts
135
+ // Correct — enumerate names from the registry and subscribe to each.
136
+ import { registeredChannels, onChannelRegistered } from '@dudousxd/nestjs-diagnostics';
137
+ for (const name of registeredChannels()) record(name);
138
+ const off = onChannelRegistered(record);
139
+ ```
140
+
141
+ There is no pattern subscription in `diagnostics_channel`; the registry exists
142
+ precisely to enumerate exact names for you.
143
+ Source: `packages/core/src/registry.ts` (module doc), `packages/core/src/channel.ts`.
144
+
145
+ ### Throwing from a subscriber listener
146
+
147
+ ```ts
148
+ // Wrong — publish() is synchronous; a throw lands in the producer's emit() call.
149
+ diagnostics_channel.channel(name).subscribe((msg) => {
150
+ JSON.parse((msg as any).payload.raw); // may throw → breaks the emitter
151
+ });
152
+ ```
153
+
154
+ ```ts
155
+ // Correct — isolate your own consumer; never let it throw into emit().
156
+ diagnostics_channel.channel(name).subscribe((msg) => {
157
+ try { handle(msg as DiagnosticEvent); } catch (err) { logger.error(err); }
158
+ });
159
+ ```
160
+
161
+ The core swallows errors in `emit`/`registerChannel`, but a listener you attach
162
+ directly runs inside the synchronous `publish` — guard it yourself, as the
163
+ Telescope watcher's `safeRecord` does.
164
+ Source: `packages/telescope/src/diagnostic.watcher.ts` (`safeRecord`).
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: react-with-on-diagnostic
3
+ description: React to diagnostics events inside a NestJS app with @OnDiagnostic(lib, event?) from @dudousxd/nestjs-diagnostics/nestjs, wired by DiagnosticsModule.forRoot(). Decorate provider methods to handle one exact aviary:<lib>:<event> channel or every aviary:<lib>:* channel (current and future). Handlers run on the DI-resolved instance; sync throws and async rejections are logged and swallowed so a reaction can never break the synchronous emit() that triggered it.
4
+ license: MIT
5
+ metadata:
6
+ type: core
7
+ library: "@dudousxd/nestjs-diagnostics"
8
+ library_version: 0.6.0
9
+ framework: nestjs
10
+ ---
11
+
12
+ # React to events with @OnDiagnostic
13
+
14
+ The `@dudousxd/nestjs-diagnostics/nestjs` subpath adds an ergonomic way to react
15
+ to any `aviary:<lib>:<event>` event in a NestJS app — no extra event library.
16
+ Register the module once, then decorate provider methods.
17
+
18
+ ## Setup
19
+
20
+ ```bash
21
+ pnpm add @dudousxd/nestjs-diagnostics
22
+ # requires @nestjs/common, @nestjs/core, reflect-metadata (optional peers)
23
+ ```
24
+
25
+ Register the module at the app root (it is `global`, imports `DiscoveryModule`):
26
+
27
+ ```ts
28
+ import { Module } from '@nestjs/common';
29
+ import { DiagnosticsModule } from '@dudousxd/nestjs-diagnostics/nestjs';
30
+
31
+ @Module({ imports: [DiagnosticsModule.forRoot()] })
32
+ export class AppModule {}
33
+ ```
34
+
35
+ Then decorate provider methods:
36
+
37
+ ```ts
38
+ import { Injectable } from '@nestjs/common';
39
+ import { OnDiagnostic } from '@dudousxd/nestjs-diagnostics/nestjs';
40
+ import type { DiagnosticEvent } from '@dudousxd/nestjs-diagnostics';
41
+
42
+ @Injectable()
43
+ export class Reactions {
44
+ @OnDiagnostic('resilience', 'circuit-opened') // one exact channel
45
+ onCircuitOpen(e: DiagnosticEvent) { /* … */ }
46
+
47
+ @OnDiagnostic('resilience') // every aviary:resilience:* channel
48
+ onAnyResilience(e: DiagnosticEvent) { /* … */ }
49
+ }
50
+ ```
51
+
52
+ Source: `packages/core/src/nestjs/diagnostics.module.ts`,
53
+ `packages/core/src/nestjs/on-diagnostic.decorator.ts`,
54
+ `packages/core/src/nestjs/diagnostics.explorer.ts`.
55
+
56
+ ## Core patterns
57
+
58
+ ### 1. Exact channel vs library wildcard
59
+
60
+ - `@OnDiagnostic('resilience', 'circuit-opened')` subscribes to the exact
61
+ `aviary:resilience:circuit-opened` channel.
62
+ - `@OnDiagnostic('resilience')` (no `event`) subscribes to **every**
63
+ `aviary:resilience:*` channel — both those already registered at bootstrap and
64
+ any that appear later (a library's first `emit('resilience', 'newevent', …)`).
65
+
66
+ The explorer scans providers on `onApplicationBootstrap`, then keeps an
67
+ `onChannelRegistered` callback alive so future wildcard channels are subscribed
68
+ automatically.
69
+
70
+ Source: `packages/core/src/nestjs/diagnostics.explorer.ts` (`onApplicationBootstrap`).
71
+
72
+ ### 2. Handlers run on the DI-resolved instance
73
+
74
+ The decorated method is invoked on the real provider instance, so injected
75
+ dependencies are available:
76
+
77
+ ```ts
78
+ @Injectable()
79
+ export class AlertReactions {
80
+ constructor(private readonly notifier: Notifier) {}
81
+
82
+ @OnDiagnostic('billing', 'invoice-paid')
83
+ async onPaid(e: DiagnosticEvent<{ invoiceId: string; amount: number }>) {
84
+ await this.notifier.send(`Paid ${e.payload.invoiceId}`);
85
+ }
86
+ }
87
+ ```
88
+
89
+ Source: `packages/core/src/nestjs/diagnostics.explorer.ts` (`safeInvoke` calls `fn.call(instance, event)`).
90
+
91
+ ### 3. Never-throw isolation
92
+
93
+ `diagnostics_channel.publish` is **synchronous**, so a subscriber that throws
94
+ would propagate into the emitter's call stack. The explorer's `safeInvoke`
95
+ catches synchronous throws and attaches a `.catch` to rejected promises, logging
96
+ both — a reaction can never break the `emit()` that triggered it. Async handlers
97
+ are therefore fire-and-forget (publish cannot await).
98
+
99
+ Source: `packages/core/src/nestjs/diagnostics.explorer.ts` (`safeInvoke`, `logError`).
100
+
101
+ ## Common mistakes
102
+
103
+ ### Using @OnDiagnostic without importing DiagnosticsModule.forRoot()
104
+
105
+ ```ts
106
+ // Wrong — decorator metadata is set, but nothing scans/subscribes it.
107
+ @Module({})
108
+ export class AppModule {}
109
+ ```
110
+
111
+ ```ts
112
+ // Correct — forRoot() registers the explorer that wires every @OnDiagnostic.
113
+ @Module({ imports: [DiagnosticsModule.forRoot()] })
114
+ export class AppModule {}
115
+ ```
116
+
117
+ `@OnDiagnostic` only records metadata; `DiagnosticsExplorer` (provided by
118
+ `forRoot()`) is what discovers decorated methods and subscribes the channels at
119
+ bootstrap.
120
+ Source: `packages/core/src/nestjs/diagnostics.module.ts` (`forRoot` provides `DiagnosticsExplorer`).
121
+
122
+ ### Decorating a method on a non-provider class
123
+
124
+ ```ts
125
+ // Wrong — a plain class Nest never instantiates is never scanned.
126
+ class Reactions {
127
+ @OnDiagnostic('billing', 'invoice-paid') onPaid(e: DiagnosticEvent) {}
128
+ }
129
+ ```
130
+
131
+ ```ts
132
+ // Correct — @Injectable() and registered in a module's providers.
133
+ @Injectable()
134
+ export class Reactions {
135
+ @OnDiagnostic('billing', 'invoice-paid') onPaid(e: DiagnosticEvent) {}
136
+ }
137
+ ```
138
+
139
+ The explorer iterates `discovery.getProviders()`; only DI-managed provider
140
+ instances are scanned for `@OnDiagnostic` metadata.
141
+ Source: `packages/core/src/nestjs/diagnostics.explorer.ts` (`discovery.getProviders()`).
142
+
143
+ ### Throwing in a handler expecting the framework to retry
144
+
145
+ ```ts
146
+ // Wrong — assuming a throw bubbles up or triggers a retry.
147
+ @OnDiagnostic('billing', 'invoice-paid')
148
+ onPaid(e: DiagnosticEvent) {
149
+ throw new Error('will be retried'); // it will NOT — it is logged and swallowed
150
+ }
151
+ ```
152
+
153
+ ```ts
154
+ // Correct — handle failures inside the reaction; it is fire-and-forget.
155
+ @OnDiagnostic('billing', 'invoice-paid')
156
+ async onPaid(e: DiagnosticEvent) {
157
+ try { await this.work(e); } catch (err) { this.logger.warn(err); }
158
+ }
159
+ ```
160
+
161
+ `safeInvoke` swallows throws/rejections by design, so emission stays safe — there
162
+ is no delivery guarantee, retry, or replay.
163
+ Source: `packages/core/src/nestjs/diagnostics.explorer.ts` (`safeInvoke`).
@@ -0,0 +1,156 @@
1
+ ---
2
+ name: trace-spans
3
+ description: Emit span-like start/end/asyncStart/asyncEnd/error events with trace(lib, event, fn, payload, opts) and tracingChannel(lib, event) from @dudousxd/nestjs-diagnostics. Wraps sync or async operations over aviary:<lib>:<event>:<phase> sub-channels, producing SpanEvent envelopes with spanId, durationMs, result and error so observers reconstruct real spans. Zero overhead when no span sub-channel has subscribers; never throws; SPAN_SCHEMA_VERSION, traceChannelNames.
4
+ license: MIT
5
+ metadata:
6
+ type: core
7
+ library: "@dudousxd/nestjs-diagnostics"
8
+ library_version: 0.6.0
9
+ framework: nestjs
10
+ ---
11
+
12
+ # Trace operations as spans
13
+
14
+ `emit` publishes a single POINT event. `trace` wraps an operation and publishes
15
+ span-like **start / end / asyncStart / asyncEnd / error** events with timing, so
16
+ observers can reconstruct real start↔end↔error pairs (durable steps, authz
17
+ decisions, cache fetches). It mirrors Node's `diagnostics_channel.tracingChannel`
18
+ sub-channels but stays inside the `aviary:` convention.
19
+
20
+ ## Setup
21
+
22
+ ```bash
23
+ pnpm add @dudousxd/nestjs-diagnostics
24
+ ```
25
+
26
+ ```ts
27
+ import { Injectable } from '@nestjs/common';
28
+ import { trace } from '@dudousxd/nestjs-diagnostics';
29
+
30
+ @Injectable()
31
+ export class AuthzService {
32
+ evaluate(req: Request) {
33
+ // Sync op: publishes start then end (with result) — or error on throw.
34
+ return trace('authz', 'decision', () => this.run(req), { subject: req.user });
35
+ }
36
+
37
+ async runStep(input: StepInput) {
38
+ // Async op: start, end (sync portion), asyncStart, then asyncEnd (with result).
39
+ return await trace('durable', 'step', () => this.execute(input), { name: 'charge' });
40
+ }
41
+ }
42
+ ```
43
+
44
+ Span events ride `aviary:<lib>:<event>:<phase>`; the base `aviary:<lib>:<event>`
45
+ name is what gets registered for discovery. A `SpanEvent` carries `v`, `ts`,
46
+ `lib`, `event`, `phase`, a per-span `spanId`, optional `traceId`, and
47
+ `payload` / `result` / `error` / `durationMs` depending on the phase.
48
+
49
+ Source: `packages/core/src/trace.ts`, `packages/core/src/types.ts` (`SpanEvent`, `SpanPhase`).
50
+
51
+ ## Core patterns
52
+
53
+ ### 1. The span lifecycle
54
+
55
+ - **Sync `fn`** → `start` (carries `payload`), then on return `end` (carries
56
+ `result` + `durationMs`); on throw `error` (carries `error` + `durationMs`).
57
+ The return value / throw is propagated to the caller unchanged.
58
+ - **Async `fn`** (returns a promise) → `start` synchronously, then `end` for the
59
+ sync portion, `asyncStart`, and finally `asyncEnd` (with `result` + `durationMs`)
60
+ on fulfilment or `error` on rejection. The promise is returned to the caller.
61
+
62
+ Every phase event of one `trace` call shares a `spanId`, so observers pair them
63
+ without relying on subscription order. `durationMs` is measured with
64
+ `performance.now()`.
65
+
66
+ Source: `packages/core/src/trace.ts` (`trace`, `publishPhase`).
67
+
68
+ ### 2. Zero overhead when unobserved
69
+
70
+ When NO span sub-channel has a subscriber, `trace` calls `fn()` and returns its
71
+ value directly — no `spanId`, no envelope, no timing. The hot path is just five
72
+ `hasSubscribers` reads (`anySubscribed`).
73
+
74
+ ```ts
75
+ // In prod with no observer: equivalent to `return this.run(req)`.
76
+ const decision = trace('authz', 'decision', () => this.run(req), { subject });
77
+ ```
78
+
79
+ Source: `packages/core/src/trace.ts` (`anySubscribed`, the early `return fn()`).
80
+
81
+ ### 3. tracingChannel for a hot, fixed call site
82
+
83
+ Bind `trace` to one `(lib, event)` pair when a call site always traces the same
84
+ operation:
85
+
86
+ ```ts
87
+ import { tracingChannel } from '@dudousxd/nestjs-diagnostics';
88
+
89
+ const decision = tracingChannel('authz', 'decision');
90
+ // decision.name -> 'aviary:authz:decision'
91
+ // decision.channels -> the five span sub-channel names (traceChannelNames)
92
+
93
+ const out = decision.trace(() => evaluate(req), { subject });
94
+ ```
95
+
96
+ Source: `packages/core/src/trace.ts` (`tracingChannel`, `TracingChannel`, `traceChannelNames`).
97
+
98
+ ## Common mistakes
99
+
100
+ ### Passing an already-invoked promise instead of a thunk
101
+
102
+ ```ts
103
+ // Wrong — fn() is called by you, so trace can't bracket start/end around it.
104
+ trace('durable', 'step', this.execute(input)); // type error: not a function
105
+ ```
106
+
107
+ ```ts
108
+ // Correct — pass a zero-arg function; trace invokes it between start and end.
109
+ await trace('durable', 'step', () => this.execute(input), { name: 'charge' });
110
+ ```
111
+
112
+ `trace(lib, event, fn, payload?, opts?)` takes a `fn: () => R` it calls itself;
113
+ the promise must be created inside the thunk so `start` precedes it.
114
+ Source: `packages/core/src/trace.ts` (`trace` signature).
115
+
116
+ ### Forgetting to return / await the trace result
117
+
118
+ ```ts
119
+ // Wrong — the operation runs but the caller loses its value and unhandled rejections.
120
+ async charge() {
121
+ trace('durable', 'step', () => this.execute());
122
+ }
123
+ ```
124
+
125
+ ```ts
126
+ // Correct — trace returns fn's value (or its promise); return/await it.
127
+ async charge() {
128
+ return await trace('durable', 'step', () => this.execute());
129
+ }
130
+ ```
131
+
132
+ `trace` is transparent: it returns exactly what `fn` returns (the same promise for
133
+ async). Drop the return and you drop the result and its error handling.
134
+ Source: `packages/core/src/trace.ts` (`trace` returns `result` / the settled promise).
135
+
136
+ ### Subscribing only to the base channel and expecting span events
137
+
138
+ ```ts
139
+ // Wrong — span phases are published on sub-channels, not the base name.
140
+ import diagnostics_channel from 'node:diagnostics_channel';
141
+ diagnostics_channel.channel('aviary:authz:decision').subscribe(onSpan); // never fires
142
+ ```
143
+
144
+ ```ts
145
+ // Correct — subscribe to the phase sub-channels via traceChannelNames.
146
+ import { traceChannelNames } from '@dudousxd/nestjs-diagnostics';
147
+ const n = traceChannelNames('authz', 'decision');
148
+ for (const name of [n.start, n.end, n.asyncStart, n.asyncEnd, n.error]) {
149
+ diagnostics_channel.channel(name).subscribe(onSpan);
150
+ }
151
+ ```
152
+
153
+ `trace` publishes on `aviary:<lib>:<event>:start|end|asyncStart|asyncEnd|error`;
154
+ only the base name is registered for discovery, so a generic observer must derive
155
+ the sub-channels.
156
+ Source: `packages/core/src/trace.ts` (`traceChannelNames`, `getSpanChannels`).