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", () => {...@@ -430,9 +430,10 @@ describe("DesktopUpdates", () => {
430 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));430 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
431 });431 });
432432
433 it.effect("logs bounded updater failure context without exposing the cause", () => {433 it.effect("logs the redacted cause of a rejected update check", () => {
434 const cause = new Error(434 const cause = Object.assign(
435 "request failed for https://user:secret@example.com/update?token=secret",435 new Error("request failed for https://user:secret@example.com/update?token=secret"),
436 { code: "ERR_UPDATER_LATEST_VERSION_NOT_FOUND" },
436 );437 );
437 const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({438 const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
438 channel: null,439 channel: null,
...@@ -459,6 +460,10 @@ describe("DesktopUpdates", () => {...@@ -459,6 +460,10 @@ describe("DesktopUpdates", () => {
459 assert.isDefined(loggedAnnotation);460 assert.isDefined(loggedAnnotation);
460 assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError");461 assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError");
461 assert.isNull(loggedAnnotation.channel);462 assert.isNull(loggedAnnotation.channel);
463 assert.equal(
464 loggedAnnotation.cause,
465 "Error [ERR_UPDATER_LATEST_VERSION_NOT_FOUND]: request failed for [redacted URL]",
466 );
462 assert.notProperty(loggedAnnotation, "error");467 assert.notProperty(loggedAnnotation, "error");
463 assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret");468 assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret");
464 assert.equal(469 assert.equal(
...@@ -478,6 +483,49 @@ describe("DesktopUpdates", () => {...@@ -478,6 +483,49 @@ describe("DesktopUpdates", () => {
478 );483 );
479 });484 });
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
481 it.effect("recovers download state after an unexpected setup failure", () => {529 it.effect("recovers download state after an unexpected setup failure", () => {
482 let disableDifferentialCalls = 0;530 let disableDifferentialCalls = 0;
483 const harness = makeHarness({531 const harness = makeHarness({
...@@ -879,3 +927,64 @@ describe("DesktopUpdates", () => {...@@ -879,3 +927,64 @@ describe("DesktopUpdates", () => {
879 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));927 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
880 });928 });
881});929});
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 {...@@ -7,6 +7,7 @@ import {
7 type DesktopUpdateCheckResult,7 type DesktopUpdateCheckResult,
8 type DesktopUpdateState,8 type DesktopUpdateState,
9} from "@t3tools/contracts";9} from "@t3tools/contracts";
10import { errorTag } from "@t3tools/shared/observability";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
11import * as Context from "effect/Context";12import * as Context from "effect/Context";
12import * as DateTime from "effect/DateTime";13import * as DateTime from "effect/DateTime";
...@@ -196,6 +197,63 @@ const {...@@ -196,6 +197,63 @@ const {
196 logError: logUpdaterError,197 logError: logUpdaterError,
197} = DesktopObservability.makeComponentLogger("desktop-updater");198} = 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
199function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {257function parseAppUpdateYml(raw: string): Effect.Effect<Option.Option<AppUpdateYmlConfig>> {
200 const entries: Record<string, string> = {};258 const entries: Record<string, string> = {};
201 for (const line of raw.split("\n")) {259 for (const line of raw.split("\n")) {
...@@ -442,6 +500,7 @@ export const make = Effect.gen(function* () {...@@ -442,6 +500,7 @@ export const make = Effect.gen(function* () {
442 );500 );
443 yield* logUpdaterError(error.message, {501 yield* logUpdaterError(error.message, {
444 errorTag: error._tag,502 errorTag: error._tag,
503 cause: describeUpdateErrorCause(error.cause),
445 channel: error.channel,504 channel: error.channel,
446 });505 });
447 return true;506 return true;
...@@ -485,6 +544,7 @@ export const make = Effect.gen(function* () {...@@ -485,6 +544,7 @@ export const make = Effect.gen(function* () {
485 );544 );
486 yield* logUpdaterError(error.message, {545 yield* logUpdaterError(error.message, {
487 errorTag: error._tag,546 errorTag: error._tag,
547 cause: describeUpdateErrorCause(error.cause),
488 channel: error.channel,548 channel: error.channel,
489 });549 });
490 return { accepted: true, completed: false };550 return { accepted: true, completed: false };
...@@ -507,6 +567,7 @@ export const make = Effect.gen(function* () {...@@ -507,6 +567,7 @@ export const make = Effect.gen(function* () {
507 );567 );
508 yield* logUpdaterError(error.message, {568 yield* logUpdaterError(error.message, {
509 errorTag: error._tag,569 errorTag: error._tag,
570 cause: describeUpdateErrorCause(error.cause),
510 action: error.action,571 action: error.action,
511 });572 });
512 return { accepted: true, completed: false };573 return { accepted: true, completed: false };
...@@ -655,6 +716,7 @@ export const make = Effect.gen(function* () {...@@ -655,6 +716,7 @@ export const make = Effect.gen(function* () {
655 yield* recoverFailedInstall(error.message);716 yield* recoverFailedInstall(error.message);
656 yield* logUpdaterError(error.message, {717 yield* logUpdaterError(error.message, {
657 errorTag: error._tag,718 errorTag: error._tag,
719 cause: describeUpdateErrorCause(error.cause),
658 channel: error.channel,720 channel: error.channel,
659 isSilent: error.isSilent,721 isSilent: error.isSilent,
660 isForceRunAfter: error.isForceRunAfter,722 isForceRunAfter: error.isForceRunAfter,
...@@ -673,6 +735,7 @@ export const make = Effect.gen(function* () {...@@ -673,6 +735,7 @@ export const make = Effect.gen(function* () {
673 yield* recoverFailedInstall(error.message);735 yield* recoverFailedInstall(error.message);
674 yield* logUpdaterError(error.message, {736 yield* logUpdaterError(error.message, {
675 errorTag: error._tag,737 errorTag: error._tag,
738 cause: describeUpdateErrorCause(error.cause),
676 action: error.action,739 action: error.action,
677 });740 });
678 return { accepted: true, completed: false, failed: true };741 return { accepted: true, completed: false, failed: true };
...@@ -712,6 +775,7 @@ export const make = Effect.gen(function* () {...@@ -712,6 +775,7 @@ export const make = Effect.gen(function* () {
712 const error = new DesktopUpdatePollerError({ poller: "startup", cause });775 const error = new DesktopUpdatePollerError({ poller: "startup", cause });
713 return logUpdaterError(error.message, {776 return logUpdaterError(error.message, {
714 errorTag: error._tag,777 errorTag: error._tag,
778 cause: describeUpdateErrorCause(error.cause),
715 poller: error.poller,779 poller: error.poller,
716 });780 });
717 }),781 }),
...@@ -727,6 +791,7 @@ export const make = Effect.gen(function* () {...@@ -727,6 +791,7 @@ export const make = Effect.gen(function* () {
727 const error = new DesktopUpdatePollerError({ poller: "poll", cause });791 const error = new DesktopUpdatePollerError({ poller: "poll", cause });
728 return logUpdaterError(error.message, {792 return logUpdaterError(error.message, {
729 errorTag: error._tag,793 errorTag: error._tag,
794 cause: describeUpdateErrorCause(error.cause),
730 poller: error.poller,795 poller: error.poller,
731 });796 });
732 }),797 }),
...@@ -782,6 +847,7 @@ export const make = Effect.gen(function* () {...@@ -782,6 +847,7 @@ export const make = Effect.gen(function* () {
782 const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });847 const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });
783 return logUpdaterWarning(error.message, {848 return logUpdaterWarning(error.message, {
784 errorTag: error._tag,849 errorTag: error._tag,
850 cause: describeUpdateErrorCause(error.cause),
785 event: error.event,851 event: error.event,
786 });852 });
787 }),853 }),
...@@ -811,6 +877,7 @@ export const make = Effect.gen(function* () {...@@ -811,6 +877,7 @@ export const make = Effect.gen(function* () {
811 yield* recoverFailedInstall(error.message);877 yield* recoverFailedInstall(error.message);
812 yield* logUpdaterError(error.message, {878 yield* logUpdaterError(error.message, {
813 errorTag: error._tag,879 errorTag: error._tag,
880 cause: describeUpdateErrorCause(error.cause),
814 operation: error.operation,881 operation: error.operation,
815 });882 });
816 return;883 return;
...@@ -831,6 +898,7 @@ export const make = Effect.gen(function* () {...@@ -831,6 +898,7 @@ export const make = Effect.gen(function* () {
831898
832 yield* logUpdaterError(error.message, {899 yield* logUpdaterError(error.message, {
833 errorTag: error._tag,900 errorTag: error._tag,
901 cause: describeUpdateErrorCause(error.cause),
834 operation: error.operation,902 operation: error.operation,
835 });903 });
836 });904 });
...@@ -861,6 +929,7 @@ export const make = Effect.gen(function* () {...@@ -861,6 +929,7 @@ export const make = Effect.gen(function* () {
861 const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });929 const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });
862 return logUpdaterWarning(error.message, {930 return logUpdaterWarning(error.message, {
863 errorTag: error._tag,931 errorTag: error._tag,
932 cause: describeUpdateErrorCause(error.cause),
864 event: error.event,933 event: error.event,
865 });934 });
866 }),935 }),
...@@ -885,6 +954,7 @@ export const make = Effect.gen(function* () {...@@ -885,6 +954,7 @@ export const make = Effect.gen(function* () {
885 const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });954 const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });
886 return logUpdaterWarning(error.message, {955 return logUpdaterWarning(error.message, {
887 errorTag: error._tag,956 errorTag: error._tag,
957 cause: describeUpdateErrorCause(error.cause),
888 event: error.event,958 event: error.event,
889 });959 });
890 }),960 }),