Skip to content

Account-scoped verbose diagnostics for App Review (cmuxVerboseDiagnostics) - #11367

Open
azooz2003-bit wants to merge 14 commits into
mainfrom
feat-verbose-diagnostics
Open

azooz2003-bit wants to merge 14 commits into
mainfrom
feat-verbose-diagnostics

Conversation

@azooz2003-bit

@azooz2003-bit azooz2003-bit commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Stacked on #11289 (feat-review-demo-mode is the PR base): reuses that PR's server-writable Stack clientReadOnlyMetadata targeting mechanism for the App Review account. Mechanism and docs only; the flag is NOT set on any live account.

What this adds

One new server-set flag, cmuxVerboseDiagnostics: true, read fail-closed (literal JSON true only) at sign-in and revalidation. It turns on two things for that account only, with zero client UI, zero user-facing strings, and no hardcoded email or user id anywhere.

Server half. verifyRequest (web/services/vms/auth.ts) captures the flag into the AuthedUser projection (so the 30s native auth cache keeps it) and, for flagged users only, records the request through web/services/observability/verboseDiagnostics.ts: one [cmux-verbose-diag] {"kind":"request",...} console line (method, path, query keys only, user id, user agent) plus cmux.verbose_diagnostics / cmux.verbose_diagnostics_user attributes on the active OTel span. withApiRouteSpan (web/services/telemetry.ts) then emits the completion line {"kind":"response", route, status, durationMs, userId, errorName?, errorMessage?}; routes not wrapped in it get a fallback {"kind":"request_end", durationMs} line scheduled through next/server's after. Unflagged users cost one boolean check and one WeakMap miss per request.

Client half (iOS). CMUXAuthUser.verboseDiagnosticsEnabled is parsed in the Stack CurrentUser actor, persisted with the cached identity card, and exposed via MobileIdentityProviding, exactly alongside demonstrationContentEnabled. The composition root fans a new VerboseDiagnosticsReporter (in CmuxMobileAnalytics) into the existing DiagnosticLog event tap next to AppLog and TransportSentryReporter (Sentry is untouched), so every ring event (lifecycle, auth, pairing, dial, RPC, stream health) is covered. The reporter batches on a single consumer task and POSTs to /api/diagnostics/ingest with the Stack bearer + refresh tokens, resolving each event to a wall-clock timestamp, a stable machine name, and an en_US_POSIX summary. withObservationTracking over AuthCoordinator.currentUser mirrors the flag through sign-in, restore, late revalidation, and sign-out.

Resilience and overhead. ingest is nonisolated and returns after one lock-protected boolean read when the account is unflagged: nothing buffers, no timer runs, no task spawns. Batches are removed from the buffer before upload and are never retried; any failure (429, outage, 4xx rejection) opens an outage gate so further attempts happen at most once per 5s cadence with fresh events, and the backlog is hard-capped at 512 drop-oldest. Uploads run on a short-timeout session off every UI and connection path, and pending events flush on backgrounding.

Server double gate. /api/diagnostics/ingest requires auth AND re-checks the server-written flag on the resolved user; unauthenticated uploads get 401 and unflagged accounts 403, so a stale or tampered client can never opt itself in. Body, batch (256 events), and every field are bounded; control characters are stripped so one event is always exactly one log line.

Operating it

Enable/disable (server credentials required, other metadata keys preserved):

bun scripts/set-verbose-diagnostics.ts <reviewer email> on
bun scripts/set-verbose-diagnostics.ts <reviewer email> off

The flag takes effect on the reviewer's next sign-in or session revalidation, within 30s on the server side (native auth cache TTL).

Reading the logs afterwards

  • Vercel runtime logs (primary sink): search the cmux web project's Logs for the literal string [cmux-verbose-diag]. Every line is that marker followed by one JSON object with kind (request, response, request_end, client_event, client_batch), userId, and at. Narrow to the reviewer with [cmux-verbose-diag] plus their user id in the query, e.g. "[cmux-verbose-diag]" "<stack user id>". client_event lines carry the iOS timeline: clientAt, code, name (e.g. transportDialFailed), summary, deviceId (per-install analytics id), buildStamp.
  • Axiom traces (sampled requests): filter spans on cmux.verbose_diagnostics == true or cmux.verbose_diagnostics_user == "<stack user id>" in the dataset Vercel's OTel export feeds. Console lines are the complete record; traces add timing detail when the trace was sampled.

Tests

  • swift test Packages/Shared/CMUXAuthCore: 25 pass, including new fail-closed metadata parse, legacy identity-card decode, and Codable round-trip for verboseDiagnosticsEnabled.
  • swift test Packages/iOS/CmuxMobileAnalytics: 33 pass, including 6 new reporter tests: unauthorized ingest uploads nothing, resolved timestamps/text, batch-size drain, failure drops the batch and opens the outage gate (never requeues), revocation discards pending, backlog hard cap.
  • bun test web/tests/verbose-diagnostics.test.ts + web/tests/diagnostics-ingest-route.test.ts: 13 pass, including marker emitted only for flagged users, no query values in logs, span-wrapper completion with status and with thrown errors, ingest 401/403 double gate, per-event marker lines, batch bounds, control-character stripping.
  • Existing suites around the touched modules (vm-route-auth, vm-auth-cache, auth-errors, telemetry-sampler, vm-observability, analytics-events-route, devices-route, vm-route-input, vm-billing-limit-paywall): 129 pass, 0 fail.
  • bun run typecheck (tsgo): clean.
  • swift build for CmuxAuthRuntime and CmuxMobileShellModel: clean. The app-target wiring (AppCompositionRoot, cmuxFeature) compiles only in the full Xcode build (GhosttyKit), which runs on the fleet lane for this stack.

