@provide-io/telemetry 0.2.2

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 (122) hide show
  1. package/README.md +247 -0
  2. package/dist/backpressure.d.ts +19 -0
  3. package/dist/backpressure.d.ts.map +1 -0
  4. package/dist/backpressure.js +51 -0
  5. package/dist/cardinality.d.ts +15 -0
  6. package/dist/cardinality.d.ts.map +1 -0
  7. package/dist/cardinality.js +69 -0
  8. package/dist/classification.d.ts +29 -0
  9. package/dist/classification.d.ts.map +1 -0
  10. package/dist/classification.js +58 -0
  11. package/dist/config.d.ts +156 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +350 -0
  14. package/dist/consent.d.ts +11 -0
  15. package/dist/consent.d.ts.map +1 -0
  16. package/dist/consent.js +50 -0
  17. package/dist/context.d.ts +60 -0
  18. package/dist/context.d.ts.map +1 -0
  19. package/dist/context.js +127 -0
  20. package/dist/exceptions.d.ts +14 -0
  21. package/dist/exceptions.d.ts.map +1 -0
  22. package/dist/exceptions.js +21 -0
  23. package/dist/fingerprint.d.ts +5 -0
  24. package/dist/fingerprint.d.ts.map +1 -0
  25. package/dist/fingerprint.js +50 -0
  26. package/dist/hash.d.ts +8 -0
  27. package/dist/hash.d.ts.map +1 -0
  28. package/dist/hash.js +102 -0
  29. package/dist/health.d.ts +54 -0
  30. package/dist/health.d.ts.map +1 -0
  31. package/dist/health.js +102 -0
  32. package/dist/index.d.ts +52 -0
  33. package/dist/index.d.ts.map +1 -0
  34. package/dist/index.js +59 -0
  35. package/dist/logger.d.ts +28 -0
  36. package/dist/logger.d.ts.map +1 -0
  37. package/dist/logger.js +254 -0
  38. package/dist/metrics.d.ts +78 -0
  39. package/dist/metrics.d.ts.map +1 -0
  40. package/dist/metrics.js +238 -0
  41. package/dist/otel-logs.d.ts +29 -0
  42. package/dist/otel-logs.d.ts.map +1 -0
  43. package/dist/otel-logs.js +127 -0
  44. package/dist/otel-noop.d.ts +13 -0
  45. package/dist/otel-noop.d.ts.map +1 -0
  46. package/dist/otel-noop.js +5 -0
  47. package/dist/otel.d.ts +20 -0
  48. package/dist/otel.d.ts.map +1 -0
  49. package/dist/otel.js +80 -0
  50. package/dist/pii.d.ts +43 -0
  51. package/dist/pii.d.ts.map +1 -0
  52. package/dist/pii.js +278 -0
  53. package/dist/pretty.d.ts +12 -0
  54. package/dist/pretty.d.ts.map +1 -0
  55. package/dist/pretty.js +85 -0
  56. package/dist/propagation.d.ts +52 -0
  57. package/dist/propagation.d.ts.map +1 -0
  58. package/dist/propagation.js +183 -0
  59. package/dist/react.d.ts +38 -0
  60. package/dist/react.d.ts.map +1 -0
  61. package/dist/react.js +72 -0
  62. package/dist/receipts.d.ts +26 -0
  63. package/dist/receipts.d.ts.map +1 -0
  64. package/dist/receipts.js +69 -0
  65. package/dist/resilience.d.ts +26 -0
  66. package/dist/resilience.d.ts.map +1 -0
  67. package/dist/resilience.js +183 -0
  68. package/dist/runtime.d.ts +33 -0
  69. package/dist/runtime.d.ts.map +1 -0
  70. package/dist/runtime.js +133 -0
  71. package/dist/sampling.d.ts +9 -0
  72. package/dist/sampling.d.ts.map +1 -0
  73. package/dist/sampling.js +53 -0
  74. package/dist/sanitize.d.ts +6 -0
  75. package/dist/sanitize.d.ts.map +1 -0
  76. package/dist/sanitize.js +7 -0
  77. package/dist/schema.d.ts +41 -0
  78. package/dist/schema.d.ts.map +1 -0
  79. package/dist/schema.js +109 -0
  80. package/dist/shutdown.d.ts +2 -0
  81. package/dist/shutdown.d.ts.map +1 -0
  82. package/dist/shutdown.js +15 -0
  83. package/dist/slo.d.ts +25 -0
  84. package/dist/slo.d.ts.map +1 -0
  85. package/dist/slo.js +115 -0
  86. package/dist/testing.d.ts +10 -0
  87. package/dist/testing.d.ts.map +1 -0
  88. package/dist/testing.js +51 -0
  89. package/dist/tracing.d.ts +51 -0
  90. package/dist/tracing.d.ts.map +1 -0
  91. package/dist/tracing.js +181 -0
  92. package/package.json +139 -0
  93. package/src/backpressure.ts +68 -0
  94. package/src/cardinality.ts +83 -0
  95. package/src/classification.ts +87 -0
  96. package/src/config.ts +589 -0
  97. package/src/consent.ts +61 -0
  98. package/src/context.ts +157 -0
  99. package/src/exceptions.ts +24 -0
  100. package/src/fingerprint.ts +53 -0
  101. package/src/hash.ts +118 -0
  102. package/src/health.ts +175 -0
  103. package/src/index.ts +183 -0
  104. package/src/logger.ts +287 -0
  105. package/src/metrics.ts +204 -0
  106. package/src/otel-logs.ts +161 -0
  107. package/src/otel-noop.ts +19 -0
  108. package/src/otel.ts +112 -0
  109. package/src/pii.ts +358 -0
  110. package/src/pretty.ts +93 -0
  111. package/src/propagation.ts +222 -0
  112. package/src/react.ts +98 -0
  113. package/src/receipts.ts +97 -0
  114. package/src/resilience.ts +220 -0
  115. package/src/runtime.ts +171 -0
  116. package/src/sampling.ts +68 -0
  117. package/src/sanitize.ts +8 -0
  118. package/src/schema.ts +135 -0
  119. package/src/shutdown.ts +18 -0
  120. package/src/slo.ts +156 -0
  121. package/src/testing.ts +56 -0
  122. package/src/tracing.ts +211 -0
