From 19848899d00de15c3af183e39649e8de3c7b7388 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sat, 7 Nov 2026 21:19:18 +0000 Subject: [PATCH] Keep host identity and error details in desktop logs (#9038) * fix(app): preserve host error details in desktop logs Bind host context through the existing client logger and emit structured text so desktop console capture retains the error record, including for adopted probe clients. * fix(app): retain discovered host identity and nested error causes --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/app/src/runtime/host-logger.test.ts | 72 +++++++++++++++++++ packages/app/src/runtime/host-logger.ts | 36 ++++++++++ packages/app/src/runtime/host-runtime.ts | 3 + .../src/utils/test-daemon-connection.test.ts | 40 +++++++++++ .../app/src/utils/test-daemon-connection.ts | 9 ++- 5 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 packages/app/src/runtime/host-logger.test.ts create mode 100644 packages/app/src/runtime/host-logger.ts diff --git a/packages/app/src/runtime/host-logger.test.ts b/packages/app/src/runtime/host-logger.test.ts new file mode 100644 index 0000000000..fa53ca7668 --- /dev/null +++ b/packages/app/src/runtime/host-logger.test.ts @@ -0,0 +1,72 @@ +import { expect, it } from "vitest"; +import { createHostLogger } from "./host-logger"; + +it("preserves the original transport cause and tolerates a cyclic cause chain", () => { + const lines: string[] = []; + const capture = (...values: unknown[]) => lines.push(values.map(String).join(" ")); + const logger = createHostLogger(() => "test-host", { + log: capture, + warn: capture, + error: capture, + }); + const transport = new Error("socket closed"); + const wrapper = new Error("Subscription failed", { cause: transport }); + Object.defineProperty(transport, "cause", { value: wrapper }); + logger.error({ err: wrapper }, "subscription_error"); + const record = JSON.parse(lines[0]!.slice("subscription_error ".length)); + expect(record.err.cause.message).toBe("socket closed"); + expect(record.err.cause.stack).toBe(transport.stack); + expect(record.err.cause.cause).toBe("[Circular]"); +}); + +it("retains host and error details in the desktop's text-only console capture", () => { + const lines: { level: string; text: string }[] = []; + const capture = + (level: string) => + (...values: unknown[]) => { + lines.push({ level, text: values.map(String).join(" ") }); + }; + const logger = createHostLogger(() => "offline-host", { + log: capture("info"), + warn: capture("warn"), + error: capture("error"), + }); + logger.warn( + { err: { name: "Error", message: "malformed ready envelope" } }, + "relay_e2ee_channel_error", + ); + expect(lines).toEqual([ + { + level: "warn", + text: 'relay_e2ee_channel_error {"serverId":"offline-host","err":{"name":"Error","message":"malformed ready envelope"}}', + }, + ]); +}); + +it("keeps native Error messages and severity without failing on circular diagnostics", () => { + const lines: { level: string; text: string }[] = []; + const capture = + (level: string) => + (...values: unknown[]) => { + lines.push({ level, text: values.map(String).join(" ") }); + }; + const logger = createHostLogger(() => "test-host", { + log: capture("info"), + warn: capture("warn"), + error: capture("error"), + }); + const record: { err: Error; self?: object } = { err: new Error("connection lost") }; + record.self = record; + logger.error(record, "Connection failed"); + expect(lines[0]?.level).toBe("error"); + expect(lines[0]?.text).toContain('"message":"connection lost"'); + expect(lines[0]?.text).toContain('"serverId":"test-host"'); + expect(lines[0]?.text).toContain('"[Circular]"'); + logger.info({ status: "offline" }, "Host status"); + expect(lines[1]).toEqual({ + level: "info", + text: 'Host status {"serverId":"test-host","status":"offline"}', + }); + logger.debug({ status: "offline" }, "Host status"); + expect(lines).toHaveLength(2); +}); diff --git a/packages/app/src/runtime/host-logger.ts b/packages/app/src/runtime/host-logger.ts new file mode 100644 index 0000000000..3950038600 --- /dev/null +++ b/packages/app/src/runtime/host-logger.ts @@ -0,0 +1,36 @@ +import type { Logger } from "@getpaseo/client/internal/daemon-client"; + +export function createHostLogger( + readServerId: () => string | undefined, + output: Pick = console, +): Logger { + const format = (record: object, message?: string): string => { + const seen = new WeakSet(); + const details = JSON.stringify( + { serverId: readServerId(), ...record }, + (_key, value: unknown) => { + if (value && typeof value === "object") { + if (seen.has(value)) return "[Circular]"; + seen.add(value); + if (value instanceof Error) { + return { + ...value, + name: value.name, + message: value.message, + stack: value.stack, + cause: value.cause, + }; + } + } + return value; + }, + ); + return message ? `${message} ${details}` : details; + }; + return { + debug: () => {}, + info: (record, message) => output.log(format(record, message)), + warn: (record, message) => output.warn(format(record, message)), + error: (record, message) => output.error(format(record, message)), + }; +} diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index b6d10dc8c7..394c61d4bf 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -1,5 +1,6 @@ import { createHostPluginClientBundleStore } from "@/plugins/client-bundles"; import { watchdog } from "@/watchdog"; +import { createHostLogger } from "./host-logger"; import { createHostChannelTransport } from "@getpaseo/client/internal/hosts"; import type { OwnedSubscription } from "@getpaseo/client"; import type { HostDirectory } from "@getpaseo/protocol/hosts"; @@ -589,6 +590,7 @@ function createDefaultDeps( runtimeGeneration, capabilities: appCapabilities, trace: createPerformanceTrace({ host: host.serverId, connection: connection.id }), + logger: createHostLogger(() => host.serverId), providerSnapshots: "wire", createPluginClientBundleStore: createHostPluginClientBundleStore, ...(host.password ? { password: host.password } : {}), @@ -669,6 +671,7 @@ function createDefaultDeps( null, ), reconnect: { enabled: false }, + logger: createHostLogger(() => host.serverId), appVersion: resolveAppVersion() ?? undefined, createPluginClientBundleStore: createHostPluginClientBundleStore, }); diff --git a/packages/app/src/utils/test-daemon-connection.test.ts b/packages/app/src/utils/test-daemon-connection.test.ts index 29382b5c89..88f36c6d0f 100644 --- a/packages/app/src/utils/test-daemon-connection.test.ts +++ b/packages/app/src/utils/test-daemon-connection.test.ts @@ -2,9 +2,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client"; import type { DaemonConnectionDependencies, DaemonProbeClient } from "./test-daemon-connection"; +import { createHostLogger } from "@/runtime/host-logger"; class FakeDaemonClient implements DaemonProbeClient { readonly lastError: string | null; + private connected = false; constructor( private readonly probe: FakeDaemonProbe, @@ -17,9 +19,11 @@ class FakeDaemonClient implements DaemonProbeClient { if (this.probe.nextConnectError) { throw this.probe.nextConnectError; } + this.connected = true; } getLastServerInfoMessage() { + if (!this.connected) return null; return { serverId: "srv_probe_test", hostname: "probe-host", @@ -27,11 +31,13 @@ class FakeDaemonClient implements DaemonProbeClient { } async close(): Promise { + this.connected = false; this.probe.closedClients.push(this); } } class FakeDaemonProbe { + diagnosticLines: string[] = []; createdClients: FakeDaemonClient[] = []; closedClients: FakeDaemonClient[] = []; clientIdsRequested = 0; @@ -39,6 +45,12 @@ class FakeDaemonProbe { nextLastError: string | null = null; readonly deps: DaemonConnectionDependencies = { + createLogger: (readServerId) => { + const capture = (...values: unknown[]) => { + this.diagnosticLines.push(values.map(String).join(" ")); + }; + return createHostLogger(readServerId, { log: capture, warn: capture, error: capture }); + }, getClientId: async () => { this.clientIdsRequested += 1; return "cid_shared_probe_test"; @@ -76,6 +88,34 @@ describe("test-daemon-connection connectToDaemon", () => { probe = new FakeDaemonProbe(); }); + it.each([undefined, "srv_probe_test"])( + "logs learned host identity on an adopted probe initially given %s", + async (serverId) => { + const { connectToDaemon } = await import("./test-daemon-connection"); + const result = await connectToDaemon( + { id: "direct:test", type: "directTcp", endpoint: "localhost:6767" }, + { serverId }, + probe.deps, + ); + try { + result.client.config.logger!.error({ err: new Error("socket closed") }, "connection_error"); + const record = JSON.parse(probe.diagnosticLines[0]!.slice("connection_error ".length)); + expect(record.serverId).toBe("srv_probe_test"); + expect(record.err.message).toBe("socket closed"); + await result.client.close(); + expect(result.client.getLastServerInfoMessage()).toBeNull(); + result.client.config.logger!.error({ err: new Error("disconnected") }, "connection_error"); + const closedRecord = JSON.parse( + probe.diagnosticLines[1]!.slice("connection_error ".length), + ); + expect(closedRecord.serverId).toBe("srv_probe_test"); + expect(closedRecord.err.message).toBe("disconnected"); + } finally { + await result.client.close(); + } + }, + ); + it.each([ { id: "direct:lan:6767", type: "directTcp" as const, endpoint: "lan:6767" }, { diff --git a/packages/app/src/utils/test-daemon-connection.ts b/packages/app/src/utils/test-daemon-connection.ts index 1d1605af62..96400b4878 100644 --- a/packages/app/src/utils/test-daemon-connection.ts +++ b/packages/app/src/utils/test-daemon-connection.ts @@ -7,6 +7,7 @@ import { import type { DaemonClientConfig } from "@getpaseo/client/internal/daemon-client"; import type { HostConnection } from "@/types/host-connection"; import { createAppWebSocketFactory } from "@/runtime/websocket-factory"; +import { createHostLogger } from "@/runtime/host-logger"; import { getOrCreateClientId } from "./client-id"; import { resolveAppVersion } from "./app-version"; import { @@ -29,6 +30,7 @@ export interface DaemonProbeClient { } export interface DaemonConnectionDependencies { + createLogger: typeof createHostLogger; getClientId(): Promise; resolveAppVersion(): string | null; createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null; @@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies } const defaultDaemonConnectionDependencies: DaemonConnectionDependencies = { + createLogger: createHostLogger, getClientId: getOrCreateClientId, resolveAppVersion, createDesktopTransportFactory: createDesktopDaemonTransportFactory, @@ -317,5 +320,9 @@ export async function connectToDaemon( deps: DaemonConnectionDependencies = defaultDaemonConnectionDependencies, ): Promise<{ client: DaemonProbeClient; serverId: string; hostname: string | null }> { const config = await buildClientConfig(connection, options?.serverId, options, deps); - return connectAndProbe(config, resolveTimeout(connection, options), deps); + let serverId = options?.serverId; + config.logger = deps.createLogger(() => serverId); + const result = await connectAndProbe(config, resolveTimeout(connection, options), deps); + serverId = result.serverId; + return result; }