Dictionary: native auth cache = 30-second server-side cache of verified bearer-token users, keyed by token hash, so client request bursts cost one Stack call; event tap = the single live observer callback on the app's bounded diagnostic ring; outage gate = reporter state entered after a failed upload that limits further attempts to the periodic cadence; span = one traced operation exported to Axiom via OpenTelemetry; runtime logs = Vercel's per-request console output for deployed functions.

🤖 Generated with Claude Code


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.


Summary by cubic

Adds a server-controlled cmuxVerboseDiagnostics account flag that, when set, turns on structured request logging for that account and streams the iOS app's diagnostic events to a new /api/diagnostics/ingest endpoint. Unflagged accounts see no behavior change, the flag reads fail-closed (only literal JSON true), and it is not set on any live account yet — the mechanism is intended for App Review sessions, with no client UI, user-facing strings, or hardcoded user identifiers.

Server side

  • verifyRequest captures the flag into the AuthedUser projection and, for flagged users, emits one [cmux-verbose-diag] request line (method, path, query-key names, user id, user agent) and annotates the active OTel span; withApiRouteSpan adds a response completion line, with a request_end fallback via next/server's after.
  • /api/diagnostics/ingest requires auth and re-checks the server-written flag (unflagged uploads get 403), bounds the body, batch, and fields, strips control characters so one event is exactly one log line, and emits one client_event line per event.
  • bun scripts/set-verbose-diagnostics.ts <email> on|off sets or clears the flag while preserving every other metadata key; changes take effect on the next sign-in or within 30s (native auth cache TTL).

Client side (iOS)

  • verboseDiagnosticsEnabled is parsed fail-closed in CMUXAuthUser, persisted with the cached identity card, and exposed via MobileIdentityProviding exactly alongside demonstrationContentEnabled.
  • A new VerboseDiagnosticsReporter in CmuxMobileAnalytics taps the DiagnosticLog ring, batches events on a single consumer off every UI and connection path, and POSTs to /api/diagnostics/ingest with the Stack bearer and refresh tokens.
  • Inert for unflagged accounts (one lock-protected boolean read); batches are dropped and never retried, failures open an outage gate holding further attempts to a 5s cadence, the backlog caps at 512 drop-oldest, and events flush on backgrounding.

Written for commit 5e09fa4. Summary will update on new commits.

Review in cubic

azooz2003-bit and others added 14 commits August 31, 2026 17:26
…he session user

The App Review demo account is flagged server-side with
clientReadOnlyMetadata.cmuxReviewDemoContent = true (server-writable only).
CMUXAuthUser now carries demonstrationContentEnabled, parsed fail-closed
(only a JSON boolean true counts) inside the Stack user actor so the
non-Sendable metadata dictionary never crosses isolation. The flag rides the
session payload the app already fetches at sign-in and persists with the
cached identity card; identity cards from older builds decode as not
flagged. MobileIdentityProviding exposes it to the shell with a false
default, and the app's AuthCoordinatorIdentityProvider reads it off the live
session.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MobileDemoContentCatalog is the canned computer behind demonstration mode:
one Demo Mac (localized name, en+ja) with three realistic developer
workspaces (agent task rows with varied todo statuses, unread state, ANSI
session transcripts) and five sample notification-feed items, all built as
the ordinary MobileWorkspacePreview / MobileNotificationFeedItem shapes so
they flow through production stores unchanged. Identifiers are cmux-demo-
prefixed so they can never collide with a real Mac, and a test pins the
sample content against internal build-lane vocabulary (Guideline 2.2).

MobileDemoTerminalEngine answers demo terminal input deterministically:
canned history replays on mount, typed characters echo, backspace erases,
Ctrl-C aborts, escape sequences are swallowed, and Enter runs a small
command table (ls, pwd, git status, echo, cat, clear, date, ...) ending in a
fresh prompt, so the reviewer gets an interactive terminal with no network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
While the signed-in account carries the server demonstration flag, the shell
seeds a Demo Mac through exactly the paths a live Mac uses, so every view
renders it unchanged and real Macs keep connecting alongside it:

- DemoContentPairedMacStore overlays one MobilePairedMac row outside the
  build-scope/team/backup stack (never persisted, never backed up, mutations
  addressed to it swallowed); the Computers list, reconnect routing, and the
  known-paired-Mac hint all see it through the ordinary loadAll path.
