Skip to content

feat(observability): serve per-model runtime health on the status listener#778

Merged
moonming merged 3 commits into
mainfrom
feat/status-models-endpoint
Jul 16, 2026
Merged

feat(observability): serve per-model runtime health on the status listener#778
moonming merged 3 commits into
mainfrom
feat/status-models-endpoint

Conversation

@moonming

@moonming moonming commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

What

GET /status/models on the dedicated metrics/status listener: the per-model runtime health view (cooldown / background-check state) as an operational read, next to /status/config and /status/ready. The response is exactly what GET /admin/v1/models/status serves; the admin endpoint is unchanged.

Why

Operational reads now have a home on the operational listener. The runtime health view is proxy-runtime state — which targets are cooling down and why — the kind of state probes, dashboards, and scrape-side tooling poll on day 2. Serving it only on the admin listener couples those reads to the management surface and its admin-key auth (and managed mode does not bind an admin listener at all). The metrics/status listener is already the unauthenticated, network-restricted operational surface (#774), identical in every mode, which mirrors the ecosystem-wide split between a management API and a dedicated status/health port.

Sensitivity class matches the listener: the view exposes the model catalog (ids and display names) and health states — the same class as /status/config's resource_counts and rejected[] resource ids already served there. No auth, like the scrape — restrict at the network layer.

Design: one render, one source

  • render_models_status(...) is extracted in models_status_handler.rs and is the single render behind both endpoints, so the two responses cannot drift while both exist.
  • The status listener reads through the same Arc<dyn ConfigStore> handle the admin surface uses (etcd store in etcd mode, snapshot read-view in file mode) plus the same shared ModelRuntimeStatusTracker — same data, same ordering, same serialization. A unit test pins the two bodies byte-for-byte.
  • Managed mode (no admin store) serves the applied snapshot through the same read-only store view file mode uses, keeping the metrics/status listener surface identical across modes.
  • Store failures answer a fixed {"error_msg": "failed to list models"} 500 on this unauthenticated surface — backend detail (etcd endpoints, connection errors) stays in the server log, while the admin-key-gated endpoint keeps its detailed envelope. Success bodies stay byte-identical.
  • The store read is bounded by a 5-second timeout that degrades into the same fixed 500: a hung store (blackholed etcd) cannot park anonymous pollers or hold the shared store client while they pile up.
  • No OpenAPI change: the generated Admin API spec documents the admin listener only; status-listener endpoints follow the /status/config precedent. Docs-site follow-up will list /status/models alongside the other status endpoints.

e2e harness repoint

AdminClient.listModelStatuses() now reads GET /status/models on the metrics/status listener (new optional metricsBaseUrl constructor argument; method signature and return shape unchanged). The existing runtime-status consumers — background-health, cooldown-contract, retry-on-429-vs-background-ignore, runtime-mixed-filtering, runtime-status — exercise the new endpoint end-to-end with their assertions untouched.

Tests

  • Unit: status_models_serves_the_admin_view_byte_for_byte (same store + tracker handles → byte-identical bodies, covering a cooldown row and a virtual not_applicable row); status_models_store_failure_never_leaks_backend_detail (failing store → fixed 500 body, no backend detail); status_models_answers_the_fixed_500_when_the_store_hangs (paused-clock test with a never-resolving store → the timeout arm answers the same fixed body); admin_router_does_not_serve_status_models; the existing metrics-listener tests updated for the new router argument.
  • e2e (status-models-e2e.test.ts, new):
    • etcd mode: one model tripped into cooldown by a real 401 upstream, then GET /status/models vs GET /admin/v1/models/status asserted deep-equal and raw-text-equal (same order, same bytes), plus the expected cooldown contract (status=cooldown, status_reason=upstream_auth_failure, cooldown_until set).
    • Auth split: the status listener serves without a key while the admin route still requires one.
    • File mode: the view serves on the status listener and matches the admin endpoint.
  • Managed mode is wired but not e2e-covered (the e2e harness has no control plane); its serving path — the read-only snapshot store view feeding the shared render on the status listener — is the same one the file-mode e2e cases exercise, just with a different snapshot feeder.
  • cargo fmt --check, cargo clippy --workspace --all-targets -- -D warnings, cargo test --workspace green; full e2e suite green twice locally.

Summary by CodeRabbit

  • New Features

    • Added an unauthenticated GET /status/models endpoint on the metrics/status listener.
    • Exposes per-model runtime health and status information, matching the admin model-status view.
    • Supports model status reporting in both managed and standalone deployments.
  • Bug Fixes

    • Model listing failures and timeouts now return a consistent, non-sensitive error response.
    • Improved status-client handling for metrics endpoint requests.

…tener

GET /status/models on the dedicated metrics/status listener serves the
per-model runtime health view (cooldown / background-check state) as an
operational read next to /status/config and /status/ready, so day-2
reads no longer depend on the admin listener (which managed mode does
not bind at all).

The response is exactly the admin listener's GET /admin/v1/models/status
body: one shared render (render_models_status) behind both endpoints,
reading through the same ConfigStore handle and the same shared runtime
status tracker, so the two responses cannot drift while both exist. The
admin endpoint is unchanged. Unauthenticated like the rest of the
listener (model names + health states, the same sensitivity class as
/status/config's resource_counts and rejected[] ids); restrict at the
network layer.

Modes: etcd and file standalone share the admin store handle; managed
mode serves the applied snapshot through the same read-only view file
mode uses, keeping the metrics/status listener identical across modes.

e2e: AdminClient.listModelStatuses() now reads GET /status/models on the
metrics listener (method signature/return unchanged), so the existing
runtime-status consumers exercise the new endpoint end-to-end; a new
status-models-e2e case pins deep-equal AND raw-byte equality against the
admin endpoint with a model in real 401-tripped cooldown, the auth
split, and file-mode serving. A unit test pins the two bodies
byte-for-byte over the same store.
@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The dedicated metrics/status listener now exposes GET /status/models, sharing model runtime rendering with the admin endpoint. Server startup supplies model storage and runtime tracking state, while the E2E harness and tests validate parity, access control, cooldown reporting, and standalone operation.

Changes

Model Status Listener

Layer / File(s) Summary
Shared model status rendering
crates/aisix-admin/src/lib.rs, crates/aisix-admin/src/models_status_handler.rs
Adds ModelsStatusState and centralizes model runtime status view construction in render_models_status.
Metrics listener status route
crates/aisix-admin/src/lib.rs, crates/aisix-admin/Cargo.toml
Accepts model status state, mounts GET /status/models, bounds store reads, returns fixed errors, and tests parity, timeout, and route isolation.
Server listener wiring
crates/aisix-server/src/main.rs
Builds model status state from the configured store and runtime tracker, then passes it to metrics_router.
Status endpoint integration coverage
tests/e2e/src/harness/admin.ts, tests/e2e/src/cases/*status*, tests/e2e/src/cases/*health*, tests/e2e/src/cases/cooldown-contract-e2e.test.ts, tests/e2e/src/cases/retry-on-429-vs-background-ignore-e2e.test.ts, tests/e2e/src/cases/runtime-mixed-filtering-e2e.test.ts
Updates AdminClient to use the metrics URL and validates model status parity, authentication behavior, cooldown state, and standalone responses.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant MetricsListener
  participant ConfigStore
  participant RuntimeTracker
  participant ModelStatusRenderer
  Client->>MetricsListener: GET /status/models
  MetricsListener->>ConfigStore: List configured models
  ConfigStore-->>MetricsListener: Model entries
  MetricsListener->>RuntimeTracker: Read optional runtime state
  RuntimeTracker-->>MetricsListener: Runtime snapshots
  MetricsListener->>ModelStatusRenderer: Render model status views
  ModelStatusRenderer-->>MetricsListener: JSON model status list
  MetricsListener-->>Client: Status response
Loading

Suggested reviewers: jarvis9443

🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning render_models_status preserves store iteration order, but DashMap/snapshot entries() are unordered, so byte-for-byte parity can flake across separate reads. Sort all_models by a stable key (e.g. id) before rendering, and add a test with opposite input orders to prove admin/status responses stay identical.
✅ Passed checks (5 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Security Check ✅ Passed No security issues found: /status/models exposes only model ids/display names and runtime state, fixed-500 hides store errors, and auth/write flows are unchanged.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: adding per-model runtime health to the status listener.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/status-models-endpoint

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…d status listener

GET /status/models is served without auth, so a store backend failure
(e.g. an etcd outage) must not echo client error detail — endpoints,
connection errors — to anonymous callers the way the admin-key-gated
endpoint may. Answer a fixed {"error_msg": "failed to list models"}
500 and keep the detail in the server log; success bodies remain
byte-identical to the admin endpoint. A unit test pins the redaction
with a failing store.
@moonming

Copy link
Copy Markdown
Collaborator Author

Review triage

  • Store-error detail on the unauthenticated listener (CodeRabbit security check; also raised by internal review) — addressed in the follow-up commit: GET /status/models now answers a fixed {"error_msg": "failed to list models"} 500 on store failure and keeps the backend detail (etcd endpoints, connection errors) in the server log; the admin-key-gated endpoint keeps its detailed envelope, and success bodies remain byte-identical. Pinned by the new status_models_store_failure_never_leaks_backend_detail unit test (failing store → fixed body, no backend detail in the response).
  • Transient non-200 handling inside the new e2e polls — kept as-is deliberately: it matches the suite's existing convention (the previous admin-listener helper threw on non-200 inside polls too), and a non-200 here only occurs during a store outage, which the test should surface loudly rather than absorb.
  • prometheus.path colliding with a /status/* route — pre-existing class (/status/config and /status/ready have the same property): the router panics loudly at boot on an overlapping route, which is the preferable failure mode for an operator misconfiguration; silently rewriting the operator's configured scrape path would hide the mistake. Can revisit separately as a config-load validation if wanted.
  • Managed-mode e2e coverage — now stated in the PR body: the e2e harness has no control plane, and managed mode's serving path (read-only snapshot view feeding the shared render) is the same one the file-mode e2e cases exercise.

@moonming

Copy link
Copy Markdown
Collaborator Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@moonming

Copy link
Copy Markdown
Collaborator Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.


Your plan includes PR reviews subject to rate limits. More reviews will be available in 29 minutes.

@moonming

Copy link
Copy Markdown
Collaborator Author

@coderabbitai summary

@coderabbitai

coderabbitai Bot commented Jul 16, 2026

Copy link
Copy Markdown
✅ Action performed

Summary regeneration triggered.

A stalled configuration store (blackholed etcd) must not park anonymous
status pollers indefinitely, or hold the shared store client while they
pile up: the unauthenticated endpoint is built for probes and dashboards
to poll, and its contract is that store failures answer a fixed 500.
Wrap the store read in a 5s timeout that degrades into that same fixed
answer, keeping backend detail in the server log.

Pinned by a paused-clock unit test with a never-resolving store.
@moonming

Copy link
Copy Markdown
Collaborator Author

Independent cold audit (second pass, at 4ac09c0)

  • MEDIUM — unbounded store read on the unauthenticated endpoint: a hung configuration store (blackholed etcd, as opposed to a fast-failing one) would park anonymous /status/models pollers indefinitely while holding the shared store client, contradicting the "store failures answer a fixed 500" contract. Fixed in 44fff83: the store read is bounded by a 5s timeout that degrades into the same fixed 500; pinned by a paused-clock unit test with a never-resolving store (status_models_answers_the_fixed_500_when_the_store_hangs).
  • LOW — exposure wording: "model names and health states" understated the surface slightly; doc comment and PR body now say "the model catalog (ids and display names) and health states". No new fields exposed — the audit verified no provider name, api_base, or upstream model name reaches the body, and every status_reason is a fixed static string.
  • LOW — prometheus.path: /status/models collision and LOW — e2e polls abort on transient non-200: both verified as the pre-existing classes already dispositioned in the triage above; non-blocking.

All four earlier triage dispositions were independently re-verified as sound (redaction covers the only fallible arm; route collisions panic loudly at boot rather than shadowing; managed mode shares the file-mode serving path; byte-for-byte pinned across empty/tracker-unknown/virtual rows).

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/aisix-admin/src/lib.rs`:
- Around line 494-498: Canonicalize model ordering before rendering the status
response. In the handler flow around
models_status_handler::render_models_status, sort all_models by a stable key
such as model ID before serialization, preserving the renderer’s behavior
otherwise. Add validation using stores that return identical models in opposite
orders and assert the admin and status responses remain byte-identical.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: de9768bf-a9d9-4666-88c4-175a3a8d0d75

📥 Commits

Reviewing files that changed from the base of the PR and between b07f814 and 44fff83.

📒 Files selected for processing (2)
  • crates/aisix-admin/Cargo.toml
  • crates/aisix-admin/src/lib.rs

Comment on lines +494 to +498
axum::Json(models_status_handler::render_models_status(
all_models,
state.models_status.runtime_status_tracker.as_deref(),
))
.into_response()

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Canonicalize model ordering before serialization.

ConfigStore::list_models has no ordering contract, while the shared renderer preserves its input order. Because the admin and status endpoints perform separate reads, an implementation returning the same entries in different orders breaks the byte-identical response guarantee; the current test only exercises one stable InMemoryStore.

Sort by an explicit stable key—preferably model ID—and test with stores returning opposite orders.

Proposed renderer fix
 pub(crate) fn render_models_status(
-    all_models: Vec<ResourceEntry<Model>>,
+    mut all_models: Vec<ResourceEntry<Model>>,
     tracker: Option<&ModelRuntimeStatusTracker>,
 ) -> Vec<ModelStatusView> {
+    all_models.sort_unstable_by(|left, right| left.id.cmp(&right.id));
     all_models
         .into_iter()

As per coding guidelines, “Define verifiable success criteria, write reproducing or validation tests where appropriate.”

Also applies to: 916-1007

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/aisix-admin/src/lib.rs` around lines 494 - 498, Canonicalize model
ordering before rendering the status response. In the handler flow around
models_status_handler::render_models_status, sort all_models by a stable key
such as model ID before serialization, preserving the renderer’s behavior
otherwise. Add validation using stores that return identical models in opposite
orders and assert the admin and status responses remain byte-identical.

Source: Coding guidelines

@moonming moonming merged commit 0a2a8b5 into main Jul 16, 2026
12 checks passed
@moonming moonming deleted the feat/status-models-endpoint branch July 16, 2026 09:57
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