Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
import static com.comet.opik.utils.template.TemplateUtils.getQueryItemPlaceHolder;

/**
* Writes the cipx_spends table from cipx LLM-call spans: span-level call data only (model + usage
* counters); the blocks land in cipx_spend_blocks via {@link CipxSpendBlockDAO}. Triggered
* Writes the cipx_spends table from cipx LLM-call spans: span-level call data only (model, usage
* counters, config knobs and the call's trigger/attribution fields); the blocks land in
* cipx_spend_blocks via {@link CipxSpendBlockDAO}. Triggered
* asynchronously off span create events; never reads the spans or cipx_spends tables. The cipx fields
* are parsed from metadata in Java ({@link SpanRow#from}); the listener only passes rows it has
* already gated to cipx.
Expand Down Expand Up @@ -60,7 +61,11 @@ public record SpanRow(
@NonNull String thinkingType,
long maxTokens,
@NonNull String contextManagement,
@NonNull String speed) {
@NonNull String speed,
@NonNull String trigger,
@NonNull String triggerDetail,
@NonNull String turnKey,
@NonNull String parentToolUseId) {

public static SpanRow from(UUID spanId, UUID traceId, UUID projectId, JsonNode metadata, Instant startTime) {
JsonNode call = metadata.path("cipx").path("call");
Expand All @@ -84,6 +89,16 @@ public static SpanRow from(UUID spanId, UUID traceId, UUID projectId, JsonNode m
.maxTokens(config.path("max_tokens").asLong(0))
.contextManagement(config.path("context_management").asText(""))
.speed(config.path("speed").asText(""))
// Attribution fields. trigger_detail carries the subagent NAME when
// trigger=subagent (the parent's Agent tool_use input.subagent_type) and the tool
// name when trigger=tool_continuation; parent_tool_use_id identifies the agent
// invocation this call belongs to, which is the parent/child edge of the agent
// tree. All default to "" when the proxy could not resolve them — an empty value
// means unknown and must not be substituted for a guessed agent name.
.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(""))
Comment on lines +98 to +101

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.

.build();
}
}
Expand All @@ -94,7 +109,8 @@ public static SpanRow from(UUID spanId, UUID traceId, UUID projectId, JsonNode m
INSERT INTO cipx_spends
(workspace_id, project_id, trace_id, span_id, start_time, model,
u_input, u_cache_read, u_cache_creation, u_cache_creation_5m, u_cache_creation_1h, u_output,
effort, thinking_type, max_tokens, context_management, speed)
effort, thinking_type, max_tokens, context_management, speed,
`trigger`, trigger_detail, turn_key, parent_tool_use_id)
SETTINGS log_comment = '<log_comment>'
FORMAT Values
<items:{item |
Expand All @@ -115,7 +131,11 @@ public static SpanRow from(UUID spanId, UUID traceId, UUID projectId, JsonNode m
:thinking_type<item.index>,
:max_tokens<item.index>,
:context_management<item.index>,
:speed<item.index>
:speed<item.index>,
:trigger<item.index>,
:trigger_detail<item.index>,
:turn_key<item.index>,
:parent_tool_use_id<item.index>
)
<if(item.hasNext)>,<endif>
}>
Expand Down Expand Up @@ -144,7 +164,9 @@ private Publisher<? extends Result> insert(List<SpanRow> rows, String workspaceI
// Positional binds: the driver resolves named binds with a linear indexOf over the statement's
// parameter list (quadratic per statement), while bind(int) is a direct array write. Indices
// follow the placeholders' first-appearance order in the rendered SQL: workspace_id once at 0
// (repeats dedup), then 16 parameters per row tuple in template order.
// (repeats dedup), then 20 parameters per row tuple in template order. The bind order below
// must stay in lockstep with the INSERT tuple above — nothing checks it at compile time, and a
// mismatch silently writes each value into the neighbouring column.
statement.bind(0, workspaceId);
int index = 1;
for (SpanRow row : rows) {
Expand All @@ -163,7 +185,11 @@ private Publisher<? extends Result> insert(List<SpanRow> rows, String workspaceI
.bind(index++, row.thinkingType())
.bind(index++, row.maxTokens())
.bind(index++, row.contextManagement())
.bind(index++, row.speed());
.bind(index++, row.speed())
.bind(index++, row.trigger())
.bind(index++, row.triggerDetail())
.bind(index++, row.turnKey())
.bind(index++, row.parentToolUseId());
}

return statement.execute();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
--liquibase formatted sql
--changeset aadereiko:000118_add_trigger_to_cipx_spends
--comment: Persist the per-call trigger, its detail (subagent NAME), the turn key and the parent tool_use id on cipx_spends
--
-- cipx already ships all four on metadata.cipx.call; none of them reached a typed column, so the
-- spend tables cannot tell a subagent's spend from the main agent's, let alone name the agent or
-- reconstruct the parent/child tree the AI Spend UI wants.
--
-- trigger what caused this call: user_turn | tool_continuation | subagent | automated
-- | unknown. Closed enum -> LowCardinality.
-- trigger_detail trigger-dependent qualifier. For trigger='subagent' this is the agent NAME
-- (the parent's `Agent` tool_use input.subagent_type: "code-reviewer",
-- "Explore", ...); for trigger='tool_continuation' it is the tool name. Empty
-- when the proxy could not resolve it -- deliberately NOT defaulted, so an
-- empty string means "unknown", never "general-purpose".
-- turn_key groups a user prompt's root call with every continuation that followed it
-- (SHA256 hex of the prompt text). The grain a per-turn read aggregates on.
-- parent_tool_use_id the `Agent` tool_use that spawned this call -- the identity of one agent
-- INVOCATION, stable across every call that agent makes. This is the edge of
-- the agent tree: child rows point at the parent's tool_use id.
--
-- Additive columns with defaults; every pre-existing row reads '' (unknown), which is honest --
-- those rows were written before the proxy carried the fields.
ALTER TABLE ${ANALYTICS_DB_DATABASE_NAME}.cipx_spends ON CLUSTER '{cluster}'
ADD COLUMN IF NOT EXISTS `trigger` LowCardinality(String) DEFAULT '',
ADD COLUMN IF NOT EXISTS trigger_detail LowCardinality(String) DEFAULT '',
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.


Loading
Loading