@dxos/log 0.8.4-main.fbb7a13 → 0.8.4-main.fcc0d83b33

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 (73) hide show
  1. package/dist/lib/browser/chunk-V7FYKT4H.mjs +311 -0
  2. package/dist/lib/browser/chunk-V7FYKT4H.mjs.map +7 -0
  3. package/dist/lib/browser/index.mjs +236 -43
  4. package/dist/lib/browser/index.mjs.map +4 -4
  5. package/dist/lib/browser/meta.json +1 -1
  6. package/dist/lib/browser/platform/node/index.mjs +1 -1
  7. package/dist/lib/browser/platform/node/index.mjs.map +3 -3
  8. package/dist/lib/browser/processors/console-processor.mjs +6 -11
  9. package/dist/lib/browser/processors/console-processor.mjs.map +3 -3
  10. package/dist/lib/node-esm/chunk-5TBDXMQF.mjs +313 -0
  11. package/dist/lib/node-esm/chunk-5TBDXMQF.mjs.map +7 -0
  12. package/dist/lib/node-esm/index.mjs +236 -43
  13. package/dist/lib/node-esm/index.mjs.map +4 -4
  14. package/dist/lib/node-esm/meta.json +1 -1
  15. package/dist/lib/node-esm/platform/node/index.mjs +1 -1
  16. package/dist/lib/node-esm/platform/node/index.mjs.map +3 -3
  17. package/dist/lib/node-esm/processors/console-processor.mjs +6 -11
  18. package/dist/lib/node-esm/processors/console-processor.mjs.map +3 -3
  19. package/dist/types/src/context.d.ts +78 -2
  20. package/dist/types/src/context.d.ts.map +1 -1
  21. package/dist/types/src/dbg.d.ts +23 -0
  22. package/dist/types/src/dbg.d.ts.map +1 -0
  23. package/dist/types/src/decorators.d.ts.map +1 -1
  24. package/dist/types/src/environment.d.ts +24 -0
  25. package/dist/types/src/environment.d.ts.map +1 -0
  26. package/dist/types/src/environment.test.d.ts +2 -0
  27. package/dist/types/src/environment.test.d.ts.map +1 -0
  28. package/dist/types/src/experimental/ownership.d.ts.map +1 -1
  29. package/dist/types/src/index.d.ts +7 -3
  30. package/dist/types/src/index.d.ts.map +1 -1
  31. package/dist/types/src/jsonl.d.ts +53 -0
  32. package/dist/types/src/jsonl.d.ts.map +1 -0
  33. package/dist/types/src/jsonl.test.d.ts +2 -0
  34. package/dist/types/src/jsonl.test.d.ts.map +1 -0
  35. package/dist/types/src/log-buffer.d.ts +20 -0
  36. package/dist/types/src/log-buffer.d.ts.map +1 -0
  37. package/dist/types/src/log-buffer.test.d.ts +2 -0
  38. package/dist/types/src/log-buffer.test.d.ts.map +1 -0
  39. package/dist/types/src/log.d.ts +44 -1
  40. package/dist/types/src/log.d.ts.map +1 -1
  41. package/dist/types/src/meta.d.ts +20 -1
  42. package/dist/types/src/meta.d.ts.map +1 -1
  43. package/dist/types/src/platform/browser/index.d.ts.map +1 -1
  44. package/dist/types/src/platform/node/index.d.ts.map +1 -1
  45. package/dist/types/src/processors/browser-processor.d.ts.map +1 -1
  46. package/dist/types/src/processors/common.d.ts.map +1 -1
  47. package/dist/types/src/processors/console-processor.d.ts.map +1 -1
  48. package/dist/types/src/processors/file-processor.d.ts.map +1 -1
  49. package/dist/types/src/scope.d.ts.map +1 -1
  50. package/dist/types/tsconfig.tsbuildinfo +1 -1
  51. package/package.json +4 -9
  52. package/src/context.ts +242 -2
  53. package/src/dbg.ts +34 -0
  54. package/src/decorators.ts +1 -2
  55. package/src/environment.test.ts +222 -0
  56. package/src/environment.ts +129 -0
  57. package/src/experimental/classes.test.ts +0 -1
  58. package/src/index.ts +7 -4
  59. package/src/jsonl.test.ts +121 -0
  60. package/src/jsonl.ts +104 -0
  61. package/src/log-buffer.test.ts +158 -0
  62. package/src/log-buffer.ts +89 -0
  63. package/src/log.test.ts +0 -1
  64. package/src/log.ts +56 -12
  65. package/src/meta.ts +29 -1
  66. package/src/platform/node/index.ts +1 -2
  67. package/src/processors/browser-processor.ts +27 -28
  68. package/src/processors/console-processor.ts +5 -13
  69. package/src/processors/file-processor.ts +7 -9
  70. package/dist/lib/browser/chunk-GPOFUMLO.mjs +0 -133
  71. package/dist/lib/browser/chunk-GPOFUMLO.mjs.map +0 -7
  72. package/dist/lib/node-esm/chunk-QPYJZ4SO.mjs +0 -135
  73. package/dist/lib/node-esm/chunk-QPYJZ4SO.mjs.map +0 -7