package/src/pretty.ts ADDED
@@ -0,0 +1,93 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Pretty ANSI log renderer for CLI / terminal output.
6
+ *
7
+ * Mirrors Python provide.telemetry.logger.pretty.PrettyRenderer.
8
+ * Same color scheme, same layout: timestamp [level] event key=value pairs.
9
+ */
10
+
11
+ const RESET = '\x1b[0m';
12
+ const DIM = '\x1b[2m';
13
+
14
+ const LEVEL_COLORS: Record<string, string> = {
15
+ fatal: '\x1b[31;1m', // bold red
16
+ error: '\x1b[31m', // red
17
+ warn: '\x1b[33m', // yellow
18
+ info: '\x1b[32m', // green
19
+ debug: '\x1b[34m', // blue
20
+ trace: '\x1b[36m', // cyan
21
+ };
22
+
23
+ // pino level number → name
24
+ const LEVEL_NAMES: Record<number, string> = {
25
+ 10: 'trace',
26
+ 20: 'debug',
27
+ 30: 'info',
28
+ 40: 'warn',
29
+ 50: 'error',
30
+ 60: 'fatal',
31
+ };
32
+
33
+ const LEVEL_PAD = 6; // "fatal" = 5, pad to 6
34
+
35
+ // Keys to exclude from the key=value tail (already rendered or internal)
36
+ const SKIP_KEYS = new Set(['level', 'time', 'msg', 'event', 'v', 'pid', 'hostname']);
37
+
38
+ /**
39
+ * Detect whether stdout supports color.
40
+ * Returns false in browsers, CI without FORCE_COLOR, or piped output.
41
+ */
42
+ export function supportsColor(): boolean {
43
+ // Stryker disable next-line ConditionalExpression,StringLiteral,BooleanLiteral -- browser-only guard
44
+ /* v8 ignore next -- browser-only path, untestable in Node */
45
+ if (typeof process === 'undefined') return false;
46
+ if (process.env['FORCE_COLOR'] === '1' || process.env['FORCE_COLOR'] === 'true') return true;
47
+ if (process.env['NO_COLOR'] !== undefined) return false;
48
+ // Stryker disable next-line OptionalChaining -- process.stdout is always defined in Node/test env
49
+ if (typeof process.stdout?.isTTY === 'boolean') return process.stdout.isTTY;
50
+ return false;
51
+ }
52
+
53
+ /**
54
+ * Format a pino log object as a pretty ANSI string.
55
+ *
56
+ * Layout: `timestamp [level ] event key=value key=value`
57
+ */
58
+ export function formatPretty(obj: Record<string, unknown>, colors: boolean): string {
59
+ const parts: string[] = [];
60
+
61
+ // 1. Timestamp
62
+ const time = obj['time'];
63
+ if (time !== undefined) {
64
+ const ts = typeof time === 'number' ? new Date(time).toISOString() : String(time);
65
+ parts.push(colors ? DIM + ts + RESET : ts);
66
+ }
67
+
68
+ // 2. Level
69
+ const levelNum = obj['level'] as number;
70
+ const levelName = LEVEL_NAMES[levelNum] ?? 'log';
71
+ const padded = levelName.padEnd(LEVEL_PAD);
72
+ if (colors) {
73
+ const c = LEVEL_COLORS[levelName] ?? '';
74
+ parts.push('[' + c + padded + RESET + ']');
75
+ } else {
76
+ parts.push('[' + padded + ']');
77
+ }
78
+
79
+ // 3. Event / message
80
+ const event = obj['event'] ?? obj['msg'] ?? '';
81
+ parts.push(String(event));
82
+
83
+ // 4. Remaining key=value pairs (sorted, skip internal keys)
84
+ const keys = Object.keys(obj)
85
+ .filter((k) => !SKIP_KEYS.has(k))
86
+ .sort();
87
+ for (const k of keys) {
88
+ const v = JSON.stringify(obj[k]);
89
+ parts.push(colors ? DIM + k + RESET + '=' + v : k + '=' + v);
90
+ }
91
+
92
+ return parts.join(' ');
93
+ }
@@ -0,0 +1,222 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * W3C trace context propagation helpers.
6
+ * Mirrors Python provide.telemetry.propagation.
7
+ */
8
+
9
+ export interface PropagationContext {
10
+ traceparent?: string;
11
+ tracestate?: string;
12
+ baggage?: string;
13
+ traceId?: string;
14
+ spanId?: string;
15
+ }
16
+
17
+ /** Maximum length (in characters) for traceparent or tracestate header values. */
18
+ export const MAX_HEADER_LENGTH = 512;
19
+ /** Maximum number of comma-separated key=value pairs in tracestate. */
20
+ export const MAX_TRACESTATE_PAIRS = 32;
21
+ /** Maximum length (in characters) for the baggage header value. */
22
+ export const MAX_BAGGAGE_LENGTH = 8192;
23
+
24
+ // ── AsyncLocalStorage type (Node.js / Cloudflare Workers) ─────────────────────
25
+ type PropagationStore = {
26
+ active: PropagationContext;
27
+ stack: PropagationContext[];
28
+ otelCtxStack: unknown[];
29
+ };
30
+
31
+ export type PropagationALS = {
32
+ getStore(): PropagationStore | undefined;
33
+ run<T>(store: PropagationStore, fn: () => T): T;
34
+ enterWith(store: PropagationStore): void;
35
+ };
36
+
37
+ // ── AsyncLocalStorage (Node.js / Cloudflare Workers) ──────────────────────────
38
+ let _als: PropagationALS | null = null;
39
+ let _AlsConstructor: (new () => PropagationALS) | null = null;
40
+ // Stryker disable BlockStatement: module-level try/catch runs once at import time — cannot be tested by unit tests
41
+ try {
42
+ // Dynamic require so the import doesn't break browser bundles.
43
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
44
+ const als = require('node:async_hooks') as {
45
+ AsyncLocalStorage: new () => PropagationALS;
46
+ };
47
+ _AlsConstructor = als.AsyncLocalStorage;
48
+ _als = new _AlsConstructor();
49
+ } catch {
50
+ // Not available — fall back to module-level store below.
51
+ }
52
+ // Stryker restore BlockStatement
53
+
54
+ // ── Fallback: module-level store (browser / single-thread) ────────────────────
55
+ // Stryker disable next-line ArrayDeclaration: initial empty arrays are overwritten by _resetPropagationForTests in every test beforeEach
56
+ let _fallbackStore: PropagationStore = { active: {}, stack: [], otelCtxStack: [] };
57
+
58
+ function _getStore(): PropagationStore {
59
+ if (_als) {
60
+ return _als.getStore() ?? _fallbackStore;
61
+ }
62
+ return _fallbackStore;
63
+ }
64
+
65
+ // Stryker disable ConditionalExpression,BlockStatement,ArrayDeclaration: _ensureStore ALS-to-fallback clone path — tested by "clones fallback stack" test; remaining mutants are equivalent because _resetPropagationForTests empties both stores
66
+ function _ensureStore(): PropagationStore {
67
+ if (_als) {
68
+ const store = _als.getStore();
69
+ if (store) return store;
70
+ const next: PropagationStore = {
71
+ active: { ..._fallbackStore.active },
72
+ stack: _fallbackStore.stack.map((entry) => ({ ...entry })),
73
+ otelCtxStack: [..._fallbackStore.otelCtxStack],
74
+ };
75
+ _als.enterWith(next);
76
+ return next;
77
+ }
78
+ return _fallbackStore;
79
+ }
80
+ // Stryker restore ConditionalExpression,BlockStatement,ArrayDeclaration
81
+
82
+ function _parseTraceparent(value: string): { traceId?: string; spanId?: string } {
83
+ const parts = value.split('-');
84
+ if (parts.length !== 4) return {};
85
+ const [version, traceId, spanId] = parts;
86
+ if (version.length !== 2 || traceId.length !== 32 || spanId.length !== 16) return {};
87
+ if (version.toLowerCase() === 'ff') return {};
88
+ if (traceId === '0'.repeat(32) || spanId === '0'.repeat(16)) return {};
89
+ // Validate that all fields are valid hex strings.
90
+ if (
91
+ !/^[0-9a-fA-F]+$/.test(version) ||
92
+ !/^[0-9a-fA-F]+$/.test(traceId) ||
93
+ !/^[0-9a-fA-F]+$/.test(spanId)
94
+ ) {
95
+ return {};
96
+ }
97
+ return { traceId: traceId.toLowerCase(), spanId: spanId.toLowerCase() };
98
+ }
99
+
100
+ /**
101
+ * Extract W3C trace context from an HTTP headers object.
102
+ */
103
+ export function extractW3cContext(headers: Record<string, string>): PropagationContext {
104
+ const lower: Record<string, string> = {};
105
+ for (const [k, v] of Object.entries(headers)) lower[k.toLowerCase()] = v;
106
+
107
+ let rawTraceparent: string | undefined = lower['traceparent'];
108
+ let tracestate: string | undefined = lower['tracestate'];
109
+ let baggage: string | undefined = lower['baggage'];
110
+
111
+ // Stryker disable next-line ConditionalExpression,EqualityOperator,BlockStatement: size guard — >= vs > on boundary is equivalent (512-char valid traceparent doesn't exist)
112
+ if (rawTraceparent !== undefined && rawTraceparent.length > MAX_HEADER_LENGTH) {
113
+ rawTraceparent = undefined;
114
+ }
115
+ if (tracestate !== undefined) {
116
+ if (tracestate.length > MAX_HEADER_LENGTH) {
117
+ tracestate = undefined;
118
+ } else if (tracestate.split(',').length > MAX_TRACESTATE_PAIRS) {
119
+ tracestate = undefined;
120
+ }
121
+ }
122
+ if (baggage !== undefined && baggage.length > MAX_BAGGAGE_LENGTH) {
123
+ baggage = undefined;
124
+ }
125
+
126
+ const { traceId, spanId } = rawTraceparent ? _parseTraceparent(rawTraceparent) : {};
127
+ // Stryker disable next-line LogicalOperator: traceId and spanId are always both defined or both undefined (from _parseTraceparent) — && and || give identical results
128
+ const traceparent = traceId && spanId ? rawTraceparent : undefined;
129
+
130
+ return {
131
+ ...(traceparent !== undefined && { traceparent }),
132
+ ...(tracestate !== undefined && { tracestate }),
133
+ ...(baggage !== undefined && { baggage }),
134
+ ...(traceId !== undefined && { traceId }),
135
+ ...(spanId !== undefined && { spanId }),
136
+ };
137
+ }
138
+
139
+ /**
140
+ * Push ctx onto the propagation stack, making it the active context.
141
+ * When traceparent is present and OTel API is available, extracts an OTel
142
+ * context so that child spans created via withTrace() inherit the parent.
143
+ */
144
+ export function bindPropagationContext(ctx: PropagationContext): void {
145
+ const store = _ensureStore();
146
+ store.stack.push({ ...store.active });
147
+ store.active = { ...store.active, ...ctx };
148
+
149
+ // Wire into OTel context chain when traceparent is present.
150
+ if (ctx.traceparent) {
151
+ try {
152
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
153
+ const otelApi = require('@opentelemetry/api') as {
154
+ propagation: { extract: (ctx: unknown, carrier: Record<string, string>) => unknown };
155
+ context: { active: () => unknown };
156
+ };
157
+ /* Stryker disable all: OTel context wiring — carrier key, extract call, catch/else sentinels are equivalent when OTel SDK behavior varies */
158
+ const carrier: Record<string, string> = { traceparent: ctx.traceparent };
159
+ if (ctx.tracestate) carrier['tracestate'] = ctx.tracestate;
160
+ const extracted = otelApi.propagation.extract(otelApi.context.active(), carrier);
161
+ store.otelCtxStack.push(extracted);
162
+ } catch {
163
+ store.otelCtxStack.push(undefined);
164
+ }
165
+ } else {
166
+ store.otelCtxStack.push(undefined);
167
+ }
168
+ /* Stryker restore all */
169
+ }
170
+
171
+ /**
172
+ * Pop the last saved context, restoring the previous state.
173
+ */
174
+ // Stryker disable BlockStatement
175
+ export function clearPropagationContext(): void {
176
+ const store = _ensureStore();
177
+ // Stryker disable next-line ConditionalExpression,EqualityOperator
178
+ if (store.stack.length > 0) {
179
+ // Stryker enable BlockStatement
180
+ const restored = store.stack.pop();
181
+ /* v8 ignore next */
182
+ store.active = restored ?? {};
183
+ } else {
184
+ // Stryker disable BlockStatement: empty else body is equivalent — active is always {} here because pop() restores prior state
185
+ store.active = {};
186
+ }
187
+ store.otelCtxStack.pop();
188
+ }
189
+ // Stryker enable BlockStatement
190
+
191
+ /** Return the currently active propagation context. */
192
+ export function getActivePropagationContext(): PropagationContext {
193
+ return { ..._getStore().active };
194
+ }
195
+
196
+ /** Return the top of the OTel context stack, or undefined if empty/no OTel wiring. */
197
+ export function getActiveOtelContext(): unknown | undefined {
198
+ const stack = _getStore().otelCtxStack;
199
+ // Stryker disable next-line ConditionalExpression: empty stack returns undefined; removing returns undefined from array[-1] which is also undefined
200
+ if (stack.length === 0) return undefined;
201
+ return stack[stack.length - 1];
202
+ }
203
+
204
+ export function _resetPropagationForTests(): void {
205
+ // Recreate the ALS instance so no enterWith-seeded store leaks between tests.
206
+ // The null branch is only reachable in environments without node:async_hooks (e.g. browsers).
207
+ /* v8 ignore next */
208
+ _als = _AlsConstructor ? new _AlsConstructor() : null;
209
+ _fallbackStore = { active: {}, stack: [], otelCtxStack: [] };
210
+ }
211
+
212
+ /** Disable AsyncLocalStorage for testing the module-level fallback path. */
213
+ export function _disablePropagationALSForTest(): PropagationALS | null {
214
+ const prev = _als;
215
+ _als = null;
216
+ return prev;
217
+ }
218
+
219
+ /** Re-enable AsyncLocalStorage after testing (pass value from _disable call). */
220
+ export function _restorePropagationALSForTest(saved: PropagationALS | null): void {
221
+ _als = saved;
222
+ }
package/src/react.ts ADDED
@@ -0,0 +1,98 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * React 18+ helpers for @provide-io/telemetry.
6
+ *
7
+ * Import from '@provide-io/telemetry/react'.
8
+ * React must be installed as a peer dependency (>=18).
9
+ */
10
+
11
+ import { Component, useEffect } from 'react';
12
+ import type { ErrorInfo, ReactNode } from 'react';
13
+ import { bindContext, unbindContext } from './context';
14
+ import { getLogger } from './logger';
15
+
16
+ // ── useTelemetryContext ──────────────────────────────────────────────────────
17
+
18
+ /**
19
+ * Bind key/value pairs into telemetry context for the lifetime of the component.
20
+ * Cleans up on unmount. Re-runs when values change (content-compared, not by reference).
21
+ */
22
+ export function useTelemetryContext(values: Record<string, unknown>): void {
23
+ // Content-stable dep: avoids re-running when the object reference changes but values are equal.
24
+ // Note: key insertion order affects JSON.stringify — { b:1, a:2 } !== { a:2, b:1 }.
25
+ // Callers that build `values` via dynamic spread should keep key order consistent.
26
+ const serialized = JSON.stringify(values);
27
+
28
+ useEffect(() => {
29
+ const keys = Object.keys(values);
30
+ bindContext(values);
31
+ return () => {
32
+ unbindContext(...keys);
33
+ };
34
+ // `serialized` is the intentional dep — avoids re-running for referentially-new-but-equal
35
+ // objects. `values` is deliberately omitted; the serialized string is the stable proxy.
36
+ }, [serialized]);
37
+ }
38
+
39
+ // ── TelemetryErrorBoundary ───────────────────────────────────────────────────
40
+
41
+ interface TelemetryErrorBoundaryProps {
42
+ children: ReactNode;
43
+ fallback: ReactNode | ((error: Error, reset: () => void) => ReactNode);
44
+ onError?: (error: Error, info: ErrorInfo) => void;
45
+ }
46
+
47
+ interface TelemetryErrorBoundaryState {
48
+ error: Error | null;
49
+ }
50
+
51
+ /**
52
+ * React error boundary that logs caught render errors via getLogger and renders
53
+ * a fallback UI. Accepts a static ReactNode or a render-prop that receives the
54
+ * caught error and a reset callback.
55
+ *
56
+ * Auto-logs to getLogger('react.error_boundary') on every catch. Call onError
57
+ * for any additional handling (alerting, Sentry, etc.).
58
+ */
59
+ export class TelemetryErrorBoundary extends Component<
60
+ TelemetryErrorBoundaryProps,
61
+ TelemetryErrorBoundaryState
62
+ > {
63
+ constructor(props: TelemetryErrorBoundaryProps) {
64
+ super(props);
65
+ this.state = { error: null };
66
+ this.reset = this.reset.bind(this);
67
+ }
68
+
69
+ static getDerivedStateFromError(error: Error): TelemetryErrorBoundaryState {
70
+ return { error };
71
+ }
72
+
73
+ componentDidCatch(error: Error, info: ErrorInfo): void {
74
+ getLogger('react.error_boundary').error({
75
+ event: 'react_error_caught',
76
+ error_message: error.message,
77
+ error_stack: error.stack ?? '',
78
+ component_stack: info.componentStack ?? '',
79
+ });
80
+ this.props.onError?.(error, info);
81
+ }
82
+
83
+ reset(): void {
84
+ this.setState({ error: null });
85
+ }
86
+
87
+ render(): ReactNode {
88
+ const { error } = this.state;
89
+ if (error !== null) {
90
+ const { fallback } = this.props;
91
+ if (typeof fallback === 'function') {
92
+ return fallback(error, this.reset);
93
+ }
94
+ return fallback;
95
+ }
96
+ return this.props.children;
97
+ }
98
+ }
@@ -0,0 +1,97 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Cryptographic redaction receipts — strippable governance module.
6
+ *
7
+ * Registers a receipt hook on the PII engine when enabled.
8
+ * If this file is deleted, the PII engine runs unchanged (hook stays null).
9
+ */
10
+
11
+ import { createHash, createHmac, randomUUID } from 'crypto';
12
+ import { setReceiptHook } from './pii';
13
+
14
+ /** An immutable audit record for a single PII redaction event. */
15
+ export interface RedactionReceipt {
16
+ receiptId: string;
17
+ timestamp: string;
18
+ serviceName: string;
19
+ fieldPath: string;
20
+ action: string;
21
+ originalHash: string;
22
+ hmac: string;
23
+ }
24
+
25
+ let _enabled = false;
26
+ let _signingKey: string | undefined;
27
+ let _serviceName = 'unknown';
28
+ let _testMode = false;
29
+ // Stryker disable next-line ArrayDeclaration
30
+ const _testReceipts: RedactionReceipt[] = [];
31
+
32
+ /** Options for enabling receipt generation. */
33
+ export interface EnableReceiptsOptions {
34
+ enabled: boolean;
35
+ signingKey?: string;
36
+ serviceName?: string;
37
+ }
38
+
39
+ /**
40
+ * Enable or disable receipt generation.
41
+ * When enabled, a hook is registered on the PII engine to capture redaction events.
42
+ */
43
+ export function enableReceipts(options: EnableReceiptsOptions): void {
44
+ _enabled = options.enabled;
45
+ _signingKey = options.signingKey;
46
+ _serviceName = options.serviceName ?? 'unknown';
47
+
48
+ if (_enabled) {
49
+ setReceiptHook(_onRedaction);
50
+ } else {
51
+ setReceiptHook(null);
52
+ }
53
+ }
54
+
55
+ function _onRedaction(fieldPath: string, action: string, originalValue: unknown): void {
56
+ const receiptId = randomUUID();
57
+ const timestamp = new Date().toISOString();
58
+ const originalHash = createHash('sha256').update(String(originalValue)).digest('hex');
59
+
60
+ let hmacValue = '';
61
+ if (_signingKey) {
62
+ const payload = `${receiptId}|${timestamp}|${fieldPath}|${action}|${originalHash}`;
63
+ hmacValue = createHmac('sha256', _signingKey).update(payload).digest('hex');
64
+ }
65
+
66
+ const receipt: RedactionReceipt = {
67
+ receiptId,
68
+ timestamp,
69
+ serviceName: _serviceName,
70
+ fieldPath,
71
+ action,
72
+ originalHash,
73
+ hmac: hmacValue,
74
+ };
75
+
76
+ /* v8 ignore next 3: production-mode receipt emission — not exercised in test mode */
77
+ if (_testMode) {
78
+ _testReceipts.push(receipt);
79
+ }
80
+ // In production mode, receipts would be emitted via the logger.
81
+ // (No-op here when not in test mode — callers integrate with their logging pipeline.)
82
+ }
83
+
84
+ /** Returns receipts collected during test mode. */
85
+ export function getEmittedReceiptsForTests(): RedactionReceipt[] {
86
+ return [..._testReceipts];
87
+ }
88
+
89
+ /** Resets all receipt state and enables test-mode collection. */
90
+ export function resetReceiptsForTests(): void {
91
+ _enabled = false;
92
+ _signingKey = undefined;
93
+ _serviceName = 'unknown';
94
+ _testMode = true;
95
+ _testReceipts.length = 0;
96
+ setReceiptHook(null);
97
+ }