Skip to main content

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):

Eventv1 tagRoleCarries
DgramOutDGRAM_OUTobservationdatagram sent (len, path, hex)
DgramInDGRAM_INinputdatagram received (len, path, hex)
StateSTATEobservationQuicConnectionState transition (qualified class name + detail)
PathStatePATH_STATEobservationmigration phase + local host/port
ErrorERRORinputtyped error (qualified class name + message)
StatsSTATSobservationquiche path-stats snapshot
NetNETinputNetworkMonitor.state emission — the whole NetworkState, identity included
NetGapNET_GAPinputobservations lost before the next NET line (the relay's DROP_OLDEST buffer overran)
NetCapabilityNET_CAPinputthe monitor's MonitorCapability (mechanism + resolution), emitted once at subscribe
LivenessLIVENESSinputliveness 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 contain NET_AVAIL / NET_ID lines. Those tags no longer exist, and TraceEvent.parse throws 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

  1. Capture — set QuicOptions.trace = QuicTraceCapture(…) and persist each event.toString().
  2. Ship the app minified (R8/ProGuard) and archive its mapping.txt. STATE/ERROR names land obfuscated in traces from the field.
  3. Retrace offline — feed the captured trace + mapping.txt to TraceDeobfuscator (or the Gradle task) to get readable class names back.

Next Steps