@graphorin/observability 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +29 -5
  3. package/dist/cost/cost-tracker.d.ts.map +1 -1
  4. package/dist/cost/cost-tracker.js +34 -0
  5. package/dist/cost/cost-tracker.js.map +1 -1
  6. package/dist/cost/delegate.d.ts +40 -0
  7. package/dist/cost/delegate.d.ts.map +1 -0
  8. package/dist/cost/delegate.js +31 -0
  9. package/dist/cost/delegate.js.map +1 -0
  10. package/dist/cost/index.d.ts +2 -1
  11. package/dist/cost/index.js +2 -1
  12. package/dist/cost/types.d.ts +39 -0
  13. package/dist/cost/types.d.ts.map +1 -1
  14. package/dist/exporters/otlp-http.d.ts.map +1 -1
  15. package/dist/exporters/otlp-http.js +4 -2
  16. package/dist/exporters/otlp-http.js.map +1 -1
  17. package/dist/exporters/types.d.ts +7 -0
  18. package/dist/exporters/types.d.ts.map +1 -1
  19. package/dist/exporters/types.js.map +1 -1
  20. package/dist/exporters/with-validation.d.ts.map +1 -1
  21. package/dist/exporters/with-validation.js +5 -4
  22. package/dist/exporters/with-validation.js.map +1 -1
  23. package/dist/gen-ai/emit.js +9 -1
  24. package/dist/gen-ai/emit.js.map +1 -1
  25. package/dist/index.d.ts +3 -3
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +5 -10
  28. package/dist/index.js.map +1 -1
  29. package/dist/package.js +6 -0
  30. package/dist/package.js.map +1 -0
  31. package/dist/redaction/imperative-patterns.d.ts +3 -3
  32. package/dist/redaction/imperative-patterns.d.ts.map +1 -1
  33. package/dist/redaction/imperative-patterns.js +7 -0
  34. package/dist/redaction/imperative-patterns.js.map +1 -1
  35. package/dist/redaction/patterns.d.ts.map +1 -1
  36. package/dist/redaction/patterns.js +2 -2
  37. package/dist/redaction/patterns.js.map +1 -1
  38. package/dist/tracer/sampling.d.ts +6 -1
  39. package/dist/tracer/sampling.d.ts.map +1 -1
  40. package/dist/tracer/sampling.js +7 -1
  41. package/dist/tracer/sampling.js.map +1 -1
  42. package/dist/tracer/span.d.ts.map +1 -1
  43. package/dist/tracer/span.js +12 -3
  44. package/dist/tracer/span.js.map +1 -1
  45. package/package.json +18 -35
  46. package/src/cost/cost-tracker.ts +315 -0
  47. package/src/cost/delegate.ts +79 -0
  48. package/src/cost/index.ts +23 -0
  49. package/src/cost/types.ts +151 -0
  50. package/src/eval/index.ts +16 -0
  51. package/src/eval/runner.ts +146 -0
  52. package/src/eval/types.ts +122 -0
  53. package/src/exporters/console.ts +75 -0
  54. package/src/exporters/index.ts +36 -0
  55. package/src/exporters/jsonl.ts +167 -0
  56. package/src/exporters/otlp-http.ts +178 -0
  57. package/src/exporters/types.ts +85 -0
  58. package/src/exporters/with-validation.ts +176 -0
  59. package/src/gen-ai/emit.ts +157 -0
  60. package/src/gen-ai/index.ts +26 -0
  61. package/src/gen-ai/operation-mapping.ts +89 -0
  62. package/src/gen-ai/system-derivation.ts +84 -0
  63. package/src/gen-ai/types.ts +143 -0
  64. package/src/index.ts +36 -0
  65. package/src/logger/index.ts +13 -0
  66. package/src/logger/logger.ts +178 -0
  67. package/src/openinference/index.ts +133 -0
  68. package/src/redaction/config.ts +50 -0
  69. package/src/redaction/errors.ts +53 -0
  70. package/src/redaction/imperative-patterns.ts +268 -0
  71. package/src/redaction/index.ts +42 -0
  72. package/src/redaction/patterns.ts +263 -0
  73. package/src/redaction/types.ts +116 -0
  74. package/src/redaction/validator.ts +302 -0
  75. package/src/replay/config.ts +58 -0
  76. package/src/replay/index.ts +17 -0
  77. package/src/replay/log.ts +89 -0
  78. package/src/replay/replay.ts +196 -0
  79. package/src/replay/types.ts +112 -0
  80. package/src/telemetry/index.ts +94 -0
  81. package/src/tracer/ids.ts +27 -0
  82. package/src/tracer/index.ts +22 -0
  83. package/src/tracer/sampling.ts +170 -0
  84. package/src/tracer/span-names.ts +52 -0
  85. package/src/tracer/span.ts +175 -0
  86. package/src/tracer/tracer.ts +309 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # @graphorin/observability
2
2
 
