Latency telemetry playbook¶
Operational reference for the tracing/metrics the bot emits: for each latency
critical phase — what it measures, what “bad” looks like, the hypotheses ranked
by prior evidence, and where in the code to look next. Companion to
docs/grafana/ALERTS.md (Prometheus rules) — this doc is
the Jaeger-side investigation guide.
Sources of truth:
Spans/events:
rust/crates/engine/degenbot-bot/src/arb_engine/solve_cycle.rs(the[solve-phase]family),bot_core/block_pump.rs(degenbot.epoch.runroot + pre-solve gap fields),bot_core/stage_telemetry.rs(degenbot.stage.*per-transition spans),arb_engine/engine_handle.rs(degenbot.arb.solve).Metrics:
rust/crates/engine/degenbot-bot/src/instruments.rs(degenbot_*families).OTel setup (event cap, exporter):
rust/crates/engine/degenbot-bot/src/otel.rs.
Incident shorthand used below: 745f21 = trace 745f2122... (4.6s solve,
pre-viability-gate), d3d15a = d3d15a5b (2.2s, first instrumented),
fb7f7a = fb7f7ab1 (441ms, post-gate). All 2026-08-22 session.
Solve cycle anatomy¶
One degenbot.arb.solve span per dirty-block cycle; four [solve-phase]
events partition it:
span start
│ (activation scan — no event until fan-out; its cost = fanout.phase_us)
├─[solve-phase] fanned out to affected paths {paths.affected, dirty.v2/v3/v4}
├─[solve-phase] resolved hop snapshots {paths.resolved, invalid.reasons}
│ (resolve→LPT staging sits in its own child span — MQUKB6-T2 follow)
├─ degenbot.arb.stage {paths.staged}
├─[solve-phase] streaming solve complete {paths.solved, paths.invalid,
│ solve.cpu_us, profitable,
│ slowest.paths}
├─[solve-phase] cycle complete (clamp done) {clamp.twins, clamp.phase_us,
└─ span end total_us}
Span children of degenbot.arb.solve (phase spans, MQUKB6-T2 pattern):
degenbot.arb.fanout, degenbot.arb.resolve, degenbot.arb.stage,
degenbot.arb.lpt, degenbot.arb.merge. Time between two adjacent child
spans is a regression signal — on trace f701ccd3 (block 25906841,
2026-09-04) 647 ms sat unattributed between resolve and lpt (per-path deep
clones of the resolved CL tick-range sequences during staging). Fixed by
Arc-sharing the resolved snapshots into the dispatch (staging is refcount
bumps; measured 0.3–0.5 µs/path vs 25-30 µs/path steady-state before, and the
cold-ramp 150-420 µs/path pathology eliminated). If a resolve→lpt gap reappears
larger than degenbot.arb.stage, suspect new per-path work added between the
stage span close and compute_bins.
Incident shorthand: f701ccd3 = trace f701ccd36f4ecf80d671e798df218fa4
(block 25906841, 2026-09-04 session).
Trace continuity across the Python handoff¶
The Rust→Python bridge (batch → simulate.dispatch fan-out) used to snap the
OTel context: simulate/bundle spans exported as ROOT traces, correlated only
by the current_block tag. The bridge (telemetry::publish_block_context at
batch send + telemetry::simulate_dispatch_span at the Python seam) parents
the dispatch fan-out to the published block’s span, so ONE Jaeger trace now
carries degenbot.epoch.run → arb.solve → simulate.dispatch → bundle.* (the per-epoch
degenbot.epoch.run root succeeded the retired pump.block waterfall, BF43PM). The lookup
falls back to the closest earlier block because Python’s current_block can
be one ahead (the batch is dispatched after the next header arrives). If
simulate spans reappear as roots, the registry capture (batch send) or the
seam call regressed; the continuity test
(otel_plumbing::simulate_dispatch_span_carries_published_block_parent)
covers the contract end to end on the InMemory exporter.
The ADR-021 verifier’s degenbot.solver.verify span rides the same bridge
via telemetry::attach_published_parent (pinned AFTER span creation — the
verifier runs on a detached task with no ambient context, so the constructor
form does not apply). Test:
otel_plumbing::attach_published_parent_parents_a_detached_task_span.
The verify span judges the whole registered set against chain state
(O(registered x hops x RPC), tens of seconds at scale) and is inherently
BEHIND the block loop — the latest-wins watch skips stale requests, so
expect one verify span per-judged-block, not per-block, always parented to
its judged block’s trace. Exporter caveat: a span only exports when ALL
handles drop — a test holding a tracing::Span (or an enter() guard)
across force_flush will not see it.
The simulate tail (pipeline, option A)¶
Derived quantities - compute on every investigation:
Quantity |
Formula |
Healthy |
|---|---|---|
per-block sim tail |
last |
< 25% of the serial sum (K=8) |
serial sum |
sum of per-fan-out |
trend only |
cold-fetch share |
|
storage RPCs dominate; see JSXP3I |
The concurrent-sim pipeline is DEGENBOT_SIM_PIPELINE_CONCURRENCY (default
8; 1 = serial A/B arm). Contract: sims overlap across batches; submissions
stay FIFO in batch-arrival order with the nonce fetched at submit time.
If the tail/sum ratio rises above ~0.5, the pipeline regressed. Lab + soak
evidence: the gitignored logs/ artifacts logs/simpipe_lab.md and
logs/simpipe_soak.md (since removed; K=8 vs K=1: 4.2x
tail compression, 0 leaf failures).
Derived quantities (compute these on every investigation):
Quantity |
Formula |
Healthy |
|---|---|---|
Activation-scan wall |
|
< 100ms; scales with affected set |
Resolve wall |
next-event − fanout timestamp |
~10µs/path |
Solve wall |
solve-event − resolve-event |
≈ |
Achieved parallelism |
|
≈ 7.8 (8 cores) |
Per-path solve CPU |
|
~1.4–2.6ms |
Invalidity rate |
|
trending down |
Symptom → hypothesis → action¶
S1. Solve cycle > 1s (p95 regression)¶
Evidence so far: pre-gate traces hit 4.2–4.6s; post-gate worst seen 441ms.
Diagnose by reading the phase split before touching code:
Rayon wall dominates + parallelism ≈ 7.8 → work-bound; the fix is fewer paths or cheaper paths. Check
slowest.pathsfor outliers (>20ms) vs a flat profile (everything ~2ms). Outliers ⇒ investigate those path_ids’ pool depth/tick counts. Flat ⇒ volume problem; look at activation fan-out width.Rayon wall dominates + parallelism well under 7 → contention or pool starvation. Hypotheses: (a) worker threads blocked on a mutex inside
solve_path(none known today — workers touch no engine state), (b) bins pool shared with another subsystem, (c) CPU steal from co-located processes.Activation scan dominates (fanout.phase_us > several hundred ms at modest affected counts) → registry walk regressed; suspect
pool_to_pathsreverse-index growth or lock contention with registration.Resolve wall grows superlinearly with resolved count → re-projection of structurally-invalid paths (see S3).
Tracing locations: solve_cycle.rs::run_epoch (all four
phase events); engine_handle.rs:129 (span open); per-path timing already in
the closure (t0/micros, feeds slowest.paths).
S2. Achieved parallelism low (< 6×)¶
solve.cpu_us high relative to solve wall means workers idle or blocked.
Hypotheses ranked:
Affected set too small to saturate 8 workers — benign; ignore unless cycles are also slow.
False sharing / allocation pressure in the par_iter closure (clones of
ResolvedMixedPathper work item). Profile allocations if cpu_us itself is fine but wall balloons only at large sets.Something inside
solve_pathtaking a lock — audit any new code forMutex/RwLockunder the par_iter insolve_cycle.rs.
S3. Invalidity rate > ~50% of resolved¶
The invalid population is rejected during resolve (under the core read lock).
Current reason histogram (from invalid.reasons):
pool not viable in the swap direction— O(1) gate (commit 0194af47d); cheap, acceptable at any rate.integer tick-range sequence unavailable— the remaining cliff: pools that pass directional viability but fail range building (sparse ticks). Each rejection pays an O(tick_data) bitmap walk, and failures are NOT cached (get_cached_tick_rangesstores successes only) — every cycle re-walks.
Next fix if this dominates again: memoize the failure per pool keyed on
state_nonce (+ current tick), or hoist a per-pool “range-buildable” flag
maintained at state-update time (same pattern as the viability gate).
Also consider: skip non-viable paths at ACTIVATION time (dirty-mark walk) via
per-pool viability flags maintained in BotState — removes them from
paths.affected entirely rather than rejecting at resolve.
S4. Clamp phase growing (clamp.phase_us)¶
Cost model: one twin simulation per CL hop across clamped paths
(clamp.twins). Observed 10–69ms for 700–2900 twins — linear, cheap.
Investigate only if twins-per-path jumps (path shape shift toward CL-heavy) or
phase exceeds ~100ms. Location: clamp_cl_hop_capacity loop after the merge;
twin simulators are v3_simulate_swap/v4_simulate_swap in degenbot-pools.
S5. Pump block span ≫ solve span total¶
degenbot.epoch.run (successor of the retired degenbot.pump.block) should wrap the epoch tightly.
If epoch duration exceeds
the sum of child solves by a lot, time is going to: header decode, log decode,
state apply (each has a Prometheus histogram: degenbot_log_decode,
degenbot_state_apply_seconds), or the settle wait (degenbot_block_settle_wait_seconds;
the DispatchOwner queue-wait series degenbot_drain_queue_wait_seconds/_depth are
RETIRED, SZJUKL — queue age now rides the degenbot.stage.* span attrs as queue.age_us).
Cross-
check the matching metric histogram percentiles in Prometheus before adding
new spans.
S7. header_to_publish dominated by a flat settle wait¶
Decompose the pre-solve gap first — degenbot.epoch.run root fields (recorded at the settle point):
header_to_first_log_us (header → first relevant log), log_burst_us
(first → last relevant log, i.e. the apply work), settle_wait_us (last log →
settle decision). The settle wait is the publish debounce — it costs roughly
DEBOUNCE_MS (the full window) on every block; it buys coalescing of
same-block log bursts at the price of that fixed tail.
2026-09-04 baseline (block-25906841 session): settle_wait_us pinned at
~51 ms on 24 of 28 blocks while log_burst_us spanned 1.3–27.5 ms — the 50 ms
debounce fired long after each burst was fully applied, making it a fixed
~50 ms per-block tax on header_to_publish (the ADR-041 epoch race; the
retired drain-era degenbot_header_to_solved endpoint measured the solve
edge). The window is operator-tunable via
DEGENBOT_PUMP_DEBOUNCE_MS (parse contract BlockPump::debounce_ms_cfg:
unset/zero/invalid falls back to 50 ms, so a bad value can never collapse the
window to zero). Live A/B at 15 ms settled the same bursts at ~16 ms with no
change in bursts and still exactly one solve cycle per block.
Trade-off when lowering: a same-block log straggling past the window starts a second solve cycle for that block (extra solve cost + an extra publish). The truth condition for publication (ADR-008 D2: all dispatched logs applied) is unchanged — check solve-cycles-per-block in Jaeger if raising throughput suspiciously.
S6. Missing events / silent spans (exporter-side data loss)¶
Known incident class, not a bot bug:
Span-event cap: opentelemetry_sdk defaults
max_events_per_span = 128; busy solves emit thousands. Fixed at u32::MAX in otel.rs (04637eacf, c9dfeb4f1). Symptom if regressed: exactly ≤128 logs per solve span and no phase tail.Stale binary: use
just devafter Rust edits, then restart. Verify the installed build first withjust verify-build-fresh(it exits 1 on a stale.so; see AGENTS.md “Verifying freshness with the build receipt”). Thecode.line.numbertag comparison in traces remains only as a forensic fallback, not the primary check.Batch-exporter lag: last spans of a killed process are lost. Don’t trust a trace count drop right after a restart as a regression.
Telemetry inventory (quick reference)¶
Jaeger spans: degenbot.epoch.run (trace ROOT per block epoch, attrs
epoch.block/epoch.seq), degenbot.stage.{streaming,quiesced,publish,finalize,rewind}
(one span per ADR-041 stage transition; streaming/rewind hold open, the rest are point
spans), degenbot.arb.solve (child, per solve cycle), degenbot.path.register
(registration worker). RETIRED (BF43PM/AJIOCU, epic MROOY7):
degenbot.pump.block / pump.log_wait / pump.apply_stream. Lifecycle tier (once per run / per log, named via
#[tracing::instrument(name = ...)] — the pre-convention tier kept on bare
function names before the 2026-09-04 rename):
degenbot.pump.subscribe+degenbot.pump.resume (WS handshake + snapshot-
to-live handoff), degenbot.log.dispatch (PER WS LOG: decode → apply →
dirty-mark; the hottest lifecycle span), degenbot.arb.solve_all (cold-start
full sweep). Span events carry code.line.number — use it to confirm binary
freshness against current source.
Key Prometheus families (instruments.rs): degenbot_solve_duration_seconds,
degenbot_epoch_header_to_publish_seconds (the epoch race — header accept →
Published dispatch; the drain-era degenbot_header_to_solved family is
RETIRED with the stage machine), degenbot_stage_streaming_age_seconds,
degenbot_epoch_stale_drops_total, degenbot_stage_publish_cycle_seconds /
degenbot_stage_rewind_total / _duration_seconds (the DispatchOwner
degenbot_drain_queue_wait_seconds / _depth families are RETIRED, SZJUKL),
degenbot_log_decode_seconds, degenbot_state_apply_seconds,
degenbot_state_head_lag_blocks, degenbot_candidates_found_total,
degenbot_submit_latency_seconds. Alert rules + thresholds:
ALERTS.md.
Open follow-ups (priority order)¶
Cache SequenceUnavailable per pool+nonce (S3) — biggest remaining structural waste when invalidity spikes recur.
Per-pool viability flags at activation time (S3 alt) — shrinks the affected set itself.
Watch clamp growth if path mix shifts CL-heavy (S4).
If parallelism ever drops below ~7× on quiet boxes, check CPU quota in the devcontainer before blaming the solver (S2).