Skip to content

fix(otlp): acknowledge an export only once it is stored; count what was refused - #5963

Merged
vivekchand merged 3 commits into
mainfrom
feat/otlp-durable-ack
Sep 15, 2026
Merged

vivekchand merged 3 commits into
mainfrom
feat/otlp-durable-ack

Conversation

@vivekchand

@vivekchand vivekchand commented Sep 14, 2026 •

Copy link
Copy Markdown
Owner

Refs #5949

Product record: https://factory.8090.ai/project/b415065f-ab2f-4f53-8864-0c009fd098cb/requirements/3caa08b2-4e39-4044-91f7-113fb2668ae0 (REQ-OBS-OIA-001, written before the code)

Problem

The OTLP receiver answered 200 to every export it could decode, whether or not anything reached the store. The store write was best effort. While the daemon that owns DuckDB was restarting, busy or unreachable, the dashboard's _ProxyStore returned None, a warning was logged, and the receiver still answered 200. The exporter then discarded a batch that was never stored. A span the store refuses (no id, no name) got the same answer.

The issue also names three gaps:

  • A spending-limit pause answered 429 to every export for as long as it lasted, so the spend after a budget incident was never recorded.
  • A retried span or generic metric point added its cost and tokens to the live tiles a second time.
  • A record reporting input tokens only stored output as 0, and a rollup group where no record carried a cost showed $0.00.

The existing suite was hiding the first gap. On main, tests/test_otlp_compat_port.py passes while every span write fails. Another file's fixture leaves a stopped store singleton behind, and running the two files together on main logs local_store.put_span failed: Connection Error: Connection already closed! twice and still reports 30 passed.

What changes (the verified subset this PR claims)

Area Before After
Acknowledgement 200 once the body decoded 200 only after the store confirms the write; 503 + Retry-After: 5 when it does not
Malformed items Silently dropped, still 200 Valid items stored; 200 with OTLP partialSuccess (rejectedSpans / rejectedLogRecords / rejectedDataPoints); protobuf senders get a protobuf response
A value the store cannot hold (see review fix below) Counted as a malformed item and refused on its own: a token count beyond the INTEGER column, or a string the driver cannot encode. The rest of the export is stored and the answer is 200 with partialSuccess
Trace writes put_span per span (one proxy hop each; None on success and on loss) One ingest_spans_batch(spans=[...], with_outcome=True) per export, returning {written, rejected} (added to _DAEMON_METHODS)
Budget pause 429 on every export Intake continues; requests received during a pause are counted
Re-delivery Logs deduped in store and tiles; spans and generic metrics doubled the tiles Tiles count a span once per (trace_id, span_id) and a metric point once per its own time; re-delivered records counted as already_stored
Unknown usage Missing token side stored as 0; a group with no cost showed 0 Stays NULL; rollup reports cost_usd: null plus records_with_cost / records_with_tokens
Diagnostics none /api/otel-status → intake: per-signal item and request counts, last success, last failure category, and a statement that no queue is kept. Counts only: no content, no credentials
Contract Documented a 429; nothing on ack, retry, identity or auth Generated docs/INGEST.md states the ack rule, each status and whether to retry it, the re-delivery identity per signal, the session fallback when none is sent, live-view-only data, and OTLP auth

The logic lives in a new short module, clawmetry/otlp_intake.py. dashboard.py only gathers counts and calls it.

Review fix: one item the store cannot hold no longer poisons its export

The coordinator review found a blocking regression. The token columns (spans.tokens_input / tokens_output / token_count, the same in otlp_records, and events.token_count) are DuckDB INTEGER, while OTLP carries int64. On the previous head (27a01b1):

  • Traces: one span with gen_ai.usage.input_tokens = 3e9 failed the one-transaction span batch. The export got 503 and so did every retry, and the valid span beside it never landed (main answered 200 and stored it).
  • Logs: the same value counted as a failed record write. It also failed the event ring flush for every event queued beside it, on every retry, so each delivery got 503.