- workspacesByMac gains one connected MacWorkspaceState entry, so the
  multi-Mac aggregation derives its rows and the list chrome reads
  connected; the notification feed gains one per-Mac snapshot ingested and
  read-state-mutated through the same revisioned paths as a live feed.
- Terminal replay funnels (cold attach and the requestTerminalReplay choke
  point every resync/reset path shares) answer demo surfaces from the local
  engine and release any replay barrier; input funnels (raw key bytes,
  composer submit, explicit sends) route demo surfaces to the engine, whose
  echo rides the same per-surface output stream. Terminal lanes skip demo
  surfaces so a real foreground Mac is never asked about them.
- switchToMac treats the demo pairing as instantly available without
  touching the live foreground connection; opening a demo row applies the
  read receipt in memory. Sign-out and flag-off tear every seed down.

Activation is re-evaluated on every shell auth sync, so it follows sign-in,
session restore, and account switches with no launch flag, build lane, or
hardcoded account identifier in the client.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…w docs

services/account/reviewDemoContent.ts owns the server-side write following
the cmuxPlan/cmuxVmPlan metadata pattern: set cmuxReviewDemoContent: true on
the demo account's clientReadOnlyMetadata (remove the key to disable),
preserving every other key and skipping no-op updates. Operate it with

    bun scripts/set-review-demo-content.ts <email> on|off

using production Stack server credentials. The reviewer-setup and
review-notes docs describe the mode, the pre-submission check, and that the
prepared review Mac stays online: demonstration content augments live
pairing, it does not replace it. Nothing here touches the live demo account;
flagging it is a deliberate post-merge operator step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e auth sync (red)