@@ -0,0 +1,129 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ /**
6
+ * Per-tab session-storage key used to stabilize the random suffix for the main thread.
7
+ * `sessionStorage` is per-tab and survives reloads of the same tab.
8
+ */
9
+ const TAB_SUFFIX_STORAGE_KEY = '@dxos/log:env-suffix';
10
+
11
+ const SUFFIX_LENGTH = 6;
12
+
13
+ /**
14
+ * Generate a 6-character lowercase-alphanumeric suffix.
15
+ * Cryptographically random when `crypto` is available; falls back to `Math.random`.
16
+ */
17
+ const randomSuffix = (): string => {
18
+ const cryptoRef = (globalThis as any).crypto as Crypto | undefined;
19
+ if (cryptoRef?.getRandomValues) {
20
+ const bytes = new Uint8Array(SUFFIX_LENGTH);
21
+ cryptoRef.getRandomValues(bytes);
22
+ // Each byte mod 36 maps to one base-36 char; mild modulo bias is acceptable for IDs.
23
+ let suffix = '';
24
+ for (const byte of bytes) {
25
+ suffix += (byte % 36).toString(36);
26
+ }
27
+ return suffix;
28
+ }
29
+ return Math.random()
30
+ .toString(36)
31
+ .slice(2, 2 + SUFFIX_LENGTH)
32
+ .padEnd(SUFFIX_LENGTH, '0');
33
+ };
34
+
35
+ /**
36
+ * Look up (or create) a stable per-tab suffix in `sessionStorage`.
37
+ * Same browser tab keeps the same suffix across reloads; different tabs get
38
+ * different suffixes; private/blocked storage falls back to a fresh random.
39
+ */
40
+ const getOrCreateTabSuffix = (session: Storage | undefined): string => {
41
+ if (!session) {
42
+ return randomSuffix();
43
+ }
44
+ try {
45
+ const existing = session.getItem(TAB_SUFFIX_STORAGE_KEY);
46
+ if (existing && existing.length > 0) {
47
+ return existing;
48
+ }
49
+ const suffix = randomSuffix();
50
+ session.setItem(TAB_SUFFIX_STORAGE_KEY, suffix);
51
+ return suffix;
52
+ } catch {
53
+ return randomSuffix();
54
+ }
55
+ };
56
+
57
+ const isInstanceOf = (scope: unknown, ctorName: string): boolean => {
58
+ const ctor = (scope as any)?.[ctorName];
59
+ return typeof ctor === 'function' && scope instanceof ctor;
60
+ };
61
+
62
+ /**
63
+ * Cloudflare Workers set `navigator.userAgent` to this exact string, regardless of
64
+ * whether the worker is using modules or service-worker syntax. This is the most
65
+ * stable signal and is checked before browser worker scopes because CF workers in
66
+ * service-worker syntax also expose a `ServiceWorkerGlobalScope`.
67
+ */
68
+ const CF_WORKER_USER_AGENT = 'Cloudflare-Workers';
69
+
70
+ /**
71
+ * Options for {@link inferEnvironmentName}.
72
+ * Tests pass a custom `scope` to simulate worker / window globals.
73
+ */
74
+ export type InferEnvironmentNameOptions = {
75
+ scope?: unknown;
76
+ };
77
+
78
+ /**
79
+ * Infer a writer/environment identifier from the current execution context.
80
+ *
81
+ * Safe to invoke in any JS runtime — never throws. Falls back to `unknown::<suffix>`
82
+ * when the runtime can't be classified.
83
+ *
84
+ * Format is always three colon-separated segments: `<scope>:<name>:<suffix>`.
85
+ * - `scope` — `tab | dedicated-worker | shared-worker | service-worker | cf-worker | node | unknown`.
86
+ * - `name` — `location.origin` for tabs, `self.name` for browser workers, `process.pid`
87
+ * for node, empty for cf-workers / service workers / anonymous workers.
88
+ * Note that `name` may itself contain `:` (e.g. `http://localhost:5173`); when parsing,
89
+ * take the first segment as scope and the last as suffix, and treat everything in
90
+ * between as the name.
91
+ * - `suffix` — 6-char random; stable per-tab via `sessionStorage`, fresh per worker / process instance.
92
+ */
93
+ export const inferEnvironmentName = (options: InferEnvironmentNameOptions = {}): string => {
94
+ const scope: any = options.scope ?? globalThis;
95
+
96
+ // Cloudflare Workers — checked first because in service-worker syntax mode CF
97
+ // workers also report as `ServiceWorkerGlobalScope`.
98
+ if (scope.navigator?.userAgent === CF_WORKER_USER_AGENT) {
99
+ return `cf-worker::${randomSuffix()}`;
100
+ }
101
+
102
+ if (isInstanceOf(scope, 'SharedWorkerGlobalScope')) {
103
+ return `shared-worker:${scope.name ?? ''}:${randomSuffix()}`;
104
+ }
105
+
106
+ if (isInstanceOf(scope, 'ServiceWorkerGlobalScope')) {
107
+ return `service-worker::${randomSuffix()}`;
108
+ }
109
+
110
+ if (isInstanceOf(scope, 'DedicatedWorkerGlobalScope')) {
111
+ return `dedicated-worker:${scope.name ?? ''}:${randomSuffix()}`;
112
+ }
113
+
114
+ if (scope.window !== undefined && scope.window === scope) {
115
+ const origin = scope.location?.origin ?? '';
116
+ return `tab:${origin}:${getOrCreateTabSuffix(scope.sessionStorage)}`;
117
+ }
118
+
119
+ // Node.js — `process.versions.node` is the canonical signal.
120
+ // Avoid touching the real `process` global directly so tests passing `scope: {}`
121
+ // can opt out of node detection.
122
+ const proc = scope.process;
123
+ if (proc && typeof proc === 'object' && proc.versions?.node) {
124
+ const pid = typeof proc.pid === 'number' ? String(proc.pid) : '';
125
+ return `node:${pid}:${randomSuffix()}`;
126
+ }
127
+
128
+ return `unknown::${randomSuffix()}`;
129
+ };
@@ -5,7 +5,6 @@
5
5
  import { describe, test } from 'vitest';
