From 9b04c42ac813e4bcf52097859179334c1e3dd09d Mon Sep 17 00:00:00 2001 From: Oskar Otwinowski Date: Thu, 11 Jun 2026 19:45:27 +0200 Subject: [PATCH] feat(cli,core): add opt-in dev-only telnet log streaming Stream dev logs over a local telnet/TCP socket. `trigger dev` mirrors its terminal output on port 6700 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. --- .changeset/telnet-dev-logs.md | 5 + .env.example | 2 + .server-changes/supervisor-telnet-logs.md | 6 + .server-changes/webapp-telnet-logs.md | 6 + apps/supervisor/.env.example | 5 +- apps/supervisor/src/env.ts | 3 + apps/supervisor/src/index.ts | 10 + apps/webapp/app/env.server.ts | 3 + apps/webapp/app/services/logger.server.ts | 17 ++ packages/core/package.json | 15 + packages/core/src/logger.ts | 12 + packages/core/src/v3/telnetLogServer.test.ts | 162 +++++++++++ packages/core/src/v3/telnetLogServer.ts | 262 ++++++++++++++++++ .../core/src/v3/utils/structuredLogger.ts | 12 + 14 files changed, 519 insertions(+), 1 deletion(-) create mode 100644 .changeset/telnet-dev-logs.md create mode 100644 .server-changes/supervisor-telnet-logs.md create mode 100644 .server-changes/webapp-telnet-logs.md create mode 100644 packages/core/src/v3/telnetLogServer.test.ts create mode 100644 packages/core/src/v3/telnetLogServer.ts diff --git a/.changeset/telnet-dev-logs.md b/.changeset/telnet-dev-logs.md new file mode 100644 index 00000000000..bf206d4dfd3 --- /dev/null +++ b/.changeset/telnet-dev-logs.md @@ -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. diff --git a/.env.example b/.env.example index c6980d7d77a..4505cb7933c 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.server-changes/supervisor-telnet-logs.md b/.server-changes/supervisor-telnet-logs.md new file mode 100644 index 00000000000..6a0fef28c41 --- /dev/null +++ b/.server-changes/supervisor-telnet-logs.md @@ -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. diff --git a/.server-changes/webapp-telnet-logs.md b/.server-changes/webapp-telnet-logs.md new file mode 100644 index 00000000000..7c6917dd7ed --- /dev/null +++ b/.server-changes/webapp-telnet-logs.md @@ -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. diff --git a/apps/supervisor/.env.example b/apps/supervisor/.env.example index 5cb86d5a331..4355a4eea30 100644 --- a/apps/supervisor/.env.example +++ b/apps/supervisor/.env.example @@ -14,4 +14,7 @@ OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:3030/otel # Optional settings DEBUG=1 -TRIGGER_DEQUEUE_INTERVAL_MS=1000 \ No newline at end of file +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 diff --git a/apps/supervisor/src/env.ts b/apps/supervisor/src/env.ts index 2cb1bfb9a74..32e669b5914 100644 --- a/apps/supervisor/src/env.ts +++ b/apps/supervisor/src/env.ts @@ -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 diff --git a/apps/supervisor/src/index.ts b/apps/supervisor/src/index.ts index 833448ad873..aeda154c355 100644 --- a/apps/supervisor/src/index.ts +++ b/apps/supervisor/src/index.ts @@ -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(); diff --git a/apps/webapp/app/env.server.ts b/apps/webapp/app/env.server.ts index 7c57733ad8f..21d42c9426c 100644 --- a/apps/webapp/app/env.server.ts +++ b/apps/webapp/app/env.server.ts @@ -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"), diff --git a/apps/webapp/app/services/logger.server.ts b/apps/webapp/app/services/logger.server.ts index e93cd138704..15b248f0d94 100644 --- a/apps/webapp/app/services/logger.server.ts +++ b/apps/webapp/app/services/logger.server.ts @@ -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 }); + } +} diff --git a/packages/core/package.json b/packages/core/package.json index 9c0a0051581..e82d2ae071f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts index f9a1de15300..18ef4634a91 100644 --- a/packages/core/src/logger.ts +++ b/packages/core/src/logger.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 | 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) => 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)); } } diff --git a/packages/core/src/v3/telnetLogServer.test.ts b/packages/core/src/v3/telnetLogServer.test.ts new file mode 100644 index 00000000000..cb86879cd48 --- /dev/null +++ b/packages/core/src/v3/telnetLogServer.test.ts @@ -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((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 { + 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 = "2026-06-11 ERROR 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 { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/packages/core/src/v3/telnetLogServer.ts b/packages/core/src/v3/telnetLogServer.ts new file mode 100644 index 00000000000..86564c19e02 --- /dev/null +++ b/packages/core/src/v3/telnetLogServer.ts @@ -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(); + #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 { + 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).message !== "string" || + ((parsed as Record).level === undefined && + (parsed as Record).$level === undefined) + ) { + return line; + } + return formatLogLine(parsed as Record); +} + +/** + * 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; + } + }; +} diff --git a/packages/core/src/v3/utils/structuredLogger.ts b/packages/core/src/v3/utils/structuredLogger.ts index 97049a9090a..c85d92a8609 100644 --- a/packages/core/src/v3/utils/structuredLogger.ts +++ b/packages/core/src/v3/utils/structuredLogger.ts @@ -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) => 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 {