@graphorin/observability 0.6.1 → 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 (80) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +24 -4
  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/types.d.ts +7 -0
  15. package/dist/exporters/types.d.ts.map +1 -1
  16. package/dist/exporters/types.js.map +1 -1
  17. package/dist/exporters/with-validation.d.ts.map +1 -1
  18. package/dist/exporters/with-validation.js +5 -4
  19. package/dist/exporters/with-validation.js.map +1 -1
  20. package/dist/gen-ai/emit.js +9 -1
  21. package/dist/gen-ai/emit.js.map +1 -1
  22. package/dist/index.d.ts +2 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +2 -8
  25. package/dist/index.js.map +1 -1
  26. package/dist/package.js +1 -1
  27. package/dist/package.js.map +1 -1
  28. package/dist/redaction/imperative-patterns.d.ts +3 -3
  29. package/dist/redaction/imperative-patterns.d.ts.map +1 -1
  30. package/dist/redaction/imperative-patterns.js +7 -0
  31. package/dist/redaction/imperative-patterns.js.map +1 -1
  32. package/dist/tracer/sampling.d.ts +6 -1
  33. package/dist/tracer/sampling.d.ts.map +1 -1
  34. package/dist/tracer/sampling.js +7 -1
  35. package/dist/tracer/sampling.js.map +1 -1
  36. package/dist/tracer/span.d.ts.map +1 -1
  37. package/dist/tracer/span.js +12 -3
  38. package/dist/tracer/span.js.map +1 -1
  39. package/package.json +18 -35
  40. package/src/cost/cost-tracker.ts +315 -0
  41. package/src/cost/delegate.ts +79 -0
  42. package/src/cost/index.ts +23 -0
  43. package/src/cost/types.ts +151 -0
  44. package/src/eval/index.ts +16 -0
  45. package/src/eval/runner.ts +146 -0
  46. package/src/eval/types.ts +122 -0
  47. package/src/exporters/console.ts +75 -0
  48. package/src/exporters/index.ts +36 -0
  49. package/src/exporters/jsonl.ts +167 -0
  50. package/src/exporters/otlp-http.ts +178 -0
  51. package/src/exporters/types.ts +85 -0
  52. package/src/exporters/with-validation.ts +176 -0
  53. package/src/gen-ai/emit.ts +157 -0
  54. package/src/gen-ai/index.ts +26 -0
  55. package/src/gen-ai/operation-mapping.ts +89 -0
  56. package/src/gen-ai/system-derivation.ts +84 -0
  57. package/src/gen-ai/types.ts +143 -0
  58. package/src/index.ts +36 -0
  59. package/src/logger/index.ts +13 -0
  60. package/src/logger/logger.ts +178 -0
  61. package/src/openinference/index.ts +133 -0
  62. package/src/redaction/config.ts +50 -0
  63. package/src/redaction/errors.ts +53 -0
  64. package/src/redaction/imperative-patterns.ts +268 -0
  65. package/src/redaction/index.ts +42 -0
  66. package/src/redaction/patterns.ts +263 -0
  67. package/src/redaction/types.ts +116 -0
  68. package/src/redaction/validator.ts +302 -0
  69. package/src/replay/config.ts +58 -0
  70. package/src/replay/index.ts +17 -0
  71. package/src/replay/log.ts +89 -0
  72. package/src/replay/replay.ts +196 -0
  73. package/src/replay/types.ts +112 -0
  74. package/src/telemetry/index.ts +94 -0
  75. package/src/tracer/ids.ts +27 -0
  76. package/src/tracer/index.ts +22 -0
  77. package/src/tracer/sampling.ts +170 -0
  78. package/src/tracer/span-names.ts +52 -0
  79. package/src/tracer/span.ts +175 -0
  80. package/src/tracer/tracer.ts +309 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
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
+
3
34
  ## 0.6.1
4
35
 
5
36
  ### Patch Changes
