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:
Oskar Otwinowski
2026-07-02 21:16:14 +02:00
committed by GitHub
parent 22a7e33df7
commit c7f6ed501c
14 changed files with 519 additions and 1 deletions
+5
View File
@@ -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.
+2
View File
@@ -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.
+6
View File
@@ -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.
+4 -1
View File
@@ -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
+3
View File
@@ -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
+10
View 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();
+3
View File
@@ -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"),
+17
View File
@@ -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 });
}
}
+15
View File
@@ -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",
+12
View File
@@ -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 = "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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
+262
View File
@@ -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 {