Skip to content

Worktree traces prometheus scraper - #13

Merged
openminddev merged 26 commits into
mainfrom
worktree-traces-prometheus-scraper
Sep 12, 2026
Merged

openminddev merged 26 commits into
mainfrom
worktree-traces-prometheus-scraper

Conversation

@25marcusb

Copy link
Copy Markdown
Contributor

No description provided.

jerinpeter and others added 26 commits August 31, 2026 15:05
Adds an opt-in TRACES_ENABLED stream that polls a co-located OM1
process's GET /traces/metrics endpoint (see OM1's
internal/tracer/traceexport) using the real Prometheus exposition-format
parser, and appends any new records to the current session's
traces.jsonl. No new upload-path logic is needed: openmind-api assigns
the S3 prefix per session directory, so this file rides the existing
jsonl-to-json conversion and upload pipeline like every other stream.

It's a persistent stream (like the DDS/RTSP ones): one poll loop and one
in-memory dedup cursor (the newest trace timestamp written) live for the
process's lifetime, with Rotate() only swapping the output file -- so
OM1 re-serving its whole buffer on every poll never produces duplicate
lines across a session rotation. Verified end-to-end against a real OM1
build (this repo's committed tracer/metrics/traceexport code) driving
the compiled om1-telemetry binary through several rotations with no
duplicate or missing records.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
jsonlToJSONArray packed the whole array onto a single line, making
traces.json (and every other converted stream) unreadable and
undiffable once uploaded. Insert a newline after the opening bracket
and between elements so each record keeps its own line, matching the
source .jsonl's readability -- still a plain JSON array, whitespace
between tokens doesn't change parsing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Found by cross-referencing a real session against what actually landed
in the bucket: only ~6 tiny files (meta.json, network_status.csv, a
couple-KB lidar/pointcloud fragment) made it, all timestamped 2 seconds
after the session opened -- not the session's real ~5 minute run.

Root cause: session.ListClosed doesn't verify a directory is actually
closed, despite the name -- it lists every dated directory, including
one session.OpenNext just created. The only thing excluding the live
session from the retention sweep is comparing it against cmd/main's
in-memory "current" pointer, which isn't updated atomically with the
directory's creation on disk (a handful of statements run in between).
A sweep tick landing in that gap sees a brand-new, virtually-empty
directory as neither current nor uploaded, uploads it, and marks it
complete -- after which the server reports the session already
complete, so the real end-of-session upload silently no-ops and the
rest of the session's files are never sent, with no error anywhere.

Fix: protected() (shared by catch-up uploads and cap enforcement) now
also excludes any directory younger than minSessionAge (30s, by its
own recorded start time) regardless of the current-dir check -- a
generous margin around a race window that's really microseconds wide.
Also corrected ListClosed's doc comment, which claimed a guarantee the
implementation never provided.

Verified the new test reproduces the exact bug on the pre-fix
protected() (temporarily disabled just that clause, confirmed the
directory gets uploaded and marked complete, restored the fix).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Found by cross-referencing a fresh session's traces.json against its
own recorded time window: 174 records spanning 21:25:54-22:51:42
landed in a session that only ran 22:47:39-22:52:39. The dedup cursor
only ever lived in memory, so restarting om1-telemetry (as I'd just
done to deploy the retention fix) reset it to zero -- OM1's exporter
was still serving its ~200-record buffer regardless, and every one of
those records looked new to the fresh process, so they all got written
into whatever session happened to be open at that moment.

Adds CursorFile to traces.Config: the newest written timestamp is
persisted to RECORDINGS_DIR/.traces_cursor (outside the rotating
session directories, since it must survive more than one session) and
reloaded on Start. Rotate's existing in-memory cursor still handles
session-to-session continuity within one process; this closes the gap
across a full restart. Verified the new test reproduces the exact bug
with loadCursor's call site commented out, then confirmed the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
…anual container restart

Root cause of the pointcloud gap found while cross-checking Iris's
uploaded data: the Livox mid360 was genuinely publishing (confirmed
independently via `ros2 topic hz`/`ros2 topic info -v`), and a fresh
process's reader matched it immediately, but the long-lived reader
already running in om1-telemetry never received a single message the
entire time it was up.

Plain DDS discovery isn't fully self-healing here: a writer announces
itself with a burst of discovery packets right after it (re)starts,
then falls back to a slow steady-state interval. A reader that's
already been idle for a while doesn't keep re-broadcasting its own
interest, so if that initial burst is lost -- and the container's own
CycloneDDS trace logs show real `ddsi_udp_conn_write ... failed`
errors, i.e. genuine packet loss on this link -- the reader can miss
a restarted writer indefinitely. Only a brand-new reader (this
rebuild, or an ad hoc `ros2` subscriber) reliably gets a clean
handshake.

heartbeat.Monitor already detects this exact condition (the "recorder
NOT WORKING ... never received any message" warning) but previously
only logged it. Added RegisterRecoverable: an optional reconnect
callback invoked once per check interval for as long as a stream
stays broken (guarded against overlapping in-flight calls). Wired the
five DDS-backed streams (lidar, pointcloud, depth, odom, lowstate) to
it via a new Reconnect() method that tears down and recreates just
that stream's DDS participant/reader -- forcing a fresh discovery
burst -- without touching output files or rotation state.

main.go's registerHeartbeats now needs the current persistentStreams
to bind each reconnect callback, but stream objects don't exist yet
at the first call site (before startRecorders runs). Resolved with a
lazily-evaluated closure over `rs` (declared before its first
assignment) rather than reordering every call site -- reconnect
callbacks are only ever invoked later, from the heartbeat monitor's
own goroutine, by which point `rs` is always set.

Verified: internal/lidar/reconnect_test.go publishes real samples
over a loopback CycloneDDS domain, calls Reconnect() mid-stream, and
confirms a sample published afterward is still received -- proving
the teardown/recreate cycle doesn't break reception. New heartbeat
tests confirm RegisterRecoverable calls reconnect only while broken,
not while healthy, and never overlaps two in-flight reconnects. Full
suite and lint pass.

Deployed to the live robot and confirmed empirically: after rebuilding
and restarting om1-telemetry, the fresh pointcloud reader matched the
already-running Livox mid360 writer immediately and has been
recording steadily since (order of thousands of records, no further
"NOT WORKING" warnings).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Cleanup pass over this session's changes: removed comments inside
function bodies and trimmed doc comments above functions/methods
down to 1-2 lines. No behavior change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Follow-up to the earlier comment cleanup: that pass only trimmed
comments before functions/methods and missed several large ones
attached to consts, types, fields, and a package doc.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Matches the corresponding OM1-side change: om1_trace_info moved off
its own separate registry onto the default one, so it's now served
at /metrics rather than /traces/metrics. Updated TRACES_URL's default
and the surrounding docs/comments accordingly -- no change to the
poller's own parsing logic, which already just looks up the
om1_trace_info family by name regardless of what else is on the page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
…k drift

Root cause of the audio/video-in-wrong-session bug flagged earlier
this session: finishSegment reconstructed a segment's real-world start
as processStart + startSeconds, where processStart is fixed at the
ffmpeg process's launch (hours ago, for these persistent streams) and
startSeconds is ffmpeg's own segment-relative offset. ffmpeg's segment
muxer times cuts off the incoming RTSP stream's own presentation
timestamps, which can drift from wall-clock at a slightly different
rate than Go's real-wall-clock rotation ticker. That per-rotation
drift accumulates over the whole process's uptime, so on a
long-running process a segment cut right at a rotation boundary can
compute to the wrong side of it -- observed live on the robot: an
audio segment for session N+1's window landed in session N's
directory.

Fixed by no longer trusting the accumulated processStart offset at
all: watchSegments now reads both start_seconds and end_seconds from
ffmpeg's segment_list CSV, and finishSegment reconstructs each
segment's start by taking a fresh wall-clock/mono reading at the
moment the segment is reported closed and subtracting just that one
segment's own (end - start) duration. Each segment's timestamp is now
anchored independently to an up-to-date clock reading, so error is
bounded to one segment's own span instead of compounding over the
process's entire uptime.

Applied identically to internal/audio and internal/video (same
structure, same bug). New regression tests in both packages
(TestFinishSegment_notMisledByAccumulatedProcessDrift) construct a
segment that truly starts 1s after a rotation boundary and runs a
full 5-minute span before closing, and assert it lands in the new
session -- the scenario a long-running process's accumulated drift
used to get wrong. Updated existing finishSegment/watchSegments/
parseSegmentListLine call sites for the new signatures. Full suite
and lint pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Root cause of the S3 data-loss another Claude session found by
comparing robot-local files against S3 directly: sessions marked
.uploaded locally, with files still present on the robot, that had
video/audio (and sometimes everything but metadata) genuinely missing
from S3 -- ~33% of all 830 sessions surveyed, spiking to 99-100% on
Aug 28-29, still 40% on Sep 1.

UploadSession called createSession, and if the server's response
reported the session as already "complete", returned immediately
without uploading anything. Retention only calls UploadSession for a
session lacking a local .uploaded marker -- so the client's own view
is always "not done yet" whenever this runs. The only time the
server's "complete" status could disagree with that is exactly the
failure case: a prior attempt uploaded some files (e.g. small
metadata) before failing on something larger (a multipart video/audio
file, a transient network error, anything), got marked failed
server-side, and left no local .uploaded marker so retention retried
it later. On that retry, if the server's account of the session
looked "complete" for any reason, this shortcut skipped uploading the
still-missing files entirely and returned success -- writing the
local .uploaded marker as if nothing were missing, permanently.

Fixed by removing the shortcut: UploadSession now always uploads
every currently-present local file and only writes .uploaded after
that upload actually completes from the client's own perspective,
regardless of what the server reports up front. Re-uploading an
already-complete session's files is a harmless, cheap no-op (S3 PUTs
and presigned-POST/multipart flows are all safe to repeat) --
skipping a re-upload was never worth the risk of silently dropping
data when client and server state disagree. Also dropped the now-
unused Status field this decision was the only reader of.

Verified with git stash on client.go alone: the new regression test
(TestUploadSession_uploadsEvenWhenServerReportsAlreadyComplete) fails
against the old code with the exact missing-file signature, and
passes with the fix restored. Full suite and lint pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01REFHurqqySZu4SZL5dpb3o
Ported from worktree-upload-S3-bucket (commit 6be805c). ffmpeg only
reports a segment closed (and relocatable into its session directory) a
full segment_time after it began -- the same duration as the session
rotate interval -- so that close notification lands at roughly the
same instant as the *next* rotation, not this one. Upload was
triggered immediately on "session rotated" and took a one-shot
os.ReadDir snapshot of the directory, so it consistently raced ahead of
the session's own video/audio and shipped without them. This is a
different bug from da78810's server-status-skip fix: that one stops a
*retry* from silently accepting a stale "complete" status with files
still missing; this one stops the *first* upload attempt from missing
them in the first place. Both are needed.

Give video/audio streams a per-target ready signal, closed once
finishSegment relocates that session's first segment (or determines it
never will, on a relocation error, so a permanent failure doesn't cost
a full timeout on every future upload). Add WaitSegment so a caller can
block on it, and thread an optional awaitReady callback through
UploadFinishedSessionAsync/UploadSession, invoked before the directory
is listed. main.go wires it at both call sites that upload a
just-finished session (rotation and schedule-driven pause) to wait on
the persistent video/audio streams; catch-up sweeps and the final
shutdown upload (already preceded by a synchronous Stop() that drains
the last segment) pass nil, since neither needs it.

