Skip to content

feat(analytics): count evaluation calls in the Decision Audit, not per-entry events - #387

Merged
prajjwalkumar17 merged 4 commits into
mainfrom
feat/audit-call-counts
Aug 29, 2026
Merged

feat(analytics): count evaluation calls in the Decision Audit, not per-entry events#387
prajjwalkumar17 merged 4 commits into
mainfrom
feat/audit-call-counts

Conversation

@prajjwalkumar17

@prajjwalkumar17 prajjwalkumar17 commented Aug 28, 2026

Copy link
Copy Markdown
Member

Description

One evaluation call records one analytics event per batch entry, so the Decision Audit listed 30–52 "events" for a payment the router had called three times. Healthy batching read as a call storm, and the noise buried the routing mismatches people open that screen for.

Traced on sandbox for pay_OR9TzfVd6AuKV8y7I9A8 (profile pro_0RzdnTKZkDHmihjtOoWu) — three calls inside 31 ms, batching 3, 7 and 7 entries:

12:49:25.219  Received batch routing evaluation request, entry_count: 3
12:49:25.235  Received batch routing evaluation request, entry_count: 7
12:49:25.250  Received batch routing evaluation request, entry_count: 7

That is 3 request-hits + 17 per-entry events, before confirm or a checkout refresh add theirs.

Count calls, not entries

Summaries now carry call_count = uniq(request_id) beside event_count, in all three paths that produce a summary row: the materialized state, the raw fragment, and the exact lookup. The Matches row reads 3 calls · 17 entries, and the trace groups under collapsible per-call headers keyed on request_id — the same key the server counts with, so the list and the trace always agree.

Per-entry events are kept, not collapsed. They carry the per-payment-method decisions the list's own filters (has(gateways, ?), has(statuses, ?)) and the inspector are built on, and three other screens read the same response shape. What changed is what gets counted and headlined.

An empty batch is a vacuous success

requests: [] returned TE_04 400, which made the router record a failed routing event and fall through to its single-evaluation path on every session/PML call from a card-only cut-over profile. It now answers 200 {"results": []}. The positional contract holds trivially and the caller's length check passes 0 == 0, so the fallback stops firing without waiting on a router release.

The router should still skip the call — a one-line guard in decision_engine_routing_batch_with_fallback, mirroring the one its shadow path already has. This just stops the engine being noisy about it meanwhile.

Batch API events

POST /routing/evaluate/batch had no classify_request arm, so batch calls were invisible to the request-level audit the single endpoint has always fed.

Migration

clickhouse/scripts/039_audit_call_counts.sh — additive and idempotent.

Do not re-run 025 for this. It opens with DROP TABLE on the audit summary tables; against a live database that discards every accumulated summary and the Matches list empties out until traffic refills it. 039 instead does ALTER ... ADD COLUMN IF NOT EXISTS (not in the sorting key, so metadata-only, no re-sort) and rebuilds only the materialized view, whose SELECT cannot be ALTERed. Dropping a MV leaves its target table's rows untouched, so all history is preserved. It asserts the view is back afterwards, so a half-applied migration fails the deploy instead of silently stopping the summaries pipeline.

Nothing needs running by hand. 039 joins the oneclick.sh heal loop beside 038 (initdb scripts only run on a fresh volume, so the column would otherwise never reach an existing deployment), with the matching column-drift probe.

No deploy ordering to get right. The reader probes system.columns and omits the column when absent, substituting a 0 the caller reports as the event count. Only the positive answer is latched — a negative is re-probed every five minutes, so the page starts counting calls on its own once 039 lands, with no pod restart.

State Behaviour
Deployed, 039 not yet run Page works; Matches shows event counts; trace grouping already works (grouped client-side)
After 039 Matches shows N calls · M entries for payments seen since
Rows predating 039 Empty state finalizes to 0 → reported as the event count
Fresh volume Nothing to do; 025 creates the column and 039 exits

How did you test it

End to end against sandbox (merchant_1703154093), plus unit tests.

Check Result
cargo test --lib 343/343
cargo clippy --lib clean
tsc --noEmit + vite build clean
sh -n on 039 and oneclick.sh clean
Volume split across two same-connector MCAs, 6 payments 4/2 across both MCAs
Cut-over profile decide path routing_source=decision_engine, routed per the DE rule
Empty-batch path (card-only cut-over profile) reproduced the 400 + fallback warn this removes

Not verified: the grouped-timeline rendering is covered by type-check and production build, not by a run against live data — the audit page needs a backend with traffic. I'd like to drive it on sandbox once this deploys. The empty-batch handler has no handler-level test; the repo has no HTTP-handler harness and neighbouring endpoints are in the same position. The logic that can be tested without one — fragment SQL, the pre-migration fallback, the classify arm — is.

Split out of this PR

A per-payment routing decision cache was developed on this branch and removed before review. It puts 2N sequential awaited Redis round-trips on the payment path with no kill switch, and it suppresses the very events call_count counts (a fully-cached call would record nothing, so "3 calls" would display as "2"). It will come back separately, behind a config flag and with the round-trips batched. Nothing here depends on it.

🤖 Generated with Claude Code

Closes #402

Copilot AI lite review requested due to automatic review settings August 28, 2026 13:44

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

prajjwalkumar17 and others added 2 commits August 29, 2026 11:35
Hyperswitch's session and payment-method-list pre-routing build one evaluation
per payment method and dispatch unconditionally, so a card-only profile sends a
zero-entry batch. Answering that 400 made every such call record a failed routing
event and fall through to the single-evaluation path — noise on both sides for a
request that simply had nothing to evaluate.

