feat(nests): NestsTrace recorder for replayable session captures

Adds an opt-in JSONL event recorder behind every receiver-side
moq-lite + NestViewModel decision point so a real two-phone
production session can be captured and (in a follow-up) replayed
through the unmodified pipeline as a unit test. Step 1 of the
"capture-then-replay" plan from the prior conversation.

Off by default — production release builds that never call
`NestsTrace.setRecording(true)` pay one volatile-load + branch per
emit site. The fields-builder lambda doesn't run on the disabled
path, so call sites can do non-trivial string concat freely.

Output goes to logcat under tag `NestsTraceJsonl`. Capture with:

  adb logcat -c
  adb logcat -s NestsTraceJsonl:D -v raw > nest-trace.jsonl

The `-v raw` formatter strips the `D NestsTraceJsonl:` prefix so
the captured file is valid JSONL ready for replay tooling.

Schema per line: `{"t_ms":N,"kind":"K", ...kind-specific fields}`,
where `t_ms` is milliseconds since `setRecording(true)` was first
called. Field names are lower_snake_case for stability across
clients.

Wired into 13 call sites this commit (matched 1:1 with existing
NestRx/NestTx human log lines so the trace and the log line stay
adjacent and the diff stays small):

`MoqLiteSession`:
  - announce_bidi_opened (per session.announce call)
  - announce_pump_emit (per Active/Ended received on a bidi)
  - announce_bidi_ended_naturally / announce_bidi_threw
  - subscribe_send / subscribe_ok / subscribe_drop
  - subscribe_bidi_exited
  - announce_watch_update / announce_watch_ended_closing_subs
  - uni_pump_started
  - group_header / group_fin / group_threw

`NestViewModel`:
  - vm_observe_announce (per ann emission)
  - cliff_tick (every CLIFF_DIAG_LOG_EVERY ticks: active + announced
    sets + per-pubkey lastFrameAt elapsed-ms)
  - cliff_recycle (when the detector forces a recycleSession())

Anonymisation: pubkeys + track names recorded verbatim — they're
already in the existing `NestRx`/`NestTx` log lines the user is
sharing. Frame payloads are NEVER recorded, only sizes — audio
content can't leak through a trace dump.

Tests: NestsTraceTest (9 cases) — exhaustive jsonStr/jsonArrStr
quoting + setRecording state-machine + emit-lambda-noop-when-
disabled coverage. The `emit` log-output side itself is untestable
in commonTest because `Log.d` writes to a platform actual; the
schema correctness we DO want to pin (a JSON syntax bug at one of
the 13 call sites would silently break replay) is covered by the
quoting helpers.

CliffDetectorTest 12/12 + MoqLiteSessionTest 11/11 still pass —
the trace wiring is purely additive next to existing log statements.

Follow-up: a `TraceReplayingTransport` reading these JSONL files
back through `WebTransportSession` to drive end-to-end regression
tests for the cliff-recovery scenarios captured from production.
This commit is contained in:
Claude
2026-05-06 00:17:43 +00:00
parent a36ccb5692
commit a86f19f069
4 changed files with 414 additions and 0 deletions
@@ -0,0 +1,149 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.trace
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Pure-string tests for [jsonStr] / [jsonArrStr] (the JSON-quoting
* helpers used at every trace call site) and a small toggle test for
* [NestsTrace.setRecording]'s state-machine.
*
* The actual `emit` log-output side is untestable in commonTest because
* `com.vitorpamplona.quartz.utils.Log` writes to logcat / stdout via
* platform actuals, not via an injectable sink. The schema correctness
* we DO want to pin — a JSON-syntax bug at one of the call sites would
* silently corrupt the trace file and break replay tooling — is covered
* by exercising the quoting helpers exhaustively and asserting on
* concatenated round-trip equality with hand-built JSON literals.
*/
class NestsTraceTest {
@Test
fun jsonStrEscapesQuotesAndBackslashes() {
assertEquals("\"hello\"", jsonStr("hello"))
assertEquals("\"with \\\"quotes\\\"\"", jsonStr("with \"quotes\""))
assertEquals("\"backslash \\\\ here\"", jsonStr("backslash \\ here"))
}
@Test
fun jsonStrEscapesControlCharacters() {
assertEquals("\"line1\\nline2\"", jsonStr("line1\nline2"))
assertEquals("\"col1\\tcol2\"", jsonStr("col1\tcol2"))
assertEquals("\"crlf\\r\\n\"", jsonStr("crlf\r\n"))
}
@Test
fun jsonStrEscapesLowControlCharsAsUnicode() {
//  (start of heading) — must be  in JSON, not raw.
val raw = "xy"
val quoted = jsonStr(raw)
assertEquals("\"x\\u0001y\"", quoted)
}
@Test
fun jsonStrLeavesPrintableAsciiAlone() {
// Every printable ASCII char that isn't `"` or `\` must round-trip
// unmodified — most production trace fields are pubkey hex,
// track names, event-kind enums.
val allPrintable =
(0x20..0x7e)
.map { it.toChar() }
.filter { it != '"' && it != '\\' }
.joinToString("")
val quoted = jsonStr(allPrintable)
assertEquals("\"$allPrintable\"", quoted)
}
@Test
fun jsonArrStrEmitsValidJsonArray() {
assertEquals("[]", jsonArrStr(emptyList()))
assertEquals("[\"a\"]", jsonArrStr(listOf("a")))
assertEquals(
"[\"alpha\",\"beta\",\"gamma\"]",
jsonArrStr(listOf("alpha", "beta", "gamma")),
)
}
@Test
fun jsonArrStrEscapesElementsConsistentlyWithJsonStr() {
// Each element runs through jsonStr — quotes and backslashes
// inside an element must be escaped just like a stand-alone field.
assertEquals(
"[\"a\\\"b\",\"c\\\\d\"]",
jsonArrStr(listOf("a\"b", "c\\d")),
)
}
@Test
fun setRecordingIsIdempotent() {
// Set up clean state for the test — flip off in case a prior
// test left the recorder enabled. (No reset() API by design;
// tests share the singleton.)
NestsTrace.setRecording(false)
assertFalse(NestsTrace.isRecording())
NestsTrace.setRecording(true)
assertTrue(NestsTrace.isRecording())
// Double-enable: no change in state, no error.
NestsTrace.setRecording(true)
assertTrue(NestsTrace.isRecording())
NestsTrace.setRecording(false)
assertFalse(NestsTrace.isRecording())
// Double-disable: no change in state, no error.
NestsTrace.setRecording(false)
assertFalse(NestsTrace.isRecording())
}
@Test
fun emitIsNoOpWhenDisabled() {
// Lambda must not run when tracing is off — call sites pass
// a non-trivial allocator (string concat) and we promise zero
// work on the disabled path.
NestsTrace.setRecording(false)
var lambdaRanCount = 0
NestsTrace.emit("would_have_recorded") {
lambdaRanCount += 1
""
}
assertEquals(0, lambdaRanCount, "emit's fields lambda must not run when tracing is disabled")
}
@Test
fun emitRunsLambdaWhenEnabled() {
NestsTrace.setRecording(true)
try {
var lambdaRanCount = 0
NestsTrace.emit("did_record") {
lambdaRanCount += 1
"\"k\":\"v\""
}
assertEquals(1, lambdaRanCount, "emit's fields lambda must run exactly once when enabled")
} finally {
NestsTrace.setRecording(false)
}
}
}