@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.
- package/README.md +247 -0
- package/dist/backpressure.d.ts +19 -0
- package/dist/backpressure.d.ts.map +1 -0
- package/dist/backpressure.js +51 -0
- package/dist/cardinality.d.ts +15 -0
- package/dist/cardinality.d.ts.map +1 -0
- package/dist/cardinality.js +69 -0
- package/dist/classification.d.ts +29 -0
- package/dist/classification.d.ts.map +1 -0
- package/dist/classification.js +58 -0
- package/dist/config.d.ts +156 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +350 -0
- package/dist/consent.d.ts +11 -0
- package/dist/consent.d.ts.map +1 -0
- package/dist/consent.js +50 -0
- package/dist/context.d.ts +60 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +127 -0
- package/dist/exceptions.d.ts +14 -0
- package/dist/exceptions.d.ts.map +1 -0
- package/dist/exceptions.js +21 -0
- package/dist/fingerprint.d.ts +5 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +50 -0
- package/dist/hash.d.ts +8 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +102 -0
- package/dist/health.d.ts +54 -0
- package/dist/health.d.ts.map +1 -0
- package/dist/health.js +102 -0
- package/dist/index.d.ts +52 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +59 -0
- package/dist/logger.d.ts +28 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +254 -0
- package/dist/metrics.d.ts +78 -0
- package/dist/metrics.d.ts.map +1 -0
- package/dist/metrics.js +238 -0
- package/dist/otel-logs.d.ts +29 -0
- package/dist/otel-logs.d.ts.map +1 -0
- package/dist/otel-logs.js +127 -0
- package/dist/otel-noop.d.ts +13 -0
- package/dist/otel-noop.d.ts.map +1 -0
- package/dist/otel-noop.js +5 -0
- package/dist/otel.d.ts +20 -0
- package/dist/otel.d.ts.map +1 -0
- package/dist/otel.js +80 -0
- package/dist/pii.d.ts +43 -0
- package/dist/pii.d.ts.map +1 -0
- package/dist/pii.js +278 -0
- package/dist/pretty.d.ts +12 -0
- package/dist/pretty.d.ts.map +1 -0
- package/dist/pretty.js +85 -0
- package/dist/propagation.d.ts +52 -0
- package/dist/propagation.d.ts.map +1 -0
- package/dist/propagation.js +183 -0
- package/dist/react.d.ts +38 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +72 -0
- package/dist/receipts.d.ts +26 -0
- package/dist/receipts.d.ts.map +1 -0
- package/dist/receipts.js +69 -0
- package/dist/resilience.d.ts +26 -0
- package/dist/resilience.d.ts.map +1 -0
- package/dist/resilience.js +183 -0
- package/dist/runtime.d.ts +33 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +133 -0
- package/dist/sampling.d.ts +9 -0
- package/dist/sampling.d.ts.map +1 -0
- package/dist/sampling.js +53 -0
- package/dist/sanitize.d.ts +6 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +7 -0
- package/dist/schema.d.ts +41 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +109 -0
- package/dist/shutdown.d.ts +2 -0
- package/dist/shutdown.d.ts.map +1 -0
- package/dist/shutdown.js +15 -0
- package/dist/slo.d.ts +25 -0
- package/dist/slo.d.ts.map +1 -0
- package/dist/slo.js +115 -0
- package/dist/testing.d.ts +10 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +51 -0
- package/dist/tracing.d.ts +51 -0
- package/dist/tracing.d.ts.map +1 -0
- package/dist/tracing.js +181 -0
- package/package.json +139 -0
- package/src/backpressure.ts +68 -0
- package/src/cardinality.ts +83 -0
- package/src/classification.ts +87 -0
- package/src/config.ts +589 -0
- package/src/consent.ts +61 -0
- package/src/context.ts +157 -0
- package/src/exceptions.ts +24 -0
- package/src/fingerprint.ts +53 -0
- package/src/hash.ts +118 -0
- package/src/health.ts +175 -0
- package/src/index.ts +183 -0
- package/src/logger.ts +287 -0
- package/src/metrics.ts +204 -0
- package/src/otel-logs.ts +161 -0
- package/src/otel-noop.ts +19 -0
- package/src/otel.ts +112 -0
- package/src/pii.ts +358 -0
- package/src/pretty.ts +93 -0
- package/src/propagation.ts +222 -0
- package/src/react.ts +98 -0
- package/src/receipts.ts +97 -0
- package/src/resilience.ts +220 -0
- package/src/runtime.ts +171 -0
- package/src/sampling.ts +68 -0
- package/src/sanitize.ts +8 -0
- package/src/schema.ts +135 -0
- package/src/shutdown.ts +18 -0
- package/src/slo.ts +156 -0
- package/src/testing.ts +56 -0
- package/src/tracing.ts +211 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @provide-io/telemetry — TypeScript structured logging + OTEL
|
|
6
|
+
*
|
|
7
|
+
* Feature parity with the Python provide.telemetry package.
|
|
8
|
+
*
|
|
9
|
+
* Quick start:
|
|
10
|
+
* import { setupTelemetry, getLogger, bindContext } from '@provide-io/telemetry';
|
|
11
|
+
*
|
|
12
|
+
* setupTelemetry({ serviceName: 'my-app', logLevel: 'debug' });
|
|
13
|
+
* const log = getLogger('api');
|
|
14
|
+
* log.info({ event: 'request_ok', method: 'GET', path: '/api/v1/users', status: 200 });
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// Config + setup
|
|
18
|
+
export {
|
|
19
|
+
setupTelemetry,
|
|
20
|
+
applyConfigPolicies,
|
|
21
|
+
getConfig,
|
|
22
|
+
configFromEnv,
|
|
23
|
+
parseOtlpHeaders,
|
|
24
|
+
redactConfig,
|
|
25
|
+
version,
|
|
26
|
+
__version__,
|
|
27
|
+
} from './config';
|
|
28
|
+
export type { TelemetryConfig, RuntimeOverrides } from './config';
|
|
29
|
+
|
|
30
|
+
// Logger
|
|
31
|
+
export { getLogger, logger } from './logger';
|
|
32
|
+
export type { Logger } from './logger';
|
|
33
|
+
|
|
34
|
+
// Context binding (mirrors Python bind_context / unbind_context / clear_context)
|
|
35
|
+
export {
|
|
36
|
+
bindContext,
|
|
37
|
+
unbindContext,
|
|
38
|
+
clearContext,
|
|
39
|
+
getContext,
|
|
40
|
+
runWithContext,
|
|
41
|
+
bindSessionContext,
|
|
42
|
+
getSessionId,
|
|
43
|
+
clearSessionContext,
|
|
44
|
+
} from './context';
|
|
45
|
+
|
|
46
|
+
// Error fingerprinting (mirrors Python add_error_fingerprint processor)
|
|
47
|
+
export { computeErrorFingerprint } from './fingerprint';
|
|
48
|
+
|
|
49
|
+
// Pretty ANSI renderer (mirrors Python PrettyRenderer)
|
|
50
|
+
export { formatPretty, supportsColor } from './pretty';
|
|
51
|
+
|
|
52
|
+
// Metrics (mirrors Python counter / gauge / histogram)
|
|
53
|
+
export {
|
|
54
|
+
counter,
|
|
55
|
+
gauge,
|
|
56
|
+
histogram,
|
|
57
|
+
getMeter,
|
|
58
|
+
CounterInstrument,
|
|
59
|
+
GaugeInstrument,
|
|
60
|
+
HistogramInstrument,
|
|
61
|
+
} from './metrics';
|
|
62
|
+
export type { Counter, Histogram, Meter, MetricOptions, UpDownCounter } from './metrics';
|
|
63
|
+
|
|
64
|
+
// Tracing (mirrors Python @trace decorator)
|
|
65
|
+
export {
|
|
66
|
+
withTrace,
|
|
67
|
+
traceDecorator as trace,
|
|
68
|
+
getActiveTraceIds,
|
|
69
|
+
getTracer,
|
|
70
|
+
tracer,
|
|
71
|
+
setTraceContext,
|
|
72
|
+
getTraceContext,
|
|
73
|
+
} from './tracing';
|
|
74
|
+
|
|
75
|
+
// Optional OTEL SDK wiring (call after setupTelemetry to activate exporters)
|
|
76
|
+
export { registerOtelProviders } from './otel';
|
|
77
|
+
|
|
78
|
+
// PII sanitization utilities
|
|
79
|
+
export {
|
|
80
|
+
sanitize,
|
|
81
|
+
DEFAULT_SANITIZE_FIELDS,
|
|
82
|
+
sanitizePayload,
|
|
83
|
+
registerPiiRule,
|
|
84
|
+
getPiiRules,
|
|
85
|
+
replacePiiRules,
|
|
86
|
+
resetPiiRulesForTests,
|
|
87
|
+
} from './pii';
|
|
88
|
+
export type { MaskMode, PIIRule, SanitizePayloadOptions } from './pii';
|
|
89
|
+
|
|
90
|
+
// Exceptions
|
|
91
|
+
export { TelemetryError, ConfigurationError } from './exceptions';
|
|
92
|
+
|
|
93
|
+
// Health
|
|
94
|
+
export { getHealthSnapshot, setSetupError } from './health';
|
|
95
|
+
export type { HealthSnapshot } from './health';
|
|
96
|
+
|
|
97
|
+
// Backpressure
|
|
98
|
+
export { setQueuePolicy, getQueuePolicy, tryAcquire, release } from './backpressure';
|
|
99
|
+
export type { QueuePolicy, QueueTicket } from './backpressure';
|
|
100
|
+
|
|
101
|
+
// Cardinality
|
|
102
|
+
export {
|
|
103
|
+
OVERFLOW_VALUE,
|
|
104
|
+
registerCardinalityLimit,
|
|
105
|
+
getCardinalityLimits,
|
|
106
|
+
clearCardinalityLimits,
|
|
107
|
+
guardAttributes,
|
|
108
|
+
} from './cardinality';
|
|
109
|
+
export type { CardinalityLimit } from './cardinality';
|
|
110
|
+
|
|
111
|
+
// Sampling
|
|
112
|
+
export { setSamplingPolicy, getSamplingPolicy, shouldSample } from './sampling';
|
|
113
|
+
export type { SamplingPolicy } from './sampling';
|
|
114
|
+
|
|
115
|
+
// Resilience
|
|
116
|
+
export {
|
|
117
|
+
setExporterPolicy,
|
|
118
|
+
getExporterPolicy,
|
|
119
|
+
runWithResilience,
|
|
120
|
+
getCircuitState,
|
|
121
|
+
TelemetryTimeoutError,
|
|
122
|
+
} from './resilience';
|
|
123
|
+
export type { ExporterPolicy, CircuitState } from './resilience';
|
|
124
|
+
|
|
125
|
+
// Schema
|
|
126
|
+
export {
|
|
127
|
+
EventSchemaError,
|
|
128
|
+
event,
|
|
129
|
+
eventName,
|
|
130
|
+
validateEventName,
|
|
131
|
+
validateRequiredKeys,
|
|
132
|
+
} from './schema';
|
|
133
|
+
export type { EventRecord } from './schema';
|
|
134
|
+
|
|
135
|
+
// SLO
|
|
136
|
+
export { recordRedMetrics, recordUseMetrics, classifyError } from './slo';
|
|
137
|
+
export type { ErrorClassification } from './slo';
|
|
138
|
+
|
|
139
|
+
// Propagation
|
|
140
|
+
export {
|
|
141
|
+
extractW3cContext,
|
|
142
|
+
bindPropagationContext,
|
|
143
|
+
clearPropagationContext,
|
|
144
|
+
getActivePropagationContext,
|
|
145
|
+
} from './propagation';
|
|
146
|
+
export type { PropagationContext } from './propagation';
|
|
147
|
+
|
|
148
|
+
// Runtime reconfiguration
|
|
149
|
+
export {
|
|
150
|
+
getRuntimeConfig,
|
|
151
|
+
updateRuntimeConfig,
|
|
152
|
+
reloadRuntimeFromEnv,
|
|
153
|
+
reconfigureTelemetry,
|
|
154
|
+
} from './runtime';
|
|
155
|
+
|
|
156
|
+
// Test utilities
|
|
157
|
+
export { resetTelemetryState, resetTraceContext, telemetryTestPlugin } from './testing';
|
|
158
|
+
|
|
159
|
+
// Optional governance module — strippable: consent
|
|
160
|
+
export type { ConsentLevel } from './consent';
|
|
161
|
+
export {
|
|
162
|
+
setConsentLevel,
|
|
163
|
+
getConsentLevel,
|
|
164
|
+
shouldAllow,
|
|
165
|
+
loadConsentFromEnv,
|
|
166
|
+
resetConsentForTests,
|
|
167
|
+
} from './consent';
|
|
168
|
+
|
|
169
|
+
// Optional governance module — strippable
|
|
170
|
+
export type { DataClass, ClassificationRule, ClassificationPolicy } from './classification';
|
|
171
|
+
export {
|
|
172
|
+
registerClassificationRules,
|
|
173
|
+
setClassificationPolicy,
|
|
174
|
+
getClassificationPolicy,
|
|
175
|
+
resetClassificationForTests,
|
|
176
|
+
} from './classification';
|
|
177
|
+
|
|
178
|
+
// Optional governance module — strippable: receipts
|
|
179
|
+
export type { RedactionReceipt } from './receipts';
|
|
180
|
+
export { enableReceipts, getEmittedReceiptsForTests, resetReceiptsForTests } from './receipts';
|
|
181
|
+
|
|
182
|
+
// Shutdown
|
|
183
|
+
export { shutdownTelemetry } from './shutdown';
|
package/src/logger.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Structured logger — wraps pino with:
|
|
6
|
+
* - browser.write hook in actual browsers; custom stream in Node.js/Vitest
|
|
7
|
+
* - window.__pinoLogs capture for Playwright/devtools inspection
|
|
8
|
+
* - Automatic context binding (from bindContext())
|
|
9
|
+
* - Automatic OTEL trace_id/span_id injection
|
|
10
|
+
* - PII sanitization
|
|
11
|
+
* - msg fallback: if msg is empty, defaults to obj.event
|
|
12
|
+
*
|
|
13
|
+
* Mirrors Python provide.telemetry get_logger().
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import pino from 'pino';
|
|
17
|
+
import { getConfig } from './config';
|
|
18
|
+
import { getContext } from './context';
|
|
19
|
+
import { computeErrorFingerprint } from './fingerprint';
|
|
20
|
+
import { formatPretty, supportsColor } from './pretty';
|
|
21
|
+
import { emitLogRecord } from './otel-logs';
|
|
22
|
+
import { sanitizePayload } from './pii';
|
|
23
|
+
import { sanitize } from './sanitize';
|
|
24
|
+
import { EventSchemaError, validateEventName, validateRequiredKeys } from './schema';
|
|
25
|
+
import { getActiveTraceIds } from './tracing';
|
|
26
|
+
|
|
27
|
+
/** Pino level number → console method name. */
|
|
28
|
+
const LEVEL_MAP: Record<number, string> = {
|
|
29
|
+
10: 'trace',
|
|
30
|
+
20: 'debug',
|
|
31
|
+
30: 'log',
|
|
32
|
+
40: 'warn',
|
|
33
|
+
50: 'error',
|
|
34
|
+
60: 'error',
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Public Logger interface — consumers should type against this, not pino.Logger. */
|
|
38
|
+
export interface Logger {
|
|
39
|
+
trace(obj: Record<string, unknown>, msg?: string): void;
|
|
40
|
+
debug(obj: Record<string, unknown>, msg?: string): void;
|
|
41
|
+
info(obj: Record<string, unknown>, msg?: string): void;
|
|
42
|
+
warn(obj: Record<string, unknown>, msg?: string): void;
|
|
43
|
+
error(obj: Record<string, unknown>, msg?: string): void;
|
|
44
|
+
/** Create a child logger with additional bound fields. */
|
|
45
|
+
child(bindings: Record<string, unknown>): Logger;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Pino root instance — lazily created so config is read after setupTelemetry().
|
|
49
|
+
let _root: pino.Logger | null = null;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build the write hook that enriches, sanitizes, captures, and optionally
|
|
53
|
+
* emits each log record. Config is read dynamically on every invocation so
|
|
54
|
+
* that resetTelemetryState() + setupTelemetry() changes take effect without
|
|
55
|
+
* needing to rebuild the hook closure.
|
|
56
|
+
*/
|
|
57
|
+
export function makeWriteHook() {
|
|
58
|
+
// pino's WriteFn signature uses `object`; we cast internally for safe property access.
|
|
59
|
+
return (obj: object): void => {
|
|
60
|
+
// Read config dynamically — avoids stale-capture bug after _resetConfig().
|
|
61
|
+
const cfg = getConfig();
|
|
62
|
+
const o = obj as Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
// Inject OTEL trace/span IDs if an active span exists.
|
|
65
|
+
const ids = getActiveTraceIds();
|
|
66
|
+
if (ids.trace_id) o['trace_id'] = ids.trace_id;
|
|
67
|
+
if (ids.span_id) o['span_id'] = ids.span_id;
|
|
68
|
+
|
|
69
|
+
// Merge module-level context bindings.
|
|
70
|
+
Object.assign(o, getContext());
|
|
71
|
+
|
|
72
|
+
// Ensure msg is always non-empty — pino sets msg='' when no string arg is passed.
|
|
73
|
+
if (!o['msg']) o['msg'] = o['event'] ?? '';
|
|
74
|
+
|
|
75
|
+
// Caller info injection — intentionally expensive (creates Error per call).
|
|
76
|
+
// Stryker disable all
|
|
77
|
+
if (cfg.logIncludeCaller) {
|
|
78
|
+
const err = new Error();
|
|
79
|
+
const stack = err.stack?.split('\n');
|
|
80
|
+
/* v8 ignore next -- stack is always defined in V8 */
|
|
81
|
+
if (stack) {
|
|
82
|
+
for (const frame of stack.slice(1)) {
|
|
83
|
+
if (
|
|
84
|
+
!frame.includes('logger.ts') &&
|
|
85
|
+
!frame.includes('node_modules') &&
|
|
86
|
+
!frame.includes('pino')
|
|
87
|
+
) {
|
|
88
|
+
const match = frame.match(/\((.+):(\d+):\d+\)/) ?? frame.match(/at (.+):(\d+):\d+/);
|
|
89
|
+
/* v8 ignore next -- match always succeeds for V8 stack frames */
|
|
90
|
+
if (match) {
|
|
91
|
+
o['caller_file'] = match[1].replace(/^.*\//, ''); // basename only
|
|
92
|
+
o['caller_line'] = Number(match[2]);
|
|
93
|
+
}
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Stryker enable all
|
|
100
|
+
|
|
101
|
+
// Error fingerprinting — stable hash from error name + stack.
|
|
102
|
+
const errObj = o['err'] as Record<string, unknown> | undefined;
|
|
103
|
+
const excName = (o['exc_name'] ?? o['exception'] ?? errObj?.['type'] ?? errObj?.['name']) as
|
|
104
|
+
| string
|
|
105
|
+
| undefined;
|
|
106
|
+
if (excName) {
|
|
107
|
+
const stack = (errObj?.['stack'] ?? o['stack']) as string | undefined;
|
|
108
|
+
o['error_fingerprint'] = computeErrorFingerprint(String(excName), stack);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// PII sanitization: blocked keys + secret detection + custom PII rules.
|
|
112
|
+
if (cfg.logSanitize) {
|
|
113
|
+
sanitize(o, cfg.sanitizeFields);
|
|
114
|
+
sanitizePayload(o, [], { maxDepth: cfg.piiMaxDepth });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Strip timestamp when configured off.
|
|
118
|
+
if (!cfg.logIncludeTimestamp) {
|
|
119
|
+
delete o['time'];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Schema validation — drop records that violate strict schema rules.
|
|
123
|
+
/* v8 ignore next -- V8 cannot fully attribute all ?? branches in a single expression */
|
|
124
|
+
if (cfg.strictSchema) {
|
|
125
|
+
const event = String(o['event'] ?? o['msg'] ?? '');
|
|
126
|
+
if (event) {
|
|
127
|
+
try {
|
|
128
|
+
validateEventName(event);
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e instanceof EventSchemaError) return;
|
|
131
|
+
throw e;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (cfg.requiredLogKeys.length > 0) {
|
|
135
|
+
try {
|
|
136
|
+
validateRequiredKeys(o, cfg.requiredLogKeys);
|
|
137
|
+
} catch (e) {
|
|
138
|
+
if (e instanceof EventSchemaError) return;
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Export to OTLP when a log provider is registered (noop otherwise).
|
|
145
|
+
emitLogRecord(o);
|
|
146
|
+
|
|
147
|
+
// Capture to window.__pinoLogs for Playwright and devtools inspection.
|
|
148
|
+
// Check is done inline (not at module load) so it works when loaded in Node.js
|
|
149
|
+
// test environments that later gain a jsdom window.
|
|
150
|
+
if (typeof window !== 'undefined' && cfg.captureToWindow) {
|
|
151
|
+
if (!('__pinoLogs' in window)) {
|
|
152
|
+
(window as unknown as Record<string, unknown>)['__pinoLogs'] = [];
|
|
153
|
+
}
|
|
154
|
+
(window as unknown as Record<string, unknown[]>)['__pinoLogs'].push(o);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Emit to console only when explicitly enabled (opt-in).
|
|
158
|
+
if (cfg.consoleOutput) {
|
|
159
|
+
const method = LEVEL_MAP[o['level'] as number] ?? 'log';
|
|
160
|
+
if (cfg.logFormat === 'pretty') {
|
|
161
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
162
|
+
(console as any)[method](formatPretty(o, supportsColor()));
|
|
163
|
+
} else {
|
|
164
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
165
|
+
(console as any)[method](o);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function getRootLogger(): pino.Logger {
|
|
172
|
+
// Stryker disable next-line ConditionalExpression
|
|
173
|
+
if (_root) return _root;
|
|
174
|
+
const cfg = getConfig();
|
|
175
|
+
const hook = makeWriteHook();
|
|
176
|
+
|
|
177
|
+
// pino only invokes browser.write when process.version is absent (real browser).
|
|
178
|
+
// In Node.js / Vitest, we use a custom destination stream that forwards every
|
|
179
|
+
// serialised log line back through the write hook.
|
|
180
|
+
// Stryker disable all
|
|
181
|
+
const isNodeEnv = typeof process !== 'undefined' && typeof process.version === 'string';
|
|
182
|
+
|
|
183
|
+
/* c8 ignore else */
|
|
184
|
+
if (isNodeEnv) {
|
|
185
|
+
const stream = {
|
|
186
|
+
write(msg: string) {
|
|
187
|
+
try {
|
|
188
|
+
hook(JSON.parse(msg.trimEnd()) as object);
|
|
189
|
+
} catch {
|
|
190
|
+
// Ignore malformed lines (e.g. pino flush sentinels).
|
|
191
|
+
}
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
_root = pino(
|
|
195
|
+
{
|
|
196
|
+
base: { service: cfg.serviceName, env: cfg.environment, version: cfg.version },
|
|
197
|
+
level: cfg.logLevel,
|
|
198
|
+
},
|
|
199
|
+
stream as unknown as pino.DestinationStream,
|
|
200
|
+
);
|
|
201
|
+
} else {
|
|
202
|
+
/* c8 ignore next 8 */
|
|
203
|
+
_root = pino({
|
|
204
|
+
base: { service: cfg.serviceName, env: cfg.environment, version: cfg.version },
|
|
205
|
+
level: cfg.logLevel,
|
|
206
|
+
browser: {
|
|
207
|
+
write: hook,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
// Stryker enable all
|
|
212
|
+
return _root;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function adaptPino(pinoLogger: pino.Logger): Logger {
|
|
216
|
+
// Stryker disable all
|
|
217
|
+
return {
|
|
218
|
+
trace: (obj, msg) => pinoLogger.trace(obj, msg ?? ''),
|
|
219
|
+
debug: (obj, msg) => pinoLogger.debug(obj, msg ?? ''),
|
|
220
|
+
info: (obj, msg) => pinoLogger.info(obj, msg ?? ''),
|
|
221
|
+
warn: (obj, msg) => pinoLogger.warn(obj, msg ?? ''),
|
|
222
|
+
error: (obj, msg) => pinoLogger.error(obj, msg ?? ''),
|
|
223
|
+
child: (bindings) => adaptPino(pinoLogger.child(bindings)),
|
|
224
|
+
};
|
|
225
|
+
// Stryker enable all
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Find the longest-prefix match in logModuleLevels for the given logger name.
|
|
230
|
+
* Returns the matched level string, or undefined if no match.
|
|
231
|
+
* Mirrors Python _LevelFilter longest-prefix matching.
|
|
232
|
+
*/
|
|
233
|
+
function findModuleLevel(name: string, moduleLevels: Record<string, string>): string | undefined {
|
|
234
|
+
let bestMatch: string | undefined;
|
|
235
|
+
let bestLen = -1;
|
|
236
|
+
for (const prefix of Object.keys(moduleLevels)) {
|
|
237
|
+
if (
|
|
238
|
+
(prefix === '' || name === prefix || name.startsWith(prefix + '.')) &&
|
|
239
|
+
prefix.length > bestLen
|
|
240
|
+
) {
|
|
241
|
+
bestMatch = prefix;
|
|
242
|
+
bestLen = prefix.length;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return bestMatch !== undefined ? moduleLevels[bestMatch] : undefined;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Return a logger for the given name.
|
|
250
|
+
* Name appears as the `name` field in every log record.
|
|
251
|
+
* Mirrors Python: get_logger(name)
|
|
252
|
+
*/
|
|
253
|
+
export function getLogger(name?: string): Logger {
|
|
254
|
+
const root = getRootLogger();
|
|
255
|
+
// Stryker disable next-line ObjectLiteral
|
|
256
|
+
const pinoLogger = name ? root.child({ name }) : root;
|
|
257
|
+
// Apply per-module level overrides (longest-prefix match).
|
|
258
|
+
if (name) {
|
|
259
|
+
const cfg = getConfig();
|
|
260
|
+
const moduleLevels = cfg.logModuleLevels;
|
|
261
|
+
if (Object.keys(moduleLevels).length > 0) {
|
|
262
|
+
const level = findModuleLevel(name, moduleLevels);
|
|
263
|
+
if (level) {
|
|
264
|
+
pinoLogger.level = level.toLowerCase();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
return adaptPino(pinoLogger);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Reset the root logger (forces re-creation with current config on next call). */
|
|
272
|
+
// Stryker disable next-line BlockStatement
|
|
273
|
+
export function _resetRootLogger(): void {
|
|
274
|
+
_root = null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/** Module-level lazy singleton logger. Mirrors Python: logger = get_logger('default'). */
|
|
278
|
+
// Stryker disable all
|
|
279
|
+
export const logger: Logger = {
|
|
280
|
+
trace: (obj, msg) => getLogger('default').trace(obj, msg),
|
|
281
|
+
debug: (obj, msg) => getLogger('default').debug(obj, msg),
|
|
282
|
+
info: (obj, msg) => getLogger('default').info(obj, msg),
|
|
283
|
+
warn: (obj, msg) => getLogger('default').warn(obj, msg),
|
|
284
|
+
error: (obj, msg) => getLogger('default').error(obj, msg),
|
|
285
|
+
child: (bindings) => getLogger('default').child(bindings),
|
|
286
|
+
};
|
|
287
|
+
// Stryker enable all
|
package/src/metrics.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 provide.io llc. All rights reserved.
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Metric instruments — mirrors Python provide.telemetry counter/gauge/histogram.
|
|
6
|
+
*
|
|
7
|
+
* Backed by @opentelemetry/api which provides no-op instruments when no SDK is registered.
|
|
8
|
+
* Safe to call in any environment; instruments are always callable without setup.
|
|
9
|
+
*
|
|
10
|
+
* Wrapper classes gate every `.add()`/`.record()` call through sampling + backpressure,
|
|
11
|
+
* matching the Python fallback.py pattern.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type Attributes,
|
|
16
|
+
type Counter,
|
|
17
|
+
type Histogram,
|
|
18
|
+
type Meter,
|
|
19
|
+
type UpDownCounter,
|
|
20
|
+
metrics,
|
|
21
|
+
} from '@opentelemetry/api';
|
|
22
|
+
import { shouldSample } from './sampling';
|
|
23
|
+
import { tryAcquire, release } from './backpressure';
|
|
24
|
+
import { getActiveTraceIds } from './tracing';
|
|
25
|
+
import { getConfig } from './config';
|
|
26
|
+
|
|
27
|
+
export type { Counter, Histogram, Meter, UpDownCounter };
|
|
28
|
+
|
|
29
|
+
// Stryker disable next-line StringLiteral: meter name not observable with no-op OTEL SDK in tests
|
|
30
|
+
const METER_NAME = '@provide-io/telemetry';
|
|
31
|
+
|
|
32
|
+
export interface MetricOptions {
|
|
33
|
+
description?: string;
|
|
34
|
+
unit?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Wrapper around OTel Counter that gates add() through sampling + backpressure.
|
|
39
|
+
*/
|
|
40
|
+
export class CounterInstrument {
|
|
41
|
+
readonly name: string;
|
|
42
|
+
private readonly _inner: Counter;
|
|
43
|
+
private _value = 0;
|
|
44
|
+
|
|
45
|
+
constructor(name: string, inner: Counter) {
|
|
46
|
+
this.name = name;
|
|
47
|
+
this._inner = inner;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Cumulative counter value (in-process; useful for testing and health checks). */
|
|
51
|
+
get value(): number {
|
|
52
|
+
return this._value;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
add(value: number, attributes?: Attributes): void {
|
|
56
|
+
if (!getConfig().metricsEnabled) return;
|
|
57
|
+
if (!shouldSample('metrics', this.name)) return;
|
|
58
|
+
const ticket = tryAcquire('metrics');
|
|
59
|
+
if (!ticket) return;
|
|
60
|
+
try {
|
|
61
|
+
const ids = getActiveTraceIds();
|
|
62
|
+
const enriched =
|
|
63
|
+
ids.trace_id && ids.span_id
|
|
64
|
+
? { ...attributes, trace_id: ids.trace_id, span_id: ids.span_id }
|
|
65
|
+
: attributes;
|
|
66
|
+
this._inner.add(value, enriched);
|
|
67
|
+
this._value += value;
|
|
68
|
+
} finally {
|
|
69
|
+
release(ticket);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Wrapper around OTel UpDownCounter with set semantics.
|
|
76
|
+
* Gates add()/set() through sampling + backpressure.
|
|
77
|
+
*/
|
|
78
|
+
export class GaugeInstrument {
|
|
79
|
+
readonly name: string;
|
|
80
|
+
private readonly _inner: UpDownCounter;
|
|
81
|
+
private readonly _values: Map<string, number> = new Map();
|
|
82
|
+
private _lastValue = 0;
|
|
83
|
+
|
|
84
|
+
constructor(name: string, inner: UpDownCounter) {
|
|
85
|
+
this.name = name;
|
|
86
|
+
this._inner = inner;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Most recent value set or accumulated via add() (in-process; useful for testing and health checks). */
|
|
90
|
+
get value(): number {
|
|
91
|
+
return this._lastValue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
add(value: number, attributes?: Attributes): void {
|
|
95
|
+
if (!getConfig().metricsEnabled) return;
|
|
96
|
+
if (!shouldSample('metrics', this.name)) return;
|
|
97
|
+
const ticket = tryAcquire('metrics');
|
|
98
|
+
if (!ticket) return;
|
|
99
|
+
try {
|
|
100
|
+
this._inner.add(value, attributes);
|
|
101
|
+
this._lastValue += value;
|
|
102
|
+
} finally {
|
|
103
|
+
release(ticket);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
set(value: number, attributes?: Attributes): void {
|
|
108
|
+
if (!getConfig().metricsEnabled) return;
|
|
109
|
+
if (!shouldSample('metrics', this.name)) return;
|
|
110
|
+
const ticket = tryAcquire('metrics');
|
|
111
|
+
if (!ticket) return;
|
|
112
|
+
try {
|
|
113
|
+
// Stryker disable next-line StringLiteral: empty string fallback for no-attributes key — functionally equivalent to any constant
|
|
114
|
+
const key = attributes ? JSON.stringify(attributes) : '';
|
|
115
|
+
const prev = this._values.get(key) ?? 0;
|
|
116
|
+
const delta = value - prev;
|
|
117
|
+
this._values.set(key, value);
|
|
118
|
+
this._inner.add(delta, attributes);
|
|
119
|
+
this._lastValue = value;
|
|
120
|
+
} finally {
|
|
121
|
+
release(ticket);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Wrapper around OTel Histogram that gates record() through sampling + backpressure.
|
|
128
|
+
*/
|
|
129
|
+
export class HistogramInstrument {
|
|
130
|
+
readonly name: string;
|
|
131
|
+
private readonly _inner: Histogram;
|
|
132
|
+
private _count = 0;
|
|
133
|
+
private _total = 0;
|
|
134
|
+
|
|
135
|
+
constructor(name: string, inner: Histogram) {
|
|
136
|
+
this.name = name;
|
|
137
|
+
this._inner = inner;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Number of values recorded (in-process; useful for testing and health checks). */
|
|
141
|
+
get count(): number {
|
|
142
|
+
return this._count;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Sum of all recorded values (in-process; useful for testing and health checks). */
|
|
146
|
+
get total(): number {
|
|
147
|
+
return this._total;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
record(value: number, attributes?: Attributes): void {
|
|
151
|
+
if (!getConfig().metricsEnabled) return;
|
|
152
|
+
if (!shouldSample('metrics', this.name)) return;
|
|
153
|
+
const ticket = tryAcquire('metrics');
|
|
154
|
+
if (!ticket) return;
|
|
155
|
+
try {
|
|
156
|
+
const ids = getActiveTraceIds();
|
|
157
|
+
const enriched =
|
|
158
|
+
ids.trace_id && ids.span_id
|
|
159
|
+
? { ...attributes, trace_id: ids.trace_id, span_id: ids.span_id }
|
|
160
|
+
: attributes;
|
|
161
|
+
this._inner.record(value, enriched);
|
|
162
|
+
this._count += 1;
|
|
163
|
+
this._total += value;
|
|
164
|
+
} finally {
|
|
165
|
+
release(ticket);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Create a monotonically increasing counter.
|
|
172
|
+
* Mirrors Python: counter(name, description, unit)
|
|
173
|
+
*/
|
|
174
|
+
export function counter(name: string, options?: MetricOptions): CounterInstrument {
|
|
175
|
+
const inner = metrics.getMeter(METER_NAME).createCounter(name, options);
|
|
176
|
+
return new CounterInstrument(name, inner);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Create an up-down counter (gauge — can increase or decrease).
|
|
181
|
+
* Mirrors Python: gauge(name, description, unit)
|
|
182
|
+
*/
|
|
183
|
+
export function gauge(name: string, options?: MetricOptions): GaugeInstrument {
|
|
184
|
+
const inner = metrics.getMeter(METER_NAME).createUpDownCounter(name, options);
|
|
185
|
+
return new GaugeInstrument(name, inner);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Create a histogram for recording distributions (latencies, sizes).
|
|
190
|
+
* Mirrors Python: histogram(name, description, unit)
|
|
191
|
+
*/
|
|
192
|
+
export function histogram(name: string, options?: MetricOptions): HistogramInstrument {
|
|
193
|
+
const inner = metrics.getMeter(METER_NAME).createHistogram(name, options);
|
|
194
|
+
return new HistogramInstrument(name, inner);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Return an OTEL Meter from the global meter provider.
|
|
199
|
+
* Mirrors Python: get_meter(name)
|
|
200
|
+
*/
|
|
201
|
+
export function getMeter(name?: string): Meter {
|
|
202
|
+
// Stryker disable next-line LogicalOperator: getMeter(undefined) behaves identically to getMeter(name) with no-op OTEL API
|
|
203
|
+
return metrics.getMeter(name ?? METER_NAME);
|
|
204
|
+
}
|