Skip to content

[OPIK-7852] [BE] Persist cipx call attribution: trigger, trigger_detail, turn_key, parent_tool_use_id - #7937

Open
aadereiko wants to merge 4 commits into
mainfrom
sasha/subagent-lineage
Open

[OPIK-7852] [BE] Persist cipx call attribution: trigger, trigger_detail, turn_key, parent_tool_use_id#7937
aadereiko wants to merge 4 commits into
mainfrom
sasha/subagent-lineage

Conversation

@aadereiko

@aadereiko aadereiko commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Details

cipx already ships trigger, trigger_detail, turn_key and parent_tool_use_id on metadata.cipx.call, but none reached a typed column — so the spend tables could not tell a sub-agent's spend from the main agent's, name the agent, or reconstruct the parent/child tree. This adds the four columns and the DAO mapping.

  • trigger_detail is overloaded: it carries the agent NAME when trigger='subagent' and the TOOL name when trigger='tool_continuation', so consumers must gate on trigger before grouping per agent.
  • Empty is deliberately not defaulted — it means unknown, never a guessed agent name.
  • Migration 000118 is additive with DEFAULT '', so pre-existing rows read as unknown, which is honest: they were written before the proxy carried the fields.

Change checklist

  • User facing
  • Documentation update

Issues

  • OPIK-7852

