Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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";
43 changes: 43 additions & 0 deletions packages/client-runtime/src/errors/safeLog.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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" });
});
});
98 changes: 98 additions & 0 deletions packages/client-runtime/src/errors/safeLog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { findErrorTraceId } from "./errorTrace.ts";

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 traceId = findErrorTraceId(error);
return traceId !== null && SAFE_TRACE_ID.test(traceId) ? traceId : 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 } : {}),
};
}
Loading