Adapted to this branch's finishSegment(scratchFile, duration,
observedNow, observedMonoNs) signature from e50c56c (upload-S3-bucket
still used the older processStart/startSeconds form) -- the wait logic
itself is unchanged, since it only needs the resulting target, not how
its timestamp was derived.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016o2Qem6WxyDBJpKotKFkKS
…ilds

Without this, `docker build` picks up whatever .cyclonedds/.draco a dev
worktree happens to have on disk (built for the host's glibc) via COPY . .,
and the Makefile's already-installed check reuses it instead of rebuilding
for the image's musl/Alpine builder stage -- idlc then fails to exec
("idlc: not found", actually an incompatible-binary error). Also excludes
.env and other .gitignore'd paths that have no business in a build context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HcFEZMmK9xhXsKjtFyiqRi
Trims the oversized doc comments this branch introduced (UploadSession,
UploadFinishedSessionAsync, awaitSegments, WaitSegment x2, its regression
test) down to the project's usual 1-2 line convention. No logic changes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CatchUpUploads treated any directory holding the live boot clock
journal as fully protected, same as EnforceRetentionCap. That's
correct for deletion, but for uploads it means a single failed
upload-on-rotation attempt for the very first session of a boot (e.g.
a timeout talking to the API) is never retried for the rest of the
process's uptime -- UploadOptions already excludes the live journal
itself via PreserveJSONL, so there was never a reason to skip the rest
of that directory's files too.

Observed on a live device: the first session after a boot lost its
one upload attempt to a transient API timeout and sat unswept for 4+
days, quietly eating into the retention cap and contributing to
retention deleting other, never-uploaded sessions to stay under it.

Split the single `protected` predicate into `uploadProtected` (just
the currently-open directory, plus the too-young-to-sweep guard) and
`deleteProtected` (that, plus the boot session directory) in both
Sweep and RunSweeps.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EnX4gj1MKZfFTPMf1WmaH
RegisterRecoverable's reconnect only recreates the DDS reader, which can
loop forever without ever restoring data if the real fault is upstream of
the reader (observed live: pointcloud reconnected every 30s for 50+
minutes straight with zero effect, while lidar/odom recovered from the
same incident within one reconnect). Only a full process restart -- a
fresh DDS participant -- actually cleared it.

Adds Monitor.SetStuckThreshold: after N consecutive failed checks for a
recoverable stream, it fires a handler once instead of retrying the same
ineffective action forever. main.go wires that into the existing shutdown
path (same session-close/upload sequence as SIGINT/SIGTERM) and exits
non-zero so the container's restart policy brings up a genuinely fresh
process.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
WaitSet.Wait returned immediately on any dds_waitset_wait error, and
every caller's poll loop does "on error, continue" with no backoff of
its own. For a reader whose entity errors persistently (observed: depth,
which has no camera publisher on this robot) that's an unthrottled busy
loop -- confirmed via profiling at ~94% of all CPU time in one process,
spent re-entering CGO and re-formatting the same DDS error string as
fast as the scheduler allows.

Wait now sleeps for the caller's timeout before returning an error, so a
permanently-broken entity is bounded to a normal retry cadence instead
of spinning a core. The success/timeout path (dds_waitset_wait >= 0) is
untouched, so healthy streams still wake immediately on new data.

Verified live: CPU usage for the process dropped from 274% to 20% total
after this change, with system-wide load average dropping accordingly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
registerHeartbeats runs on every 5-minute session rotation, and
register() replaced each stream's state wholesale, so consecutiveBad and
escalated got wiped back to zero every rotation. With a 30s check
interval and 60s grace period, only ~8 bad checks fit in a rotation
window -- one short of the 10-check stuckStreamThreshold -- so the
escalation added last commit could never actually fire, for any stream,
no matter how long it stayed broken.

register() now carries consecutiveBad and escalated over from any
existing entry for that name, so a stream's time-to-escalate spans
rotations instead of restarting every 5 minutes.

That alone would create a new problem: depth has no camera on this
robot and will never tick, so it would now reach the threshold and
force a restart, then immediately start climbing toward another one --
an infinite restart loop over a device that will never come back no
matter how many times the process restarts. Added everWorked, a latch
that's only set once a stream ticks for the first time; only a stream
that worked and then got stuck is eligible to escalate. A stream with
no hardware ever attached just keeps logging NOT WORKING, as before.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Merged onto worktree-traces-prometheus-scraper, reconciling with awaitReady/DeleteAfterUpload/boot-session-retry changes, and dropping the reintroduced sess.Status=="complete" shortcut that da78810 (Never skip uploading based on server-reported session status) had removed as the root cause of a measured S3 data-loss incident.
A stuck-stream restart (lidar, this occurrence) calls Stop(), which
cancels the reader goroutine and waits for it to exit. Its cleanup calls
Participant.Close() -> dds_delete(), a cgo call Go's context cancellation
cannot interrupt. On a participant whose subscription never received any
data, that call hung forever (confirmed via a SIGQUIT goroutine dump: 945
minutes stuck in the cgo syscall), wedging the whole shutdown sequence --
and with it session rotation, retention sweeps, and uploads, since all run
on or depend on the same main goroutine. The escalate-to-restart mechanism
meant to recover a stuck stream became a silent, total, unrecoverable
freeze instead, invisible because ffmpeg's own segment writers kept
running independently the whole time.

closeEntity now runs dds_delete in its own goroutine and gives it 5s to
return, abandoning (and leaking) the goroutine on timeout instead of
blocking its caller forever. Applied to both Participant.Close and
WaitSet.Close, the two dds_delete call sites.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EnX4gj1MKZfFTPMf1WmaH
Measured on the robot: ~90ms RTT and ~5% packet loss to S3, which caps a
single TCP stream's throughput regardless of the underlying link's real
capacity (229Mbit/s WiFi PHY, ~600KB/s observed). uploadMultipart sent
parts through one connection sequentially, so large files rode entirely on
that one connection's loss/RTT-limited ceiling.

Parts now upload up to Concurrency at a time (same knob uploadFiles already
uses across files), each getting its own connection and congestion window.
Reads switch from a shared cursor to ReadAt per part, since concurrent
goroutines can't share one. Completed parts are sorted by part number
before /files/complete, since S3 requires ascending order and concurrent
parts can finish out of order.

Updated the test fake to match: it reassembled multipart bytes by PUT
arrival order, which happened to match part order only because uploads
were sequential before. Real S3 reassembles from the ordered part list in
CompleteMultipartUpload regardless of PUT order, so the fake now stores
each part keyed by part number and reassembles the same way on complete.

Verified: full suite passes, upload package clean under -race (x5).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EnX4gj1MKZfFTPMf1WmaH
These sat outside the zstd whole-file pipeline that already covers their
sibling *_frames.bin files, uploading raw. lowstate_timestamps.csv at
400Hz was the single largest contributor after the actual frame data --
16MB of a real session's ~27MB of small-file overhead, with odom's
timestamps CSV a distant second at 7MB. Both are repetitive numeric text,
a good zstd fit, and zstdName/compressWholeFile were already generic over
file extension, so this only needed adding both names to wholeFileTargets.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015EnX4gj1MKZfFTPMf1WmaH
UploadFinishedSessionAsync's background goroutine ran under
context.Background(), so its awaitReady call (awaitSegments ->
WaitSegment) could block forever waiting on a video/audio segment
that a broken recorder stream would never produce -- exactly the
condition that also trips the heartbeat monitor's stuck-stream
restart. main's shutdown path waits on every such goroutine via
uploadWG before it can os.Exit to let the container restart, so one
stuck goroutine silently defeated that recovery: the process hung
forever instead of restarting, with recording and uploading both
dead.

Give the goroutine's context a 10-minute deadline instead. A timed-out
attempt is simply picked up by the next catch-up sweep, same as any
other failed upload.

Reproduced live: the deployed traces-prometheus-scraper build hung for
over an hour after a stuck odom stream triggered this exact restart
path, with the process alive but no recorder children, no new
sessions, and no log output.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015KeydH8jwQFhRpXPXTMGdD
A second occurrence of the shutdown deadlock (dds_delete fixed earlier
today) showed the same "stuck forever in an uninterruptible cgo call"
failure mode at a different call site: lidar's dds_create_participant, in
subscribeDDS's setup path rather than teardown. Confirmed via SIGQUIT
goroutine dump -- goroutine 1 blocked 4 minutes on LidarStream.Stop()'s
wg.Wait(), same as before, but this time the lidar goroutine was stuck in
NewParticipant, not Participant.Close(). Lidar had been reconnecting
constantly all session (real upstream DDS instability), and enough
create/destroy churn apparently left dds_create_participant itself wedged.

Applied the same bounded-goroutine pattern (closeEntity's sibling,
boundedCreate) to every dds_create_* call in ddscore: NewParticipant,
CreateTopic, CreateReader, CreateWriter, and NewWaitSet's three-call
sequence. CreateTopic's C string is freed inside the goroutine rather than
via an outer defer, since freeing it early on a timeout could race a
use-after-free if the abandoned call eventually runs.

Verified: full suite and vet pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@openminddev
openminddev merged commit 7da3fd1 into main Sep 12, 2026
7 of 8 checks passed
Sign up for free to 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.

3 participants