An empty batch is now a vacuous success: 200 with an empty result set. The
positional contract holds trivially (`results[i]` answers `requests[i]`), and the
caller's own length check passes, so the fallback stops firing without waiting on
a router release. The router should still skip the call; this only stops the
engine being noisy about it in the meantime.

Also classifies POST /routing/evaluate/batch for request-level API events, which
the single endpoint has always had and the batch one silently lacked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One evaluation call records one analytics event per batch entry, so the Decision
Audit listed 30-52 "events" for a payment the router had called three times —
healthy batching reading as a call storm, and burying the routing mismatches
people open that screen for. Traced on sandbox: three calls inside 31ms carrying
3, 7 and 7 entries.

Summaries now carry `call_count = uniq(request_id)` beside `event_count`, in all
three paths that produce a summary row (the materialized state, the raw fragment,
and the exact lookup). Per-entry events are kept, not collapsed: they hold the
per-payment-method decisions the list's filters and the inspector are built on.
What changed is what gets counted.

The column arrives via 039, an additive migration — ALTER ADD COLUMN (not in the
sorting key, so metadata-only) plus a materialized-view rebuild, which leaves the
summary rows in place. 025 keeps the full schema for fresh volumes. Re-running 025
was NOT an option: it opens with DROP TABLE and would discard every accumulated
summary.

Neither is the migration a deploy-ordering hazard. The reader probes for the
column and omits it when absent, substituting a 0 the caller reports as the event
count, so the page works before, during and after. Only the positive answer is
latched — a negative is re-probed every five minutes, so the page starts counting
calls on its own once 039 lands, without a pod restart. 039 also joins the
oneclick heal loop beside 038, since initdb scripts only run on a fresh volume and
the column would otherwise never reach an existing deployment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@prajjwalkumar17 prajjwalkumar17 changed the title feat(routing): count evaluation calls in the audit, dedupe init-burst evaluations feat(analytics): count evaluation calls in the Decision Audit, not per-entry events Aug 29, 2026
… call

The Matches row reads "3 calls · 17 entries" instead of "17 events", and the
trace groups under collapsible per-call headers keyed on request_id — the same
key the server counts with, so the two panels always agree. A trace whose calls
each carry one entry stays flat, as before.

A server without the call_count migration sends 0, and the row then keeps the
old "N events" wording. It deliberately does not relabel an event total as a
call count: that would overstate the calls made, which is worse than the number
it replaced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…he query

`uniqExact(request_id)` sat in the same SELECT as `argMax(request_id,
created_at_ms) AS request_id`, so ClickHouse bound the bare name to that output
alias and rejected the resulting nested aggregate:

    Code: 184. Aggregate function argMax(request_id, created_at_ms) AS
    request_id is found inside another aggregate function in query.
    (ILLEGAL_AGGREGATION)

The Decision Audit's exact-lookup path failed outright, which the E2E suite
caught and the unit tests did not: they assert the generated SQL as text, so a
query that is well-formed but semantically invalid passes them. Both aggregating
fragments now alias their source subquery and reference `src.request_id`, and the
tests assert the qualified form so the shape cannot regress silently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@prajjwalkumar17 prajjwalkumar17 self-assigned this Aug 29, 2026
@prajjwalkumar17
prajjwalkumar17 merged commit 4d366f5 into main Aug 29, 2026
18 checks passed
@prajjwalkumar17
prajjwalkumar17 deleted the feat/audit-call-counts branch August 29, 2026 07:10
prajjwalkumar17 added a commit to juspay/hyperswitch that referenced this pull request Aug 29, 2026
…ng to evaluate

Session and payment-method-list pre-routing build one entry per payment method
type and dispatch unconditionally, so a profile with no eligible types — a
card-only profile on the session flow — reaches the batch dispatcher with an
empty input list. The engine answered that with a 400, which surfaced as a
failed routing event plus a fallback round of single evaluations on every such
call, for a request that had nothing to ask.

Guard the dispatcher instead: an empty input satisfies the positional contract
trivially (`results.len() == backend_inputs.len() == 0`), so return early. Both
callers funnel through `decision_engine_routing_batch`, so the load-bearing path
and the shadow path are covered from one place, and the fallback wrapper
inherits it — an `Ok(empty)` means no single-evaluation round fires.

The engine side is being fixed in parallel (juspay/decision-engine#387 answers an
empty batch with an empty result set rather than a 400), so the noise is gone
whichever side is deployed first; this stops the round trip happening at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
prajjwalkumar17 added a commit to juspay/hyperswitch that referenced this pull request Aug 29, 2026
…ng to evaluate

Session and payment-method-list pre-routing build one entry per payment method
type and dispatch unconditionally, so a profile with no eligible types — a
card-only profile on the session flow — reaches the batch dispatcher with an
empty input list. The engine answered that with a 400, which surfaced as a
failed routing event plus a fallback round of single evaluations on every such
call, for a request that had nothing to ask.

Guard the dispatcher instead: an empty input satisfies the positional contract
trivially (`results.len() == backend_inputs.len() == 0`), so return early. Both
callers funnel through `decision_engine_routing_batch`, so the load-bearing path
and the shadow path are covered from one place, and the fallback wrapper
inherits it — an `Ok(empty)` means no single-evaluation round fires.

The engine side is being fixed in parallel (juspay/decision-engine#387 answers an
empty batch with an empty result set rather than a 400), so the noise is gone
whichever side is deployed first; this stops the round trip happening at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

[BUG] One batch evaluate call floods the Decision Audit with per-entry events

2 participants