Skip to content

fix(metrics): bucket streaming request counts - #1059

Open
lloydmak99 wants to merge 1 commit into
mainfrom
fix/context-bucket-request-metrics
Open

lloydmak99 wants to merge 1 commit into
mainfrom
fix/context-bucket-request-metrics

Conversation

@lloydmak99

Copy link
Copy Markdown
Contributor

Why

Grafana renders Cloud API request-count series without an input_bucket label as N/A. Streaming requests emitted cloud_api.request.count at admission with only model and environment tags, before provider usage existed; interrupted streams could never be backfilled.

What

Reuse the existing request-side routing estimate to attach a bounded context bucket to streaming admission metrics. When provider-reported usage becomes available, replace the estimate for completion-time metrics so each tag set contains exactly one input_bucket. Non-streaming request counts continue to use actual provider prompt tokens.

This does not change which requests the historical counter includes. Requests rejected before its existing writers remain outside the metric.

How to test

cargo fmt --all -- --check
cargo build -p services --no-default-features
cargo test -p services --lib --no-default-features
cargo clippy -p services --lib --tests --no-default-features -- -D warnings
cargo test -p services input_bucket --no-default-features -- --nocapture
cargo test -p services test_intercept_stream_metrics --no-default-features -- --nocapture

Expected: formatting, build, and Clippy exit 0; service tests report 660 passed and 1 ignored; focused bucket and stream-replacement tests pass.

Tier

  • T0 — Routine
  • T1 — Non-prod-blast
  • T2 — Prod-affecting
  • T3 — Boundary / irreversible

Checklist

  • Tests added/updated
  • Inline metric semantics updated
  • No lockfile changes
  • No secrets or local absolute paths in the diff
  • Tier declared above
  • No linked issue

Risks / rollback

Streaming request-count buckets are request-side estimates because the counter must be recorded before stream completion; completed-stream metrics still use provider-reported prompt tokens. This may shift existing context-distribution dashboards and should be verified after rollout by checking that new input_bucket="" samples stop across both production producers.

Rollback by reverting commit eecf15ab4b8eee5b34e9c7383ce55ae32f084832. Existing unlabeled historical series remain queryable until they age out of dashboard windows.

Streaming counters were emitted before provider usage and therefore carried only model and environment tags, which surfaced as N/A in Grafana. Interrupted streams could never be backfilled.

Reuse the existing routing token estimate for admission metrics, then replace that estimate with provider-reported usage for completion-time metrics so every tag set contains exactly one input bucket.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
@lloydmak99
lloydmak99 deployed to Cloud API test env September 12, 2026 17:43 — with GitHub Actions Active
@ironloopai

ironloopai Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review · Status

🟩 Completed

IronLoop completed the review and posted it to GitHub.

Result

Open submitted review →

Run details
  • Run: a331434e-82cb-4b48-958b-47504a49023d
  • Base: main at 96ae228
  • Head: fix/context-bucket-request-metrics at eecf15a
  • Created: 2026-09-12 17:43 UTC
  • Updated: 2026-09-12 18:07 UTC

Automatic trigger · attempt 1 of 3 · completed in 23m 50s

@lloydmak99
lloydmak99 marked this pull request as ready for review September 12, 2026 17:50
@claude

claude Bot commented Sep 12, 2026

Copy link
Copy Markdown

Review — fix(metrics): bucket streaming request counts

No prior human review threads on this PR (only the IronLoop status comment), so nothing to build on. Mechanically the change is sound: set_input_bucket_tag correctly guarantees exactly one input_bucket tag, the estimate→actual replacement in Drop is right, and the i32::try_from(...).map_or("128k+", ...) fallback degrades instead of panicking. Test coverage for the replacement invariant is good.

Three issues worth addressing before merge.


⚠️ 1. The estimator ignores tools, tool-call args, and media — the bucket will be systematically wrong on exactly the traffic this dashboard cares about