3
+ ## 0.7.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [#154](https://github.com/o-stepper/graphorin/pull/154) [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04) Thanks [@o-stepper](https://github.com/o-stepper)! - W-072: every export map's `import` condition becomes `default`, and the Node floor rises to `>=22.12.0`.
8
+
9
+ CJS consumers previously hit a bewildering `ERR_PACKAGE_PATH_NOT_EXPORTED` instead of a clear ESM-only signal. With the `default` condition, plain `require('@graphorin/core')` works via Node's stable `require(esm)` - which shipped in 22.12, hence the engines bump across every workspace manifest (packages, examples, benchmarks, docs; enforced by the widened mvp-readiness sweep). No dual-instance hazard: there is no CJS build, `require()` returns the same ESM module instance. ESM consumers are unaffected (`default` serves both paths; `types` stays first). The pack gate now runs attw under the full `node16` profile (was `esm-only`) and adds a runtime `require(esm)` smoke against the packed tarballs. Installs on Node 22.0-22.11 with `engine-strict` will refuse - upgrade Node (see the migration guide).
10
+
11
+ - [#153](https://github.com/o-stepper/graphorin/pull/153) [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534) Thanks [@o-stepper](https://github.com/o-stepper)! - New imperative pattern `untrusted-content-delimiter-injection` (W-030 defense-in-depth): fabricated `<<<untrusted_content` / `<<</untrusted_content` envelope delimiters inside untrusted content are now detected and stripped by the `detect-and-strip*` policies, giving an audit signal (`tool.inbound.sanitization.hit`) on envelope break-out attempts. The regex is scoped strictly to the envelope markers - bare `<<<` / `>>>` runs (Python doctest, shell heredoc) never match. Minor bump per the catalogue's stability rule: existing deployments may see new counter increments.
12
+
13
+ - [#158](https://github.com/o-stepper/graphorin/pull/158) [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab) Thanks [@o-stepper](https://github.com/o-stepper)! - Per-type sampling rules now apply to CHILD spans under the default parent-based decision maker (W-090) - `{ type: 'tool.execute', rate: 0.01 }` finally thins the per-call spans inside sampled `agent.run` traces, where the volume actually lives, as the docstring always promised. Rules only ever downsample: children of an unsampled parent are never resurrected, and a child dropped by its rule propagates `parentSampled=false` to its own descendants (documented tree break). Operators whose previously-inert rules now take effect will see child-span export volume drop accordingly; configurations without rules are unchanged.
14
+
15
+ - [#160](https://github.com/o-stepper/graphorin/pull/160) [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156) Thanks [@o-stepper](https://github.com/o-stepper)! - W-092: `CostTracker` grows three capabilities for the long-running-assistant scenario. (1) Prompt-cache legs: `CostRecordInput` accepts `cachedReadTokens` / `cacheWriteTokens` (names mirror core `Usage`), and `CostSnapshot` + its byModel entries always carry both (0 when never recorded) so cache economics are visible per span/scope/model. (2) Bounded memory: internal maps are capped by default (`retention: { maxSpanEntries: 10_000, maxScopeEntries: 10_000 }`) with oldest-first (insertion-order) eviction, an `onEviction` observer, documented ZERO reads for evicted ids, and `retention: false` restoring the previous unbounded behaviour - the maps no longer grow for the process lifetime. (3) A shipped provider bridge: `costTrackerUsageDelegate(tracker, ids)` converts `withCostTracking`'s `onUsage` info (structurally - no provider import) into `record()` calls, carrying the cache legs and converting `costUsd` to a `Cost` (a zero cost records tokens without fabricating a $0 entry). Structural consumers of the exact `CostSnapshot` shape gain two required fields.
16
+
17
+ - [#160](https://github.com/o-stepper/graphorin/pull/160) [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156) Thanks [@o-stepper](https://github.com/o-stepper)! - W-094: span EVENT attributes can finally carry a sensitivity tier. `AISpan.addEvent` gains an optional third parameter (`{ sensitivity, sensitivityByAttribute }`, additive - existing implementations keep compiling), the tracer records it onto `SpanRecordEvent.sensitivityByAttribute`, and the validation exporter honours it instead of passing an empty map (which dropped EVERY event attribute under the default `'public'` floor). Out of the box, `recordException` now exports a `exception` event with a non-empty `exception.type` (the class name - safe and load-bearing for error dashboards) while `exception.message`/`exception.stacktrace` stay `'internal'`; `emitGenAIMessageEvents` marks role / `gen_ai.system` / message name / tool-call id `'public'` and keeps content `'internal'`. Untagged event attributes keep the default-deny behaviour, and `onViolation` now distinguishes event drops from span-attribute drops via `origin: 'event:<name>'`.
18
+
19
+ ### Patch Changes
20
+
21
+ - [#153](https://github.com/o-stepper/graphorin/pull/153) [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534) Thanks [@o-stepper](https://github.com/o-stepper)! - OpenTelemetry peer ranges fixed (W-014): `@opentelemetry/sdk-node` and `@opentelemetry/exporter-trace-otlp-http` peers were caret-pinned to two DIFFERENT experimental 0.x minors (`^0.217.0` and `^0.215.0`) - on 0.x a caret pins the minor, otel requires version-matched experimental packages, and the current otel line satisfied neither pin, so following the observability guide ended in a hard `npm ERESOLVE`. Both peers are now the floor range `>=0.215.0 <1.0.0` (otel ships experimental minors monthly; a caret goes stale immediately and lockstep pin-bumping would require a release per otel minor), and the devDependencies moved to one current lockstep line (`^0.220.0`, tests green on it). `@opentelemetry/api ^1.9.0` (stable 1.x) is untouched. The pack gate's otel-freshness leg installs the packed tarball together with `@latest` of both packages, so future incompatibilities surface in CI before users hit them.
22
+
23
+ - [#160](https://github.com/o-stepper/graphorin/pull/160) [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156) Thanks [@o-stepper](https://github.com/o-stepper)! - W-091: the observability guide's wiring examples now compile and run - they use the package's own `createOTLPHttpExporter` (which implements the `TraceExporter` contract) instead of passing an `@opentelemetry/exporter-trace-otlp-http` class that does not (no `id`/`flush()`, different `export` signature). The GenAI attribute table now lists what `withTracing` actually emits (`gen_ai.operation.name`, `gen_ai.provider.name`, `gen_ai.request.model`, `gen_ai.usage.input_tokens/output_tokens`); `gen_ai.request.temperature` and `gen_ai.completion.0.*` (emitted nowhere) are gone and the helper-only family (`emitGenAIAttributes`/`emitGenAIMessageEvents`, incl. `gen_ai.system`) is marked as such. The phantom `@opentelemetry/*` peer dependencies are removed - the package has zero `@opentelemetry` imports, so a consumer install no longer demands `@opentelemetry/api` it never uses (upstream OTel SDK pipelines adapt via the exported `toOtlpEnvelope`).
24
+
25
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - TSDoc `{@link}` hygiene sweep (W-130): all 55 broken links found by TypeDoc's now-enabled `validation.invalidLink` are fixed - two resolved to their real targets (`GraphorinMCPError` was misnamed `MCPError`), the rest (cross-package, `import()`-form, unexported-constant, and DOM-type references that have never rendered as hrefs) converted to plain inline code. The docs build now fails on any new broken `{@link}` via a scoped gate.
26
+
27
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Tarballs now ship `src/` so the published `dist/**/*.d.ts.map` files actually work (W-136): the maps referenced `../src/*.ts` that the `files` whitelist excluded, so go-to-definition fell back into `.d.ts` and the shipped maps were dead weight. The pack gate gains a `map-integrity` leg: every source referenced by a shipped map must resolve inside the tarball (or be embedded via `sourcesContent`), with an anti-vacuous guard - a package whose tsdown config emits declaration maps must contain a non-zero number of `.d.ts.map` files, so a cache-restored dist that silently dropped maps fails the gate instead of passing vacuously. `mvp-readiness` now requires `src` in every publishable `files` array.
28
+
29
+ - [#164](https://github.com/o-stepper/graphorin/pull/164) [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00) Thanks [@o-stepper](https://github.com/o-stepper)! - Every published package now declares its tree-shaking contract via `sideEffects` (W-137): 18 packages audited to a pure module scope get `false`, the CLI declares its bin entry (`["./dist/bin/*"]`), and `@graphorin/security` gets an explicit `true` - its secrets subsystem registers built-in resolvers and the SecretValue caller-context provider at import time, so marking it pure would let bundlers drop those registrations. `mvp-readiness` now fails any publishable manifest without a declared `sideEffects`, closing the drift for future packages.
30
+
31
+ - Updated dependencies [[`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`832f22e`](https://github.com/o-stepper/graphorin/commit/832f22e570b8c3175c1adeb4c150070cbd131534), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`4ee256e`](https://github.com/o-stepper/graphorin/commit/4ee256e30fe9190cef6c48dc6785464757707156), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`32bbd03`](https://github.com/o-stepper/graphorin/commit/32bbd03b588136a355e4b5ad6ac5e19b36b4d8ab), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`764239b`](https://github.com/o-stepper/graphorin/commit/764239b97e0e0202442e91272583f13adeb12d00), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04), [`fe98522`](https://github.com/o-stepper/graphorin/commit/fe98522ce2477c9a7dc09029f9dcfdb1f7c9aa04)]:
32
+ - @graphorin/core@0.7.0
33
+
34
+ ## 0.6.1
35
+
36
+ ### Patch Changes
37
+
38
+ - [#140](https://github.com/o-stepper/graphorin/pull/140) [`826ee59`](https://github.com/o-stepper/graphorin/commit/826ee5933ad38693b0dd2f20a110abfecba7d23d) Thanks [@o-stepper](https://github.com/o-stepper)! - Fix the default-on `graphorin-token` redaction pattern: it was hardcoded to a stale `kru_(dev|test|prod)` token shape and never matched real framework tokens, which use the `gph` default prefix with `live|test|local` environment labels (`@graphorin/security` `DEFAULT_TOKEN_PREFIX`). The pattern now matches `gph_<env>_v1_<entropy>_<crc32>` with a loose env label (operators can extend `acceptEnvironments`); deployments that configure a custom token prefix must register their own pattern.
39
+
40
+ - [#142](https://github.com/o-stepper/graphorin/pull/142) [`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430) Thanks [@o-stepper](https://github.com/o-stepper)! - Version constants and version-bearing strings now derive from each package's manifest at build time (`VERSION = pkg.version`; writer ids, client/server info, OTLP framework attributes, build-info metrics interpolate it). No behavioral change at the current version: the rendered strings are byte-identical. A release bump no longer edits source; the new `check-version-consistency` gate fails any reintroduced hardcoded framework version.
41
+
42
+ - Updated dependencies [[`436d6ca`](https://github.com/o-stepper/graphorin/commit/436d6ca5ebbd16df094e915682d3915c279a8430)]:
43
+ - @graphorin/core@0.6.1
44
+
3
45
  ## 0.6.0
4
46
 
5
47
  ### Minor Changes
package/README.md CHANGED
@@ -30,7 +30,11 @@ outbound network calls without an explicit user action.
30
30
  `emitGenAIAttributes(span, {...})`, `emitGenAIMessageEvents(span, [...])`,
31
31
  and `deriveGenAISystem(...)` ship the canonical
32
32
  `gen_ai.*` attribute family alongside the existing
33
- `graphorin.*` attributes - additive, never replacing.
33
+ `graphorin.*` attributes - additive, never replacing. Span names
34
+ follow the semconv `{operation} {target}` shape (`chat <model>`,
35
+ `execute_tool <tool>`, `invoke_agent <agent>`) via the exported
36
+ `spanNameFor(type, attrs)` helper, applied by the tracer
37
+ automatically.
34
38
  - **OpenInference span-kind layer.** `emitOpenInferenceKind(span)` emits
35
39
  the `openinference.span.kind` attribute via the canonical
36
40
  per-`SpanType` mapping (`agent.*` → `AGENT`,
@@ -47,9 +51,29 @@ outbound network calls without an explicit user action.
47
51
  requires the `canReadRaw` callback to return `true` and emits an
48
52
  audit-bridge entry on every invocation.
49
53
  - **Hierarchical `CostTracker`.** `createCostTracker({...})` rolls up
50
- tokens + cost across parent-child spans and supports per-run /
51
- per-session / per-agent / per-user budgets with an `onExceed`
52
- callback.
54
+ tokens + cost (including the prompt-cache read/write legs) across
55
+ parent-child spans and supports per-run / per-session / per-agent /
56
+ per-user budgets with an `onExceed` callback. Internal maps are
57
+ bounded by default (`retention: { maxSpanEntries, maxScopeEntries }`,
58
+ 10k each; oldest-first eviction with an `onEviction` observer -
59
+ `usage()` for an evicted id reports zero; pass `retention: false`
60
+ for the old unbounded behaviour). Feed it from the provider
61
+ middleware with the shipped bridge:
62
+
63
+ ```ts no-check
64
+ import { costTrackerUsageDelegate, createCostTracker } from '@graphorin/observability';
65
+ import { withCostTracking } from '@graphorin/provider';
66
+
67
+ const tracker = createCostTracker({ budgets: { perSession: 5 } });
68
+ const provider = withCostTracking(base, {
69
+ priceLookup: () => ({ inputPerMtok: 3, outputPerMtok: 15 }),
70
+ onUsage: costTrackerUsageDelegate(tracker, () => ({
71
+ spanId: currentSpanId(),
72
+ sessionId: currentSessionId(),
73
+ })),
74
+ });
75
+ // Later: tracker.usage('session', sessionId).cost
76
+ ```
53
77
  - **Structured logger.** `createLogger({...})` writes JSON or pretty
54
78
  records, automatically correlates with the current span via
55
79
  `withCurrentSpan(...)`, and pipes every field through the validator.
@@ -112,4 +136,4 @@ MIT © 2026 [Oleksiy Stepurenko](https://github.com/o-stepper).
112
136
 
113
137
  ---
114
138
 
115
- **Project Graphorin** · v0.6.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
139
+ **Project Graphorin** · v0.7.0 · MIT License · © 2026 Oleksiy Stepurenko · <https://github.com/o-stepper/graphorin>
@@ -1 +1 @@
1
- {"version":3,"file":"cost-tracker.d.ts","names":[],"sources":["../../src/cost/cost-tracker.ts"],"sourcesContent":[],"mappings":";;;;;;;UA2BiB,WAAA;;gBAED;;eAED,wBAAwB;;gCAEP;;;;6BAIH;;;;;;;iBAmGb,iBAAA,QAAwB,qBAA0B"}
1
+ {"version":3,"file":"cost-tracker.d.ts","names":[],"sources":["../../src/cost/cost-tracker.ts"],"sourcesContent":[],"mappings":";;;;;;;UA2BiB,WAAA;;gBAED;;eAED,wBAAwB;;gCAEP;;;;6BAIH;;;;;;;iBAiHb,iBAAA,QAAwB,qBAA0B"}
@@ -4,6 +4,8 @@ function freshBucket() {
4
4
  promptTokens: 0,
5
5
  completionTokens: 0,
6
6
  reasoningTokens: 0,
7
+ cachedReadTokens: 0,
8
+ cacheWriteTokens: 0,
7
9
  callCount: 0,
8
10
  costAmount: 0,
9
11
  costCurrency: null,
@@ -21,12 +23,16 @@ function combine(target, input) {
21
23
  target.promptTokens += input.promptTokens;
22
24
  target.completionTokens += input.completionTokens;
23
25
  target.reasoningTokens += input.reasoningTokens ?? 0;
26
+ target.cachedReadTokens += input.cachedReadTokens ?? 0;
27
+ target.cacheWriteTokens += input.cacheWriteTokens ?? 0;
24
28
  target.callCount += 1;
25
29
  if (input.cost !== void 0) addCost(target, input.cost);
26
30
  const modelBucket = target.byModel.get(input.model) ?? freshBucket();
27
31
  modelBucket.promptTokens += input.promptTokens;
28
32
  modelBucket.completionTokens += input.completionTokens;
29
33
  modelBucket.reasoningTokens += input.reasoningTokens ?? 0;
34
+ modelBucket.cachedReadTokens += input.cachedReadTokens ?? 0;
35
+ modelBucket.cacheWriteTokens += input.cacheWriteTokens ?? 0;
30
36
  modelBucket.callCount += 1;
31
37
  if (input.cost !== void 0) addCost(modelBucket, input.cost);
32
38
  target.byModel.set(input.model, modelBucket);
@@ -40,6 +46,8 @@ function snapshotOf(bucket) {
40
46
  promptTokens: bucket.promptTokens,
41
47
  completionTokens: bucket.completionTokens,
42
48
  reasoningTokens: bucket.reasoningTokens,
49
+ cachedReadTokens: bucket.cachedReadTokens,
50
+ cacheWriteTokens: bucket.cacheWriteTokens,
43
51
  totalTokens: bucket.promptTokens + bucket.completionTokens + bucket.reasoningTokens,
44
52
  callCount: bucket.callCount,
45
53
  cost,
@@ -49,6 +57,8 @@ function snapshotOf(bucket) {
49
57
  promptTokens: b.promptTokens,
50
58
  completionTokens: b.completionTokens,
51
59
  reasoningTokens: b.reasoningTokens,
60
+ cachedReadTokens: b.cachedReadTokens,
61
+ cacheWriteTokens: b.cacheWriteTokens,
52
62
  callCount: b.callCount,
53
63
  cost: b.costCurrency === null ? null : {
54
64
  amount: b.costAmount,
@@ -62,6 +72,8 @@ const ZERO = Object.freeze({
62
72
  promptTokens: 0,
63
73
  completionTokens: 0,
64
74
  reasoningTokens: 0,
75
+ cachedReadTokens: 0,
76
+ cacheWriteTokens: 0,
65
77
  totalTokens: 0,
66
78
  callCount: 0,
67
79
  cost: null,
@@ -77,6 +89,9 @@ function createCostTracker(opts = {}) {
77
89
  const budgets = opts.budgets ?? {};
78
90
  const currency = budgets.currency ?? "USD";
79
91
  const onExceed = opts.onExceed;
92
+ const retention = opts.retention;
93
+ const maxSpanEntries = retention === false ? Number.POSITIVE_INFINITY : retention?.maxSpanEntries ?? DEFAULT_MAX_SPAN_ENTRIES;
94
+ const maxScopeEntries = retention === false ? Number.POSITIVE_INFINITY : retention?.maxScopeEntries ?? DEFAULT_MAX_SCOPE_ENTRIES;
80
95
  const bySpan = /* @__PURE__ */ new Map();
81
96
  const byScope = {
82
97
  run: /* @__PURE__ */ new Map(),
@@ -86,12 +101,27 @@ function createCostTracker(opts = {}) {
86
101
  };
87
102
  const parents = /* @__PURE__ */ new Map();
88
103
  const listeners = /* @__PURE__ */ new Set();
104
+ function evictOldest(map, max, surface) {
105
+ while (map.size > max) {
106
+ const oldest = map.keys().next().value;
107
+ if (oldest === void 0) return;
108
+ map.delete(oldest);
109
+ if (surface === "span") parents.delete(oldest);
110
+ try {
111
+ opts.onEviction?.({
112
+ surface,
113
+ id: oldest
114
+ });
115
+ } catch {}
116
+ }
117
+ }
89
118
  function bump(scope, id, input) {
90
119
  if (id === void 0) return;
91
120
  const map = byScope[scope];
92
121
  const bucket = map.get(id) ?? freshBucket();
93
122
  combine(bucket, input);
94
123
  map.set(id, bucket);
124
+ evictOldest(map, maxScopeEntries, scope);
95
125
  enforceBudget(scope, id, bucket, onExceed);
96
126
  }
97
127
  function enforceBudget(scope, id, bucket, cb) {
@@ -124,6 +154,7 @@ function createCostTracker(opts = {}) {
124
154
  bySpan.set(ancestor, ancestorBucket);
125
155
  ancestor = parents.get(ancestor);
126
156
  }
157
+ evictOldest(bySpan, maxSpanEntries, "span");
127
158
  bump("run", input.runId, input);
128
159
  bump("session", input.sessionId, input);
129
160
  bump("agent", input.agentId, input);
@@ -155,6 +186,9 @@ function createCostTracker(opts = {}) {
155
186
  }
156
187
  };
157
188
  }
189
+ /** W-092: default retention caps - generous for a long-lived assistant. */
190
+ const DEFAULT_MAX_SPAN_ENTRIES = 1e4;
191
+ const DEFAULT_MAX_SCOPE_ENTRIES = 1e4;
158
192
  function pickBudget(budgets, scope) {
159
193
  switch (scope) {
160
194
  case "session": return budgets.perSession;
@@ -1 +1 @@
1
- {"version":3,"file":"cost-tracker.js","names":["cost: Cost | null","ZERO: CostSnapshot","budgets: CostBudgets","byScope: Record<CostScope, Map<string, AggregateBucket>>"],"sources":["../../src/cost/cost-tracker.ts"],"sourcesContent":["/**\n * `createCostTracker(...)` - aggregate token + cost figures across\n * spans and provide per-scope queries.\n *\n * The tracker preserves parent-child relationships: when\n * `record({ parentSpanId })` is supplied, the recorded usage is also\n * attributed to the ancestor span via the configured spans-by-id\n * lookup. Aggregations across runs / sessions / agents / users are\n * computed on demand.\n *\n * @packageDocumentation\n */\n\nimport type { Cost } from '@graphorin/core';\n\nimport type {\n CostBudgetExceededCallback,\n CostBudgets,\n CostRecordInput,\n CostScope,\n CostSnapshot,\n CostTrackerOptions,\n} from './types.js';\n\n/**\n * @stable\n */\nexport interface CostTracker {\n /** Record a single LLM-call usage / cost figure. */\n record(input: CostRecordInput): void;\n /** Snapshot for a given scope id. Returns zero figures when unknown. */\n usage(scope: CostScope, id: string): CostSnapshot;\n /** Snapshot for a single span id (carries nested attributions). */\n usageForSpan(spanId: string): CostSnapshot;\n /** Reset every counter back to zero. */\n reset(): void;\n /** Subscribe to per-scope rollup notifications. Returns an unsubscribe. */\n onRollup(listener: (input: CostRecordInput) => void): () => void;\n}\n\ninterface AggregateBucket {\n promptTokens: number;\n completionTokens: number;\n reasoningTokens: number;\n callCount: number;\n costAmount: number;\n costCurrency: string | null;\n /** RP-22: set when records carrying differing currencies are aggregated. */\n mixedCurrency: boolean;\n byModel: Map<string, AggregateBucket>;\n exceeded: boolean;\n}\n\nfunction freshBucket(): AggregateBucket {\n return {\n promptTokens: 0,\n completionTokens: 0,\n reasoningTokens: 0,\n callCount: 0,\n costAmount: 0,\n costCurrency: null,\n mixedCurrency: false,\n byModel: new Map(),\n exceeded: false,\n };\n}\n\n// RP-22: accumulate a cost without silently overwriting the currency. The\n// first currency seen is kept; a record in a different currency flags the\n// bucket as `mixedCurrency` so a USD + EUR total is never reported as one\n// clean figure.\nfunction addCost(bucket: AggregateBucket, cost: Cost): void {\n if (bucket.costCurrency !== null && bucket.costCurrency !== cost.currency) {\n bucket.mixedCurrency = true;\n } else if (bucket.costCurrency === null) {\n bucket.costCurrency = cost.currency;\n }\n bucket.costAmount += cost.amount;\n}\n\nfunction combine(target: AggregateBucket, input: CostRecordInput): void {\n target.promptTokens += input.promptTokens;\n target.completionTokens += input.completionTokens;\n target.reasoningTokens += input.reasoningTokens ?? 0;\n target.callCount += 1;\n if (input.cost !== undefined) addCost(target, input.cost);\n const modelBucket = target.byModel.get(input.model) ?? freshBucket();\n modelBucket.promptTokens += input.promptTokens;\n modelBucket.completionTokens += input.completionTokens;\n modelBucket.reasoningTokens += input.reasoningTokens ?? 0;\n modelBucket.callCount += 1;\n if (input.cost !== undefined) addCost(modelBucket, input.cost);\n target.byModel.set(input.model, modelBucket);\n}\n\nfunction snapshotOf(bucket: AggregateBucket): CostSnapshot {\n const cost: Cost | null =\n bucket.costCurrency === null\n ? null\n : { amount: bucket.costAmount, currency: bucket.costCurrency };\n return {\n promptTokens: bucket.promptTokens,\n completionTokens: bucket.completionTokens,\n reasoningTokens: bucket.reasoningTokens,\n totalTokens: bucket.promptTokens + bucket.completionTokens + bucket.reasoningTokens,\n callCount: bucket.callCount,\n cost,\n mixedCurrency: bucket.mixedCurrency,\n byModel: [...bucket.byModel.entries()].map(([model, b]) => ({\n model,\n promptTokens: b.promptTokens,\n completionTokens: b.completionTokens,\n reasoningTokens: b.reasoningTokens,\n callCount: b.callCount,\n cost: b.costCurrency === null ? null : { amount: b.costAmount, currency: b.costCurrency },\n mixedCurrency: b.mixedCurrency,\n })),\n };\n}\n\nconst ZERO: CostSnapshot = Object.freeze({\n promptTokens: 0,\n completionTokens: 0,\n reasoningTokens: 0,\n totalTokens: 0,\n callCount: 0,\n cost: null,\n mixedCurrency: false,\n byModel: [],\n});\n\n/**\n * Build a {@link CostTracker} configured with the supplied budgets.\n *\n * @stable\n */\nexport function createCostTracker(opts: CostTrackerOptions = {}): CostTracker {\n const budgets: CostBudgets = opts.budgets ?? {};\n const currency = budgets.currency ?? 'USD';\n const onExceed = opts.onExceed;\n\n const bySpan = new Map<string, AggregateBucket>();\n const byScope: Record<CostScope, Map<string, AggregateBucket>> = {\n run: new Map(),\n session: new Map(),\n agent: new Map(),\n user: new Map(),\n };\n\n // Parent index: child span -> parent span. Used to roll attributions\n // up the ancestor chain on every record call.\n const parents = new Map<string, string>();\n const listeners = new Set<(input: CostRecordInput) => void>();\n\n function bump(scope: CostScope, id: string | undefined, input: CostRecordInput): void {\n if (id === undefined) return;\n const map = byScope[scope];\n const bucket = map.get(id) ?? freshBucket();\n combine(bucket, input);\n map.set(id, bucket);\n enforceBudget(scope, id, bucket, onExceed);\n }\n\n function enforceBudget(\n scope: CostScope,\n id: string,\n bucket: AggregateBucket,\n cb?: CostBudgetExceededCallback,\n ): void {\n const limit = pickBudget(budgets, scope);\n if (limit === undefined) return;\n if (bucket.exceeded) return;\n if (bucket.costAmount > limit) {\n bucket.exceeded = true;\n cb?.({\n scope,\n id,\n budget: limit,\n actual: bucket.costAmount,\n currency: bucket.costCurrency ?? currency,\n });\n }\n }\n\n return {\n record(input: CostRecordInput): void {\n // Record the raw span figures.\n const spanBucket = bySpan.get(input.spanId) ?? freshBucket();\n combine(spanBucket, input);\n bySpan.set(input.spanId, spanBucket);\n\n // Walk the ancestor chain so that the figures roll up to every\n // parent span. The walker breaks if it encounters a cycle.\n if (input.parentSpanId !== undefined) {\n parents.set(input.spanId, input.parentSpanId);\n }\n const seen = new Set<string>();\n let ancestor = parents.get(input.spanId);\n while (ancestor !== undefined && !seen.has(ancestor)) {\n seen.add(ancestor);\n const ancestorBucket = bySpan.get(ancestor) ?? freshBucket();\n combine(ancestorBucket, input);\n bySpan.set(ancestor, ancestorBucket);\n ancestor = parents.get(ancestor);\n }\n\n // Roll up to the requested aggregation scopes.\n bump('run', input.runId, input);\n bump('session', input.sessionId, input);\n bump('agent', input.agentId, input);\n bump('user', input.userId, input);\n\n for (const listener of listeners) {\n try {\n listener(input);\n } catch {\n // Listeners must never break the tracker.\n }\n }\n },\n usage(scope: CostScope, id: string): CostSnapshot {\n const bucket = byScope[scope].get(id);\n if (bucket === undefined) return ZERO;\n return snapshotOf(bucket);\n },\n usageForSpan(spanId: string): CostSnapshot {\n const bucket = bySpan.get(spanId);\n if (bucket === undefined) return ZERO;\n return snapshotOf(bucket);\n },\n reset(): void {\n bySpan.clear();\n for (const map of Object.values(byScope)) {\n map.clear();\n }\n parents.clear();\n },\n onRollup(listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\nfunction pickBudget(budgets: CostBudgets, scope: CostScope): number | undefined {\n switch (scope) {\n case 'session':\n return budgets.perSession;\n case 'user':\n return budgets.perUser;\n case 'agent':\n return budgets.perAgent;\n case 'run':\n return budgets.perRun;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"cost-tracker.js","names":["cost: Cost | null","ZERO: CostSnapshot","budgets: CostBudgets","byScope: Record<CostScope, Map<string, AggregateBucket>>"],"sources":["../../src/cost/cost-tracker.ts"],"sourcesContent":["/**\n * `createCostTracker(...)` - aggregate token + cost figures across\n * spans and provide per-scope queries.\n *\n * The tracker preserves parent-child relationships: when\n * `record({ parentSpanId })` is supplied, the recorded usage is also\n * attributed to the ancestor span via the configured spans-by-id\n * lookup. Aggregations across runs / sessions / agents / users are\n * computed on demand.\n *\n * @packageDocumentation\n */\n\nimport type { Cost } from '@graphorin/core';\n\nimport type {\n CostBudgetExceededCallback,\n CostBudgets,\n CostRecordInput,\n CostScope,\n CostSnapshot,\n CostTrackerOptions,\n} from './types.js';\n\n/**\n * @stable\n */\nexport interface CostTracker {\n /** Record a single LLM-call usage / cost figure. */\n record(input: CostRecordInput): void;\n /** Snapshot for a given scope id. Returns zero figures when unknown. */\n usage(scope: CostScope, id: string): CostSnapshot;\n /** Snapshot for a single span id (carries nested attributions). */\n usageForSpan(spanId: string): CostSnapshot;\n /** Reset every counter back to zero. */\n reset(): void;\n /** Subscribe to per-scope rollup notifications. Returns an unsubscribe. */\n onRollup(listener: (input: CostRecordInput) => void): () => void;\n}\n\ninterface AggregateBucket {\n promptTokens: number;\n completionTokens: number;\n reasoningTokens: number;\n cachedReadTokens: number;\n cacheWriteTokens: number;\n callCount: number;\n costAmount: number;\n costCurrency: string | null;\n /** RP-22: set when records carrying differing currencies are aggregated. */\n mixedCurrency: boolean;\n byModel: Map<string, AggregateBucket>;\n exceeded: boolean;\n}\n\nfunction freshBucket(): AggregateBucket {\n return {\n promptTokens: 0,\n completionTokens: 0,\n reasoningTokens: 0,\n cachedReadTokens: 0,\n cacheWriteTokens: 0,\n callCount: 0,\n costAmount: 0,\n costCurrency: null,\n mixedCurrency: false,\n byModel: new Map(),\n exceeded: false,\n };\n}\n\n// RP-22: accumulate a cost without silently overwriting the currency. The\n// first currency seen is kept; a record in a different currency flags the\n// bucket as `mixedCurrency` so a USD + EUR total is never reported as one\n// clean figure.\nfunction addCost(bucket: AggregateBucket, cost: Cost): void {\n if (bucket.costCurrency !== null && bucket.costCurrency !== cost.currency) {\n bucket.mixedCurrency = true;\n } else if (bucket.costCurrency === null) {\n bucket.costCurrency = cost.currency;\n }\n bucket.costAmount += cost.amount;\n}\n\nfunction combine(target: AggregateBucket, input: CostRecordInput): void {\n target.promptTokens += input.promptTokens;\n target.completionTokens += input.completionTokens;\n target.reasoningTokens += input.reasoningTokens ?? 0;\n target.cachedReadTokens += input.cachedReadTokens ?? 0;\n target.cacheWriteTokens += input.cacheWriteTokens ?? 0;\n target.callCount += 1;\n if (input.cost !== undefined) addCost(target, input.cost);\n const modelBucket = target.byModel.get(input.model) ?? freshBucket();\n modelBucket.promptTokens += input.promptTokens;\n modelBucket.completionTokens += input.completionTokens;\n modelBucket.reasoningTokens += input.reasoningTokens ?? 0;\n modelBucket.cachedReadTokens += input.cachedReadTokens ?? 0;\n modelBucket.cacheWriteTokens += input.cacheWriteTokens ?? 0;\n modelBucket.callCount += 1;\n if (input.cost !== undefined) addCost(modelBucket, input.cost);\n target.byModel.set(input.model, modelBucket);\n}\n\nfunction snapshotOf(bucket: AggregateBucket): CostSnapshot {\n const cost: Cost | null =\n bucket.costCurrency === null\n ? null\n : { amount: bucket.costAmount, currency: bucket.costCurrency };\n return {\n promptTokens: bucket.promptTokens,\n completionTokens: bucket.completionTokens,\n reasoningTokens: bucket.reasoningTokens,\n cachedReadTokens: bucket.cachedReadTokens,\n cacheWriteTokens: bucket.cacheWriteTokens,\n totalTokens: bucket.promptTokens + bucket.completionTokens + bucket.reasoningTokens,\n callCount: bucket.callCount,\n cost,\n mixedCurrency: bucket.mixedCurrency,\n byModel: [...bucket.byModel.entries()].map(([model, b]) => ({\n model,\n promptTokens: b.promptTokens,\n completionTokens: b.completionTokens,\n reasoningTokens: b.reasoningTokens,\n cachedReadTokens: b.cachedReadTokens,\n cacheWriteTokens: b.cacheWriteTokens,\n callCount: b.callCount,\n cost: b.costCurrency === null ? null : { amount: b.costAmount, currency: b.costCurrency },\n mixedCurrency: b.mixedCurrency,\n })),\n };\n}\n\nconst ZERO: CostSnapshot = Object.freeze({\n promptTokens: 0,\n completionTokens: 0,\n reasoningTokens: 0,\n cachedReadTokens: 0,\n cacheWriteTokens: 0,\n totalTokens: 0,\n callCount: 0,\n cost: null,\n mixedCurrency: false,\n byModel: [],\n});\n\n/**\n * Build a {@link CostTracker} configured with the supplied budgets.\n *\n * @stable\n */\nexport function createCostTracker(opts: CostTrackerOptions = {}): CostTracker {\n const budgets: CostBudgets = opts.budgets ?? {};\n const currency = budgets.currency ?? 'USD';\n const onExceed = opts.onExceed;\n // W-092: memory bounds - the tracker lives for the process lifetime,\n // so unbounded maps are a leak in the long-running-assistant scenario.\n const retention = opts.retention;\n const maxSpanEntries =\n retention === false\n ? Number.POSITIVE_INFINITY\n : (retention?.maxSpanEntries ?? DEFAULT_MAX_SPAN_ENTRIES);\n const maxScopeEntries =\n retention === false\n ? Number.POSITIVE_INFINITY\n : (retention?.maxScopeEntries ?? DEFAULT_MAX_SCOPE_ENTRIES);\n\n const bySpan = new Map<string, AggregateBucket>();\n const byScope: Record<CostScope, Map<string, AggregateBucket>> = {\n run: new Map(),\n session: new Map(),\n agent: new Map(),\n user: new Map(),\n };\n\n // Parent index: child span -> parent span. Used to roll attributions\n // up the ancestor chain on every record call.\n const parents = new Map<string, string>();\n const listeners = new Set<(input: CostRecordInput) => void>();\n\n // W-092: evict OLDEST entries (Map insertion order - a re-recorded\n // key keeps its original position on purpose: this is an age bound,\n // not an LRU) once a map exceeds its cap. Evicting a span also drops\n // its parent edge so `parents` cannot outgrow `bySpan`.\n function evictOldest(\n map: Map<string, AggregateBucket>,\n max: number,\n surface: 'span' | CostScope,\n ): void {\n while (map.size > max) {\n const oldest = map.keys().next().value;\n if (oldest === undefined) return;\n map.delete(oldest);\n if (surface === 'span') parents.delete(oldest);\n try {\n opts.onEviction?.({ surface, id: oldest });\n } catch {\n // Observers must never break the tracker.\n }\n }\n }\n\n function bump(scope: CostScope, id: string | undefined, input: CostRecordInput): void {\n if (id === undefined) return;\n const map = byScope[scope];\n const bucket = map.get(id) ?? freshBucket();\n combine(bucket, input);\n map.set(id, bucket);\n evictOldest(map, maxScopeEntries, scope);\n enforceBudget(scope, id, bucket, onExceed);\n }\n\n function enforceBudget(\n scope: CostScope,\n id: string,\n bucket: AggregateBucket,\n cb?: CostBudgetExceededCallback,\n ): void {\n const limit = pickBudget(budgets, scope);\n if (limit === undefined) return;\n if (bucket.exceeded) return;\n if (bucket.costAmount > limit) {\n bucket.exceeded = true;\n cb?.({\n scope,\n id,\n budget: limit,\n actual: bucket.costAmount,\n currency: bucket.costCurrency ?? currency,\n });\n }\n }\n\n return {\n record(input: CostRecordInput): void {\n // Record the raw span figures.\n const spanBucket = bySpan.get(input.spanId) ?? freshBucket();\n combine(spanBucket, input);\n bySpan.set(input.spanId, spanBucket);\n\n // Walk the ancestor chain so that the figures roll up to every\n // parent span. The walker breaks if it encounters a cycle.\n if (input.parentSpanId !== undefined) {\n parents.set(input.spanId, input.parentSpanId);\n }\n const seen = new Set<string>();\n let ancestor = parents.get(input.spanId);\n while (ancestor !== undefined && !seen.has(ancestor)) {\n seen.add(ancestor);\n const ancestorBucket = bySpan.get(ancestor) ?? freshBucket();\n combine(ancestorBucket, input);\n bySpan.set(ancestor, ancestorBucket);\n ancestor = parents.get(ancestor);\n }\n // W-092: bound the span map AFTER the rollup walk so the entries\n // this record touched are all present before age eviction runs.\n evictOldest(bySpan, maxSpanEntries, 'span');\n\n // Roll up to the requested aggregation scopes.\n bump('run', input.runId, input);\n bump('session', input.sessionId, input);\n bump('agent', input.agentId, input);\n bump('user', input.userId, input);\n\n for (const listener of listeners) {\n try {\n listener(input);\n } catch {\n // Listeners must never break the tracker.\n }\n }\n },\n // W-092: an id evicted by the retention bound reports ZERO - same\n // as never-seen. Pass `retention: false` for the old unbounded maps.\n usage(scope: CostScope, id: string): CostSnapshot {\n const bucket = byScope[scope].get(id);\n if (bucket === undefined) return ZERO;\n return snapshotOf(bucket);\n },\n usageForSpan(spanId: string): CostSnapshot {\n const bucket = bySpan.get(spanId);\n if (bucket === undefined) return ZERO;\n return snapshotOf(bucket);\n },\n reset(): void {\n bySpan.clear();\n for (const map of Object.values(byScope)) {\n map.clear();\n }\n parents.clear();\n },\n onRollup(listener): () => void {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n };\n}\n\n/** W-092: default retention caps - generous for a long-lived assistant. */\nconst DEFAULT_MAX_SPAN_ENTRIES = 10_000;\nconst DEFAULT_MAX_SCOPE_ENTRIES = 10_000;\n\nfunction pickBudget(budgets: CostBudgets, scope: CostScope): number | undefined {\n switch (scope) {\n case 'session':\n return budgets.perSession;\n case 'user':\n return budgets.perUser;\n case 'agent':\n return budgets.perAgent;\n case 'run':\n return budgets.perRun;\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,40 @@
1
+ import { CostRecordInput } from "./types.js";
2
+ import { CostTracker } from "./cost-tracker.js";
3
+
4
+ //#region src/cost/delegate.d.ts
5
+
6
+ /**
7
+ * Structural mirror of the info object `withCostTracking`'s `onUsage`
8
+ * hook receives (no provider dependency).
9
+ *
10
+ * @stable
11
+ */
12
+ interface ProviderUsageInfoLike {
13
+ readonly modelId: string;
14
+ readonly promptTokens: number;
15
+ readonly completionTokens: number;
16
+ readonly cachedReadTokens?: number;
17
+ readonly cacheWriteTokens?: number;
18
+ readonly costUsd: number;
19
+ }
20
+ /**
21
+ * Attribution ids for one recorded call. `spanId` is mandatory (the
22
+ * tracker keys rollups on it); everything else is optional scope
23
+ * attribution.
24
+ *
25
+ * @stable
26
+ */
27
+ type CostTrackerDelegateIds = Pick<CostRecordInput, 'spanId' | 'parentSpanId' | 'runId' | 'sessionId' | 'agentId' | 'userId'>;
28
+ /**
29
+ * Build an `onUsage` callback that records into `tracker`. Pass either
30
+ * static ids (a provider instance bound to one session) or a resolver
31
+ * invoked per call (a shared provider serving many runs). A zero
32
+ * `costUsd` records token figures WITHOUT a cost so a price-less
33
+ * middleware does not fabricate a $0 USD cost entry.
34
+ *
35
+ * @stable
36
+ */
37
+ declare function costTrackerUsageDelegate(tracker: Pick<CostTracker, 'record'>, ids: CostTrackerDelegateIds | ((info: ProviderUsageInfoLike) => CostTrackerDelegateIds)): (info: ProviderUsageInfoLike) => void;
38
+ //#endregion
39
+ export { CostTrackerDelegateIds, ProviderUsageInfoLike, costTrackerUsageDelegate };
40
+ //# sourceMappingURL=delegate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delegate.d.ts","names":[],"sources":["../../src/cost/delegate.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;UAgCiB,qBAAA;;;;;;;;;;;;;;;KAgBL,sBAAA,GAAyB,KACnC;;;;;;;;;;iBAac,wBAAA,UACL,KAAK,6BACT,iCAAiC,0BAA0B,iCACxD"}
@@ -0,0 +1,31 @@
1
+ //#region src/cost/delegate.ts
2
+ /**
3
+ * Build an `onUsage` callback that records into `tracker`. Pass either
4
+ * static ids (a provider instance bound to one session) or a resolver
5
+ * invoked per call (a shared provider serving many runs). A zero
6
+ * `costUsd` records token figures WITHOUT a cost so a price-less
7
+ * middleware does not fabricate a $0 USD cost entry.
8
+ *
9
+ * @stable
10
+ */
11
+ function costTrackerUsageDelegate(tracker, ids) {
12
+ return (info) => {
13
+ const resolved = typeof ids === "function" ? ids(info) : ids;
14
+ tracker.record({
15
+ model: info.modelId,
16
+ promptTokens: info.promptTokens,
17
+ completionTokens: info.completionTokens,
18
+ ...info.cachedReadTokens !== void 0 ? { cachedReadTokens: info.cachedReadTokens } : {},
19
+ ...info.cacheWriteTokens !== void 0 ? { cacheWriteTokens: info.cacheWriteTokens } : {},
20
+ ...info.costUsd > 0 ? { cost: {
21
+ amount: info.costUsd,
22
+ currency: "USD"
23
+ } } : {},
24
+ ...resolved
25
+ });
26
+ };
27
+ }
28
+
29
+ //#endregion
30
+ export { costTrackerUsageDelegate };
31
+ //# sourceMappingURL=delegate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"delegate.js","names":[],"sources":["../../src/cost/delegate.ts"],"sourcesContent":["/**\n * W-092: the documented-but-missing bridge between the provider\n * middleware `withCostTracking` and a {@link CostTracker}. The\n * middleware's `onUsage` hook carries per-call figures (incl. the\n * prompt-cache legs and a computed `costUsd`); this adapter converts\n * them into a {@link CostRecordInput} and records it - STRUCTURALLY,\n * with no `@graphorin/provider` import, so the observability package\n * keeps its dependency profile.\n *\n * ```ts\n * const tracker = createCostTracker({ budgets: { perSession: 5 } });\n * const provider = withCostTracking(base, {\n * priceLookup,\n * onUsage: costTrackerUsageDelegate(tracker, () => ({\n * spanId: currentSpanId(),\n * sessionId: currentSessionId(),\n * })),\n * });\n * ```\n *\n * @packageDocumentation\n */\n\nimport type { CostTracker } from './cost-tracker.js';\nimport type { CostRecordInput } from './types.js';\n\n/**\n * Structural mirror of the info object `withCostTracking`'s `onUsage`\n * hook receives (no provider dependency).\n *\n * @stable\n */\nexport interface ProviderUsageInfoLike {\n readonly modelId: string;\n readonly promptTokens: number;\n readonly completionTokens: number;\n readonly cachedReadTokens?: number;\n readonly cacheWriteTokens?: number;\n readonly costUsd: number;\n}\n\n/**\n * Attribution ids for one recorded call. `spanId` is mandatory (the\n * tracker keys rollups on it); everything else is optional scope\n * attribution.\n *\n * @stable\n */\nexport type CostTrackerDelegateIds = Pick<\n CostRecordInput,\n 'spanId' | 'parentSpanId' | 'runId' | 'sessionId' | 'agentId' | 'userId'\n>;\n\n/**\n * Build an `onUsage` callback that records into `tracker`. Pass either\n * static ids (a provider instance bound to one session) or a resolver\n * invoked per call (a shared provider serving many runs). A zero\n * `costUsd` records token figures WITHOUT a cost so a price-less\n * middleware does not fabricate a $0 USD cost entry.\n *\n * @stable\n */\nexport function costTrackerUsageDelegate(\n tracker: Pick<CostTracker, 'record'>,\n ids: CostTrackerDelegateIds | ((info: ProviderUsageInfoLike) => CostTrackerDelegateIds),\n): (info: ProviderUsageInfoLike) => void {\n return (info) => {\n const resolved = typeof ids === 'function' ? ids(info) : ids;\n tracker.record({\n model: info.modelId,\n promptTokens: info.promptTokens,\n completionTokens: info.completionTokens,\n ...(info.cachedReadTokens !== undefined ? { cachedReadTokens: info.cachedReadTokens } : {}),\n ...(info.cacheWriteTokens !== undefined ? { cacheWriteTokens: info.cacheWriteTokens } : {}),\n ...(info.costUsd > 0 ? { cost: { amount: info.costUsd, currency: 'USD' } } : {}),\n ...resolved,\n });\n };\n}\n"],"mappings":";;;;;;;;;;AA8DA,SAAgB,yBACd,SACA,KACuC;AACvC,SAAQ,SAAS;EACf,MAAM,WAAW,OAAO,QAAQ,aAAa,IAAI,KAAK,GAAG;AACzD,UAAQ,OAAO;GACb,OAAO,KAAK;GACZ,cAAc,KAAK;GACnB,kBAAkB,KAAK;GACvB,GAAI,KAAK,qBAAqB,SAAY,EAAE,kBAAkB,KAAK,kBAAkB,GAAG,EAAE;GAC1F,GAAI,KAAK,qBAAqB,SAAY,EAAE,kBAAkB,KAAK,kBAAkB,GAAG,EAAE;GAC1F,GAAI,KAAK,UAAU,IAAI,EAAE,MAAM;IAAE,QAAQ,KAAK;IAAS,UAAU;IAAO,EAAE,GAAG,EAAE;GAC/E,GAAG;GACJ,CAAC"}
@@ -1,3 +1,4 @@
1
1
  import { CostBudgetExceededCallback, CostBudgets, CostRecordInput, CostScope, CostSnapshot, CostTrackerOptions } from "./types.js";
2
2
  import { CostTracker, createCostTracker } from "./cost-tracker.js";
3
- export { type CostBudgetExceededCallback, type CostBudgets, type CostRecordInput, type CostScope, type CostSnapshot, type CostTracker, type CostTrackerOptions, createCostTracker };
3
+ import { CostTrackerDelegateIds, ProviderUsageInfoLike, costTrackerUsageDelegate } from "./delegate.js";
4
+ export { type CostBudgetExceededCallback, type CostBudgets, type CostRecordInput, type CostScope, type CostSnapshot, type CostTracker, type CostTrackerDelegateIds, type CostTrackerOptions, type ProviderUsageInfoLike, costTrackerUsageDelegate, createCostTracker };
@@ -1,3 +1,4 @@
1
1
  import { createCostTracker } from "./cost-tracker.js";
2
+ import { costTrackerUsageDelegate } from "./delegate.js";
2
3
 
3
- export { createCostTracker };
4
+ export { costTrackerUsageDelegate, createCostTracker };
@@ -21,6 +21,10 @@ interface CostRecordInput {
21
21
  readonly promptTokens: number;
22
22
  readonly completionTokens: number;
23
23
  readonly reasoningTokens?: number;
24
+ /** Prompt-cache READ leg (W-092; name mirrors core `Usage`). */
25
+ readonly cachedReadTokens?: number;
26
+ /** Prompt-cache WRITE leg (W-092; name mirrors core `Usage`). */
27
+ readonly cacheWriteTokens?: number;
24
28
  readonly cost?: Cost;
25
29
  /** Span id; used to thread parent-child rollups. */
26
30
  readonly spanId: string;
@@ -41,7 +45,16 @@ interface CostSnapshot {
41
45
  readonly promptTokens: number;
42
46
  readonly completionTokens: number;
43
47
  readonly reasoningTokens: number;
48
+ /** W-092: aggregated prompt-cache READ tokens (0 when never recorded). */
49
+ readonly cachedReadTokens: number;
50
+ /** W-092: aggregated prompt-cache WRITE tokens (0 when never recorded). */
51
+ readonly cacheWriteTokens: number;
44
52
  readonly callCount: number;
53
+ /**
54
+ * Aggregated cost in WHOLE currency units (W-045; for USD - dollars,
55
+ * fractional values expected). Same convention as core `Cost.amount`
56
+ * and `@graphorin/pricing.calculateCost` - never minor units.
57
+ */
45
58
  readonly cost: Cost | null;
46
59
  /**
47
60
  * RP-22: `true` when records carrying differing currencies were aggregated
@@ -54,6 +67,10 @@ interface CostSnapshot {
54
67
  readonly promptTokens: number;
55
68
  readonly completionTokens: number;
56
69
  readonly reasoningTokens: number;
70
+ /** W-092: per-model prompt-cache READ tokens. */
71
+ readonly cachedReadTokens: number;
72
+ /** W-092: per-model prompt-cache WRITE tokens. */
73
+ readonly cacheWriteTokens: number;
57
74
  readonly callCount: number;
58
75
  readonly cost: Cost | null;
59
76
  readonly mixedCurrency: boolean;
@@ -98,6 +115,28 @@ type CostBudgetExceededCallback = (event: {
98
115
  interface CostTrackerOptions {
99
116
  readonly budgets?: CostBudgets;
100
117
  readonly onExceed?: CostBudgetExceededCallback;
118
+ /**
119
+ * W-092: memory bound for the tracker's internal maps. The tracker
120
+ * aggregates for the LIFETIME of the process - exactly the
121
+ * long-running-assistant scenario - so unbounded per-span / per-scope
122
+ * maps are a leak. When an insertion pushes a map past its limit the
123
+ * OLDEST entries (insertion order, not LRU) are evicted and
124
+ * `onEviction` fires per dropped id; `usage()` / `usageForSpan()` for
125
+ * an evicted id then report zero figures, and a late rollup to an
126
+ * evicted ancestor re-creates it from zero. Defaults to
127
+ * `{ maxSpanEntries: 10_000, maxScopeEntries: 10_000 }`; pass `false`
128
+ * to restore the previous unbounded behaviour.
129
+ */
130
+ readonly retention?: {
131
+ readonly maxSpanEntries?: number;
132
+ /** Per scope-kind cap (each of run/session/agent/user maps). */
133
+ readonly maxScopeEntries?: number;
134
+ } | false;
135
+ /** W-092: observer for retention evictions (dashboards / warnings). */
136
+ readonly onEviction?: (event: {
137
+ readonly surface: 'span' | CostScope;
138
+ readonly id: string;
139
+ }) => void;
101
140
  }
102
141
  //#endregion
103
142
  export { CostBudgetExceededCallback, CostBudgets, CostRecordInput, CostScope, CostSnapshot, CostTrackerOptions };
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../src/cost/types.ts"],"sourcesContent":[],"mappings":";;;;;;;;AAyEA;AAoBA;AAaA;;KA1FY,SAAA;;;;;;UAOK,eAAA;;;;;kBAKC;;;;;;;;;;;;;;;UAgBD,YAAA;;;;;;iBAMA;;;;;;;oBAOG;;;;;;mBAMD;;;;;;;;;UAUF,WAAA;;;;;;;;;;;;;;;;;;;KAoBL,0BAAA;kBACM;;;;;;;;;;;UAYD,kBAAA;qBACI;sBACC"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../src/cost/types.ts"],"sourcesContent":[],"mappings":";;;;;;;;AA0FA;AAoBA;AAaA;;AAEsB,KA7GV,SAAA,GA6GU,KAAA,GAAA,SAAA,GAAA,OAAA,GAAA,MAAA;;;;;;UAtGL,eAAA;;;;;;;;;kBASC;;;;;;;;;;;;;;;UAgBD,YAAA;;;;;;;;;;;;;;;iBAeA;;;;;;;oBAOG;;;;;;;;;;mBAUD;;;;;;;;;UAUF,WAAA;;;;;;;;;;;;;;;;;;;KAoBL,0BAAA;kBACM;;;;;;;;;;;UAYD,kBAAA;qBACI;sBACC;;;;;;;;;;;;;;;;;;;;+BAsBS"}
@@ -1 +1 @@
1
- {"version":3,"file":"otlp-http.d.ts","names":[],"sources":["../../src/exporters/otlp-http.ts"],"sourcesContent":[],"mappings":";;;;;;;;;UA4BiB,uBAAA;;;;;;qBAMI,SAAS;;;;8BAIA;;;;;;;;;;;;;;;iBAgBd,sBAAA,OAA6B,0BAA0B"}
1
+ {"version":3,"file":"otlp-http.d.ts","names":[],"sources":["../../src/exporters/otlp-http.ts"],"sourcesContent":[],"mappings":";;;;;;AA6BA;;;AAU8B,UAVb,uBAAA,CAUa;EAAK;EAgBnB,SAAA,EAAA,CAAA,EAAA,MAAA;;;;qBApBK,SAAS;;;;8BAIA;;;;;;;;;;;;;;;iBAgBd,sBAAA,OAA6B,0BAA0B"}
@@ -1,3 +1,5 @@
1
+ import { version } from "../package.js";
2
+
1
3
  //#region src/exporters/otlp-http.ts
2
4
  /**
3
5
  * Build a minimal OTLP-over-HTTP trace exporter. Call
@@ -58,12 +60,12 @@ function toOtlpEnvelope(record, serviceName) {
58
60
  value: { stringValue: serviceName }
59
61
  }, {
60
62
  key: "graphorin.framework.version",
61
- value: { stringValue: "0.6.0" }
63
+ value: { stringValue: version }
62
64
  }] },
63
65
  scopeSpans: [{
64
66
  scope: {
65
67
  name: "@graphorin/observability",
66
- version: "0.6.0"
68
+ version
67
69
  },
68
70
  spans: [{
69
71
  traceId: record.traceId,
@@ -1 +1 @@
1
- {"version":3,"file":"otlp-http.js","names":[],"sources":["../../src/exporters/otlp-http.ts"],"sourcesContent":["/**\n * `OTLPHttpExporter` - reference implementation that POSTs span\n * records to an OpenTelemetry HTTP endpoint.\n *\n * The exporter is deliberately minimal: production hardening (retry\n * logic, batching policy tuning, gzip compression, bearer-token auth\n * rotation) is left to operators. The wire format follows the OTLP\n * `traces` resource shape and is compatible with collectors that accept\n * the `application/json` content type.\n *\n * Operators wanting the upstream OpenTelemetry SDK exporter\n * (`@opentelemetry/exporter-trace-otlp-http`) can adapt\n * {@link toOtlpEnvelope} into their own wrapper - the helper is\n * exported so the OTel adapter does not need to duplicate the wire\n * shape.\n *\n * @packageDocumentation\n */\n\nimport type { SpanAttributes, SpanAttributeValue, SpanType } from '@graphorin/core';\n\nimport type { SpanRecord, SpanRecordEvent, TraceExporter } from './types.js';\n\n/**\n * Configuration shape for {@link createOTLPHttpExporter}.\n *\n * @stable\n */\nexport interface OTLPHttpExporterOptions {\n /** Identifier reported via `exporter.id`. Defaults to `'otlp-http'`. */\n readonly id?: string;\n /** OTLP collector URL (e.g. `http://localhost:4318/v1/traces`). */\n readonly url: string;\n /** Optional headers (auth, tenant id, …). Avoid passing secrets in plain. */\n readonly headers?: Readonly<Record<string, string>>;\n /** Service name embedded in the OTLP `Resource`. */\n readonly serviceName?: string;\n /** Optional `fetch` override - useful for testing without the network. */\n readonly fetchImpl?: typeof fetch;\n /** Optional timeout (ms). Defaults to 10000. */\n readonly timeoutMs?: number;\n}\n\n/**\n * Build a minimal OTLP-over-HTTP trace exporter. Call\n * `withValidation(exporter)` before passing the result to\n * `createTracer({ exporters })`.\n *\n * The implementation issues one `POST` per finished span - no\n * batching. Operators wanting batching should wrap this exporter\n * with their own queue or use a sidecar collector.\n *\n * @stable\n */\nexport function createOTLPHttpExporter(opts: OTLPHttpExporterOptions): TraceExporter {\n const id = opts.id ?? 'otlp-http';\n const url = opts.url;\n const headers = opts.headers ?? {};\n const serviceName = opts.serviceName ?? 'graphorin';\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n let closed = false;\n\n if (typeof fetchImpl !== 'function') {\n throw new TypeError(\n 'createOTLPHttpExporter: no fetch implementation available. Provide ' +\n '`fetchImpl` explicitly or run on a Node.js version with global fetch ' +\n '(>= 18).',\n );\n }\n\n return {\n id,\n async export(record: SpanRecord): Promise<void> {\n if (closed) return;\n const body = toOtlpEnvelope(record, serviceName);\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetchImpl(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...headers },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n if (!res.ok) {\n throw new Error(`OTLP exporter received HTTP ${res.status} ${res.statusText}`);\n }\n } finally {\n clearTimeout(timer);\n }\n },\n async flush(): Promise<void> {\n // No internal queue; each export() awaits the POST already.\n },\n async shutdown(): Promise<void> {\n closed = true;\n },\n };\n}\n\n/**\n * @internal - exposed for unit tests\n */\nexport function toOtlpEnvelope(record: SpanRecord, serviceName: string): unknown {\n const otelStatus = record.status === 'ok' ? 1 : record.status === 'error' ? 2 : 0;\n return {\n resourceSpans: [\n {\n resource: {\n attributes: [\n { key: 'service.name', value: { stringValue: serviceName } },\n { key: 'graphorin.framework.version', value: { stringValue: '0.6.0' } },\n ],\n },\n scopeSpans: [\n {\n scope: { name: '@graphorin/observability', version: '0.6.0' },\n spans: [\n {\n traceId: record.traceId,\n spanId: record.id,\n ...(record.parentId === undefined ? {} : { parentSpanId: record.parentId }),\n name: record.name,\n kind: spanKindFor(record.type),\n startTimeUnixNano: String(record.startUnixNano),\n endTimeUnixNano: String(record.endUnixNano),\n attributes: toOtlpAttributes(record.attributes),\n events: record.events.map(toOtlpEvent),\n status: {\n code: otelStatus,\n ...(record.statusMessage === undefined ? {} : { message: record.statusMessage }),\n },\n },\n ],\n },\n ],\n },\n ],\n };\n}\n\nfunction spanKindFor(type: SpanType): number {\n // OTel SpanKind enum: 0 unspecified, 1 internal, 2 server, 3 client, 4 producer, 5 consumer.\n if (type === 'provider.generate' || type === 'provider.stream' || type === 'mcp.call') return 3;\n if (type === 'tool.execute' || type === 'memory.embed') return 1;\n return 1;\n}\n\nfunction toOtlpAttributes(attrs: SpanAttributes): unknown[] {\n return Object.entries(attrs).map(([k, v]) => ({ key: k, value: toOtlpAttributeValue(v) }));\n}\n\nfunction toOtlpEvent(event: SpanRecordEvent): unknown {\n return {\n timeUnixNano: String(event.timeUnixNano),\n name: event.name,\n attributes: toOtlpAttributes(event.attributes),\n };\n}\n\nfunction toOtlpAttributeValue(v: SpanAttributeValue): unknown {\n if (typeof v === 'string') return { stringValue: v };\n if (typeof v === 'number') {\n return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };\n }\n if (typeof v === 'boolean') return { boolValue: v };\n if (v === null) return { stringValue: '' };\n if (Array.isArray(v)) {\n return {\n arrayValue: {\n values: v.map((item) => toOtlpAttributeValue(item as SpanAttributeValue)),\n },\n };\n }\n return { stringValue: String(v) };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"otlp-http.js","names":["pkg.version"],"sources":["../../src/exporters/otlp-http.ts"],"sourcesContent":["import pkg from '../../package.json' with { type: 'json' };\n/**\n * `OTLPHttpExporter` - reference implementation that POSTs span\n * records to an OpenTelemetry HTTP endpoint.\n *\n * The exporter is deliberately minimal: production hardening (retry\n * logic, batching policy tuning, gzip compression, bearer-token auth\n * rotation) is left to operators. The wire format follows the OTLP\n * `traces` resource shape and is compatible with collectors that accept\n * the `application/json` content type.\n *\n * Operators wanting the upstream OpenTelemetry SDK exporter\n * (`@opentelemetry/exporter-trace-otlp-http`) can adapt\n * {@link toOtlpEnvelope} into their own wrapper - the helper is\n * exported so the OTel adapter does not need to duplicate the wire\n * shape.\n *\n * @packageDocumentation\n */\n\nimport type { SpanAttributes, SpanAttributeValue, SpanType } from '@graphorin/core';\n\nimport type { SpanRecord, SpanRecordEvent, TraceExporter } from './types.js';\n\n/**\n * Configuration shape for {@link createOTLPHttpExporter}.\n *\n * @stable\n */\nexport interface OTLPHttpExporterOptions {\n /** Identifier reported via `exporter.id`. Defaults to `'otlp-http'`. */\n readonly id?: string;\n /** OTLP collector URL (e.g. `http://localhost:4318/v1/traces`). */\n readonly url: string;\n /** Optional headers (auth, tenant id, …). Avoid passing secrets in plain. */\n readonly headers?: Readonly<Record<string, string>>;\n /** Service name embedded in the OTLP `Resource`. */\n readonly serviceName?: string;\n /** Optional `fetch` override - useful for testing without the network. */\n readonly fetchImpl?: typeof fetch;\n /** Optional timeout (ms). Defaults to 10000. */\n readonly timeoutMs?: number;\n}\n\n/**\n * Build a minimal OTLP-over-HTTP trace exporter. Call\n * `withValidation(exporter)` before passing the result to\n * `createTracer({ exporters })`.\n *\n * The implementation issues one `POST` per finished span - no\n * batching. Operators wanting batching should wrap this exporter\n * with their own queue or use a sidecar collector.\n *\n * @stable\n */\nexport function createOTLPHttpExporter(opts: OTLPHttpExporterOptions): TraceExporter {\n const id = opts.id ?? 'otlp-http';\n const url = opts.url;\n const headers = opts.headers ?? {};\n const serviceName = opts.serviceName ?? 'graphorin';\n const fetchImpl = opts.fetchImpl ?? globalThis.fetch;\n const timeoutMs = opts.timeoutMs ?? 10_000;\n let closed = false;\n\n if (typeof fetchImpl !== 'function') {\n throw new TypeError(\n 'createOTLPHttpExporter: no fetch implementation available. Provide ' +\n '`fetchImpl` explicitly or run on a Node.js version with global fetch ' +\n '(>= 18).',\n );\n }\n\n return {\n id,\n async export(record: SpanRecord): Promise<void> {\n if (closed) return;\n const body = toOtlpEnvelope(record, serviceName);\n const controller = new AbortController();\n const timer = setTimeout(() => controller.abort(), timeoutMs);\n try {\n const res = await fetchImpl(url, {\n method: 'POST',\n headers: { 'content-type': 'application/json', ...headers },\n body: JSON.stringify(body),\n signal: controller.signal,\n });\n if (!res.ok) {\n throw new Error(`OTLP exporter received HTTP ${res.status} ${res.statusText}`);\n }\n } finally {\n clearTimeout(timer);\n }\n },\n async flush(): Promise<void> {\n // No internal queue; each export() awaits the POST already.\n },\n async shutdown(): Promise<void> {\n closed = true;\n },\n };\n}\n\n/**\n * @internal - exposed for unit tests\n */\nexport function toOtlpEnvelope(record: SpanRecord, serviceName: string): unknown {\n const otelStatus = record.status === 'ok' ? 1 : record.status === 'error' ? 2 : 0;\n return {\n resourceSpans: [\n {\n resource: {\n attributes: [\n { key: 'service.name', value: { stringValue: serviceName } },\n { key: 'graphorin.framework.version', value: { stringValue: pkg.version } },\n ],\n },\n scopeSpans: [\n {\n scope: { name: '@graphorin/observability', version: pkg.version },\n spans: [\n {\n traceId: record.traceId,\n spanId: record.id,\n ...(record.parentId === undefined ? {} : { parentSpanId: record.parentId }),\n name: record.name,\n kind: spanKindFor(record.type),\n startTimeUnixNano: String(record.startUnixNano),\n endTimeUnixNano: String(record.endUnixNano),\n attributes: toOtlpAttributes(record.attributes),\n events: record.events.map(toOtlpEvent),\n status: {\n code: otelStatus,\n ...(record.statusMessage === undefined ? {} : { message: record.statusMessage }),\n },\n },\n ],\n },\n ],\n },\n ],\n };\n}\n\nfunction spanKindFor(type: SpanType): number {\n // OTel SpanKind enum: 0 unspecified, 1 internal, 2 server, 3 client, 4 producer, 5 consumer.\n if (type === 'provider.generate' || type === 'provider.stream' || type === 'mcp.call') return 3;\n if (type === 'tool.execute' || type === 'memory.embed') return 1;\n return 1;\n}\n\nfunction toOtlpAttributes(attrs: SpanAttributes): unknown[] {\n return Object.entries(attrs).map(([k, v]) => ({ key: k, value: toOtlpAttributeValue(v) }));\n}\n\nfunction toOtlpEvent(event: SpanRecordEvent): unknown {\n return {\n timeUnixNano: String(event.timeUnixNano),\n name: event.name,\n attributes: toOtlpAttributes(event.attributes),\n };\n}\n\nfunction toOtlpAttributeValue(v: SpanAttributeValue): unknown {\n if (typeof v === 'string') return { stringValue: v };\n if (typeof v === 'number') {\n return Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };\n }\n if (typeof v === 'boolean') return { boolValue: v };\n if (v === null) return { stringValue: '' };\n if (Array.isArray(v)) {\n return {\n arrayValue: {\n values: v.map((item) => toOtlpAttributeValue(item as SpanAttributeValue)),\n },\n };\n }\n return { stringValue: String(v) };\n}\n"],"mappings":"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"}
@@ -43,6 +43,13 @@ interface SpanRecordEvent {
43
43
  readonly name: string;
44
44
  readonly timeUnixNano: number;
45
45
  readonly attributes: SpanAttributes;
46
+ /**
47
+ * W-094: per-attribute sensitivity map recorded by
48
+ * `addEvent(name, attrs, { sensitivity, sensitivityByAttribute })`.
49
+ * Consumed by the validation exporter; absent ⇒ every attribute is
50
+ * untagged (default-deny below the export floor).
51
+ */
52
+ readonly sensitivityByAttribute?: Readonly<Record<string, 'public' | 'internal' | 'secret'>>;
46
53
  }
47
54
  /**
48
55
  * A trace exporter contract. Exporters consume a stream of finished
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","names":[],"sources":["../../src/exporters/types.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;AAuC6C,cAxBhC,wBAwBgC,EAAA,OAAA,MAAA;;;AAU7C;AAYA;;;;AAOc,UA1CG,UA0CH,CAAA,UA1CwB,QA0CxB,GA1CmC,QA0CnC,CAAA,CAAA;EAQF,SAAA,IAAA,EAjDK,CAiDL;EAAwB,SAAA,EAAA,EAAA,MAAA;;;;;;mBA1CjB;;uBAEI;mBACJ,cAAc;;oCAEG,SAAS,eAAe;;;;;;;;;UAU3C,eAAA;;;uBAGM;;;;;;;;UASN,aAAA;;;iBAGA,aAAa;;WAEnB;;cAEG;;;;;;;;YAQF,wBAAA"}
1
+ {"version":3,"file":"types.d.ts","names":[],"sources":["../../src/exporters/types.ts"],"sourcesContent":[],"mappings":";;;;;;;;;;;AAuC6C,cAxBhC,wBAwBgC,EAAA,OAAA,MAAA;;;AAU7C;;;;;AAmBiB,UA1CA,UA0Ca,CAAA,UA1CQ,QA0CR,GA1CmB,QA0CnB,CAAA,CAAA;EAGb,SAAA,IAAA,EA5CA,CA4CA;EAAa,SAAA,EAAA,EAAA,MAAA;EAEnB,SAAA,OAAA,EAAA,MAAA;EAEG,SAAA,QAAA,CAAA,EAAA,MAAA;EAQF,SAAA,IAAA,EAAA,MAAA;EAAwB,SAAA,aAAA,EAAA,MAAA;;mBAjDjB;;uBAEI;mBACJ,cAAc;;oCAEG,SAAS,eAAe;;;;;;;;;UAU3C,eAAA;;;uBAGM;;;;;;;oCAOa,SAAS;;;;;;;;UAS5B,aAAA;;;iBAGA,aAAa;;WAEnB;;cAEG;;;;;;;;YAQF,wBAAA"}
@@ -1 +1 @@
1
- {"version":3,"file":"types.js","names":["VALIDATED_EXPORTER_BRAND: unique symbol"],"sources":["../../src/exporters/types.ts"],"sourcesContent":["/**\n * Shared types for the exporter surface.\n *\n * @packageDocumentation\n */\n\nimport type { SpanAttributes, SpanAttributeValue, SpanStatus, SpanType } from '@graphorin/core';\n\n/**\n * Discriminator marker - every exporter that has been wrapped via\n * `withValidation(...)` is branded with this symbol so the tracer can\n * fail-fast at startup when a raw exporter is registered.\n *\n * @stable\n */\nexport const VALIDATED_EXPORTER_BRAND: unique symbol = Symbol.for(\n 'graphorin.observability.exporter.validated',\n);\n\n/**\n * Sanitized, JSON-serialisable representation of a finished span. The\n * exporters never see the live OTel span; the tracer materialises this\n * record once the span ends and runs it through the validator.\n *\n * @stable\n */\nexport interface SpanRecord<T extends SpanType = SpanType> {\n readonly type: T;\n readonly id: string;\n readonly traceId: string;\n readonly parentId?: string;\n readonly name: string;\n readonly startUnixNano: number;\n readonly endUnixNano: number;\n readonly status: SpanStatus;\n readonly statusMessage?: string;\n readonly attributes: SpanAttributes;\n readonly events: ReadonlyArray<SpanRecordEvent>;\n /** Optional per-attribute sensitivity map - see `setAttribute({ sensitivity })`. */\n readonly sensitivityByAttribute?: Readonly<Record<string, SpanAttributeValue>>;\n /** Set when the validator dropped the span entirely (replay marker). */\n readonly droppedReason?: string;\n}\n\n/**\n * Single span event carried alongside the span record.\n *\n * @stable\n */\nexport interface SpanRecordEvent {\n readonly name: string;\n readonly timeUnixNano: number;\n readonly attributes: SpanAttributes;\n}\n\n/**\n * A trace exporter contract. Exporters consume a stream of finished\n * spans and forward them to a sink (console, file, OTLP wire, …).\n *\n * @stable\n */\nexport interface TraceExporter {\n readonly id: string;\n /** Forward a finished span record. Implementations should be cheap. */\n export(record: SpanRecord): Promise<void>;\n /** Flush any buffered spans. Called on `tracer.shutdown()`. */\n flush(): Promise<void>;\n /** Close any underlying resources. Idempotent. */\n shutdown(): Promise<void>;\n /**\n * Branded-marker stub. Set by `withValidation(...)` to signal that the\n * exporter has been wrapped. Direct exporters omit the brand and the\n * tracer fails fast at startup.\n *\n * @internal\n */\n readonly [VALIDATED_EXPORTER_BRAND]?: true;\n}\n"],"mappings":";;;;;;;;AAeA,MAAaA,2BAA0C,OAAO,IAC5D,6CACD"}
1
+ {"version":3,"file":"types.js","names":["VALIDATED_EXPORTER_BRAND: unique symbol"],"sources":["../../src/exporters/types.ts"],"sourcesContent":["/**\n * Shared types for the exporter surface.\n *\n * @packageDocumentation\n */\n\nimport type { SpanAttributes, SpanAttributeValue, SpanStatus, SpanType } from '@graphorin/core';\n\n/**\n * Discriminator marker - every exporter that has been wrapped via\n * `withValidation(...)` is branded with this symbol so the tracer can\n * fail-fast at startup when a raw exporter is registered.\n *\n * @stable\n */\nexport const VALIDATED_EXPORTER_BRAND: unique symbol = Symbol.for(\n 'graphorin.observability.exporter.validated',\n);\n\n/**\n * Sanitized, JSON-serialisable representation of a finished span. The\n * exporters never see the live OTel span; the tracer materialises this\n * record once the span ends and runs it through the validator.\n *\n * @stable\n */\nexport interface SpanRecord<T extends SpanType = SpanType> {\n readonly type: T;\n readonly id: string;\n readonly traceId: string;\n readonly parentId?: string;\n readonly name: string;\n readonly startUnixNano: number;\n readonly endUnixNano: number;\n readonly status: SpanStatus;\n readonly statusMessage?: string;\n readonly attributes: SpanAttributes;\n readonly events: ReadonlyArray<SpanRecordEvent>;\n /** Optional per-attribute sensitivity map - see `setAttribute({ sensitivity })`. */\n readonly sensitivityByAttribute?: Readonly<Record<string, SpanAttributeValue>>;\n /** Set when the validator dropped the span entirely (replay marker). */\n readonly droppedReason?: string;\n}\n\n/**\n * Single span event carried alongside the span record.\n *\n * @stable\n */\nexport interface SpanRecordEvent {\n readonly name: string;\n readonly timeUnixNano: number;\n readonly attributes: SpanAttributes;\n /**\n * W-094: per-attribute sensitivity map recorded by\n * `addEvent(name, attrs, { sensitivity, sensitivityByAttribute })`.\n * Consumed by the validation exporter; absent ⇒ every attribute is\n * untagged (default-deny below the export floor).\n */\n readonly sensitivityByAttribute?: Readonly<Record<string, 'public' | 'internal' | 'secret'>>;\n}\n\n/**\n * A trace exporter contract. Exporters consume a stream of finished\n * spans and forward them to a sink (console, file, OTLP wire, …).\n *\n * @stable\n */\nexport interface TraceExporter {\n readonly id: string;\n /** Forward a finished span record. Implementations should be cheap. */\n export(record: SpanRecord): Promise<void>;\n /** Flush any buffered spans. Called on `tracer.shutdown()`. */\n flush(): Promise<void>;\n /** Close any underlying resources. Idempotent. */\n shutdown(): Promise<void>;\n /**\n * Branded-marker stub. Set by `withValidation(...)` to signal that the\n * exporter has been wrapped. Direct exporters omit the brand and the\n * tracer fails fast at startup.\n *\n * @internal\n */\n readonly [VALIDATED_EXPORTER_BRAND]?: true;\n}\n"],"mappings":";;;;;;;;AAeA,MAAaA,2BAA0C,OAAO,IAC5D,6CACD"}
@@ -1 +1 @@
1
- {"version":3,"file":"with-validation.d.ts","names":[],"sources":["../../src/exporters/with-validation.ts"],"sourcesContent":[],"mappings":";;;;;;;AAgJA;AAUA;;;UAhIiB,qBAAA,SAA8B;;;;;;uBAMxB;;;;;;;;;iBAUP,yBAAyB,yBAC7B,UACJ,wBACL;;;;;;;;iBAmGa,mBAAA,WAA8B;;;;;;;iBAU9B,uBAAA,YAAmC,6BAA6B"}
1
+ {"version":3,"file":"with-validation.d.ts","names":[],"sources":["../../src/exporters/with-validation.ts"],"sourcesContent":[],"mappings":";;;;;;;AAmKA;AAUA;;;UAnJiB,qBAAA,SAA8B;;;;;;uBAMxB;;;;;;;;;iBAUP,yBAAyB,yBAC7B,UACJ,wBACL;;;;;;;;iBAsHa,mBAAA,WAA8B;;;;;;;iBAU9B,uBAAA,YAAmC,6BAA6B"}
@@ -38,16 +38,17 @@ function sanitizeRecord(record, validator) {
38
38
  /**
39
39
  * @internal
40
40
  */
41
- function sanitizeAttributes(attrs, sensitivities, validator, spanType) {
41
+ function sanitizeAttributes(attrs, sensitivities, validator, spanType, origin) {
42
42
  const out = {};
43
43
  for (const [key, value] of Object.entries(attrs)) {
44
44
  const tier = readTier(sensitivities[key]);
45
45
  const result = validator.validate({
46
46
  value,
47
47
  tier,
48
- context: spanType === void 0 ? { attribute: key } : {
48
+ context: {
49
49
  attribute: key,
50
- spanType
50
+ ...spanType !== void 0 ? { spanType } : {},
51
+ ...origin !== void 0 ? { origin } : {}
51
52
  }
52
53
  });
53
54
  if (result === null) continue;
@@ -61,7 +62,7 @@ function sanitizeAttributes(attrs, sensitivities, validator, spanType) {
61
62
  function sanitizeEvents(events, validator, spanType) {
62
63
  const out = [];
63
64
  for (const event of events) {
64
- const sanitizedAttrs = sanitizeAttributes(event.attributes, {}, validator, spanType);
65
+ const sanitizedAttrs = sanitizeAttributes(event.attributes, event.sensitivityByAttribute ?? {}, validator, spanType, `event:${event.name}`);
65
66
  out.push({
66
67
  name: event.name,
67
68
  timeUnixNano: event.timeUnixNano,