@dudousxd/nestjs-diagnostics 0.6.0 → 0.7.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/CHANGELOG.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # @dudousxd/nestjs-diagnostics
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`1568de4`](https://github.com/DavideCarvalho/nestjs-diagnostics/commit/1568de44f05770ad2559f7d6d4ea38d9d213d7e5) - Cross-lib dedup, no consumer config: lib-specific telescope watchers (nestjs-agent's,
8
+ nestjs-media's) record their `aviary:<lib>:<event>` channels as first-class typed entries — and
9
+ the generic bridge recorded them AGAIN as `diagnostic` entries unless the consumer hand-maintained
10
+ an exclude list. Now the libs resolve it themselves:
11
+
12
+ - core: `claimDiagnostics(lib, events)` registers `lib:event` keys in a process-global,
13
+ refcounted claim registry (`Symbol.for('aviary:diagnostics:claims')` — the raw convention is
14
+ documented so packages without this dependency can participate); returns a release fn.
15
+ `isDiagnosticClaimed(lib, event)` reads it.
16
+ - telescope: the generic watcher checks claims AT RECORD TIME (order-independent) and skips
17
+ claimed keys by default. `recordClaimed: true` opts back in; `exclude` still exists — its job
18
+ is muting noisy events (e.g. `media:upload.progress`), not dedup.
19
+
20
+ Lib watchers claim in their own upcoming releases; until then behavior is unchanged (nothing
21
+ claims → everything records, exactly as today).
22
+
23
+ ## 0.6.1
24
+
25
+ ### Patch Changes
26
+
27
+ - [#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.
28
+
3
29
  ## 0.6.0
4
30
 
5
31
  ### 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
@@ -0,0 +1,68 @@
1
+ /**
2
+ * A process-wide registry of which `aviary:<lib>:<event>` channels are already
3
+ * recorded as a first-class, typed Telescope entry by a lib-specific watcher
4
+ * (e.g. nestjs-agent's own watcher for `aviary:agent:*`, nestjs-media's for
5
+ * `aviary:media:*`). The generic `DiagnosticWatcher` in
6
+ * `@dudousxd/nestjs-diagnostics-telescope` auto-subscribes to EVERY registered
7
+ * channel (see {@link registerChannel}), so without this registry every event a
8
+ * claiming lib emits would be recorded TWICE: once as its own typed entry, once
9
+ * as a generic `diagnostic` entry. Claiming a key tells the generic watcher "I
10
+ * already record this — skip it by default."
11
+ *
12
+ * ## RAW convention (no dependency on this package required)
13
+ * The registry is a plain `Map<string, number>` behind a well-known
14
+ * `Symbol.for` key, so a package that does NOT want to depend on
15
+ * `@dudousxd/nestjs-diagnostics` can still participate by replicating this
16
+ * structure exactly, without importing anything from here:
17
+ *
18
+ * - `Symbol.for('aviary:diagnostics:claims')` resolves to a
19
+ * `Map<string, number>` stored on `globalThis`.
20
+ * - Each key is `` `${lib}:${event}` `` — the same label the "Busiest events"
21
+ * dashboard panel and `DiagnosticWatcherOptions.exclude` use.
22
+ * - The value is a reference count, always `>= 1` while the key is claimed by
23
+ * at least one caller. Claiming an already-claimed key increments the count;
24
+ * releasing decrements it; the key is deleted (unclaimed again) only once the
25
+ * count reaches `0`. This is what lets two independent watchers claim the
26
+ * same key without one's release un-claiming it for the other.
27
+ * - A key is "claimed" iff the map has it; "unclaimed" iff absent (or its count
28
+ * reached `0` and was deleted).
29
+ *
30
+ * ## Record-time-check contract
31
+ * A generic observer (like `DiagnosticWatcher`) MUST call
32
+ * {@link isDiagnosticClaimed} at RECORD time — when an event is actually
33
+ * published — not at subscribe time. This makes claiming order-independent: a
34
+ * lib-specific watcher may call {@link claimDiagnostics} before or after the
35
+ * generic watcher's `register()` runs, and every event recorded after the claim
36
+ * exists is skipped either way. Checking once at subscribe time would miss
37
+ * claims registered later and could not un-skip a released claim either.
38
+ */
39
+ /**
40
+ * Claim `lib:event` for every event in `events`, so a generic observer skips
41
+ * them by default (see the record-time-check contract above). Call this once
42
+ * when a lib-specific watcher registers, passing every event it records as a
43
+ * typed entry.
44
+ *
45
+ * Reference-counted: claiming a key already claimed by a different call (e.g.
46
+ * two Telescope instances, or overlapping event lists) increments its count
47
+ * instead of overwriting it, so releasing one caller's claim never un-claims a
48
+ * key another caller still holds.
49
+ *
50
+ * Returns a release function that removes EXACTLY the keys this call added —
51
+ * decrementing each of `events`' counts by one, deleting the key only once its
52
+ * count reaches `0`. Idempotent: calling the release function more than once
53
+ * has no additional effect.
54
+ *
55
+ * ```ts
56
+ * const release = claimDiagnostics('agent', ['chat-request', 'tool-call']);
57
+ * // ... later, e.g. on watcher cleanup:
58
+ * release();
59
+ * ```
60
+ */
61
+ export declare function claimDiagnostics(lib: string, events: readonly string[]): () => void;
62
+ /**
63
+ * Whether `lib:event` is currently claimed by at least one
64
+ * {@link claimDiagnostics} call. Intended to be checked at record time by a
65
+ * generic observer, per the contract above.
66
+ */
67
+ export declare function isDiagnosticClaimed(lib: string, event: string): boolean;
68
+ //# sourceMappingURL=claims.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.d.ts","sourceRoot":"","sources":["../src/claims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAeH;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,MAAM,IAAI,CAgBnF;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAEvE"}
package/dist/claims.js ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * A process-wide registry of which `aviary:<lib>:<event>` channels are already
3
+ * recorded as a first-class, typed Telescope entry by a lib-specific watcher
4
+ * (e.g. nestjs-agent's own watcher for `aviary:agent:*`, nestjs-media's for
5
+ * `aviary:media:*`). The generic `DiagnosticWatcher` in
6
+ * `@dudousxd/nestjs-diagnostics-telescope` auto-subscribes to EVERY registered
7
+ * channel (see {@link registerChannel}), so without this registry every event a
8
+ * claiming lib emits would be recorded TWICE: once as its own typed entry, once
9
+ * as a generic `diagnostic` entry. Claiming a key tells the generic watcher "I
10
+ * already record this — skip it by default."
11
+ *
12
+ * ## RAW convention (no dependency on this package required)
13
+ * The registry is a plain `Map<string, number>` behind a well-known
14
+ * `Symbol.for` key, so a package that does NOT want to depend on
15
+ * `@dudousxd/nestjs-diagnostics` can still participate by replicating this
16
+ * structure exactly, without importing anything from here:
17
+ *
18
+ * - `Symbol.for('aviary:diagnostics:claims')` resolves to a
19
+ * `Map<string, number>` stored on `globalThis`.
20
+ * - Each key is `` `${lib}:${event}` `` — the same label the "Busiest events"
21
+ * dashboard panel and `DiagnosticWatcherOptions.exclude` use.
22
+ * - The value is a reference count, always `>= 1` while the key is claimed by
23
+ * at least one caller. Claiming an already-claimed key increments the count;
24
+ * releasing decrements it; the key is deleted (unclaimed again) only once the
25
+ * count reaches `0`. This is what lets two independent watchers claim the
26
+ * same key without one's release un-claiming it for the other.
27
+ * - A key is "claimed" iff the map has it; "unclaimed" iff absent (or its count
28
+ * reached `0` and was deleted).
29
+ *
30
+ * ## Record-time-check contract
31
+ * A generic observer (like `DiagnosticWatcher`) MUST call
32
+ * {@link isDiagnosticClaimed} at RECORD time — when an event is actually
33
+ * published — not at subscribe time. This makes claiming order-independent: a
34
+ * lib-specific watcher may call {@link claimDiagnostics} before or after the
35
+ * generic watcher's `register()` runs, and every event recorded after the claim
36
+ * exists is skipped either way. Checking once at subscribe time would miss
37
+ * claims registered later and could not un-skip a released claim either.
38
+ */
39
+ const CLAIMS_KEY = Symbol.for('aviary:diagnostics:claims');
40
+ const claimsGlobal = globalThis;
41
+ const claims = claimsGlobal[CLAIMS_KEY] ?? new Map();
42
+ claimsGlobal[CLAIMS_KEY] = claims;
43
+ /** The `lib:event` label used as the claim-store key, same shape as `exclude` entries. */
44
+ function claimKey(lib, event) {
45
+ return `${lib}:${event}`;
46
+ }
47
+ /**
48
+ * Claim `lib:event` for every event in `events`, so a generic observer skips
49
+ * them by default (see the record-time-check contract above). Call this once
50
+ * when a lib-specific watcher registers, passing every event it records as a
51
+ * typed entry.
52
+ *
53
+ * Reference-counted: claiming a key already claimed by a different call (e.g.
54
+ * two Telescope instances, or overlapping event lists) increments its count
55
+ * instead of overwriting it, so releasing one caller's claim never un-claims a
56
+ * key another caller still holds.
57
+ *
58
+ * Returns a release function that removes EXACTLY the keys this call added —
59
+ * decrementing each of `events`' counts by one, deleting the key only once its
60
+ * count reaches `0`. Idempotent: calling the release function more than once
61
+ * has no additional effect.
62
+ *
63
+ * ```ts
64
+ * const release = claimDiagnostics('agent', ['chat-request', 'tool-call']);
65
+ * // ... later, e.g. on watcher cleanup:
66
+ * release();
67
+ * ```
68
+ */
69
+ export function claimDiagnostics(lib, events) {
70
+ const keys = events.map((event) => claimKey(lib, event));
71
+ for (const key of keys) {
72
+ claims.set(key, (claims.get(key) ?? 0) + 1);
73
+ }
74
+ let released = false;
75
+ return () => {
76
+ if (released)
77
+ return;
78
+ released = true;
79
+ for (const key of keys) {
80
+ const count = claims.get(key);
81
+ if (count === undefined)
82
+ continue;
83
+ if (count <= 1)
84
+ claims.delete(key);
85
+ else
86
+ claims.set(key, count - 1);
87
+ }
88
+ };
89
+ }
90
+ /**
91
+ * Whether `lib:event` is currently claimed by at least one
92
+ * {@link claimDiagnostics} call. Intended to be checked at record time by a
93
+ * generic observer, per the contract above.
94
+ */
95
+ export function isDiagnosticClaimed(lib, event) {
96
+ return claims.has(claimKey(lib, event));
97
+ }
98
+ //# sourceMappingURL=claims.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"claims.js","sourceRoot":"","sources":["../src/claims.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAqCG;AAKH,MAAM,UAAU,GAAG,MAAM,CAAC,GAAG,CAAC,2BAA2B,CAAC,CAAC;AAC3D,MAAM,YAAY,GAAG,UAA+D,CAAC;AACrF,MAAM,MAAM,GAAe,YAAY,CAAC,UAAU,CAAC,IAAI,IAAI,GAAG,EAAkB,CAAC;AACjF,YAAY,CAAC,UAAU,CAAC,GAAG,MAAM,CAAC;AAElC,0FAA0F;AAC1F,SAAS,QAAQ,CAAC,GAAW,EAAE,KAAa;IAC1C,OAAO,GAAG,GAAG,IAAI,KAAK,EAAE,CAAC;AAC3B,CAAC;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,MAAM,UAAU,gBAAgB,CAAC,GAAW,EAAE,MAAyB;IACrE,MAAM,IAAI,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC,KAAK,EAAE,EAAE,CAAC,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;IACzD,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,CAAC,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC;IAC9C,CAAC;IACD,IAAI,QAAQ,GAAG,KAAK,CAAC;IACrB,OAAO,GAAG,EAAE;QACV,IAAI,QAAQ;YAAE,OAAO;QACrB,QAAQ,GAAG,IAAI,CAAC;QAChB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,KAAK,GAAG,MAAM,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;YAC9B,IAAI,KAAK,KAAK,SAAS;gBAAE,SAAS;YAClC,IAAI,KAAK,IAAI,CAAC;gBAAE,MAAM,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;;gBAC9B,MAAM,CAAC,GAAG,CAAC,GAAG,EAAE,KAAK,GAAG,CAAC,CAAC,CAAC;QAClC,CAAC;IACH,CAAC,CAAC;AACJ,CAAC;AAED;;;;GAIG;AACH,MAAM,UAAU,mBAAmB,CAAC,GAAW,EAAE,KAAa;IAC5D,OAAO,MAAM,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC,CAAC;AAC1C,CAAC"}
package/dist/index.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export { capability } from './capability.js';
2
2
  export type { CapabilityOf, CapabilityRegistry } from './capability.js';
3
- export { assertCapabilityNaming } from './conformance.js';
4
3
  export { CHANNEL_PREFIX, channelName, emit, getChannel, SCHEMA_VERSION } from './channel.js';
4
+ export { claimDiagnostics, isDiagnosticClaimed } from './claims.js';
5
+ export { assertCapabilityNaming } from './conformance.js';
5
6
  export { CONTEXT_ACCESSOR, type ContextAccessor, type ContextStore, getContextAccessor, resolveTraceId, setContextAccessor, type UserRef, } from './context-accessor.js';
6
7
  export { onChannelRegistered, registerChannel, registeredChannels, resetRegistry, } from './registry.js';
7
8
  export { SPAN_SCHEMA_VERSION, trace, type TraceChannelNames, traceChannelNames, type TracingChannel, tracingChannel, } from './trace.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EACL,gBAAgB,EAChB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,OAAO,GACb,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,kBAAkB,EAClB,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,mBAAmB,EACnB,KAAK,EACL,KAAK,iBAAiB,EACtB,iBAAiB,EACjB,KAAK,cAAc,EACnB,cAAc,GACf,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,eAAe,EACf,eAAe,EACf,WAAW,EACX,OAAO,EACP,KAAK,EACL,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAY,GACb,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAC7C,YAAY,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AACxE,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EACL,gBAAgB,EAChB,KAAK,eAAe,EACpB,KAAK,YAAY,EACjB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,EAClB,KAAK,OAAO,GACb,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,kBAAkB,EAClB,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,mBAAmB,EACnB,KAAK,EACL,KAAK,iBAAiB,EACtB,iBAAiB,EACjB,KAAK,cAAc,EACnB,cAAc,GACf,MAAM,YAAY,CAAC;AACpB,YAAY,EACV,eAAe,EACf,eAAe,EACf,WAAW,EACX,OAAO,EACP,KAAK,EACL,SAAS,EACT,SAAS,EACT,SAAS,EACT,YAAY,GACb,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export { capability } from './capability.js';
2
- export { assertCapabilityNaming } from './conformance.js';
3
2
  export { CHANNEL_PREFIX, channelName, emit, getChannel, SCHEMA_VERSION } from './channel.js';
3
+ export { claimDiagnostics, isDiagnosticClaimed } from './claims.js';
4
+ export { assertCapabilityNaming } from './conformance.js';
4
5
  export { CONTEXT_ACCESSOR, getContextAccessor, resolveTraceId, setContextAccessor, } from './context-accessor.js';
5
6
  export { onChannelRegistered, registerChannel, registeredChannels, resetRegistry, } from './registry.js';
6
7
  export { SPAN_SCHEMA_VERSION, trace, traceChannelNames, tracingChannel, } from './trace.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EACL,gBAAgB,EAGhB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,GAEnB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,kBAAkB,EAClB,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,mBAAmB,EACnB,KAAK,EAEL,iBAAiB,EAEjB,cAAc,GACf,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,IAAI,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAC7F,OAAO,EAAE,gBAAgB,EAAE,mBAAmB,EAAE,MAAM,aAAa,CAAC;AACpE,OAAO,EAAE,sBAAsB,EAAE,MAAM,kBAAkB,CAAC;AAC1D,OAAO,EACL,gBAAgB,EAGhB,kBAAkB,EAClB,cAAc,EACd,kBAAkB,GAEnB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EACL,mBAAmB,EACnB,eAAe,EACf,kBAAkB,EAClB,aAAa,GACd,MAAM,eAAe,CAAC;AACvB,OAAO,EACL,mBAAmB,EACnB,KAAK,EAEL,iBAAiB,EAEjB,cAAc,GACf,MAAM,YAAY,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dudousxd/nestjs-diagnostics",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
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`).