feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

feat: runtime log verbosity control on the developer screen - #476

Merged
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity
Sep 1, 2026
Merged

feat: runtime log verbosity control on the developer screen#476
Ryanmello07 merged 3 commits into
urnetwork:mainfrom
Ryanmello07:upstream/android-log-verbosity

Conversation

@Ryanmello07

Copy link
Copy Markdown
Contributor

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.

LevelWhat it buys
0 Defaultconnection errors, contract pings
1 Verbosecontract accounting, per-packet routing decisions — includes destination IPs
2 Tracetransport and window internals; very large logs

Why it exists

connect gates roughly 290 of ~700 log statements behind V(1)/V(2), and the SDK sets v=0 at 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 Sdk function — 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

Ryanmello07and others added 3 commits August 31, 2026 22:08
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_014vN7zh8WeQYCtirhcA3aWw
The 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
@Ryanmello07
Ryanmello07 merged commit e551d89 into urnetwork:mainSep 1, 2026
2 checks passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@Ryanmello07