feat(cli,core): add opt-in dev-only telnet log streaming (#4110)
Stream dev logs over a local telnet/TCP socket. `trigger dev` mirrors its terminal output on port 6767 by default (override with --telnet-logs-port or TRIGGER_DEV_TELNET_LOGS_PORT, 0 disables). webapp, supervisor, and coordinator each expose an opt-in stream gated on a per-service *_TELNET_LOGS_PORT env var. New @trigger.dev/core/v3/telnetLogServer module (localhost-only, backpressure-safe, plain-text) plus optional static Logger.onLog / SimpleStructuredLogger.onLog sinks. Then you (or your agent) can use `nc` to connect and filter out the stream. <img width="1103" height="239" alt="image" src="https://github.com/user-attachments/assets/b4d47efc-8a57-4185-a159-10f2806627ae" />
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add a `@trigger.dev/core/v3/telnetLogServer` module: the shared `TelnetLogServer` (localhost-only, backpressure-safe), `formatLogLine`, and `stripAnsi` helpers, plus an optional static `Logger.onLog` / `SimpleStructuredLogger.onLog` sink used to fan structured logs out to a local dev-only telnet/TCP stream.
|
||||
@@ -8,6 +8,8 @@ DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres?schema=publi
|
||||
# See: https://www.prisma.io/docs/reference/api-reference/prisma-schema-reference#fields:~:text=the%20shadow%20database.-,directUrl,-No
|
||||
DIRECT_URL=${DATABASE_URL}
|
||||
REMIX_APP_PORT=3030
|
||||
# Dev-only: stream the webapp's logs over a local telnet/TCP socket (nc localhost 6767). Uncomment to enable.
|
||||
# WEBAPP_TELNET_LOGS_PORT=6767
|
||||
APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
ELECTRIC_ORIGIN=http://localhost:3060
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: supervisor
|
||||
type: feature
|
||||
---
|
||||
|
||||
Add an opt-in, dev-only telnet log stream: set `SUPERVISOR_TELNET_LOGS_PORT` (e.g. 6769) to tail this process's logs as plain text over a local TCP socket (`nc localhost 6769`). Bound to localhost; off unless the port is set.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
area: webapp
|
||||
type: feature
|
||||
---
|
||||
|
||||
Add an opt-in, dev-only telnet log stream: set `WEBAPP_TELNET_LOGS_PORT` (e.g. 6767) to tail this process's logs as plain text over a local TCP socket (`nc localhost 6767`). Bound to localhost; off unless the port is set.
|
||||
@@ -14,4 +14,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:3030/otel
|
||||
|
||||
# Optional settings
|
||||
DEBUG=1
|
||||
TRIGGER_DEQUEUE_INTERVAL_MS=1000
|
||||
TRIGGER_DEQUEUE_INTERVAL_MS=1000
|
||||
|
||||
# Dev-only: stream this process's logs over a local telnet/TCP socket (nc localhost 6769). Uncomment to enable.
|
||||
# SUPERVISOR_TELNET_LOGS_PORT=6769
|
||||
|
||||
@@ -9,6 +9,9 @@ export const Env = z
|
||||
TRIGGER_WORKER_INSTANCE_NAME: z.string().default(randomUUID()),
|
||||
TRIGGER_WORKER_HEARTBEAT_INTERVAL_SECONDS: z.coerce.number().default(30),
|
||||
|
||||
// Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port.
|
||||
SUPERVISOR_TELNET_LOGS_PORT: z.coerce.number().optional(),
|
||||
|
||||
// Required settings
|
||||
TRIGGER_API_URL: z.string().url(),
|
||||
TRIGGER_WORKER_TOKEN: z.string(), // accepts file:// path to read from a file
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SupervisorSession } from "@trigger.dev/core/v3/workers";
|
||||
import { SimpleStructuredLogger } from "@trigger.dev/core/v3/utils/structuredLogger";
|
||||
import { formatLogLine, startTelnetLogServer } from "@trigger.dev/core/v3/telnetLogServer";
|
||||
import { env } from "./env.js";
|
||||
import { WorkloadServer } from "./workloadServer/index.js";
|
||||
import type { WorkloadManagerOptions, WorkloadManager } from "./workloadManager/types.js";
|
||||
@@ -749,5 +750,14 @@ class ManagedSupervisor {
|
||||
}
|
||||
}
|
||||
|
||||
// Opt-in, dev-only: mirror this process's structured logs to a local telnet/TCP stream.
|
||||
if (env.SUPERVISOR_TELNET_LOGS_PORT && env.SUPERVISOR_TELNET_LOGS_PORT > 0) {
|
||||
const telnetLogServer = startTelnetLogServer({
|
||||
port: env.SUPERVISOR_TELNET_LOGS_PORT,
|
||||
name: "supervisor",
|
||||
});
|
||||
SimpleStructuredLogger.onLog = (log) => telnetLogServer.broadcast(formatLogLine(log));
|
||||
}
|
||||
|
||||
const worker = new ManagedSupervisor();
|
||||
worker.start();
|
||||
|
||||
@@ -139,6 +139,9 @@ const EnvironmentSchema = z
|
||||
.optional(),
|
||||
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
|
||||
REMIX_APP_PORT: z.string().optional(),
|
||||
// Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port.
|
||||
// Read directly from process.env in server.ts (before this schema loads); declared here for discoverability.
|
||||
WEBAPP_TELNET_LOGS_PORT: z.coerce.number().optional(),
|
||||
LOGIN_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
LOGIN_RATE_LIMITS_ENABLED: BoolEnv.default(true),
|
||||
APP_ORIGIN: z.string().default("http://localhost:3030"),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { LogLevel } from "@trigger.dev/core/logger";
|
||||
import { Logger } from "@trigger.dev/core/logger";
|
||||
import { patchConsoleToTelnet, startTelnetLogServer } from "@trigger.dev/core/v3/telnetLogServer";
|
||||
import { sensitiveDataReplacer } from "./sensitiveDataReplacer";
|
||||
import { AsyncLocalStorage } from "async_hooks";
|
||||
import { getHttpContext } from "./httpAsyncStorage.server";
|
||||
@@ -79,3 +80,19 @@ export const socketLogger = new Logger(
|
||||
return fields ? { ...fields } : {};
|
||||
}
|
||||
);
|
||||
|
||||
// Opt-in, dev-only: mirror this process's stdout to a local telnet/TCP stream.
|
||||
// We patch console (rather than the static Logger.onLog sink) so the stream also captures logs
|
||||
// from separate/bundled copies of the Logger — e.g. the enterprise SSO plugin, which bundles its
|
||||
// own @trigger.dev/core and logs via its own console.log, invisible to the webapp's onLog hook.
|
||||
const telnetLogsPort = process.env.WEBAPP_TELNET_LOGS_PORT
|
||||
? Number(process.env.WEBAPP_TELNET_LOGS_PORT)
|
||||
: undefined;
|
||||
if (telnetLogsPort && Number.isFinite(telnetLogsPort) && telnetLogsPort > 0) {
|
||||
const telnetGlobal = globalThis as typeof globalThis & { __webappTelnetLogs?: boolean };
|
||||
if (!telnetGlobal.__webappTelnetLogs) {
|
||||
telnetGlobal.__webappTelnetLogs = true;
|
||||
const telnetLogServer = startTelnetLogServer({ port: telnetLogsPort, name: "webapp" });
|
||||
patchConsoleToTelnet(telnetLogServer, { pretty: true });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
"./v3/utils/omit": "./src/v3/utils/omit.ts",
|
||||
"./v3/utils/retries": "./src/v3/utils/retries.ts",
|
||||
"./v3/utils/structuredLogger": "./src/v3/utils/structuredLogger.ts",
|
||||
"./v3/telnetLogServer": "./src/v3/telnetLogServer.ts",
|
||||
"./v3/test": "./src/v3/test/index.ts",
|
||||
"./v3/zodfetch": "./src/v3/zodfetch.ts",
|
||||
"./v3/zodMessageHandler": "./src/v3/zodMessageHandler.ts",
|
||||
@@ -124,6 +125,9 @@
|
||||
"v3/utils/structuredLogger": [
|
||||
"dist/commonjs/v3/utils/structuredLogger.d.ts"
|
||||
],
|
||||
"v3/telnetLogServer": [
|
||||
"dist/commonjs/v3/telnetLogServer.d.ts"
|
||||
],
|
||||
"v3/zodfetch": [
|
||||
"dist/commonjs/v3/zodfetch.d.ts"
|
||||
],
|
||||
@@ -490,6 +494,17 @@
|
||||
"default": "./dist/commonjs/v3/utils/structuredLogger.js"
|
||||
}
|
||||
},
|
||||
"./v3/telnetLogServer": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/telnetLogServer.ts",
|
||||
"types": "./dist/esm/v3/telnetLogServer.d.ts",
|
||||
"default": "./dist/esm/v3/telnetLogServer.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/commonjs/v3/telnetLogServer.d.ts",
|
||||
"default": "./dist/commonjs/v3/telnetLogServer.js"
|
||||
}
|
||||
},
|
||||
"./v3/test": {
|
||||
"import": {
|
||||
"@triggerdotdev/source": "./src/v3/test/index.ts",
|
||||
|
||||
@@ -26,6 +26,10 @@ export class Logger {
|
||||
// Add a static "onError" method that will be called when an error is logged
|
||||
static onError: (message: string, ...args: Array<Record<string, unknown> | undefined>) => void;
|
||||
|
||||
// Optional static sink called with the fully-structured log for every emitted line.
|
||||
// Used (e.g.) to fan logs out to a dev-only telnet stream. Must not re-enter the Logger.
|
||||
static onLog?: (structuredLog: Record<string, unknown>) => void;
|
||||
|
||||
constructor(
|
||||
name: string,
|
||||
level: LogLevel = "info",
|
||||
@@ -134,6 +138,14 @@ export class Logger {
|
||||
structuredLog.skipForwarding = true;
|
||||
}
|
||||
|
||||
if (Logger.onLog) {
|
||||
try {
|
||||
Logger.onLog(structuredLog);
|
||||
} catch {
|
||||
// A sink must never break logging — and must never re-enter the Logger.
|
||||
}
|
||||
}
|
||||
|
||||
loggerFunction(JSON.stringify(structuredLog, this.#jsonReplacer));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import net from "node:net";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { formatConsoleLine, formatLogLine, stripAnsi, TelnetLogServer } from "./telnetLogServer.js";
|
||||
|
||||
const servers: TelnetLogServer[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
while (servers.length) {
|
||||
servers.pop()?.close();
|
||||
}
|
||||
});
|
||||
|
||||
/** Grab a port the OS just told us is free, then hand it to a TelnetLogServer. */
|
||||
async function startServerOnFreePort(
|
||||
name = "test"
|
||||
): Promise<{ server: TelnetLogServer; port: number }> {
|
||||
const probe = net.createServer();
|
||||
const port = await listening(probe, 0);
|
||||
await new Promise<void>((resolve) => probe.close(() => resolve()));
|
||||
|
||||
// A banner lets clients deterministically wait until the server has registered them
|
||||
// (it's written in the connection handler), removing the connect/register race.
|
||||
const server = new TelnetLogServer({ port, name, banner: "ready" });
|
||||
servers.push(server);
|
||||
server.start();
|
||||
await delay(30);
|
||||
return { server, port };
|
||||
}
|
||||
|
||||
/** Connects and resolves only once the first bytes (the banner) arrive — server-side socket is registered by then. */
|
||||
function connectAndCollect(port: number): Promise<{ socket: net.Socket; lines: () => string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
const socket = net.connect(port, "127.0.0.1");
|
||||
socket.setEncoding("utf8");
|
||||
socket.on("data", (chunk) => {
|
||||
const first = buffer === "";
|
||||
buffer += chunk;
|
||||
if (first) {
|
||||
resolve({ socket, lines: () => buffer });
|
||||
}
|
||||
});
|
||||
socket.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function listening(server: net.Server, port: number, host = "127.0.0.1"): Promise<number> {
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, host, () => {
|
||||
const address = server.address();
|
||||
resolve(typeof address === "object" && address ? address.port : port);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("stripAnsi", () => {
|
||||
test("removes color escape codes", () => {
|
||||
const colored = "[90m2026-06-11[39m [31mERROR[39m boom";
|
||||
expect(stripAnsi(colored)).toBe("2026-06-11 ERROR boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatLogLine", () => {
|
||||
test("formats a core Logger-shaped object (level/name)", () => {
|
||||
const line = formatLogLine({
|
||||
timestamp: new Date("2026-06-11T12:00:00.000Z"),
|
||||
name: "webapp",
|
||||
level: "info",
|
||||
message: "queue drained",
|
||||
count: 3,
|
||||
});
|
||||
expect(line).toBe("2026-06-11T12:00:00.000Z INFO [webapp] queue drained {count=3}");
|
||||
});
|
||||
|
||||
test("formats a SimpleStructuredLogger-shaped object ($level/$name)", () => {
|
||||
const line = formatLogLine({
|
||||
timestamp: new Date("2026-06-11T12:00:00.000Z"),
|
||||
$name: "supervisor",
|
||||
$level: "warn",
|
||||
message: "retrying",
|
||||
attempt: 2,
|
||||
});
|
||||
expect(line).toBe("2026-06-11T12:00:00.000Z WARN [supervisor] retrying {attempt=2}");
|
||||
});
|
||||
|
||||
test("omits empty name and extras", () => {
|
||||
const line = formatLogLine({
|
||||
timestamp: "2026-06-11T12:00:00.000Z",
|
||||
level: "log",
|
||||
message: "hello",
|
||||
});
|
||||
expect(line).toBe("2026-06-11T12:00:00.000Z LOG hello");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatConsoleLine", () => {
|
||||
test("pretty-formats a JSON structured-log line", () => {
|
||||
const raw = JSON.stringify({
|
||||
timestamp: "2026-06-11T12:00:00.000Z",
|
||||
name: "sso-plugin",
|
||||
level: "info",
|
||||
message: "sso.webhook.connection.activated: connection marked active",
|
||||
connId: "conn_123",
|
||||
});
|
||||
expect(formatConsoleLine(raw)).toBe(
|
||||
"2026-06-11T12:00:00.000Z INFO [sso-plugin] sso.webhook.connection.activated: connection marked active {connId=conn_123}"
|
||||
);
|
||||
});
|
||||
|
||||
test("passes non-JSON console output through unchanged", () => {
|
||||
expect(formatConsoleLine("GET /healthcheck 200 1.2 ms")).toBe("GET /healthcheck 200 1.2 ms");
|
||||
});
|
||||
|
||||
test("passes JSON that isn't a structured log through unchanged", () => {
|
||||
const raw = JSON.stringify({ foo: "bar" });
|
||||
expect(formatConsoleLine(raw)).toBe(raw);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TelnetLogServer", () => {
|
||||
test("broadcasts a line to a connected client", async () => {
|
||||
const { server, port } = await startServerOnFreePort();
|
||||
const client = await connectAndCollect(port);
|
||||
server.broadcast("first line");
|
||||
await delay(50);
|
||||
expect(client.lines()).toContain("first line\r\n");
|
||||
client.socket.destroy();
|
||||
});
|
||||
|
||||
test("close() ends connected sockets", async () => {
|
||||
const { server, port } = await startServerOnFreePort();
|
||||
const client = await connectAndCollect(port);
|
||||
server.broadcast("alive");
|
||||
await delay(30);
|
||||
expect(client.lines()).toContain("alive\r\n");
|
||||
|
||||
let closed = false;
|
||||
client.socket.on("close", () => {
|
||||
closed = true;
|
||||
});
|
||||
server.close();
|
||||
await delay(30);
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
|
||||
test("EADDRINUSE does not throw", async () => {
|
||||
const blocker = net.createServer();
|
||||
const port = await listening(blocker, 0);
|
||||
|
||||
const server = new TelnetLogServer({ port, name: "test" });
|
||||
servers.push(server);
|
||||
// Must not throw even though the port is taken.
|
||||
expect(() => server.start()).not.toThrow();
|
||||
await delay(30);
|
||||
|
||||
blocker.close();
|
||||
});
|
||||
});
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
// Node-only. Streams log lines to connected raw-TCP ("telnet") clients for local development.
|
||||
// Never import this from isomorphic/browser code — it pulls in node:net.
|
||||
|
||||
import net from "node:net";
|
||||
import { format } from "node:util";
|
||||
|
||||
/**
|
||||
* Per-socket buffer cap. If a client isn't reading fast enough and its outgoing
|
||||
* buffer grows past this, we drop lines for that client rather than buffering
|
||||
* unbounded in the host process. Lossy for the lagging client only.
|
||||
*/
|
||||
const MAX_SOCKET_BUFFER_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
// Matches ANSI escape sequences (colors, cursor moves, etc.) so the stream is plain text.
|
||||
// Built via RegExp to keep literal control characters out of the source. This is the
|
||||
// ansi-regex@6 pattern (post CVE-2021-3807): the alternation is de-nested so a run of
|
||||
// unterminated separators (e.g. `ESC[;;;;…`) can't trigger quadratic backtracking.
|
||||
const ST = "(?:\\u0007|\\u001B\\u005C|\\u009C)";
|
||||
const ANSI_PATTERN = new RegExp(
|
||||
[
|
||||
"[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?" +
|
||||
ST +
|
||||
")",
|
||||
"(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-ntqry=><~]))",
|
||||
].join("|"),
|
||||
"g"
|
||||
);
|
||||
|
||||
export function stripAnsi(input: string): string {
|
||||
return input.replace(ANSI_PATTERN, "");
|
||||
}
|
||||
|
||||
export type TelnetLogServerOptions = {
|
||||
/** TCP port to listen on. */
|
||||
port: number;
|
||||
/** Defaults to 127.0.0.1 — never bind a public interface for an unauthenticated stream. */
|
||||
host?: string;
|
||||
/** Short label used in startup/error messages, e.g. "webapp". */
|
||||
name: string;
|
||||
/** Optional one-line greeting written to each client on connect. */
|
||||
banner?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A tiny write-only TCP server that fans out log lines to every connected client.
|
||||
* Robust by design: a bind failure or a slow client never crashes the host process.
|
||||
*/
|
||||
export class TelnetLogServer {
|
||||
readonly name: string;
|
||||
readonly port: number;
|
||||
readonly host: string;
|
||||
|
||||
#server: net.Server;
|
||||
#sockets = new Set<net.Socket>();
|
||||
#banner?: string;
|
||||
|
||||
constructor(options: TelnetLogServerOptions) {
|
||||
this.name = options.name;
|
||||
this.port = options.port;
|
||||
this.host = options.host ?? "127.0.0.1";
|
||||
this.#banner = options.banner;
|
||||
|
||||
this.#server = net.createServer((socket) => this.#handleConnection(socket));
|
||||
this.#server.on("error", (err) => this.#handleServerError(err as NodeJS.ErrnoException));
|
||||
}
|
||||
|
||||
/** Begin listening. Returns `this`. Bind failures are swallowed (logged, not thrown). */
|
||||
start(): this {
|
||||
this.#server.listen(this.port, this.host, () => {
|
||||
process.stdout.write(`[telnet-logs] ${this.name} streaming on ${this.host}:${this.port}\n`);
|
||||
});
|
||||
// A dev-only side-channel must never keep the host process alive on its own.
|
||||
this.#server.unref();
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Write one line to every healthy client. Lagging clients (over the buffer cap) are skipped. */
|
||||
broadcast(line: string): void {
|
||||
if (this.#sockets.size === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = line.replace(/\r?\n$/, "") + "\r\n";
|
||||
|
||||
for (const socket of this.#sockets) {
|
||||
if (socket.destroyed) {
|
||||
this.#sockets.delete(socket);
|
||||
continue;
|
||||
}
|
||||
if (socket.writableLength > MAX_SOCKET_BUFFER_BYTES) {
|
||||
// Lagging client — drop this line rather than buffer unbounded.
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
socket.write(data);
|
||||
} catch {
|
||||
// The "error"/"close" handlers will remove it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
for (const socket of this.#sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
this.#sockets.clear();
|
||||
this.#server.close();
|
||||
}
|
||||
|
||||
#handleConnection(socket: net.Socket): void {
|
||||
socket.setNoDelay(true);
|
||||
// Like the server, a connected client must never hold the host process open.
|
||||
socket.unref();
|
||||
this.#sockets.add(socket);
|
||||
|
||||
// Write-only: ignore all inbound bytes (telnet clients send IAC negotiation).
|
||||
socket.on("data", () => {});
|
||||
socket.on("close", () => this.#sockets.delete(socket));
|
||||
socket.on("error", () => {
|
||||
this.#sockets.delete(socket);
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
if (this.#banner) {
|
||||
try {
|
||||
socket.write(this.#banner.replace(/\r?\n$/, "") + "\r\n");
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#handleServerError(err: NodeJS.ErrnoException): void {
|
||||
// Never crash the host process over a logging side-channel.
|
||||
if (err.code === "EADDRINUSE") {
|
||||
process.stderr.write(
|
||||
`[telnet-logs] ${this.name} disabled: port ${this.host}:${this.port} in use\n`
|
||||
);
|
||||
} else {
|
||||
process.stderr.write(`[telnet-logs] ${this.name} server error: ${err.message}\n`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startTelnetLogServer(options: TelnetLogServerOptions): TelnetLogServer {
|
||||
return new TelnetLogServer(options).start();
|
||||
}
|
||||
|
||||
const RESERVED_LOG_KEYS = new Set([
|
||||
"timestamp",
|
||||
"level",
|
||||
"$level",
|
||||
"name",
|
||||
"$name",
|
||||
"message",
|
||||
"$message",
|
||||
"skipForwarding",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format a structured log object (from either `Logger` or `SimpleStructuredLogger`) into a
|
||||
* single plain-text line. Normalizes the two shapes (`level`/`$level`, `name`/`$name`).
|
||||
*/
|
||||
export function formatLogLine(log: Record<string, unknown>): string {
|
||||
const ts = log.timestamp;
|
||||
const timestamp =
|
||||
ts instanceof Date ? ts.toISOString() : typeof ts === "string" ? ts : new Date().toISOString();
|
||||
|
||||
const level = String(log.level ?? log.$level ?? "log")
|
||||
.toUpperCase()
|
||||
.padEnd(5);
|
||||
const name = log.name ?? log.$name;
|
||||
const message = typeof log.message === "string" ? log.message : "";
|
||||
|
||||
const extras: string[] = [];
|
||||
for (const [key, value] of Object.entries(log)) {
|
||||
if (RESERVED_LOG_KEYS.has(key) || value === undefined) {
|
||||
continue;
|
||||
}
|
||||
extras.push(`${key}=${formatValue(value)}`);
|
||||
}
|
||||
|
||||
const namePart = name ? ` [${String(name)}]` : "";
|
||||
const extraPart = extras.length ? ` {${extras.join(", ")}}` : "";
|
||||
return `${timestamp} ${level}${namePart} ${message}${extraPart}`;
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null) return "null";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a single console line, pretty-format it if it's a JSON structured log (as emitted by
|
||||
* `Logger`/`SimpleStructuredLogger`, including bundled copies in plugins). Otherwise returns it
|
||||
* unchanged. Lets a console tap surface structured logs as readable lines while passing plain
|
||||
* `console.log` output through verbatim.
|
||||
*/
|
||||
export function formatConsoleLine(line: string): string {
|
||||
const trimmed = line.trimStart();
|
||||
if (!trimmed.startsWith("{")) {
|
||||
return line;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(trimmed);
|
||||
} catch {
|
||||
return line;
|
||||
}
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
Array.isArray(parsed) ||
|
||||
typeof (parsed as Record<string, unknown>).message !== "string" ||
|
||||
((parsed as Record<string, unknown>).level === undefined &&
|
||||
(parsed as Record<string, unknown>).$level === undefined)
|
||||
) {
|
||||
return line;
|
||||
}
|
||||
return formatLogLine(parsed as Record<string, unknown>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror `console.*` output to a telnet server. Use this (rather than the `Logger.onLog` sink)
|
||||
* when you need to capture EVERYTHING on stdout — including logs from a separate/bundled copy of
|
||||
* the logger (e.g. a plugin), which the static `onLog` hook can't see. With `pretty` (default),
|
||||
* JSON structured-log lines are reformatted via `formatConsoleLine`; other output passes through.
|
||||
* Returns a restore function.
|
||||
*/
|
||||
export function patchConsoleToTelnet(
|
||||
server: TelnetLogServer,
|
||||
options?: { pretty?: boolean }
|
||||
): () => void {
|
||||
const pretty = options?.pretty ?? true;
|
||||
const methods = ["log", "info", "warn", "error", "debug"] as const;
|
||||
const originals = {} as Record<(typeof methods)[number], (...args: unknown[]) => void>;
|
||||
|
||||
for (const method of methods) {
|
||||
originals[method] = console[method].bind(console) as (...args: unknown[]) => void;
|
||||
console[method] = (...args: unknown[]) => {
|
||||
originals[method](...args);
|
||||
try {
|
||||
const line = format(...args);
|
||||
server.broadcast(stripAnsi(pretty ? formatConsoleLine(line) : line));
|
||||
} catch {
|
||||
// never let the mirror break console
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const method of methods) {
|
||||
console[method] = originals[method] as never;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -19,6 +19,10 @@ export enum LogLevel {
|
||||
}
|
||||
|
||||
export class SimpleStructuredLogger implements StructuredLogger {
|
||||
// Optional static sink called with the fully-structured log for every emitted line.
|
||||
// Used (e.g.) to fan logs out to a dev-only telnet stream. Must not re-enter the logger.
|
||||
static onLog?: (structuredLog: Record<string, unknown>) => void;
|
||||
|
||||
private prettyPrint = ["1", "true"].includes(process.env.PRETTY_LOGS ?? "");
|
||||
|
||||
constructor(
|
||||
@@ -93,6 +97,14 @@ export class SimpleStructuredLogger implements StructuredLogger {
|
||||
...(args.length === 1 ? args[0] : args),
|
||||
};
|
||||
|
||||
if (SimpleStructuredLogger.onLog) {
|
||||
try {
|
||||
SimpleStructuredLogger.onLog(structuredLog);
|
||||
} catch {
|
||||
// A sink must never break logging — and must never re-enter the logger.
|
||||
}
|
||||
}
|
||||
|
||||
if (this.prettyPrint) {
|
||||
loggerFunction(JSON.stringify(structuredLog, null, 2));
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user