authorgravatar for saiansh2525@gmail.comYash Singh <saiansh2525@gmail.com> 2026-10-12 19:29:10-05:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-12 19:29:10-05:00
log979589d6d82d2757188f977dc4869813183ebb49
tree49a17396af161cdb98491afa0541969f3bff506f
parentb03534fa7b847e8403322e416189c6293e6846e0
signature Signed by PGP key B5690EEEBB952194

fix(desktop): preserve updater failure causes in diagnostics (#18864)


2 files changed, 182 insertions(+), 3 deletions(-)

apps/desktop/src/updates/DesktopUpdates.test.ts+112-3
......@@ -430,9 +430,10 @@ describe("DesktopUpdates", () => {
430430 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
431431 });
432432
433 it.effect("logs bounded updater failure context without exposing the cause", () => {
434 const cause = new Error(
435 "request failed for https://user:secret@example.com/update?token=secret",
433 it.effect("logs the redacted cause of a rejected update check", () => {
434 const cause = Object.assign(
435 new Error("request failed for https://user:secret@example.com/update?token=secret"),
436 { code: "ERR_UPDATER_LATEST_VERSION_NOT_FOUND" },
436437 );
437438 const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
438439 channel: null,
......@@ -459,6 +460,10 @@ describe("DesktopUpdates", () => {
459460 assert.isDefined(loggedAnnotation);
460461 assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError");
461462 assert.isNull(loggedAnnotation.channel);
463 assert.equal(
464 loggedAnnotation.cause,
465 "Error [ERR_UPDATER_LATEST_VERSION_NOT_FOUND]: request failed for [redacted URL]",
466 );
462467 assert.notProperty(loggedAnnotation, "error");
463468 assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret");
464469 assert.equal(
......@@ -478,6 +483,49 @@ describe("DesktopUpdates", () => {
478483 );
479484 });
480485
486 it.effect("logs the redacted cause of an updater error event", () => {
487 const harness = makeHarness();
488 const cause = Object.assign(new Error("404 Not Found\nHeaders: authorization=secret"), {
489 code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND",
490 });
491 const loggedAnnotations: Array<Record<string, unknown>> = [];
492 const logger = Logger.make(({ fiber }) => {
493 const annotations = fiber.getRef(References.CurrentLogAnnotations);
494 if (annotations.errorTag === "DesktopUpdaterReportedError") {
495 loggedAnnotations.push(annotations);
496 }
497 });
498
499 return Effect.scoped(
500 Effect.gen(function* () {
501 const updates = yield* DesktopUpdates.DesktopUpdates;
502 yield* updates.configure;
503
504 harness.emit("error", cause);
505 yield* flushCallbacks;
506
507 const loggedAnnotation = loggedAnnotations.at(-1);
508 assert.isDefined(loggedAnnotation);
509 assert.equal(loggedAnnotation.operation, "background");
510 assert.equal(
511 loggedAnnotation.cause,
512 "Error [ERR_UPDATER_CHANNEL_FILE_NOT_FOUND]: 404 Not Found",
513 );
514 assert.notInclude(JSON.stringify(loggedAnnotations), "secret");
515 const state = yield* updates.getState;
516 assert.equal(state.message, "Desktop updater background operation reported an error.");
517 }),
518 ).pipe(
519 Effect.provide(
520 Layer.mergeAll(
521 TestClock.layer(),
522 harness.layer,
523 Logger.layer([logger], { mergeWithExisting: false }),
524 ),
525 ),
526 );
527 });
528
481529 it.effect("recovers download state after an unexpected setup failure", () => {
482530 let disableDifferentialCalls = 0;
483531 const harness = makeHarness({
......@@ -879,3 +927,64 @@ describe("DesktopUpdates", () => {
879927 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
880928 });
881929});
930
931describe("describeUpdateErrorCause", () => {
932 it("preserves network and nested failure reasons", () => {
933 const networkError = Object.assign(new Error("getaddrinfo ENOTFOUND updates.example.com"), {
934 code: "ENOTFOUND",
935 });
936 const cause = new Error("fetch failed", { cause: networkError });
937
938 assert.equal(
939 DesktopUpdates.describeUpdateErrorCause(Cause.fail(cause)),
940 "Error: fetch failed; caused by Error [ENOTFOUND]: getaddrinfo ENOTFOUND updates.example.com",
941 );
942 assert.include(DesktopUpdates.describeUpdateErrorCause(Cause.die(networkError)), "[ENOTFOUND]");
943 });
944
945 it("removes URLs, credentials, response bodies, and headers from summaries", () => {
946 const messages = [
947 "Cannot download https://user:url-secret@example.com/update?token=query-secret, status 403",
948 "Request failed: Bearer bearer-secret",
949 "Request failed: Basic basic-secret",
950 "Request failed: Authorization: Bearer header-secret",
951 "Request failed: Cookie=session-secret",
952 "Request failed: api_key=\"key-secret\" password='password-secret' access_token=token-secret",
953 '404 Not Found\n{"body":"body-secret"}\nHeaders: cookie=header-secret',
954 "Request failed\rHeaders: cookie=header-secret",
955 ];
956
957 for (const message of messages) {
958 const summary = DesktopUpdates.describeUpdateErrorCause(new Error(message));
959 assert.include(summary, "Error:");
960 assert.notInclude(summary, "secret");
961 assert.notInclude(summary, "https://");
962 assert.notInclude(summary, "Headers:");
963 }
964 assert.equal(
965 DesktopUpdates.describeUpdateErrorCause("404 Not Found\nbody-secret"),
966 "404 Not Found",
967 );
968 });
969
970 it("bounds long messages, parallel failures, and cyclic causes", () => {
971 const longError = new Error("x".repeat(10_000));
972 const parallel = Cause.combine(Cause.fail(longError), Cause.die(longError));
973 assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(parallel).length, 1024);
974
975 const cyclic = new Error("retry failed");
976 cyclic.cause = cyclic;
977 assert.include(DesktopUpdates.describeUpdateErrorCause(cyclic), "[truncated cause]");
978 assert.isAtMost(DesktopUpdates.describeUpdateErrorCause(cyclic).length, 1024);
979 });
980
981 it("does not serialize arbitrary payloads or let unreadable errors break logging", () => {
982 assert.equal(DesktopUpdates.describeUpdateErrorCause({ payload: "secret" }), "object");
983 const unreadable = Object.defineProperty({}, "message", {
984 get() {
985 throw new Error("unreadable error");
986 },
987 });
988 assert.equal(DesktopUpdates.describeUpdateErrorCause(unreadable), "UnknownError");
989 });
990});
apps/desktop/src/updates/DesktopUpdates.ts+70
......@@ -7,6 +7,7 @@ import {
77 type DesktopUpdateCheckResult,
88 type DesktopUpdateState,
99} from "@t3tools/contracts";
10import { errorTag } from "@t3tools/shared/observability";
1011import * as Cause from "effect/Cause";
1112import * as Context from "effect/Context";
1213import * as DateTime from "effect/DateTime";
......@@ -196,6 +197,63 @@ const {
196197 logError: logUpdaterError,
197198} = DesktopObservability.makeComponentLogger("desktop-updater");
198199
200function redactMessage(message: string): string {
201 // Updater HTTP errors append response bodies and headers after the first line.
202 return (message.split(/[\r\n]/, 1)[0] ?? "")
203 .replace(/\b(?:https?|file):\/\/[^\s"'<>]+/gi, "[redacted URL]")
204 .replace(/\b(?:Bearer|Basic)\s+[^\s,;]+/gi, "[redacted credential]")
205 .replace(
206 /\b(?:authorization|proxy-authorization|cookie|set-cookie)\s*[:=].*$/gi,
207 "[redacted header]",
208 )
209 .replace(
210 /\b[\w-]*(?:token|password|secret|credential|api[_-]?key)[\w-]*\s*[:=]\s*(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi,
211 "[redacted credential]",
212 )
213 .slice(0, 512);
214}
215
216function summarizeCause(cause: unknown, depth: number): string {
217 if (depth >= 3) return "[truncated cause]";
218 if (Cause.isCause(cause)) {
219 return cause.reasons
220 .slice(0, 3)
221 .map((reason) =>
222 reason._tag === "Fail"
223 ? summarizeCause(reason.error, depth + 1)
224 : reason._tag === "Die"
225 ? summarizeCause(reason.defect, depth + 1)
226 : "Interrupted",
227 )
228 .join("; ");
229 }
230 if (typeof cause === "string") return redactMessage(cause);
231 if (typeof cause !== "object" || cause === null) return errorTag(cause);
232
233 const error = cause as {
234 readonly message?: unknown;
235 readonly code?: unknown;
236 readonly cause?: unknown;
237 };
238 const code =
239 typeof error.code === "string" && /^[A-Z][A-Z0-9_]{0,127}$/.test(error.code)
240 ? ` [${error.code}]`
241 : "";
242 const message = typeof error.message === "string" ? `: ${redactMessage(error.message)}` : "";
243 const nested =
244 error.cause === undefined ? "" : `; caused by ${summarizeCause(error.cause, depth + 1)}`;
245 return `${errorTag(cause)}${code}${message}${nested}`;
246}
247
248/** Log a bounded cause summary without stacks, response payloads, or request credentials. */
249export function describeUpdateErrorCause(cause: unknown): string {
250 try {
251 return summarizeCause(cause, 0).slice(0, 1024);
252 } catch {
253 return "UnknownError";
254 }
255}
256
199257function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {
200258 const entries: Record<string, string> = {};
201259 for (const line of raw.split("\n")) {
......@@ -442,6 +500,7 @@ export const make = Effect.gen(function* () {
442500 );
443501 yield* logUpdaterError(error.message, {
444502 errorTag: error._tag,
503 cause: describeUpdateErrorCause(error.cause),
445504 channel: error.channel,
446505 });
447506 return true;
......@@ -485,6 +544,7 @@ export const make = Effect.gen(function* () {
485544 );
486545 yield* logUpdaterError(error.message, {
487546 errorTag: error._tag,
547 cause: describeUpdateErrorCause(error.cause),
488548 channel: error.channel,
489549 });
490550 return { accepted: true, completed: false };
......@@ -507,6 +567,7 @@ export const make = Effect.gen(function* () {
507567 );
508568 yield* logUpdaterError(error.message, {
509569 errorTag: error._tag,
570 cause: describeUpdateErrorCause(error.cause),
510571 action: error.action,
511572 });
512573 return { accepted: true, completed: false };
......@@ -655,6 +716,7 @@ export const make = Effect.gen(function* () {
655716 yield* recoverFailedInstall(error.message);
656717 yield* logUpdaterError(error.message, {
657718 errorTag: error._tag,
719 cause: describeUpdateErrorCause(error.cause),
658720 channel: error.channel,
659721 isSilent: error.isSilent,
660722 isForceRunAfter: error.isForceRunAfter,
......@@ -673,6 +735,7 @@ export const make = Effect.gen(function* () {
673735 yield* recoverFailedInstall(error.message);
674736 yield* logUpdaterError(error.message, {
675737 errorTag: error._tag,
738 cause: describeUpdateErrorCause(error.cause),
676739 action: error.action,
677740 });
678741 return { accepted: true, completed: false, failed: true };
......@@ -712,6 +775,7 @@ export const make = Effect.gen(function* () {
712775 const error = new DesktopUpdatePollerError({ poller: "startup", cause });
713776 return logUpdaterError(error.message, {
714777 errorTag: error._tag,
778 cause: describeUpdateErrorCause(error.cause),
715779 poller: error.poller,
716780 });
717781 }),
......@@ -727,6 +791,7 @@ export const make = Effect.gen(function* () {
727791 const error = new DesktopUpdatePollerError({ poller: "poll", cause });
728792 return logUpdaterError(error.message, {
729793 errorTag: error._tag,
794 cause: describeUpdateErrorCause(error.cause),
730795 poller: error.poller,
731796 });
732797 }),
......@@ -782,6 +847,7 @@ export const make = Effect.gen(function* () {
782847 const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });
783848 return logUpdaterWarning(error.message, {
784849 errorTag: error._tag,
850 cause: describeUpdateErrorCause(error.cause),
785851 event: error.event,
786852 });
787853 }),
......@@ -811,6 +877,7 @@ export const make = Effect.gen(function* () {
811877 yield* recoverFailedInstall(error.message);
812878 yield* logUpdaterError(error.message, {
813879 errorTag: error._tag,
880 cause: describeUpdateErrorCause(error.cause),
814881 operation: error.operation,
815882 });
816883 return;
......@@ -831,6 +898,7 @@ export const make = Effect.gen(function* () {
831898
832899 yield* logUpdaterError(error.message, {
833900 errorTag: error._tag,
901 cause: describeUpdateErrorCause(error.cause),
834902 operation: error.operation,
835903 });
836904 });
......@@ -861,6 +929,7 @@ export const make = Effect.gen(function* () {
861929 const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });
862930 return logUpdaterWarning(error.message, {
863931 errorTag: error._tag,
932 cause: describeUpdateErrorCause(error.cause),
864933 event: error.event,
865934 });
866935 }),
......@@ -885,6 +954,7 @@ export const make = Effect.gen(function* () {
885954 const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });
886955 return logUpdaterWarning(error.message, {
887956 errorTag: error._tag,
957 cause: describeUpdateErrorCause(error.cause),
888958 event: error.event,
889959 });
890960 }),