estimate_input_tokens (crates/services/src/completions/mod.rs:816) counts only content text parts + reasoning_content. It ignores:

  • ChatMessage::tool_calls (function-call arguments JSON)
  • chat_params.tools — the tool schemas, which are not in messages at all
  • non-text content parts (image_url, audio, data URIs)
  • per-message chat-template overhead

That was fine as a routing hint. As a metric label it is a structural undercount, not within-bucket noise:

  • 15 tool definitions (~3k tokens) + a 300-char user message → estimate ~75 → 0-1k; actual ~3.5k → 1-4k. One bucket off.
  • 4 images + a short caption → estimate 0-1k; actual >=4k → 4-16k. Two buckets off.

This is not an edge case, because /v1/responses always routes through this path: run_agent_loop (crates/services/src/responses/service.rs:1399) hardcodes stream: Some(true) at line 1468 and calls create_chat_completion_stream at line 1488 for every agent-loop turn, streaming or not. Responses traffic is tool-heavy by definition (function calls, web_search, file_search, MCP), and later turns carry accumulated tool_calls + tool outputs. The context-distribution dashboard would be biased low precisely where long context matters.

The repo already has the correct estimator — context_routing::estimate_input() (crates/services/src/inference_provider_pool/context_routing.rs:112) — which accounts for tool-call args, tool definitions, media (media_part_tokens()), and template overhead, and splits countable_tokens / uncounted_tokens so you can exclude the max_tokens output reserve that refine_context_requirement adds.

Suggested: use it for the metric bucket and leave estimate_input_tokens alone so routing behaviour is untouched.

// inference_provider_pool/mod.rs:25 — needs `pub(crate) mod context_routing;`
let est = context_routing::estimate_input(&chat_params);
let estimated_input_tokens = (est.countable_tokens + est.uncounted_tokens).min(u32::MAX as u64) as u32;

If you would rather not widen that module visibility this round, at minimum add the tool_calls and tools byte counts — those two account for most of the error.

⚠️ 2. input_bucket now means two different things on the same metric, with no way to tell them apart

After this change cloud_api.request.count and cloud_api.latency.queue_time carry input_bucket derived from a client-side text heuristic on the streaming path (mod.rs:1172) and from provider-reported prompt_tokens on the non-streaming path (mod.rs:2072). Same metric, same tag key, two incompatible definitions — and create_metric_tags emits only model + environment, so a Grafana query cannot separate or even detect the mix.

It also breaks within-request consistency: queue_time lands in the estimated bucket while cloud_api.latency.total for that same request lands in the actual bucket (mod.rs:396-398). Any panel putting queue time and total latency side by side, broken down by input_bucket, is comparing different populations.

Consider a distinguishing tag, e.g. bucket_source:estimated|actual (2 values, bounded cardinality), so dashboards can filter or at least know what they are looking at.

⚠️ 3. cloud_api.latency.time_to_first_token{,_total} silently gain the tag — not mentioned in the PR description

handle_stream_with_context now assigns the bucketed tags to InterceptStream::metric_tags, which poll_next reuses for TTFT at first token (mod.rs:575-586). Those two histograms previously had no input_bucket; they now split 7 ways per model. That is a prod-affecting change to existing percentile panels and monitors, and it is in neither the "What" nor the "Risks / rollback" section — only the inline comment at line 409 hints at it. Please call it out explicitly for a T2 change.

Related: this makes METRIC_LATENCY_STREAMING_TTFT_BY_INPUT largely redundant — both are now streaming-only and bucketed, differing only in bucket source and population. Worth a note on whether the _by_input pair still earns its keep.

Also, the checklist claims "Inline metric semantics updated" but crates/services/src/metrics/consts.rs is untouched (this PR changes 1 file). Two comments there are now stale or incomplete:

  • TAG_INPUT_BUCKET (line 93) has no doc noting it is provider-actual on some series and estimated on others.
  • Lines 4-5 justify the _BY_INPUT metrics as carrying "the actual prompt-token bucket" — worth clarifying now that time_to_first_token carries an estimated one.

Nit

