Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion apps/mobile/src/state/use-thread-composer-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react";
import { useCallback, useEffect, useMemo } from "react";

import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming";

import { makeQueuedMessageMetadata } from "../lib/commandMetadata";
Expand Down Expand Up @@ -231,7 +232,12 @@ export function useThreadComposerState() {
appendComposerDraftAttachments(threadKey, images);
}
} catch (error) {
console.error("[native paste] error converting images", error);
console.error("[native paste] error converting images", {
environmentId: selectedThreadShell.environmentId,
threadId: selectedThreadShell.id,
uriCount: uris.length,
...safeErrorLogAttributes(error),
});
}
},
[composerDrafts, selectedThreadShell],
Expand Down
12 changes: 11 additions & 1 deletion apps/web/src/components/DiffPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
isAtomCommandInterrupted,
squashAtomCommandFailure,
} from "@t3tools/client-runtime/state/runtime";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import type { ScopedThreadRef, TurnId } from "@t3tools/contracts";
import {
ArrowRightIcon,
Expand Down Expand Up @@ -452,7 +453,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff
void (async () => {
const result = await openInPreferredEditor(targetPath);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
console.warn("Failed to open diff file in editor.", squashAtomCommandFailure(result));
console.warn("Failed to open diff file in editor.", {
operation: "open-diff-file",
...(routeThreadRef
? {
environmentId: routeThreadRef.environmentId,
threadId: routeThreadRef.threadId,
}
: {}),
...safeErrorLogAttributes(squashAtomCommandFailure(result)),
});
}
})();
},
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
scopeProjectRef,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import {
isAtomCommandInterrupted,
settlePromise,
Expand Down Expand Up @@ -1523,7 +1524,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
console.error("Failed to remove project", {
projectId: member.id,
environmentId: member.environmentId,
error,
...safeErrorLogAttributes(error),
});
toastManager.add(
stackedThreadToast({
Expand Down Expand Up @@ -1558,7 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
console.error("Failed to remove project", {
projectId: member.id,
environmentId: member.environmentId,
error,
...safeErrorLogAttributes(error),
});
toastManager.add(
stackedThreadToast({
Expand Down
7 changes: 6 additions & 1 deletion apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
type ScopedThreadRef,
} from "@t3tools/contracts";
import { scopeThreadRef } from "@t3tools/client-runtime/environment";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import {
isAtomCommandInterrupted,
settlePromise,
Expand Down Expand Up @@ -1038,7 +1039,11 @@ export function ProviderSettingsPanel() {
refreshingRef.current = false;
setIsRefreshingProviders(false);
if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) {
console.warn("Failed to refresh providers", squashAtomCommandFailure(result));
console.warn("Failed to refresh providers", {
operation: "refresh-providers",
environmentId: primaryEnvironment.environmentId,
...safeErrorLogAttributes(squashAtomCommandFailure(result)),
});
}
})();
}, [primaryEnvironment, refreshServerProviders]);
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/hooks/useSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
DEFAULT_CLIENT_SETTINGS,
type UnifiedSettings,
} from "@t3tools/contracts/settings";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { ensureLocalApi } from "~/localApi";
import * as Struct from "effect/Struct";
import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
Expand Down Expand Up @@ -106,7 +107,10 @@ async function hydrateClientSettings(): Promise<void> {
replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings });
}
} catch (error) {
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, error);
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} hydrate failed`, {
operation: "hydrate",
...safeErrorLogAttributes(error),
});
} finally {
if (hydrationGeneration === clientSettingsHydrationGeneration) {
setClientSettingsHydrated(true);
Expand All @@ -129,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void {
void ensureLocalApi()
.persistence.setClientSettings(settings)
.catch((error) => {
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, error);
console.error(`${CLIENT_SETTINGS_PERSISTENCE_ERROR_SCOPE} persist failed`, {
operation: "persist",
...safeErrorLogAttributes(error),
});
});
}

Expand Down
18 changes: 8 additions & 10 deletions apps/web/src/observability/clientTracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http";
import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability";

import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors";
import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary";
import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer";
import { isElectron } from "../env";
Expand Down Expand Up @@ -95,9 +96,14 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo
await disposeTracerRuntime(runtime, scope);

if (generation === configurationGeneration) {
const error = squashAtomCommandFailure(delegateResult);
const tracesUrl = new URL(otlpTracesUrl);
console.warn("Failed to configure client tracing exporter", {
error: formatError(squashAtomCommandFailure(delegateResult)),
otlpTracesUrl,
scheme: tracesUrl.protocol.replace(/:$/, ""),
host: tracesUrl.hostname,
port: tracesUrl.port || undefined,
exportIntervalMs,
...safeErrorLogAttributes(error),
});
}
return;
Expand Down Expand Up @@ -125,14 +131,6 @@ async function disposeTracerRuntime(
runtime.dispose();
}

function formatError(error: unknown): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}

return String(error);
}

export async function __resetClientTracingForTests() {
configurationGeneration++;
activeConfigKey = null;
Expand Down
5 changes: 4 additions & 1 deletion packages/client-runtime/src/connection/supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
type SupervisorConnectionState,
} from "./model.ts";
import * as RpcSession from "../rpc/session.ts";
import { safeErrorLogAttributes } from "../errors/safeLog.ts";
import * as ConnectionWakeups from "./wakeups.ts";

const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const;
Expand Down Expand Up @@ -503,11 +504,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* (
!establishment.exit.cause.reasons.some(Cause.isFailReason);
const outcome = failureFromExit(target, establishment.exit, false, false);
if (isUnexpectedDefect) {
const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect;
yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe(
Effect.annotateLogs({
"environment.id": target.environmentId,
"environment.label": target.label,
cause: Cause.pretty(establishment.exit.cause),
"cause.reason_count": establishment.exit.cause.reasons.length,
...safeErrorLogAttributes(defect),
}),
);
}
Expand Down
1 change: 1 addition & 0 deletions packages/client-runtime/src/errors/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from "./errorTrace.ts";
export * from "./safeLog.ts";
export * from "./transport.ts";
55 changes: 55 additions & 0 deletions packages/client-runtime/src/errors/safeLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vite-plus/test";

import { safeErrorLogAttributes } from "./safeLog.ts";

describe("safeErrorLogAttributes", () => {
it("keeps correlation and stack frames without serializing messages or nested causes", () => {
const cause = Object.assign(new Error("nested-cause-secret-sentinel"), {
traceId: "trace-safe-123",
});
const error = Object.assign(new Error("outer-error-secret-sentinel", { cause }), {
_tag: "ProjectRemovalError",
});
error.stack = [
"ProjectRemovalError: outer-error-secret-sentinel",
" at removeProject (https://user:password@example.com/project.ts?token=secret#fragment)",
].join("\n");

const attributes = safeErrorLogAttributes(error);

expect(attributes).toMatchObject({
errorType: "error",
errorName: "Error",
errorTag: "ProjectRemovalError",
traceId: "trace-safe-123",
stack: " at removeProject (https://example.com/project.ts)",
});
const diagnosticText = Object.values(attributes).map(String).join("\n");
expect(diagnosticText).not.toContain("outer-error-secret-sentinel");
expect(diagnosticText).not.toContain("nested-cause-secret-sentinel");
expect(diagnosticText).not.toContain("user:password");
expect(diagnosticText).not.toContain("token=secret");
});

it("does not trust arbitrary object messages or tags", () => {
const attributes = safeErrorLogAttributes({
_tag: "payload-secret-sentinel",
message: "message-secret-sentinel",
cause: { traceId: "trace id with unsafe whitespace" },
});

expect(attributes).toEqual({ errorType: "object" });
});

it("skips an unsafe outer trace id when a nested safe trace id is available", () => {
const attributes = safeErrorLogAttributes({
traceId: "unsafe trace id",
cause: { traceId: "trace-safe-inner" },
});

expect(attributes).toEqual({
errorType: "object",
traceId: "trace-safe-inner",
});
});
});
107 changes: 107 additions & 0 deletions packages/client-runtime/src/errors/safeLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
const SAFE_ERROR_LABEL =
/^(?:Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|AggregateError|DOMException|[A-Za-z][A-Za-z0-9]*(?:Error|Failure))$/;
const SAFE_TRACE_ID = /^[A-Za-z0-9._:-]{1,128}$/;
const STACK_FRAME_LIMIT = 32;

export interface SafeErrorLogAttributes {
readonly errorType: "error" | "array" | "null" | "object" | "primitive";
readonly errorName?: string;
readonly errorTag?: string;
readonly traceId?: string;
readonly stack?: string;
}

function readSafeLabel(value: unknown): string | undefined {
return typeof value === "string" && SAFE_ERROR_LABEL.test(value) ? value : undefined;
}

function sanitizeStackUrl(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return value;
}
}

function sanitizeStackFrame(frame: string): string {
return frame.replace(/(?:https?|file):\/\/[^\s)]+/g, sanitizeStackUrl);
}

function readSafeStack(error: Error): string | undefined {
try {
const frames = error.stack
?.split(/\r?\n/)
.filter((line) => /^\s*at\s+/.test(line) || /^[^@\s]+@(?:https?|file):\/\//.test(line))
.slice(0, STACK_FRAME_LIMIT)
.map(sanitizeStackFrame);
return frames && frames.length > 0 ? frames.join("\n") : undefined;
} catch {
return undefined;
}
}

function readErrorTag(error: unknown): string | undefined {
try {
if (typeof error !== "object" || error === null) {
return undefined;
}
return readSafeLabel((error as { readonly _tag?: unknown })._tag);
} catch {
return undefined;
}
}

function readTraceId(error: unknown): string | undefined {
try {
const seen = new Set<object>();
let current: unknown = error;

while (typeof current === "object" && current !== null && !seen.has(current)) {
seen.add(current);
const record = current as { readonly cause?: unknown; readonly traceId?: unknown };
if (typeof record.traceId === "string" && SAFE_TRACE_ID.test(record.traceId)) {
return record.traceId;
}
current = record.cause;
}

return undefined;
} catch {
return undefined;
}
Comment thread
cursor[bot] marked this conversation as resolved.
}

export function safeErrorLogAttributes(error: unknown): SafeErrorLogAttributes {
const errorTag = readErrorTag(error);
const traceId = readTraceId(error);

if (error instanceof Error) {
const errorName = readSafeLabel(error.name);
const stack = readSafeStack(error);
return {
errorType: "error",
...(errorName !== undefined ? { errorName } : {}),
...(errorTag !== undefined ? { errorTag } : {}),
...(traceId !== undefined ? { traceId } : {}),
...(stack !== undefined ? { stack } : {}),
};
}

return {
errorType:
error === null
? "null"
: Array.isArray(error)
? "array"
: typeof error === "object"
? "object"
: "primitive",
...(errorTag !== undefined ? { errorTag } : {}),
...(traceId !== undefined ? { traceId } : {}),
};
}
27 changes: 26 additions & 1 deletion packages/client-runtime/src/state/archivedThreads.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { EnvironmentId } from "@t3tools/contracts";
import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts";
import * as Cause from "effect/Cause";
import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity";
import { expect, it } from "vite-plus/test";

import {
createArchivedThreadSnapshotsAtomFamily,
makeArchivedThreadsEnvironmentKey,
parseArchivedThreadsEnvironmentKey,
} from "./archivedThreads.ts";
Expand All @@ -13,3 +16,25 @@ it("round-trips environment keys in sorted order", () => {

expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]);
});

it("does not expose an archived snapshot failure message", () => {
const environmentId = EnvironmentId.make("env-sensitive");
const snapshotsAtom = createArchivedThreadSnapshotsAtomFamily<Error>({
getSnapshotAtom: () =>
Atom.make(
AsyncResult.failure<OrchestrationShellSnapshot, Error>(
Cause.fail(new Error("credential=secret-value")),
),
),
labelPrefix: "test:archived-thread-snapshots",
});
const registry = AtomRegistry.make();

expect(registry.get(snapshotsAtom(makeArchivedThreadsEnvironmentKey([environmentId])))).toEqual({
snapshots: [],
error: "Failed to load archived threads.",
isLoading: false,
});

registry.dispose();
});
Loading
Loading