@telorun/kernel 0.49.0 → 0.50.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/dist/controller-loader.d.ts +6 -1
- package/dist/controller-loader.d.ts.map +1 -1
- package/dist/controller-loader.js +2 -0
- package/dist/controller-loader.js.map +1 -1
- package/dist/controller-loaders/bundle-builder.d.ts +2 -1
- package/dist/controller-loaders/bundle-builder.d.ts.map +1 -1
- package/dist/controller-loaders/bundle-builder.js +14 -9
- package/dist/controller-loaders/bundle-builder.js.map +1 -1
- package/dist/controller-loaders/npm-loader.d.ts +8 -2
- package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
- package/dist/controller-loaders/npm-loader.js +19 -10
- package/dist/controller-loaders/npm-loader.js.map +1 -1
- package/dist/controllers/logging/console-sink-controller.d.ts +15 -0
- package/dist/controllers/logging/console-sink-controller.d.ts.map +1 -0
- package/dist/controllers/logging/console-sink-controller.js +40 -0
- package/dist/controllers/logging/console-sink-controller.js.map +1 -0
- package/dist/controllers/logging/file-sink-controller.d.ts +11 -0
- package/dist/controllers/logging/file-sink-controller.d.ts.map +1 -0
- package/dist/controllers/logging/file-sink-controller.js +33 -0
- package/dist/controllers/logging/file-sink-controller.js.map +1 -0
- package/dist/controllers/logging/sink-identity.d.ts +37 -0
- package/dist/controllers/logging/sink-identity.d.ts.map +1 -0
- package/dist/controllers/logging/sink-identity.js +40 -0
- package/dist/controllers/logging/sink-identity.js.map +1 -0
- package/dist/controllers/module/import-controller.d.ts.map +1 -1
- package/dist/controllers/module/import-controller.js +25 -0
- package/dist/controllers/module/import-controller.js.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
- package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
- package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
- package/dist/evaluation-context.d.ts +27 -1
- package/dist/evaluation-context.d.ts.map +1 -1
- package/dist/evaluation-context.js +84 -16
- package/dist/evaluation-context.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/internal-context.d.ts +4 -0
- package/dist/internal-context.d.ts.map +1 -1
- package/dist/kernel.d.ts +26 -0
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +78 -5
- package/dist/kernel.js.map +1 -1
- package/dist/logging/bootstrap-writer.d.ts +23 -0
- package/dist/logging/bootstrap-writer.d.ts.map +1 -0
- package/dist/logging/bootstrap-writer.js +31 -0
- package/dist/logging/bootstrap-writer.js.map +1 -0
- package/dist/logging/color-precedence.d.ts +27 -0
- package/dist/logging/color-precedence.d.ts.map +1 -0
- package/dist/logging/color-precedence.js +47 -0
- package/dist/logging/color-precedence.js.map +1 -0
- package/dist/logging/console-sink.d.ts +45 -0
- package/dist/logging/console-sink.d.ts.map +1 -0
- package/dist/logging/console-sink.js +56 -0
- package/dist/logging/console-sink.js.map +1 -0
- package/dist/logging/debug-wire-sink.d.ts +38 -0
- package/dist/logging/debug-wire-sink.d.ts.map +1 -0
- package/dist/logging/debug-wire-sink.js +85 -0
- package/dist/logging/debug-wire-sink.js.map +1 -0
- package/dist/logging/drop-accounting.d.ts +34 -0
- package/dist/logging/drop-accounting.d.ts.map +1 -0
- package/dist/logging/drop-accounting.js +98 -0
- package/dist/logging/drop-accounting.js.map +1 -0
- package/dist/logging/encode-json.d.ts +28 -0
- package/dist/logging/encode-json.d.ts.map +1 -0
- package/dist/logging/encode-json.js +86 -0
- package/dist/logging/encode-json.js.map +1 -0
- package/dist/logging/encode-pretty.d.ts +7 -0
- package/dist/logging/encode-pretty.d.ts.map +1 -0
- package/dist/logging/encode-pretty.js +104 -0
- package/dist/logging/encode-pretty.js.map +1 -0
- package/dist/logging/file-sink.d.ts +33 -0
- package/dist/logging/file-sink.d.ts.map +1 -0
- package/dist/logging/file-sink.js +72 -0
- package/dist/logging/file-sink.js.map +1 -0
- package/dist/logging/index.d.ts +39 -0
- package/dist/logging/index.d.ts.map +1 -0
- package/dist/logging/index.js +27 -0
- package/dist/logging/index.js.map +1 -0
- package/dist/logging/kernel-logging.d.ts +110 -0
- package/dist/logging/kernel-logging.d.ts.map +1 -0
- package/dist/logging/kernel-logging.js +199 -0
- package/dist/logging/kernel-logging.js.map +1 -0
- package/dist/logging/log-sink.d.ts +9 -0
- package/dist/logging/log-sink.d.ts.map +1 -0
- package/dist/logging/log-sink.js +8 -0
- package/dist/logging/log-sink.js.map +1 -0
- package/dist/logging/logging-host.d.ts +14 -0
- package/dist/logging/logging-host.d.ts.map +1 -0
- package/dist/logging/logging-host.js +27 -0
- package/dist/logging/logging-host.js.map +1 -0
- package/dist/logging/logging-pipeline.d.ts +89 -0
- package/dist/logging/logging-pipeline.d.ts.map +1 -0
- package/dist/logging/logging-pipeline.js +349 -0
- package/dist/logging/logging-pipeline.js.map +1 -0
- package/dist/logging/normalize-attributes.d.ts +48 -0
- package/dist/logging/normalize-attributes.d.ts.map +1 -0
- package/dist/logging/normalize-attributes.js +140 -0
- package/dist/logging/normalize-attributes.js.map +1 -0
- package/dist/logging/record-buffer.d.ts +24 -0
- package/dist/logging/record-buffer.d.ts.map +1 -0
- package/dist/logging/record-buffer.js +78 -0
- package/dist/logging/record-buffer.js.map +1 -0
- package/dist/logging/redact-attributes.d.ts +45 -0
- package/dist/logging/redact-attributes.d.ts.map +1 -0
- package/dist/logging/redact-attributes.js +152 -0
- package/dist/logging/redact-attributes.js.map +1 -0
- package/dist/logging/sampler.d.ts +34 -0
- package/dist/logging/sampler.d.ts.map +1 -0
- package/dist/logging/sampler.js +52 -0
- package/dist/logging/sampler.js.map +1 -0
- package/dist/logging/scope-config.d.ts +32 -0
- package/dist/logging/scope-config.d.ts.map +1 -0
- package/dist/logging/scope-config.js +2 -0
- package/dist/logging/scope-config.js.map +1 -0
- package/dist/logging/span-id.d.ts +24 -0
- package/dist/logging/span-id.d.ts.map +1 -0
- package/dist/logging/span-id.js +85 -0
- package/dist/logging/span-id.js.map +1 -0
- package/dist/logging/to-error-value.d.ts +3 -0
- package/dist/logging/to-error-value.d.ts.map +1 -0
- package/dist/logging/to-error-value.js +79 -0
- package/dist/logging/to-error-value.js.map +1 -0
- package/dist/logging/trace-parent.d.ts +54 -0
- package/dist/logging/trace-parent.d.ts.map +1 -0
- package/dist/logging/trace-parent.js +92 -0
- package/dist/logging/trace-parent.js.map +1 -0
- package/dist/manifest-schemas.d.ts +1 -1
- package/dist/manifest-schemas.d.ts.map +1 -1
- package/dist/manifest-schemas.js +12 -0
- package/dist/manifest-schemas.js.map +1 -1
- package/dist/module-context.d.ts +16 -0
- package/dist/module-context.d.ts.map +1 -1
- package/dist/module-context.js +6 -0
- package/dist/module-context.js.map +1 -1
- package/dist/resource-context.d.ts +15 -1
- package/dist/resource-context.d.ts.map +1 -1
- package/dist/resource-context.js +45 -0
- package/dist/resource-context.js.map +1 -1
- package/dist/schema-compiled-values.d.ts.map +1 -1
- package/dist/schema-compiled-values.js +7 -0
- package/dist/schema-compiled-values.js.map +1 -1
- package/dist/schema-validator.d.ts +8 -1
- package/dist/schema-validator.d.ts.map +1 -1
- package/dist/schema-validator.js +13 -3
- package/dist/schema-validator.js.map +1 -1
- package/package.json +3 -3
- package/src/controller-loader.ts +7 -1
- package/src/controller-loaders/bundle-builder.ts +18 -10
- package/src/controller-loaders/npm-loader.ts +32 -11
- package/src/controllers/logging/console-sink-controller.ts +46 -0
- package/src/controllers/logging/file-sink-controller.ts +40 -0
- package/src/controllers/logging/sink-identity.ts +53 -0
- package/src/controllers/module/import-controller.ts +27 -0
- package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
- package/src/evaluation-context.ts +93 -15
- package/src/index.ts +54 -0
- package/src/internal-context.ts +4 -0
- package/src/kernel.ts +98 -8
- package/src/logging/bootstrap-writer.ts +38 -0
- package/src/logging/color-precedence.ts +62 -0
- package/src/logging/console-sink.ts +86 -0
- package/src/logging/debug-wire-sink.ts +85 -0
- package/src/logging/drop-accounting.ts +108 -0
- package/src/logging/encode-json.ts +98 -0
- package/src/logging/encode-pretty.ts +117 -0
- package/src/logging/file-sink.ts +87 -0
- package/src/logging/index.ts +65 -0
- package/src/logging/kernel-logging.ts +266 -0
- package/src/logging/log-sink.ts +18 -0
- package/src/logging/logging-host.ts +33 -0
- package/src/logging/logging-pipeline.ts +465 -0
- package/src/logging/normalize-attributes.ts +228 -0
- package/src/logging/record-buffer.ts +72 -0
- package/src/logging/redact-attributes.ts +168 -0
- package/src/logging/sampler.ts +76 -0
- package/src/logging/scope-config.ts +32 -0
- package/src/logging/span-id.ts +84 -0
- package/src/logging/to-error-value.ts +89 -0
- package/src/logging/trace-parent.ts +110 -0
- package/src/manifest-schemas.ts +12 -0
- package/src/module-context.ts +23 -0
- package/src/resource-context.ts +48 -0
- package/src/schema-compiled-values.ts +6 -0
- package/src/schema-validator.ts +20 -5
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
import {
|
|
2
|
+
nowUnixNano,
|
|
3
|
+
severityText as canonicalSeverityText,
|
|
4
|
+
SEVERITY,
|
|
5
|
+
type LogAttributes,
|
|
6
|
+
type LogAttributesInput,
|
|
7
|
+
type LogOptions,
|
|
8
|
+
type Logger,
|
|
9
|
+
type LogRecord,
|
|
10
|
+
type ResourceRef,
|
|
11
|
+
} from "@telorun/sdk";
|
|
12
|
+
import { DropRegistry, PIPELINE_SINK_ID, type DropReport } from "./drop-accounting.js";
|
|
13
|
+
import type { DropCause, LogSinkInstance } from "./log-sink.js";
|
|
14
|
+
import { normalizeAttributes, type AttributeLimits } from "./normalize-attributes.js";
|
|
15
|
+
import { EMPTY_REDACTION_POLICY, redactAttributes, redactError, type RedactionPolicy } from "./redact-attributes.js";
|
|
16
|
+
import { Sampler, type SamplingConfig } from "./sampler.js";
|
|
17
|
+
import type { ScopeConfig } from "./scope-config.js";
|
|
18
|
+
|
|
19
|
+
export type { ScopeConfig } from "./scope-config.js";
|
|
20
|
+
import { toErrorValue } from "./to-error-value.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The emission pipeline — `kernel/specs/logging.md` §10.1:
|
|
24
|
+
*
|
|
25
|
+
* controller → Logger → [threshold] → [redaction] → [sampling] → fan-out → Sink₁..Sinkₙ
|
|
26
|
+
*
|
|
27
|
+
* Redaction runs **before** serialization and before any sink sees the record.
|
|
28
|
+
*
|
|
29
|
+
* The logger core is deliberately independent of the sink set (D1): the debug
|
|
30
|
+
* wire is *one sink*, not the pipeline. Logging works with no debug consumer
|
|
31
|
+
* attached and with tracing off, and no sink depends on the event bus — which
|
|
32
|
+
* short-circuits to zero cost when unsubscribed and therefore cannot carry logs.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/** Ambient span context, supplied by the host so §7.2's automatic attachment
|
|
36
|
+
* needs no cooperation from the controller. Returns `undefined` when no span is
|
|
37
|
+
* active, in which case all three trace fields are omitted. */
|
|
38
|
+
export type TraceContextProvider = () =>
|
|
39
|
+
| { traceId: string; spanId: string; traceFlags?: number }
|
|
40
|
+
| undefined;
|
|
41
|
+
|
|
42
|
+
export const ROOT_SCOPE_CONFIG: ScopeConfig = {
|
|
43
|
+
threshold: SEVERITY.info,
|
|
44
|
+
redaction: EMPTY_REDACTION_POLICY,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export interface PipelineOptions {
|
|
48
|
+
/** The process's real stderr — the fallback diagnostic stream of §8.4. This is
|
|
49
|
+
* the one place a logger may not surface an error inline; it is reported
|
|
50
|
+
* out-of-band, never swallowed. */
|
|
51
|
+
fallbackStream: { write(chunk: string): unknown };
|
|
52
|
+
traceContext?: TraceContextProvider;
|
|
53
|
+
limits?: AttributeLimits;
|
|
54
|
+
/** Records held before the first declared sink attaches, replayed in order
|
|
55
|
+
* (§12.1). Bounded, and overflow is counted like any other drop. */
|
|
56
|
+
bootstrapCapacity?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const DEFAULT_BOOTSTRAP_CAPACITY = 1024;
|
|
60
|
+
const FALLBACK_REPORT_INTERVAL_MS = 5000;
|
|
61
|
+
|
|
62
|
+
export class LoggingPipeline {
|
|
63
|
+
readonly #sinks: LogSinkInstance[] = [];
|
|
64
|
+
readonly #drops: DropRegistry;
|
|
65
|
+
readonly #fallback: { write(chunk: string): unknown };
|
|
66
|
+
readonly #limits: AttributeLimits | undefined;
|
|
67
|
+
readonly #bootstrapCapacity: number;
|
|
68
|
+
#traceContext: TraceContextProvider | undefined;
|
|
69
|
+
|
|
70
|
+
/** Bumped whenever the sink set changes, so a logger's cached gate
|
|
71
|
+
* invalidates without every logger being tracked and rewritten (§12.4). */
|
|
72
|
+
#gateVersion = 0;
|
|
73
|
+
#gateCache = new Map<number, number>();
|
|
74
|
+
|
|
75
|
+
#bootstrap: LogRecord[] | undefined = [];
|
|
76
|
+
#bootstrapDropped = 0;
|
|
77
|
+
readonly #samplers = new Map<ScopeConfig, Sampler>();
|
|
78
|
+
readonly #lastFallbackReport = new Map<string, number>();
|
|
79
|
+
|
|
80
|
+
constructor(options: PipelineOptions) {
|
|
81
|
+
this.#fallback = options.fallbackStream;
|
|
82
|
+
this.#traceContext = options.traceContext;
|
|
83
|
+
this.#limits = options.limits;
|
|
84
|
+
this.#bootstrapCapacity = options.bootstrapCapacity ?? DEFAULT_BOOTSTRAP_CAPACITY;
|
|
85
|
+
this.#drops = new DropRegistry((report) => this.#reportDrops(report));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Wire the ambient trace source once the kernel's tracer exists. */
|
|
89
|
+
setTraceContextProvider(provider: TraceContextProvider | undefined): void {
|
|
90
|
+
this.#traceContext = provider;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
get sinkCount(): number {
|
|
94
|
+
return this.#sinks.length;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Attach a sink and replay the bootstrap backlog into it, in original order.
|
|
99
|
+
* Attaching changes the minimum-level gate, so it is recomputed and the new
|
|
100
|
+
* threshold propagates to guests (§12.4).
|
|
101
|
+
*/
|
|
102
|
+
attach(sink: LogSinkInstance): void {
|
|
103
|
+
this.#sinks.push(sink);
|
|
104
|
+
this.#invalidateGate();
|
|
105
|
+
if (this.#bootstrap) {
|
|
106
|
+
for (const record of this.#bootstrap) {
|
|
107
|
+
if (record.severityNumber >= sink.level) this.#writeTo(sink, record);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
detach(sink: LogSinkInstance): void {
|
|
113
|
+
const index = this.#sinks.indexOf(sink);
|
|
114
|
+
if (index >= 0) this.#sinks.splice(index, 1);
|
|
115
|
+
this.#invalidateGate();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Stop holding records for replay. Called once every declared sink has
|
|
120
|
+
* attached: the bootstrap buffer covers the pre-attach window, and a consumer
|
|
121
|
+
* connecting later (the debug wire) wants the live stream, not the whole
|
|
122
|
+
* process history.
|
|
123
|
+
*/
|
|
124
|
+
sealBootstrap(): void {
|
|
125
|
+
if (this.#bootstrapDropped > 0) {
|
|
126
|
+
this.#drops.record(PIPELINE_SINK_ID, "buffer_full", this.#bootstrapDropped);
|
|
127
|
+
this.#bootstrapDropped = 0;
|
|
128
|
+
}
|
|
129
|
+
this.#bootstrap = undefined;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The severity at or above which a record is created for a scope at
|
|
134
|
+
* `scopeThreshold`: the minimum — most verbose — effective level across all
|
|
135
|
+
* attached sinks. A record failing this gate reaches no sink and is never
|
|
136
|
+
* created, formatted, or sent across an FFI boundary.
|
|
137
|
+
*
|
|
138
|
+
* With no sink attached the scope's own threshold governs, so records still
|
|
139
|
+
* reach the bootstrap buffer and are replayed once a sink arrives.
|
|
140
|
+
*/
|
|
141
|
+
gateFor(scopeThreshold: number): number {
|
|
142
|
+
const cached = this.#gateCache.get(scopeThreshold);
|
|
143
|
+
if (cached !== undefined) return cached;
|
|
144
|
+
let gate = scopeThreshold;
|
|
145
|
+
if (this.#sinks.length > 0) {
|
|
146
|
+
gate = Number.POSITIVE_INFINITY;
|
|
147
|
+
for (const sink of this.#sinks) {
|
|
148
|
+
const effective = Number.isFinite(sink.level) ? sink.level : scopeThreshold;
|
|
149
|
+
if (effective < gate) gate = effective;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
this.#gateCache.set(scopeThreshold, gate);
|
|
153
|
+
return gate;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get gateVersion(): number {
|
|
157
|
+
return this.#gateVersion;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Count a drop against a sink. Exposed so a sink's own buffer can report
|
|
161
|
+
* saturation without reaching into the registry directly. */
|
|
162
|
+
recordDrop(sinkId: string, cause: DropCause, count = 1): void {
|
|
163
|
+
this.#drops.record(sinkId, cause, count);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Lifetime drop total for a (sink, cause) pair, for assertions and for the
|
|
167
|
+
* shutdown report. */
|
|
168
|
+
dropTotal(sinkId: string, cause: DropCause): number {
|
|
169
|
+
return this.#drops.total(sinkId, cause);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
createLogger(scope: ScopeConfig, resource?: ResourceRef, bound?: LogAttributes): Logger {
|
|
173
|
+
return new ScopedLogger(this, scope, resource, bound);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Build and dispatch a record. Never throws (§8.4). */
|
|
177
|
+
emit(
|
|
178
|
+
scope: ScopeConfig,
|
|
179
|
+
severity: number,
|
|
180
|
+
message: string,
|
|
181
|
+
resource: ResourceRef | undefined,
|
|
182
|
+
bound: LogAttributes | undefined,
|
|
183
|
+
attributes: LogAttributesInput | undefined,
|
|
184
|
+
options: LogOptions | undefined,
|
|
185
|
+
): void {
|
|
186
|
+
try {
|
|
187
|
+
const record = this.#buildRecord(scope, severity, message, resource, bound, attributes, options);
|
|
188
|
+
|
|
189
|
+
const sampler = this.#samplerFor(scope);
|
|
190
|
+
if (sampler && !sampler.shouldEmit(record, Date.now())) {
|
|
191
|
+
this.#drops.record(PIPELINE_SINK_ID, "sampled");
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
this.#dispatch(record);
|
|
196
|
+
|
|
197
|
+
// `fatal` never alters control flow — no exit, no panic — but it does
|
|
198
|
+
// oblige an immediate flush: synchronous on every sink that supports it,
|
|
199
|
+
// initiated without waiting on the rest (§10.5).
|
|
200
|
+
if (severity >= SEVERITY.fatal) this.flushFatal();
|
|
201
|
+
} catch (err) {
|
|
202
|
+
this.#reportFallback("<pipeline>", err);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#buildRecord(
|
|
207
|
+
scope: ScopeConfig,
|
|
208
|
+
severity: number,
|
|
209
|
+
message: string,
|
|
210
|
+
resource: ResourceRef | undefined,
|
|
211
|
+
bound: LogAttributes | undefined,
|
|
212
|
+
attributes: LogAttributesInput | undefined,
|
|
213
|
+
options: LogOptions | undefined,
|
|
214
|
+
): LogRecord {
|
|
215
|
+
const merged: LogAttributesInput | undefined =
|
|
216
|
+
bound || attributes || scope.attributes
|
|
217
|
+
? { ...scope.attributes, ...bound, ...attributes }
|
|
218
|
+
: undefined;
|
|
219
|
+
|
|
220
|
+
const normalized = normalizeAttributes(merged, {
|
|
221
|
+
limits: this.#limits,
|
|
222
|
+
secretValues: scope.secretValues,
|
|
223
|
+
censor: scope.redaction.censor,
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
redactAttributes(normalized.attributes, scope.redaction);
|
|
227
|
+
|
|
228
|
+
const error = options?.error === undefined ? undefined : toErrorValue(options.error);
|
|
229
|
+
redactError(error, scope.redaction);
|
|
230
|
+
|
|
231
|
+
const timestamp = options?.timestamp ?? nowUnixNano();
|
|
232
|
+
const record: LogRecord = {
|
|
233
|
+
timestamp,
|
|
234
|
+
severityNumber: severity,
|
|
235
|
+
severityText: options?.severityText ?? canonicalSeverityText(severity),
|
|
236
|
+
message,
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// A bridged record's origin time precedes the moment the runtime saw it.
|
|
240
|
+
if (options?.timestamp !== undefined) record.observedTimestamp = nowUnixNano();
|
|
241
|
+
if (normalized.attributes && Object.keys(normalized.attributes).length > 0) {
|
|
242
|
+
record.attributes = normalized.attributes;
|
|
243
|
+
}
|
|
244
|
+
if (normalized.droppedCount > 0) record.droppedAttributesCount = normalized.droppedCount;
|
|
245
|
+
if (error) record.error = error;
|
|
246
|
+
if (resource) record.resource = resource;
|
|
247
|
+
if (scope.module) record.module = scope.module;
|
|
248
|
+
if (scope.scope) record.scope = scope.scope;
|
|
249
|
+
if (options?.eventName) record.eventName = options.eventName.slice(0, 256);
|
|
250
|
+
|
|
251
|
+
const trace = this.#traceContext?.();
|
|
252
|
+
if (trace) {
|
|
253
|
+
record.traceId = trace.traceId;
|
|
254
|
+
record.spanId = trace.spanId;
|
|
255
|
+
if (trace.traceFlags !== undefined) record.traceFlags = trace.traceFlags;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return record;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
#dispatch(record: LogRecord): void {
|
|
262
|
+
if (this.#sinks.length === 0) {
|
|
263
|
+
this.#bufferBootstrap(record);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (this.#bootstrap) this.#bufferBootstrap(record);
|
|
267
|
+
for (const sink of this.#sinks) {
|
|
268
|
+
if (record.severityNumber < sink.level) continue;
|
|
269
|
+
this.#writeTo(sink, record);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
#writeTo(sink: LogSinkInstance, record: LogRecord): void {
|
|
274
|
+
try {
|
|
275
|
+
sink.write(record);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
this.#drops.record(sink.sinkId, "sink_error");
|
|
278
|
+
this.#reportFallback(sink.sinkId, err);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
#bufferBootstrap(record: LogRecord): void {
|
|
283
|
+
const buffer = this.#bootstrap;
|
|
284
|
+
if (!buffer) return;
|
|
285
|
+
if (buffer.length >= this.#bootstrapCapacity) {
|
|
286
|
+
this.#bootstrapDropped += 1;
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
buffer.push(record);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
#samplerFor(scope: ScopeConfig): Sampler | undefined {
|
|
293
|
+
if (!scope.sampling) return undefined;
|
|
294
|
+
let sampler = this.#samplers.get(scope);
|
|
295
|
+
if (!sampler) {
|
|
296
|
+
sampler = new Sampler(scope.sampling);
|
|
297
|
+
this.#samplers.set(scope, sampler);
|
|
298
|
+
}
|
|
299
|
+
return sampler;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
#invalidateGate(): void {
|
|
303
|
+
this.#gateVersion += 1;
|
|
304
|
+
this.#gateCache = new Map();
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** §10.5's fatal tiering: drain every sync-flushable sink to completion before
|
|
308
|
+
* returning, and merely *initiate* the rest. Blocking on a sink that cannot
|
|
309
|
+
* be synchronously drained is a deadlock on an event loop, not durability. */
|
|
310
|
+
flushFatal(): void {
|
|
311
|
+
for (const sink of this.#sinks) {
|
|
312
|
+
try {
|
|
313
|
+
if (sink.syncFlushable) sink.flushSync();
|
|
314
|
+
else void sink.flush().catch((err) => this.#reportFallback(sink.sinkId, err));
|
|
315
|
+
} catch (err) {
|
|
316
|
+
this.#reportFallback(sink.sinkId, err);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async flush(): Promise<void> {
|
|
322
|
+
await Promise.all(
|
|
323
|
+
this.#sinks.map((sink) =>
|
|
324
|
+
sink.flush().catch((err) => {
|
|
325
|
+
this.#drops.record(sink.sinkId, "sink_error");
|
|
326
|
+
this.#reportFallback(sink.sinkId, err);
|
|
327
|
+
}),
|
|
328
|
+
),
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Final flush and release. Outstanding drop reports are emitted first so a
|
|
333
|
+
* run that ends while still dropping does not lose its accounting. */
|
|
334
|
+
async close(): Promise<void> {
|
|
335
|
+
this.#drops.reportPending();
|
|
336
|
+
await this.flush();
|
|
337
|
+
await Promise.all(
|
|
338
|
+
this.#sinks.map((sink) =>
|
|
339
|
+
sink.close().catch((err) => this.#reportFallback(sink.sinkId, err)),
|
|
340
|
+
),
|
|
341
|
+
);
|
|
342
|
+
this.#sinks.length = 0;
|
|
343
|
+
this.#drops.dispose();
|
|
344
|
+
this.#invalidateGate();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The recovery warning §10.4 requires once drops cease. Emitted as an
|
|
348
|
+
* ordinary record so it reaches every sink. */
|
|
349
|
+
#reportDrops(report: DropReport): void {
|
|
350
|
+
this.emit(
|
|
351
|
+
ROOT_SCOPE_CONFIG,
|
|
352
|
+
SEVERITY.warn,
|
|
353
|
+
`dropped ${report.count} log record(s)`,
|
|
354
|
+
undefined,
|
|
355
|
+
undefined,
|
|
356
|
+
{
|
|
357
|
+
"telo.log.sink": report.sinkId,
|
|
358
|
+
"telo.log.drop_cause": report.cause,
|
|
359
|
+
"telo.log.dropped": report.count,
|
|
360
|
+
"telo.log.dropped_total": report.total,
|
|
361
|
+
},
|
|
362
|
+
{ eventName: "telo.log.dropped" },
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* §8.4: a sink failure never propagates to the caller and is never silently
|
|
368
|
+
* discarded — it goes to the process's real stderr, at most once per sink per
|
|
369
|
+
* interval, and is counted.
|
|
370
|
+
*/
|
|
371
|
+
#reportFallback(sinkId: string, err: unknown): void {
|
|
372
|
+
const now = Date.now();
|
|
373
|
+
const last = this.#lastFallbackReport.get(sinkId) ?? 0;
|
|
374
|
+
if (now - last < FALLBACK_REPORT_INTERVAL_MS) return;
|
|
375
|
+
this.#lastFallbackReport.set(sinkId, now);
|
|
376
|
+
const detail = err instanceof Error ? `${err.name}: ${err.message}` : String(err);
|
|
377
|
+
try {
|
|
378
|
+
this.#fallback.write(`[telo:logging] sink "${sinkId}" failed: ${detail}\n`);
|
|
379
|
+
} catch {
|
|
380
|
+
// The fallback stream itself is gone. There is nowhere left to report to,
|
|
381
|
+
// and throwing here would break the application — which §8.4 forbids.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
class ScopedLogger implements Logger {
|
|
387
|
+
readonly #pipeline: LoggingPipeline;
|
|
388
|
+
readonly #scope: ScopeConfig;
|
|
389
|
+
readonly #resource: ResourceRef | undefined;
|
|
390
|
+
readonly #bound: LogAttributes | undefined;
|
|
391
|
+
#cachedGate = Number.NaN;
|
|
392
|
+
#cachedVersion = -1;
|
|
393
|
+
|
|
394
|
+
constructor(
|
|
395
|
+
pipeline: LoggingPipeline,
|
|
396
|
+
scope: ScopeConfig,
|
|
397
|
+
resource: ResourceRef | undefined,
|
|
398
|
+
bound: LogAttributes | undefined,
|
|
399
|
+
) {
|
|
400
|
+
this.#pipeline = pipeline;
|
|
401
|
+
this.#scope = scope;
|
|
402
|
+
this.#resource = resource;
|
|
403
|
+
this.#bound = bound;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
enabled(severity: number): boolean {
|
|
407
|
+
const version = this.#pipeline.gateVersion;
|
|
408
|
+
if (version !== this.#cachedVersion) {
|
|
409
|
+
this.#cachedGate = this.#pipeline.gateFor(this.#scope.threshold);
|
|
410
|
+
this.#cachedVersion = version;
|
|
411
|
+
}
|
|
412
|
+
return severity >= this.#cachedGate;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
log(
|
|
416
|
+
severity: number,
|
|
417
|
+
message: string,
|
|
418
|
+
attributes?: LogAttributesInput,
|
|
419
|
+
options?: LogOptions,
|
|
420
|
+
): void {
|
|
421
|
+
if (!this.enabled(severity)) return;
|
|
422
|
+
this.#pipeline.emit(
|
|
423
|
+
this.#scope,
|
|
424
|
+
severity,
|
|
425
|
+
message,
|
|
426
|
+
this.#resource,
|
|
427
|
+
this.#bound,
|
|
428
|
+
attributes,
|
|
429
|
+
options,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Binding merges once, here — never per record — so a child logger costs one
|
|
434
|
+
* spread at creation and nothing at emit time (§8.3). */
|
|
435
|
+
with(attributes: LogAttributesInput): Logger {
|
|
436
|
+
const normalized = normalizeAttributes(attributes).attributes;
|
|
437
|
+
return new ScopedLogger(this.#pipeline, this.#scope, this.#resource, {
|
|
438
|
+
...this.#bound,
|
|
439
|
+
...normalized,
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
flush(): Promise<void> {
|
|
444
|
+
return this.#pipeline.flush();
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
trace(message: string, attributes?: LogAttributesInput, options?: LogOptions): void {
|
|
448
|
+
this.log(SEVERITY.trace, message, attributes, options);
|
|
449
|
+
}
|
|
450
|
+
debug(message: string, attributes?: LogAttributesInput, options?: LogOptions): void {
|
|
451
|
+
this.log(SEVERITY.debug, message, attributes, options);
|
|
452
|
+
}
|
|
453
|
+
info(message: string, attributes?: LogAttributesInput, options?: LogOptions): void {
|
|
454
|
+
this.log(SEVERITY.info, message, attributes, options);
|
|
455
|
+
}
|
|
456
|
+
warn(message: string, attributes?: LogAttributesInput, options?: LogOptions): void {
|
|
457
|
+
this.log(SEVERITY.warn, message, attributes, options);
|
|
458
|
+
}
|
|
459
|
+
error(message: string, attributes?: LogAttributesInput, options?: LogOptions): void {
|
|
460
|
+
this.log(SEVERITY.error, message, attributes, options);
|
|
461
|
+
}
|
|
462
|
+
fatal(message: string, attributes?: LogAttributesInput, options?: LogOptions): void {
|
|
463
|
+
this.log(SEVERITY.fatal, message, attributes, options);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { isLogValuer, type AnyValue, type LogAttributes, type LogAttributesInput } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Attribute normalization — `kernel/specs/logging.md` §6.3.
|
|
5
|
+
*
|
|
6
|
+
* Resolves deferred values, applies every limit, scrubs secret values, and
|
|
7
|
+
* detects cycles in a single traversal. The caps are not optional hardening:
|
|
8
|
+
* they bound the blast radius of a redaction miss and of a cyclic or
|
|
9
|
+
* pathological value graph, so the traversal is iterative with a bounded
|
|
10
|
+
* counter rather than recursive, and an exception raised inside a user-supplied
|
|
11
|
+
* deferred value is caught and rendered as a diagnostic string rather than
|
|
12
|
+
* propagated (§8.4 — a logging call never throws).
|
|
13
|
+
*
|
|
14
|
+
* Secret scrubbing happens here rather than in the path-based pass because this
|
|
15
|
+
* traversal already visits every node, and §14 requires manifest secrets to
|
|
16
|
+
* redact with no configuration at all.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export interface AttributeLimits {
|
|
20
|
+
/** Excess attributes are dropped and counted into `dropped_attributes_count`. */
|
|
21
|
+
attributeCount: number;
|
|
22
|
+
/** Unlimited when `undefined`; when set, longer strings truncate and are marked. */
|
|
23
|
+
valueLength?: number;
|
|
24
|
+
/** Over-deep subtrees are replaced with `"[depth exceeded]"`. */
|
|
25
|
+
depth: number;
|
|
26
|
+
/** Arrays and maps truncate, recording the original length. */
|
|
27
|
+
collectionElements: number;
|
|
28
|
+
/** Deferred-value resolutions per record. Beyond this the traversal stops
|
|
29
|
+
* resolving and substitutes a diagnostic string. */
|
|
30
|
+
deferredSteps: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const DEFAULT_ATTRIBUTE_LIMITS: AttributeLimits = {
|
|
34
|
+
attributeCount: 128,
|
|
35
|
+
valueLength: undefined,
|
|
36
|
+
depth: 10,
|
|
37
|
+
collectionElements: 1000,
|
|
38
|
+
deferredSteps: 100,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const DEPTH_EXCEEDED = "[depth exceeded]";
|
|
42
|
+
export const CIRCULAR = "[circular]";
|
|
43
|
+
|
|
44
|
+
export interface NormalizeOptions {
|
|
45
|
+
limits?: AttributeLimits;
|
|
46
|
+
/** Exact string values to replace with the censor token. Sourced from the
|
|
47
|
+
* emitting module context's resolved `secrets:`, so redaction follows the
|
|
48
|
+
* same cascade the threshold does. */
|
|
49
|
+
secretValues?: ReadonlySet<string>;
|
|
50
|
+
censor?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface NormalizedAttributes {
|
|
54
|
+
attributes: LogAttributes | undefined;
|
|
55
|
+
/** Non-zero when limits truncated attributes; emitted as
|
|
56
|
+
* `dropped_attributes_count` and omitted when zero. */
|
|
57
|
+
droppedCount: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Task {
|
|
61
|
+
/** The raw value to normalize. */
|
|
62
|
+
source: unknown;
|
|
63
|
+
/** Container to write the normalized value into. */
|
|
64
|
+
parent: AnyValue[] | Record<string, AnyValue>;
|
|
65
|
+
key: string | number;
|
|
66
|
+
depth: number;
|
|
67
|
+
/** Ancestor objects on this branch, for cycle detection. Bounded by
|
|
68
|
+
* `limits.depth`, so copying it per push stays cheap and — unlike a global
|
|
69
|
+
* seen-set — a value legitimately shared by two sibling branches is not
|
|
70
|
+
* mislabelled circular. */
|
|
71
|
+
ancestors: readonly object[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function normalizeAttributes(
|
|
75
|
+
input: LogAttributesInput | undefined,
|
|
76
|
+
options: NormalizeOptions = {},
|
|
77
|
+
): NormalizedAttributes {
|
|
78
|
+
if (!input) return { attributes: undefined, droppedCount: 0 };
|
|
79
|
+
|
|
80
|
+
const limits = options.limits ?? DEFAULT_ATTRIBUTE_LIMITS;
|
|
81
|
+
const censor = options.censor ?? "[redacted]";
|
|
82
|
+
const secrets = options.secretValues;
|
|
83
|
+
|
|
84
|
+
const root: Record<string, AnyValue> = {};
|
|
85
|
+
let droppedCount = 0;
|
|
86
|
+
let deferredBudget = limits.deferredSteps;
|
|
87
|
+
|
|
88
|
+
const keys = Object.keys(input);
|
|
89
|
+
const kept = keys.length > limits.attributeCount ? limits.attributeCount : keys.length;
|
|
90
|
+
droppedCount += keys.length - kept;
|
|
91
|
+
|
|
92
|
+
const stack: Task[] = [];
|
|
93
|
+
for (let i = kept - 1; i >= 0; i -= 1) {
|
|
94
|
+
const key = keys[i]!;
|
|
95
|
+
stack.push({ source: input[key], parent: root, key, depth: 1, ancestors: [] });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
while (stack.length > 0) {
|
|
99
|
+
const task = stack.pop()!;
|
|
100
|
+
let value: unknown = task.source;
|
|
101
|
+
|
|
102
|
+
if (isLogValuer(value)) {
|
|
103
|
+
if (deferredBudget <= 0) {
|
|
104
|
+
write(task, "[deferred limit exceeded]");
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
deferredBudget -= 1;
|
|
108
|
+
try {
|
|
109
|
+
value = value.toLogValue();
|
|
110
|
+
} catch (err) {
|
|
111
|
+
write(task, `[deferred value threw: ${describeThrown(err)}]`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (value === null || value === undefined) {
|
|
117
|
+
// `null` is a valid attribute value and is preserved (§6.1). `undefined`
|
|
118
|
+
// has no AnyValue variant, so it normalizes to the empty variant.
|
|
119
|
+
write(task, null);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const type = typeof value;
|
|
124
|
+
|
|
125
|
+
if (type === "string") {
|
|
126
|
+
write(task, scrubString(value as string, secrets, censor, limits.valueLength));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (type === "boolean" || type === "bigint") {
|
|
131
|
+
write(task, value as boolean | bigint);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (type === "number") {
|
|
136
|
+
const num = value as number;
|
|
137
|
+
// JSON has no representation for these; emitting them raw produces
|
|
138
|
+
// invalid output, so they render as their spelling instead.
|
|
139
|
+
write(task, Number.isFinite(num) ? num : String(num));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (value instanceof Uint8Array) {
|
|
144
|
+
write(task, value);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (type !== "object") {
|
|
149
|
+
// Functions and symbols have no AnyValue variant.
|
|
150
|
+
write(task, `[${type}]`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const object = value as object;
|
|
155
|
+
|
|
156
|
+
if (task.ancestors.includes(object)) {
|
|
157
|
+
write(task, CIRCULAR);
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (task.depth >= limits.depth) {
|
|
162
|
+
write(task, DEPTH_EXCEEDED);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const ancestors = [...task.ancestors, object];
|
|
167
|
+
|
|
168
|
+
if (Array.isArray(value)) {
|
|
169
|
+
const total = value.length;
|
|
170
|
+
const keep = total > limits.collectionElements ? limits.collectionElements : total;
|
|
171
|
+
const array: AnyValue[] = new Array(keep);
|
|
172
|
+
write(task, array);
|
|
173
|
+
if (keep < total) array.push(`[truncated: ${keep} of ${total} elements]`);
|
|
174
|
+
for (let i = keep - 1; i >= 0; i -= 1) {
|
|
175
|
+
stack.push({ source: value[i], parent: array, key: i, depth: task.depth + 1, ancestors });
|
|
176
|
+
}
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (value instanceof Date) {
|
|
181
|
+
write(task, value.toISOString());
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const record = value as Record<string, unknown>;
|
|
186
|
+
const entryKeys = Object.keys(record);
|
|
187
|
+
const keepKeys =
|
|
188
|
+
entryKeys.length > limits.collectionElements ? limits.collectionElements : entryKeys.length;
|
|
189
|
+
const map: Record<string, AnyValue> = {};
|
|
190
|
+
write(task, map);
|
|
191
|
+
if (keepKeys < entryKeys.length) {
|
|
192
|
+
map["[truncated]"] = `${keepKeys} of ${entryKeys.length} entries`;
|
|
193
|
+
}
|
|
194
|
+
for (let i = keepKeys - 1; i >= 0; i -= 1) {
|
|
195
|
+
const key = entryKeys[i]!;
|
|
196
|
+
stack.push({
|
|
197
|
+
source: record[key],
|
|
198
|
+
parent: map,
|
|
199
|
+
key,
|
|
200
|
+
depth: task.depth + 1,
|
|
201
|
+
ancestors,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { attributes: root, droppedCount };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function write(task: Task, value: AnyValue): void {
|
|
210
|
+
if (Array.isArray(task.parent)) task.parent[task.key as number] = value;
|
|
211
|
+
else (task.parent as Record<string, AnyValue>)[task.key as string] = value;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function scrubString(
|
|
215
|
+
value: string,
|
|
216
|
+
secrets: ReadonlySet<string> | undefined,
|
|
217
|
+
censor: string,
|
|
218
|
+
maxLength: number | undefined,
|
|
219
|
+
): string {
|
|
220
|
+
const scrubbed = secrets?.has(value) ? censor : value;
|
|
221
|
+
if (maxLength === undefined || scrubbed.length <= maxLength) return scrubbed;
|
|
222
|
+
return `${scrubbed.slice(0, maxLength)}[truncated ${scrubbed.length - maxLength} chars]`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function describeThrown(err: unknown): string {
|
|
226
|
+
if (err instanceof Error) return `${err.name}: ${err.message}`;
|
|
227
|
+
return String(err);
|
|
228
|
+
}
|