Live dogfood repro (PR #11289): a launch that mounts already authenticated
syncs the shell against the CACHED identity card, which predates the flag
and decodes as not-flagged; session revalidation later publishes the fresh
flagged user WITHOUT an isAuthenticated edge, so activation is never
re-evaluated. The paired-Mac decorator reads the flag lazily on every load,
so the Demo Mac row rendered ('Not connected · 0 workspaces') while the
workspace and notification seeds never landed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arrives late

Root cause of the dogfood defect (Demo Mac row visible with 'Not connected ·
0 workspaces', zero demo workspaces/notifications): the shell evaluated
demonstration activation ONLY inside signIn() auth syncs. A launch that
mounts already authenticated syncs against the cached identity card, which
predates the flag and decodes as not-flagged; validateCachedSession later
publishes the fresh flagged user via applySignedInUser(.revalidation), which
updates currentUser WITHOUT an isAuthenticated edge, so no root onChange
re-fires the sync and the seeds never land. Meanwhile
DemoContentPairedMacStore reads the flag lazily on every loadAll, so later
list loads revealed the row — diverging from the missing seeded state.

Two-part fix:
- CMUXMobileRootView observes the published user's demonstration flag and
  re-runs the ordinary shell auth sync on its edge, so revalidation
  activating the flag seeds immediately (deterministic trigger).
- loadPairedMacs() re-evaluates activation (without re-entrant reload)
  before reading the store, so ANY load that can reveal the demo row also
  seeds with it — row visibility and seeded state can no longer diverge,
  and reconnect churn self-heals the seeds.

The new seedsSurviveReconnectAndAggregationChurn test also pins that a full
secondary reconcile pass and repeated list loads retain the seeds
idempotently (one row, three workspaces, unchanged feed).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…(red)

Dogfood round 2 (PR #11289): with the real Mac wedged, every failed
stored-Mac dial funnels into clearRemoteConnectionContext(), which drops all
non-foreground workspacesByMac entries — including the demo Mac's. Real
secondaries are re-established by the next aggregation pass; the demo Mac
has no subscription, so each ~85s reconnect cycle erased the demo
workspaces (five terminalClosed events per cycle in cmux-app.log), and a
tap racing the wipe bounced back to an empty 'Not Connected' list.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…demo Mac

Mechanism of the round-2 defect (from cmux-app.log on the live sim): with
the real Mac wedged, each storedMacReconnect attempt fails and funnels into
clearRemoteConnectionContext(), whose teardown keeps only the offline
FOREGROUND entry of workspacesByMac — dropping the demonstration Mac's
seeded entry every ~85s cycle (surfaceListUpdated 0 + five terminalClosed
per cycle). A tap racing that wipe resolved no row after switchToMac and
bounced back to an empty Not Connected list; the loadPairedMacs self-heal
restored the seeds only when a list refresh happened to run.

Demo state is now invariant to ALL real-connection churn at the shared
choke points, instead of per-caller patches:

- clearRemoteConnectionContext retains the demo entry (it has no
  subscription or transport; nothing else re-establishes it) and never
  downgrades its connected presentation.
- The stored-Mac reconnect loop never admits the demo row as a dial
  candidate (nothing to dial; registry refresh and dial-failure cleanup
  must not touch it). Zero-touch and secondary-aggregation candidate
  selection exclude it explicitly instead of relying on presence contents.
- markSecondaryMacUnavailable and refreshRoutesFromRegistry no-op for the
  demo pairing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ingle-file command catalog

Owner direction for the reviewer scenario: every demo workspace opens onto
a simulated terminal we control — canned transcript plus a live prompt that
fakes ls, cd, echo, and whatever we want to showcase.

- cd joins the engine with a coherent fake filesystem per terminal
  (MobileDemoDirectory trees: src/, tests/, README.md, nested dirs), so
  cd/ls/pwd/cat compose: 'cd src && ls' lists plausible files, 'cd ..' and
  'cd ~' return, unknown dirs answer 'no such file or directory', and the
  prompt tracks the working directory across replays.
- MobileDemoCommandCatalog is THE single extension point for showcase
  commands (echo, git, date, whoami, hostname, uname, help today): add one
  entry there and every demo terminal serves it, no engine changes. The
  engine keeps only line discipline and the session-stateful filesystem
  commands (cd/ls/pwd/cat/clear).
- All three demo workspaces are proven interactive end to end through the
  production open/mount/input paths (everyDemoWorkspaceOpensAnInteractive-
  SimulatedTerminal exercises each workspace's every terminal: replay,
  echo, pwd answer, fresh prompt).

Engine tests cover cd/ls/pwd/cat composition, nested paths, .., ~, error
copy, and the catalog-as-extension-point contract; content stays realistic
English developer material with the vocabulary pin extended over the trees.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…r send entries (red)

Dogfood round 3 on the rebuilt sim: the workspace opens but the canvas
stays blank forever (render tick sinceOutput=-1 — not one byte reached the
mounted surface), and the composer shows 'Couldn't send. Check the
connection and try again.'

Mechanism 1: GhosttySurface's output consumer is gated on the first
geometry callback producing a viewport preparation
(prepareTerminalViewport), which resolves the workspace through the
foreground-scoped workspaceID(forTerminalID:). Demo rows are stamped with
the demo mac id and the demo Mac is never foreground, so the resolver
returns nil, the gate never opens, the sink never registers, and the
mount-time replay never runs. Earlier tests attached the stream directly
and bypassed the gate; the new test drives the real order (geometry
preparation, viewport answer, THEN sink attach, THEN replay).

Mechanism 2: the composer band submits through submitComposerInput /
submitComposer -> terminal.paste on the foreground remoteClient, which is
nil while only the demo Mac serves content, so the send fails its
connection gate before reaching the demo input fence and the red banner
shows. The new test drives those exact entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rminals

Blank canvas (sinceOutput=-1): the mounted view's output consumer waits on
the first geometry callback producing a viewport preparation, and
prepareTerminalViewport resolves the workspace through the
foreground-scoped workspaceID(forTerminalID:), which returned nil for demo
surfaces — gate never opened, sink never registered, replay never ran.
workspaceID(forTerminalID:) now resolves demo surfaces to their demo-owned
row regardless of the foreground pairing (demo ids are cmux-demo- prefixed,
so the sibling-build ambiguity that scoping defends against cannot occur),
and updatePreparedTerminalViewport answers demo reports locally with the
phone's natural grid — before the replay-barrier prearm, so no barrier is
ever armed against a demo surface, and without an RPC, so the view's
bounded retryViewportReport loop settles instead of spinning on nil.

'Couldn't send' banner: every composer route funnels into
sendRemoteTerminalPaste, which required the foreground remoteClient. Demo
surfaces now branch there to the local engine (text pasted, interior
newlines execute per line, the return submit key runs the final line), and
submitComposerInput's connection gate admits demo-owned terminals, so the
send status reports success and the banner never shows.

Demo replays are additionally prefixed with a screen+scrollback erase so a
re-delivered replay (view reset, viewport churn, remount) repaints from
blank instead of appending a second transcript copy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oseDiagnostics

When an authenticated request's Stack user carries the server-written
clientReadOnlyMetadata flag cmuxVerboseDiagnostics: true, verifyRequest
emits a structured [cmux-verbose-diag] request line (method, path, query
keys, user id, user agent) into the Vercel runtime logs, annotates the
active OTel span for Axiom trace filtering, and withApiRouteSpan emits a
completion line with route, status, duration, and error details. Routes
without the span wrapper get a fallback end line via next/server after.
The flag rides the AuthedUser projection so the native auth cache keeps
it, and only the literal boolean true counts (fail closed).

/api/diagnostics/ingest accepts batched client diagnostic events from
flagged accounts: auth is required and the server re-checks the account
flag (double gate), the body and every field are bounded, control
characters are stripped so one event is one log line, and each event
becomes a [cmux-verbose-diag] client_event line.

bun scripts/set-verbose-diagnostics.ts <email> on|off owns the metadata
write, preserving every other key, mirroring set-review-demo-content.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d accounts

CMUXAuthUser gains verboseDiagnosticsEnabled, parsed fail-closed from the
server-written cmuxVerboseDiagnostics clientReadOnlyMetadata key exactly
like the demonstration-content flag, persisted with the cached identity
card, resolved inside the Stack CurrentUser actor, and exposed through
MobileIdentityProviding (default false).

VerboseDiagnosticsReporter (CmuxMobileAnalytics) fans out of the
DiagnosticLog event tap alongside AppLog and TransportSentryReporter.
ingest is nonisolated and returns after one synchronized boolean read
while the account is unflagged, so ordinary users pay nothing and buffer
nothing. Accepted events batch on a single consumer task (AsyncStream
channel, barrier flush, injected clock cadence, hard-capped drop-oldest
backlog) and POST to /api/diagnostics/ingest with the Stack bearer and
refresh tokens. Batches are removed before upload and never retried; any
failure (429, outage, server-gate rejection) opens an outage gate that
limits further attempts to the cadence, so the reporter can never block
UI or connection paths or queue unboundedly.

The composition root mirrors the signed-in account's flag into the
reporter with withObservationTracking over AuthCoordinator.currentUser,
covering sign-in, session restore, late revalidation arrival, and
sign-out, and flushes pending events on background alongside analytics.
The server re-checks the account flag on every upload, so the client
mirror is never the authority.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercel Bot commented Sep 1, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cmux166 Ready Ready Preview Sep 1, 2026 2:35pm UTC
cmux41 Ready Ready Preview Sep 1, 2026 2:35pm UTC

@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Team

Run ID: 5003f645-20fe-4f0c-8932-17a10b1e709b

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15 issues found across 18 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="web/services/account/verboseDiagnostics.ts">

<violation number="1" location="web/services/account/verboseDiagnostics.ts:50">
P3: This module duplicates `reviewDemoContent.ts`'s metadata mutation logic nearly line-for-line, including the full-object write. Extract a shared flag metadata helper so future changes cannot make the two implementations drift.</violation>

<violation number="2" location="web/services/account/verboseDiagnostics.ts:75">
P2: When the stored flag has a non-`true` value, requesting `off` treats it as already disabled and skips the write, so the stale `cmuxVerboseDiagnostics` key is never removed. Detect the key's presence separately and persist the normalized object when disabling.</violation>

<violation number="3" location="web/services/account/verboseDiagnostics.ts:85">
P1: When a billing, TestFlight, or account-deletion metadata update overlaps this call, `next` comes from a stale snapshot and replaces the whole metadata object, losing the other update. Reload the user under the existing account metadata mutation lock before applying this flag.</violation>
</file>

<file name="web/services/observability/verboseDiagnostics.ts">

<violation number="1" location="web/services/observability/verboseDiagnostics.ts:95">
P2: The flagged request's User-Agent is untrusted client input, but this line sends arbitrary header contents into runtime logs without bounds or scrubbing. Bound and sanitize the value before logging it, or omit it.</violation>

<violation number="2" location="web/services/observability/verboseDiagnostics.ts:108">
P3: recordVerboseDiagnosticsRequest schedules an `after` fallback on every flagged request even when the route is wrapped in `withApiRouteSpan`, which always calls completeVerboseDiagnosticsRequest (success or error) and sets entry.finished. For all current flagged traffic (every VM route and the diagnostics ingest route use withApiRouteSpan), the after() closure is a guaranteed no-op, so each flagged request pays for scheduling a `next/server` after-task that never does anything. Since recordVerboseDiagnosticsRequest runs before the route can signal whether it is span-wrapped, this is inherent to the sync design, but worth noting given flagged requests already burst several /api/vm calls per user action.</violation>

<violation number="3" location="web/services/observability/verboseDiagnostics.ts:150">
P1: When a flagged request fails with an exception containing a signed URL, bearer token, or request detail, this line writes the raw message to Vercel logs. Scrub error text before emitting it, or log only `errorName`.</violation>
</file>

<file name="web/app/api/diagnostics/ingest/route.ts">

<violation number="1" location="web/app/api/diagnostics/ingest/route.ts:108">
P2: A flagged or compromised client can flood Vercel logs because this endpoint has no server-side rate limit and emits one line per accepted event. Add a per-user rate limit or quota before processing the batch.</violation>

<violation number="2" location="web/app/api/diagnostics/ingest/route.ts:185">
P3: boundedString counts code points (`[...value]`, `cleaned.length`) but truncates with `cleaned.slice(0, maxChars)`, which slices by UTF-16 code units. When an astral character (a surrogate pair) straddles the cutoff, the slice returns an unpaired (lone) surrogate. That dangling surrogate is later serialized by emitVerboseDiagnosticsLog's JSON.stringify and becomes a U+FFFD replacement character, silently corrupting the end of long summary/name strings and meaning `maxChars` is not a true code-point cap. Slice on the code-point array instead (e.g. build from `[...cleaned].slice(0, maxChars)`), or guard by trimming the truncated string so it never ends on a lone low surrogate.</violation>
</file>

<file name="Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift">

<violation number="1" location="Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift:189">
P2: While `drain()` awaits `uploader.upload`, the consumer cannot trim `pending`, but `ingest` continues yielding into this unbounded stream. A stalled transport therefore bypasses `maxPendingEvents` and can grow memory with every diagnostic event; use a bounded event buffer separate from non-droppable flush/control barriers.</violation>

<violation number="2" location="Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift:222">
P1: When `setAuthorization(false)` runs with queued events, `.authorization(false)` is placed after those `.event` items. `consume` can reach `drain()` and POST them while `isAuthorized` remains true, so sign-out or flag removal does not stop the upload. Carry an authorization generation into events and re-check or cancel before sending.</violation>

<violation number="3" location="Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift:261">
P3: After any authorized event starts `cadenceTask`, disabling authorization leaves that task alive and waking the actor every `flushInterval` for an unflagged account. Cancel and clear `cadenceTask` when authorization is disabled, then recreate it when authorization is enabled again.</violation>

<violation number="4" location="Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift:295">
P2: The reporter ships the entire buffered `pending` array as a single batch (up to the 512-event `maxPendingEvents`), but the ingest route rejects any batch over 256 events with a 400 `batch_too_large`, and 4xx (other than 408/429) maps to `.drop`. Once more than 256 events accumulate between flushes the entire batch is discarded and the outage gate opens. Cap each drain at at most 256 events and chunk any remainder, instead of sending everything at once.</violation>
</file>

<file name="ios/cmuxPackage/Sources/cmuxFeature/MobileAnalyticsComposition.swift">

<violation number="1" location="ios/cmuxPackage/Sources/cmuxFeature/MobileAnalyticsComposition.swift:100">
P3: The verbose diagnostics uploader constructs a new `URLSession` (`session ?? Self.analyticsSession()`) instead of reusing the `uploadSession` already created in this same init for the analytics uploader. This makes three ephemeral sessions instead of two and is inconsistent with the "same short-timeout session policy as the analytics uploader" comment. Reuse `uploadSession` (the shared injected/test session) for the diagnostics uploader.</violation>

<violation number="2" location="ios/cmuxPackage/Sources/cmuxFeature/MobileAnalyticsComposition.swift:103">
P2: The production reporter uses 40-event batches instead of the specified 256-event batches, increasing request overhead and reducing diagnostic coverage per failed upload. Pass `flushBatchSize: 256` when constructing the reporter.</violation>
</file>

<file name="web/scripts/set-verbose-diagnostics.ts">

<violation number="1" location="web/scripts/set-verbose-diagnostics.ts:28">
P2: When the email argument is only whitespace, the script can search with an empty email and toggle an unrelated user whose primary email is empty. Reject the normalized email before calling `listUsers`.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

user.clientReadOnlyMetadata,
enabled,
);
await user.update({

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a billing, TestFlight, or account-deletion metadata update overlaps this call, next comes from a stale snapshot and replaces the whole metadata object, losing the other update. Reload the user under the existing account metadata mutation lock before applying this flag.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/services/account/verboseDiagnostics.ts, line 85:

<comment>When a billing, TestFlight, or account-deletion metadata update overlaps this call, `next` comes from a stale snapshot and replaces the whole metadata object, losing the other update. Reload the user under the existing account metadata mutation lock before applying this flag.</comment>

<file context>
@@ -0,0 +1,89 @@
+    user.clientReadOnlyMetadata,
+    enabled,
+  );
+  await user.update({
+    clientReadOnlyMetadata: next as VerboseDiagnosticsJson,
+  });
</file context>

userId: entry.userId,
durationMs: Date.now() - entry.startedAt,
errorName: outcome.errorName,
errorMessage: outcome.errorMessage,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When a flagged request fails with an exception containing a signed URL, bearer token, or request detail, this line writes the raw message to Vercel logs. Scrub error text before emitting it, or log only errorName.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/services/observability/verboseDiagnostics.ts, line 150:

<comment>When a flagged request fails with an exception containing a signed URL, bearer token, or request detail, this line writes the raw message to Vercel logs. Scrub error text before emitting it, or log only `errorName`.</comment>

<file context>
@@ -0,0 +1,160 @@
+    userId: entry.userId,
+    durationMs: Date.now() - entry.startedAt,
+    errorName: outcome.errorName,
+    errorMessage: outcome.errorMessage,
+  });
+}
</file context>