package/README.md CHANGED
@@ -51,9 +51,29 @@ outbound network calls without an explicit user action.
51
51
  requires the `canReadRaw` callback to return `true` and emits an
52
52
  audit-bridge entry on every invocation.
53
53
  - **Hierarchical `CostTracker`.** `createCostTracker({...})` rolls up
54
- tokens + cost across parent-child spans and supports per-run /
55
- per-session / per-agent / per-user budgets with an `onExceed`
56
- 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
+ ```
57
77
  - **Structured logger.** `createLogger({...})` writes JSON or pretty
58
78
  records, automatically correlates with the current span via
59
79
  `withCurrentSpan(...)`, and pipes every field through the validator.
@@ -116,4 +136,4 @@ MIT © 2026 [Oleksiy Stepurenko](https://github.com/o-stepper).
116
136
 
117
137
  ---
118
138
 
119
- **Project Graphorin** · v0.6.1 · 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"}
@@ -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,
@@ -1 +1 @@
1
- {"version":3,"file":"with-validation.js","names":["DEFAULT_ATTR_SENSITIVITY: Sensitivity","out: Record<string, SpanAttributeValue>","out: SpanRecord['events'][number][]"],"sources":["../../src/exporters/with-validation.ts"],"sourcesContent":["/**\n * `withValidation(exporter, opts)` - the **mandatory wrapper** applied\n * to every exporter. Forwards each span record through a\n * `RedactionValidator` first; drops + counts when a value exceeds the\n * configured tier floor or matches a secret / PII pattern.\n *\n * @packageDocumentation\n */\n\nimport type { Sensitivity, SpanAttributes, SpanAttributeValue } from '@graphorin/core';\nimport type {\n RedactionCounters,\n RedactionValidatorInstance,\n RedactionValidatorOptions,\n} from '../redaction/types.js';\nimport { createRedactionValidator } from '../redaction/validator.js';\n\nimport { type SpanRecord, type TraceExporter, VALIDATED_EXPORTER_BRAND } from './types.js';\n\nconst DEFAULT_ATTR_SENSITIVITY: Sensitivity = 'internal';\n\n/**\n * Options for {@link withValidation}.\n *\n * @stable\n */\nexport interface WithValidationOptions extends RedactionValidatorOptions {\n /**\n * Optional pre-built validator. When supplied, the rest of the\n * options on this object are ignored and the supplied validator is\n * reused - useful for sharing one validator across multiple exporters.\n */\n readonly validator?: RedactionValidatorInstance;\n}\n\n/**\n * Wrap an exporter so every span flows through a {@link RedactionValidator}\n * before reaching the sink. Exporters that are not wrapped are rejected\n * by the tracer at startup.\n *\n * @stable\n */\nexport function withValidation<E extends TraceExporter>(\n exporter: E,\n opts: WithValidationOptions = {},\n): TraceExporter {\n const validator = opts.validator ?? createRedactionValidator(opts);\n const wrapped: TraceExporter = {\n id: `${exporter.id}+validated`,\n [VALIDATED_EXPORTER_BRAND]: true,\n async export(record: SpanRecord): Promise<void> {\n // RP-18: the validator strips offending attributes (counting each drop)\n // and always returns an exportable record - a single untagged or\n // over-tier attribute no longer makes the whole span vanish from every\n // exporter.\n await exporter.export(sanitizeRecord(record, validator));\n },\n flush: () => exporter.flush(),\n shutdown: () => exporter.shutdown(),\n };\n return wrapped;\n}\n\n/**\n * @internal\n */\nexport function sanitizeRecord(\n record: SpanRecord,\n validator: RedactionValidatorInstance,\n): SpanRecord {\n const sensitivities = record.sensitivityByAttribute ?? {};\n // RP-18: attribute-granular sanitization. Offending attributes are stripped\n // (and counted by the validator); the span itself always survives so\n // framework spans carrying untagged attributes still reach every exporter.\n const sanitizedAttrs = sanitizeAttributes(\n record.attributes,\n sensitivities,\n validator,\n record.type,\n );\n const sanitizedEvents = sanitizeEvents(record.events, validator, record.type);\n return {\n ...record,\n attributes: sanitizedAttrs,\n events: sanitizedEvents,\n };\n}\n\n/**\n * @internal\n */\nexport function sanitizeAttributes(\n attrs: SpanAttributes,\n sensitivities: Readonly<Record<string, SpanAttributeValue>>,\n validator: RedactionValidatorInstance,\n spanType?: string,\n): SpanAttributes {\n const out: Record<string, SpanAttributeValue> = {};\n for (const [key, value] of Object.entries(attrs)) {\n const tier = readTier(sensitivities[key]);\n const result = validator.validate({\n value,\n tier,\n context: spanType === undefined ? { attribute: key } : { attribute: key, spanType },\n });\n if (result === null) {\n // RP-18: this single attribute exceeded the floor (or matched a secret\n // pattern). Strip it - the validator has already counted the drop - and\n // keep the rest of the span rather than discarding the whole record.\n continue;\n }\n out[key] = result.value as SpanAttributeValue;\n }\n return Object.freeze(out) as SpanAttributes;\n}\n\n/**\n * @internal\n */\nexport function sanitizeEvents(\n events: SpanRecord['events'],\n validator: RedactionValidatorInstance,\n spanType?: string,\n): SpanRecord['events'] {\n const out: SpanRecord['events'][number][] = [];\n for (const event of events) {\n const sanitizedAttrs = sanitizeAttributes(event.attributes, {}, validator, spanType);\n out.push({ name: event.name, timeUnixNano: event.timeUnixNano, attributes: sanitizedAttrs });\n }\n return out;\n}\n\nfunction readTier(value: SpanAttributeValue | undefined): Sensitivity {\n if (value === 'public' || value === 'internal' || value === 'secret') return value;\n return DEFAULT_ATTR_SENSITIVITY;\n}\n\n/**\n * Quickly check whether an exporter is the result of a previous\n * {@link withValidation} call. The tracer uses this to fail fast at\n * startup.\n *\n * @stable\n */\nexport function isValidatedExporter(exporter: TraceExporter): boolean {\n return exporter[VALIDATED_EXPORTER_BRAND] === true;\n}\n\n/**\n * Pull the counters out of any exporter wrapped by {@link withValidation}.\n * Returns `null` for exporters that were never wrapped.\n *\n * @stable\n */\nexport function tryGetValidatorCounters(validator: RedactionValidatorInstance): RedactionCounters {\n return validator.counters();\n}\n"],"mappings":";;;;AAmBA,MAAMA,2BAAwC;;;;;;;;AAuB9C,SAAgB,eACd,UACA,OAA8B,EAAE,EACjB;CACf,MAAM,YAAY,KAAK,aAAa,yBAAyB,KAAK;AAclE,QAb+B;EAC7B,IAAI,GAAG,SAAS,GAAG;GAClB,2BAA2B;EAC5B,MAAM,OAAO,QAAmC;AAK9C,SAAM,SAAS,OAAO,eAAe,QAAQ,UAAU,CAAC;;EAE1D,aAAa,SAAS,OAAO;EAC7B,gBAAgB,SAAS,UAAU;EACpC;;;;;AAOH,SAAgB,eACd,QACA,WACY;CACZ,MAAM,gBAAgB,OAAO,0BAA0B,EAAE;CAIzD,MAAM,iBAAiB,mBACrB,OAAO,YACP,eACA,WACA,OAAO,KACR;CACD,MAAM,kBAAkB,eAAe,OAAO,QAAQ,WAAW,OAAO,KAAK;AAC7E,QAAO;EACL,GAAG;EACH,YAAY;EACZ,QAAQ;EACT;;;;;AAMH,SAAgB,mBACd,OACA,eACA,WACA,UACgB;CAChB,MAAMC,MAA0C,EAAE;AAClD,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,EAAE;EAChD,MAAM,OAAO,SAAS,cAAc,KAAK;EACzC,MAAM,SAAS,UAAU,SAAS;GAChC;GACA;GACA,SAAS,aAAa,SAAY,EAAE,WAAW,KAAK,GAAG;IAAE,WAAW;IAAK;IAAU;GACpF,CAAC;AACF,MAAI,WAAW,KAIb;AAEF,MAAI,OAAO,OAAO;;AAEpB,QAAO,OAAO,OAAO,IAAI;;;;;AAM3B,SAAgB,eACd,QACA,WACA,UACsB;CACtB,MAAMC,MAAsC,EAAE;AAC9C,MAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,iBAAiB,mBAAmB,MAAM,YAAY,EAAE,EAAE,WAAW,SAAS;AACpF,MAAI,KAAK;GAAE,MAAM,MAAM;GAAM,cAAc,MAAM;GAAc,YAAY;GAAgB,CAAC;;AAE9F,QAAO;;AAGT,SAAS,SAAS,OAAoD;AACpE,KAAI,UAAU,YAAY,UAAU,cAAc,UAAU,SAAU,QAAO;AAC7E,QAAO;;;;;;;;;AAUT,SAAgB,oBAAoB,UAAkC;AACpE,QAAO,SAAS,8BAA8B;;;;;;;;AAShD,SAAgB,wBAAwB,WAA0D;AAChG,QAAO,UAAU,UAAU"}
1
+ {"version":3,"file":"with-validation.js","names":["DEFAULT_ATTR_SENSITIVITY: Sensitivity","out: Record<string, SpanAttributeValue>","out: SpanRecord['events'][number][]"],"sources":["../../src/exporters/with-validation.ts"],"sourcesContent":["/**\n * `withValidation(exporter, opts)` - the **mandatory wrapper** applied\n * to every exporter. Forwards each span record through a\n * `RedactionValidator` first; drops + counts when a value exceeds the\n * configured tier floor or matches a secret / PII pattern.\n *\n * @packageDocumentation\n */\n\nimport type { Sensitivity, SpanAttributes, SpanAttributeValue } from '@graphorin/core';\nimport type {\n RedactionCounters,\n RedactionValidatorInstance,\n RedactionValidatorOptions,\n} from '../redaction/types.js';\nimport { createRedactionValidator } from '../redaction/validator.js';\n\nimport { type SpanRecord, type TraceExporter, VALIDATED_EXPORTER_BRAND } from './types.js';\n\nconst DEFAULT_ATTR_SENSITIVITY: Sensitivity = 'internal';\n\n/**\n * Options for {@link withValidation}.\n *\n * @stable\n */\nexport interface WithValidationOptions extends RedactionValidatorOptions {\n /**\n * Optional pre-built validator. When supplied, the rest of the\n * options on this object are ignored and the supplied validator is\n * reused - useful for sharing one validator across multiple exporters.\n */\n readonly validator?: RedactionValidatorInstance;\n}\n\n/**\n * Wrap an exporter so every span flows through a {@link RedactionValidator}\n * before reaching the sink. Exporters that are not wrapped are rejected\n * by the tracer at startup.\n *\n * @stable\n */\nexport function withValidation<E extends TraceExporter>(\n exporter: E,\n opts: WithValidationOptions = {},\n): TraceExporter {\n const validator = opts.validator ?? createRedactionValidator(opts);\n const wrapped: TraceExporter = {\n id: `${exporter.id}+validated`,\n [VALIDATED_EXPORTER_BRAND]: true,\n async export(record: SpanRecord): Promise<void> {\n // RP-18: the validator strips offending attributes (counting each drop)\n // and always returns an exportable record - a single untagged or\n // over-tier attribute no longer makes the whole span vanish from every\n // exporter.\n await exporter.export(sanitizeRecord(record, validator));\n },\n flush: () => exporter.flush(),\n shutdown: () => exporter.shutdown(),\n };\n return wrapped;\n}\n\n/**\n * @internal\n */\nexport function sanitizeRecord(\n record: SpanRecord,\n validator: RedactionValidatorInstance,\n): SpanRecord {\n const sensitivities = record.sensitivityByAttribute ?? {};\n // RP-18: attribute-granular sanitization. Offending attributes are stripped\n // (and counted by the validator); the span itself always survives so\n // framework spans carrying untagged attributes still reach every exporter.\n const sanitizedAttrs = sanitizeAttributes(\n record.attributes,\n sensitivities,\n validator,\n record.type,\n );\n const sanitizedEvents = sanitizeEvents(record.events, validator, record.type);\n return {\n ...record,\n attributes: sanitizedAttrs,\n events: sanitizedEvents,\n };\n}\n\n/**\n * @internal\n */\nexport function sanitizeAttributes(\n attrs: SpanAttributes,\n sensitivities: Readonly<Record<string, SpanAttributeValue>>,\n validator: RedactionValidatorInstance,\n spanType?: string,\n origin?: string,\n): SpanAttributes {\n const out: Record<string, SpanAttributeValue> = {};\n for (const [key, value] of Object.entries(attrs)) {\n const tier = readTier(sensitivities[key]);\n const result = validator.validate({\n value,\n tier,\n context: {\n attribute: key,\n ...(spanType !== undefined ? { spanType } : {}),\n // W-094: lets onViolation / droppedByReason tell an EVENT\n // attribute from a span attribute.\n ...(origin !== undefined ? { origin } : {}),\n },\n });\n if (result === null) {\n // RP-18: this single attribute exceeded the floor (or matched a secret\n // pattern). Strip it - the validator has already counted the drop - and\n // keep the rest of the span rather than discarding the whole record.\n continue;\n }\n out[key] = result.value as SpanAttributeValue;\n }\n return Object.freeze(out) as SpanAttributes;\n}\n\n/**\n * @internal\n */\nexport function sanitizeEvents(\n events: SpanRecord['events'],\n validator: RedactionValidatorInstance,\n spanType?: string,\n): SpanRecord['events'] {\n const out: SpanRecord['events'][number][] = [];\n for (const event of events) {\n // W-094: honour the per-event sensitivity map recorded by\n // `addEvent(..., opts)` (pre-fix an empty map was passed here, so\n // EVERY event attribute - including `exception.type` - was dropped\n // under the default 'public' floor). Untagged attributes still\n // default-deny; the `event:<name>` origin distinguishes event drops\n // from span-attribute drops in onViolation.\n const sanitizedAttrs = sanitizeAttributes(\n event.attributes,\n event.sensitivityByAttribute ?? {},\n validator,\n spanType,\n `event:${event.name}`,\n );\n out.push({ name: event.name, timeUnixNano: event.timeUnixNano, attributes: sanitizedAttrs });\n }\n return out;\n}\n\nfunction readTier(value: SpanAttributeValue | undefined): Sensitivity {\n if (value === 'public' || value === 'internal' || value === 'secret') return value;\n return DEFAULT_ATTR_SENSITIVITY;\n}\n\n/**\n * Quickly check whether an exporter is the result of a previous\n * {@link withValidation} call. The tracer uses this to fail fast at\n * startup.\n *\n * @stable\n */\nexport function isValidatedExporter(exporter: TraceExporter): boolean {\n return exporter[VALIDATED_EXPORTER_BRAND] === true;\n}\n\n/**\n * Pull the counters out of any exporter wrapped by {@link withValidation}.\n * Returns `null` for exporters that were never wrapped.\n *\n * @stable\n */\nexport function tryGetValidatorCounters(validator: RedactionValidatorInstance): RedactionCounters {\n return validator.counters();\n}\n"],"mappings":";;;;AAmBA,MAAMA,2BAAwC;;;;;;;;AAuB9C,SAAgB,eACd,UACA,OAA8B,EAAE,EACjB;CACf,MAAM,YAAY,KAAK,aAAa,yBAAyB,KAAK;AAclE,QAb+B;EAC7B,IAAI,GAAG,SAAS,GAAG;GAClB,2BAA2B;EAC5B,MAAM,OAAO,QAAmC;AAK9C,SAAM,SAAS,OAAO,eAAe,QAAQ,UAAU,CAAC;;EAE1D,aAAa,SAAS,OAAO;EAC7B,gBAAgB,SAAS,UAAU;EACpC;;;;;AAOH,SAAgB,eACd,QACA,WACY;CACZ,MAAM,gBAAgB,OAAO,0BAA0B,EAAE;CAIzD,MAAM,iBAAiB,mBACrB,OAAO,YACP,eACA,WACA,OAAO,KACR;CACD,MAAM,kBAAkB,eAAe,OAAO,QAAQ,WAAW,OAAO,KAAK;AAC7E,QAAO;EACL,GAAG;EACH,YAAY;EACZ,QAAQ;EACT;;;;;AAMH,SAAgB,mBACd,OACA,eACA,WACA,UACA,QACgB;CAChB,MAAMC,MAA0C,EAAE;AAClD,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,MAAM,EAAE;EAChD,MAAM,OAAO,SAAS,cAAc,KAAK;EACzC,MAAM,SAAS,UAAU,SAAS;GAChC;GACA;GACA,SAAS;IACP,WAAW;IACX,GAAI,aAAa,SAAY,EAAE,UAAU,GAAG,EAAE;IAG9C,GAAI,WAAW,SAAY,EAAE,QAAQ,GAAG,EAAE;IAC3C;GACF,CAAC;AACF,MAAI,WAAW,KAIb;AAEF,MAAI,OAAO,OAAO;;AAEpB,QAAO,OAAO,OAAO,IAAI;;;;;AAM3B,SAAgB,eACd,QACA,WACA,UACsB;CACtB,MAAMC,MAAsC,EAAE;AAC9C,MAAK,MAAM,SAAS,QAAQ;EAO1B,MAAM,iBAAiB,mBACrB,MAAM,YACN,MAAM,0BAA0B,EAAE,EAClC,WACA,UACA,SAAS,MAAM,OAChB;AACD,MAAI,KAAK;GAAE,MAAM,MAAM;GAAM,cAAc,MAAM;GAAc,YAAY;GAAgB,CAAC;;AAE9F,QAAO;;AAGT,SAAS,SAAS,OAAoD;AACpE,KAAI,UAAU,YAAY,UAAU,cAAc,UAAU,SAAU,QAAO;AAC7E,QAAO;;;;;;;;;AAUT,SAAgB,oBAAoB,UAAkC;AACpE,QAAO,SAAS,8BAA8B;;;;;;;;AAShD,SAAgB,wBAAwB,WAA0D;AAChG,QAAO,UAAU,UAAU"}
@@ -54,7 +54,15 @@ function emitGenAIAttributes(span, attrs) {
54
54
  * @stable
55
55
  */
56
56
  function emitGenAIMessageEvents(span, messages, opts = {}) {
57
- for (const message of messages) span.addEvent(eventNameFor(message.role), buildEventAttrs(message, opts.system));
57
+ for (const message of messages) span.addEvent(eventNameFor(message.role), buildEventAttrs(message, opts.system), {
58
+ sensitivity: "internal",
59
+ sensitivityByAttribute: {
60
+ "gen_ai.message.role": "public",
61
+ "gen_ai.system": "public",
62
+ "gen_ai.message.name": "public",
63
+ "gen_ai.tool.call.id": "public"
64
+ }
65
+ });
58
66
  }
59
67
  function eventNameFor(role) {
60
68
  switch (role) {