@zudojs/observability 1.1.0 → 1.2.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.
- package/README.md +20 -7
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/observability/observability.core.d.ts +6 -1
- package/dist/observability/observability.core.js +58 -13
- package/dist/redaction/redaction.core.js +10 -0
- package/dist/tracing/span/span.core.js +20 -3
- package/dist/types/config.types.d.ts +8 -3
- package/package.json +6 -5
package/README.md
CHANGED
|
@@ -33,8 +33,7 @@ const obs = createObservability({
|
|
|
33
33
|
environment: "production",
|
|
34
34
|
logLevel: LogLevel.INFO,
|
|
35
35
|
|
|
36
|
-
//
|
|
37
|
-
redaction: {},
|
|
36
|
+
// Redaction is on by default — see "Redaction" below.
|
|
38
37
|
|
|
39
38
|
// Sample 10% of traces. The decision is derived from the trace ID, so a
|
|
40
39
|
// trace is never sampled in half across services.
|
|
@@ -82,8 +81,17 @@ Never pass a raw level number between the two; convert with
|
|
|
82
81
|
|
|
83
82
|
## Redaction
|
|
84
83
|
|
|
85
|
-
Redaction is
|
|
86
|
-
|
|
84
|
+
Redaction is **on by default**, as it is in `@zudojs/logger`: with no
|
|
85
|
+
`redaction` option, every name the logger's default matcher redacts
|
|
86
|
+
(`DEFAULT_LOGGER_SECRET_FIELDS` — password, passphrase, secret, token, jwt,
|
|
87
|
+
bearer, auth, authorization, cookie, session, sid, credential, api key,
|
|
88
|
+
private key, client secret, card number, cvv, ssn, pin, otp and more) is
|
|
89
|
+
redacted here too, along with this package's own `DEFAULT_SENSITIVE_FIELDS`.
|
|
90
|
+
Pass a config to change the rules, or `redaction: false` to turn it off.
|
|
91
|
+
Before 1.2 redaction was off unless configured, so a `password` field was
|
|
92
|
+
exported in the clear.
|
|
93
|
+
|
|
94
|
+
The logger applies it to every record before any transport sees it, and the tracer
|
|
87
95
|
applies it to every span attribute and span event attribute before any
|
|
88
96
|
processor or exporter sees it — a `Bearer` token attached to a span is
|
|
89
97
|
redacted the same way one written to a log field is.
|
|
@@ -92,7 +100,7 @@ redacted the same way one written to a log field is.
|
|
|
92
100
|
const obs = createObservability({
|
|
93
101
|
serviceName: "api",
|
|
94
102
|
redaction: {
|
|
95
|
-
//
|
|
103
|
+
// Setting `fields` replaces the default list (and the logger's rules).
|
|
96
104
|
fields: ["password", "token", "ssn"],
|
|
97
105
|
patterns: [/^x-.*-secret$/i],
|
|
98
106
|
// "contains" (the default) matches on word boundaries: "userPassword"
|
|
@@ -119,6 +127,10 @@ being flattened to `{}`.
|
|
|
119
127
|
`redactObject`, `redactValue` and `createStructureRedactor` are exported for
|
|
120
128
|
use outside the logger.
|
|
121
129
|
|
|
130
|
+
```typescript
|
|
131
|
+
createObservability({ serviceName: "api", redaction: false }); // opt out
|
|
132
|
+
```
|
|
133
|
+
|
|
122
134
|
## Metrics
|
|
123
135
|
|
|
124
136
|
Counters, gauges and histograms, keyed by name **and** labels.
|
|
@@ -165,8 +177,9 @@ name as two types is rejected wholesale by OTLP and Prometheus.
|
|
|
165
177
|
|
|
166
178
|
Metrics are exported by a `PeriodicMetricReader`, which the facade starts for
|
|
167
179
|
you when a `metricExporter` is configured. `metricExportIntervalMs: 0`
|
|
168
|
-
disables the periodic export while still collecting a
|
|
169
|
-
`
|
|
180
|
+
disables the periodic export while still collecting a snapshot on `flush()`
|
|
181
|
+
and a final one on `shutdown()`. `shutdown()` exports that final snapshot
|
|
182
|
+
exactly once (before 1.2 it was exported twice):
|
|
170
183
|
|
|
171
184
|
```typescript
|
|
172
185
|
const obs = createObservability({
|
package/dist/index.d.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* const obs = createObservability({
|
|
17
17
|
* serviceName: "my-api",
|
|
18
18
|
* logLevel: LogLevel.INFO,
|
|
19
|
-
* redaction:
|
|
19
|
+
* // Redaction is on by default; `redaction: false` turns it off.
|
|
20
20
|
* sampler: createProbabilitySampler(0.1),
|
|
21
21
|
* });
|
|
22
22
|
*
|
package/dist/index.js
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* const obs = createObservability({
|
|
17
17
|
* serviceName: "my-api",
|
|
18
18
|
* logLevel: LogLevel.INFO,
|
|
19
|
-
* redaction:
|
|
19
|
+
* // Redaction is on by default; `redaction: false` turns it off.
|
|
20
20
|
* sampler: createProbabilitySampler(0.1),
|
|
21
21
|
* });
|
|
22
22
|
*
|
|
@@ -23,7 +23,7 @@ interface TelemetryPipeline {
|
|
|
23
23
|
readonly metricReader: PeriodicMetricReader;
|
|
24
24
|
readonly config: ObservabilityConfig;
|
|
25
25
|
readonly sampler: ObservabilityConfig["sampler"];
|
|
26
|
-
/** Redacts span attributes,
|
|
26
|
+
/** Redacts span attributes, unless `config.redaction` is `false`. */
|
|
27
27
|
readonly redactAttribute?: (key: string, value: unknown) => unknown;
|
|
28
28
|
}
|
|
29
29
|
/**
|
|
@@ -64,6 +64,11 @@ export declare class DefaultObservability implements Observability {
|
|
|
64
64
|
* when the caller went on to exit.
|
|
65
65
|
*/
|
|
66
66
|
flush(): Promise<void>;
|
|
67
|
+
/**
|
|
68
|
+
* Drains the log and span buffers, and — when `includeMetrics` — exports a
|
|
69
|
+
* metric snapshot. Shutdown passes `false`: the reader's own `shutdown()`
|
|
70
|
+
* exports the final snapshot, and collecting here as well sent it twice.
|
|
71
|
+
*/
|
|
67
72
|
private drain;
|
|
68
73
|
private reportFailures;
|
|
69
74
|
/**
|
|
@@ -79,9 +79,14 @@ export class DefaultObservability {
|
|
|
79
79
|
* when the caller went on to exit.
|
|
80
80
|
*/
|
|
81
81
|
async flush() {
|
|
82
|
-
await this.drain();
|
|
82
|
+
await this.drain(true);
|
|
83
83
|
}
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* Drains the log and span buffers, and — when `includeMetrics` — exports a
|
|
86
|
+
* metric snapshot. Shutdown passes `false`: the reader's own `shutdown()`
|
|
87
|
+
* exports the final snapshot, and collecting here as well sent it twice.
|
|
88
|
+
*/
|
|
89
|
+
async drain(includeMetrics) {
|
|
85
90
|
const tasks = [];
|
|
86
91
|
if (this.pipeline.logProcessor) {
|
|
87
92
|
tasks.push(this.pipeline.logProcessor.flush());
|
|
@@ -90,7 +95,8 @@ export class DefaultObservability {
|
|
|
90
95
|
if (processor.forceFlush)
|
|
91
96
|
tasks.push(processor.forceFlush());
|
|
92
97
|
}
|
|
93
|
-
|
|
98
|
+
if (includeMetrics)
|
|
99
|
+
tasks.push(this.pipeline.metricReader.collect());
|
|
94
100
|
await this.reportFailures(await Promise.allSettled(tasks), "flush");
|
|
95
101
|
}
|
|
96
102
|
async reportFailures(results, source) {
|
|
@@ -115,8 +121,9 @@ export class DefaultObservability {
|
|
|
115
121
|
}
|
|
116
122
|
async performShutdown() {
|
|
117
123
|
// Drain first: whatever is still queued should reach the backend before
|
|
118
|
-
// the exporters close.
|
|
119
|
-
|
|
124
|
+
// the exporters close. Metrics are left to the reader's shutdown, which
|
|
125
|
+
// exports the final snapshot exactly once.
|
|
126
|
+
await this.drain(false);
|
|
120
127
|
const steps = [];
|
|
121
128
|
steps.push(this.pipeline.metricReader.shutdown());
|
|
122
129
|
if (this.pipeline.logProcessor) {
|
|
@@ -143,6 +150,35 @@ function buildResourceAttributes(config) {
|
|
|
143
150
|
...(config.resource ?? {}),
|
|
144
151
|
};
|
|
145
152
|
}
|
|
153
|
+
/** Shortest interval between two repeat queue-overflow reports. */
|
|
154
|
+
const DROP_REPORT_INTERVAL_MS = 60_000;
|
|
155
|
+
/**
|
|
156
|
+
* Rate-limits a processor's `onDrop` callback.
|
|
157
|
+
*
|
|
158
|
+
* `onDrop` fires once per record the bounded queue refuses, on the
|
|
159
|
+
* synchronous `logger.info()` path, and the report below allocates an
|
|
160
|
+
* `Error` (so, a stack capture) and re-enters the caller's `onError` —
|
|
161
|
+
* which usually writes to the sink that is already stalled. The bounded
|
|
162
|
+
* queue is the control that makes a stalled exporter survivable; reporting
|
|
163
|
+
* every drop turned it into a second, louder failure.
|
|
164
|
+
*
|
|
165
|
+
* Handled the same way an over-cardinality metric is a few lines below: the
|
|
166
|
+
* first occurrence is reported immediately, then at most one per interval.
|
|
167
|
+
* The count handed to `report` is the processor's running total, so
|
|
168
|
+
* whoever does get notified still sees how many records were lost.
|
|
169
|
+
*/
|
|
170
|
+
function throttleDropReports(report) {
|
|
171
|
+
let lastReportedAt;
|
|
172
|
+
return (dropped) => {
|
|
173
|
+
const at = Date.now();
|
|
174
|
+
if (lastReportedAt !== undefined &&
|
|
175
|
+
at - lastReportedAt < DROP_REPORT_INTERVAL_MS) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
lastReportedAt = at;
|
|
179
|
+
report(dropped);
|
|
180
|
+
};
|
|
181
|
+
}
|
|
146
182
|
function buildPipeline(config) {
|
|
147
183
|
const useConsole = config.useConsoleExporters ?? true;
|
|
148
184
|
/* ── Logging ─────────────────────────────────────────────────────────── */
|
|
@@ -153,14 +189,14 @@ function buildPipeline(config) {
|
|
|
153
189
|
batchSize: config.logBatchSize,
|
|
154
190
|
flushIntervalMs: config.logFlushIntervalMs,
|
|
155
191
|
onError: config.onError,
|
|
156
|
-
onDrop: (dropped) => config.onError?.(new Error(`Dropped ${dropped} log records: queue full`), "BatchLogProcessor"),
|
|
192
|
+
onDrop: throttleDropReports((dropped) => config.onError?.(new Error(`Dropped ${dropped} log records: queue full`), "BatchLogProcessor")),
|
|
157
193
|
});
|
|
158
194
|
// Redaction is applied by the logger, so every transport and exporter
|
|
159
195
|
// downstream sees redacted records — configuring it and not wiring it here
|
|
160
|
-
// is what made the "credentials are never logged" promise untrue.
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
196
|
+
// is what made the "credentials are never logged" promise untrue. It is on
|
|
197
|
+
// unless the caller opts out with `redaction: false`, as in @zudojs/logger.
|
|
198
|
+
const redaction = resolveRedaction(config);
|
|
199
|
+
const redactor = redaction ? createStructureRedactor(redaction) : undefined;
|
|
164
200
|
const logger = new StructuredLogger({
|
|
165
201
|
name: config.serviceName,
|
|
166
202
|
level: config.logLevel ?? LogLevel.INFO,
|
|
@@ -179,7 +215,7 @@ function buildPipeline(config) {
|
|
|
179
215
|
new BatchSpanProcessor({
|
|
180
216
|
exporter: ownSpanExporter(),
|
|
181
217
|
onError: config.onError,
|
|
182
|
-
onDrop: (dropped) => config.onError?.(new Error(`Dropped ${dropped} spans: queue full`), "BatchSpanProcessor"),
|
|
218
|
+
onDrop: throttleDropReports((dropped) => config.onError?.(new Error(`Dropped ${dropped} spans: queue full`), "BatchSpanProcessor")),
|
|
183
219
|
}),
|
|
184
220
|
];
|
|
185
221
|
// When the caller supplied their own processors, they own the exporter's
|
|
@@ -215,8 +251,8 @@ function buildPipeline(config) {
|
|
|
215
251
|
onError: config.onError,
|
|
216
252
|
});
|
|
217
253
|
metricReader.start();
|
|
218
|
-
const redactAttribute =
|
|
219
|
-
? buildAttributeRedactor(
|
|
254
|
+
const redactAttribute = redaction
|
|
255
|
+
? buildAttributeRedactor(redaction)
|
|
220
256
|
: undefined;
|
|
221
257
|
return {
|
|
222
258
|
logger,
|
|
@@ -230,6 +266,15 @@ function buildPipeline(config) {
|
|
|
230
266
|
redactAttribute,
|
|
231
267
|
};
|
|
232
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* The redaction rules in force: the caller's, the defaults when none were
|
|
271
|
+
* given, or none at all for an explicit `redaction: false`.
|
|
272
|
+
*/
|
|
273
|
+
function resolveRedaction(config) {
|
|
274
|
+
if (config.redaction === false)
|
|
275
|
+
return undefined;
|
|
276
|
+
return config.redaction ?? {};
|
|
277
|
+
}
|
|
233
278
|
/**
|
|
234
279
|
* Builds the span-attribute redactor.
|
|
235
280
|
*
|
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* - matching is substring-based by default, so `userPassword` and
|
|
17
17
|
* `x-api-key` are caught, not just the exact names in the list.
|
|
18
18
|
*/
|
|
19
|
+
import { createDefaultSecretFieldMatcher } from "@zudojs/logger";
|
|
20
|
+
/**
|
|
21
|
+
* @zudojs/logger's default secret-name matcher. The default rules here
|
|
22
|
+
* include it, so a field the logger redacts is never exported in the clear
|
|
23
|
+
* by this package.
|
|
24
|
+
*/
|
|
25
|
+
const isLoggerSecretField = createDefaultSecretFieldMatcher();
|
|
19
26
|
/** Default sensitive field names, matched case-insensitively. */
|
|
20
27
|
export const DEFAULT_SENSITIVE_FIELDS = [
|
|
21
28
|
"password",
|
|
@@ -92,10 +99,13 @@ function compile(config) {
|
|
|
92
99
|
const matchMode = config?.matchMode ?? "contains";
|
|
93
100
|
const exact = new Set(fields);
|
|
94
101
|
const normalizedFields = new Set(fields.map((field) => field.replace(/[^a-z0-9]/g, "")).filter(Boolean));
|
|
102
|
+
const withLoggerDefaults = config?.fields === undefined && matchMode === "contains";
|
|
95
103
|
const isSensitive = (key) => {
|
|
96
104
|
const lower = key.toLowerCase();
|
|
97
105
|
if (exact.has(lower))
|
|
98
106
|
return true;
|
|
107
|
+
if (withLoggerDefaults && isLoggerSecretField(key))
|
|
108
|
+
return true;
|
|
99
109
|
if (matchMode === "contains") {
|
|
100
110
|
for (const candidate of wordJoins(key)) {
|
|
101
111
|
if (normalizedFields.has(candidate))
|
|
@@ -6,6 +6,23 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import { SpanKind, SpanStatus } from "../../types.js";
|
|
8
8
|
import { createSpanContext, createChildSpanContext, } from "./spanContext.type.js";
|
|
9
|
+
/**
|
|
10
|
+
* Stores one attribute on a bag whose keys come from instrumentation.
|
|
11
|
+
*
|
|
12
|
+
* `bag[key] = value` invokes the inherited `__proto__` setter for that one
|
|
13
|
+
* key: the attribute never lands, and an object value replaces the bag's
|
|
14
|
+
* prototype. A poisoned prototype then defeats `maxAttributes`, because the
|
|
15
|
+
* cap is only applied to keys the bag does not already answer for. Matches
|
|
16
|
+
* the same guard in `redaction.core.ts`.
|
|
17
|
+
*/
|
|
18
|
+
function defineAttribute(target, key, value) {
|
|
19
|
+
Object.defineProperty(target, key, {
|
|
20
|
+
value,
|
|
21
|
+
enumerable: true,
|
|
22
|
+
writable: true,
|
|
23
|
+
configurable: true,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
9
26
|
/** Defaults matching the OpenTelemetry specification. */
|
|
10
27
|
const DEFAULT_LIMITS = {
|
|
11
28
|
maxAttributes: 128,
|
|
@@ -81,12 +98,12 @@ export class DefaultSpan {
|
|
|
81
98
|
setAttribute(key, value) {
|
|
82
99
|
if (this.ended || !this.recording)
|
|
83
100
|
return;
|
|
84
|
-
if (!(
|
|
101
|
+
if (!Object.prototype.hasOwnProperty.call(this.attributes, key) &&
|
|
85
102
|
Object.keys(this.attributes).length >= this.limits.maxAttributes) {
|
|
86
103
|
this.droppedAttributes++;
|
|
87
104
|
return;
|
|
88
105
|
}
|
|
89
|
-
this.attributes
|
|
106
|
+
defineAttribute(this.attributes, key, this.sanitize(key, value));
|
|
90
107
|
}
|
|
91
108
|
addEvent(name, attributes) {
|
|
92
109
|
if (this.ended || !this.recording)
|
|
@@ -104,7 +121,7 @@ export class DefaultSpan {
|
|
|
104
121
|
this.droppedAttributes++;
|
|
105
122
|
continue;
|
|
106
123
|
}
|
|
107
|
-
eventAttributes
|
|
124
|
+
defineAttribute(eventAttributes, key, this.sanitize(key, value));
|
|
108
125
|
kept++;
|
|
109
126
|
}
|
|
110
127
|
}
|
|
@@ -51,7 +51,8 @@ export type RedactionMatchMode = "exact" | "contains";
|
|
|
51
51
|
export interface RedactionConfig {
|
|
52
52
|
/**
|
|
53
53
|
* Field names to redact (case-insensitive). Defaults to a built-in list
|
|
54
|
-
* covering passwords, tokens, cookies, keys and card numbers
|
|
54
|
+
* covering passwords, tokens, cookies, keys and card numbers, plus every
|
|
55
|
+
* name @zudojs/logger's default matcher redacts. Setting it replaces both.
|
|
55
56
|
*/
|
|
56
57
|
readonly fields?: readonly string[];
|
|
57
58
|
/**
|
|
@@ -101,9 +102,13 @@ export interface ObservabilityConfig {
|
|
|
101
102
|
readonly processors?: readonly SpanProcessor[];
|
|
102
103
|
/**
|
|
103
104
|
* Redacts sensitive fields from log contexts *and* from span attributes and
|
|
104
|
-
* span event attributes.
|
|
105
|
+
* span event attributes.
|
|
106
|
+
*
|
|
107
|
+
* On by default: omitting it applies the default rules, which include
|
|
108
|
+
* every name @zudojs/logger redacts. Pass a config to change the rules, or
|
|
109
|
+
* `false` to turn redaction off.
|
|
105
110
|
*/
|
|
106
|
-
readonly redaction?: RedactionConfig;
|
|
111
|
+
readonly redaction?: RedactionConfig | false;
|
|
107
112
|
/**
|
|
108
113
|
* Metrics registry tuning: the series cap that bounds cardinality, and the
|
|
109
114
|
* histogram bucket boundaries. Without this the defaults were unreachable
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/observability",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Structured logging, metrics, tracing, context propagation, and exporters for Zudojs applications.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -26,12 +26,13 @@
|
|
|
26
26
|
"!dist/.tsbuildinfo"
|
|
27
27
|
],
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@zudojs/errors": "1.
|
|
29
|
+
"@zudojs/errors": "1.3.0",
|
|
30
|
+
"@zudojs/logger": "1.4.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"typescript": "7.0.2",
|
|
33
|
-
"vitest": "^
|
|
34
|
-
"@types/node": "^26.
|
|
34
|
+
"vitest": "^5.0.1",
|
|
35
|
+
"@types/node": "^26.6.2"
|
|
35
36
|
},
|
|
36
37
|
"engines": {
|
|
37
38
|
"node": ">=24.0.0"
|
|
@@ -46,7 +47,7 @@
|
|
|
46
47
|
"tracing",
|
|
47
48
|
"logs"
|
|
48
49
|
],
|
|
49
|
-
"homepage": "https://
|
|
50
|
+
"homepage": "https://zudojs.oyinlola.site/docs/packages-observability",
|
|
50
51
|
"bugs": {
|
|
51
52
|
"url": "https://github.com/oyinlola-tech/zudo/issues"
|
|
52
53
|
},
|