Uh oh!
There was an error while loading. Please reload this page.
feat: runtime log verbosity control on the developer screen - #476
Merged
Ryanmello07 merged 3 commits intoSep 1, 2026
Merged
Conversation
MainApplication pointed glog at filesDir itself. Move it to <filesDir>/logs/app via Sdk.setLogDirForProcess, inside the try/catch that urnetwork#473 added -- the sdk still returns an error when the directory cannot be created, because its os.TempDir() fallback lands on /data/local/tmp, which an app uid cannot write. The retention pass (clearOldLogs) only ever prunes the directory glog is currently pointed at, so moving the root would strand whatever pre-upgrade builds wrote into filesDir: up to four files of up to 16MB each, never pruned again, and no longer reachable through Sdk.getLogDir() -- which is what the feedback screen's share and export buttons read. A user who upgraded and then reported an incident that predated the upgrade would attach none of the logs that recorded it. So migrateLegacyLogFiles moves those files into the new per-process directory BEFORE glog is repointed, which hands the merged set to the same retention pass rather than doubling the storage. It renames rather than copies, never overwrites a file already under the new root (a name collision means an earlier launch already migrated it), and never deletes a log it could not move. Android is single-process, so "app" is the only subdirectory that ever appears; the layout matches iOS, where the app and the network extension would otherwise prune each other's history out of a shared directory. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vN7zh8WeQYCtirhcA3aWw
A user reporting a connection fault has no way to hand over the logs
that recorded it. glog writes up to four 16MB files per process under
<filesDir>/logs, and nothing in the app reads them: the feedback
screen's share button attaches one file at a time, unredacted, and only
the app's own. There is no manifest saying what the device was doing,
no logcat, and nothing that can be safely posted in public.
This adds a Diagnostics section to the developer screen with three
controls -- "Export all logs (raw)", "Export redacted logs", and
"Choose logs…" with a per-file picker -- each producing one zip in
cacheDir/share, handed to the existing FileProvider share intent
(authority ${applicationId}.fileprovider, which res/xml/file_paths.xml
already exposes cacheDir/share to, so no manifest change).
Three things are load-bearing:
The section renders ABOVE DeveloperContent's `if (!connected) return`
guard. `connected` is `reliability != null`, which needs a live device,
so below that guard the export would be unreachable whenever the tunnel
is down -- exactly when someone needs to export logs. The export path
already tolerates a null device (deviceManager.device?.let { ... } for
the manifest), which is what makes that placement safe.
"Export selected" with nothing checked is refused, in the row's
`enabled` and again in exportSelectedDiagnostics. An empty
SelectedNames means "no filter" to the sdk -- a control labelled as a
narrow subset would otherwise write a complete RAW bundle: every
severity, every rotation, the logcat dump, and a manifest carrying
client_id and instance_id in the clear.
The export runs on Dispatchers.IO behind an `exporting` re-entrancy
guard, with "Exporting…" on screen while it does. It walks the on-disk
inventory, spawns logcat and deflates up to 4x16MB per process; on the
main thread that is an ANR past the ~5s input-dispatch watchdog, and
two taps inside one second name the same destination (the file name has
one-second resolution and only a `-redacted` discriminator), so the
second os.Create truncates the zip the first is still streaming into
and the share sheet hands support a corrupt archive.
The logcat dump goes in as platform/logcat.txt. `logcat -d -v
threadtime` dumps and exits, and since android 4.1 an app reads only
its OWN buffer, so no permission is involved and no other app's entries
are reachable; `-t` and a character cap bound it, because a developer
debugging this kind of fault has usually run `logcat -G 8M` and the
dump is live three times over -- kotlin String, Go string, deflate
input -- under a Go soft memory limit of 3/4 of the app heap.
A source that cannot be READ is recorded, never silently dropped. The
sdk reports per-file open/stat failures but swallows directory-read
ones, so logSourceUnavailableReason is the only place android can
notice an unreadable log directory; the reason is deliberately
path-free, because the sdk copies it verbatim into README.txt, the one
bundle entry written without the redaction transform.
The summary carries the file count as a number all the way to the
screen so R.plurals.dev_export_summary can select on it. Formatted in
the viewmodel it reads "Exported 1 log files" -- and one file is what a
selective export produces most often. Sizes go through the app's own
formatByteCountCompact rather than byteCount / 1024, which renders a
freshly rotated 400-byte log as "0 KiB", i.e. as an empty file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014vN7zh8WeQYCtirhcA3aWwThe diagnostics export added in the previous commit can only hand over what glog actually wrote, and at the default level that is very little of what a connection report needs. `connect` gates its contract accounting, transport internals and window diagnostics behind V(1) and V(2) -- roughly 290 of some 700 log statements -- so a bundle exported from a live connected session at level 0 is rpc chatter and nothing about contracts, transports or window formation at all. Reproducing a fault and exporting it currently produces a zip that cannot answer the question it was collected for. This adds a "Log detail" stepper to the Diagnostics section cycling Default (0) -> Verbose (1) -> Trace (2), backed by the sdk's SetLogVerbosity/GetLogVerbosity, which take effect on the next log statement with no reconnect. Three things are load-bearing: It is placed ABOVE the export rows. The order of operations is set the level, reproduce the fault, then export; a verbosity control sitting under the export actions is found only after the capture it was supposed to widen, and the bundle already written is the useless one. It goes through the DEVICE -- device.setLogVerbosity / device.getLogVerbosity -- never the process-local Sdk.SetLogVerbosity. That one raises the level of the calling process only, and the logs worth raising it for are written by the process the device runs in. On android that is a DeviceLocal in this process, so the two happen to coincide today; on the platform where the transport runs in a network extension they do not, and Device.SetLogVerbosity is what carries the level across. Going through the device is what keeps the two honest, the same trap and the same fix as FlushGlog. The level displayed is READ BACK from the device, polled with the rest of the developer readout rather than remembered from the last tap, and "no device" is a distinct Unavailable state rather than level 0. Nothing in this path throws: the sdk clamps an out-of-range level silently and a hosted device refuses the call outright, so a set that did not apply is invisible unless the value is re-read. Showing the level the user asked for would claim a capture is running at Verbose while it is still at 0, and the bundle exported from it would be the empty one this control exists to prevent. Reporting "Default" for "there is no device to ask" would be the same lie in the other direction. At Verbose and above a persistent warning says the logs now contain the destination IP addresses and ports of real traffic, and names "Export redacted logs" as the way to share them. Persistent rather than a toast because it has to be on screen at the moment the user reaches for "Export all logs (raw)", which can be many minutes after the level was raised. That pairing is the point: raising the verbosity is what makes the redaction stop being decorative. The level value is drawn in the danger color for the same reason -- a level that records real destinations must not read as an ordinary setting value. The decision logic is pure and unit tested (LogVerbosityTest): the step-and-wrap, the clamping of an out-of-range level toward the level it actually logs at (a -v of 5 fires every V(2) statement, so it is named Trace and shown as "5 · Trace" rather than quietly redrawn as 2), and the null-vs-0 distinction the warning is keyed off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014vN7zh8WeQYCtirhcA3aWw
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #475 (→ #474). Needs urnetwork/sdk#150 (merged).
A "Log detail" stepper in the Diagnostics section, above the export actions — because the order is set the level, reproduce, then export.
Why it exists
connectgates roughly 290 of ~700 log statements behindV(1)/V(2), and the SDK setsv=0at every process start. Measured on a real device: a full connected session at the default level produced 237 lines, entirely rpc chatter. The same session at Trace produced 42,542 lines including[contract],[multi],[t]and[rtt].Without this control, an uploaded log from the field cannot contain the thing you would want it for.
Design points
Driven through the device, not the process-local
Sdkfunction — the point is reaching the process that writes the connect logs.The level is read back from the device, and "no device" is representable and distinct from level 0 — it shows Unavailable with an inert row. A row confidently reading "0 · Default" when nothing was actually read is precisely the false reassurance worth avoiding.
At level ≥ 1 a persistent warning names the destination addresses and points at the redacted export. That pairing is deliberate: raising verbosity is exactly what makes redaction stop being decorative.
A caveat worth documenting somewhere user-facing
At Trace the measured burn rate on a real device was 15.6 MiB/min. Against the 16 MiB file cap and 4 retained files, that is roughly 4 minutes of retained history — so a bug that takes five minutes to reproduce loses its beginning, with no warning, because the export still succeeds and looks complete. Level 1 gives the contract and routing detail without the per-packet firehose.
Verification
Not compiled locally (no JDK/SDK/NDK); upstream CI is the first compile and the first run of the new tests. Verified by inspection as with the rest of this stack.
🤖 Generated with Claude Code