@telorun/kernel 0.48.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.
Files changed (194) hide show
  1. package/dist/controller-loader.d.ts +6 -1
  2. package/dist/controller-loader.d.ts.map +1 -1
  3. package/dist/controller-loader.js +2 -0
  4. package/dist/controller-loader.js.map +1 -1
  5. package/dist/controller-loaders/bundle-builder.d.ts +2 -1
  6. package/dist/controller-loaders/bundle-builder.d.ts.map +1 -1
  7. package/dist/controller-loaders/bundle-builder.js +14 -9
  8. package/dist/controller-loaders/bundle-builder.js.map +1 -1
  9. package/dist/controller-loaders/npm-loader.d.ts +8 -2
  10. package/dist/controller-loaders/npm-loader.d.ts.map +1 -1
  11. package/dist/controller-loaders/npm-loader.js +19 -10
  12. package/dist/controller-loaders/npm-loader.js.map +1 -1
  13. package/dist/controllers/logging/console-sink-controller.d.ts +15 -0
  14. package/dist/controllers/logging/console-sink-controller.d.ts.map +1 -0
  15. package/dist/controllers/logging/console-sink-controller.js +40 -0
  16. package/dist/controllers/logging/console-sink-controller.js.map +1 -0
  17. package/dist/controllers/logging/file-sink-controller.d.ts +11 -0
  18. package/dist/controllers/logging/file-sink-controller.d.ts.map +1 -0
  19. package/dist/controllers/logging/file-sink-controller.js +33 -0
  20. package/dist/controllers/logging/file-sink-controller.js.map +1 -0
  21. package/dist/controllers/logging/sink-identity.d.ts +37 -0
  22. package/dist/controllers/logging/sink-identity.d.ts.map +1 -0
  23. package/dist/controllers/logging/sink-identity.js +40 -0
  24. package/dist/controllers/logging/sink-identity.js.map +1 -0
  25. package/dist/controllers/module/import-controller.d.ts.map +1 -1
  26. package/dist/controllers/module/import-controller.js +25 -0
  27. package/dist/controllers/module/import-controller.js.map +1 -1
  28. package/dist/controllers/resource-definition/resource-definition-controller.d.ts.map +1 -1
  29. package/dist/controllers/resource-definition/resource-definition-controller.js +1 -0
  30. package/dist/controllers/resource-definition/resource-definition-controller.js.map +1 -1
  31. package/dist/evaluation-context.d.ts +27 -1
  32. package/dist/evaluation-context.d.ts.map +1 -1
  33. package/dist/evaluation-context.js +84 -16
  34. package/dist/evaluation-context.js.map +1 -1
  35. package/dist/index.d.ts +4 -0
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +5 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/internal-context.d.ts +4 -0
  40. package/dist/internal-context.d.ts.map +1 -1
  41. package/dist/kernel.d.ts +26 -0
  42. package/dist/kernel.d.ts.map +1 -1
  43. package/dist/kernel.js +78 -5
  44. package/dist/kernel.js.map +1 -1
  45. package/dist/logging/bootstrap-writer.d.ts +23 -0
  46. package/dist/logging/bootstrap-writer.d.ts.map +1 -0
  47. package/dist/logging/bootstrap-writer.js +31 -0
  48. package/dist/logging/bootstrap-writer.js.map +1 -0
  49. package/dist/logging/color-precedence.d.ts +27 -0
  50. package/dist/logging/color-precedence.d.ts.map +1 -0
  51. package/dist/logging/color-precedence.js +47 -0
  52. package/dist/logging/color-precedence.js.map +1 -0
  53. package/dist/logging/console-sink.d.ts +45 -0
  54. package/dist/logging/console-sink.d.ts.map +1 -0
  55. package/dist/logging/console-sink.js +56 -0
  56. package/dist/logging/console-sink.js.map +1 -0
  57. package/dist/logging/debug-wire-sink.d.ts +38 -0
  58. package/dist/logging/debug-wire-sink.d.ts.map +1 -0
  59. package/dist/logging/debug-wire-sink.js +85 -0
  60. package/dist/logging/debug-wire-sink.js.map +1 -0
  61. package/dist/logging/drop-accounting.d.ts +34 -0
  62. package/dist/logging/drop-accounting.d.ts.map +1 -0
  63. package/dist/logging/drop-accounting.js +98 -0
  64. package/dist/logging/drop-accounting.js.map +1 -0
  65. package/dist/logging/encode-json.d.ts +28 -0
  66. package/dist/logging/encode-json.d.ts.map +1 -0
  67. package/dist/logging/encode-json.js +86 -0
  68. package/dist/logging/encode-json.js.map +1 -0
  69. package/dist/logging/encode-pretty.d.ts +7 -0
  70. package/dist/logging/encode-pretty.d.ts.map +1 -0
  71. package/dist/logging/encode-pretty.js +104 -0
  72. package/dist/logging/encode-pretty.js.map +1 -0
  73. package/dist/logging/file-sink.d.ts +33 -0
  74. package/dist/logging/file-sink.d.ts.map +1 -0
  75. package/dist/logging/file-sink.js +72 -0
  76. package/dist/logging/file-sink.js.map +1 -0
  77. package/dist/logging/index.d.ts +39 -0
  78. package/dist/logging/index.d.ts.map +1 -0
  79. package/dist/logging/index.js +27 -0
  80. package/dist/logging/index.js.map +1 -0
  81. package/dist/logging/kernel-logging.d.ts +110 -0
  82. package/dist/logging/kernel-logging.d.ts.map +1 -0
  83. package/dist/logging/kernel-logging.js +199 -0
  84. package/dist/logging/kernel-logging.js.map +1 -0
  85. package/dist/logging/log-sink.d.ts +9 -0
  86. package/dist/logging/log-sink.d.ts.map +1 -0
  87. package/dist/logging/log-sink.js +8 -0
  88. package/dist/logging/log-sink.js.map +1 -0
  89. package/dist/logging/logging-host.d.ts +14 -0
  90. package/dist/logging/logging-host.d.ts.map +1 -0
  91. package/dist/logging/logging-host.js +27 -0
  92. package/dist/logging/logging-host.js.map +1 -0
  93. package/dist/logging/logging-pipeline.d.ts +89 -0
  94. package/dist/logging/logging-pipeline.d.ts.map +1 -0
  95. package/dist/logging/logging-pipeline.js +349 -0
  96. package/dist/logging/logging-pipeline.js.map +1 -0
  97. package/dist/logging/normalize-attributes.d.ts +48 -0
  98. package/dist/logging/normalize-attributes.d.ts.map +1 -0
  99. package/dist/logging/normalize-attributes.js +140 -0
  100. package/dist/logging/normalize-attributes.js.map +1 -0
  101. package/dist/logging/record-buffer.d.ts +24 -0
  102. package/dist/logging/record-buffer.d.ts.map +1 -0
  103. package/dist/logging/record-buffer.js +78 -0
  104. package/dist/logging/record-buffer.js.map +1 -0
  105. package/dist/logging/redact-attributes.d.ts +45 -0
  106. package/dist/logging/redact-attributes.d.ts.map +1 -0
  107. package/dist/logging/redact-attributes.js +152 -0
  108. package/dist/logging/redact-attributes.js.map +1 -0
  109. package/dist/logging/sampler.d.ts +34 -0
  110. package/dist/logging/sampler.d.ts.map +1 -0
  111. package/dist/logging/sampler.js +52 -0
  112. package/dist/logging/sampler.js.map +1 -0
  113. package/dist/logging/scope-config.d.ts +32 -0
  114. package/dist/logging/scope-config.d.ts.map +1 -0
  115. package/dist/logging/scope-config.js +2 -0
  116. package/dist/logging/scope-config.js.map +1 -0
  117. package/dist/logging/span-id.d.ts +24 -0
  118. package/dist/logging/span-id.d.ts.map +1 -0
  119. package/dist/logging/span-id.js +85 -0
  120. package/dist/logging/span-id.js.map +1 -0
  121. package/dist/logging/to-error-value.d.ts +3 -0
  122. package/dist/logging/to-error-value.d.ts.map +1 -0
  123. package/dist/logging/to-error-value.js +79 -0
  124. package/dist/logging/to-error-value.js.map +1 -0
  125. package/dist/logging/trace-parent.d.ts +54 -0
  126. package/dist/logging/trace-parent.d.ts.map +1 -0
  127. package/dist/logging/trace-parent.js +92 -0
  128. package/dist/logging/trace-parent.js.map +1 -0
  129. package/dist/manifest-schemas.d.ts +1 -1
  130. package/dist/manifest-schemas.d.ts.map +1 -1
  131. package/dist/manifest-schemas.js +12 -0
  132. package/dist/manifest-schemas.js.map +1 -1
  133. package/dist/module-context.d.ts +16 -0
  134. package/dist/module-context.d.ts.map +1 -1
  135. package/dist/module-context.js +6 -0
  136. package/dist/module-context.js.map +1 -1
  137. package/dist/resource-context.d.ts +15 -1
  138. package/dist/resource-context.d.ts.map +1 -1
  139. package/dist/resource-context.js +45 -0
  140. package/dist/resource-context.js.map +1 -1
  141. package/dist/schema-compiled-values.d.ts.map +1 -1
  142. package/dist/schema-compiled-values.js +7 -0
  143. package/dist/schema-compiled-values.js.map +1 -1
  144. package/dist/schema-validator.d.ts +8 -1
  145. package/dist/schema-validator.d.ts.map +1 -1
  146. package/dist/schema-validator.js +13 -3
  147. package/dist/schema-validator.js.map +1 -1
  148. package/dist/transports/oci/oci-client.d.ts.map +1 -1
  149. package/dist/transports/oci/oci-client.js +13 -3
  150. package/dist/transports/oci/oci-client.js.map +1 -1
  151. package/dist/transports/registry-transport.d.ts.map +1 -1
  152. package/dist/transports/registry-transport.js +15 -5
  153. package/dist/transports/registry-transport.js.map +1 -1
  154. package/package.json +3 -3
  155. package/src/controller-loader.ts +7 -1
  156. package/src/controller-loaders/bundle-builder.ts +18 -10
  157. package/src/controller-loaders/npm-loader.ts +32 -11
  158. package/src/controllers/logging/console-sink-controller.ts +46 -0
  159. package/src/controllers/logging/file-sink-controller.ts +40 -0
  160. package/src/controllers/logging/sink-identity.ts +53 -0
  161. package/src/controllers/module/import-controller.ts +27 -0
  162. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  163. package/src/evaluation-context.ts +93 -15
  164. package/src/index.ts +54 -0
  165. package/src/internal-context.ts +4 -0
  166. package/src/kernel.ts +98 -8
  167. package/src/logging/bootstrap-writer.ts +38 -0
  168. package/src/logging/color-precedence.ts +62 -0
  169. package/src/logging/console-sink.ts +86 -0
  170. package/src/logging/debug-wire-sink.ts +85 -0
  171. package/src/logging/drop-accounting.ts +108 -0
  172. package/src/logging/encode-json.ts +98 -0
  173. package/src/logging/encode-pretty.ts +117 -0
  174. package/src/logging/file-sink.ts +87 -0
  175. package/src/logging/index.ts +65 -0
  176. package/src/logging/kernel-logging.ts +266 -0
  177. package/src/logging/log-sink.ts +18 -0
  178. package/src/logging/logging-host.ts +33 -0
  179. package/src/logging/logging-pipeline.ts +465 -0
  180. package/src/logging/normalize-attributes.ts +228 -0
  181. package/src/logging/record-buffer.ts +72 -0
  182. package/src/logging/redact-attributes.ts +168 -0
  183. package/src/logging/sampler.ts +76 -0
  184. package/src/logging/scope-config.ts +32 -0
  185. package/src/logging/span-id.ts +84 -0
  186. package/src/logging/to-error-value.ts +89 -0
  187. package/src/logging/trace-parent.ts +110 -0
  188. package/src/manifest-schemas.ts +12 -0
  189. package/src/module-context.ts +23 -0
  190. package/src/resource-context.ts +48 -0
  191. package/src/schema-compiled-values.ts +6 -0
  192. package/src/schema-validator.ts +20 -5
  193. package/src/transports/oci/oci-client.ts +13 -3
  194. package/src/transports/registry-transport.ts +23 -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
+ }