return true
}
guard changed else { return }
continuation.yield(.authorization(enabled))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: When setAuthorization(false) runs with queued events, .authorization(false) is placed after those .event items. consume can reach drain() and POST them while isAuthorized remains true, so sign-out or flag removal does not stop the upload. Carry an authorization generation into events and re-check or cancel before sending.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift, line 222:

<comment>When `setAuthorization(false)` runs with queued events, `.authorization(false)` is placed after those `.event` items. `consume` can reach `drain()` and POST them while `isAuthorized` remains true, so sign-out or flag removal does not stop the upload. Carry an authorization generation into events and re-check or cancel before sending.</comment>

<file context>
@@ -0,0 +1,345 @@
+            return true
+        }
+        guard changed else { return }
+        continuation.yield(.authorization(enabled))
+    }
+
</file context>

user: VerboseDiagnosticsUser,
enabled: boolean,
): Promise<Record<string, unknown>> {
if (verboseDiagnosticsEnabled(user.clientReadOnlyMetadata) === enabled) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When the stored flag has a non-true value, requesting off treats it as already disabled and skips the write, so the stale cmuxVerboseDiagnostics key is never removed. Detect the key's presence separately and persist the normalized object when disabling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/services/account/verboseDiagnostics.ts, line 75:

<comment>When the stored flag has a non-`true` value, requesting `off` treats it as already disabled and skips the write, so the stale `cmuxVerboseDiagnostics` key is never removed. Detect the key's presence separately and persist the normalized object when disabling.</comment>

<file context>
@@ -0,0 +1,89 @@
+  user: VerboseDiagnosticsUser,
+  enabled: boolean,
+): Promise<Record<string, unknown>> {
+  if (verboseDiagnosticsEnabled(user.clientReadOnlyMetadata) === enabled) {
+    return metadataApplyingVerboseDiagnostics(
+      user.clientReadOnlyMetadata,
</file context>

// that must not be copied into a log sink verbatim.
queryKeys: url ? [...url.searchParams.keys()].sort().join(",") || undefined : undefined,
userId: user.id,
userAgent: request.headers.get("user-agent") ?? undefined,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The flagged request's User-Agent is untrusted client input, but this line sends arbitrary header contents into runtime logs without bounds or scrubbing. Bound and sanitize the value before logging it, or omit it.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/services/observability/verboseDiagnostics.ts, line 95:

<comment>The flagged request's User-Agent is untrusted client input, but this line sends arbitrary header contents into runtime logs without bounds or scrubbing. Bound and sanitize the value before logging it, or omit it.</comment>

<file context>
@@ -0,0 +1,160 @@
+    // that must not be copied into a log sink verbatim.
+    queryKeys: url ? [...url.searchParams.keys()].sort().join(",") || undefined : undefined,
+    userId: user.id,
+    userAgent: request.headers.get("user-agent") ?? undefined,
+  });
+
</file context>

* non-`true` shape reads as off, so no tombstone value is needed). Every other
* key is preserved verbatim.
*/
export function metadataApplyingVerboseDiagnostics(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: This module duplicates reviewDemoContent.ts's metadata mutation logic nearly line-for-line, including the full-object write. Extract a shared flag metadata helper so future changes cannot make the two implementations drift.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/services/account/verboseDiagnostics.ts, line 50:

<comment>This module duplicates `reviewDemoContent.ts`'s metadata mutation logic nearly line-for-line, including the full-object write. Extract a shared flag metadata helper so future changes cannot make the two implementations drift.</comment>

<file context>
@@ -0,0 +1,89 @@
+ * non-`true` shape reads as off, so no tombstone value is needed). Every other
+ * key is preserved verbatim.
+ */
+export function metadataApplyingVerboseDiagnostics(
+  raw: unknown,
+  enabled: boolean,
</file context>

Comment on lines +261 to +264
if !enabled {
pending.removeAll()
uploadOutageOpen = false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: After any authorized event starts cadenceTask, disabling authorization leaves that task alive and waking the actor every flushInterval for an unflagged account. Cancel and clear cadenceTask when authorization is disabled, then recreate it when authorization is enabled again.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At Packages/iOS/CmuxMobileAnalytics/Sources/CmuxMobileAnalytics/VerboseDiagnosticsReporter.swift, line 261:

<comment>After any authorized event starts `cadenceTask`, disabling authorization leaves that task alive and waking the actor every `flushInterval` for an unflagged account. Cancel and clear `cadenceTask` when authorization is disabled, then recreate it when authorization is enabled again.</comment>

<file context>
@@ -0,0 +1,345 @@
+                }
+            case let .authorization(enabled):
+                isAuthorized = enabled
+                if !enabled {
+                    pending.removeAll()
+                    uploadOutageOpen = false
</file context>
Suggested change
if !enabled {
pending.removeAll()
uploadOutageOpen = false
}
if !enabled {
cadenceTask?.cancel()
cadenceTask = nil
pending.removeAll()
uploadOutageOpen = false
}

})
.join("");
if (!cleaned) return undefined;
return cleaned.length > maxChars ? `${cleaned.slice(0, maxChars)}…` : cleaned;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: boundedString counts code points ([...value], cleaned.length) but truncates with cleaned.slice(0, maxChars), which slices by UTF-16 code units. When an astral character (a surrogate pair) straddles the cutoff, the slice returns an unpaired (lone) surrogate. That dangling surrogate is later serialized by emitVerboseDiagnosticsLog's JSON.stringify and becomes a U+FFFD replacement character, silently corrupting the end of long summary/name strings and meaning maxChars is not a true code-point cap. Slice on the code-point array instead (e.g. build from [...cleaned].slice(0, maxChars)), or guard by trimming the truncated string so it never ends on a lone low surrogate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/app/api/diagnostics/ingest/route.ts, line 185:

