authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-22 17:52:15-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 17:52:15-07:00
loga493946bb42ab16e1d18285dac3e9e4603651330
treea08904dbe01066e57712db66bd3d7aef0f231f13
parentca864a25b14813e390cbcaeb334b97cd61717ce3
signature Signed by PGP key B5690EEEBB952194

fix(mobile): keep ordinary offline outbox failures out of console.warn (#13144)

Co-authored-by: Apex by Callstack <noreply@callstack.com>

4 files changed, 189 insertions(+), 47 deletions(-)

apps/mobile/src/features/cloud/cloudDebugLog.ts+9-13
...@@ -1,18 +1,14 @@...@@ -1,18 +1,14 @@
1import { createDebugLogger } from "../../lib/debugLog";
2
3const logger = createDebugLogger("cloud", {
4 enabledInDev: true,
5 legacyGlobalFlag: "__T3_CLOUD_DEBUG__",
6});
7
1export function isCloudDebugEnabled(): boolean {8export function isCloudDebugEnabled(): boolean {
2 return (9 return logger.isEnabled();
3 (typeof __DEV__ !== "undefined" && __DEV__) ||
4 (typeof globalThis !== "undefined" &&
5 (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true)
6 );
7}10}
811
9export function cloudDebugLog(event: string, data?: Record<string, unknown>): void {12export function cloudDebugLog(event: string, data?: Record<string, unknown>): void {
10 if (!isCloudDebugEnabled()) {13 logger.log(event, data);
11 return;
12 }
13 if (data) {
14 console.log(`[t3-cloud] ${event}`, data);
15 } else {
16 console.log(`[t3-cloud] ${event}`);
17 }
18}14}
apps/mobile/src/features/terminal/terminalDebugLog.ts+12-15
...@@ -1,24 +1,21 @@...@@ -1,24 +1,21 @@
1import { createDebugLogger } from "../../lib/debugLog";
2
1/**3/**
2 * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`.4 * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`.
3 *5 *
4 * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS6 * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__`
5 * debugger / Metro console to trace release/TestFlight builds.7 * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro
8 * console to trace release/TestFlight builds.
6 */9 */
10const logger = createDebugLogger("terminal", {
11 enabledInDev: true,
12 legacyGlobalFlag: "__T3_TERMINAL_DEBUG__",
13});
14
7export function isTerminalDebugEnabled(): boolean {15export function isTerminalDebugEnabled(): boolean {
8 return (16 return logger.isEnabled();
9 (typeof __DEV__ !== "undefined" && __DEV__) ||
10 (typeof globalThis !== "undefined" &&
11 (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true)
12 );
13}17}
1418
15export function terminalDebugLog(message: string, data?: Record<string, unknown>): void {19export function terminalDebugLog(message: string, data?: Record<string, unknown>): void {
16 if (!isTerminalDebugEnabled()) {20 logger.log(message, data);
17 return;
18 }
19 if (data !== undefined) {
20 console.log(`[t3-terminal] ${message}`, data);
21 } else {
22 console.log(`[t3-terminal] ${message}`);
23 }
24}21}
apps/mobile/src/lib/debugLog.ts created+64
...@@ -0,0 +1,64 @@
1/**
2 * Namespaced, filterable debug logging shared by mobile subsystems.
3 *
4 * Ordinary, expected conditions — a queued send failing while the device is
5 * offline, for example — go through a debug logger instead of `console.warn`
6 * so warning output stays reserved for failures someone can act on. Output
7 * uses `console.log` with a `[t3-<namespace>]` prefix, matching the existing
8 * cloud and terminal debug logs. (client-runtime cannot host this: its
9 * tooling bans `console.*` in favor of Effect logging.)
10 *
11 * A logger is silent in every build, including development, unless enabled.
12 * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds:
13 * - `globalThis.__T3_DEBUG__ = true` enables every namespace;
14 * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones.
15 *
16 * Subsystems whose traces are useful by default in development (`__DEV__`)
17 * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older
18 * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working.
19 */
20
21export interface DebugLogger {
22 readonly isEnabled: () => boolean;
23 readonly log: (event: string, data?: Record<string, unknown>) => void;
24}
25
26export interface DebugLoggerOptions {
27 /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */
28 readonly enabledInDev?: boolean;
29 /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */
30 readonly legacyGlobalFlag?: string;
31}
32
33function globalValue(name: string): unknown {
34 return typeof globalThis === "undefined"
35 ? undefined
36 : (globalThis as Record<string, unknown>)[name];
37}
38
39export function createDebugLogger(
40 namespace: string,
41 options: DebugLoggerOptions = {},
42): DebugLogger {
43 const isEnabled = () => {
44 if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) {
45 return true;
46 }
47 if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) {
48 return true;
49 }
50 const filter = globalValue("__T3_DEBUG__");
51 return filter === true || (Array.isArray(filter) && filter.includes(namespace));
52 };
53 const log = (event: string, data?: Record<string, unknown>) => {
54 if (!isEnabled()) {
55 return;
56 }
57 if (data === undefined) {
58 console.log(`[t3-${namespace}] ${event}`);
59 } else {
60 console.log(`[t3-${namespace}] ${event}`, data);
61 }
62 };
63 return { isEnabled, log };
64}
apps/mobile/src/state/use-thread-outbox-drain.ts+104-19
...@@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity";...@@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity";
17import { useCallback, useEffect, useRef, useState } from "react";17import { useCallback, useEffect, useRef, useState } from "react";
18import { Alert } from "react-native";18import { Alert } from "react-native";
1919
20import { createDebugLogger } from "../lib/debugLog";
20import { scopedThreadKey } from "../lib/scopedEntities";21import { scopedThreadKey } from "../lib/scopedEntities";
21import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";22import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn";
22import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext";23import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext";
...@@ -55,6 +56,7 @@ import {...@@ -55,6 +56,7 @@ import {
55 type QueuedThreadCreation,56 type QueuedThreadCreation,
56 type QueuedThreadMessage,57 type QueuedThreadMessage,
57 type ThreadOutboxCommandStage,58 type ThreadOutboxCommandStage,
59 type ThreadOutboxFailureAction,
58} from "./thread-outbox-model";60} from "./thread-outbox-model";
59import { environmentThreadShells, threadEnvironment } from "./threads";61import { environmentThreadShells, threadEnvironment } from "./threads";
60import {62import {
...@@ -82,6 +84,92 @@ import {...@@ -82,6 +84,92 @@ import {
82 useRemoteConnectionStatus,84 useRemoteConnectionStatus,
83} from "./use-remote-environment-registry";85} from "./use-remote-environment-registry";
8486
87// Ordinary offline behavior (a socket dropping mid-request, a retryable
88// attachment upload failure) must not spam `console.warn` on every backoff
89// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead.
90// Failures the server decided stay on `console.warn`.
91const threadOutboxDebug = createDebugLogger("thread-outbox");
92
93/**
94 * On the queued-request path (settings sync, startTurn) the RPC client
95 * reports ordinary transport drops as the raw socket/worker reason tags, and
96 * reserves `RpcClientDefect` for client-side protocol violations and decoding
97 * failures — unlike the shared config-subscription stream, which
98 * deliberately re-wraps transport causes under that tag. Defects still retry,
99 * but they are not ordinary offline behavior and must not hide behind the
100 * offline debug log.
101 */
102function isRpcClientDecodeDefect(error: unknown): boolean {
103 if (
104 typeof error !== "object" ||
105 error === null ||
106 !("_tag" in error) ||
107 error._tag !== "RpcClientError"
108 ) {
109 return false;
110 }
111 const reason: unknown = (error as { readonly reason?: unknown }).reason;
112 return (
113 typeof reason === "object" &&
114 reason !== null &&
115 "_tag" in reason &&
116 reason._tag === "RpcClientDefect"
117 );
118}
119
120function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean {
121 return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error);
122}
123
124/**
125 * Logs one queued-message delivery failure and returns the retry-or-restore
126 * decision for the caller. Ordinary transport retries — what an offline
127 * device or a flapping socket produces on every backoff attempt — go to the
128 * debug log. Server-decided failures warn. Settings-sync failures always
129 * resolve to a retry even when the server rejected the command, so the
130 * error, not the resolved action, must decide the log level there; routing
131 * every retry to debug could hide a permanently rejected update forever.
132 */
133function logThreadOutboxDeliveryFailure(input: {
134 readonly stage: ThreadOutboxCommandStage;
135 readonly error: unknown;
136 readonly interrupted: boolean;
137 readonly context: Record<string, unknown>;
138}): ThreadOutboxFailureAction {
139 const action = resolveThreadOutboxFailureAction({
140 stage: input.stage,
141 error: input.error,
142 interrupted: input.interrupted,
143 });
144 const details = { ...input.context, stage: input.stage, action };
145 const ordinaryTransportRetry =
146 action === "retry" &&
147 !isRpcClientDecodeDefect(input.error) &&
148 (input.interrupted ||
149 input.stage !== "settings-sync" ||
150 shouldRetryThreadOutboxDelivery(input.error));
151 if (ordinaryTransportRetry) {
152 threadOutboxDebug.log("queued message delivery failed", details);
153 } else {
154 console.warn("[thread-outbox] queued message delivery failed", details);
155 }
156 return action;
157}
158
159/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */
160function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void {
161 const context = {
162 environmentId: queuedMessage.environmentId,
163 threadId: queuedMessage.threadId,
164 messageId: queuedMessage.messageId,
165 };
166 if (isOrdinaryThreadOutboxTransportFailure(error)) {
167 threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error });
168 } else {
169 console.warn("[thread-outbox] failed to upload attachments", { ...context, error });
170 }
171}
172
85function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {173function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void {
86 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);174 appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId);
87}175}
...@@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery(...@@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery(
215 );303 );
216 if (!removed) {304 if (!removed) {
217 forgetAcknowledgedThreadMessage(queuedMessage);305 forgetAcknowledgedThreadMessage(queuedMessage);
218 console.warn(306 // Losing the cleanup race to a user edit is an expected outcome the
219 "[thread-outbox] delivered message was edited before cleanup; keeping the newer message",307 // caller handles by keeping the newer message; it is not a warning.
220 {308 threadOutboxDebug.log("delivered message was edited before cleanup", {
221 environmentId: queuedMessage.environmentId,309 environmentId: queuedMessage.environmentId,
222 threadId: queuedMessage.threadId,310 threadId: queuedMessage.threadId,
223 messageId: queuedMessage.messageId,311 messageId: queuedMessage.messageId,
224 },312 });
225 );
226 return "edited";313 return "edited";
227 }314 }
228 return "removed";315 return "removed";
...@@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void {...@@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void {
667 return null;754 return null;
668 }755 }
669 const error = Cause.squash(commandResult.cause);756 const error = Cause.squash(commandResult.cause);
670 const action = resolveThreadOutboxFailureAction({757 const action = logThreadOutboxDeliveryFailure({
671 stage,758 stage,
672 error,759 error,
673 interrupted: Cause.hasInterruptsOnly(commandResult.cause),760 interrupted: Cause.hasInterruptsOnly(commandResult.cause),
674 });761 context: {
675 console.warn("[thread-outbox] queued message delivery failed", {762 environmentId: queuedMessage.environmentId,
676 environmentId: queuedMessage.environmentId,763 threadId: queuedMessage.threadId,
677 threadId: queuedMessage.threadId,764 messageId: queuedMessage.messageId,
678 messageId: queuedMessage.messageId,765 cause: commandResult.cause,
679 stage,766 },
680 cause: commandResult.cause,
681 action,
682 });767 });
683 return {768 return {
684 action,769 action,
...@@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void {...@@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void {
772 return true;857 return true;
773 }858 }
774 } catch (error) {859 } catch (error) {
775 console.warn("[thread-outbox] failed to upload attachments", error);860 logThreadOutboxUploadFailure(queuedMessage, error);
776 if (!shouldRetryThreadOutboxDelivery(error)) {861 if (!shouldRetryThreadOutboxDelivery(error)) {
777 return restoreQueuedMessage(862 return restoreQueuedMessage(
778 queuedMessage,863 queuedMessage,
...@@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void {...@@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void {
900 return true;985 return true;
901 }986 }
902 } catch (error) {987 } catch (error) {
903 console.warn("[thread-outbox] failed to upload attachments", error);988 logThreadOutboxUploadFailure(queuedMessage, error);
904 if (!shouldRetryThreadOutboxDelivery(error)) {989 if (!shouldRetryThreadOutboxDelivery(error)) {
905 return restoreQueuedMessage(990 return restoreQueuedMessage(
906 queuedMessage,991 queuedMessage,