feat(analytics): count evaluation calls in the Decision Audit, not per-entry events - #387
Merged
Conversation
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
force-pushed
the
feat/audit-call-counts
branch
from
August 29, 2026 06:06
256dba5 to
0cd172a
Compare
… 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>
prajjwalkumar17
force-pushed
the
feat/audit-call-counts
branch
from
August 29, 2026 06:08
0cd172a to
22f8f12
Compare
…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
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>
This was referenced Aug 31, 2026
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.
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(profilepro_0RzdnTKZkDHmihjtOoWu) — three calls inside 31 ms, batching 3, 7 and 7 entries: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)besideevent_count, in all three paths that produce a summary row: the materialized state, the raw fragment, and the exact lookup. The Matches row reads3 calls · 17 entries, and the trace groups under collapsible per-call headers keyed onrequest_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: []returnedTE_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 answers200 {"results": []}. The positional contract holds trivially and the caller's length check passes0 == 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/batchhad noclassify_requestarm, 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 TABLEon 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 doesALTER ... 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 beALTERed. 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.shheal 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.columnsand omits the column when absent, substituting a0the 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.N calls · M entriesfor payments seen sinceHow did you test it
End to end against sandbox (
merchant_1703154093), plus unit tests.cargo test --libcargo clippy --libtsc --noEmit+vite buildsh -non 039 and oneclick.shrouting_source=decision_engine, routed per the DE ruleNot 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_countcounts (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