record_stream_admission_metrics takes metrics_service first and also returns the tags it built — an unusual shape for a "record" function. Splitting tag construction from emission at the call site would read more clearly.

Not an issue

  • set_input_bucket_tag in the non-streaming path (mod.rs:2073) is a no-op retain since create_metric_tags never contains the tag — harmless, and it keeps the invariant local.
  • The u32 → i32 overflow fallback is unreachable in practice (would need ~8 TB of prompt text) and fails safe.
  • PR description says "Non-streaming request counts continue to use actual provider prompt tokens" — accurate for /v1/chat/completions with stream: false, but not for /v1/responses, per issue 1.

I could not execute cargo test / cargo clippy in this environment, so the build and test results in the PR description are unverified here.

⚠️ Issues found

🤖 Generated with Claude Code

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 OpenCodeReview found 3 issue(s) in this PR.

  • ✅ 3 posted as inline comment(s)
  • 📝 0 posted as summary

Comment on lines +1151 to +1153
let prefix = format!("{TAG_INPUT_BUCKET}:");
metric_tags.retain(|tag| !tag.starts_with(&prefix));
metric_tags.push(format!("{prefix}{input_bucket}"));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This formats a fresh prefix String on every call, and the helper now runs twice per streaming request (admission via record_stream_admission_metrics, then again at usage time). The retain pass can match the tag prefix without allocating by using strip_prefix with a separator check; the only unavoidable allocation left is the final push. Alternatively, hoist the prefix into a const (e.g. TAG_INPUT_BUCKET_PREFIX in metrics/consts.rs) so it is built once.

Suggestion:

Suggested change
let prefix = format!("{TAG_INPUT_BUCKET}:");
metric_tags.retain(|tag| !tag.starts_with(&prefix));
metric_tags.push(format!("{prefix}{input_bucket}"));
metric_tags.retain(|tag| {
!tag.strip_prefix(TAG_INPUT_BUCKET)
.is_some_and(|rest| rest.starts_with(':'))
});
metric_tags.push(format!("{TAG_INPUT_BUCKET}:{input_bucket}"));

model_name: &str,
estimated_input_tokens: u32,
) -> Vec<String> {
let input_bucket = i32::try_from(estimated_input_tokens).map_or("128k+", get_input_bucket);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fallback "128k+" duplicates the top-bucket label that already lives in get_input_bucket's catch-all arm. If the bucket boundaries/labels ever change (e.g. a "1m+" bucket is added), this hardcoded string silently drifts out of sync. Since any value that fails i32::try_from would map to the top bucket anyway, clamping and letting get_input_bucket produce the label keeps a single source of truth for bucket names.

Suggestion:

Suggested change
let input_bucket = i32::try_from(estimated_input_tokens).map_or("128k+", get_input_bucket);
let input_bucket =
get_input_bucket(i32::try_from(estimated_input_tokens).unwrap_or(i32::MAX));

Comment on lines +1175 to +1176
metrics_service.record_count(METRIC_REQUEST_COUNT, 1, &tags);
metrics_service.record_latency(METRIC_LATENCY_QUEUE_TIME, queue_time, &tags);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With this change, METRIC_REQUEST_COUNT and METRIC_LATENCY_QUEUE_TIME now carry an input_bucket whose meaning differs by code path: streaming buckets by the request-estimated token count at admission (here), while the non-streaming path buckets by actual prompt_tokens at completion (see the tokio::spawn in create_chat_completion). Any dashboard grouping these metric names by input_bucket will silently mix the two populations. The codebase is careful about this distinction elsewhere (dedicated ..._BY_INPUT TTFT series plus an explanatory comment), so a brief note here — or a distinct tag key for the estimated series — would prevent future misreads of the telemetry.

@ironloopai ironloopai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review · Summary

🟢 No actionable findings

No actionable issues found.

Validation
  • Focused services metric tests — Admission-bucket and final-usage replacement tests passed.
  • Formatting — Rust formatting check passed.
Review details
  • Run: a331434e-82cb-4b48-958b-47504a49023d
  • Attempts: 1

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