feat(logging): capture frontend errors to disk with structured redaction

Frontend half of the logging overhaul.

Capture:
- Add a global error boundary plus window error/unhandledrejection
  handlers that persist renderer errors via tauri-plugin-log (new
  @tauri-apps/plugin-log dependency + log:default capability), so a
  white-screen crash leaves a trace.

Redaction (two layers over every logged value):
- Structured serializer: redact by sensitive property name (arrays and
  nested values included) and by opaque value shape.
- Text layer, the universal final pass: redact URL credentials/query,
  auth schemes, known secret shapes, and named-key array/object values.
  The container rule closes prefix+JSON, double-encoded, and POJO-error
  bypasses regardless of which entrypoint the structured data came from.
- Render Errors as redacted message + native stack across V8 and WebKit
  (macOS/Linux) without depending on a single stack format.
- Bound all inputs and drop oversized JSON to keep the UI responsive.

i18n:
- zh / en / ja / zh-TW for the new log settings and error text.
This commit is contained in:
Jason
2026-07-16 10:21:00 +08:00
parent 22d2872c33
commit 6274705848
12 changed files with 853 additions and 46 deletions
@@ -0,0 +1,36 @@
const reportFrontendError = vi.hoisted(() => vi.fn());
vi.mock("@/lib/frontendLogger", () => ({
reportFrontendError,
}));
import { render, screen } from "@testing-library/react";
import { FrontendErrorBoundary } from "@/components/FrontendErrorBoundary";
function ThrowingChild(): React.ReactNode {
throw new Error("sensitive render failure");
}
describe("FrontendErrorBoundary", () => {
it("reports render failures and replaces the broken tree", () => {
const consoleError = vi
.spyOn(console, "error")
.mockImplementation(() => undefined);
render(
<FrontendErrorBoundary>
<ThrowingChild />
</FrontendErrorBoundary>,
);
expect(screen.getByRole("alert")).toBeInTheDocument();
expect(screen.getByRole("button")).toBeInTheDocument();
expect(reportFrontendError).toHaveBeenCalledWith(
"react.error_boundary",
expect.objectContaining({ message: "sensitive render failure" }),
expect.any(String),
);
consoleError.mockRestore();
});
});
+327
View File
@@ -0,0 +1,327 @@
const writeErrorLog = vi.hoisted(() =>
vi.fn<(message: string, options: { file: string }) => Promise<void>>(() =>
Promise.resolve(),
),
);
vi.mock("@tauri-apps/plugin-log", () => ({
error: writeErrorLog,
}));
import {
installGlobalErrorHandlers,
redactFrontendLogText,
reportFrontendError,
} from "@/lib/frontendLogger";
describe("frontendLogger", () => {
beforeEach(() => {
writeErrorLog.mockClear();
});
it("redacts URL parameters and named credentials", () => {
const redacted = redactFrontendLogText(
"https://example.test/path?apiKey=query-secret&name=alice\n" +
'api_key: "config secret with spaces"\n' +
"Authorization: Bearer bearer-secret\n" +
"Authorization: Basic dXNlcjpwYXNz\n" +
"Authorization: Token token-secret\n" +
" Cookie: session=cookie-secret; preference=private\n" +
"ApiKey standalone-secret\n" +
"https://user:password@example.test/private",
);
expect(redacted).not.toContain("query-secret");
expect(redacted).not.toContain("alice");
expect(redacted).not.toContain("config secret with spaces");
expect(redacted).not.toContain("secret with spaces");
expect(redacted).not.toContain("bearer-secret");
expect(redacted).not.toContain("dXNlcjpwYXNz");
expect(redacted).not.toContain("token-secret");
expect(redacted).not.toContain("standalone-secret");
expect(redacted).not.toContain("cookie-secret");
expect(redacted).not.toContain("preference=private");
expect(redacted).not.toContain("user:password");
expect(redacted).toContain("apiKey=[REDACTED]");
expect(redacted).toContain('api_key: "[REDACTED]"');
const escapedMultiline = redactFrontendLogText(
'{"detail":"line1\\nsk-ant-api03-escaped-secret"}',
);
expect(escapedMultiline).not.toContain("sk-ant-api03-escaped-secret");
});
it("writes bounded, redacted errors through the Tauri log plugin", () => {
const secret = "sensitive-token";
const oversizedDetails = `${"x".repeat(2_000_000)} token=${secret}`;
reportFrontendError(
"window.error",
new Error(`failed at https://example.test/?key=${secret}`),
oversizedDetails,
);
expect(writeErrorLog).toHaveBeenCalledOnce();
const [message, options] = writeErrorLog.mock.calls[0];
expect(message).not.toContain(secret);
expect(message).toContain("[truncated]");
expect(message.length).toBeLessThanOrEqual(12_020);
expect(options).toEqual({ file: "frontend" });
});
it("serializes object-shaped rejection reasons without exposing secrets", () => {
// 两层脱敏契约:
// - 属性名一级:命中敏感名(含复数/数组/嵌套)整个值一律隐藏;
// - 值一级:任意位置的“不透明密钥形状”按形状隐藏;
// - 文本一级:序列化后 `"name":"value"` 的裸密钥由正则兜底。
reportFrontendError("unhandledrejection", {
code: 500,
message: "auth failed", // 良性文本保留(值含 "auth" 但非 name:value)
key: "short-secret", // 裸 key 标量(非不透明) → 属性名层
token: "object-secret", // 标量命名 → 属性名层
tokens: ["k-9f3a7c2b1e"], // 复数+数组(短值) → 属性名层(去尾 s + 整体隐藏)
auth: ["opaque-credential"], // 数组 → 属性名层
credential: "AIzaRealCredential123",
nested: { detail: "ghp_abcdef123456" }, // 非敏感名,靠不透明形状
values: ["eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2lnbmF0dXJl"], // 数组内不透明形状
multiline: "line1\nsk-ant-api03-multiline-secret", // 串内不透明形状
session: { activeTab: "providers", scrollPos: 120 }, // 良性状态原样保留
});
const [message] = writeErrorLog.mock.calls[0];
expect(message).toContain('"code":500');
expect(message).toContain('"message":"auth failed"');
expect(message).not.toContain("short-secret");
expect(message).not.toContain("object-secret");
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).not.toContain("opaque-credential");
expect(message).not.toContain("AIzaRealCredential123");
expect(message).not.toContain("ghp_abcdef123456");
expect(message).not.toContain("eyJhbGciOiJIUzI1NiJ9");
expect(message).not.toContain("sk-ant-api03-multiline-secret");
expect(message).toContain('"key":"[REDACTED]"');
expect(message).toContain('"token":"[REDACTED]"');
expect(message).toContain('"tokens":"[REDACTED]"');
expect(message).toContain('"auth":"[REDACTED]"');
expect(message).toContain('"credential":"[REDACTED]"');
expect(message).toContain(
'"session":{"activeTab":"providers","scrollPos":120}',
);
});
it("applies property-level redaction to stringified-JSON rejection reasons", () => {
// 字符串形态的 JSON 不能只靠文本正则——数组/复数/裸标量字段会漏。
reportFrontendError(
"unhandledrejection",
'{"tokens":["k-9f3a7c2b1e"],"auth":["opaque-credential"],"key":"short-secret","keepMe":"visible"}',
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).not.toContain("opaque-credential");
expect(message).not.toContain("short-secret");
expect(message).toContain('"tokens":"[REDACTED]"');
expect(message).toContain('"auth":"[REDACTED]"');
expect(message).toContain('"key":"[REDACTED]"');
expect(message).toContain('"keepMe":"visible"');
});
it("keeps non-JSON error strings as readable text", () => {
reportFrontendError("window.error", "plain failure at step 3");
const [message] = writeErrorLog.mock.calls[0];
expect(message).toContain("plain failure at step 3");
});
it("applies property-level redaction to JSON wrapped in an Error message", () => {
// throw new Error(JSON.stringify(payload)) 会把凭据藏进 message
// 而 error.stack 第一行原样吐出 message —— 必须先对 message 结构化脱敏。
reportFrontendError(
"unhandledrejection",
new Error(
'{"tokens":["k-9f3a7c2b1e"],"key":"short-secret","keepMe":"visible"}',
),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).not.toContain("short-secret");
expect(message).toContain('"tokens":"[REDACTED]"');
expect(message).toContain('"key":"[REDACTED]"');
expect(message).toContain('"keepMe":"visible"');
});
it("omits oversized JSON error strings instead of leaking truncated fields", () => {
// 合法但超长的 JSON 若先截断再 parse,必成非法 JSON 而退回文本层,数组字段泄漏。
const padding = "x".repeat(20_000);
reportFrontendError(
"unhandledrejection",
`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`,
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain("[oversized structured error omitted]");
});
it("omits oversized JSON wrapped in an Error message", () => {
const padding = "x".repeat(20_000);
reportFrontendError(
"unhandledrejection",
new Error(`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain("[oversized structured error omitted]");
});
it("redacts array credentials in prefix+JSON rejection strings", () => {
// 前缀 + JSON`redactStructuredString` 的 startsWith 门被前缀挡掉,退回文本层。
// 文本层的容器正则必须在这个统一出口兜住 `"tokens":[...]`。
reportFrontendError(
"unhandledrejection",
'Load failed: {"tokens":["ak_live_7f3d9b21c8e4"]}',
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
expect(message).toContain("Load failed");
});
it("redacts array credentials in prefix+JSON wrapped in an Error", () => {
reportFrontendError(
"unhandledrejection",
new Error(
"Provider provisioning failed: " +
JSON.stringify({ apiKeys: ["ak_live_7f3d9b21c8e4"] }),
),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
});
it("redacts credentials in double-encoded nested JSON (escaped quotes)", () => {
reportFrontendError(
"unhandledrejection",
new Error(
JSON.stringify({
status: 400,
body: '{"keys":["abcd1234efgh5678ij"]}',
}),
),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("abcd1234efgh5678ij");
});
it("redacts array credentials in a POJO (non-Error) rejection", () => {
reportFrontendError("unhandledrejection", {
name: "HttpError",
message: '{"tokens":["opaqueTokenValue12345"]}',
code: 400,
});
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("opaqueTokenValue12345");
});
it("redacts container values only under sensitive keys", () => {
// 容器正则只对敏感键生效:普通数组/对象(items/config)与后缀含 key 的词(monkey)不误伤。
const redacted = redactFrontendLogText(
'{"tokens":["k-secret-1"],"monkey":["visible-a"],"items":["visible-b"],"config":{"theme":"dark"}}',
);
expect(redacted).not.toContain("k-secret-1");
expect(redacted).toContain("visible-a");
expect(redacted).toContain("visible-b");
expect(redacted).toContain('"theme":"dark"');
});
it("preserves native WebKit-style stack frames for JSON-wrapped errors", () => {
// macOS/Linux 的 WKWebView 用 `fn@file:line:col` 格式,且 stack 不含 message。
// 旧的 `/^\s+at\s/` 过滤会把整段栈丢掉;新实现须补脱敏头并保留原生栈。
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
Object.defineProperty(err, "stack", {
value:
"handleClick@tauri://localhost/assets/index.js:42:15\n" +
"dispatch@tauri://localhost/assets/index.js:99:3",
});
reportFrontendError("unhandledrejection", err);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain('"tokens":"[REDACTED]"'); // 脱敏 message 头
expect(message).toContain("handleClick@tauri://localhost"); // 原生栈帧保留
expect(message).toContain("dispatch@tauri://localhost");
});
it("replaces every occurrence of the raw message in a V8-style stack", () => {
// message 若在 stack 里出现多次(eval/匿名帧回显),字面量替换必须全部换掉,零残留。
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
Object.defineProperty(err, "stack", {
value:
'Error: {"tokens":["k-9f3a7c2b1e"]}\n' +
' at eval (eval at <anonymous>, {"tokens":["k-9f3a7c2b1e"]}:1:1)\n' +
" at run (index.js:10:5)",
});
reportFrontendError("unhandledrejection", err);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("k-9f3a7c2b1e");
expect(message).toContain("at run (index.js:10:5)"); // 栈帧保留
});
it("redacts standalone secret shapes in ordinary error text", () => {
reportFrontendError(
"window.error",
new Error("request failed with sk-ant-api03-real-secret"),
);
const [message] = writeErrorLog.mock.calls[0];
expect(message).not.toContain("sk-ant-api03-real-secret");
expect(message).toContain("[REDACTED]");
});
it("handles circular rejection objects", () => {
const reason: Record<string, unknown> = { message: "failed" };
reason.self = reason;
expect(() =>
reportFrontendError("unhandledrejection", reason),
).not.toThrow();
expect(writeErrorLog.mock.calls[0][0]).toContain("[Circular]");
});
it("captures global errors and unhandled rejections and can uninstall", () => {
const target = new EventTarget() as unknown as Window;
const uninstall = installGlobalErrorHandlers(target);
const errorEvent = new Event("error") as ErrorEvent;
Object.defineProperties(errorEvent, {
error: { value: new Error("render failed") },
filename: { value: "app.js" },
lineno: { value: 10 },
colno: { value: 4 },
});
target.dispatchEvent(errorEvent);
const rejectionEvent = new Event(
"unhandledrejection",
) as PromiseRejectionEvent;
Object.defineProperty(rejectionEvent, "reason", {
value: new Error("request failed"),
});
target.dispatchEvent(rejectionEvent);
expect(writeErrorLog).toHaveBeenCalledTimes(2);
uninstall();
target.dispatchEvent(errorEvent);
expect(writeErrorLog).toHaveBeenCalledTimes(2);
});
});