| author | |
| committer | |
| log | 0cd0ed1a3dc3280cf0be6128b377abc095ba806f |
| tree | 659d3656059781ce8a89ab90c0b59298d5fa530e |
| parent | d53237cbd14cbdc7fe6b1f1ec2f8aa224a3f7aff |
| signature | Signed by PGP key B5690EEEBB952194 |
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"; | ... | @@ -2,6 +2,7 @@ import { useAtomValue } from "@effect/atom-react"; |
| 2 | import { useCallback, useEffect, useMemo } from "react"; | 2 | import { useCallback, useEffect, useMemo } from "react"; |
| 3 | 3 | ||
| 4 | import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; | 4 | import { CommandId, MessageId, type EnvironmentId, type ThreadId } from "@t3tools/contracts"; |
| 5 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 5 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; | 6 | import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; |
| 6 | 7 | ||
| 7 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; | 8 | import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; |
| ... | @@ -231,7 +232,12 @@ export function useThreadComposerState() { | ... | @@ -231,7 +232,12 @@ export function useThreadComposerState() { |
| 231 | appendComposerDraftAttachments(threadKey, images); | 232 | appendComposerDraftAttachments(threadKey, images); |
| 232 | } | 233 | } |
| 233 | } catch (error) { | 234 | } 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 | }); | ||
| 235 | } | 241 | } |
| 236 | }, | 242 | }, |
| 237 | [composerDrafts, selectedThreadShell], | 243 | [composerDrafts, selectedThreadShell], |
apps/web/src/components/DiffPanel.tsx+11-1| ... | @@ -4,6 +4,7 @@ import { | ... | @@ -4,6 +4,7 @@ import { |
| 4 | isAtomCommandInterrupted, | 4 | isAtomCommandInterrupted, |
| 5 | squashAtomCommandFailure, | 5 | squashAtomCommandFailure, |
| 6 | } from "@t3tools/client-runtime/state/runtime"; | 6 | } from "@t3tools/client-runtime/state/runtime"; |
| 7 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 7 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; | 8 | import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; |
| 8 | import { | 9 | import { |
| 9 | ArrowRightIcon, | 10 | ArrowRightIcon, |
| ... | @@ -452,7 +453,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff | ... | @@ -452,7 +453,16 @@ export default function DiffPanel({ mode = "inline", composerDraftTarget }: Diff |
| 452 | void (async () => { | 453 | void (async () => { |
| 453 | const result = await openInPreferredEditor(targetPath); | 454 | const result = await openInPreferredEditor(targetPath); |
| 454 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 455 | 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 | }); | ||
| 456 | } | 466 | } |
| 457 | })(); | 467 | })(); |
| 458 | }, | 468 | }, |
apps/web/src/components/Sidebar.tsx+3-2| ... | @@ -55,6 +55,7 @@ import { | ... | @@ -55,6 +55,7 @@ import { |
| 55 | scopeProjectRef, | 55 | scopeProjectRef, |
| 56 | scopeThreadRef, | 56 | scopeThreadRef, |
| 57 | } from "@t3tools/client-runtime/environment"; | 57 | } from "@t3tools/client-runtime/environment"; |
| 58 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 58 | import { | 59 | import { |
| 59 | isAtomCommandInterrupted, | 60 | isAtomCommandInterrupted, |
| 60 | settlePromise, | 61 | settlePromise, |
| ... | @@ -1523,7 +1524,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1523,7 +1524,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1523 | console.error("Failed to remove project", { | 1524 | console.error("Failed to remove project", { |
| 1524 | projectId: member.id, | 1525 | projectId: member.id, |
| 1525 | environmentId: member.environmentId, | 1526 | environmentId: member.environmentId, |
| 1526 | error, | 1527 | ...safeErrorLogAttributes(error), |
| 1527 | }); | 1528 | }); |
| 1528 | toastManager.add( | 1529 | toastManager.add( |
| 1529 | stackedThreadToast({ | 1530 | stackedThreadToast({ |
| ... | @@ -1558,7 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec | ... | @@ -1558,7 +1559,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec |
| 1558 | console.error("Failed to remove project", { | 1559 | console.error("Failed to remove project", { |
| 1559 | projectId: member.id, | 1560 | projectId: member.id, |
| 1560 | environmentId: member.environmentId, | 1561 | environmentId: member.environmentId, |
| 1561 | error, | 1562 | ...safeErrorLogAttributes(error), |
| 1562 | }); | 1563 | }); |
| 1563 | toastManager.add( | 1564 | toastManager.add( |
| 1564 | stackedThreadToast({ | 1565 | stackedThreadToast({ |
apps/web/src/components/settings/SettingsPanels.tsx+6-1| ... | @@ -12,6 +12,7 @@ import { | ... | @@ -12,6 +12,7 @@ import { |
| 12 | type ScopedThreadRef, | 12 | type ScopedThreadRef, |
| 13 | } from "@t3tools/contracts"; | 13 | } from "@t3tools/contracts"; |
| 14 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; | 14 | import { scopeThreadRef } from "@t3tools/client-runtime/environment"; |
| 15 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 15 | import { | 16 | import { |
| 16 | isAtomCommandInterrupted, | 17 | isAtomCommandInterrupted, |
| 17 | settlePromise, | 18 | settlePromise, |
| ... | @@ -1038,7 +1039,11 @@ export function ProviderSettingsPanel() { | ... | @@ -1038,7 +1039,11 @@ export function ProviderSettingsPanel() { |
| 1038 | refreshingRef.current = false; | 1039 | refreshingRef.current = false; |
| 1039 | setIsRefreshingProviders(false); | 1040 | setIsRefreshingProviders(false); |
| 1040 | if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { | 1041 | 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 | }); | ||
| 1042 | } | 1047 | } |
| 1043 | })(); | 1048 | })(); |
| 1044 | }, [primaryEnvironment, refreshServerProviders]); | 1049 | }, [primaryEnvironment, refreshServerProviders]); |
apps/web/src/hooks/useSettings.ts+9-2| ... | @@ -23,6 +23,7 @@ import { | ... | @@ -23,6 +23,7 @@ import { |
| 23 | DEFAULT_CLIENT_SETTINGS, | 23 | DEFAULT_CLIENT_SETTINGS, |
| 24 | type UnifiedSettings, | 24 | type UnifiedSettings, |
| 25 | } from "@t3tools/contracts/settings"; | 25 | } from "@t3tools/contracts/settings"; |
| 26 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 26 | import { ensureLocalApi } from "~/localApi"; | 27 | import { ensureLocalApi } from "~/localApi"; |
| 27 | import * as Struct from "effect/Struct"; | 28 | import * as Struct from "effect/Struct"; |
| 28 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; | 29 | import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server"; |
| ... | @@ -106,7 +107,10 @@ async function hydrateClientSettings(): Promise<void> { | ... | @@ -106,7 +107,10 @@ async function hydrateClientSettings(): Promise<void> { |
| 106 | replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); | 107 | replaceClientSettingsSnapshot({ ...DEFAULT_CLIENT_SETTINGS, ...persistedSettings }); |
| 107 | } | 108 | } |
| 108 | } catch (error) { | 109 | } 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 | }); | ||
| 110 | } finally { | 114 | } finally { |
| 111 | if (hydrationGeneration === clientSettingsHydrationGeneration) { | 115 | if (hydrationGeneration === clientSettingsHydrationGeneration) { |
| 112 | setClientSettingsHydrated(true); | 116 | setClientSettingsHydrated(true); |
| ... | @@ -129,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void { | ... | @@ -129,7 +133,10 @@ function persistClientSettings(settings: ClientSettings): void { |
| 129 | void ensureLocalApi() | 133 | void ensureLocalApi() |
| 130 | .persistence.setClientSettings(settings) | 134 | .persistence.setClientSettings(settings) |
| 131 | .catch((error) => { | 135 | .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 | }); | ||
| 133 | }); | 140 | }); |
| 134 | } | 141 | } |
| 135 | 142 |
apps/web/src/observability/clientTracing.ts+8-10| ... | @@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http"; | ... | @@ -7,6 +7,7 @@ import { HttpClient } from "effect/unstable/http"; |
| 7 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; | 7 | import { OtlpSerialization, OtlpTracer } from "effect/unstable/observability"; |
| 8 | 8 | ||
| 9 | import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 9 | import { settleAsyncResult, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 10 | import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; | ||
| 10 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; | 11 | import { resolvePrimaryEnvironmentHttpUrl } from "../environments/primary"; |
| 11 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; | 12 | import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; |
| 12 | import { isElectron } from "../env"; | 13 | import { isElectron } from "../env"; |
| ... | @@ -95,9 +96,14 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo | ... | @@ -95,9 +96,14 @@ async function applyClientTracingConfig(config: ClientTracingConfig): Promise<vo |
| 95 | await disposeTracerRuntime(runtime, scope); | 96 | await disposeTracerRuntime(runtime, scope); |
| 96 | 97 | ||
| 97 | if (generation === configurationGeneration) { | 98 | if (generation === configurationGeneration) { |
| 99 | const error = squashAtomCommandFailure(delegateResult); | ||
| 100 | const tracesUrl = new URL(otlpTracesUrl); | ||
| 98 | console.warn("Failed to configure client tracing exporter", { | 101 | console.warn("Failed to configure client tracing exporter", { |
| 99 | error: formatError(squashAtomCommandFailure(delegateResult)), | 102 | scheme: tracesUrl.protocol.replace(/:$/, ""), |
| 100 | otlpTracesUrl, | 103 | host: tracesUrl.hostname, |
| 104 | port: tracesUrl.port || undefined, | ||
| 105 | exportIntervalMs, | ||
| 106 | ...safeErrorLogAttributes(error), | ||
| 101 | }); | 107 | }); |
| 102 | } | 108 | } |
| 103 | return; | 109 | return; |
| ... | @@ -125,14 +131,6 @@ async function disposeTracerRuntime( | ... | @@ -125,14 +131,6 @@ async function disposeTracerRuntime( |
| 125 | runtime.dispose(); | 131 | runtime.dispose(); |
| 126 | } | 132 | } |
| 127 | 133 | ||
| 128 | function 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 | |||
| 136 | export async function __resetClientTracingForTests() { | 134 | export async function __resetClientTracingForTests() { |
| 137 | configurationGeneration++; | 135 | configurationGeneration++; |
| 138 | activeConfigKey = null; | 136 | activeConfigKey = null; |
packages/client-runtime/src/connection/supervisor.ts+4-1| ... | @@ -26,6 +26,7 @@ import { | ... | @@ -26,6 +26,7 @@ import { |
| 26 | type SupervisorConnectionState, | 26 | type SupervisorConnectionState, |
| 27 | } from "./model.ts"; | 27 | } from "./model.ts"; |
| 28 | import * as RpcSession from "../rpc/session.ts"; | 28 | import * as RpcSession from "../rpc/session.ts"; |
| 29 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | ||
| 29 | import * as ConnectionWakeups from "./wakeups.ts"; | 30 | import * as ConnectionWakeups from "./wakeups.ts"; |
| 30 | 31 | ||
| 31 | const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000, 16_000] as const; | 32 | const 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* ( | ... | @@ -503,11 +504,13 @@ export const make = Effect.fn("EnvironmentSupervisor.make")(function* ( |
| 503 | !establishment.exit.cause.reasons.some(Cause.isFailReason); | 504 | !establishment.exit.cause.reasons.some(Cause.isFailReason); |
| 504 | const outcome = failureFromExit(target, establishment.exit, false, false); | 505 | const outcome = failureFromExit(target, establishment.exit, false, false); |
| 505 | if (isUnexpectedDefect) { | 506 | if (isUnexpectedDefect) { |
| 507 | const defect = establishment.exit.cause.reasons.find(Cause.isDieReason)?.defect; | ||
| 506 | yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( | 508 | yield* Effect.logError("Connection attempt failed with an unexpected defect.").pipe( |
| 507 | Effect.annotateLogs({ | 509 | Effect.annotateLogs({ |
| 508 | "environment.id": target.environmentId, | 510 | "environment.id": target.environmentId, |
| 509 | "environment.label": target.label, | 511 | "environment.label": target.label, |
| 510 | cause: Cause.pretty(establishment.exit.cause), | 512 | "cause.reason_count": establishment.exit.cause.reasons.length, |
| 513 | ...safeErrorLogAttributes(defect), | ||
| 511 | }), | 514 | }), |
| 512 | ); | 515 | ); |
| 513 | } | 516 | } |
packages/client-runtime/src/errors/index.ts+1| ... | @@ -1,2 +1,3 @@ | ... | @@ -1,2 +1,3 @@ |
| 1 | export * from "./errorTrace.ts"; | 1 | export * from "./errorTrace.ts"; |
| 2 | export * from "./safeLog.ts"; | ||
| 2 | export * from "./transport.ts"; | 3 | export * from "./transport.ts"; |
packages/client-runtime/src/errors/safeLog.test.ts created+55| ... | @@ -0,0 +1,55 @@ | ||
| 1 | import { describe, expect, it } from "vite-plus/test"; | ||
| 2 | |||
| 3 | import { safeErrorLogAttributes } from "./safeLog.ts"; | ||
| 4 | |||
| 5 | describe("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 @@ | ||
| 1 | const SAFE_ERROR_LABEL = | ||
| 2 | /^(?:Error|EvalError|RangeError|ReferenceError|SyntaxError|TypeError|URIError|AggregateError|DOMException|[A-Za-z][A-Za-z0-9]*(?:Error|Failure))$/; | ||
| 3 | const SAFE_TRACE_ID = /^[A-Za-z0-9._:-]{1,128}$/; | ||
| 4 | const STACK_FRAME_LIMIT = 32; | ||
| 5 | |||
| 6 | export 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 | |||
| 14 | function readSafeLabel(value: unknown): string | undefined { | ||
| 15 | return typeof value === "string" && SAFE_ERROR_LABEL.test(value) ? value : undefined; | ||
| 16 | } | ||
| 17 | |||
| 18 | function 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 | |||
| 31 | function sanitizeStackFrame(frame: string): string { | ||
| 32 | return frame.replace(/(?:https?|file):\/\/[^\s)]+/g, sanitizeStackUrl); | ||
| 33 | } | ||
| 34 | |||
| 35 | function 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 | |||
| 48 | function 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 | |||
| 59 | function 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 | |||
| 79 | export 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 @@ | ... | @@ -1,7 +1,10 @@ |
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 2 | import * as Cause from "effect/Cause"; | ||
| 3 | import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; | ||
| 2 | import { expect, it } from "vite-plus/test"; | 4 | import { expect, it } from "vite-plus/test"; |
| 3 | 5 | ||
| 4 | import { | 6 | import { |
| 7 | createArchivedThreadSnapshotsAtomFamily, | ||
| 5 | makeArchivedThreadsEnvironmentKey, | 8 | makeArchivedThreadsEnvironmentKey, |
| 6 | parseArchivedThreadsEnvironmentKey, | 9 | parseArchivedThreadsEnvironmentKey, |
| 7 | } from "./archivedThreads.ts"; | 10 | } from "./archivedThreads.ts"; |
| ... | @@ -13,3 +16,25 @@ it("round-trips environment keys in sorted order", () => { | ... | @@ -13,3 +16,25 @@ it("round-trips environment keys in sorted order", () => { |
| 13 | 16 | ||
| 14 | expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); | 17 | expect(parseArchivedThreadsEnvironmentKey(key)).toEqual([envA, envB]); |
| 15 | }); | 18 | }); |
| 19 | |||
| 20 | it("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 @@ | ... | @@ -1,6 +1,5 @@ |
| 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; | 1 | import { EnvironmentId, type OrchestrationShellSnapshot } from "@t3tools/contracts"; |
| 2 | import * as Arr from "effect/Array"; | 2 | import * as Arr from "effect/Array"; |
| 3 | import * as Cause from "effect/Cause"; | ||
| 4 | import { pipe } from "effect/Function"; | 3 | import { pipe } from "effect/Function"; |
| 5 | import * as Option from "effect/Option"; | 4 | import * as Option from "effect/Option"; |
| 6 | import * as Order from "effect/Order"; | 5 | import * as Order from "effect/Order"; |
| ... | @@ -60,11 +59,7 @@ export function createArchivedThreadSnapshotsAtomFamily<E>(options: { | ... | @@ -60,11 +59,7 @@ export function createArchivedThreadSnapshotsAtomFamily<E>(options: { |
| 60 | } | 59 | } |
| 61 | 60 | ||
| 62 | if (error === null && result._tag === "Failure") { | 61 | if (error === null && result._tag === "Failure") { |
| 63 | const cause = Cause.squash(result.cause); | 62 | error = "Failed to load archived threads."; |
| 64 | error = | ||
| 65 | cause instanceof Error && cause.message.trim().length > 0 | ||
| 66 | ? cause.message | ||
| 67 | : "Failed to load archived threads."; | ||
| 68 | } | 63 | } |
| 69 | } | 64 | } |
| 70 | 65 |
packages/client-runtime/src/state/session.ts+5-3| ... | @@ -8,6 +8,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ... | @@ -8,6 +8,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 8 | import { EnvironmentRegistry } from "../connection/registry.ts"; | 8 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 9 | import type { PreparedConnection } from "../connection/model.ts"; | 9 | import type { PreparedConnection } from "../connection/model.ts"; |
| 10 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 10 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 11 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | ||
| 11 | import { followStreamInEnvironment } from "./runtime.ts"; | 12 | import { followStreamInEnvironment } from "./runtime.ts"; |
| 12 | 13 | ||
| 13 | export function initialConfigOption<E>( | 14 | export function initialConfigOption<E>( |
| ... | @@ -16,9 +17,10 @@ export function initialConfigOption<E>( | ... | @@ -16,9 +17,10 @@ export function initialConfigOption<E>( |
| 16 | return initialConfig.pipe( | 17 | return initialConfig.pipe( |
| 17 | Effect.map(Option.some), | 18 | Effect.map(Option.some), |
| 18 | Effect.catch((error) => | 19 | Effect.catch((error) => |
| 19 | Effect.logWarning("Could not load the initial environment configuration.", { | 20 | Effect.logWarning("Could not load the initial environment configuration.").pipe( |
| 20 | error, | 21 | Effect.annotateLogs({ ...safeErrorLogAttributes(error) }), |
| 21 | }).pipe(Effect.as(Option.none<ServerConfig>())), | 22 | Effect.as(Option.none<ServerConfig>()), |
| 23 | ), | ||
| 22 | ), | 24 | ), |
| 23 | ); | 25 | ); |
| 24 | } | 26 | } |
packages/client-runtime/src/state/shell.ts+17-12| ... | @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; | ... | @@ -16,6 +16,7 @@ import { AsyncResult, Atom } from "effect/unstable/reactivity"; |
| 16 | import { EnvironmentRegistry } from "../connection/registry.ts"; | 16 | import { EnvironmentRegistry } from "../connection/registry.ts"; |
| 17 | import { connectionProjectionPhase } from "../connection/model.ts"; | 17 | import { connectionProjectionPhase } from "../connection/model.ts"; |
| 18 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; | 18 | import { EnvironmentSupervisor } from "../connection/supervisor.ts"; |
| 19 | import { safeErrorLogAttributes } from "../errors/safeLog.ts"; | ||
| 19 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; | 20 | import { EnvironmentCacheStore } from "../platform/persistence.ts"; |
| 20 | import { subscribe } from "../rpc/client.ts"; | 21 | import { subscribe } from "../rpc/client.ts"; |
| 21 | import { applyShellStreamEvent } from "./shellReducer.ts"; | 22 | import { applyShellStreamEvent } from "./shellReducer.ts"; |
| ... | @@ -42,11 +43,7 @@ function shellStatusForSnapshot( | ... | @@ -42,11 +43,7 @@ function shellStatusForSnapshot( |
| 42 | return Option.isSome(snapshot) ? "cached" : "empty"; | 43 | return Option.isSome(snapshot) ? "cached" : "empty"; |
| 43 | } | 44 | } |
| 44 | 45 | ||
| 45 | function formatShellError(error: unknown): string { | 46 | const SHELL_SYNCHRONIZATION_ERROR_MESSAGE = "Could not synchronize environment data."; |
| 46 | return error instanceof Error && error.message.trim().length > 0 | ||
| 47 | ? error.message | ||
| 48 | : "Could not synchronize environment data."; | ||
| 49 | } | ||
| 50 | 47 | ||
| 51 | export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { | 48 | export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make")(function* () { |
| 52 | const supervisor = yield* EnvironmentSupervisor; | 49 | const supervisor = yield* EnvironmentSupervisor; |
| ... | @@ -57,7 +54,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") | ... | @@ -57,7 +54,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 57 | Effect.logWarning("Could not load cached environment shell.").pipe( | 54 | Effect.logWarning("Could not load cached environment shell.").pipe( |
| 58 | Effect.annotateLogs({ | 55 | Effect.annotateLogs({ |
| 59 | environmentId, | 56 | environmentId, |
| 60 | error: error.message, | 57 | ...safeErrorLogAttributes(error), |
| 61 | }), | 58 | }), |
| 62 | Effect.as(Option.none<OrchestrationShellSnapshot>()), | 59 | Effect.as(Option.none<OrchestrationShellSnapshot>()), |
| 63 | ), | 60 | ), |
| ... | @@ -78,7 +75,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") | ... | @@ -78,7 +75,7 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 78 | Effect.logWarning("Could not persist environment shell cache.").pipe( | 75 | Effect.logWarning("Could not persist environment shell cache.").pipe( |
| 79 | Effect.annotateLogs({ | 76 | Effect.annotateLogs({ |
| 80 | environmentId, | 77 | environmentId, |
| 81 | error: error.message, | 78 | ...safeErrorLogAttributes(error), |
| 82 | }), | 79 | }), |
| 83 | ), | 80 | ), |
| 84 | ), | 81 | ), |
| ... | @@ -110,11 +107,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") | ... | @@ -110,11 +107,19 @@ export const makeEnvironmentShellState = Effect.fn("EnvironmentShellState.make") |
| 110 | }, | 107 | }, |
| 111 | ); | 108 | ); |
| 112 | const setStreamError = (error: unknown) => | 109 | const setStreamError = (error: unknown) => |
| 113 | SubscriptionRef.update(state, (current) => ({ | 110 | Effect.logWarning("Could not synchronize the environment shell.").pipe( |
| 114 | ...current, | 111 | Effect.annotateLogs({ |
| 115 | status: shellStatusForSnapshot(current.snapshot), | 112 | environmentId, |
| 116 | error: Option.some(formatShellError(error)), | 113 | ...safeErrorLogAttributes(error), |
| 117 | })); | 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 | ); | ||
| 118 | 123 | ||
| 119 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( | 124 | const applyItem = Effect.fn("EnvironmentShellState.applyItem")(function* ( |
| 120 | item: OrchestrationShellStreamItem, | 125 | item: OrchestrationShellStreamItem, |