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; }