What changed:

  • local_store._is_data_error tells a value the store cannot hold (DuckDB DataError, ValueError / OverflowError, the driver's own cast error) from a store failure (closed connection, I/O, anything else).
  • ingest_spans_batch: a batch that fails on a data error is written again one span at a time, so valid spans land and only the bad one is dropped. Any other error still raises. This also protects the daemon's own span writes. with_outcome=True returns {"written", "rejected"} for the receiver; without it the return value and the ValueError for a missing field are unchanged.
  • put_otlp_batch: such a row counts as records_rejected, not records_failed.
  • _event_to_row: an events.token_count beyond INTEGER range is stored as unknown, so it cannot block the ring flush. The rollups are BIGINT and unaffected.
  • The _i helpers treat inf / NaN as unknown instead of raising.
  • The receiver answers 200 with partialSuccess for refused items and keeps 503 for a store that did not confirm the write. The partialSuccess message and the generated contract now name this case.

Coordination

#5684. This extends the ingest contract already on main (clawmetry/ingest_contract.py + scripts/gen_ingest_doc.py) and does not start a second reference. #5684 rewrites that module and _otlp_receive, so whichever PR merges second must carry the other's sections. The constants added here are self-contained (OTLP_RETRY_AFTER_SECONDS, OTLP_ACKNOWLEDGEMENT, OTLP_REDELIVERY_IDENTITY, OTLP_SESSION_IDENTITY, OTLP_LIVE_VIEW_ONLY, OTLP_AUTH, OTLP_INTAKE_STATUS), so carrying them is a copy.

#5953 (span Guard + redaction, a dependency of #5949) merged first; this PR is rebased onto it and combines the two: spans are redacted outside the write lock before any write, and the one-span-at-a-time retry after a data error writes those same redacted rows (pinned by test_spans_written_one_at_a_time_after_a_data_error_are_still_scrubbed). Both PRs touch _process_otlp_traces and the dict put_otlp_batch returns:

Done: #5953 merged, and this PR was rebased onto it on 2026-09-14 (confirmed-write accounting first, then wait events and tool events from spans; both counter sets returned).

No new HTTP route, so no cloud_route_policy entry or cloud PR is needed. The diff adds no @*.route( decorator; /api/otel-status only gains a key.

Mixed versions. ingest_spans_batch (and its with_outcome argument) and the allowlist entry ship in the same release. A dashboard newer than its daemon, mid-upgrade, gets None from the proxy, so trace exports answer 503 until the daemon updates; the sender's retries then land.

Verification

  • New guard file. tests/test_otlp_durable_ack.py has 25 tests and is in the CI "OTLP receiver suite" job. It drives the real route over the real mappers, a real DuckDB store, and the real _ProxyStore with no daemon behind it, and declares every AC-OBS-OIA-001.* criterion. It covers:
    • 503 then landing exactly once on retry, for logs, traces and profile metrics;
    • a store that raises, and a failed event flush;
    • partial success in JSON and protobuf;
    • a span and a log record carrying a token count beyond INTEGER range, each sent three times: 200 with partialSuccess every time and the valid item stored once;
    • a span write the store itself failed (I/O) still answering 503;
    • the store half: a batch with an out-of-range, an unencodable and an infinite value keeps its valid spans, and an out-of-range event token count no longer fails the ring flush;
    • 400 and 401, both counted;
    • re-delivery leaving totals and tiles unchanged, while two distinct charged calls stay two;
    • a 6-hour-late delivery landing at its own time after a retry;
    • intake during a budget pause;
    • unknown versus zero usage;
    • sampled spans versus spend from unsampled log records;
    • the generated doc.
  • Revert proof, review fix. The updated test file was run in a separate detached worktree at the previous head 27a01b1, with no product changes and no stash: 4 failed, 21 passed, and the 4 failures are exactly the new tests. With the fix: 25 passed.
  • Revert proof, original change. Only the product diff was reverse-applied (dashboard.py, routes/meta.py, clawmetry/local_store.py, routes/local_query.py), from a saved patch, with no stash. Red: 18 failed, 3 passed (the 3 pin behaviour that already held, or read files that were not reverted). Green: the tree is byte-identical and all 21 then-existing tests pass.
  • Existing suites on the fix: the span batch, OTLP edge-case, GenAI cache, traces cost, JSON-without-protobuf, range-index, ingest-doc drift and every other tests/test_otlp_*.py file: 163 passed. Five tests fail locally in the same way on the untouched previous head, so this change did not cause them. Four are in test_otlp_daemon_free_intake.py, which was green in this PR's CI OTLP job. The fifth is test_ingest_bulk_flush.py::test_pre_chain_redelivery_gets_stamped_in_place (a DuckDB parameter-count error), which is in no CI job.
  • Existing tests moved to the new contract, none weakened:
    • test_genai_cache_semconv.py and test_spans_otlp_edge_cases.py pinned the old per-span put_span call. The proxy-keyword guard now asserts exactly one ingest_spans_batch(spans=...) call and its allowlist entry.
    • test_otlp_traces_cost.py posted a span with no ids, which OTLP forbids and the store refuses.
    • test_otlp_json_no_protobuf.py's fixture now resets the store singleton it stops, which fixes the hidden closed-store leak described above.
  • Local gates: check_ac_coverage.py --check, check_ci_test_coverage.py --check, lint_daemon_allowlist.py, check_py39_annotations.py, gen_ingest_doc.py --check and gen_module_map.py --check all exit 0.

Remaining (not claimed by this PR)

  • The numbered reconciliation workload against an installed package on a live receiver: a daemon restart, a transient writer failure and a one-hour disconnection, reconciled to rows and to the rendered UI. The fixtures here exercise each failure mode through the real code paths, but a fixture pass is not evidence of an installed integration.
  • Served artifact: verifying the released wheel and the route the deployment actually uses, after release.
  • Per-session completeness: showing in the UI which signals arrived for a session and which sources are missing.
  • Generic metrics persistence: the openclaw.* and gen_ai.client.* metrics stay live-tile only. That is documented, and they are counted as live_view_only.
  • Durable bounded intake queue: the design deliberately has none, because the exporter's retry buffer holds the batch. The contract and the status block both say so.
  • Scoped ingest keys for remote senders (feat(ingest): let a scoped ingest key authenticate /v1/* and /api/v1/*, so an agent off-box can be observed #5679, via feat(ingest): open the front door — ingest key, generated contract, setup prompt, live status #5684).
  • Event ring: strings (follow-up). An event whose text the driver cannot encode (a lone surrogate from a JSON body) would still fail the ring flush, as it does on main; only the out-of-range token count is fixed here.
  • Live tiles for a refused span (follow-up). Tiles light before the store write, so a span the store refuses for an out-of-range value can still light a tile once. The per-span dedup keeps retries from adding to it.
  • The existing 400 response body still carries the raw decode error message; this predates the PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9

@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: An OpenTelemetry sender knows what was kept

File: clawmetry/otlp_intake.py

The blueprint is a template placeholder with only outline sections (Feature Summary, Component Blueprint Composition, etc.) and contains no technical design details, while the requirement REQ-OBS-OIA-001 is comprehensive and fully implemented in code including the new otlp_intake module, modified response handling, intake status tracking, and documentation.

@github-actions

github-actions Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Visual diff

Comparing 857da28fb768 (head) against the PR base branch.

56 of 70 comparison(s) flagged (>1% pixel diff).

View Before After Diff
desktop overview before after diff · 0.36%
desktop flow ⚠️ before after diff · 100.00%
desktop brain ⚠️ before after diff · 100.00%
desktop usage ⚠️ before after diff · 100.00%
desktop crons before after diff · 0.02%
desktop memory ⚠️ before after diff · 100.00%
desktop security ⚠️ before after diff · 100.00%
desktop subagents ⚠️ before after diff · 100.00%
desktop transcripts ⚠️ before after diff · 100.00%
desktop logs ⚠️ before after diff · 100.00%
desktop skills ⚠️ before after diff · 100.00%
desktop models ⚠️ before after diff · 2.34%
desktop approvals before after diff · 0.02%
desktop alerts ⚠️ before after diff · 100.00%
desktop notifications before after diff · 0.00%
desktop limits ⚠️ before after diff · 100.00%
desktop history ⚠️ before after diff · 100.00%
desktop channels ⚠️ before after diff · 100.00%
desktop harness ⚠️ before after diff · 100.00%
desktop inventory ⚠️ before after diff · 100.00%
desktop nemoclaw ⚠️ before after diff · 100.00%
desktop guard ⚠️ before after diff · 100.00%
desktop signals before after diff · 0.02%
desktop policy before after diff · 0.01%
desktop selfevolve ⚠️ before after diff · 100.00%
desktop swimlane ⚠️ before after diff · 100.00%
desktop tool-catalog before after diff · 0.02%
desktop tracing before after diff · 0.02%
desktop turn-anatomy ⚠️ before after diff · 100.00%
desktop version-impact ⚠️ before after diff · 100.00%
desktop context-economics ⚠️ before after diff · 100.00%
desktop agents ⚠️ before after diff · 100.00%
desktop evals before after diff · 0.00%
desktop bench ⚠️ before after diff · 100.00%
desktop trail ⚠️ before after diff · 100.00%
mobile overview ⚠️ before after diff · 100.00%
mobile flow ⚠️ before after diff · 100.00%
mobile brain before after diff · 0.00%
mobile usage ⚠️ before after diff · 100.00%
mobile crons ⚠️ before after diff · 100.00%
mobile memory ⚠️ before after diff · 100.00%
mobile security ⚠️ before after diff · 100.00%
mobile subagents ⚠️ before after diff · 100.00%
mobile transcripts ⚠️ before after diff · 100.00%
mobile logs ⚠️ before after diff · 100.00%
mobile skills before after diff · 0.02%
mobile models before after diff · 0.00%
mobile approvals ⚠️ before after diff · 100.00%
mobile alerts ⚠️ before after diff · 100.00%
mobile notifications ⚠️ before after diff · 100.00%
mobile limits ⚠️ before after diff · 2.47%
mobile history ⚠️ before after diff · 100.00%
mobile channels before after diff · 0.00%
mobile harness ⚠️ before after diff · 100.00%
mobile inventory ⚠️ before after diff · 100.00%
mobile nemoclaw ⚠️ before after diff · 100.00%
mobile guard ⚠️ before after diff · 100.00%
mobile signals ⚠️ before after diff · 100.00%
mobile policy ⚠️ before after diff · 3.72%
mobile selfevolve ⚠️ before after diff · 100.00%
mobile swimlane ⚠️ before after diff · 3.74%
mobile tool-catalog ⚠️ before after diff · 100.00%
mobile tracing ⚠️ before after diff · 100.00%
mobile turn-anatomy ⚠️ before after diff · 100.00%
mobile version-impact ⚠️ before after diff · 3.28%
mobile context-economics before after diff · 0.00%
mobile agents ⚠️ before after diff · 100.00%
mobile evals ⚠️ before after diff · 100.00%
mobile bench ⚠️ before after diff · 100.00%
mobile trail ⚠️ before after diff · 100.00%

Folder: 857da28fb768. Full PNGs also attached as a workflow artefact.

Generated by visual-diff bot. Pixel diffs >1% flagged; eyeball the table before merging. This check is non-blocking — fail = bot bug, not a code problem.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand

Copy link
Copy Markdown
Owner Author

Coordinator review

Verdict: fix before merge. One blocking regression; the rest is solid.

Blocking

1. One out-of-range item now makes an export fail forever. For traces, it also loses every valid span in that export.

  • Cause: spans.tokens_input / tokens_output / token_count and the matching otlp_records columns are DuckDB INTEGER (int32). OTLP sends int64.
  • Traces: _otlp_span_row_storable checks ids, name and start time, but not value ranges. ingest_spans_batch writes the export in one transaction, so a single span with gen_ai.usage.input_tokens = 3_000_000_000 fails the whole batch. The receiver returns 503 and records store_write_failed. Every retry fails the same way, and the valid spans never land.
  • Logs: each row is written separately and the bad one raises, so records_failed gets counted and the receiver returns 503 forever. The valid rows are stored and rewritten on every retry.
  • Probe: a reviewer script, not committed. It uses the real route, mappers and DuckDB store, and posts the same export three times on this PR head and on origin/main:
this PR main
/v1/traces, 1 valid span + 1 span with 3e9 input tokens [503, 503, 503], valid span stored: 0 [200, 200, 200], valid span stored: 1
/v1/logs, 1 valid record + 1 record with 3e9 input tokens [503, 503, 503], rows stored: 1 [200, 200, 200], rows stored: 1
  • Why it blocks: this is the poison-batch case REQ-OBS-OIA-001 rejects ("A retry would be refused again for the same item, forever, and every valid item in the batch would be lost with it"). It contradicts AC-OBS-OIA-001.2: store the valid items, answer success, and report the refused count. For traces it is a data-loss regression against main, and the exporter is told to retry something retrying cannot fix.
  • Fix: a store error caused by the data itself (DuckDB conversion or out-of-range, ValueError) should count as rejected and return partialSuccess. Only a store that is unavailable, or an error not caused by the data, should return 503. Either of these works:
    • check and clamp integer ranges in the mapper (store an out-of-range usage value as unknown, or refuse that span);
    • or, when the batch write raises a data error, retry the spans one at a time so valid ones land and the bad ones are counted as rejected.
  • Regression tests: add one per signal to tests/test_otlp_durable_ack.py, and show they fail on the current head.

Verified OK

  • Acceptance criteria are reported honestly. AC 1, 3, 4, 5, 6, 7 and 8 have real-path tests (21/21 pass locally on the head; I did not re-run the revert proof). The issue's "numbered real workload" and "served package" criteria are listed as remaining in both the PR body and the CHANGELOG, not claimed. The body says Refs, not Closes.
  • Queue fields: the issue asks for queued counts and oldest queued age. No queue exists, which is a stated non-goal in the requirement. The status says so instead of reporting a zero queue depth.
  • Gates:
    • The full Factory URL is in the body, and all 8 criteria are mirrored in acceptance_criteria.json.
    • The new test file is in the CI OTLP job, and no existing check was weakened (the three changed tests follow the batched write).
    • No new route, so no cloud policy entry is needed.
    • No customer names; acme_oia is a placeholder.
    • Merge state: MERGEABLE / CLEAN, 1 commit behind main (fix: auto-update plan sync ignores a stored opt-out #5966, which does not overlap).
  • Proxy path:
    • ingest_spans_batch is on the allowlist.
    • The RPC memo covers only query_events_slim, so write calls are never served from cache.
    • A retry whose spans are unchanged returns 0, not None, so it is acknowledged and counted as already_stored.
    • A timeout after the daemon has committed leads to a retry that dedupes safely.
  • Redaction: the intake status holds counts and fixed failure categories only; tests check that no secret, email or token appears.
  • Budget pause: nothing else in the repo relies on the removed 429.
  • Duplication: extends the existing ingest_contract.py and generated doc; no second reference. Overlap with fix(otlp): trace spans reach Guard and are scrubbed before storage #5953 and feat(ingest): open the front door — ingest key, generated contract, setup prompt, live status #5684 is disclosed.

Non-blocking (follow-ups)

  • Mixed versions: a dashboard newer than its daemon (for example, mid-upgrade) calls ingest_spans_batch, which the older daemon does not allow. Every trace export then returns 503 until the daemon updates. The retries recover once it does, but a line in the contract would help.
  • Existing 400 body: it still returns {"error": str(e)}, a raw exception message. This predates the PR.
  • Rollup shape change: cost_usd / tokens are now null for groups with no reported values. Nothing in this repo or the cloud UI reads them, and the CHANGELOG notes the change. Hosted or third-party API users should be told in the release notes.

vivekchand added a commit that referenced this pull request Sep 14, 2026
…s export

Review of #5963: the token columns are DuckDB INTEGER while OTLP carries
int64. One span with gen_ai.usage.input_tokens=3e9 failed the one-transaction
span batch, so the export was answered 503, every retry failed the same way
and the valid spans beside it never landed (main answered 200 and stored
them). A log record with the same value counted as a failed write, so every
delivery was answered 503.

- local_store: _is_data_error tells a value the store cannot hold (DuckDB
  DataError, ValueError/OverflowError, the driver's cast error) from a store
  failure (closed connection, I/O).
- ingest_spans_batch: a batch that fails on a data error is written again one
  span at a time, so valid spans land; other errors still raise.
  with_outcome=True returns {written, rejected} for the OTLP receiver.
- put_otlp_batch: such a row counts as records_rejected, not records_failed.
- _event_to_row: an events.token_count beyond INTEGER is stored as unknown.
  It used to fail the ring flush for every event queued beside it, on every
  retry.
- _i helpers: inf/NaN become unknown instead of raising.
- The receiver answers 200 with partialSuccess for the refused items and
  keeps 503 for a store that did not confirm the write.

Regression guard: 4 new tests in tests/test_otlp_durable_ack.py fail on the
previous head (27a01b1) and pass here (25/25).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from 27a01b1 to b29491d Compare September 14, 2026 03:11
@vivekchand

Copy link
Copy Markdown
Owner Author

Review fix pushed (b29491d): an item the store cannot hold no longer poisons its export

Addresses the blocking item in the coordinator review.

Your probe, re-run as regression tests on the real route, mappers and DuckDB store (each export sent 3 times):

previous head 27a01b1 this head
/v1/traces, 1 valid span + 1 span with 3e9 input tokens 503 every time, valid span stored: 0 200 with partialSuccess.rejected_spans = 1 every time, valid span stored once
/v1/logs, 1 valid record + 1 record with 3e9 input tokens 503 every time 200 with partialSuccess.rejected_log_records = 1 every time, valid record stored once

A second poison path turned up while writing the logs test. events.token_count is also INTEGER. The event built from that record failed the ring flush for every event queued beside it, on every retry. That behaviour predates this PR; the PR's receiver only surfaced it as a 503. It is fixed here as well: such a count is stored as unknown, and the rollups are BIGINT and unaffected.

How:

  • local_store._is_data_error separates a value the store cannot hold (DuckDB DataError, ValueError / OverflowError, the driver's cast error) from a store failure (closed connection, I/O).
  • A span batch that fails on a data error is written again one span at a time. This covers the daemon's own span writes too.
  • put_otlp_batch counts such a row as records_rejected, not records_failed.
  • 503 stays for a store that did not confirm the write. A new test covers an I/O error on the span write.

Proof: 4 new tests in tests/test_otlp_durable_ack.py. In a detached worktree at 27a01b1 with only the new test file: 4 failed, 21 passed. On this head: 25 passed.

Still open, listed in the PR body as follow-ups: an event whose text the driver cannot encode would still fail the ring flush, as it does on main. A span refused for an out-of-range value can still light a live tile once.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

Merged origin/main into this branch. Resolved a mechanical CHANGELOG.md conflict (kept both this PR's OTLP durable-ack entry and main's newer entries in the Unreleased section).


Generated by Claude Code

vivekchand added a commit that referenced this pull request Sep 14, 2026
…s export

Review of #5963: the token columns are DuckDB INTEGER while OTLP carries
int64. One span with gen_ai.usage.input_tokens=3e9 failed the one-transaction
span batch, so the export was answered 503, every retry failed the same way
and the valid spans beside it never landed (main answered 200 and stored
them). A log record with the same value counted as a failed write, so every
delivery was answered 503.

- local_store: _is_data_error tells a value the store cannot hold (DuckDB
  DataError, ValueError/OverflowError, the driver's cast error) from a store
  failure (closed connection, I/O).
- ingest_spans_batch: a batch that fails on a data error is written again one
  span at a time, so valid spans land; other errors still raise.
  with_outcome=True returns {written, rejected} for the OTLP receiver.
- put_otlp_batch: such a row counts as records_rejected, not records_failed.
- _event_to_row: an events.token_count beyond INTEGER is stored as unknown.
  It used to fail the ring flush for every event queued beside it, on every
  retry.
- _i helpers: inf/NaN become unknown instead of raising.
- The receiver answers 200 with partialSuccess for the refused items and
  keeps 503 for a store that did not confirm the write.

Regression guard: 4 new tests in tests/test_otlp_durable_ack.py fail on the
previous head (27a01b1) and pass here (25/25).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from 9ad6a98 to 005698b Compare September 14, 2026 06:22
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

vivekchand added a commit that referenced this pull request Sep 14, 2026
…s export

Review of #5963: the token columns are DuckDB INTEGER while OTLP carries
int64. One span with gen_ai.usage.input_tokens=3e9 failed the one-transaction
span batch, so the export was answered 503, every retry failed the same way
and the valid spans beside it never landed (main answered 200 and stored
them). A log record with the same value counted as a failed write, so every
delivery was answered 503.

- local_store: _is_data_error tells a value the store cannot hold (DuckDB
  DataError, ValueError/OverflowError, the driver's cast error) from a store
  failure (closed connection, I/O).
- ingest_spans_batch: a batch that fails on a data error is written again one
  span at a time, so valid spans land; other errors still raise.
  with_outcome=True returns {written, rejected} for the OTLP receiver.
- put_otlp_batch: such a row counts as records_rejected, not records_failed.
- _event_to_row: an events.token_count beyond INTEGER is stored as unknown.
  It used to fail the ring flush for every event queued beside it, on every
  retry.
- _i helpers: inf/NaN become unknown instead of raising.
- The receiver answers 200 with partialSuccess for the refused items and
  keeps 503 for a store that did not confirm the write.

Regression guard: 4 new tests in tests/test_otlp_durable_ack.py fail on the
previous head (27a01b1) and pass here (25/25).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from 005698b to 2cdb11b Compare September 14, 2026 06:23
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

1 similar comment
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

awaiting confirmation — non-trivial rebase, needs human review (conflicts in: .claude/settings.json, .github/workflows/ci.yml, dashboard.py, clawmetry/sync.py, clawmetry/local_store.py, routes/meta.py, routes/local_query.py, and 140+ other files — add/add conflicts across the entire tree on the first replayed commit; main has diverged significantly since this branch forked)


Generated by Claude Code

github-actions Bot pushed a commit that referenced this pull request Sep 14, 2026
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

github-actions Bot pushed a commit that referenced this pull request Sep 14, 2026
vivekchand added a commit that referenced this pull request Sep 14, 2026
…s export

Review of #5963: the token columns are DuckDB INTEGER while OTLP carries
int64. One span with gen_ai.usage.input_tokens=3e9 failed the one-transaction
span batch, so the export was answered 503, every retry failed the same way
and the valid spans beside it never landed (main answered 200 and stored
them). A log record with the same value counted as a failed write, so every
delivery was answered 503.

- local_store: _is_data_error tells a value the store cannot hold (DuckDB
  DataError, ValueError/OverflowError, the driver's cast error) from a store
  failure (closed connection, I/O).
- ingest_spans_batch: a batch that fails on a data error is written again one
  span at a time, so valid spans land; other errors still raise.
  with_outcome=True returns {written, rejected} for the OTLP receiver.
- put_otlp_batch: such a row counts as records_rejected, not records_failed.
- _event_to_row: an events.token_count beyond INTEGER is stored as unknown.
  It used to fail the ring flush for every event queued beside it, on every
  retry.
- _i helpers: inf/NaN become unknown instead of raising.
- The receiver answers 200 with partialSuccess for the refused items and
  keeps 503 for a store that did not confirm the write.

Regression guard: 4 new tests in tests/test_otlp_durable_ack.py fail on the
previous head (27a01b1) and pass here (25/25).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from 9081798 to c57dd2a Compare September 14, 2026 12:32
github-actions Bot pushed a commit that referenced this pull request Sep 14, 2026
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

github-actions Bot pushed a commit that referenced this pull request Sep 14, 2026
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from 9a8f3f4 to 21a8933 Compare September 14, 2026 21:08
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

⚠️ needs manual rebase — conflict in dashboard.py, clawmetry/local_store.py, routes/meta.py, docs/MODULE_MAP.md (and others). Cannot auto-resolve: changes to core 20k+ line files require code-level judgment.


Generated by Claude Code

Copy link
Copy Markdown
Owner Author

Blocked on required review — skipping (auto-mergeability sweep). @vivekchand please approve when ready.


Generated by Claude Code

github-actions Bot pushed a commit that referenced this pull request Sep 14, 2026
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from 21a8933 to 857da28 Compare September 14, 2026 21:43
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

github-actions Bot pushed a commit that referenced this pull request Sep 14, 2026
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

Copy link
Copy Markdown
Owner Author

✨ auto-fixed: merged main into branch (was UNSTABLE/BEHIND — CI will re-run)


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from bbb3a02 to 1238cec Compare September 15, 2026 03:50
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

1 similar comment
@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

vivekchand and others added 2 commits September 15, 2026 09:34
…as refused

The OTLP receiver answered 200 to every export that decoded. The store write
behind it was best effort: while the daemon owning DuckDB was unreachable the
proxy returned None, a warning was logged, and the exporter discarded a batch
that was never stored. Spans the store refused were acknowledged the same
way, a budget pause answered 429 to all intake, retried spans and metric
points doubled the live tiles, and a missing token side was stored as 0.

- 200 only after the store confirms the write; 503 + Retry-After when it does
  not; OTLP partialSuccess (JSON or protobuf) for malformed items.
- Traces write one ingest_spans_batch per export (allowlisted) so a lost write
  is visible; put_span returned None either way.
- A value the store cannot hold (an int64 token count in an INTEGER column) is
  refused on its own: ingest_spans_batch retries a batch that fails on a data
  error one span at a time, put_otlp_batch counts such a row as rejected, and
  events.token_count beyond INTEGER is stored as unknown.
- Budget pause no longer pauses intake; requests during it are counted.
- Live tiles count a span once per (trace, span) id and a metric point once
  per its own time.
- Unknown usage stays NULL; rollup reports cost_usd null with
  records_with_cost / records_with_tokens.
- /api/otel-status gains an intake block (counts and categories only).
- Generated docs/INGEST.md states ack, retry, re-delivery identity, session
  fallback, live-view-only data and OTLP auth.

Rebased onto main after #5953 (trace spans reach Guard and are scrubbed)
merged first. The two changes are combined, not just both kept:
- ingest_spans_batch: spans are redacted outside the write lock BEFORE any
  write, and both the batch write and the one-span-at-a-time retry write those
  same redacted rows. A span redaction leaves unstorable is refused, never
  stored unredacted.
- _process_otlp_traces: the confirmed-write accounting runs first; wait events
  and the tool events from spans are written after it. A refused span yields
  no tool events.
- put_otlp_batch returns both counter sets (events_skipped_other_signal plus
  the records_* / events_* outcome counters); apply_batch_outcome reads any
  events_skipped_* key as skipped, not as a failed write.
- tests/test_otlp_trace_guard_redaction.py gains
  test_spans_written_one_at_a_time_after_a_data_error_are_still_scrubbed,
  checked against a mutant that writes unredacted rows on the retry path.
- docs/acceptance_criteria.json carries AC-OBS-OIA-001.1-.8 once each;
  docs/MODULE_MAP.md regenerated. CHANGELOG.md is unchanged from main.

REQ-OBS-OIA-001. Refs #5949

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
@vivekchand
vivekchand force-pushed the feat/otlp-durable-ack branch from a4741c0 to bd47c73 Compare September 15, 2026 07:37
@vivekchand

Copy link
Copy Markdown
Owner Author

PR fixer: rebased linear onto main after #5968 and #5962 (bd47c73)

The a4741c07b2 head merged main into the branch. I replaced that merge with a rebase. The branch is now two commits directly on origin/main 55d3adf06c: the squashed change b719954e84 and a MODULE_MAP regeneration bd47c73e8a.

  • Conflicts: only docs/MODULE_MAP.md, fixed by regenerating it. docs/acceptance_criteria.json and clawmetry/local_store.py merged without conflict. Project attribution and per-project budgets: burn against budget #5968 and Guard: prompt injection is a detector, and untrusted content raises the pre-tool risk tier #5962 do not touch the span write, put_otlp_batch, _process_otlp_traces or _DAEMON_METHODS.
  • fix(otlp): trace spans reach Guard and are scrubbed before storage #5953 (already on main): the combined ingest_spans_batch redacts before any write attempt, outside the write lock. The batch write and the one-span-at-a-time data-error retry both write the redacted rows, and a span that redaction leaves unstorable is refused, never written unredacted.
  • Manifest: no duplicate criterion ids. CHANGELOG.md: identical to main.
  • Local gates: check_ac_coverage.py --check, check_ci_test_coverage.py --check, gen_module_map.py --check, gen_ingest_doc.py --check, lint_daemon_allowlist.py and check_py39_annotations.py all pass.
  • Tests: 138 passed across test_otlp_durable_ack.py, test_otlp_trace_guard_redaction.py, test_genai_cache_semconv.py, test_otlp_json_no_protobuf.py, test_otlp_traces_cost.py, test_spans_otlp_edge_cases.py, test_otlp_compat_port.py, test_otlp_daemon_free_intake.py and test_module_map_drift.py.

#6009 (span parity) can rebase onto this once it merges.


Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

⚠️ Drift Bot (ClawMetry): 1 potential drift finding(s)

1. Blueprint: An OpenTelemetry sender knows what was kept

File: clawmetry/local_store.py

The code calls _is_data_error(exc) function to distinguish values the store cannot hold from store failures, but this helper function is not defined in the codebase. The blueprint and requirement specify that data errors (value out of range, unencodable string) should be treated differently from store errors (I/O, connection closed).

…rs.py

is_data_error and int32_or_none were defined near line 20,100 of the
21k-line local_store.py, past what Drift Bot reads, so it reported
_is_data_error as undefined. A short leaf module holds them now;
local_store imports them under the same private names, so callers and
tests are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jm9d7s4fN55hN3YzQo75o9
@vivekchand

Copy link
Copy Markdown
Owner Author

Drift Bot finding on bd47c73: the helper existed, past what the bot reads (fixed in 7531e2a)

Drift Bot reported that _is_data_error "is not defined in the codebase". On bd47c73e8a it was defined at clawmetry/local_store.py:20097, in a 21,135-line file, and called at lines 9870, 9877 and 10180. tests/test_otlp_durable_ack.py:473-476 tests it directly. The definition was past the part of a long file the bot reads.

Fix in 7531e2a9df: is_data_error and int32_or_none (both added by this PR) now live in a short leaf module, clawmetry/store_errors.py. local_store.py imports them near the top under the same private names, so no caller or test changed. docs/MODULE_MAP.md is regenerated.

Verified locally:

  • Gates: gen_module_map.py --check, check_ac_coverage.py --check, check_ci_test_coverage.py --check, gen_ingest_doc.py --check, lint_daemon_allowlist.py and check_py39_annotations.py pass.
  • Tests: test_module_map_drift.py has 5 passed. The OTLP suites have 133 passed, including test_otlp_durable_ack.py and test_otlp_trace_guard_redaction.py.
  • Files: CHANGELOG.md is identical to main, and the AC manifest has no duplicate ids.

Generated by Claude Code

@8090-software-factory

Copy link
Copy Markdown

✅ Drift Bot (ClawMetry): no drift detected

Drift Bot analyzed the changed files against this project's blueprints and requirements and found no drift.

@vivekchand
vivekchand merged commit f489d0a into main Sep 15, 2026
53 of 54 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.

1 participant