<comment>boundedString counts code points (`[...value]`, `cleaned.length`) but truncates with `cleaned.slice(0, maxChars)`, which slices by UTF-16 code units. When an astral character (a surrogate pair) straddles the cutoff, the slice returns an unpaired (lone) surrogate. That dangling surrogate is later serialized by emitVerboseDiagnosticsLog's JSON.stringify and becomes a U+FFFD replacement character, silently corrupting the end of long summary/name strings and meaning `maxChars` is not a true code-point cap. Slice on the code-point array instead (e.g. build from `[...cleaned].slice(0, maxChars)`), or guard by trimming the truncated string so it never ends on a lone low surrogate.</comment>

<file context>
@@ -0,0 +1,186 @@
+    })
+    .join("");
+  if (!cleaned) return undefined;
+  return cleaned.length > maxChars ? `${cleaned.slice(0, maxChars)}…` : cleaned;
+}
</file context>
Suggested change
return cleaned.length > maxChars ? `${cleaned.slice(0, maxChars)}…` : cleaned;
return cleaned.length > maxChars
? `${[...cleaned].slice(0, maxChars).join("")}…`
: cleaned;

uploader: HTTPVerboseDiagnosticsUploader(
apiBaseURL: apiBaseURL,
tokenProvider: AnalyticsTokenProviderBridge(tokenProvider: tokenProvider),
session: session ?? Self.analyticsSession()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The verbose diagnostics uploader constructs a new URLSession (session ?? Self.analyticsSession()) instead of reusing the uploadSession already created in this same init for the analytics uploader. This makes three ephemeral sessions instead of two and is inconsistent with the "same short-timeout session policy as the analytics uploader" comment. Reuse uploadSession (the shared injected/test session) for the diagnostics uploader.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At ios/cmuxPackage/Sources/cmuxFeature/MobileAnalyticsComposition.swift, line 100:

<comment>The verbose diagnostics uploader constructs a new `URLSession` (`session ?? Self.analyticsSession()`) instead of reusing the `uploadSession` already created in this same init for the analytics uploader. This makes three ephemeral sessions instead of two and is inconsistent with the "same short-timeout session policy as the analytics uploader" comment. Reuse `uploadSession` (the shared injected/test session) for the diagnostics uploader.</comment>

<file context>
@@ -83,6 +89,19 @@ public struct MobileAnalyticsComposition {
+            uploader: HTTPVerboseDiagnosticsUploader(
+                apiBaseURL: apiBaseURL,
+                tokenProvider: AnalyticsTokenProviderBridge(tokenProvider: tokenProvider),
+                session: session ?? Self.analyticsSession()
+            ),
+            buildStamp: DiagnosticBuildStamp.make(infoDictionary: Bundle.main.infoDictionary),
</file context>
Suggested change
session: session ?? Self.analyticsSession()
session: uploadSession

// end line (duration only; the response status is not observable here) once
// the response has finished. `after` throws outside a request scope
// (tests); the request line above already covers that case.
try {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: recordVerboseDiagnosticsRequest schedules an after fallback on every flagged request even when the route is wrapped in withApiRouteSpan, which always calls completeVerboseDiagnosticsRequest (success or error) and sets entry.finished. For all current flagged traffic (every VM route and the diagnostics ingest route use withApiRouteSpan), the after() closure is a guaranteed no-op, so each flagged request pays for scheduling a next/server after-task that never does anything. Since recordVerboseDiagnosticsRequest runs before the route can signal whether it is span-wrapped, this is inherent to the sync design, but worth noting given flagged requests already burst several /api/vm calls per user action.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At web/services/observability/verboseDiagnostics.ts, line 108:

<comment>recordVerboseDiagnosticsRequest schedules an `after` fallback on every flagged request even when the route is wrapped in `withApiRouteSpan`, which always calls completeVerboseDiagnosticsRequest (success or error) and sets entry.finished. For all current flagged traffic (every VM route and the diagnostics ingest route use withApiRouteSpan), the after() closure is a guaranteed no-op, so each flagged request pays for scheduling a `next/server` after-task that never does anything. Since recordVerboseDiagnosticsRequest runs before the route can signal whether it is span-wrapped, this is inherent to the sync design, but worth noting given flagged requests already burst several /api/vm calls per user action.</comment>

<file context>
@@ -0,0 +1,160 @@
+  // end line (duration only; the response status is not observable here) once
+  // the response has finished. `after` throws outside a request scope
+  // (tests); the request line above already covers that case.
+  try {
+    after(() => {
+      if (entry.finished) return;
</file context>

@azooz2003-bit
azooz2003-bit changed the base branch from feat-review-demo-mode to main September 1, 2026 08:47
@teamleaderleo teamleaderleo added area: auth Sign-in, accounts, teams, billing area: ios The iOS app and mobile clients S3: minor Wrong behavior with a workaround ready-to-land Reviewed and ready to land when CI is green labels Sep 30, 2026
@teamleaderleo

Copy link
Copy Markdown
Collaborator

This remains a large App Review diagnostics stack with unresolved review findings, so I left it open and marked ready-to-land.

This branch was successfully deployed

2 active deployments
Preview – cmux166 — 5e09fa4d Deployed Sep 1, 2026 by vercel[bot]
Preview – cmux41 — 5e09fa4d Deployed Sep 1, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: auth Sign-in, accounts, teams, billing area: ios The iOS app and mobile clients ready-to-land Reviewed and ready to land when CI is green S3: minor Wrong behavior with a workaround

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants