@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
@@ -0,0 +1,220 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Exporter resilience — retry, backoff, timeout, and circuit breaker.
6
+ * Mirrors Python provide.telemetry.resilience.
7
+ */
8
+
9
+ import {
10
+ _exportFailuresField,
11
+ _incrementHealth,
12
+ _recordExportLatency,
13
+ _registerCircuitStateFn,
14
+ _retriesField,
15
+ } from './health';
16
+
17
+ export class TelemetryTimeoutError extends Error {
18
+ constructor(message: string) {
19
+ super(message);
20
+ this.name = 'TelemetryTimeoutError';
21
+ }
22
+ }
23
+
24
+ export interface ExporterPolicy {
25
+ retries: number;
26
+ backoffMs: number;
27
+ timeoutMs: number;
28
+ failOpen: boolean;
29
+ }
30
+
31
+ const DEFAULT_POLICY: ExporterPolicy = {
32
+ retries: 0,
33
+ backoffMs: 0,
34
+ timeoutMs: 10_000,
35
+ failOpen: true,
36
+ };
37
+
38
+ export const CIRCUIT_BREAKER_THRESHOLD = 3;
39
+ export const CIRCUIT_BASE_COOLDOWN_MS = 30_000;
40
+ const CIRCUIT_MAX_COOLDOWN_MS = 1_024_000;
41
+
42
+ // Stryker disable next-line ObjectLiteral
43
+ let _policies: Record<string, ExporterPolicy> = {};
44
+ // Stryker disable next-line ObjectLiteral
45
+ export const _consecutiveTimeouts: Record<string, number> = { logs: 0, traces: 0, metrics: 0 };
46
+ // Stryker disable next-line ObjectLiteral
47
+ export const _circuitTrippedAt: Record<string, number> = { logs: 0, traces: 0, metrics: 0 };
48
+ // Stryker disable next-line ObjectLiteral
49
+ export const _openCount: Record<string, number> = { logs: 0, traces: 0, metrics: 0 };
50
+ /* Stryker disable BooleanLiteral: initial false values are reset by _resetResilienceForTests before each test — equivalent mutant */
51
+ // Stryker disable next-line ObjectLiteral
52
+ export const _halfOpenProbing: Record<string, boolean> = {
53
+ logs: false,
54
+ traces: false,
55
+ metrics: false,
56
+ };
57
+ /* Stryker restore BooleanLiteral */
58
+
59
+ export function setExporterPolicy(signal: string, policy: Partial<ExporterPolicy>): void {
60
+ _policies[signal] = { ...DEFAULT_POLICY, ...policy };
61
+ }
62
+
63
+ export function getExporterPolicy(signal: string): ExporterPolicy {
64
+ return { ...(_policies[signal] ?? DEFAULT_POLICY) };
65
+ }
66
+
67
+ // Stryker disable next-line BlockStatement
68
+ function _sleep(ms: number): Promise<void> {
69
+ return new Promise((resolve) => setTimeout(resolve, ms));
70
+ }
71
+
72
+ async function _withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T> {
73
+ // Stryker disable next-line ConditionalExpression,EqualityOperator
74
+ if (timeoutMs <= 0) return fn();
75
+ return new Promise<T>((resolve, reject) => {
76
+ const timer = setTimeout(() => {
77
+ // Stryker disable next-line StringLiteral: timeout error message content is not tested
78
+ reject(new TelemetryTimeoutError(`operation timed out after ${timeoutMs}ms`));
79
+ }, timeoutMs);
80
+ fn().then(
81
+ (val) => {
82
+ clearTimeout(timer);
83
+ resolve(val);
84
+ },
85
+ (err: unknown) => {
86
+ clearTimeout(timer);
87
+ reject(err);
88
+ },
89
+ );
90
+ });
91
+ }
92
+
93
+ export async function runWithResilience<T>(
94
+ signal: string,
95
+ fn: () => Promise<T>,
96
+ ): Promise<T | null> {
97
+ const policy = _policies[signal] ?? { ...DEFAULT_POLICY };
98
+ const attempts = Math.max(1, policy.retries + 1);
99
+
100
+ // Ensure per-signal dicts are initialized for custom signals.
101
+ if (!(signal in _openCount)) _openCount[signal] = 0;
102
+ // Stryker disable next-line ConditionalExpression: custom signal init — skipping is equivalent since undefined is falsy like false
103
+ if (!(signal in _halfOpenProbing)) _halfOpenProbing[signal] = false;
104
+
105
+ // Circuit breaker check.
106
+ const failField = _exportFailuresField(signal);
107
+ const retryField = _retriesField(signal);
108
+ // Stryker disable next-line ConditionalExpression
109
+ if (_consecutiveTimeouts[signal] >= CIRCUIT_BREAKER_THRESHOLD) {
110
+ // Reject concurrent callers while a half-open probe is already in flight.
111
+ if (_halfOpenProbing[signal]) {
112
+ _incrementHealth(failField);
113
+ if (policy.failOpen) return null;
114
+ throw new TelemetryTimeoutError('circuit breaker open: probe in progress');
115
+ }
116
+ const cooldown = Math.min(
117
+ CIRCUIT_BASE_COOLDOWN_MS * 2 ** _openCount[signal],
118
+ CIRCUIT_MAX_COOLDOWN_MS,
119
+ );
120
+ const elapsed = Date.now() - _circuitTrippedAt[signal];
121
+ if (elapsed < cooldown) {
122
+ _incrementHealth(failField);
123
+ if (policy.failOpen) return null;
124
+ throw new TelemetryTimeoutError('circuit breaker open: too many consecutive timeouts');
125
+ }
126
+ // Half-open: cooldown elapsed — allow one probe.
127
+ _halfOpenProbing[signal] = true;
128
+ }
129
+
130
+ let lastError: Error | null = null;
131
+
132
+ for (let attempt = 0; attempt < attempts; attempt++) {
133
+ const started = Date.now();
134
+ try {
135
+ const result = await _withTimeout(fn, policy.timeoutMs);
136
+ _recordExportLatency(signal, Date.now() - started);
137
+ if (_halfOpenProbing[signal]) {
138
+ _halfOpenProbing[signal] = false;
139
+ _consecutiveTimeouts[signal] = 0;
140
+ _openCount[signal] = Math.max(0, _openCount[signal] - 1);
141
+ // Stryker disable next-line BlockStatement: else-block body on non-probe success — removing is equivalent since timeouts are already 0 on fresh closed circuit
142
+ } else {
143
+ _consecutiveTimeouts[signal] = 0;
144
+ }
145
+ return result;
146
+ } catch (err) {
147
+ lastError = err instanceof Error ? err : new Error(String(err));
148
+ _incrementHealth(failField);
149
+
150
+ if (err instanceof TelemetryTimeoutError) {
151
+ if (_halfOpenProbing[signal]) {
152
+ _halfOpenProbing[signal] = false;
153
+ _openCount[signal] += 1;
154
+ _circuitTrippedAt[signal] = Date.now();
155
+ } else {
156
+ _consecutiveTimeouts[signal] = (_consecutiveTimeouts[signal] ?? 0) + 1;
157
+ // Stryker disable next-line ConditionalExpression,EqualityOperator
158
+ if (_consecutiveTimeouts[signal] >= CIRCUIT_BREAKER_THRESHOLD) {
159
+ _openCount[signal] += 1;
160
+ _circuitTrippedAt[signal] = Date.now();
161
+ }
162
+ }
163
+ } else if (_halfOpenProbing[signal]) {
164
+ _halfOpenProbing[signal] = false;
165
+ _openCount[signal] += 1;
166
+ _circuitTrippedAt[signal] = Date.now();
167
+ } else {
168
+ _consecutiveTimeouts[signal] = 0;
169
+ }
170
+
171
+ // Stryker disable next-line ArithmeticOperator
172
+ if (attempt < attempts - 1) {
173
+ _incrementHealth(retryField);
174
+ // Stryker disable next-line ConditionalExpression,EqualityOperator
175
+ if (policy.backoffMs > 0) await _sleep(policy.backoffMs);
176
+ }
177
+ }
178
+ }
179
+
180
+ if (policy.failOpen) return null;
181
+ // Stryker disable next-line StringLiteral: unreachable fallback — lastError is always set by the catch block above
182
+ /* v8 ignore next */
183
+ throw lastError ?? new Error('all retry attempts failed');
184
+ }
185
+
186
+ export interface CircuitState {
187
+ state: string;
188
+ openCount: number;
189
+ cooldownRemainingMs: number;
190
+ }
191
+
192
+ export function getCircuitState(signal: string): CircuitState {
193
+ const openCount = _openCount[signal] ?? 0;
194
+ if (_halfOpenProbing[signal]) {
195
+ return { state: 'half-open', openCount, cooldownRemainingMs: 0 };
196
+ }
197
+ if ((_consecutiveTimeouts[signal] ?? 0) >= CIRCUIT_BREAKER_THRESHOLD) {
198
+ const cooldown = Math.min(CIRCUIT_BASE_COOLDOWN_MS * 2 ** openCount, CIRCUIT_MAX_COOLDOWN_MS);
199
+ const remaining = cooldown - (Date.now() - _circuitTrippedAt[signal]);
200
+ // Stryker disable next-line EqualityOperator: > 0 vs >= 0 — exact millisecond boundary P≈0
201
+ if (remaining > 0) {
202
+ return { state: 'open', openCount, cooldownRemainingMs: remaining };
203
+ }
204
+ return { state: 'half-open', openCount, cooldownRemainingMs: 0 };
205
+ }
206
+ return { state: 'closed', openCount, cooldownRemainingMs: 0 };
207
+ }
208
+
209
+ // Register with health module to break circular dependency.
210
+ _registerCircuitStateFn(getCircuitState);
211
+
212
+ export function _resetResilienceForTests(): void {
213
+ _policies = {};
214
+ for (const k of ['logs', 'traces', 'metrics']) {
215
+ _consecutiveTimeouts[k] = 0;
216
+ _circuitTrippedAt[k] = 0;
217
+ _openCount[k] = 0;
218
+ _halfOpenProbing[k] = false;
219
+ }
220
+ }
package/src/runtime.ts ADDED
@@ -0,0 +1,171 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Runtime reconfiguration helpers.
6
+ * Mirrors Python provide.telemetry.runtime.
7
+ */
8
+
9
+ import {
10
+ type RuntimeOverrides,
11
+ type TelemetryConfig,
12
+ configFromEnv,
13
+ setupTelemetry,
14
+ } from './config';
15
+
16
+ /** Minimal interface for providers that can be flushed and shut down cleanly. */
17
+ export interface ShutdownableProvider {
18
+ forceFlush?(): Promise<void>;
19
+ shutdown?(): Promise<void>;
20
+ }
21
+
22
+ let _activeConfig: TelemetryConfig | null = null;
23
+ // Stryker disable next-line BooleanLiteral: initial false is overwritten by _resetRuntimeForTests() in every test beforeEach — equivalent mutant
24
+ let _providersRegistered = false;
25
+ // Stryker disable next-line ArrayDeclaration: initial [] is overwritten by _resetRuntimeForTests() in every test beforeEach — equivalent mutant
26
+ let _registeredProviders: ShutdownableProvider[] = [];
27
+
28
+ /** Store the live providers so shutdownTelemetry can flush and drain them. */
29
+ export function _storeRegisteredProviders(providers: ShutdownableProvider[]): void {
30
+ _registeredProviders = providers;
31
+ }
32
+
33
+ /** Return the currently registered providers (snapshot). */
34
+ export function _getRegisteredProviders(): ShutdownableProvider[] {
35
+ return [..._registeredProviders];
36
+ }
37
+
38
+ /** Called by registerOtelProviders once providers are live. */
39
+ export function _markProvidersRegistered(): void {
40
+ _providersRegistered = true;
41
+ }
42
+
43
+ /** Return true if OTEL providers have been registered. */
44
+ export function _areProvidersRegistered(): boolean {
45
+ return _providersRegistered;
46
+ }
47
+
48
+ function deepFreeze<T extends object>(obj: T): Readonly<T> {
49
+ for (const val of Object.values(obj)) {
50
+ if (typeof val === 'object' && val !== null && !Object.isFrozen(val)) {
51
+ deepFreeze(val as object);
52
+ }
53
+ }
54
+ return Object.freeze(obj);
55
+ }
56
+
57
+ /** Return the active runtime config (or env-derived defaults if none set). */
58
+ export function getRuntimeConfig(): Readonly<TelemetryConfig> {
59
+ const cfg = _activeConfig ?? configFromEnv();
60
+ return deepFreeze({ ...cfg });
61
+ }
62
+
63
+ /** Merge hot-reloadable overrides into the active config and re-apply policies. */
64
+ export function updateRuntimeConfig(overrides: RuntimeOverrides): void {
65
+ const base = _activeConfig ?? configFromEnv();
66
+ const merged: TelemetryConfig = { ...base };
67
+ for (const [key, value] of Object.entries(overrides)) {
68
+ if (value !== undefined) {
69
+ (merged as unknown as Record<string, unknown>)[key] = value;
70
+ }
71
+ }
72
+ _activeConfig = merged;
73
+ setupTelemetry(_activeConfig);
74
+ }
75
+
76
+ const _COLD_FIELDS: (keyof TelemetryConfig)[] = [
77
+ 'serviceName',
78
+ 'environment',
79
+ 'version',
80
+ 'otelEnabled',
81
+ 'otlpEndpoint',
82
+ 'otlpHeaders',
83
+ ];
84
+
85
+ /** Reload config from env vars and apply only hot-reloadable fields. */
86
+ export function reloadRuntimeFromEnv(): void {
87
+ const fresh = configFromEnv();
88
+ const current = _activeConfig;
89
+ if (current) {
90
+ const drifted = _COLD_FIELDS.filter(
91
+ (k) => JSON.stringify(current[k]) !== JSON.stringify(fresh[k]),
92
+ );
93
+ if (drifted.length > 0) {
94
+ console.warn(
95
+ '[provide-telemetry] runtime.cold_field_drift:',
96
+ drifted.join(', '),
97
+ '— restart required to apply',
98
+ );
99
+ }
100
+ }
101
+ // Apply only hot fields via overrides
102
+ const overrides: RuntimeOverrides = {
103
+ samplingLogsRate: fresh.samplingLogsRate,
104
+ samplingTracesRate: fresh.samplingTracesRate,
105
+ samplingMetricsRate: fresh.samplingMetricsRate,
106
+ backpressureLogsMaxsize: fresh.backpressureLogsMaxsize,
107
+ backpressureTracesMaxsize: fresh.backpressureTracesMaxsize,
108
+ backpressureMetricsMaxsize: fresh.backpressureMetricsMaxsize,
109
+ exporterLogsRetries: fresh.exporterLogsRetries,
110
+ exporterLogsBackoffMs: fresh.exporterLogsBackoffMs,
111
+ exporterLogsTimeoutMs: fresh.exporterLogsTimeoutMs,
112
+ exporterLogsFailOpen: fresh.exporterLogsFailOpen,
113
+ exporterTracesRetries: fresh.exporterTracesRetries,
114
+ exporterTracesBackoffMs: fresh.exporterTracesBackoffMs,
115
+ exporterTracesTimeoutMs: fresh.exporterTracesTimeoutMs,
116
+ exporterTracesFailOpen: fresh.exporterTracesFailOpen,
117
+ exporterMetricsRetries: fresh.exporterMetricsRetries,
118
+ exporterMetricsBackoffMs: fresh.exporterMetricsBackoffMs,
119
+ exporterMetricsTimeoutMs: fresh.exporterMetricsTimeoutMs,
120
+ exporterMetricsFailOpen: fresh.exporterMetricsFailOpen,
121
+ securityMaxAttrValueLength: fresh.securityMaxAttrValueLength,
122
+ securityMaxAttrCount: fresh.securityMaxAttrCount,
123
+ sloEnableRedMetrics: fresh.sloEnableRedMetrics,
124
+ sloEnableUseMetrics: fresh.sloEnableUseMetrics,
125
+ piiMaxDepth: fresh.piiMaxDepth,
126
+ };
127
+ updateRuntimeConfig(overrides);
128
+ }
129
+
130
+ const PROVIDER_CHANGING_FIELDS: (keyof TelemetryConfig)[] = [
131
+ 'otelEnabled',
132
+ 'otlpEndpoint',
133
+ 'otlpHeaders',
134
+ ];
135
+
136
+ /**
137
+ * Apply config changes.
138
+ * If provider-changing fields differ and providers are already registered, performs a
139
+ * best-effort shutdown (fire-and-forget) then re-initialises — matching Go/Python behaviour.
140
+ * Otherwise delegates to setupTelemetry.
141
+ */
142
+ export function reconfigureTelemetry(config: Partial<TelemetryConfig>): void {
143
+ const current = getRuntimeConfig();
144
+ const proposed: TelemetryConfig = { ...current, ...config };
145
+
146
+ if (_providersRegistered) {
147
+ const changed = PROVIDER_CHANGING_FIELDS.some(
148
+ (k) => JSON.stringify(current[k]) !== JSON.stringify(proposed[k]),
149
+ );
150
+ if (changed) {
151
+ // Best-effort async shutdown — fire-and-forget, errors ignored (mirrors Go's `_ = ShutdownTelemetry(ctx)`)
152
+ const providers = _getRegisteredProviders();
153
+ // Stryker disable LogicalOperator: ?? vs && is equivalent here — forceFlush/shutdown return Promise (truthy) so && still resolves; when undefined, Promise.allSettled wraps both in Promise.resolve
154
+ void Promise.allSettled(providers.map((p) => p.forceFlush?.() ?? Promise.resolve())).then(
155
+ () => Promise.allSettled(providers.map((p) => p.shutdown?.() ?? Promise.resolve())),
156
+ );
157
+ // Stryker restore LogicalOperator
158
+ _providersRegistered = false;
159
+ _registeredProviders = [];
160
+ }
161
+ }
162
+
163
+ setupTelemetry(proposed);
164
+ _activeConfig = proposed;
165
+ }
166
+
167
+ export function _resetRuntimeForTests(): void {
168
+ _activeConfig = null;
169
+ _providersRegistered = false;
170
+ _registeredProviders = [];
171
+ }
@@ -0,0 +1,68 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Runtime sampling policy — mirrors Python provide.telemetry.sampling.
6
+ */
7
+
8
+ import { ConfigurationError } from './exceptions';
9
+
10
+ export interface SamplingPolicy {
11
+ defaultRate: number;
12
+ overrides?: Record<string, number>;
13
+ }
14
+
15
+ const DEFAULT_POLICY: SamplingPolicy = { defaultRate: 1.0 };
16
+ let _policies: Record<string, SamplingPolicy> = {};
17
+
18
+ const VALID_SIGNALS = new Set(['logs', 'traces', 'metrics']);
19
+
20
+ function _validateSignal(signal: string): void {
21
+ if (!VALID_SIGNALS.has(signal)) {
22
+ throw new ConfigurationError(
23
+ `unknown signal "${signal}", expected one of [logs, metrics, traces]`,
24
+ );
25
+ }
26
+ }
27
+
28
+ function _clamp(rate: number): number {
29
+ return Math.max(0, Math.min(1, rate));
30
+ }
31
+
32
+ export function setSamplingPolicy(signal: string, policy: SamplingPolicy): void {
33
+ _validateSignal(signal);
34
+ _policies[signal] = {
35
+ defaultRate: _clamp(policy.defaultRate),
36
+ overrides: policy.overrides
37
+ ? Object.fromEntries(Object.entries(policy.overrides).map(([k, v]) => [k, _clamp(v)]))
38
+ : undefined,
39
+ };
40
+ }
41
+
42
+ export function getSamplingPolicy(signal: string): SamplingPolicy {
43
+ _validateSignal(signal);
44
+ const _policy = _policies[signal] ?? DEFAULT_POLICY;
45
+ return {
46
+ defaultRate: _policy.defaultRate,
47
+ overrides: _policy.overrides ? { ..._policy.overrides } : undefined,
48
+ };
49
+ }
50
+
51
+ export function shouldSample(signal: string, key?: string): boolean {
52
+ _validateSignal(signal);
53
+ const _policy = _policies[signal] ?? DEFAULT_POLICY;
54
+ const overrides = _policy.overrides;
55
+ const lookupKey = key ?? signal;
56
+ const rate = overrides && lookupKey in overrides ? overrides[lookupKey] : _policy.defaultRate;
57
+ const clamped = _clamp(rate);
58
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent mutant — Math.random() in [0,1) so boundary is not observable
59
+ if (clamped <= 0) return false;
60
+ // Stryker disable next-line ConditionalExpression,EqualityOperator: equivalent mutant — Math.random() in [0,1) so boundary is not observable
61
+ if (clamped >= 1) return true;
62
+ // Stryker disable next-line EqualityOperator: Math.random() is in [0,1) so < 1.0 and <= 1.0 are equivalent (random never equals 1.0)
63
+ return Math.random() < clamped;
64
+ }
65
+
66
+ export function _resetSamplingForTests(): void {
67
+ _policies = {};
68
+ }
@@ -0,0 +1,8 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Re-exports sanitize() and DEFAULT_SANITIZE_FIELDS from pii.ts for backwards compatibility.
6
+ * New code should import from './pii' directly.
7
+ */
8
+ export { sanitize, DEFAULT_SANITIZE_FIELDS } from './pii';
package/src/schema.ts ADDED
@@ -0,0 +1,135 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * Event schema validation — mirrors Python provide.telemetry.schema.events.
6
+ */
7
+
8
+ import { getConfig } from './config';
9
+ import { TelemetryError } from './exceptions';
10
+
11
+ export class EventSchemaError extends TelemetryError {
12
+ constructor(message?: string) {
13
+ super(message);
14
+ this.name = 'EventSchemaError';
15
+ }
16
+ }
17
+
18
+ const SEGMENT_RE = /^[a-z][a-z0-9_]*$/;
19
+ const MIN_SEGMENTS = 3;
20
+ const MAX_SEGMENTS = 5;
21
+
22
+ /**
23
+ * Structured event record returned by event().
24
+ * Fields spread directly into pino log objects.
25
+ */
26
+ export interface EventRecord {
27
+ event: string;
28
+ domain: string;
29
+ action: string;
30
+ resource?: string;
31
+ status: string;
32
+ }
33
+
34
+ /**
35
+ * Build a structured EventRecord from 3 (DAS) or 4 (DARS) segments.
36
+ *
37
+ * In strict mode: validates each segment matches /^[a-z][a-z0-9_]*$/.
38
+ *
39
+ * Usage: `log.info({ ...event('auth', 'login', 'success'), userId: '123' })`
40
+ */
41
+ export function event(...segments: string[]): EventRecord {
42
+ if (segments.length !== 3 && segments.length !== 4) {
43
+ throw new EventSchemaError(`event() requires 3 or 4 segments (DA[R]S), got ${segments.length}`);
44
+ }
45
+
46
+ const strict = getConfig().strictSchema;
47
+ if (strict) {
48
+ for (const seg of segments) {
49
+ if (!SEGMENT_RE.test(seg)) {
50
+ throw new EventSchemaError(`segment '${seg}' does not match pattern ^[a-z][a-z0-9_]*$`);
51
+ }
52
+ }
53
+ }
54
+
55
+ const name = segments.join('.');
56
+
57
+ if (segments.length === 3) {
58
+ return { event: name, domain: segments[0], action: segments[1], status: segments[2] };
59
+ }
60
+ return {
61
+ event: name,
62
+ domain: segments[0],
63
+ action: segments[1],
64
+ resource: segments[2],
65
+ status: segments[3],
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Build a dot-joined event name string from segments.
71
+ * In strict mode (default): enforces 3–5 segments, each matching /^[a-z][a-z0-9_]*$/.
72
+ * In relaxed mode: requires at least 1 segment, skips count and format checks.
73
+ */
74
+ export function eventName(...segments: string[]): string {
75
+ if (segments.length === 0) {
76
+ // Stryker disable next-line StringLiteral: error message content doesn't affect behavior
77
+ throw new EventSchemaError(`expected ${MIN_SEGMENTS}-${MAX_SEGMENTS} segments, got 0`);
78
+ }
79
+ const strict = getConfig().strictSchema;
80
+ if (strict) {
81
+ if (segments.length < MIN_SEGMENTS || segments.length > MAX_SEGMENTS) {
82
+ throw new EventSchemaError(
83
+ `expected ${MIN_SEGMENTS}-${MAX_SEGMENTS} segments, got ${segments.length}`,
84
+ );
85
+ }
86
+ // Stryker disable next-line EqualityOperator: segments[length] is undefined; SEGMENT_RE.test('undefined') returns true so no extra throw — equivalent
87
+ for (let i = 0; i < segments.length; i++) {
88
+ if (!SEGMENT_RE.test(segments[i])) {
89
+ // Stryker disable next-line StringLiteral
90
+ throw new EventSchemaError(`invalid event segment: segment[${i}]=${segments[i]}`);
91
+ }
92
+ }
93
+ }
94
+ return segments.join('.');
95
+ }
96
+
97
+ /**
98
+ * Validate an already-assembled event name string.
99
+ * In strict mode (default), enforces 3–5 dot-separated lowercase segments.
100
+ * In relaxed mode, only checks that each segment is non-empty.
101
+ */
102
+ export function validateEventName(name: string, strict: boolean = true): void {
103
+ const segments = name.split('.');
104
+ if (strict) {
105
+ if (segments.length < MIN_SEGMENTS || segments.length > MAX_SEGMENTS) {
106
+ // Stryker disable next-line StringLiteral
107
+ throw new EventSchemaError(
108
+ `expected ${MIN_SEGMENTS}-${MAX_SEGMENTS} segments, got ${segments.length}`,
109
+ );
110
+ }
111
+ // Stryker disable next-line EqualityOperator: same as above — undefined segment passes SEGMENT_RE
112
+ for (let i = 0; i < segments.length; i++) {
113
+ if (!SEGMENT_RE.test(segments[i])) {
114
+ // Stryker disable next-line StringLiteral
115
+ throw new EventSchemaError(`invalid event segment: segment[${i}]=${segments[i]}`);
116
+ }
117
+ }
118
+ } else {
119
+ // Stryker disable next-line ConditionalExpression: an empty input 'split' always has length >= 1; the `< 1` check is never triggered and is unreachable
120
+ if (segments.length < 1 || segments.some((s) => s.length === 0)) {
121
+ throw new EventSchemaError('event name must have at least one non-empty segment');
122
+ }
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Verify that all required keys are present in obj.
128
+ * Throws EventSchemaError listing the missing keys.
129
+ */
130
+ export function validateRequiredKeys(obj: Record<string, unknown>, keys: string[]): void {
131
+ const missing = keys.filter((k) => !(k in obj));
132
+ if (missing.length > 0) {
133
+ throw new EventSchemaError(`missing required keys: ${missing.sort().join(', ')}`);
134
+ }
135
+ }
@@ -0,0 +1,18 @@
1
+ // SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+
4
+ /**
5
+ * shutdownTelemetry — flushes and shuts down any OTEL providers registered by
6
+ * registerOtelProviders. Safe to call before process exit or on hot-reload.
7
+ *
8
+ * Uses Promise.allSettled so a failure in one provider's forceFlush/shutdown
9
+ * does not prevent the others from draining.
10
+ */
11
+
12
+ import { _getRegisteredProviders } from './runtime';
13
+
14
+ export async function shutdownTelemetry(): Promise<void> {
15
+ const providers = _getRegisteredProviders();
16
+ await Promise.allSettled(providers.map((p) => p.forceFlush?.() ?? Promise.resolve()));
17
+ await Promise.allSettled(providers.map((p) => p.shutdown?.() ?? Promise.resolve()));
18
+ }