@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.
Files changed (186) 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/package.json +3 -3
  149. package/src/controller-loader.ts +7 -1
  150. package/src/controller-loaders/bundle-builder.ts +18 -10
  151. package/src/controller-loaders/npm-loader.ts +32 -11
  152. package/src/controllers/logging/console-sink-controller.ts +46 -0
  153. package/src/controllers/logging/file-sink-controller.ts +40 -0
  154. package/src/controllers/logging/sink-identity.ts +53 -0
  155. package/src/controllers/module/import-controller.ts +27 -0
  156. package/src/controllers/resource-definition/resource-definition-controller.ts +1 -0
  157. package/src/evaluation-context.ts +93 -15
  158. package/src/index.ts +54 -0
  159. package/src/internal-context.ts +4 -0
  160. package/src/kernel.ts +98 -8
  161. package/src/logging/bootstrap-writer.ts +38 -0
  162. package/src/logging/color-precedence.ts +62 -0
  163. package/src/logging/console-sink.ts +86 -0
  164. package/src/logging/debug-wire-sink.ts +85 -0
  165. package/src/logging/drop-accounting.ts +108 -0
  166. package/src/logging/encode-json.ts +98 -0
  167. package/src/logging/encode-pretty.ts +117 -0
  168. package/src/logging/file-sink.ts +87 -0
  169. package/src/logging/index.ts +65 -0
  170. package/src/logging/kernel-logging.ts +266 -0
  171. package/src/logging/log-sink.ts +18 -0
  172. package/src/logging/logging-host.ts +33 -0
  173. package/src/logging/logging-pipeline.ts +465 -0
  174. package/src/logging/normalize-attributes.ts +228 -0
  175. package/src/logging/record-buffer.ts +72 -0
  176. package/src/logging/redact-attributes.ts +168 -0
  177. package/src/logging/sampler.ts +76 -0
  178. package/src/logging/scope-config.ts +32 -0
  179. package/src/logging/span-id.ts +84 -0
  180. package/src/logging/to-error-value.ts +89 -0
  181. package/src/logging/trace-parent.ts +110 -0
  182. package/src/manifest-schemas.ts +12 -0
  183. package/src/module-context.ts +23 -0
  184. package/src/resource-context.ts +48 -0
  185. package/src/schema-compiled-values.ts +6 -0
  186. package/src/schema-validator.ts +20 -5
