Skip to content

Commit b957f79

Browse files
add support for ndjson logging (#3381)
* add support for ndjson logging * move install location
1 parent 393500d commit b957f79

5 files changed

Lines changed: 313 additions & 0 deletions

File tree

config.template.jsonc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@
1616
// `dev` opens a browser on boot, skips blocked-email checks, and runs the
1717
// dev-time webpack watcher; `prod` serves pre-built bundles.
1818
"env": "dev",
19+
// Console output format. `json` replaces the global console so every call
20+
// emits one structured JSON line (level, timestamp, msg, and the active
21+
// request's trace id) — one event per call, so a line-oriented log
22+
// collector can't split stack traces across events and level filtering
23+
// works. Unset (the default) leaves console output human-readable.
24+
"log_format": "text",
1925
"version": "0.0.0",
2026
// Stable identity for this server node — used by pager alerts and
2127
// graceful-shutdown coordination.

src/backend/index.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,15 @@
1919

2020
import { existsSync, readFileSync } from 'node:fs';
2121
import path from 'node:path';
22+
import { isSpanContextValid, trace } from '@opentelemetry/api';
2223
import { puterClients } from './clients';
2324
import { puterControllers } from './controllers';
2425
import { puterDrivers } from './drivers';
2526
import { PuterServer } from './server';
2627
import { puterServices } from './services';
2728
import { puterStores } from './stores';
2829
import type { IConfig } from './types';
30+
import { installJsonConsole } from './util/jsonConsole.js';
2931

3032
// Config resolution order:
3133
// 1. `process.env.PUTER_CONFIG_PATH` — absolute path to a config file. Used
@@ -167,6 +169,23 @@ const loadConfig = (): IConfig => {
167169
// if called directly, start the server
168170
if (require.main === module) {
169171
const config = loadConfig();
172+
173+
// Structured logging: when `log_format: "json"`, replace the global console
174+
// so each call emits one JSON line (level, timestamp, msg, and the active
175+
// trace id) — one event per call, so a line-oriented log collector can't
176+
// split stack traces across events. Installed here rather than in the OTel
177+
// preload so it applies even when telemetry is disabled; the trace id is
178+
// simply absent when no span is active.
179+
if (config.log_format === 'json') {
180+
installJsonConsole({
181+
getTraceContext: () => {
182+
const ctx = trace.getActiveSpan()?.spanContext();
183+
if (!ctx || !isSpanContextValid(ctx)) return undefined;
184+
return { traceId: ctx.traceId, spanId: ctx.spanId };
185+
},
186+
});
187+
}
188+
170189
const server = new PuterServer(
171190
config,
172191
puterClients,

src/backend/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -452,6 +452,14 @@ interface IConfigOptional {
452452
env: 'dev' | 'prod';
453453
/** Free-form name of the config profile (e.g. `oss-default`). Surfaced in logs. */
454454
config_name: string;
455+
/**
456+
* Console output format. `json` replaces the global console so every call
457+
* emits one structured JSON line (`level`, `timestamp`, `msg`, and the
458+
* active `traceId`) — one event per call, so a line-oriented log collector
459+
* can't split stack traces across events, and level filtering works. `text`
460+
* (the default) leaves console output human-readable for local/dev.
461+
*/
462+
log_format: 'json' | 'text';
455463
/** Server version. Falls back to `npm_package_version`. */
456464
version: string;
457465
/** Stable identity for this server node. Enables pager alerts + graceful shutdown delay. */
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
/**
2+
* Copyright (C) 2024-present Puter Technologies Inc.
3+
*
4+
* This file is part of Puter.
5+
*
6+
* Puter is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
import { afterEach, describe, expect, it, vi } from 'vitest';
21+
import { installJsonConsole } from './jsonConsole.js';
22+
23+
/**
24+
* Capture what the patched console writes to stdout/stderr. Returns the raw
25+
* chunks plus helpers to parse them, then uninstall() restores everything.
26+
*/
27+
const withInstalledConsole = (
28+
options?: Parameters<typeof installJsonConsole>[0],
29+
) => {
30+
const out: string[] = [];
31+
const err: string[] = [];
32+
const stdout = vi
33+
.spyOn(process.stdout, 'write')
34+
.mockImplementation((chunk: unknown) => {
35+
out.push(String(chunk));
36+
return true;
37+
});
38+
const stderr = vi
39+
.spyOn(process.stderr, 'write')
40+
.mockImplementation((chunk: unknown) => {
41+
err.push(String(chunk));
42+
return true;
43+
});
44+
const uninstall = installJsonConsole(options);
45+
return {
46+
out,
47+
err,
48+
uninstall: () => {
49+
uninstall();
50+
stdout.mockRestore();
51+
stderr.mockRestore();
52+
},
53+
};
54+
};
55+
56+
describe('installJsonConsole', () => {
57+
afterEach(() => {
58+
vi.restoreAllMocks();
59+
});
60+
61+
it('emits one JSON line per call with level, timestamp and msg', () => {
62+
const { out, uninstall } = withInstalledConsole();
63+
try {
64+
console.log('hello world');
65+
} finally {
66+
uninstall();
67+
}
68+
69+
expect(out).toHaveLength(1);
70+
expect(out[0].endsWith('\n')).toBe(true);
71+
const entry = JSON.parse(out[0]);
72+
expect(entry.level).toBe('info');
73+
expect(entry.msg).toBe('hello world');
74+
expect(() => new Date(entry.timestamp).toISOString()).not.toThrow();
75+
expect(entry.timestamp).toBe(new Date(entry.timestamp).toISOString());
76+
});
77+
78+
it('maps each console method to the expected level and stream', () => {
79+
const { out, err, uninstall } = withInstalledConsole();
80+
try {
81+
console.info('i');
82+
console.debug('d');
83+
console.warn('w');
84+
console.error('e');
85+
} finally {
86+
uninstall();
87+
}
88+
89+
expect(out.map((l) => JSON.parse(l).level)).toEqual(['info', 'debug']);
90+
expect(err.map((l) => JSON.parse(l).level)).toEqual(['warn', 'error']);
91+
});
92+
93+
it('formats non-string args like console does (objects preserved)', () => {
94+
const { out, uninstall } = withInstalledConsole();
95+
try {
96+
console.log('user', { id: 5, roles: ['a'] }, [1, 2]);
97+
} finally {
98+
uninstall();
99+
}
100+
101+
const entry = JSON.parse(out[0]);
102+
expect(entry.msg).toBe("user { id: 5, roles: [ 'a' ] } [ 1, 2 ]");
103+
});
104+
105+
it('collapses a multi-line stack trace into a single log event', () => {
106+
const { err, uninstall } = withInstalledConsole();
107+
try {
108+
console.error(new Error('boom'));
109+
} finally {
110+
uninstall();
111+
}
112+
113+
// Exactly one write, one trailing newline, no interior raw newlines
114+
// (the stack lives inside the JSON-escaped `msg` string).
115+
expect(err).toHaveLength(1);
116+
expect(err[0].match(/\n/g)).toHaveLength(1);
117+
const entry = JSON.parse(err[0]);
118+
expect(entry.level).toBe('error');
119+
expect(entry.msg).toContain('Error: boom');
120+
expect(entry.msg).toContain('\n at '); // stack frames survive in msg
121+
});
122+
123+
it('attaches traceId/spanId only when a span is active', () => {
124+
let ctx: { traceId: string; spanId?: string } | undefined;
125+
const { out, uninstall } = withInstalledConsole({
126+
getTraceContext: () => ctx,
127+
});
128+
try {
129+
console.log('no span');
130+
ctx = { traceId: 'abc123', spanId: 'def456' };
131+
console.log('with span');
132+
} finally {
133+
uninstall();
134+
}
135+
136+
const first = JSON.parse(out[0]);
137+
expect(first).not.toHaveProperty('traceId');
138+
const second = JSON.parse(out[1]);
139+
expect(second.traceId).toBe('abc123');
140+
expect(second.spanId).toBe('def456');
141+
});
142+
143+
it('restores the original console methods on uninstall', () => {
144+
const before = console.log;
145+
const { uninstall } = withInstalledConsole();
146+
expect(console.log).not.toBe(before);
147+
uninstall();
148+
expect(console.log).toBe(before);
149+
});
150+
151+
it('is idempotent — a second install is a no-op', () => {
152+
const { out, uninstall } = withInstalledConsole();
153+
const second = installJsonConsole();
154+
try {
155+
console.log('once');
156+
} finally {
157+
second();
158+
uninstall();
159+
}
160+
// Still a single JSON line, not double-patched.
161+
expect(out).toHaveLength(1);
162+
expect(JSON.parse(out[0]).msg).toBe('once');
163+
});
164+
});

src/backend/util/jsonConsole.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
/**
2+
* Copyright (C) 2024-present Puter Technologies Inc.
3+
*
4+
* This file is part of Puter.
5+
*
6+
* Puter is free software: you can redistribute it and/or modify
7+
* it under the terms of the GNU Affero General Public License as published
8+
* by the Free Software Foundation, either version 3 of the License, or
9+
* (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
* GNU Affero General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Affero General Public License
17+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
18+
*/
19+
20+
import { format } from 'node:util';
21+
22+
/**
23+
* Console severity methods we replace, mapped to the `level` value emitted for
24+
* each. `log` collapses to `info` so downstream `level = "info"` / `"error"`
25+
* filters behave conventionally.
26+
*/
27+
const METHOD_LEVELS = {
28+
log: 'info',
29+
info: 'info',
30+
warn: 'warn',
31+
error: 'error',
32+
debug: 'debug',
33+
} as const;
34+
35+
type ConsoleMethod = keyof typeof METHOD_LEVELS;
36+
37+
/** Identifiers for the currently-active trace, if any. */
38+
export interface TraceContext {
39+
traceId: string;
40+
spanId?: string;
41+
}
42+
43+
export interface JsonConsoleOptions {
44+
/**
45+
* Resolves the active trace context at log time, or `undefined` when no
46+
* recording span is active. Kept as a callback so this module carries no
47+
* telemetry dependency and stays trivially unit-testable.
48+
*/
49+
getTraceContext?: () => TraceContext | undefined;
50+
}
51+
52+
// Guard against double-installation across duplicate module instances.
53+
const INSTALLED_FLAG = '__puterJsonConsoleInstalled';
54+
55+
/**
56+
* Replace the global console severity methods so every call emits exactly one
57+
* line of JSON: `{ level, timestamp, msg, traceId?, spanId? }`. `msg` is
58+
* `util.format`ed from the call args — byte-for-byte what console would have
59+
* printed — so multi-line values (stack traces, inspected objects) become a
60+
* single log event instead of being split across many by a line-oriented log
61+
* collector.
62+
*
63+
* Returns a function that restores the original console methods.
64+
*/
65+
export const installJsonConsole = (
66+
options: JsonConsoleOptions = {},
67+
): (() => void) => {
68+
const globals = globalThis as Record<string, unknown>;
69+
if (globals[INSTALLED_FLAG]) return () => {};
70+
71+
const { getTraceContext } = options;
72+
const originals = {} as Record<ConsoleMethod, (...args: unknown[]) => void>;
73+
74+
for (const method of Object.keys(METHOD_LEVELS) as ConsoleMethod[]) {
75+
// Keep the exact reference so uninstall() restores it identically.
76+
const original = console[method] as (...args: unknown[]) => void;
77+
originals[method] = original;
78+
79+
const level = METHOD_LEVELS[method];
80+
// Match console's stream routing so stderr keeps carrying warnings and
81+
// errors even in JSON mode.
82+
const stream =
83+
method === 'warn' || method === 'error'
84+
? process.stderr
85+
: process.stdout;
86+
87+
console[method] = (...args: unknown[]): void => {
88+
try {
89+
const entry: Record<string, unknown> = {
90+
level,
91+
timestamp: new Date().toISOString(),
92+
msg: format(...args),
93+
};
94+
const trace = getTraceContext?.();
95+
if (trace?.traceId) {
96+
entry.traceId = trace.traceId;
97+
if (trace.spanId) entry.spanId = trace.spanId;
98+
}
99+
stream.write(`${JSON.stringify(entry)}\n`);
100+
} catch {
101+
// Logging must never take down the process — fall back to the
102+
// untouched console method if formatting/serialization throws.
103+
original.apply(console, args);
104+
}
105+
};
106+
}
107+
108+
globals[INSTALLED_FLAG] = true;
109+
110+
return () => {
111+
for (const method of Object.keys(originals) as ConsoleMethod[]) {
112+
console[method] = originals[method];
113+
}
114+
delete globals[INSTALLED_FLAG];
115+
};
116+
};

0 commit comments

Comments
 (0)