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>
This commit is contained in:
paseo-bot[bot]
2026-11-07 21:19:18 +00:00
committed by GitHub
parent 249c35891b
commit 19848899d0
5 changed files with 159 additions and 1 deletions
@@ -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);
});
+36
View File
@@ -0,0 +1,36 @@
import type { Logger } from "@getpaseo/client/internal/daemon-client";
export function createHostLogger(
readServerId: () => string | undefined,
output: Pick<Console, "log" | "warn" | "error"> = console,
): Logger {
const format = (record: object, message?: string): string => {
const seen = new WeakSet<object>();
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)),
};
}
+3
View File
@@ -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,
});
@@ -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<void> {
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<FakeDaemonClient> = {
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" },
{
@@ -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<TClient extends DaemonProbeClient> {
createLogger: typeof createHostLogger;
getClientId(): Promise<string>;
resolveAppVersion(): string | null;
createDesktopTransportFactory(): DaemonClientConfig["transportFactory"] | null;
@@ -37,6 +39,7 @@ export interface DaemonConnectionDependencies<TClient extends DaemonProbeClient>
}
const defaultDaemonConnectionDependencies: DaemonConnectionDependencies<DaemonClient> = {
createLogger: createHostLogger,
getClientId: getOrCreateClientId,
resolveAppVersion,
createDesktopTransportFactory: createDesktopDaemonTransportFactory,
@@ -317,5 +320,9 @@ export async function connectToDaemon(
deps: DaemonConnectionDependencies<DaemonProbeClient> = 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;
}