@flighthq/log 0.1.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/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/log.d.ts +91 -0
- package/dist/log.d.ts.map +1 -0
- package/dist/log.js +719 -0
- package/dist/log.js.map +1 -0
- package/package.json +38 -0
- package/src/log.test.ts +1131 -0
package/dist/log.js
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
import { createSignal, emitSignal } from '@flighthq/signals';
|
|
2
|
+
import { LogLevel } from '@flighthq/types';
|
|
3
|
+
// Logging is split into two faces of one contract so each consumer tree-shakes its half:
|
|
4
|
+
//
|
|
5
|
+
// Emit side — log and the log* wrappers. Featherweight: each checks the level gate and
|
|
6
|
+
// forwards an entry to the installed sinks. A build that never installs a sink
|
|
7
|
+
// carries only the forwarder and the LogLevel enum; the listener-side code
|
|
8
|
+
// tree-shakes away.
|
|
9
|
+
// Listener side — createConsoleCaptureSink, sink management (addLogSink / removeLogSink),
|
|
10
|
+
// formatters, and sink combinators. Imported by tools (the examples, the capture
|
|
11
|
+
// harness). This is where levels gate output: each sink records what it wants;
|
|
12
|
+
// the console-capture sink records EVERY level (the machine record is complete)
|
|
13
|
+
// and additionally prints a human console line for levels at or above the
|
|
14
|
+
// configured threshold.
|
|
15
|
+
// Listener side. Adds a sink to the fan-out list. No-op if already present.
|
|
16
|
+
export function addLogSink(sink) {
|
|
17
|
+
if (_sinks.includes(sink))
|
|
18
|
+
return;
|
|
19
|
+
_sinks.push(sink);
|
|
20
|
+
}
|
|
21
|
+
// Opens a named log group. All entries emitted while the group is open carry a `depth` field in
|
|
22
|
+
// their structured data and the text formatter will indent them. Call endLogGroup to close. Groups
|
|
23
|
+
// nest: each beginLogGroup increments the depth, endLogGroup decrements it.
|
|
24
|
+
export function beginLogGroup(label, channel = null) {
|
|
25
|
+
_groupDepth++;
|
|
26
|
+
if (!_passesLevelGate(LogLevel.Debug, channel))
|
|
27
|
+
return;
|
|
28
|
+
_emitToSinks({ level: LogLevel.Debug, channel, data: { msg: label, group: 'begin', depth: _groupDepth } });
|
|
29
|
+
}
|
|
30
|
+
// Listener side. Clears all per-channel level overrides.
|
|
31
|
+
export function clearLogChannelLevels() {
|
|
32
|
+
_channelLevels.clear();
|
|
33
|
+
}
|
|
34
|
+
// Resets the group nesting depth to zero without emitting any entries. Useful for test teardown or
|
|
35
|
+
// error recovery when normal beginLogGroup / endLogGroup pairing breaks down.
|
|
36
|
+
export function clearLogGroups() {
|
|
37
|
+
_groupDepth = 0;
|
|
38
|
+
}
|
|
39
|
+
// Clears all field redaction paths set by setLogRedactionPaths.
|
|
40
|
+
export function clearLogRedactionPaths() {
|
|
41
|
+
_redactionPaths.length = 0;
|
|
42
|
+
}
|
|
43
|
+
// Clears all custom serializer registrations set by registerLogSerializer.
|
|
44
|
+
export function clearLogSerializers() {
|
|
45
|
+
_serializers.clear();
|
|
46
|
+
}
|
|
47
|
+
// Listener side. Clears all installed sinks.
|
|
48
|
+
export function clearLogSinks() {
|
|
49
|
+
_sinks.length = 0;
|
|
50
|
+
}
|
|
51
|
+
// Clears all captured entries from a memory sink.
|
|
52
|
+
export function clearMemoryLogSink(handle) {
|
|
53
|
+
const state = _memorySinkStates.get(handle);
|
|
54
|
+
if (!state)
|
|
55
|
+
return;
|
|
56
|
+
state.buf.length = 0;
|
|
57
|
+
state.head = 0;
|
|
58
|
+
}
|
|
59
|
+
// Creates a sink that batches entries and forwards them to `target` in bulk. The buffer flushes
|
|
60
|
+
// when it reaches `size` entries (default 100) or at `intervalMs` milliseconds (default 1000, 0
|
|
61
|
+
// to disable the interval). Use flushLogSink to flush manually, disposeLogSink to cancel the
|
|
62
|
+
// interval timer and release the resource.
|
|
63
|
+
export function createBufferedLogSink(target, options = {}) {
|
|
64
|
+
const size = options.size ?? 100;
|
|
65
|
+
const intervalMs = options.intervalMs ?? 1000;
|
|
66
|
+
const flush = () => {
|
|
67
|
+
const state = _bufferedSinkStates.get(handle);
|
|
68
|
+
if (!state || state.buf.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
const batch = state.buf.splice(0);
|
|
71
|
+
for (const entry of batch)
|
|
72
|
+
target(entry);
|
|
73
|
+
};
|
|
74
|
+
const sink = (entry) => {
|
|
75
|
+
const state = _bufferedSinkStates.get(handle);
|
|
76
|
+
if (!state)
|
|
77
|
+
return;
|
|
78
|
+
state.buf.push({ level: entry.level, channel: entry.channel, data: entry.data });
|
|
79
|
+
if (state.buf.length >= size)
|
|
80
|
+
flush();
|
|
81
|
+
};
|
|
82
|
+
const handle = { sink };
|
|
83
|
+
let timer = null;
|
|
84
|
+
if (intervalMs > 0 && typeof setInterval !== 'undefined') {
|
|
85
|
+
timer = setInterval(flush, intervalMs);
|
|
86
|
+
}
|
|
87
|
+
_bufferedSinkStates.set(handle, { buf: [], timer, flush });
|
|
88
|
+
return handle;
|
|
89
|
+
}
|
|
90
|
+
// Creates a child context that inherits the parent's channel and fields. The child wins on key
|
|
91
|
+
// collision. An explicit channel overrides the parent's channel.
|
|
92
|
+
export function createChildLogContext(parent, fields, channel) {
|
|
93
|
+
const merged = { ...parent.fields, ...fields };
|
|
94
|
+
return { channel: channel !== undefined ? channel : parent.channel, fields: merged };
|
|
95
|
+
}
|
|
96
|
+
// Creates a sink that records every entry as a tagged JSON envelope on console.debug — low
|
|
97
|
+
// visual noise, but the Playwright capture script reads every console level, so it always
|
|
98
|
+
// lands in logs.jsonl. Entries at or above the console threshold (setLogConsoleLevel) are ALSO
|
|
99
|
+
// printed as a human-readable line via the matching console method. Install with addLogSink or
|
|
100
|
+
// setLogSink.
|
|
101
|
+
export function createConsoleCaptureSink(options = {}) {
|
|
102
|
+
const envelopeFormatter = options.formatter ?? _defaultJsonFormatter;
|
|
103
|
+
return (entry) => _writeConsoleCaptureEntry(entry, envelopeFormatter);
|
|
104
|
+
}
|
|
105
|
+
// Creates a fan-out sink that forwards every entry to all supplied sinks.
|
|
106
|
+
export function createFanoutLogSink(...sinks) {
|
|
107
|
+
const list = sinks.slice();
|
|
108
|
+
return (entry) => {
|
|
109
|
+
for (const s of list)
|
|
110
|
+
s(entry);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Creates a sink that writes formatted entries to a LogTransportBackend. The backend is resolved
|
|
114
|
+
// at emit time (not at creation time) so setLogTransportBackend can be called after
|
|
115
|
+
// createFileLogSink. The formatter defaults to createJsonLogFormatter (one JSON line per entry).
|
|
116
|
+
// Use disposeFileLogSink to flush and release the underlying backend resource.
|
|
117
|
+
export function createFileLogSink(options = {}) {
|
|
118
|
+
const formatter = options.formatter ?? createJsonLogFormatter();
|
|
119
|
+
const sink = (entry) => {
|
|
120
|
+
const backend = _transportBackend;
|
|
121
|
+
if (backend === null)
|
|
122
|
+
return;
|
|
123
|
+
backend.write(formatter(entry) + '\n');
|
|
124
|
+
};
|
|
125
|
+
const handle = { sink };
|
|
126
|
+
return handle;
|
|
127
|
+
}
|
|
128
|
+
// Creates a sink that forwards only entries matching a predicate. Compose with addLogSink to give
|
|
129
|
+
// each target its own independent filter (per-sink level, per-channel filter, etc.).
|
|
130
|
+
export function createFilterLogSink(target, predicate) {
|
|
131
|
+
return (entry) => {
|
|
132
|
+
if (predicate(entry))
|
|
133
|
+
target(entry);
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
// Creates a formatter that produces the `__flight` JSON envelope used by the capture harness.
|
|
137
|
+
// Field redaction (setLogRedactionPaths) and custom serializers (registerLogSerializer) are
|
|
138
|
+
// applied during formatting.
|
|
139
|
+
export function createJsonLogFormatter() {
|
|
140
|
+
return (entry) => {
|
|
141
|
+
const { level, channel, data } = entry;
|
|
142
|
+
const serialized = _applySerializers(typeof data === 'string' ? { msg: data } : data);
|
|
143
|
+
const redacted = _redactionPaths.length > 0 ? _applyRedaction(serialized) : serialized;
|
|
144
|
+
return JSON.stringify({
|
|
145
|
+
__flight: true,
|
|
146
|
+
t: _timestamp(),
|
|
147
|
+
level: _levelNames[level],
|
|
148
|
+
channel,
|
|
149
|
+
data: redacted,
|
|
150
|
+
});
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
// Creates a bound logging context with a channel and optional base fields. Pass the context to
|
|
154
|
+
// logWith / logErrorWith / etc. to have the channel and fields merged into every entry.
|
|
155
|
+
export function createLogContext(channel, fields = {}) {
|
|
156
|
+
return { channel, fields };
|
|
157
|
+
}
|
|
158
|
+
// Creates a named tracing span. The returned LogSpan is a plain value — it is not active until
|
|
159
|
+
// enterLogSpan is called. While active, the span's fields are merged into every emitted entry
|
|
160
|
+
// (span fields have lower priority than direct emit fields). enterLogSpan / exitLogSpan are
|
|
161
|
+
// stack-based: multiple spans can be active simultaneously; the most recently entered span wins
|
|
162
|
+
// on field key collision.
|
|
163
|
+
export function createLogSpan(name, fields = {}, channel = null) {
|
|
164
|
+
return { name, fields, channel };
|
|
165
|
+
}
|
|
166
|
+
// Creates a sink that captures the last `capacity` entries in a ring buffer. Use
|
|
167
|
+
// getMemoryLogSinkEntries to read (oldest-first) and clearMemoryLogSink to reset.
|
|
168
|
+
export function createMemoryLogSink(capacity) {
|
|
169
|
+
const state = { buf: [], head: 0 };
|
|
170
|
+
const sink = (entry) => {
|
|
171
|
+
const stored = { level: entry.level, channel: entry.channel, data: entry.data };
|
|
172
|
+
if (state.buf.length < capacity) {
|
|
173
|
+
state.buf.push(stored);
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
state.buf[state.head] = stored;
|
|
177
|
+
state.head = (state.head + 1) % capacity;
|
|
178
|
+
}
|
|
179
|
+
};
|
|
180
|
+
const handle = { sink };
|
|
181
|
+
_memorySinkStates.set(handle, state);
|
|
182
|
+
return handle;
|
|
183
|
+
}
|
|
184
|
+
// Creates a sink that forwards at most `maxPerInterval` entries per `intervalMs` window. When
|
|
185
|
+
// `perChannel` is true, the budget is tracked per channel independently.
|
|
186
|
+
export function createRateLimitedLogSink(target, options) {
|
|
187
|
+
const { perChannel = false, maxPerInterval, intervalMs } = options;
|
|
188
|
+
const counts = new Map();
|
|
189
|
+
let windowStart = _timestamp();
|
|
190
|
+
const sink = (entry) => {
|
|
191
|
+
const now = _timestamp();
|
|
192
|
+
if (now - windowStart >= intervalMs) {
|
|
193
|
+
counts.clear();
|
|
194
|
+
windowStart = now;
|
|
195
|
+
}
|
|
196
|
+
const key = perChannel ? entry.channel : null;
|
|
197
|
+
const current = counts.get(key) ?? 0;
|
|
198
|
+
if (current >= maxPerInterval)
|
|
199
|
+
return;
|
|
200
|
+
counts.set(key, current + 1);
|
|
201
|
+
target(entry);
|
|
202
|
+
};
|
|
203
|
+
return { sink };
|
|
204
|
+
}
|
|
205
|
+
// Creates a sink that forwards approximately 1-in-N entries (probability = 1 / rate when rate > 1).
|
|
206
|
+
export function createSampledLogSink(target, rate) {
|
|
207
|
+
if (rate <= 1)
|
|
208
|
+
return target;
|
|
209
|
+
let counter = 0;
|
|
210
|
+
return (entry) => {
|
|
211
|
+
counter = (counter + 1) % rate;
|
|
212
|
+
if (counter === 0)
|
|
213
|
+
target(entry);
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
// Creates a formatter that produces a human-readable `[channel] message` line. When
|
|
217
|
+
// `indentGroups` is true the formatter indents the message by the current group depth.
|
|
218
|
+
export function createTextLogFormatter(options = {}) {
|
|
219
|
+
return (entry) => {
|
|
220
|
+
const { level, channel, data } = entry;
|
|
221
|
+
const parts = [];
|
|
222
|
+
if (options.timestamp)
|
|
223
|
+
parts.push(`t=${_timestamp().toFixed(2)}`);
|
|
224
|
+
if (options.levelPrefix)
|
|
225
|
+
parts.push(_levelNames[level] ?? 'unknown');
|
|
226
|
+
parts.push(channel !== null ? `[${channel}]` : '[flight]');
|
|
227
|
+
if (options.indentGroups && _groupDepth > 0)
|
|
228
|
+
parts.push(' '.repeat(_groupDepth));
|
|
229
|
+
if (typeof data === 'string')
|
|
230
|
+
parts.push(data);
|
|
231
|
+
else
|
|
232
|
+
parts.push(JSON.stringify(data));
|
|
233
|
+
return parts.join(' ');
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
// Creates the web default LogTransportBackend — a no-op transport whose write/flush/dispose do
|
|
237
|
+
// nothing. The web has no destination a sink can write a formatted line to (no filesystem, and
|
|
238
|
+
// network requests from within a sink are outside the SDK's concern), so createFileLogSink entries
|
|
239
|
+
// silently drop until a host registers a real backend via setLogTransportBackend. Native/Node hosts
|
|
240
|
+
// register a backend that writes the lines to a file or stream; for remote shipping, compose
|
|
241
|
+
// createBufferedLogSink over such a backend rather than baking batching into the transport.
|
|
242
|
+
export function createWebLogTransportBackend() {
|
|
243
|
+
// Web default: no-op transport (the SDK does not own network requests from sinks).
|
|
244
|
+
return {
|
|
245
|
+
write(_line) {
|
|
246
|
+
// no-op on web — caller must register a real backend via setLogTransportBackend
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
// Flushes a file-log sink's transport backend immediately, then disposes it. After this call
|
|
251
|
+
// createFileLogSink entries will silently no-op until setLogTransportBackend is called again.
|
|
252
|
+
// (`dispose*` — releases the backend resource; no GPU/native handle.)
|
|
253
|
+
export function disposeFileLogSink(_handle) {
|
|
254
|
+
const backend = _transportBackend;
|
|
255
|
+
if (backend === null)
|
|
256
|
+
return;
|
|
257
|
+
if (backend.flush)
|
|
258
|
+
backend.flush();
|
|
259
|
+
if (backend.dispose)
|
|
260
|
+
backend.dispose();
|
|
261
|
+
}
|
|
262
|
+
// Disposes a buffered sink: cancels its interval timer and flushes remaining entries. The sink
|
|
263
|
+
// remains callable after disposal but will no longer auto-flush. (`dispose*` because this
|
|
264
|
+
// detaches the timer that keeps the sink reachable — no non-GC resource.)
|
|
265
|
+
export function disposeLogSink(handle) {
|
|
266
|
+
const state = _bufferedSinkStates.get(handle);
|
|
267
|
+
if (!state)
|
|
268
|
+
return;
|
|
269
|
+
if (state.timer !== null)
|
|
270
|
+
clearInterval(state.timer);
|
|
271
|
+
state.flush();
|
|
272
|
+
state.timer = null;
|
|
273
|
+
}
|
|
274
|
+
// Enables the log signals group. Returns the process-global LogSignals entity; calling multiple
|
|
275
|
+
// times returns the same object. Tree-shakes away unless this function is called. The signals
|
|
276
|
+
// are emitted synchronously in the fan-out path.
|
|
277
|
+
export function enableLogSignals() {
|
|
278
|
+
if (_logSignals !== null)
|
|
279
|
+
return _logSignals;
|
|
280
|
+
_logSignals = {
|
|
281
|
+
onLogEntry: createSignal(),
|
|
282
|
+
onLogError: createSignal(),
|
|
283
|
+
};
|
|
284
|
+
return _logSignals;
|
|
285
|
+
}
|
|
286
|
+
// Closes the innermost open log group (decrements nesting depth). Emits a Debug entry marking
|
|
287
|
+
// the group end. No-op if no group is open.
|
|
288
|
+
export function endLogGroup(channel = null) {
|
|
289
|
+
if (_groupDepth <= 0)
|
|
290
|
+
return;
|
|
291
|
+
_groupDepth--;
|
|
292
|
+
if (!_passesLevelGate(LogLevel.Debug, channel))
|
|
293
|
+
return;
|
|
294
|
+
_emitToSinks({ level: LogLevel.Debug, channel, data: { group: 'end', depth: _groupDepth + 1 } });
|
|
295
|
+
}
|
|
296
|
+
// Ends a timer, emits a structured Debug entry with the elapsed milliseconds, and returns the
|
|
297
|
+
// elapsed time in milliseconds.
|
|
298
|
+
export function endLogTimer(timer) {
|
|
299
|
+
const elapsed = _timestamp() - timer.startedAt;
|
|
300
|
+
logDebug({ label: timer.label, elapsedMs: elapsed }, timer.channel);
|
|
301
|
+
return elapsed;
|
|
302
|
+
}
|
|
303
|
+
// Activates a log span, pushing it onto the active-span stack. While active, the span's fields
|
|
304
|
+
// and channel are merged into every emitted entry. The span with the highest stack index (most
|
|
305
|
+
// recently entered) wins on field key collision. Pair every enterLogSpan with exitLogSpan.
|
|
306
|
+
export function enterLogSpan(span) {
|
|
307
|
+
_spanStack.push(span);
|
|
308
|
+
}
|
|
309
|
+
// Deactivates a log span by removing it from the active-span stack. The span need not be the
|
|
310
|
+
// topmost — it is removed by identity from wherever it appears in the stack, supporting both
|
|
311
|
+
// strict LIFO and out-of-order unwinding. No-op if the span is not in the stack.
|
|
312
|
+
export function exitLogSpan(span) {
|
|
313
|
+
const idx = _spanStack.indexOf(span);
|
|
314
|
+
if (idx >= 0)
|
|
315
|
+
_spanStack.splice(idx, 1);
|
|
316
|
+
}
|
|
317
|
+
// Flushes a buffered sink immediately, forwarding all queued entries to its target.
|
|
318
|
+
export function flushLogSink(handle) {
|
|
319
|
+
const state = _bufferedSinkStates.get(handle);
|
|
320
|
+
if (state)
|
|
321
|
+
state.flush();
|
|
322
|
+
}
|
|
323
|
+
// Listener side. Per-channel level override. Returns null when no channel-specific level is set
|
|
324
|
+
// (the channel inherits the global level).
|
|
325
|
+
export function getLogChannelLevel(channel) {
|
|
326
|
+
return _channelLevels.get(channel) ?? null;
|
|
327
|
+
}
|
|
328
|
+
// Listener side. Reads the human-readable console threshold (default LogLevel.Info). Capture is
|
|
329
|
+
// unaffected by it — the console-capture sink records every level regardless.
|
|
330
|
+
export function getLogConsoleLevel() {
|
|
331
|
+
return _consoleLevel;
|
|
332
|
+
}
|
|
333
|
+
// Listener side. Reads the global minimum emit level (default LogLevel.Verbose — emit
|
|
334
|
+
// everything). Lower-priority levels (higher numeric value) are suppressed before any sink work.
|
|
335
|
+
export function getLogLevel() {
|
|
336
|
+
return _level;
|
|
337
|
+
}
|
|
338
|
+
// Listener side. Returns the canonical lowercase name for a LogLevel value.
|
|
339
|
+
export function getLogLevelName(level) {
|
|
340
|
+
return _levelNames[level] ?? 'unknown';
|
|
341
|
+
}
|
|
342
|
+
// Returns the installed LogTransportBackend, or null if none has been set.
|
|
343
|
+
export function getLogTransportBackend() {
|
|
344
|
+
return _transportBackend;
|
|
345
|
+
}
|
|
346
|
+
// Returns the captured log entries from a memory sink in insertion order (oldest-first).
|
|
347
|
+
export function getMemoryLogSinkEntries(handle) {
|
|
348
|
+
const state = _memorySinkStates.get(handle);
|
|
349
|
+
if (!state)
|
|
350
|
+
return [];
|
|
351
|
+
const { buf, head } = state;
|
|
352
|
+
// If head is 0 the buffer has not wrapped, or it just wrapped — either way slice gives correct order.
|
|
353
|
+
if (head === 0)
|
|
354
|
+
return buf.slice();
|
|
355
|
+
// Ring buffer wrapped: entries from head..end are oldest, then start..head are newest.
|
|
356
|
+
return [...buf.slice(head), ...buf.slice(0, head)];
|
|
357
|
+
}
|
|
358
|
+
// Emit side. Emits a log entry at an explicit level. `channel` is a free categorization tag
|
|
359
|
+
// (e.g. 'batch', 'shader', 'user') for filtering captured output. Accepts a plain LogData value
|
|
360
|
+
// or a LogDataProvider thunk — the thunk is not called unless the entry passes the level gate,
|
|
361
|
+
// making suppressed verbose calls allocation-free. No-ops when no sinks are installed or when
|
|
362
|
+
// the global or per-channel level gate suppresses the entry.
|
|
363
|
+
export function log(level, data, channel = null) {
|
|
364
|
+
if (!_passesLevelGate(level, channel))
|
|
365
|
+
return;
|
|
366
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
367
|
+
const entry = { level, channel, data: _mergeSpanFields(resolved, channel) };
|
|
368
|
+
_emitToSinks(entry);
|
|
369
|
+
}
|
|
370
|
+
// Emit side. Emits an Error-level entry only when condition is false. Never throws — sentinel
|
|
371
|
+
// behavior for assertion-style diagnostics.
|
|
372
|
+
export function logAssert(condition, data, channel = null) {
|
|
373
|
+
if (condition)
|
|
374
|
+
return;
|
|
375
|
+
if (!_passesLevelGate(LogLevel.Error, channel))
|
|
376
|
+
return;
|
|
377
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
378
|
+
_emitToSinks({ level: LogLevel.Error, channel, data: _mergeSpanFields(resolved, channel) });
|
|
379
|
+
}
|
|
380
|
+
// Emit side. Severity-named convenience wrappers over log (mirrors console.debug/info/warn/error).
|
|
381
|
+
export function logDebug(data, channel = null) {
|
|
382
|
+
if (!_passesLevelGate(LogLevel.Debug, channel))
|
|
383
|
+
return;
|
|
384
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
385
|
+
_emitToSinks({ level: LogLevel.Debug, channel, data: _mergeSpanFields(resolved, channel) });
|
|
386
|
+
}
|
|
387
|
+
export function logDebugWith(context, data) {
|
|
388
|
+
const { channel } = context;
|
|
389
|
+
if (!_passesLevelGate(LogLevel.Debug, channel))
|
|
390
|
+
return;
|
|
391
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
392
|
+
_emitToSinks({
|
|
393
|
+
level: LogLevel.Debug,
|
|
394
|
+
channel,
|
|
395
|
+
data: _mergeContextFields(context, _mergeSpanFields(resolved, channel)),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
export function logError(data, channel = null) {
|
|
399
|
+
if (!_passesLevelGate(LogLevel.Error, channel))
|
|
400
|
+
return;
|
|
401
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
402
|
+
_emitToSinks({ level: LogLevel.Error, channel, data: _mergeSpanFields(resolved, channel) });
|
|
403
|
+
}
|
|
404
|
+
export function logErrorWith(context, data) {
|
|
405
|
+
const { channel } = context;
|
|
406
|
+
if (!_passesLevelGate(LogLevel.Error, channel))
|
|
407
|
+
return;
|
|
408
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
409
|
+
_emitToSinks({
|
|
410
|
+
level: LogLevel.Error,
|
|
411
|
+
channel,
|
|
412
|
+
data: _mergeContextFields(context, _mergeSpanFields(resolved, channel)),
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
export function logInfo(data, channel = null) {
|
|
416
|
+
if (!_passesLevelGate(LogLevel.Info, channel))
|
|
417
|
+
return;
|
|
418
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
419
|
+
_emitToSinks({ level: LogLevel.Info, channel, data: _mergeSpanFields(resolved, channel) });
|
|
420
|
+
}
|
|
421
|
+
export function logInfoWith(context, data) {
|
|
422
|
+
const { channel } = context;
|
|
423
|
+
if (!_passesLevelGate(LogLevel.Info, channel))
|
|
424
|
+
return;
|
|
425
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
426
|
+
_emitToSinks({
|
|
427
|
+
level: LogLevel.Info,
|
|
428
|
+
channel,
|
|
429
|
+
data: _mergeContextFields(context, _mergeSpanFields(resolved, channel)),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
// Emit side. Emits a given key at most once per process lifetime (useful for warmup warnings and
|
|
433
|
+
// deprecation notices). Returns true if the entry was emitted, false if it was suppressed.
|
|
434
|
+
export function logOnce(key, level, data, channel = null) {
|
|
435
|
+
if (_onceKeys.has(key))
|
|
436
|
+
return false;
|
|
437
|
+
_onceKeys.add(key);
|
|
438
|
+
log(level, data, channel);
|
|
439
|
+
return true;
|
|
440
|
+
}
|
|
441
|
+
export function logVerbose(data, channel = null) {
|
|
442
|
+
if (!_passesLevelGate(LogLevel.Verbose, channel))
|
|
443
|
+
return;
|
|
444
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
445
|
+
_emitToSinks({ level: LogLevel.Verbose, channel, data: _mergeSpanFields(resolved, channel) });
|
|
446
|
+
}
|
|
447
|
+
export function logVerboseWith(context, data) {
|
|
448
|
+
const { channel } = context;
|
|
449
|
+
if (!_passesLevelGate(LogLevel.Verbose, channel))
|
|
450
|
+
return;
|
|
451
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
452
|
+
_emitToSinks({
|
|
453
|
+
level: LogLevel.Verbose,
|
|
454
|
+
channel,
|
|
455
|
+
data: _mergeContextFields(context, _mergeSpanFields(resolved, channel)),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
export function logWarn(data, channel = null) {
|
|
459
|
+
if (!_passesLevelGate(LogLevel.Warn, channel))
|
|
460
|
+
return;
|
|
461
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
462
|
+
_emitToSinks({ level: LogLevel.Warn, channel, data: _mergeSpanFields(resolved, channel) });
|
|
463
|
+
}
|
|
464
|
+
export function logWarnWith(context, data) {
|
|
465
|
+
const { channel } = context;
|
|
466
|
+
if (!_passesLevelGate(LogLevel.Warn, channel))
|
|
467
|
+
return;
|
|
468
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
469
|
+
_emitToSinks({
|
|
470
|
+
level: LogLevel.Warn,
|
|
471
|
+
channel,
|
|
472
|
+
data: _mergeContextFields(context, _mergeSpanFields(resolved, channel)),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
// Emit side. Emits a log entry using the bound channel and merged fields from a LogContext. The
|
|
476
|
+
// context's fields are merged into the entry's data (string data becomes { msg, ...fields }).
|
|
477
|
+
export function logWith(context, level, data) {
|
|
478
|
+
const { channel } = context;
|
|
479
|
+
if (!_passesLevelGate(level, channel))
|
|
480
|
+
return;
|
|
481
|
+
const resolved = typeof data === 'function' ? data() : data;
|
|
482
|
+
_emitToSinks({ level, channel, data: _mergeContextFields(context, _mergeSpanFields(resolved, channel)) });
|
|
483
|
+
}
|
|
484
|
+
// Listener side. Parses a level name (case-insensitive) back to a LogLevel. Returns null for
|
|
485
|
+
// unknown names (sentinel, not a throw).
|
|
486
|
+
export function parseLogLevel(name) {
|
|
487
|
+
return _levelByName.get(name.toLowerCase()) ?? null;
|
|
488
|
+
}
|
|
489
|
+
// Registers a custom serializer for a named kind. When the JSON formatter encounters a field
|
|
490
|
+
// value whose `__kind` property matches `kind`, it calls `fn` to convert the value to a plain
|
|
491
|
+
// record. Kind strings follow the `*Kind`-style string registry: last-write-wins; vendor-prefix
|
|
492
|
+
// custom kinds (e.g. 'acme.Foo') to avoid collisions with built-in kinds (bare names reserved).
|
|
493
|
+
export function registerLogSerializer(kind, fn) {
|
|
494
|
+
_serializers.set(kind, fn);
|
|
495
|
+
}
|
|
496
|
+
// Listener side. Removes a sink from the fan-out list. Returns false if not present (sentinel,
|
|
497
|
+
// not a throw).
|
|
498
|
+
export function removeLogSink(sink) {
|
|
499
|
+
const idx = _sinks.indexOf(sink);
|
|
500
|
+
if (idx < 0)
|
|
501
|
+
return false;
|
|
502
|
+
_sinks.splice(idx, 1);
|
|
503
|
+
return true;
|
|
504
|
+
}
|
|
505
|
+
// Extracts name/message/stack/cause from an Error (recursively) into a plain record suitable
|
|
506
|
+
// for JSON serialization. Pass the result as LogData to capture stack traces correctly.
|
|
507
|
+
export function serializeLogError(value) {
|
|
508
|
+
if (!(value instanceof Error))
|
|
509
|
+
return { value: String(value) };
|
|
510
|
+
const result = {
|
|
511
|
+
name: value.name,
|
|
512
|
+
message: value.message,
|
|
513
|
+
};
|
|
514
|
+
if (value.stack !== undefined)
|
|
515
|
+
result.stack = value.stack;
|
|
516
|
+
if (value.cause !== undefined)
|
|
517
|
+
result.cause = serializeLogError(value.cause);
|
|
518
|
+
return result;
|
|
519
|
+
}
|
|
520
|
+
// Listener side. Sets a per-channel minimum emit level. Resolution order: channel level if set,
|
|
521
|
+
// else global level. Use getLogChannelLevel to read, clearLogChannelLevels to reset.
|
|
522
|
+
export function setLogChannelLevel(channel, level) {
|
|
523
|
+
_channelLevels.set(channel, level);
|
|
524
|
+
}
|
|
525
|
+
// Listener side. Sets the highest level printed as a human-readable console line. LogLevel.None
|
|
526
|
+
// silences those lines; the capture record still receives every level.
|
|
527
|
+
export function setLogConsoleLevel(level) {
|
|
528
|
+
_consoleLevel = level;
|
|
529
|
+
}
|
|
530
|
+
// Listener side. Sets the global minimum emit level. Entries below this level are suppressed
|
|
531
|
+
// before any sink work, making suppressed verbose logging genuinely free in hot paths.
|
|
532
|
+
export function setLogLevel(level) {
|
|
533
|
+
_level = level;
|
|
534
|
+
}
|
|
535
|
+
// Sets the redaction paths applied by the JSON formatter. Paths use dot notation to target
|
|
536
|
+
// nested fields (e.g. ['headers.authorization', 'user.token']). Matching field values are
|
|
537
|
+
// replaced with '[REDACTED]'. Pass an empty array to disable redaction.
|
|
538
|
+
export function setLogRedactionPaths(paths) {
|
|
539
|
+
_redactionPaths.length = 0;
|
|
540
|
+
for (const p of paths)
|
|
541
|
+
_redactionPaths.push(p);
|
|
542
|
+
}
|
|
543
|
+
// Installs (or clears, with null) the single sink — clears the list then adds the new sink if
|
|
544
|
+
// non-null. Kept for source compatibility with the capture harness.
|
|
545
|
+
export function setLogSink(sink) {
|
|
546
|
+
_sinks.length = 0;
|
|
547
|
+
if (sink !== null)
|
|
548
|
+
_sinks.push(sink);
|
|
549
|
+
}
|
|
550
|
+
// Sets the LogTransportBackend used by createFileLogSink. Set to null to detach the backend.
|
|
551
|
+
// Call createWebLogTransportBackend for a no-op web default; native/Node hosts register a real
|
|
552
|
+
// fs-backed implementation. The backend is process-global (one transport per process).
|
|
553
|
+
export function setLogTransportBackend(backend) {
|
|
554
|
+
_transportBackend = backend;
|
|
555
|
+
}
|
|
556
|
+
// Starts a named timer. Pass the returned LogTimer to endLogTimer to record elapsed time and
|
|
557
|
+
// emit a structured Debug entry.
|
|
558
|
+
export function startLogTimer(label, channel = null) {
|
|
559
|
+
return { label, channel, startedAt: _timestamp() };
|
|
560
|
+
}
|
|
561
|
+
const _bufferedSinkStates = new WeakMap();
|
|
562
|
+
const _memorySinkStates = new WeakMap();
|
|
563
|
+
const _consoleMethods = {
|
|
564
|
+
[LogLevel.None]: 'log',
|
|
565
|
+
[LogLevel.Error]: 'error',
|
|
566
|
+
[LogLevel.Warn]: 'warn',
|
|
567
|
+
[LogLevel.Info]: 'info',
|
|
568
|
+
[LogLevel.Debug]: 'debug',
|
|
569
|
+
[LogLevel.Verbose]: 'log',
|
|
570
|
+
};
|
|
571
|
+
const _levelNames = {
|
|
572
|
+
[LogLevel.None]: 'none',
|
|
573
|
+
[LogLevel.Error]: 'error',
|
|
574
|
+
[LogLevel.Warn]: 'warn',
|
|
575
|
+
[LogLevel.Info]: 'info',
|
|
576
|
+
[LogLevel.Debug]: 'debug',
|
|
577
|
+
[LogLevel.Verbose]: 'verbose',
|
|
578
|
+
};
|
|
579
|
+
const _levelByName = new Map([
|
|
580
|
+
['none', LogLevel.None],
|
|
581
|
+
['error', LogLevel.Error],
|
|
582
|
+
['warn', LogLevel.Warn],
|
|
583
|
+
['info', LogLevel.Info],
|
|
584
|
+
['debug', LogLevel.Debug],
|
|
585
|
+
['verbose', LogLevel.Verbose],
|
|
586
|
+
]);
|
|
587
|
+
const _channelLevels = new Map();
|
|
588
|
+
const _onceKeys = new Set();
|
|
589
|
+
const _redactionPaths = [];
|
|
590
|
+
const _serializers = new Map();
|
|
591
|
+
const _sinks = [];
|
|
592
|
+
const _spanStack = [];
|
|
593
|
+
let _consoleLevel = LogLevel.Info;
|
|
594
|
+
let _groupDepth = 0;
|
|
595
|
+
let _level = LogLevel.Verbose;
|
|
596
|
+
let _logSignals = null;
|
|
597
|
+
let _transportBackend = null;
|
|
598
|
+
// Applies registered serializers to values whose `__kind` matches a registered kind.
|
|
599
|
+
function _applySerializers(data) {
|
|
600
|
+
if (_serializers.size === 0)
|
|
601
|
+
return data;
|
|
602
|
+
const result = {};
|
|
603
|
+
for (const [key, value] of Object.entries(data)) {
|
|
604
|
+
if (value !== null &&
|
|
605
|
+
typeof value === 'object' &&
|
|
606
|
+
'__kind' in value &&
|
|
607
|
+
typeof value.__kind === 'string') {
|
|
608
|
+
const kind = value.__kind;
|
|
609
|
+
const fn = _serializers.get(kind);
|
|
610
|
+
result[key] = fn ? fn(value) : value;
|
|
611
|
+
}
|
|
612
|
+
else {
|
|
613
|
+
result[key] = value;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
// Applies dot-notation redaction paths to a record, replacing matching values with '[REDACTED]'.
|
|
619
|
+
function _applyRedaction(data) {
|
|
620
|
+
if (_redactionPaths.length === 0)
|
|
621
|
+
return data;
|
|
622
|
+
// Shallow copy at root; only deep-copy when traversal requires it.
|
|
623
|
+
const result = { ...data };
|
|
624
|
+
for (const path of _redactionPaths) {
|
|
625
|
+
const parts = path.split('.');
|
|
626
|
+
_redactPath(result, parts, 0);
|
|
627
|
+
}
|
|
628
|
+
return result;
|
|
629
|
+
}
|
|
630
|
+
function _redactPath(obj, parts, idx) {
|
|
631
|
+
if (idx >= parts.length)
|
|
632
|
+
return;
|
|
633
|
+
const key = parts[idx];
|
|
634
|
+
if (!(key in obj))
|
|
635
|
+
return;
|
|
636
|
+
if (idx === parts.length - 1) {
|
|
637
|
+
obj[key] = '[REDACTED]';
|
|
638
|
+
return;
|
|
639
|
+
}
|
|
640
|
+
const next = obj[key];
|
|
641
|
+
if (next !== null && typeof next === 'object' && !Array.isArray(next)) {
|
|
642
|
+
// Deep-copy one level before mutating.
|
|
643
|
+
obj[key] = { ...next };
|
|
644
|
+
_redactPath(obj[key], parts, idx + 1);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function _emitToSinks(entry) {
|
|
648
|
+
for (const sink of _sinks)
|
|
649
|
+
sink(entry);
|
|
650
|
+
if (_logSignals !== null) {
|
|
651
|
+
emitSignal(_logSignals.onLogEntry, entry);
|
|
652
|
+
if (entry.level === LogLevel.Error)
|
|
653
|
+
emitSignal(_logSignals.onLogError, entry);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
function _mergeContextFields(context, data) {
|
|
657
|
+
const { fields } = context;
|
|
658
|
+
if (Object.keys(fields).length === 0)
|
|
659
|
+
return data;
|
|
660
|
+
if (typeof data === 'string')
|
|
661
|
+
return { msg: data, ...fields };
|
|
662
|
+
return { ...fields, ...data };
|
|
663
|
+
}
|
|
664
|
+
// Merges active span fields into emitted data. Span fields have lower priority than data fields;
|
|
665
|
+
// later spans in the stack win over earlier ones on key collision within span fields.
|
|
666
|
+
function _mergeSpanFields(data, _channel) {
|
|
667
|
+
if (_spanStack.length === 0)
|
|
668
|
+
return data;
|
|
669
|
+
// Accumulate span fields oldest-first so newer spans overwrite older ones.
|
|
670
|
+
const spanFields = {};
|
|
671
|
+
for (const span of _spanStack) {
|
|
672
|
+
Object.assign(spanFields, span.fields);
|
|
673
|
+
}
|
|
674
|
+
if (Object.keys(spanFields).length === 0)
|
|
675
|
+
return data;
|
|
676
|
+
// Data fields win over span fields on key collision.
|
|
677
|
+
if (typeof data === 'string')
|
|
678
|
+
return { msg: data, ...spanFields };
|
|
679
|
+
return { ...spanFields, ...data };
|
|
680
|
+
}
|
|
681
|
+
function _passesLevelGate(level, channel) {
|
|
682
|
+
if (_sinks.length === 0 && _logSignals === null)
|
|
683
|
+
return false;
|
|
684
|
+
const gate = channel !== null && _channelLevels.has(channel) ? _channelLevels.get(channel) : _level;
|
|
685
|
+
return level <= gate && level !== LogLevel.None;
|
|
686
|
+
}
|
|
687
|
+
function _timestamp() {
|
|
688
|
+
return typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
689
|
+
}
|
|
690
|
+
const _defaultJsonFormatter = (entry) => {
|
|
691
|
+
const { level, channel, data } = entry;
|
|
692
|
+
return JSON.stringify({
|
|
693
|
+
__flight: true,
|
|
694
|
+
t: _timestamp(),
|
|
695
|
+
level: _levelNames[level],
|
|
696
|
+
channel,
|
|
697
|
+
data: typeof data === 'string' ? { msg: data } : data,
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
/* eslint-disable no-console -- this is the console-capture sink; writing to the console is its job */
|
|
701
|
+
function _writeConsoleCaptureEntry(entry, envelopeFormatter) {
|
|
702
|
+
if (typeof console === 'undefined')
|
|
703
|
+
return;
|
|
704
|
+
const { level, channel } = entry;
|
|
705
|
+
// The capture record: every level, as a tagged JSON line the collector parses.
|
|
706
|
+
console.debug(envelopeFormatter(entry));
|
|
707
|
+
// The human-readable subset.
|
|
708
|
+
if (level !== LogLevel.None && _consoleLevel >= level) {
|
|
709
|
+
const method = _consoleMethods[level];
|
|
710
|
+
const prefix = channel !== null ? `[${channel}]` : '[flight]';
|
|
711
|
+
const { data } = entry;
|
|
712
|
+
if (typeof data === 'string')
|
|
713
|
+
console[method](`${prefix} ${data}`);
|
|
714
|
+
else
|
|
715
|
+
console[method](prefix, data);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
/* eslint-enable no-console */
|
|
719
|
+
//# sourceMappingURL=log.js.map
|