fix(core-api): send exactly one heartbeat per container and fail loudly on a bad collector URL - #1623
Merged
Merged
Conversation
…ly on a bad collector URL End-to-end validation of the heartbeat shipped in #1577 (backend 3.17.0) found these defects; all are fixed here. D1: the Docker image runs uvicorn with --workers 2 and every worker ran its own heartbeat loop, so a container sent two beats per cycle (seen against production on 2026-09-19: POSTs at 16:23:52Z and 16:23:59Z, and at 16:28:55Z and 16:29:21Z, one deployment_id each), GET /api/v1/telemetry answered differently per worker, and the clients_24h counter was split so the stored beat carried only the sending worker's half. The workers now coordinate through a per-container state directory (CAURA_TELEMETRY_STATE_DIR, default <tmpdir>/caura-heartbeat, 0700): a non-blocking flock on leader.lock elects the one worker that runs the loop (followers retry every 60 s and take over if it dies), every worker flushes its client counter to clients-<pid>.json every 30 s and on shutdown so the leader sums them at send time (an epoch file drops what was already reported; dead pids are pruned), and the leader publishes state.json so followers answer GET with the same values. An unwritable directory falls back to one loop per worker with a WARNING; nothing crashes. No new dependency, worker count unchanged. D2: a plain-http CAURA_TELEMETRY_URL to a non-localhost host was announced as ON at boot and refused at send time at DEBUG. The check is now a policy row: Disabled(reason="invalid_endpoint_url"), a WARNING boot line naming the URL, and GET shows enabled=false with the reason and the endpoint. D5: README said "CI set to any value"; code and docs say non-empty. Also: delivery failures now record last_error (short, URL-free) and last_attempt_at and log once per cycle at WARNING; a leading "v" is stripped from the version (CAURA_VERSION=v3.17.0 is the documented pinning form); docs/telemetry.md states one beat per container, that source runs report "dev" unless CAURA_VERSION is set, and that providers.* report the configured kind. The TypeScript client is bumped to 1.0.2 so the User-Agent header from #1561 can be published. Signed-off-by: erni <erni@caura.ai>
Contributor
|
Claude Code Review — skipped: PR author 'erni-a' is not a public member of the 'caura-ai' org |
Contributor
Author
|
@Eldad-Caura Please approve |
Eldad-Caura
approved these changes
Sep 19, 2026
Merged
erni-a
pushed a commit
that referenced
this pull request
Sep 19, 2026
🤖 I have created a release *beep* *boop* --- <details><summary>backend: 3.17.1</summary> ## [3.17.1](backend-v3.17.0...backend-v3.17.1) (2026-09-19) ### Bug Fixes * **core-api:** send exactly one heartbeat per container and fail loudly on a bad collector URL ([#1623](#1623)) ([a9f417b](a9f417b)) </details> --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). Signed-off-by: release-please[bot] <release-please[bot]@users.noreply.github.com> Co-authored-by: caura-deploy-bot[bot] <265395343+caura-deploy-bot[bot]@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end validation of the heartbeat shipped in #1577 (backend 3.17.0) found that a Docker container sends two beats per cycle, that a mistyped collector URL is announced as ON and then refused silently, and a handful of smaller reporting and documentation defects. This PR fixes all of them in one place, and bumps the TypeScript client so the User-Agent header from #1561 can be published.
Defects and fixes
D1 - two heartbeats per container.
core-api/Dockerfileruns uvicorn with--workers 2; every worker runs the lifespan, so each started its own loop. Observed against production on 2026-09-19: deployment0e9d4f29...POSTed at 16:23:52Z and 16:23:59Z, deployment1f866780...at 16:28:55Z and 16:29:21Z (both 202; the receiver's 20 h dedupe hid the second).GET /api/v1/telemetryanswered differently depending on which worker took the request (last_sent_at,next_send_at,payload_preview), and the in-processclients_24hcounter was split, so the stored beat carried only the sending worker's half (one worker previewedcaura-client-python "21-100", the other"0").Fix (no new dependency, worker count unchanged): a per-container state directory (
CAURA_TELEMETRY_STATE_DIR, default<tmpdir>/caura-heartbeat, created 0700) and a newcore_api.heartbeat.statemodule.fcntl.flockon<dir>/leader.locktaken ininstall(). The holder runs the send loop; the others run no loop and print a "follower" line instead of a second ON line. Followers retry the lock every 60 s and take over if the leader dies (the kernel drops the lock), resuming from the leader's publishednext_send_at(bounded by one interval).<dir>/clients-<pid>.json(write-to-temp + atomic rename). At send time the leader sums its live counter with every other live worker's file; on 202 it zeroes its own counter and writes<dir>/epoch; a worker seeing a newer epoch at its next flush subtracts what it had already flushed. Files of dead pids are pruned (os.kill(pid, 0)).<dir>/state.jsonafter each cycle step; a follower answersGET /api/v1/telemetryfrom it, so every worker reports the same values.payload_previewis built by the answering worker from the summed counters.D2 - plain-http collector URL announced ON, refused silently.
check_endpoint_urlran only insend_once, at DEBUG, so the boot line said ON,GETsaidenabled: true, and nothing was ever sent or explained. The check now lives inpolicy.evaluate(): an invalid URL isDisabled(reason="invalid_endpoint_url"), the boot line prints OFF with the reason and the offending URL at WARNING, andGETshowsenabled: false, the reason and the endpoint. The send-time check stays as defence in depth.D5 / docs. README said "
CIset to any value"; code anddocs/telemetry.mdsay non-empty (an emptyCI=keeps the heartbeat on). README now says non-empty.Small fixes.
last_status/last_sent_atnull with no trace. The state andGETgainlast_error(short, URL-stripped, so a credential in an override can never surface) andlast_attempt_at; failures are logged once per cycle at WARNING instead of DEBUG.CAURA_VERSION=v3.17.0, the pinning formdocs/self-hosting.mdrecommends and which core-api reads from.env, was sent verbatim. The payloadversionand theUser-Agentnow strip a leadingv.docs/telemetry.mdnow states: one beat per container regardless of worker count (and how), source runs reportdevunlessCAURA_VERSIONis set (and that the collector excludesdev),providers.*report the configured kind even when the server fell back to the fake provider, the newinvalid_endpoint_urlrow,last_error/last_attempt_at, and the new boot lines. README kept consistent..env.exampledocumentsCAURA_TELEMETRY_STATE_DIR.TypeScript client 1.0.2. The published
@caura/client(1.0.1) predates the User-Agent header from #1561, so its requests are counted asother.clients/typescriptis bumped to 1.0.2 (package.json,package-lock.json,src/version.ts; theVERSION agrees with package.jsontest passes). Publishing needs tagcaura-client-ts-v1.0.2on the merge commit (publish-npm-client.yml).Type of Change
How Has This Been Tested?
New tests:
tests/test_heartbeat_state.py: lock acquisition and hand-over (including a real child process that dies holding the lock), counter-file aggregation, epoch reset, stale/malformed/dead-pid files,state.jsonround trip.tests/test_heartbeat_multiworker.py: twomultiprocessingworker processes sharing a state directory against a fake receiver; asserts exactly one POST per cycle over two cycles, that the beat carries the summed counts (1 + 1 -> "2-5", then"0"after the epoch reset), identical status from both workers,v3.17.0 -> 3.17.0, and that shutdown releases the lock. Runs in about 5 s.test_heartbeat_sender.py(roles, takeover,last_error, WARNING once per cycle, URL-free error text),test_heartbeat_policy.py(URL row, boot lines,install()role election and the unwritable-dir fallback),test_api_telemetry.py(new fields, a follower worker'sGET, the bad-URL response),test_heartbeat_payload.py(version normalisation).Gates, run as in
ci.ymlfrom a freshuv venv -p 3.12with all four packages editable:ruff check+ruff format --checkoncore-api/srcandtests/;cd core-api && mypy src/;scripts/tenant_scope_gate.py --base origin/main;scripts/legacy_name_ratchet.py --base origin/main(no new lines);pytest tests/ -m "not benchmark"against apgvector/pgvector:pg16container with CI's env afteralembic upgrade head;clients/typescript:npm install+npm test(33 pass).Docker check: built
core-api/Dockerfilefrom this branch and ran it throughdocker-compose.yml(the real CMD, two uvicorn workers) with a receiver sidecar sharing the container's network namespace on127.0.0.1:8010. Boot log: oneanonymous heartbeat ONline (worker pid 10) and oneanonymous heartbeat followerline (worker pid 9), where 3.17.0 printed two ON lines. SixGET /api/v1/telemetrycalls spread over both workers returned the samedeployment_idandnext_send_at(2026-09-19T17:16:29Z); the receiver saw exactly one POST, at17:16:29Z, whoseclients_24h.caura-client-pythonwas6-20(those six calls, summed across both workers). After the beat both workers reportedlast_sent_at 17:16:29Z,last_status 202,last_error nulland the samenext_send_at. The state directory heldleader.lock,clients-9.json,clients-10.json,state.jsonand, after the send,epoch.Checklist
ruff checkandruff format --checkpassmypypassespytestpasses locallyAdditional Notes
/tmpis.docs/telemetry-schema-v1.jsonis unchanged: no payload field was added or renamed.