@@ -0,0 +1,72 @@
1
+ import type { LogRecord } from "@telorun/sdk";
2
+ import type { SinkBufferPolicy } from "./log-sink.js";
3
+
4
+ /**
5
+ * The bounded buffer every asynchronous sink composes — `kernel/specs/logging.md`
6
+ * §10.3.
7
+ *
8
+ * The ecosystems disagree by default: Rust's `tracing-appender` drops, zap
9
+ * buffers 256 kB / 30 s, and pino has no bound at all. Telo therefore makes the
10
+ * policy explicit and required, and the buffer is never unbounded.
11
+ *
12
+ * `drop_old` has no precedent among the surveyed libraries, so it is implemented
13
+ * as specified — true ring-buffer semantics, evicting the oldest record — rather
14
+ * than by analogy to something else.
15
+ */
16
+ export class RecordBuffer {
17
+ readonly #capacity: number;
18
+ readonly #policy: SinkBufferPolicy;
19
+ readonly #onDrop: () => void;
20
+ #items: LogRecord[] = [];
21
+ /** Index of the oldest record, so `drop_old` evicts in O(1) instead of
22
+ * shifting the whole array on every overflow. */
23
+ #head = 0;
24
+
25
+ constructor(policy: SinkBufferPolicy, onDrop: () => void) {
26
+ this.#capacity = Math.max(1, policy.buffer);
27
+ this.#policy = policy;
28
+ this.#onDrop = onDrop;
29
+ }
30
+
31
+ get size(): number {
32
+ return this.#items.length - this.#head;
33
+ }
34
+
35
+ get isFull(): boolean {
36
+ return this.size >= this.#capacity;
37
+ }
38
+
39
+ push(record: LogRecord): void {
40
+ if (this.isFull) {
41
+ if (this.#policy.onFull === "drop_old") {
42
+ this.#head += 1;
43
+ this.#compact();
44
+ } else {
45
+ // `drop_new` — and `block`, which never reaches here because a runtime
46
+ // that cannot honour it rejects the manifest at load rather than
47
+ // silently degrading to a dropping policy.
48
+ this.#onDrop();
49
+ return;
50
+ }
51
+ this.#onDrop();
52
+ }
53
+ this.#items.push(record);
54
+ }
55
+
56
+ /** Take everything buffered, leaving the buffer empty. */
57
+ drain(): LogRecord[] {
58
+ const drained = this.#head === 0 ? this.#items : this.#items.slice(this.#head);
59
+ this.#items = [];
60
+ this.#head = 0;
61
+ return drained;
62
+ }
63
+
64
+ #compact(): void {
65
+ // Reclaim the consumed prefix once it dominates the array, so a long-lived
66
+ // ring does not grow its backing store without bound.
67
+ if (this.#head > 32 && this.#head * 2 >= this.#items.length) {
68
+ this.#items = this.#items.slice(this.#head);
69
+ this.#head = 0;
70
+ }
71
+ }
72
+ }
@@ -0,0 +1,168 @@
1
+ import { parseRedactionPath, type RedactionSegment } from "@telorun/analyzer";
2
+ import type { AnyValue, ErrorValue, LogAttributes } from "@telorun/sdk";
3
+
4
+ /**
5
+ * Path-based redaction — `kernel/specs/logging.md` §14.
6
+ *
7
+ * Only path-based redaction is portable across Node, Rust, and Go, so it is the
8
+ * only form specified normatively. Paths are compiled once at configuration
9
+ * time by the analyzer's hand-written parser (never by compiling source in the
10
+ * host language, §14.1) and applied here by *navigating* to each path rather
11
+ * than walking the whole value tree — which is what keeps an explicit path at
12
+ * §14.2's 1–2% cost instead of the 25–55% an intermediate wildcard pays.
13
+ *
14
+ * The key is always preserved and only the value replaced. Deletion destroys
15
+ * schema stability and hides that a field was present at all, so `remove` is
16
+ * offered for the cases that genuinely need it but is never the default.
17
+ */
18
+
19
+ export const DEFAULT_CENSOR = "[redacted]";
20
+
21
+ export interface RedactionPolicy {
22
+ paths: readonly CompiledRedactionPath[];
23
+ censor: string;
24
+ remove: boolean;
25
+ }
26
+
27
+ export interface CompiledRedactionPath {
28
+ /** The path as written, kept for diagnostics. */
29
+ source: string;
30
+ segments: readonly RedactionSegment[];
31
+ }
32
+
33
+ /** Compile a policy's paths once, at configuration time. Throws
34
+ * `RedactionPathError` with the offending offset, so a bad path fails the
35
+ * manifest rather than silently failing to redact at runtime. */
36
+ export function compileRedactionPolicy(config: {
37
+ paths?: readonly string[];
38
+ censor?: string;
39
+ remove?: boolean;
40
+ }): RedactionPolicy {
41
+ return {
42
+ paths: (config.paths ?? []).map((source) => ({
43
+ source,
44
+ segments: parseRedactionPath(source),
45
+ })),
46
+ censor: config.censor ?? DEFAULT_CENSOR,
47
+ remove: config.remove ?? false,
48
+ };
49
+ }
50
+
51
+ export const EMPTY_REDACTION_POLICY: RedactionPolicy = {
52
+ paths: [],
53
+ censor: DEFAULT_CENSOR,
54
+ remove: false,
55
+ };
56
+
57
+ /** Apply the policy to a record's attributes in place. The attributes have
58
+ * already been normalized (§6.3), so collections are bounded and no cycle can
59
+ * make the navigation diverge. */
60
+ export function redactAttributes(
61
+ attributes: LogAttributes | undefined,
62
+ policy: RedactionPolicy,
63
+ ): void {
64
+ if (!attributes || policy.paths.length === 0) return;
65
+ for (const path of policy.paths) applyPath(attributes, path.segments, policy);
66
+ }
67
+
68
+ /** Redaction applies to the error as well as the attributes (§14). The error's
69
+ * own fields are structural rather than user data, so a path addresses them by
70
+ * their record spelling — `error.message`, `error.cause.message`. */
71
+ export function redactError(error: ErrorValue | undefined, policy: RedactionPolicy): void {
72
+ if (!error || policy.paths.length === 0) return;
73
+ const wrapper: LogAttributes = { error: error as unknown as AnyValue };
74
+ for (const path of policy.paths) applyPath(wrapper, path.segments, policy);
75
+ }
76
+
77
+ function applyPath(
78
+ root: LogAttributes,
79
+ segments: readonly RedactionSegment[],
80
+ policy: RedactionPolicy,
81
+ ): void {
82
+ if (segments.length === 0) return;
83
+
84
+ let frontier: unknown[] = [root];
85
+
86
+ for (let i = 0; i < segments.length - 1; i += 1) {
87
+ const segment = segments[i]!;
88
+ const next: unknown[] = [];
89
+ for (const container of frontier) {
90
+ if (segment.kind === "wildcard") {
91
+ for (const child of childrenOf(container)) next.push(child);
92
+ } else {
93
+ const child = readKey(container, segment.name);
94
+ if (child !== undefined) next.push(child);
95
+ }
96
+ }
97
+ if (next.length === 0) return;
98
+ frontier = next;
99
+ }
100
+
101
+ const last = segments[segments.length - 1]!;
102
+ for (const container of frontier) {
103
+ if (last.kind === "wildcard") {
104
+ // Descending so that `remove: true` over an array — which splices each
105
+ // matched index — does not shift the indices still to be visited. Without
106
+ // this, `items[*]` with remove leaves every other element behind, which
107
+ // for the §14 security control means a secret is only partially removed.
108
+ // Order is irrelevant for the censor path.
109
+ const keys = keysOf(container);
110
+ for (let i = keys.length - 1; i >= 0; i -= 1) writeKey(container, keys[i]!, policy);
111
+ } else {
112
+ if (readKey(container, last.name) === undefined && !hasKey(container, last.name)) continue;
113
+ writeKey(container, last.name, policy);
114
+ }
115
+ }
116
+ }
117
+
118
+ function childrenOf(container: unknown): unknown[] {
119
+ if (Array.isArray(container)) return container;
120
+ if (isPlainContainer(container)) return Object.values(container);
121
+ return [];
122
+ }
123
+
124
+ function keysOf(container: unknown): (string | number)[] {
125
+ if (Array.isArray(container)) return container.map((_item, index) => index);
126
+ if (isPlainContainer(container)) return Object.keys(container);
127
+ return [];
128
+ }
129
+
130
+ function readKey(container: unknown, key: string): unknown {
131
+ if (Array.isArray(container)) {
132
+ const index = Number(key);
133
+ return Number.isInteger(index) ? container[index] : undefined;
134
+ }
135
+ if (isPlainContainer(container)) return container[key];
136
+ return undefined;
137
+ }
138
+
139
+ function hasKey(container: unknown, key: string): boolean {
140
+ if (Array.isArray(container)) {
141
+ const index = Number(key);
142
+ return Number.isInteger(index) && index >= 0 && index < container.length;
143
+ }
144
+ return isPlainContainer(container) && hasOwn(container, key);
145
+ }
146
+
147
+ function hasOwn(container: Record<string, unknown>, key: string): boolean {
148
+ return Object.prototype.hasOwnProperty.call(container, key);
149
+ }
150
+
151
+ function writeKey(container: unknown, key: string | number, policy: RedactionPolicy): void {
152
+ if (Array.isArray(container)) {
153
+ const index = typeof key === "number" ? key : Number(key);
154
+ if (!Number.isInteger(index)) return;
155
+ if (policy.remove) container.splice(index, 1);
156
+ else container[index] = policy.censor;
157
+ return;
158
+ }
159
+ if (!isPlainContainer(container)) return;
160
+ const name = String(key);
161
+ if (!hasOwn(container, name)) return;
162
+ if (policy.remove) delete container[name];
163
+ else container[name] = policy.censor;
164
+ }
165
+
166
+ function isPlainContainer(value: unknown): value is Record<string, unknown> {
167
+ return typeof value === "object" && value !== null && !(value instanceof Uint8Array);
168
+ }
@@ -0,0 +1,76 @@
1
+ import { isErrorSeverity, type LogRecord } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Sampling — `kernel/specs/logging.md` §15. Off by default.
5
+ *
6
+ * The dedup key is (`severity_number`, `message`) rather than the attributes,
7
+ * which is what keeps it cheap: a repeated message throttles regardless of the
8
+ * per-occurrence data hanging off it.
9
+ *
10
+ * §15 permits a fixed-size counter table that accepts collisions, trading
11
+ * precision for speed. This implementation instead keeps an
12
+ * **insertion-ordered map bounded by an entry cap**, evicting the least recently
13
+ * created key on overflow. Memory stays bounded either way, but a collision can
14
+ * only ever *lose* a record that should have been emitted, and no throughput
15
+ * gain justified that.
16
+ */
17
+
18
+ export interface SamplingConfig {
19
+ /** Records emitted unconditionally at the start of each window. */
20
+ first: number;
21
+ /** Thereafter every Nth record is emitted. `0` drops everything after the
22
+ * first `first` in the window. */
23
+ thereafter: number;
24
+ /** Window length in milliseconds. */
25
+ tickMs: number;
26
+ /** Records at ERROR and above are not sampled by default (§15). */
27
+ sampleErrors?: boolean;
28
+ }
29
+
30
+ const MAX_TRACKED_KEYS = 4096;
31
+
32
+ interface Window {
33
+ start: number;
34
+ count: number;
35
+ }
36
+
37
+ export class Sampler {
38
+ readonly #config: SamplingConfig;
39
+ readonly #windows = new Map<string, Window>();
40
+
41
+ constructor(config: SamplingConfig) {
42
+ this.#config = config;
43
+ }
44
+
45
+ /** `true` when the record should be emitted, `false` when it is sampled out.
46
+ * A `false` result is counted under cause `sampled` by the caller. */
47
+ shouldEmit(record: LogRecord, now: number): boolean {
48
+ if (!this.#config.sampleErrors && isErrorSeverity(record.severityNumber)) return true;
49
+
50
+ const key = `${record.severityNumber}\x00${record.message}`;
51
+ let window = this.#windows.get(key);
52
+
53
+ if (!window || now - window.start >= this.#config.tickMs) {
54
+ window = { start: now, count: 0 };
55
+ this.#windows.delete(key);
56
+ this.#windows.set(key, window);
57
+ this.#evictOverflow();
58
+ }
59
+
60
+ window.count += 1;
61
+
62
+ if (window.count <= this.#config.first) return true;
63
+ if (this.#config.thereafter <= 0) return false;
64
+
65
+ const sinceFirst = window.count - this.#config.first;
66
+ return sinceFirst % this.#config.thereafter === 0;
67
+ }
68
+
69
+ #evictOverflow(): void {
70
+ while (this.#windows.size > MAX_TRACKED_KEYS) {
71
+ const oldest = this.#windows.keys().next();
72
+ if (oldest.done) return;
73
+ this.#windows.delete(oldest.value);
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,32 @@
1
+ import type { LogAttributes } from "@telorun/sdk";
2
+ import type { RedactionPolicy } from "./redact-attributes.js";
3
+ import type { SamplingConfig } from "./sampler.js";
4
+
5
+ /**
6
+ * Per-module-context logging configuration — `kernel/specs/logging.md` §12.2.
7
+ *
8
+ * Resolved once when the import graph is built and held as plain values: there
9
+ * is no per-record lookup and no walk up the import chain at emit time.
10
+ *
11
+ * This is a **leaf type module** on purpose. `ScopeConfig` is stamped onto the
12
+ * generic `ModuleContext` and read by `internal-context` / `resource-context`,
13
+ * so having those import it from the 400-line `logging-pipeline` would point
14
+ * generic core plumbing at a specific subsystem's implementation. Keeping the
15
+ * type here — depending only on the two policy leaves and the SDK — keeps that
16
+ * dependency pointing at a type, not at the pipeline.
17
+ */
18
+ export interface ScopeConfig {
19
+ /** The resolved level for this module context. Also the default a sink's own
20
+ * `level` falls back to (§12.1). */
21
+ threshold: number;
22
+ redaction: RedactionPolicy;
23
+ sampling?: SamplingConfig;
24
+ /** Values bound to the manifest's `secrets:`, redacted with no configuration
25
+ * (§14). Cascades down the import graph exactly as the threshold does. */
26
+ secretValues?: ReadonlySet<string>;
27
+ /** Dotted import-alias path of the emitting module context. Absent at root. */
28
+ scope?: string;
29
+ module?: string;
30
+ /** Resource-level attributes merged into every record from this scope. */
31
+ attributes?: LogAttributes;
32
+ }
@@ -0,0 +1,84 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Span and trace identifier formatting — `kernel/specs/logging.md` §7.1 (D7).
5
+ *
6
+ * The internal representation is unconstrained; only the emitted form is
7
+ * normative. Telo keeps span ids as a native counter — minting is an increment,
8
+ * comparison and map-keying stay cheap, nothing allocates — and renders the
9
+ * 16-character hex form **only at the encoding boundary**, on records actually
10
+ * being emitted to a sink that needs it. Ids are never formatted eagerly at span
11
+ * creation.
12
+ *
13
+ * Zero-padding is enforced here rather than left to a caller because it is a
14
+ * live bug class, not a formality: rendering a fixed-width byte array through a
15
+ * general integer formatter silently produces a short, spec-invalid id whenever
16
+ * the value has leading zero bytes.
17
+ */
18
+
19
+ const SPAN_ID_HEX_LENGTH = 16;
20
+ const TRACE_ID_HEX_LENGTH = 32;
21
+ const U64_MASK = (1n << 64n) - 1n;
22
+
23
+ /**
24
+ * An 8-byte per-process salt, minted once at startup. A bare counter starting at
25
+ * 1 collides across processes participating in one distributed trace — two
26
+ * services would both mint span id `1`. XORing costs a single operation,
27
+ * preserves the cheap counter internally, and keeps ids unique within a trace.
28
+ * Span ids carry no randomness requirement of their own (unlike trace ids under
29
+ * W3C Level 2), so a salted counter is sufficient.
30
+ */
31
+ const SPAN_ID_SALT = BigInt(`0x${randomBytes(8).toString("hex")}`);
32
+
33
+ /** Apply the process salt to a raw counter. Bijective, so uniqueness within the
34
+ * process is preserved exactly. */
35
+ export function saltSpanId(counter: number | bigint): bigint {
36
+ return (BigInt(counter) ^ SPAN_ID_SALT) & U64_MASK;
37
+ }
38
+
39
+ /**
40
+ * Render a span id as exactly 16 lowercase hex characters. An all-zero id is
41
+ * invalid and is treated as absent rather than emitted, so this returns
42
+ * `undefined` for zero.
43
+ */
44
+ export function formatSpanId(value: bigint | number): string | undefined {
45
+ const masked = BigInt(value) & U64_MASK;
46
+ if (masked === 0n) return undefined;
47
+ return masked.toString(16).padStart(SPAN_ID_HEX_LENGTH, "0");
48
+ }
49
+
50
+ /** Format the salted form of a raw counter — the two steps a record emission
51
+ * performs together. */
52
+ export function formatSpanCounter(counter: number | bigint): string | undefined {
53
+ return formatSpanId(saltSpanId(counter));
54
+ }
55
+
56
+ /**
57
+ * Normalize a trace id for emission: exactly 32 lowercase hex characters. Ids
58
+ * are accepted in either case on ingest and always emitted lowercase. An
59
+ * all-zero or malformed id is treated as absent.
60
+ */
61
+ export function normalizeTraceId(value: string | undefined): string | undefined {
62
+ if (!value) return undefined;
63
+ const lowered = value.toLowerCase();
64
+ if (lowered.length !== TRACE_ID_HEX_LENGTH) return undefined;
65
+ if (!/^[0-9a-f]+$/.test(lowered)) return undefined;
66
+ if (/^0+$/.test(lowered)) return undefined;
67
+ return lowered;
68
+ }
69
+
70
+ /** The `span_id` counterpart of {@link normalizeTraceId}, for ids arriving as
71
+ * hex from an upstream rather than as a local counter. */
72
+ export function normalizeSpanId(value: string | undefined): string | undefined {
73
+ if (!value) return undefined;
74
+ const lowered = value.toLowerCase();
75
+ if (lowered.length !== SPAN_ID_HEX_LENGTH) return undefined;
76
+ if (!/^[0-9a-f]+$/.test(lowered)) return undefined;
77
+ if (/^0+$/.test(lowered)) return undefined;
78
+ return lowered;
79
+ }
80
+
81
+ /** A fresh W3C-compatible 16-byte trace id. */
82
+ export function newTraceId(): string {
83
+ return randomBytes(16).toString("hex");
84
+ }
@@ -0,0 +1,89 @@
1
+ import type { ErrorValue } from "@telorun/sdk";
2
+
3
+ /**
4
+ * Normalize any thrown value into the record's structured error —
5
+ * `kernel/specs/logging.md` §4.2.
6
+ *
7
+ * The `cause` chain is bounded per §6.3 and the truncation is *recorded* rather
8
+ * than silently applied, so a reader can tell a chain was cut from one that
9
+ * simply ended.
10
+ */
11
+
12
+ const MAX_CAUSE_DEPTH = 10;
13
+
14
+ export function toErrorValue(thrown: unknown, maxDepth = MAX_CAUSE_DEPTH): ErrorValue {
15
+ const seen = new Set<unknown>();
16
+ let depth = 0;
17
+ let current: unknown = thrown;
18
+
19
+ const root = shallowErrorValue(current);
20
+ let tail = root;
21
+ seen.add(current);
22
+
23
+ while (depth < maxDepth) {
24
+ const cause = causeOf(current);
25
+ if (cause === undefined) return root;
26
+ if (seen.has(cause)) {
27
+ tail.cause = { type: "ERR_CAUSE_CYCLE", message: "cause chain refers to itself" };
28
+ return root;
29
+ }
30
+ seen.add(cause);
31
+ const next = shallowErrorValue(cause);
32
+ tail.cause = next;
33
+ tail = next;
34
+ current = cause;
35
+ depth += 1;
36
+ }
37
+
38
+ if (causeOf(current) !== undefined) {
39
+ tail.cause = {
40
+ type: "ERR_CAUSE_CHAIN_TRUNCATED",
41
+ message: `cause chain truncated at ${maxDepth} entries`,
42
+ };
43
+ }
44
+ return root;
45
+ }
46
+
47
+ function shallowErrorValue(thrown: unknown): ErrorValue {
48
+ if (thrown instanceof Error) {
49
+ const code = (thrown as { code?: unknown }).code;
50
+ return {
51
+ type: typeof code === "string" && code.length > 0 ? code : thrown.name,
52
+ message: thrown.message,
53
+ ...(thrown.stack ? { stack: thrown.stack } : {}),
54
+ };
55
+ }
56
+
57
+ if (typeof thrown === "object" && thrown !== null) {
58
+ const record = thrown as { code?: unknown; name?: unknown; message?: unknown; stack?: unknown };
59
+ const type =
60
+ typeof record.code === "string"
61
+ ? record.code
62
+ : typeof record.name === "string"
63
+ ? record.name
64
+ : "Error";
65
+ const message =
66
+ typeof record.message === "string" ? record.message : safeStringify(thrown);
67
+ return {
68
+ type,
69
+ message,
70
+ ...(typeof record.stack === "string" ? { stack: record.stack } : {}),
71
+ };
72
+ }
73
+
74
+ return { type: typeof thrown === "string" ? "Error" : typeof thrown, message: String(thrown) };
75
+ }
76
+
77
+ function causeOf(value: unknown): unknown {
78
+ if (typeof value !== "object" || value === null) return undefined;
79
+ const cause = (value as { cause?: unknown }).cause;
80
+ return cause === undefined || cause === null ? undefined : cause;
81
+ }
82
+
83
+ function safeStringify(value: unknown): string {
84
+ try {
85
+ return JSON.stringify(value) ?? String(value);
86
+ } catch {
87
+ return String(value);
88
+ }
89
+ }
@@ -0,0 +1,110 @@
1
+ import { normalizeSpanId, normalizeTraceId } from "./span-id.js";
2
+
3
+ /**
4
+ * W3C Trace Context propagation — `kernel/specs/logging.md` §7.4 and §7.5.
5
+ *
6
+ * traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
7
+ * ^version ^trace-id (32 hex) ^parent-id (16) ^flags
8
+ *
9
+ * Partial adoption of an invalid header is forbidden: a `traceparent` whose
10
+ * trace-id or parent-id is all zeros is ignored *in full* and a new trace is
11
+ * started, rather than salvaging whichever half parsed.
12
+ */
13
+
14
+ /** Bit 0 — the sampled flag. */
15
+ export const TRACE_FLAG_SAMPLED = 0x01;
16
+
17
+ /**
18
+ * Bit 1 — W3C Trace Context **Level 2**'s random trace-id flag, indicating the
19
+ * right-most 7 bytes of the trace id were chosen randomly, which enables
20
+ * downstream consistent sampling.
21
+ *
22
+ * The bit is *reserved* rather than required to be zero. Level 2 is a Candidate
23
+ * Recommendation, so a runtime preserves the bit when forwarding an inbound
24
+ * header but never sets it itself. Requiring it to be zero — the Level 1
25
+ * reading — would make a conforming runtime corrupt Level 2 traces it merely
26
+ * forwards.
27
+ */
28
+ export const TRACE_FLAG_RANDOM = 0x02;
29
+
30
+ /** Bits 0–1 survive an outgoing request; bits 2–7 are zeroed (§7.5). */
31
+ const OUTGOING_FLAG_MASK = TRACE_FLAG_SAMPLED | TRACE_FLAG_RANDOM;
32
+
33
+ const TRACESTATE_MAX_MEMBERS = 32;
34
+ const TRACESTATE_MAX_LENGTH = 512;
35
+
36
+ export interface TraceContext {
37
+ traceId: string;
38
+ parentSpanId: string;
39
+ traceFlags: number;
40
+ /** Propagated unmodified, or absent when it could not be parsed. Never
41
+ * partially rewritten. */
42
+ traceState?: string;
43
+ }
44
+
45
+ /**
46
+ * Parse an inbound `traceparent`. Returns `undefined` when the header is absent,
47
+ * malformed, or carries an all-zero id — in every one of those cases the caller
48
+ * starts a fresh trace rather than adopting part of it.
49
+ */
50
+ export function parseTraceParent(
51
+ traceparent: string | undefined,
52
+ tracestate?: string | undefined,
53
+ ): TraceContext | undefined {
54
+ if (!traceparent) return undefined;
55
+
56
+ const parts = traceparent.trim().split("-");
57
+ if (parts.length < 4) return undefined;
58
+
59
+ const [version, rawTraceId, rawParentId, rawFlags] = parts as [string, string, string, string];
60
+
61
+ // Version `ff` is invalid; unknown future versions are parsed leniently, which
62
+ // the spec permits so long as the four known fields validate.
63
+ if (!/^[0-9a-f]{2}$/.test(version) || version === "ff") return undefined;
64
+
65
+ const traceId = normalizeTraceId(rawTraceId);
66
+ const parentSpanId = normalizeSpanId(rawParentId);
67
+ if (!traceId || !parentSpanId) return undefined;
68
+
69
+ if (!/^[0-9a-f]{2}$/.test(rawFlags)) return undefined;
70
+ const traceFlags = Number.parseInt(rawFlags, 16);
71
+
72
+ return {
73
+ traceId,
74
+ parentSpanId,
75
+ traceFlags,
76
+ traceState: parseTraceState(tracestate),
77
+ };
78
+ }
79
+
80
+ /**
81
+ * `tracestate` is propagated unmodified. One that cannot be parsed is discarded
82
+ * in full rather than partially rewritten, so a downstream never receives a
83
+ * mangled header that looks valid.
84
+ */
85
+ export function parseTraceState(tracestate: string | undefined): string | undefined {
86
+ if (!tracestate) return undefined;
87
+ const trimmed = tracestate.trim();
88
+ if (trimmed.length === 0) return undefined;
89
+ if (trimmed.length > TRACESTATE_MAX_LENGTH) return undefined;
90
+ const members = trimmed.split(",");
91
+ if (members.length > TRACESTATE_MAX_MEMBERS) return undefined;
92
+ for (const member of members) {
93
+ if (member.trim().length === 0) continue;
94
+ if (!member.includes("=")) return undefined;
95
+ }
96
+ return trimmed;
97
+ }
98
+
99
+ /**
100
+ * Serialize an outgoing `traceparent`. Bits 2–7 of the flags are zeroed; bit 1
101
+ * survives only because it arrived that way on an inbound header.
102
+ */
103
+ export function formatTraceParent(context: {
104
+ traceId: string;
105
+ spanId: string;
106
+ traceFlags?: number;
107
+ }): string {
108
+ const flags = (context.traceFlags ?? 0) & OUTGOING_FLAG_MASK;
109
+ return `00-${context.traceId}-${context.spanId}-${flags.toString(16).padStart(2, "0")}`;
110
+ }
@@ -82,6 +82,11 @@ const KNOWN_CAPABILITIES = [
82
82
  "Telo.Provider",
83
83
  "Telo.Type",
84
84
  "Telo.Mount",
85
+ // A record-stream destination the runtime writes to directly rather than
86
+ // through `ctx.invoke` — per-record dispatch is far too slow for a logging hot
87
+ // path, and dispatch emits trace events, so routing logs through it would
88
+ // generate telemetry from inside the telemetry path. See kernel/specs/logging.md §10.
89
+ "Telo.Sink",
85
90
  ] as const;
86
91
 
87
92
  /** Rule 8: `throws:` is only meaningful on Telo.Invocable or Telo.Runnable.
@@ -115,6 +120,13 @@ export const ResourceDefinitionSchema = {
115
120
  properties: { capability: { const: "Telo.Mount" } },
116
121
  ...forbidThrows,
117
122
  },
123
+ {
124
+ // A sink is written to directly, never dispatched, so a thrown error is a
125
+ // boot-time failure rather than a structured runtime error for a caller.
126
+ required: ["capability"],
127
+ properties: { capability: { const: "Telo.Sink" } },
128
+ ...forbidThrows,
129
+ },
118
130
  // Unknown/absent capability: open schema for third-party extensibility
119
131
  {
120
132
  not: {