diff --git a/apps/mobile/src/features/cloud/cloudDebugLog.ts b/apps/mobile/src/features/cloud/cloudDebugLog.ts index 840a3db55680f3c874f2010f940497ec85c7f53b..7e57b39b7bb88c848ff0ca7eec2df55270f5cb53 100644 --- a/apps/mobile/src/features/cloud/cloudDebugLog.ts +++ b/apps/mobile/src/features/cloud/cloudDebugLog.ts @@ -1,18 +1,14 @@ +import { createDebugLogger } from "../../lib/debugLog"; + +const logger = createDebugLogger("cloud", { + enabledInDev: true, + legacyGlobalFlag: "__T3_CLOUD_DEBUG__", +}); + export function isCloudDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_CLOUD_DEBUG__?: boolean }).__T3_CLOUD_DEBUG__ === true) - ); + return logger.isEnabled(); } export function cloudDebugLog(event: string, data?: Record): void { - if (!isCloudDebugEnabled()) { - return; - } - if (data) { - console.log(`[t3-cloud] ${event}`, data); - } else { - console.log(`[t3-cloud] ${event}`); - } + logger.log(event, data); } diff --git a/apps/mobile/src/features/terminal/terminalDebugLog.ts b/apps/mobile/src/features/terminal/terminalDebugLog.ts index eb11419b330cc17f82d0442acef146f76cd5e1af..31c8497915c39d0127e0ae3aa649e8ade486dc24 100644 --- a/apps/mobile/src/features/terminal/terminalDebugLog.ts +++ b/apps/mobile/src/features/terminal/terminalDebugLog.ts @@ -1,24 +1,21 @@ +import { createDebugLogger } from "../../lib/debugLog"; + /** * Debug logging for the mobile terminal pipeline. Prefix: `[t3-terminal]`. * - * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__ = true` in a JS - * debugger / Metro console to trace release/TestFlight builds. + * Enabled when `__DEV__` is true, or set `globalThis.__T3_TERMINAL_DEBUG__` + * (or the shared `globalThis.__T3_DEBUG__` filter) in a JS debugger / Metro + * console to trace release/TestFlight builds. */ +const logger = createDebugLogger("terminal", { + enabledInDev: true, + legacyGlobalFlag: "__T3_TERMINAL_DEBUG__", +}); + export function isTerminalDebugEnabled(): boolean { - return ( - (typeof __DEV__ !== "undefined" && __DEV__) || - (typeof globalThis !== "undefined" && - (globalThis as { __T3_TERMINAL_DEBUG__?: boolean }).__T3_TERMINAL_DEBUG__ === true) - ); + return logger.isEnabled(); } export function terminalDebugLog(message: string, data?: Record): void { - if (!isTerminalDebugEnabled()) { - return; - } - if (data !== undefined) { - console.log(`[t3-terminal] ${message}`, data); - } else { - console.log(`[t3-terminal] ${message}`); - } + logger.log(message, data); } diff --git a/apps/mobile/src/lib/debugLog.ts b/apps/mobile/src/lib/debugLog.ts new file mode 100644 index 0000000000000000000000000000000000000000..447973503333bbcf00aa5e7a8078bf41389151a9 --- /dev/null +++ b/apps/mobile/src/lib/debugLog.ts @@ -0,0 +1,64 @@ +/** + * Namespaced, filterable debug logging shared by mobile subsystems. + * + * Ordinary, expected conditions — a queued send failing while the device is + * offline, for example — go through a debug logger instead of `console.warn` + * so warning output stays reserved for failures someone can act on. Output + * uses `console.log` with a `[t3-]` prefix, matching the existing + * cloud and terminal debug logs. (client-runtime cannot host this: its + * tooling bans `console.*` in favor of Effect logging.) + * + * A logger is silent in every build, including development, unless enabled. + * Toggle it from a JS debugger or the Metro console, including on release/TestFlight builds: + * - `globalThis.__T3_DEBUG__ = true` enables every namespace; + * - `globalThis.__T3_DEBUG__ = ["thread-outbox"]` enables only listed ones. + * + * Subsystems whose traces are useful by default in development (`__DEV__`) + * opt in with `enabledInDev`; `legacyGlobalFlag` keeps an older + * subsystem-specific global (e.g. `__T3_CLOUD_DEBUG__`) working. + */ + +export interface DebugLogger { + readonly isEnabled: () => boolean; + readonly log: (event: string, data?: Record) => void; +} + +export interface DebugLoggerOptions { + /** Log whenever `__DEV__` is true, without the global filter. Defaults to false. */ + readonly enabledInDev?: boolean; + /** Name of a legacy subsystem-specific global boolean, e.g. `"__T3_CLOUD_DEBUG__"`. */ + readonly legacyGlobalFlag?: string; +} + +function globalValue(name: string): unknown { + return typeof globalThis === "undefined" + ? undefined + : (globalThis as Record)[name]; +} + +export function createDebugLogger( + namespace: string, + options: DebugLoggerOptions = {}, +): DebugLogger { + const isEnabled = () => { + if (options.enabledInDev === true && typeof __DEV__ !== "undefined" && __DEV__) { + return true; + } + if (options.legacyGlobalFlag !== undefined && globalValue(options.legacyGlobalFlag) === true) { + return true; + } + const filter = globalValue("__T3_DEBUG__"); + return filter === true || (Array.isArray(filter) && filter.includes(namespace)); + }; + const log = (event: string, data?: Record) => { + if (!isEnabled()) { + return; + } + if (data === undefined) { + console.log(`[t3-${namespace}] ${event}`); + } else { + console.log(`[t3-${namespace}] ${event}`, data); + } + }; + return { isEnabled, log }; +} diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 1147e9fd47af41cfb592a1c0d8d544705fcf8716..022c0ad6addbeda357185f771ab0fc5ed4fae561 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -17,6 +17,7 @@ import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useRef, useState } from "react"; import { Alert } from "react-native"; +import { createDebugLogger } from "../lib/debugLog"; import { scopedThreadKey } from "../lib/scopedEntities"; import { buildProjectThreadStartTurnInput } from "../lib/projectThreadStartTurn"; import { serializeComposerMessageForServer, uploadedComposerContext } from "../lib/composerContext"; @@ -55,6 +56,7 @@ import { type QueuedThreadCreation, type QueuedThreadMessage, type ThreadOutboxCommandStage, + type ThreadOutboxFailureAction, } from "./thread-outbox-model"; import { environmentThreadShells, threadEnvironment } from "./threads"; import { @@ -82,6 +84,92 @@ import { useRemoteConnectionStatus, } from "./use-remote-environment-registry"; +// Ordinary offline behavior (a socket dropping mid-request, a retryable +// attachment upload failure) must not spam `console.warn` on every backoff +// retry; it goes to the filterable `[t3-thread-outbox]` debug log instead. +// Failures the server decided stay on `console.warn`. +const threadOutboxDebug = createDebugLogger("thread-outbox"); + +/** + * On the queued-request path (settings sync, startTurn) the RPC client + * reports ordinary transport drops as the raw socket/worker reason tags, and + * reserves `RpcClientDefect` for client-side protocol violations and decoding + * failures — unlike the shared config-subscription stream, which + * deliberately re-wraps transport causes under that tag. Defects still retry, + * but they are not ordinary offline behavior and must not hide behind the + * offline debug log. + */ +function isRpcClientDecodeDefect(error: unknown): boolean { + if ( + typeof error !== "object" || + error === null || + !("_tag" in error) || + error._tag !== "RpcClientError" + ) { + return false; + } + const reason: unknown = (error as { readonly reason?: unknown }).reason; + return ( + typeof reason === "object" && + reason !== null && + "_tag" in reason && + reason._tag === "RpcClientDefect" + ); +} + +function isOrdinaryThreadOutboxTransportFailure(error: unknown): boolean { + return shouldRetryThreadOutboxDelivery(error) && !isRpcClientDecodeDefect(error); +} + +/** + * Logs one queued-message delivery failure and returns the retry-or-restore + * decision for the caller. Ordinary transport retries — what an offline + * device or a flapping socket produces on every backoff attempt — go to the + * debug log. Server-decided failures warn. Settings-sync failures always + * resolve to a retry even when the server rejected the command, so the + * error, not the resolved action, must decide the log level there; routing + * every retry to debug could hide a permanently rejected update forever. + */ +function logThreadOutboxDeliveryFailure(input: { + readonly stage: ThreadOutboxCommandStage; + readonly error: unknown; + readonly interrupted: boolean; + readonly context: Record; +}): ThreadOutboxFailureAction { + const action = resolveThreadOutboxFailureAction({ + stage: input.stage, + error: input.error, + interrupted: input.interrupted, + }); + const details = { ...input.context, stage: input.stage, action }; + const ordinaryTransportRetry = + action === "retry" && + !isRpcClientDecodeDefect(input.error) && + (input.interrupted || + input.stage !== "settings-sync" || + shouldRetryThreadOutboxDelivery(input.error)); + if (ordinaryTransportRetry) { + threadOutboxDebug.log("queued message delivery failed", details); + } else { + console.warn("[thread-outbox] queued message delivery failed", details); + } + return action; +} + +/** Attachment uploads retry like delivery: transport failures are ordinary offline noise. */ +function logThreadOutboxUploadFailure(queuedMessage: QueuedThreadMessage, error: unknown): void { + const context = { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }; + if (isOrdinaryThreadOutboxTransportFailure(error)) { + threadOutboxDebug.log("attachment upload failed; retrying", { ...context, error }); + } else { + console.warn("[thread-outbox] failed to upload attachments", { ...context, error }); + } +} + function beginDispatchingQueuedMessage(queuedMessageId: MessageId): void { appAtomRegistry.set(dispatchingQueuedMessageIdAtom, queuedMessageId); } @@ -215,14 +303,13 @@ export async function completeQueuedMessageDelivery( ); if (!removed) { forgetAcknowledgedThreadMessage(queuedMessage); - console.warn( - "[thread-outbox] delivered message was edited before cleanup; keeping the newer message", - { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - }, - ); + // Losing the cleanup race to a user edit is an expected outcome the + // caller handles by keeping the newer message; it is not a warning. + threadOutboxDebug.log("delivered message was edited before cleanup", { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + }); return "edited"; } return "removed"; @@ -667,18 +754,16 @@ export function useThreadOutboxDrain(): void { return null; } const error = Cause.squash(commandResult.cause); - const action = resolveThreadOutboxFailureAction({ + const action = logThreadOutboxDeliveryFailure({ stage, error, interrupted: Cause.hasInterruptsOnly(commandResult.cause), - }); - console.warn("[thread-outbox] queued message delivery failed", { - environmentId: queuedMessage.environmentId, - threadId: queuedMessage.threadId, - messageId: queuedMessage.messageId, - stage, - cause: commandResult.cause, - action, + context: { + environmentId: queuedMessage.environmentId, + threadId: queuedMessage.threadId, + messageId: queuedMessage.messageId, + cause: commandResult.cause, + }, }); return { action, @@ -772,7 +857,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage, @@ -900,7 +985,7 @@ export function useThreadOutboxDrain(): void { return true; } } catch (error) { - console.warn("[thread-outbox] failed to upload attachments", error); + logThreadOutboxUploadFailure(queuedMessage, error); if (!shouldRetryThreadOutboxDelivery(error)) { return restoreQueuedMessage( queuedMessage,