6
6
 
7
7
  import { log } from '../log';
8
-
9
8
  import { debugInfo, ownershipClass } from './ownership';
10
9
 
11
10
  describe('classes', function () {
package/src/index.ts CHANGED
@@ -2,18 +2,21 @@
2
2
  // Copyright 2022 DXOS.org
3
3
  //
4
4
 
5
- import omit from 'lodash.omit';
6
-
7
- import { pick } from '@dxos/util';
5
+ import { omit, pick } from '@dxos/util';
8
6
 
9
7
  export { omit, pick };
10
8
 
11
9
  export * from './config';
12
10
  export * from './context';
11
+ export * from './environment';
12
+ export * from './jsonl';
13
13
  export * from './log';
14
14
  export { parseFilter } from './options';
15
15
  export * from './processors';
16
16
  export * from './scope';
17
- export type * from './meta';
17
+ export type { CallMetadata } from './meta';
18
+ export { LOG_META_MARKER, isLogMeta } from './meta';
19
+ export { dbg } from './dbg';
20
+ export * from './log-buffer';
18
21
 
19
22
  export { getCurrentOwnershipScope } from './experimental/ownership';
@@ -0,0 +1,121 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { LogEntry, type LogEntryInit, LogLevel } from './index';
8
+ import { serializeToJsonl } from './jsonl';
9
+
10
+ const createEntry = (overrides: Partial<LogEntryInit> = {}): LogEntry =>
11
+ new LogEntry({
12
+ level: LogLevel.INFO,
13
+ message: 'test message',
14
+ ...overrides,
15
+ });
16
+
17
+ const parseLine = (line: string | undefined) => {
18
+ if (line === undefined) {
19
+ throw new Error('expected line, got undefined');
20
+ }
21
+ return JSON.parse(line) as Record<string, unknown>;
22
+ };
23
+
24
+ describe('serializeToJsonl', () => {
25
+ test('returns undefined for TRACE level entries', ({ expect }) => {
26
+ expect(serializeToJsonl(createEntry({ level: LogLevel.TRACE }))).toBeUndefined();
27
+ });
28
+
29
+ test('emits compact record with t/l/m fields', ({ expect }) => {
30
+ const record = parseLine(serializeToJsonl(createEntry({ message: 'hello' })));
31
+ expect(record.t).toBeTypeOf('string');
32
+ expect(record.l).toBe('I');
33
+ expect(record.m).toBe('hello');
34
+ });
35
+
36
+ test('uses level letters D V I W E', ({ expect }) => {
37
+ const levels: Array<[LogLevel, string]> = [
38
+ [LogLevel.DEBUG, 'D'],
39
+ [LogLevel.VERBOSE, 'V'],
40
+ [LogLevel.INFO, 'I'],
41
+ [LogLevel.WARN, 'W'],
42
+ [LogLevel.ERROR, 'E'],
43
+ ];
44
+ for (const [level, letter] of levels) {
45
+ const record = parseLine(serializeToJsonl(createEntry({ level, message: '' })));
46
+ expect(record.l).toBe(letter);
47
+ }
48
+ });
49
+
50
+ test('omits optional fields when not present', ({ expect }) => {
51
+ const record = parseLine(serializeToJsonl(createEntry({ message: 'plain' })));
52
+ expect(record.f).toBeUndefined();
53
+ expect(record.n).toBeUndefined();
54
+ expect(record.o).toBeUndefined();
55
+ expect(record.e).toBeUndefined();
56
+ expect(record.c).toBeUndefined();
57
+ expect(record.i).toBeUndefined();
58
+ });
59
+
60
+ test('captures filename and line metadata', ({ expect }) => {
61
+ const record = parseLine(
62
+ serializeToJsonl(
63
+ createEntry({
64
+ meta: { F: '/home/user/project/packages/sdk/test.ts', L: 42, S: undefined },
65
+ }),
66
+ ),
67
+ );
68
+ expect(record.f).toBe('packages/sdk/test.ts');
69
+ expect(record.n).toBe(42);
70
+ });
71
+
72
+ test('captures error stack via computedError', ({ expect }) => {
73
+ const error = new Error('boom');
74
+ const record = parseLine(serializeToJsonl(createEntry({ error })));
75
+ expect(record.e).toContain('boom');
76
+ });
77
+
78
+ test('captures context as flat JSON string', ({ expect }) => {
79
+ const record = parseLine(serializeToJsonl(createEntry({ context: { count: 3, name: 'x' } })));
80
+ expect(record.c).toBeTypeOf('string');
81
+ const ctx = JSON.parse(record.c as string);
82
+ expect(ctx.count).toBe(3);
83
+ expect(ctx.name).toBe('x');
84
+ });
85
+
86
+ test('flattens nested objects in context (one level)', ({ expect }) => {
87
+ const record = parseLine(serializeToJsonl(createEntry({ context: { nested: { a: 1, b: 2 } } })));
88
+ const ctx = JSON.parse(record.c as string);
89
+ // computedContext converts nested objects to JSON strings via stringifyOneLevel.
90
+ expect(typeof ctx.nested).toBe('string');
91
+ expect(ctx.nested).toBe('{"a":1,"b":2}');
92
+ });
93
+
94
+ test('does not truncate long context (idb-store style)', ({ expect }) => {
95
+ const longValue = 'x'.repeat(2_000);
96
+ const record = parseLine(serializeToJsonl(createEntry({ context: { data: longValue } })));
97
+ const ctx = JSON.parse(record.c as string);
98
+ expect(ctx.data).toBe(longValue);
99
+ });
100
+
101
+ test('embeds env identifier in `i` field', ({ expect }) => {
102
+ const record = parseLine(
103
+ serializeToJsonl(createEntry({ message: 'm' }), { env: 'tab:http://localhost:5173:abc123' }),
104
+ );
105
+ expect(record.i).toBe('tab:http://localhost:5173:abc123');
106
+ });
107
+
108
+ test('omits `i` when env is not provided', ({ expect }) => {
109
+ const record = parseLine(serializeToJsonl(createEntry({ message: 'm' })));
110
+ expect(record.i).toBeUndefined();
111
+ });
112
+
113
+ test('output is valid single-line JSON (no embedded newlines)', ({ expect }) => {
114
+ const line = serializeToJsonl(createEntry({ message: 'multi\nline\nmessage' }));
115
+ expect(line).toBeDefined();
116
+ expect(line!.indexOf('\n')).toBe(-1);
117
+ // The newlines inside the message are escaped by JSON.stringify.
118
+ const record = JSON.parse(line!);
119
+ expect(record.m).toBe('multi\nline\nmessage');
120
+ });
121
+ });
package/src/jsonl.ts ADDED
@@ -0,0 +1,104 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { LogLevel, shortLevelName } from './config';
6
+ import { type LogEntry } from './context';
7
+
8
+ /**
9
+ * Compact JSONL log record with short property names for small serialized size.
10
+ *
11
+ * Field names are intentionally one character to keep on-disk / on-wire size minimal —
12
+ * a single record can be emitted thousands of times per session.
13
+ */
14
+ export type LogRecord = {
15
+ /** ISO timestamp. */
16
+ t: string;
17
+ /** Level letter (D, V, I, W, E). */
18
+ l: string;
19
+ /** Message. */
20
+ m: string;
21
+ /** File path. */
22
+ f?: string;
23
+ /** Line number. */
24
+ n?: number;
25
+ /** Object/scope from which the log was emitted. */
26
+ o?: string;
27
+ /** Error stack. */
28
+ e?: string;
29
+ /** Context JSON (already a string of pre-stringified flat key/values). */
30
+ c?: string;
31
+ /** Environment identifier (see {@link inferEnvironmentName}). */
32
+ i?: string;
33
+ };
34
+
35
+ /**
36
+ * Options for {@link serializeToJsonl}.
37
+ */
38
+ export type SerializeToJsonlOptions = {
39
+ /**
40
+ * Environment identifier embedded in the record's `i` field.
41
+ * Use {@link inferEnvironmentName} for a default that disambiguates tabs and workers.
42
+ */
43
+ env?: string;
44
+ };
45
+
46
+ /**
47
+ * Serialize a {@link LogEntry} to a single compact JSON line (no trailing newline).
48
+ *
49
+ * Returns `undefined` for entries at TRACE level — callers can use this to skip them.
50
+ *
51
+ * The output is compatible with newline-delimited JSON (NDJSON / JSONL): join multiple
52
+ * results with `\n` for a stream / file representation.
53
+ *
54
+ * Context is taken from {@link LogEntry.computedContext}, which has already flattened
55
+ * nested objects to strings (see `stringifyOneLevel` in `context.ts`), so the resulting
56
+ * `c` field is a JSON string of a flat `Record<string, primitive>` map. The function
57
+ * does **not** truncate context — callers that care about line size should set their own
58
+ * cap before calling, or trim post-hoc.
59
+ */
60
+ export const serializeToJsonl = (entry: LogEntry, opts: SerializeToJsonlOptions = {}): string | undefined => {
61
+ if (entry.level <= LogLevel.TRACE) {
62
+ return undefined;
63
+ }
64
+
65
+ const { filename, line, context: scopeName } = entry.computedMeta;
66
+
67
+ const record: LogRecord = {
68
+ t: new Date(entry.timestamp).toISOString(),
69
+ l: shortLevelName[entry.level] ?? '?',
70
+ m: entry.message ?? '',
71
+ };
72
+
73
+ if (filename !== undefined) {
74
+ record.f = filename;
75
+ }
76
+ if (line !== undefined) {
77
+ record.n = line;
78
+ }
79
+ if (scopeName !== undefined) {
80
+ record.o = scopeName;
81
+ }
82
+ if (entry.computedError !== undefined) {
83
+ record.e = entry.computedError;
84
+ }
85
+ if (opts.env !== undefined) {
86
+ record.i = opts.env;
87
+ }
88
+
89
+ const computedContext = entry.computedContext;
90
+ if (Object.keys(computedContext).length > 0) {
91
+ try {
92
+ record.c = JSON.stringify(computedContext);
93
+ } catch {
94
+ // Skip context that throws during serialization. `computedContext` is already flattened
95
+ // via `stringifyOneLevel`, so this is best-effort belt-and-suspenders.
96
+ }
97
+ }
98
+
99
+ try {
100
+ return JSON.stringify(record);
101
+ } catch {
102
+ return undefined;
103
+ }
104
+ };
@@ -0,0 +1,158 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { type LogConfig, LogEntry, type LogEntryInit, LogLevel } from './index';
8
+ import { LogBuffer } from './log-buffer';
9
+
10
+ const baseConfig: LogConfig = {
11
+ options: {},
12
+ filters: [{ level: LogLevel.DEBUG }],
13
+ processors: [],
14
+ };
15
+
16
+ const createEntry = (overrides: Partial<LogEntryInit> = {}): LogEntry =>
17
+ new LogEntry({
18
+ level: LogLevel.INFO,
19
+ message: 'test message',
20
+ ...overrides,
21
+ });
22
+
23
+ describe('LogBuffer', () => {
24
+ test('pushes and serializes log entries', ({ expect }) => {
25
+ const buffer = new LogBuffer(10);
26
+ buffer.logProcessor(baseConfig, createEntry({ message: 'hello' }));
27
+ buffer.logProcessor(baseConfig, createEntry({ message: 'world' }));
28
+
29
+ expect(buffer.size).toBe(2);
30
+ const lines = buffer.serialize().split('\n');
31
+ expect(lines).toHaveLength(2);
32
+
33
+ const first = JSON.parse(lines[0]);
34
+ expect(first.m).toBe('hello');
35
+ expect(first.l).toBe('I');
36
+ expect(first.t).toBeDefined();
37
+
38
+ const second = JSON.parse(lines[1]);
39
+ expect(second.m).toBe('world');
40
+ });
41
+
42
+ test('evicts oldest entries when buffer is full', ({ expect }) => {
43
+ const buffer = new LogBuffer(3);
44
+ buffer.logProcessor(baseConfig, createEntry({ message: 'a' }));
45
+ buffer.logProcessor(baseConfig, createEntry({ message: 'b' }));
46
+ buffer.logProcessor(baseConfig, createEntry({ message: 'c' }));
47
+ buffer.logProcessor(baseConfig, createEntry({ message: 'd' }));
48
+
49
+ expect(buffer.size).toBe(3);
50
+ const lines = buffer.serialize().split('\n');
51
+ const messages = lines.map((line) => JSON.parse(line).m);
52
+ expect(messages).toEqual(['b', 'c', 'd']);
53
+ });
54
+
55
+ test('skips TRACE-level logs', ({ expect }) => {
56
+ const buffer = new LogBuffer(10);
57
+ buffer.logProcessor(baseConfig, createEntry({ level: LogLevel.TRACE, message: 'trace' }));
58
+ expect(buffer.size).toBe(0);
59
+ });
60
+
61
+ test('captures DEBUG-level and above', ({ expect }) => {
62
+ const buffer = new LogBuffer(10);
63
+ buffer.logProcessor(baseConfig, createEntry({ level: LogLevel.DEBUG, message: 'debug' }));
64
+ buffer.logProcessor(baseConfig, createEntry({ level: LogLevel.WARN, message: 'warn' }));
65
+ buffer.logProcessor(baseConfig, createEntry({ level: LogLevel.ERROR, message: 'error' }));
66
+
67
+ expect(buffer.size).toBe(3);
68
+ const lines = buffer.serialize().split('\n');
69
+ expect(JSON.parse(lines[0]).l).toBe('D');
70
+ expect(JSON.parse(lines[1]).l).toBe('W');
71
+ expect(JSON.parse(lines[2]).l).toBe('E');
72
+ });
73
+
74
+ test('captures file and line metadata', ({ expect }) => {
75
+ const buffer = new LogBuffer(10);
76
+ buffer.logProcessor(
77
+ baseConfig,
78
+ createEntry({
79
+ meta: { F: '/home/user/project/packages/sdk/test.ts', L: 42, S: undefined },
80
+ }),
81
+ );
82
+
83
+ const lines = buffer.serialize().split('\n');
84
+ const record = JSON.parse(lines[0]);
85
+ expect(record.f).toBe('packages/sdk/test.ts');
86
+ expect(record.n).toBe(42);
87
+ });
88
+
89
+ test('captures error stack', ({ expect }) => {
90
+ const buffer = new LogBuffer(10);
91
+ const error = new Error('boom');
92
+ buffer.logProcessor(baseConfig, createEntry({ error }));
93
+
94
+ const lines = buffer.serialize().split('\n');
95
+ const record = JSON.parse(lines[0]);
96
+ expect(record.e).toContain('boom');
97
+ });
98
+
99
+ test('truncates context to 500 chars', ({ expect }) => {
100
+ const buffer = new LogBuffer(10);
101
+ const longValue = 'x'.repeat(1000);
102
+ buffer.logProcessor(baseConfig, createEntry({ context: { data: longValue } }));
103
+
104
+ const lines = buffer.serialize().split('\n');
105
+ const record = JSON.parse(lines[0]);
106
+ expect(record.c).toBeDefined();
107
+ expect(record.c!.length).toBe(500);
108
+ });
109
+
110
+ test('skips Error context objects', ({ expect }) => {
111
+ const buffer = new LogBuffer(10);
112
+ buffer.logProcessor(baseConfig, createEntry({ context: new Error('ctx error') }));
113
+
114
+ const lines = buffer.serialize().split('\n');
115
+ const record = JSON.parse(lines[0]);
116
+ expect(record.c).toBeUndefined();
117
+ });
118
+
119
+ test('handles non-serializable context gracefully', ({ expect }) => {
120
+ const buffer = new LogBuffer(10);
121
+ const circular: Record<string, any> = {};
122
+ circular.self = circular;
123
+ buffer.logProcessor(baseConfig, createEntry({ context: circular }));
124
+
125
+ const lines = buffer.serialize().split('\n');
126
+ const record = JSON.parse(lines[0]);
127
+ // Circular values fall back to String(value) rather than dropping the entry.
128
+ expect(record.c).toBeDefined();
129
+ expect(JSON.parse(record.c!).self).toBe('[object Object]');
130
+ });
131
+
132
+ test('serialize returns empty string for empty buffer', ({ expect }) => {
133
+ const buffer = new LogBuffer(10);
134
+ expect(buffer.serialize()).toBe('');
135
+ });
136
+
137
+ test('clear discards all entries', ({ expect }) => {
138
+ const buffer = new LogBuffer(10);
139
+ buffer.logProcessor(baseConfig, createEntry({ message: 'a' }));
140
+ buffer.logProcessor(baseConfig, createEntry({ message: 'b' }));
141
+ expect(buffer.size).toBe(2);
142
+
143
+ buffer.clear();
144
+ expect(buffer.size).toBe(0);
145
+ expect(buffer.serialize()).toBe('');
146
+ });
147
+
148
+ test('clear allows new entries', ({ expect }) => {
149
+ const buffer = new LogBuffer(10);
150
+ buffer.logProcessor(baseConfig, createEntry({ message: 'old' }));
151
+ buffer.clear();
152
+ buffer.logProcessor(baseConfig, createEntry({ message: 'new' }));
153
+
154
+ expect(buffer.size).toBe(1);
155
+ const record = JSON.parse(buffer.serialize());
156
+ expect(record.m).toBe('new');
157
+ });
158
+ });
@@ -0,0 +1,89 @@
1
+ //
2
+ // Copyright 2025 DXOS.org
3
+ //
4
+
5
+ import { CircularBuffer } from '@dxos/util';
6
+
7
+ import { type LogConfig, LogLevel, shortLevelName } from './config';
8
+ import { type LogEntry, type LogProcessor } from './context';
9
+ import { type LogRecord } from './jsonl';
10
+
11
+ const DEFAULT_BUFFER_SIZE = 2_000;
12
+ const MAX_CONTEXT_LENGTH = 500;
13
+
14
+ /**
15
+ * Captures recent log entries in a circular buffer for debug log dump.
16
+ */
17
+ export class LogBuffer {
18
+ private readonly _buffer: CircularBuffer<LogRecord>;
19
+
20
+ constructor(size = DEFAULT_BUFFER_SIZE) {
21
+ this._buffer = new CircularBuffer<LogRecord>(size);
22
+ }
23
+
24
+ /**
25
+ * Log processor that can be registered with `log.runtimeConfig.processors`.
26
+ * Captures every level except TRACE (does not apply `shouldLog` / filter; use for full debug dumps).
27
+ */
28
+ readonly logProcessor: LogProcessor = (_config: LogConfig, entry: LogEntry) => {
29
+ if (entry.level <= LogLevel.TRACE) {
30
+ return;
31
+ }
32
+
33
+ const { filename, line, context: scopeName } = entry.computedMeta;
34
+
35
+ const record: LogRecord = {
36
+ t: new Date(entry.timestamp).toISOString(),
37
+ l: shortLevelName[entry.level] ?? '?',
38
+ m: entry.message ?? '',
39
+ };
40
+
41
+ if (filename !== undefined) {
42
+ record.f = filename;
43
+ }
44
+ if (line !== undefined) {
45
+ record.n = line;
46
+ }
47
+ if (scopeName !== undefined) {
48
+ record.o = scopeName;
49
+ }
50
+
51
+ if (entry.computedError !== undefined) {
52
+ record.e = entry.computedError;
53
+ }
54
+
55
+ const computedContext = entry.computedContext;
56
+ if (Object.keys(computedContext).length > 0) {
57
+ try {
58
+ let json = JSON.stringify(computedContext);
59
+ if (json.length > MAX_CONTEXT_LENGTH) {
60
+ json = json.slice(0, MAX_CONTEXT_LENGTH);
61
+ }
62
+ record.c = json;
63
+ } catch {
64
+ // Skip context that throws or is non-serializable.
65
+ }
66
+ }
67
+
68
+ this._buffer.push(record);
69
+ };
70
+
71
+ /** Number of entries currently in the buffer. */
72
+ get size(): number {
73
+ return this._buffer.elementCount;
74
+ }
75
+
76
+ /** Discard all buffered entries. */
77
+ clear(): void {
78
+ this._buffer.clear();
79
+ }
80
+
81
+ /** Serialize buffer contents as NDJSON (newline-delimited JSON). */
82
+ serialize(): string {
83
+ const lines: string[] = [];
84
+ for (const record of this._buffer) {
85
+ lines.push(JSON.stringify(record));
86
+ }
87
+ return lines.join('\n');
88
+ }
89
+ }
package/src/log.test.ts CHANGED
@@ -3,7 +3,6 @@
3
3
  //
4
4
 
5
5
  import path from 'node:path';
6
-
7
6
  import { beforeEach, describe, test } from 'vitest';
8
7
 
9
8
  import { LogLevel } from './config';