Follow-up found while testing this work, not resolved here: OPIK_8026 (a fork's inherited-cache spend is billed to the main agent). Related but not resolved: OPIK_7757 (inference_geo), whose field name is used as the "unknown field" in one test precisely because it is a real pending addition.

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: Claude Code
  • Model(s): Claude Opus 5
  • Scope: Migration, DAO mapping and both tests authored with Claude Code; the design decisions (forward-compatibility over validation, two-row sentinel insert) were reviewed and are described below.
  • Human verification: Test suite run locally (12 tests green) before the local Maven harness degraded; end-to-end verified against a local Opik with a branch-built proxy — 9 real agent invocations, all named and linked, depth-2 nesting intact.

Testing

mvn test -Dtest='CostIntelligenceIngestionTest'12 tests, 0 failures at the time the tests were written.

Two tests, both aimed at failure modes that would otherwise be silent:

  • Positional bind order. The DAO binds by position and nothing checks the order against the INSERT tuple at compile time; a mismatch writes each value into the neighbouring column without erroring. Every column gets a distinct sentinel and is asserted to hold its own, with the column name as the assertion description. Two rows, not one — the bind index accumulates across rows while workspace_id is bound once at index 0 and its repeats dedup, so a wrong stride corrupts only the second row and a single-row insert cannot see it. The ids are UUIDs because project_id/trace_id/span_id are FixedString(36), where a rotation among the three is silently accepted by ClickHouse.
  • Unknown-field tolerance. cipx ships to laptops on its own cadence, so a newer proxy talking to an older backend is the normal state, not an edge case. Unknown fields at every level must be ignored rather than reject the row — dropping it would lose the spend entirely, and spend totals are the one thing already correct today.

End-to-end: migration applied to a local Opik and exercised by real Claude Code sessions through a branch-built proxy. Result: 9 agent invocations, all named and linked, agents_dispatched=9 linked=9 ambiguous=0, depth-2 nesting intact, and the auto-mode permission classifier correctly bucketed as automated rather than counted as user work.

Not re-run locally: the last commit moves one assertion inside an existing await (a strictly narrower timing window). The local Maven surefire environment has since started failing this whole test class with ClassNotFoundException: DropwizardAppExtensionProvider — including on test methods untouched by this PR, so it is environmental — and CI is the verification for that commit.

Documentation

No user-facing documentation change. The columns are internal analytics storage; the semantics (especially trigger_detail's overloading and the empty-means-unknown rule) are documented in migration 000118's changeset comment and in the DAO's javadoc, which is where the next person to touch the INSERT will look.

aadereiko and others added 2 commits August 19, 2026 14:42
…nt_tool_use_id

cipx already ships all four on metadata.cipx.call, but none reached a typed
column, so the spend tables cannot tell a subagent's spend from the main
agent's, name the agent, or reconstruct the parent/child tree. Migration 000118
adds them in one ALTER, additive with DEFAULT '' so pre-existing rows read as
unknown - honest, since they were written before the proxy carried the fields.

trigger_detail is the agent NAME when trigger='subagent' and the tool name when
trigger='tool_continuation', so consumers must gate on trigger before grouping
per agent. Empty is deliberately not defaulted: it means unknown, never a
guessed agent name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…lerance

CipxSpendDAO binds by position and nothing checks the order against the INSERT
tuple at compile time - the DAO's own comment says a mismatch "silently writes
each value into the neighbouring column". Give every column a distinct sentinel
and assert each holds its own, with the column name as the assertion
description so a regression names the column that received the wrong value.

Two rows, not one: the bind index accumulates across rows while workspace_id is
bound once at index 0 and its repeats dedup. If that dedup assumption is ever
wrong the stride becomes 21 instead of 20 and only the SECOND row is corrupted,
which a single-row insert cannot see. The ids are UUIDs because
project_id/trace_id/span_id are FixedString(36), so a rotation among those three
is silently accepted by ClickHouse.

Second test: cipx adds fields to metadata.cipx.call on its own release cadence
and ships to laptops independently of this service, so a newer proxy talking to
an older backend is the normal state. Unknown fields at every level - on the
call, inside usage (inference_geo, the real pending one from OPIK-7757), inside
cache_creation, inside config, beside call, on a block, and beside cipx in
metadata - must be ignored rather than reject the row. Dropping the row would
lose the spend entirely, and spend totals are the one thing already correct.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aadereiko
aadereiko requested a review from a team as a code owner August 20, 2026 15:57
@github-actions github-actions Bot added java Pull requests that update Java code Backend tests Including test files, or tests related like configuration. 🟠 size/L labels Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

⏱️ pre-commit per-hook timing

Hook Description Result Duration
🌐 typecheck — frontend Whole-project tsc type check 21.17s
☕ spotless — java backend Format Java code 5.38s
⚓ helm-docs Regenerate Helm chart README 3.85s
⚙️ actionlint — github workflows Lint GitHub Actions workflows 3.67s
🌐 eslint — frontend Lint + autofix JS/TS 2.19s
🐍 mypy — python sdk Static type check 1.60s
🌈 zizmor — github workflows security Security-scan GitHub Actions workflows 0.07s
🐍 trim trailing whitespace — python sdk Strip trailing whitespace 0.04s
🐍 fix end of files — python sdk Ensure files end in a newline 0.03s
🐍 ruff-format — python sdk Format Python code (ruff) 0.01s
🐍 ruff — python sdk Lint + autofix Python (ruff) 0.01s
Total (11 ran) 38.02s
⏭️ 32 skipped (no matching files changed)
Hook Description Result
🤖 trim trailing whitespace — optimizer Strip trailing whitespace ⏭️
🤖 fix end of files — optimizer Ensure files end in a newline ⏭️
🤖 check yaml — optimizer Validate YAML syntax ⏭️
🤖 check json — optimizer Validate JSON syntax ⏭️
🤖 check toml — optimizer Validate TOML syntax ⏭️
🤖 check for added large files — optimizer Block large files (>1MB) ⏭️
🔐 detect private key — optimizer Block committed private keys ⏭️
🤖 check for merge conflicts — optimizer Block merge-conflict markers ⏭️
🤖 check for case conflicts — optimizer Block case-only name clashes ⏭️
🤖 pyupgrade — optimizer Modernize Python syntax ⏭️
🤖 ruff — optimizer Lint + autofix Python (ruff) ⏭️
🤖 ruff-format — optimizer Format Python code (ruff) ⏭️
🤖 mypy — optimizer Static type check ⏭️
📓 nbstripout — optimizer notebooks Strip notebook output ⏭️
📝 markdownlint — optimizer Lint Markdown ⏭️
🔤 codespell — optimizer Fix common misspellings ⏭️
📊 radon cc — optimizer Cyclomatic-complexity gate ⏭️
📊 radon raw — optimizer Raw size metrics gate ⏭️
📊 xenon — optimizer Fail on complexity thresholds ⏭️
📊 lizard — optimizer Cyclomatic-complexity gate ⏭️
🧹 vulture — optimizer Find dead code ⏭️
🛡️ trim trailing whitespace — guardrails Strip trailing whitespace ⏭️
🛡️ fix end of files — guardrails Ensure files end in a newline ⏭️
🛡️ ruff — guardrails Lint + autofix Python (ruff) ⏭️
🛡️ ruff-format — guardrails Format Python code (ruff) ⏭️
🛡️ mypy — guardrails Static type check ⏭️
block non-public FE plugins Block non-public FE plugins ⏭️
🧪 pre-commit wrapper smoke tests Self-test the wrapper scripts ⏭️
🧪 rebaseline script tests Self-test the changelog re-baseline script ⏭️
📘 eslint — typescript sdk Lint + autofix JS/TS ⏭️
📘 typecheck — typescript sdk Whole-project tsc type check ⏭️
🐳 hadolint — dockerfiles Lint Dockerfiles ⏭️

@CometActions

CometActions commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

No test needed here.

The four new columns are write-only today: nothing in opik SELECTs from cipx_spends (no resource, no endpoint), and the AI Spend / Cost Intelligence entry point in the frontend is the comet ai-spend plugin that links out of Opik (hasAccess defaults to false on OSS). So there is no page or API response an e2e test could read trigger / trigger_detail / turn_key / parent_tool_use_id back from. The real risk in this diff is the positional bind order in the INSERT, and your own everyPositionalBindLandsInItsOwnColumn sentinel test in CostIntelligenceIngestionTest already pins exactly that, plus the empty-attribution default. Nothing an e2e layer could add. Worth re-triaging when the read side lands.

Run

Advisory, from the QA test radar. Nothing here blocks this PR, and anything it proposes is a draft for review.

Re-checked after a push on 20 Aug 16:50 UTC — nothing the verdict depends on changed.

Comment on lines +263 to +265
// The block writer sees the same metadata, so an unknown field on a block must not drop
// the blocks either.
assertThat(getCipxBlocks(span.id(), ws.workspaceId())).isNotEmpty();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Flaky unknown-field ingestion test

The test stops waiting when getCipxSpend(...) returns a row, so asynchronous block ingestion may still be incomplete when it asserts getCipxBlocks(...) is non-empty and intermittently fails — should we await the block-table condition separately or move that assertion into the existing untilAsserted callback?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/events/CostIntelligenceIngestionTest.java`
around lines 263-265, update `unknownFieldsDoNotRejectTheRow` because waiting for the
spend row does not guarantee that asynchronous block ingestion has completed. Add a
separate `await().untilAsserted` around the `getCipxBlocks(span.id(), ws.workspaceId())`
non-empty assertion, or perform that assertion inside the existing callback, so the test
does not intermittently fail due to timing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 79b112a addressed this comment by moving the block non-empty assertion inside the existing untilAsserted callback, so it is retried until asynchronous block ingestion completes.

Comment on lines +98 to +101
.trigger(call.path("trigger").asText(""))
.triggerDetail(call.path("trigger_detail").asText(""))
.turnKey(call.path("turn_key").asText(""))
.parentToolUseId(call.path("parent_tool_use_id").asText(""))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Malformed attribution silently persisted

metadata.cipx.call accepts any object, then parses its four call fields with asText("") without validation, so malformed values become the empty unknown sentinel or unsupported text and are indistinguishable from genuine unresolved attribution or unusable in downstream spend queries. Should we validate field shapes and trigger against migration 000118’s closed set (user_turn, tool_continuation, subagent, automated, unknown), or normalize malformed values to unknown while preserving the row insert and covering each JSON node kind?

Severity web_search

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
apps/opik-backend/src/main/java/com/comet/opik/domain/CipxSpendDAO.java around lines
98-101, update SpanRow.from so the attribution fields are not blindly converted with
asText(""). Validate `trigger` against migration 000118’s allowed values (`user_turn`,
`tool_continuation`, `subagent`, `automated`, `unknown`) and require the other
attribution fields to have the expected string shape, normalizing null, numeric, object,
array, and other malformed values to the documented unknown representation without
rejecting the row. Add regression tests covering all relevant JSON node kinds and
unsupported trigger tokens.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Declining — validating trigger against a closed set would work against what the sibling test in this PR exists to protect.

cipx ships to laptops on its own cadence, independently of this service's deploys, so a newer proxy talking to an older backend is the normal state, not an edge case. trigger is a closed enum as of today; the moment cipx adds a value (a fork trigger is already on the roadmap — OPIK_8026), validating against migration 000118's list would reject or mangle a legitimate value. That is exactly the failure unknownFieldsDoNotRejectTheRow guards against, and dropping the row would lose the spend entirely — spend totals being the one thing that is already correct today.

On the normalization half: asText("") already does it. A null, numeric, object or array node yields "", which is the documented unknown sentinel (see the SpanRow.from comment and the 000118 changeset). So malformed input already lands on "unknown" without rejecting the row.

You're right that this makes malformed values indistinguishable from genuinely unresolved attribution. That's deliberate and stated: "" means unknown, and a guessed agent name would book real spend against an agent that never ran. Distinguishing "the proxy could not resolve this" from "the proxy sent something we could not parse" would need a separate signal on the wire, not a decision made at parse time here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the clarification—forward compatibility and the intentional unknown sentinel make sense here. I’ll save this context to memory once the PR is merged.

ADD COLUMN IF NOT EXISTS turn_key String DEFAULT '',
ADD COLUMN IF NOT EXISTS parent_tool_use_id String DEFAULT '';

--rollback ALTER TABLE ${ANALYTICS_DB_DATABASE_NAME}.cipx_spends ON CLUSTER '{cluster}' DROP COLUMN IF EXISTS `trigger`, DROP COLUMN IF EXISTS trigger_detail, DROP COLUMN IF EXISTS turn_key, DROP COLUMN IF EXISTS parent_tool_use_id;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Migration violates required final blank line

The migration ends immediately after the --rollback ALTER TABLE ... line, so it lacks the required trailing blank line — should we add one, as .agents/skills/opik-backend/migrations.md requires?

Severity

Want Baz to fix this for you? Activate Fixer You can also update your AI coding guidelines based on this comment by apply pr to [branch name]

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/main/resources/liquibase/db-app-analytics/migrations/000118_add_trigger_to_cipx_spends.sql`
around line 30, update the migration file formatting so the `--rollback ALTER TABLE ...`
statement is followed by one empty line. Do not change the SQL or rollback behavior;
only add the required trailing blank line per the backend migration guidelines.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit 79b112a addressed this comment by adding a trailing blank line after the rollback statement.

…mpty line

Both from baz review feedback on #7937.

The unknown-field test awaited the spend row and then asserted the blocks
outside the callback. CostIntelligenceIngestionListener subscribes to the spend
insert and the block insert independently, so the spend row landing says nothing
about whether the blocks have - a real flake. Moved inside the same await.

Migration now ends with an empty line, per .agents/skills/opik-backend/
migrations.md ("Always end with empty line"). Note 000117 does not, so the
convention is documented but not uniformly followed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +261 to +265
// The block writer sees the same metadata, so an unknown field on a block must not
// drop the blocks either. Asserted inside the same await: the listener subscribes to
// the spend insert and the block insert independently
// (CostIntelligenceIngestionListener), so the spend row landing says nothing about
// whether the blocks have.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Incomplete test rationale

The comment ends with the incomplete phrase “whether the blocks have.”, so the rationale is unfinished — should we complete it as “whether the blocks have landed.”?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/events/CostIntelligenceIngestionTest.java`
around lines 261-265, update the comment in the block-ingestion assertion logic so the
incomplete phrase “whether the blocks have.” reads “whether the blocks have
landed.” Preserve the existing meaning and test behavior.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Commit b0941b3 addressed this comment by completing the unfinished comment phrase from “whether the blocks have.” to “whether the blocks have landed.”

From baz review feedback on #7937.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +176 to +187
@Test
@DisplayName("a call carrying no trigger fields ingests with empty attribution columns")
void spanWithoutTriggerFieldsLandsWithEmptyAttribution() {
var ws = newWorkspace();
String projectName = "cipx-" + UUID.randomUUID();

// systemToolsCipxMetadata carries no trigger/turn_key/parent_tool_use_id — the shape
// every span written before the proxy shipped them has. Ingestion must still land the
// row and must leave the attribution columns empty rather than substituting a default:
// "" means unknown, and a guessed agent name would book real spend against an agent
// that never ran.
var span = factory.manufacturePojo(Span.class).toBuilder()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy-row compatibility remains untested

spanWithoutTriggerFieldsLandsWithEmptyAttribution runs after runClickhouseDbMigration(CLICKHOUSE) has applied 000118, so it tests that the DAO supplies "" rather than that ClickHouse preserves empty values for pre-migration rows, allowing regressions in existing spend rows to pass — should we insert a row before 000118, run that changeset, then read all four columns as strings and assert they remain empty?

Severity

Want Baz to fix this for you? Activate Fixer

Other fix methods

Fix in Cursor

Prompt for AI Agents
Before applying, verify this suggestion against the current code. In
`apps/opik-backend/src/test/java/com/comet/opik/api/resources/v1/events/CostIntelligenceIngestionTest.java`
around lines 176-187, update `spanWithoutTriggerFieldsLandsWithEmptyAttribution` so it
does not rely on the class-level migration having already applied `000118`; that only
proves the current DAO supplies empty defaults. Arrange for a spend row to be written
before `000118`, apply that changeset, then read the row through the current DAO and
assert `trigger`, `triggerDetail`, `turnKey`, and `parentToolUseId` are all empty
strings, verifying migration compatibility for existing rows.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct that it is untested, and fair to ask given the PR body claims pre-existing rows read as unknown. Declining anyway, on cost/benefit.

What such a test would assert is ClickHouse's own ADD COLUMN … DEFAULT '' semantics — materializing the default for rows written before the column existed. That is not behaviour this repo implements, and there is no code path here that could regress it: the DAO only ever writes new rows, and the migration is a single additive ALTER. A green assertion would be testing the database, not us.

The cost is also not small. CostIntelligenceIngestionTest applies the full migration set at construction (MigrationUtils.runClickhouseDbMigration), so exercising the pre-migration state needs either a second database or custom changeset ordering in the harness. That is a meaningful amount of test infrastructure to protect a property we cannot break.

For what it's worth, the equivalent case on the read side is covered, because there it protects our code rather than ClickHouse's: ai-cost-backend #84 has a test that queries a genuinely column-less table and asserts the endpoint degrades to available=false instead of raising — that one guards a try/except we wrote.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, that distinction makes sense—this would test ClickHouse rather than a regressible repository behavior. I’ll save this guidance to memory once the PR is merged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Backend java Pull requests that update Java code 🟠 size/L tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants