Trace Capture & Retrace
QUIC connections can record a deterministic-replay trace — every datagram, state transition,
error, path-stats snapshot, and (optionally) connectivity change, stamped against one clock. Capture
is opt-in and zero-cost when off (the default): a null trace is byte-identical to the
pre-capture path.
A trace is useful for three things:
- Field diagnostics — attach the trace to a bug report; it's a packet-level record of what the connection actually did.
- Deterministic replay — feed the input events back through the simulation harness to reproduce a
failure offline (see
RFC_DETERMINISTIC_SIMULATION.md). - Post-hoc retrace — on an obfuscated release build, class names in the trace are renamed by
R8/ProGuard; a companion tool maps them back using your app's
mapping.txt.
The sink is typed
Capture is driven by a TraceSink — a functional interface that receives a typed
TraceEvent, not a string:
fun interface TraceSink {
fun emit(event: TraceEvent)
}
TraceEvent.toString() renders the canonical v1 line (one event per line, space-delimited), so
a file/network sink is just event.toString(). But because the event is typed, a sink can also match
on it directly — filter, count, or react without parsing. TraceEvent.parse(line) / parseAll(lines)
decode a persisted trace back into events, and parse(e.toString()) == e holds for every event.
Enabling capture on a client
Set QuicOptions.trace to a QuicTraceCapture. The engine then wraps the connection's UDP channels
in a recording decorator and mirrors state/error/stats transitions onto your sink.
import com.ditchoom.socket.NetworkMonitor
import com.ditchoom.socket.quic.QuicOptions
import com.ditchoom.socket.quic.trace.QuicTraceCapture
import com.ditchoom.socket.quic.trace.TraceEvent
import com.ditchoom.socket.quic.trace.TraceSink
import java.io.File
import kotlin.time.Duration.Companion.seconds
// A sink may be invoked from several coroutines (the driver loop, per-path readers, the monitor
// collectors) — treat it like a log sink and guard shared state.
val out = File("captured.trace").bufferedWriter()
val capture = QuicTraceCapture(
sink = TraceSink { event -> synchronized(out) { out.appendLine(event.toString()) } },
// Optional: also fold connectivity into the SAME trace — the monitor's capability once
// (NET_CAP), then every NetworkState emission (NET): the airplane-mode toggle /
// Wi-Fi↔cellular handoff, not just QUIC traffic. Client-only.
networkMonitor = NetworkMonitor.default(),
)
withQuicConnection(
hostname = "example.com",
port = 443,
quicOptions = QuicOptions(alpnProtocols = listOf("h3"), trace = capture),
timeout = 15.seconds,
) {
val stream = openStream()
// … normal QUIC usage; every datagram, state change, error, and stats snapshot is recorded …
stream.close()
}
out.flush()
Consuming typed events instead of lines
TraceSink { event ->
when (event) {
is TraceEvent.State -> println("state → ${event.name} ${event.detail ?: ""}")
is TraceEvent.Error -> println("error → ${event.type}: ${event.message}")
else -> out.appendLine(event.toString())
}
}
Enabling capture on a server
A server handles many connections; the v1 grammar carries no connection identifier and each
connection records against its own clock origin. So for replayable per-connection traces, use the
sinkFor factory constructor — it's invoked once per accepted connection, and returning a
fresh sink each time keeps the connections isolated:
val capture = QuicTraceCapture(
sinkFor = {
val writer = newTraceFileForThisConnection().bufferedWriter()
TraceSink { event -> synchronized(writer) { writer.appendLine(event.toString()) } }
},
)
withQuicServer(port = 4433, tlsConfig = tlsConfig, quicOptions = quicOptions.copy(trace = capture)) {
connections { /* … */ }
}
The single-TraceSink convenience constructor (QuicTraceCapture(sink = …)) is the opposite choice
on purpose: it hands the same sink to every connection (log-sink semantics) — fine for a single
connection or aggregate diagnostics, but it interleaves concurrent connections onto one stream.
Pairing quiche's qlog with the trace
quiche can write its own frame-level record of a connection (a .sqlog) beside the replay trace.
The captureFor factory mints both for a connection in one call, so one sequence number the
consumer owns names the two records together:
val connections = AtomicInteger(0)
val capture = QuicTraceCapture(
captureFor = {
val name = "conn-%04d".format(connections.incrementAndGet())
QuicConnectionCapture(
sink = fileSink("$dir/$name.trace"),
qlog = QlogTarget.File("$dir/$name.sqlog"),
)
},
)
quiche opens the qlog with create_new, so the path must be unique per connection — a repeated
name is refused and that connection has no qlog. QUIC_QLOG_DIR (or the quic.qlog.dir system
property on the JVM) is the door for a capture that names no qlog: every connection then writes
quiche-<role>-<session id>.sqlog there.
Event types
Each TraceEvent is either a replayable input or an observed observation (RFC §2):
| Event | v1 tag | Role | Carries |
|---|---|---|---|
DgramOut | DGRAM_OUT | observation | datagram sent (len, path, hex) |
DgramIn | DGRAM_IN | input | datagram received (len, path, hex) |
State | STATE | observation | QuicConnectionState transition (qualified class name + detail) |
PathState | PATH_STATE | observation | migration phase + local host/port |
Error | ERROR | input | typed error (qualified class name + message) |
Stats | STATS | observation | quiche path-stats snapshot |
Net | NET | input | NetworkMonitor.state emission — the whole NetworkState, identity included |
NetGap | NET_GAP | input | observations lost before the next NET line (the relay's DROP_OLDEST buffer overran) |
NetCapability | NET_CAP | input | the monitor's MonitorCapability (mechanism + resolution), emitted once at subscribe |
Liveness | LIVENESS | input | liveness probe outcome |
On the wire, NET is NET <rung> [<id> [<internet>]] — only the rungs that carry a link spend
fields on identity, so an Offline line is just NET Offline while a full line reads
NET Routable Link:Wifi:441492361229 Confirmed. NET_CAP is NET_CAP <mechanism> <resolution>,
e.g. NET_CAP PlatformSignalled RouteAndInternet.
NET_GAP is NET_GAP <dropped>, e.g. NET_GAP 36, and always precedes the NET it modifies
(sharing its timestamp): 36 platform observations were lost, then this state was seen. An intact
stream writes no line at all rather than a confirmed zero, so a trace recorded before NET_GAP
existed and a gap-free one recorded today mean the same thing — networkMonitorScriptFromTrace
replays both with droppedBefore = 0. On replay, ScriptedNetworkMonitor jumps its observation
sequence by dropped + 1, so re-recording a replayed ride reproduces the same gap instead of
erasing it.
Migration note. Traces recorded before the single-
NetworkState-flow migration containNET_AVAIL/NET_IDlines. Those tags no longer exist, andTraceEvent.parsethrows on an unknown tag rather than skipping it — a silently-dropped input event would make a replay lie — so old traces are not parseable and must be re-captured.
State.name and Error.type are captured as qualified class names (::class.qualifiedName),
never simpleName. That's what makes the next section work.
Retracing an obfuscated trace
On a minified release build (R8 on Android, or ProGuard/R8 over a JVM app), the class names in
STATE and ERROR are renamed — a trace line reads STATE a.b.c instead of
STATE …QuicConnectionState.Established. There are no keep-rules to add: capture uses qualified
names precisely so the rename is reversible after the fact, the way a crash reporter symbolicates a
stack trace. You just need to keep the build's mapping.txt.
The retrace tool is a separate, JVM-only artifact (it pulls com.android.tools:r8, so add Google's
Maven repo):
repositories {
google() // com.android.tools:r8 (runtime-transitive)
mavenCentral()
}
dependencies {
implementation("com.ditchoom:socket-quic-trace-tools:<latest-version>")
}
import com.ditchoom.socket.quic.trace.tools.TraceDeobfuscator
import java.io.File
// mapping.txt from the release build (e.g. app/build/outputs/mapping/release/mapping.txt).
val deobfuscator = TraceDeobfuscator.fromMapping(File("mapping.txt").readText())
val readable = deobfuscator.deobfuscateAll(File("captured.trace").readLines())
File("captured.deobf.trace").writeText(readable.joinToString("\n"))
Only the STATE/ERROR class-name tokens are rewritten; every other event and field passes through
untouched, and a name absent from the mapping (a non-obfuscated trace, or a Kotlin/Native or JS trace
— those aren't obfuscated) is a clean identity pass-through. It handles both R8 and plain ProGuard
mappings (same grammar).
From Gradle
If you build from this repository, the same tool is wired as a Gradle task:
./gradlew :socket-quic-trace-tools:retraceQuicTrace \
-PtraceIn=captured.trace -Pmapping=mapping.txt -PtraceOut=captured.deobf.trace
End-to-end
- Capture — set
QuicOptions.trace = QuicTraceCapture(…)and persist eachevent.toString(). - Ship the app minified (R8/ProGuard) and archive its
mapping.txt.STATE/ERRORnames land obfuscated in traces from the field. - Retrace offline — feed the captured trace +
mapping.txttoTraceDeobfuscator(or the Gradle task) to get readable class names back.
Next Steps
- Connections & Streams — the stream model you're tracing
- Typed Stream Multiplexing — codec-typed messages over the same connection