authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:50:46-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:50:46-07:00
log0cd0ed1a3dc3280cf0be6128b377abc095ba806f
tree659d3656059781ce8a89ab90c0b59298d5fa530e
parentd53237cbd14cbdc7fe6b1f1ec2f8aa224a3f7aff
signature Signed by PGP key B5690EEEBB952194

[codex] Sanitize client error log diagnostics (#3405)

Co-authored-by: codex <codex@users.noreply.github.com>

14 files changed, 260 insertions(+), 40 deletions(-)

apps/mobile/src/state/use-thread-composer-state.ts+7-1
......@@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
22import { useCallback, useEffect, useMemo } from "react";
33
44import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
5import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
56import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";
67
78import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
......@@ -231,7 +232,12 @@ export function useThreadComposerState() {
231232 appendComposerDraftAttachments(threadKey, images);
232233 }
233234 } catch (error) {
234 console.error("[native paste] error converting images", error);
235 console.error("[native paste] error converting images", {
236 environmentId: selectedThreadShell.environmentId,
237 threadId: selectedThreadShell.id,
238 uriCount: uris.length,
239 ...safeErrorLogAttributes(error),
240 });
235241 }
236242 },
237243 [composerDrafts, selectedThreadShell],
apps/web/src/components/DiffPanel.tsx+11-1
......@@ -4,6 +4,7 @@ import {
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
7import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
78import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
89import {
910 ArrowRightIcon,
......@@ -452,7 +453,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
452453 void (async () => {
453454 const result = await openInPreferredEditor(targetPath);
454455 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
455 console.warn("Failed to open diff file in editor.", squashAtomCommandFailure(result));
456 console.warn("Failed to open diff file in editor.", {
457 operation: "open-diff-file",
458 ...(routeThreadRef
459 ? {
460 environmentId: routeThreadRef.environmentId,
461 threadId: routeThreadRef.threadId,
462 }
463 : {}),
464 ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
465 });
456466 }
457467 })();
458468 },
apps/web/src/components/Sidebar.tsx+3-2
......@@ -55,6 +55,7 @@ import {
5555 scopeProjectRef,
5656 scopeThreadRef,
5757} from "@t3tools/client-runtime/environment";
58import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
5859import {
5960 isAtomCommandInterrupted,
6061 settlePromise,
......@@ -1523,7 +1524,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15231524 console.error("Failed to remove project", {
15241525 projectId: member.id,
15251526 environmentId: member.environmentId,
1526 error,
1527 ...safeErrorLogAttributes(error),
15271528 });
15281529 toastManager.add(
15291530 stackedThreadToast({
......@@ -1558,7 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15581559 console.error("Failed to remove project", {
15591560 projectId: member.id,
15601561 environmentId: member.environmentId,
1561 error,
1562 ...safeErrorLogAttributes(error),
15621563 });
15631564 toastManager.add(
15641565 stackedThreadToast({
apps/web/src/components/settings/SettingsPanels.tsx+6-1
......@@ -12,6 +12,7 @@ import {
1212 type ScopedThreadRef,
1313} from "@t3tools/contracts";
1414import { scopeThreadRef } from "@t3tools/client-runtime/environment";
15import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
1516import {
1617 isAtomCommandInterrupted,
1718 settlePromise,
......@@ -1038,7 +1039,11 @@ export function ProviderSettingsPanel() {
10381039 refreshingRef.current = false;
10391040 setIsRefreshingProviders(false);
10401041 if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
1041 console.warn("Failed to refresh providers", squashAtomCommandFailure(result));
1042 console.warn("Failed to refresh providers", {
1043 operation: "refresh-providers",
1044 environmentId: primaryEnvironment.environmentId,
1045 ...safeErrorLogAttributes(squashAtomCommandFailure(result)),
1046 });
10421047 }
10431048 })();
10441049 }, [primaryEnvironment, refreshServerProviders]);
apps/web/src/hooks/useSettings.ts+9-2
......@@ -23,6 +23,7 @@ import {
2323 DEFAULT_CLIENT_SETTINGS,
2424 type UnifiedSettings,
2525} from "@t3tools/contracts/settings";
26import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
2627import { ensureLocalApi } from "~/localApi";
2728import * as Struct from "effect/Struct";
2829import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
......@@ -106,7 +107,10 @@ async function hydrateClientSettings(): Promise<void> {
106107 replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
107108 }
108109 } catch (error) {
109 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
110 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {
111 operation: "hydrate",
112 ...safeErrorLogAttributes(error),
113 });
110114 } finally {
111115 if (hydrationGeneration === clientSettingsHydrationGeneration) {
112116 setClientSettingsHydrated(true);
......@@ -129,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void {
129133 void ensureLocalApi()
130134 .persistence.setClientSettings(settings)
131135 .catch((error) => {
132 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error);
136 console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
137 operation: "persist",
138 ...safeErrorLogAttributes(error),
139 });
133140 });
134141}
135142
apps/web/src/observability/clientTracing.ts+8-10
......@@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http";
77import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";
88
99import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
10import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
1011import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";
1112import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
1213import { isElectron } from "../env";
......@@ -95,9 +96,14 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
9596 await disposeTracerRuntime(runtime, scope);
9697
9798 if (generation === configurationGeneration) {
99 const error = squashAtomCommandFailure(delegateResult);
100 const tracesUrl = new URL(otlpTracesUrl);
98101 console.warn("Failed to configure client tracing exporter", {
99 error: formatError(squashAtomCommandFailure(delegateResult)),
100 otlpTracesUrl,
102 scheme: tracesUrl.protocol.replace(/:$/, ""),
103 host: tracesUrl.hostname,
104 port: tracesUrl.port || undefined,
105 exportIntervalMs,
106 ...safeErrorLogAttributes(error),
101107 });
102108 }
103109 return;
......@@ -125,14 +131,6 @@ async function disposeTracerRuntime(
125131 runtime.dispose();
126132}
127133
128function formatError(error: unknown): string {
129 if (error instanceof Error && error.message.trim().length > 0) {
130 return error.message;
131 }
132
133 return String(error);
134}
135
136134export async function __resetClientTracingForTests() {
137135 configurationGeneration++;
138136 activeConfigKey = null;
packages/client-runtime/src/connection/supervisor.ts+4-1
......@@ -26,6 +26,7 @@ import {
2626 type SupervisorConnectionState,
2727} from "./model.ts";
2828import * as RpcSession from "../rpc/session.ts";
29import { safeErrorLogAttributes } from "../errors/safeLog.ts";
2930import * as ConnectionWakeups from "./wakeups.ts";
3031
3132const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
......@@ -503,11 +504,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
503504 !establishment.exit.cause.reasons.some(Cause.isFailReason);
504505 const outcome = failureFromExit(target, establishment.exit, false, false);
505506 if (isUnexpectedDefect) {
507 const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect;
506508 yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe(
507509 Effect.annotateLogs({
508510 "environment.id": target.environmentId,
509511 "environment.label": target.label,
510 cause: Cause.pretty(establishment.exit.cause),
512 "cause.reason_count": establishment.exit.cause.reasons.length,
513 ...safeErrorLogAttributes(defect),
511514 }),
512515 );
513516 }
packages/client-runtime/src/errors/index.ts+1
......@@ -1,2 +1,3 @@
11export * from "./errorTrace.ts";
2export * from "./safeLog.ts";
23export * from "./transport.ts";
packages/client-runtime/src/errors/safeLog.test.ts created+55
......@@ -0,0 +1,55 @@
1import { describe, expect, it } from "vite-plus/test";
2
3import { safeErrorLogAttributes } from "./safeLog.ts";
4
5describe("safeErrorLogAttributes", () => {
6 it("keeps correlation and stack frames without serializing messages or nested causes", () => {
7 const cause = Object.assign(new Error("nested-cause-secret-sentinel"), {
8 traceId: "trace-safe-123",
9 });
10 const error = Object.assign(new Error("outer-error-secret-sentinel", { cause }), {
11 _tag: "ProjectRemovalError",
12 });
13 error.stack = [
14 "ProjectRemovalError: outer-error-secret-sentinel",
15 " at removeProject (https://user:password@example.com/project.ts?token=secret#fragment)",
16 ].join("\n");
17
18 const attributes = safeErrorLogAttributes(error);
19
20 expect(attributes).toMatchObject({
21 errorType: "error",
22 errorName: "Error",
23 errorTag: "ProjectRemovalError",
24 traceId: "trace-safe-123",
25 stack: " at removeProject (https://example.com/project.ts)",
26 });
27 const diagnosticText = Object.values(attributes).map(String).join("\n");
28 expect(diagnosticText).not.toContain("outer-error-secret-sentinel");
29 expect(diagnosticText).not.toContain("nested-cause-secret-sentinel");
30 expect(diagnosticText).not.toContain("user:password");
31 expect(diagnosticText).not.toContain("token=secret");
32 });
33
34 it("does not trust arbitrary object messages or tags", () => {
35 const attributes = safeErrorLogAttributes({
36 _tag: "payload-secret-sentinel",
37 message: "message-secret-sentinel",
38 cause: { traceId: "trace id with unsafe whitespace" },
39 });
40
41 expect(attributes).toEqual({ errorType: "object" });
42 });
43
44 it("skips an unsafe outer trace id when a nested safe trace id is available", () => {
45 const attributes = safeErrorLogAttributes({
46 traceId: "unsafe trace id",
47 cause: { traceId: "trace-safe-inner" },
48 });
49
50 expect(attributes).toEqual({
51 errorType: "object",
52 traceId: "trace-safe-inner",
53 });
54 });
55});
packages/client-runtime/src/errors/safeLog.ts created+107
......@@ -0,0 +1,107 @@
1const SAFE_ERROR_LABEL =
2 /^(?:Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|AggregateError|DOMException|[A-Za-z][A-Za-z0-9]*(?:Error|Failure))$/;
3const SAFE_TRACE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
4const STACK_FRAME_LIMIT = 32;
5
6export interface SafeErrorLogAttributes {
7 readonly errorType: "error" | "array" | "null" | "object" | "primitive";
8 readonly errorName?: string;
9 readonly errorTag?: string;
10 readonly traceId?: string;
11 readonly stack?: string;
12}
13
14function readSafeLabel(value: unknown): string | undefined {
15 return typeof value === "string" && SAFE_ERROR_LABEL.test(value) ? value : undefined;
16}
17
18function sanitizeStackUrl(value: string): string {
19 try {
20 const url = new URL(value);
21 url.username = "";
22 url.password = "";
23 url.search = "";
24 url.hash = "";
25 return url.toString();
26 } catch {
27 return value;
28 }
29}
30
31function sanitizeStackFrame(frame: string): string {
32 return frame.replace(/(?:https?|file):\/\/[^\s)]+/g, sanitizeStackUrl);
33}
34
35function readSafeStack(error: Error): string | undefined {
36 try {
37 const frames = error.stack
38 ?.split(/\r?\n/)
39 .filter((line) => /^\s*at\s+/.test(line) || /^[^@\s]+@(?:https?|file):\/\//.test(line))
40 .slice(0, STACK_FRAME_LIMIT)
41 .map(sanitizeStackFrame);
42 return frames && frames.length > 0 ? frames.join("\n") : undefined;
43 } catch {
44 return undefined;
45 }
46}
47
48function readErrorTag(error: unknown): string | undefined {
49 try {
50 if (typeof error !== "object" || error === null) {
51 return undefined;
52 }
53 return readSafeLabel((error as { readonly _tag?: unknown })._tag);
54 } catch {
55 return undefined;
56 }
57}
58
59function readTraceId(error: unknown): string | undefined {
60 try {
61 const seen = new Set<object>();
62 let current: unknown = error;
63
64 while (typeof current === "object" && current !== null && !seen.has(current)) {
65 seen.add(current);
66 const record = current as { readonly cause?: unknown; readonly traceId?: unknown };
67 if (typeof record.traceId === "string" && SAFE_TRACE_ID.test(record.traceId)) {
68 return record.traceId;
69 }
70 current = record.cause;
71 }
72
73 return undefined;
74 } catch {
75 return undefined;
76 }
77}
78
79export function safeErrorLogAttributes(error: unknown): SafeErrorLogAttributes {
80 const errorTag = readErrorTag(error);
81 const traceId = readTraceId(error);
82
83 if (error instanceof Error) {
84 const errorName = readSafeLabel(error.name);
85 const stack = readSafeStack(error);
86 return {
87 errorType: "error",
88 ...(errorName !== undefined ? { errorName } : {}),
89 ...(errorTag !== undefined ? { errorTag } : {}),
90 ...(traceId !== undefined ? { traceId } : {}),
91 ...(stack !== undefined ? { stack } : {}),
92 };
93 }
94
95 return {
96 errorType:
97 error === null
98 ? "null"
99 : Array.isArray(error)
100 ? "array"
101 : typeof error === "object"
102 ? "object"
103 : "primitive",
104 ...(errorTag !== undefined ? { errorTag } : {}),
105 ...(traceId !== undefined ? { traceId } : {}),
106 };
107}
packages/client-runtime/src/state/archivedThreads.test.ts+26-1
......@@ -1,7 +1,10 @@
1import { EnvironmentId } from "@t3tools/contracts";
1import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
3import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
24import { expect, it } from "vite-plus/test";
35
46import {
7 createArchivedThreadSnapshotsAtomFamily,
58 makeArchivedThreadsEnvironmentKey,
69 parseArchivedThreadsEnvironmentKey,
710} from "./archivedThreads.ts";
......@@ -13,3 +16,25 @@ it("round-trips environment keys in sorted order", () => {
1316
1417 expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]);
1518});
19
20it("does not expose an archived snapshot failure message", () => {
21 const environmentId = EnvironmentId.make("env-sensitive");
22 const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({
23 getSnapshotAtom: () =>
24 Atom.make(
25 AsyncResult.failure<OrchestrationShellSnapshot, Error>(
26 Cause.fail(new Error("credential=secret-value")),
27 ),
28 ),
29 labelPrefix: "test:archived-thread-snapshots",
30 });
31 const registry = AtomRegistry.make();
32
33 expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({
34 snapshots: [],
35 error: "Failed to load archived threads.",
36 isLoading: false,
37 });
38
39 registry.dispose();
40});
packages/client-runtime/src/state/archivedThreads.ts+1-6
......@@ -1,6 +1,5 @@
11import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
22import * as Arr from "effect/Array";
3import * as Cause from "effect/Cause";
43import { pipe } from "effect/Function";
54import * as Option from "effect/Option";
65import * as Order from "effect/Order";
......@@ -60,11 +59,7 @@ export function createArchivedThreadSnapshotsAtomFamily<E>(options: {
6059 }
6160
6261 if (error === null && result._tag === "Failure") {
63 const cause = Cause.squash(result.cause);
64 error =
65 cause instanceof Error && cause.message.trim().length > 0
66 ? cause.message
67 : "Failed to load archived threads.";
62 error = "Failed to load archived threads.";
6863 }
6964 }
7065
packages/client-runtime/src/state/session.ts+5-3
......@@ -8,6 +8,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
88import { EnvironmentRegistry } from "../connection/registry.ts";
99import type { PreparedConnection } from "../connection/model.ts";
1010import { EnvironmentSupervisor } from "../connection/supervisor.ts";
11import { safeErrorLogAttributes } from "../errors/safeLog.ts";
1112import { followStreamInEnvironment } from "./runtime.ts";
1213
1314export function initialConfigOption<E>(
......@@ -16,9 +17,10 @@ export function initialConfigOption<E>(
1617 return initialConfig.pipe(
1718 Effect.map(Option.some),
1819 Effect.catch((error) =>
19 Effect.logWarning("Could not load the initial environment configuration.", {
20 error,
21 }).pipe(Effect.as(Option.none<ServerConfig>())),
20 Effect.logWarning("Could not load the initial environment configuration.").pipe(
21 Effect.annotateLogs({ ...safeErrorLogAttributes(error) }),
22 Effect.as(Option.none<ServerConfig>()),
23 ),
2224 ),
2325 );
2426}
packages/client-runtime/src/state/shell.ts+17-12
......@@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity";
1616import { EnvironmentRegistry } from "../connection/registry.ts";
1717import { connectionProjectionPhase } from "../connection/model.ts";
1818import { EnvironmentSupervisor } from "../connection/supervisor.ts";
19import { safeErrorLogAttributes } from "../errors/safeLog.ts";
1920import { EnvironmentCacheStore } from "../platform/persistence.ts";
2021import { subscribe } from "../rpc/client.ts";
2122import { applyShellStreamEvent } from "./shellReducer.ts";
......@@ -42,11 +43,7 @@ function shellStatusForSnapshot(
4243 return Option.isSome(snapshot) ? "cached" : "empty";
4344}
4445
45function formatShellError(error: unknown): string {
46 return error instanceof Error && error.message.trim().length > 0
47 ? error.message
48 : "Could not synchronize environment data.";
49}
46const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data.";
5047
5148export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () {
5249 const supervisor = yield* EnvironmentSupervisor;
......@@ -57,7 +54,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
5754 Effect.logWarning("Could not load cached environment shell.").pipe(
5855 Effect.annotateLogs({
5956 environmentId,
60 error: error.message,
57 ...safeErrorLogAttributes(error),
6158 }),
6259 Effect.as(Option.none<OrchestrationShellSnapshot>()),
6360 ),
......@@ -78,7 +75,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
7875 Effect.logWarning("Could not persist environment shell cache.").pipe(
7976 Effect.annotateLogs({
8077 environmentId,
81 error: error.message,
78 ...safeErrorLogAttributes(error),
8279 }),
8380 ),
8481 ),
......@@ -110,11 +107,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")
110107 },
111108 );
112109 const setStreamError = (error: unknown) =>
113 SubscriptionRef.update(state, (current) => ({
114 ...current,
115 status: shellStatusForSnapshot(current.snapshot),
116 error: Option.some(formatShellError(error)),
117 }));
110 Effect.logWarning("Could not synchronize the environment shell.").pipe(
111 Effect.annotateLogs({
112 environmentId,
113 ...safeErrorLogAttributes(error),
114 }),
115 Effect.andThen(
116 SubscriptionRef.update(state, (current) => ({
117 ...current,
118 status: shellStatusForSnapshot(current.snapshot),
119 error: Option.some(SHELL_SYNCHRONIZATION_ERROR_MESSAGE),
120 })),
121 ),
122 );
118123
119124 const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* (
120125 item: OrchestrationShellStreamItem,