Skip to content

feat(epp): observable request lifecycle, routing phases, and reservation callbacks - #15056

Draft
0z5a wants to merge 5 commits into
ai-dynamo:mainfrom
0z5a:feat/epp-observability
Draft

0z5a wants to merge 5 commits into
ai-dynamo:mainfrom
0z5a:feat/epp-observability

Conversation

@0z5a

@0z5a 0z5a commented Sep 18, 2026

Copy link
Copy Markdown

Part of #11661 (Milestone 9 — Embedded EPP Observability). This is the first batch: request/phase instrumentation on the current EPP path. It does not close the milestone.

What this adds

Extends the EPP's existing private Prometheus registry (deploy/inference-gateway/ext-proc/src/metrics.rs) with the request, phase, and lifecycle families. The exporter, the /metrics listener, the startup/discovery model-label binding, and dynamo_epp_cached_tokens are reused unchanged — no second registry, no second model labeling, no runtime initialization.

Metric Type Labels Observed at
dynamo_epp_requests_total counter model, outcome once per ext_proc attempt, at its terminal state
dynamo_epp_request_duration_seconds histogram model, outcome same instant, measured from attempt start
dynamo_epp_streams_inflight gauge model incremented when an attempt starts, decremented at its terminal state
dynamo_epp_phase_duration_seconds histogram model, phase, outcome when a phase the attempt entered returns
dynamo_epp_first_response_body_seconds histogram model on the first non-empty response body chunk
dynamo_epp_lifecycle_callbacks_total counter model, operation, result when a picker lifecycle callback returns
dynamo_epp_cached_tokens histogram model unchanged

outcome ∈ {response_eos, upstream_http_error, early_reject, ext_proc_error, incomplete}; phase ∈ {render_tokenize, selection}; operation ∈ {prefill_complete, request_complete}; result ∈ {ok, error}. There is no cancelled result — a call site cannot reliably tell a cancelled call from a failed one, so it reports the failure rather than guessing.

The metric glossary lives in the module docs: exact observation points, what each family includes and does not include, the zero/absent/unknown rules, and the list of things that are not observable at this layer.

Terminal classification

result.is_ok() is deliberately not consulted. The gRPC handler returns Ok(()) both for a completed response and for an immediate rejection it already wrote, so the outcome is derived from the control flow, in a fixed priority order: early rejection → protocol error → upstream HTTP error status → completed response → incomplete.

  • incomplete is what a disconnect or force shutdown actually is. It is not reported as a client cancellation, which this layer cannot distinguish.
  • upstream_http_error uses the :status pseudo-header when the gateway forwards response headers, so a 5xx backend is not folded into response_eos.
  • An attempt that never sees a response body records no first_response_body_seconds sample.
  • Backend-reported cached_tokens = 0 remains a real zero sample; a response with no usage information records nothing rather than a fabricated zero.

Exactly-once terminal accounting

One RequestObservation owns one attempt. finish() is consuming, so an attempt records exactly one terminal; if the owning future is dropped first, Drop records Incomplete instead. A dropped stream therefore cannot leak the inflight gauge or disappear from the counters, and no shared request-keyed map is needed.

Reservation lifecycle: calls, not state

dynamo_epp_lifecycle_callbacks_total counts callback invocations and their return. It is explicitly not an active-reservation gauge: free_reservation and prefill_complete are idempotent and return success for an unknown id without releasing anything, so callback == Ok must not decrement a state counter. Authoritative Applied/NoChange transitions need a core-level observer and are not claimed here.

Structured events

epp.request_finished (finite outcome, duration_ms) and epp.routing_failed (request_id, finite stage, finite code, reservation_taken) carry bounded fields only. No prompt, token, authorization, raw handoff, worker address, or error message text is logged. These are tracing events on the existing subscriber; this PR does not add a JSON sink.

Cardinality

model remains the startup/discovery-bound label; it is never read from a request body. Nothing request-scoped (request id, attempt id, reservation/plan/session id, prompt, cache salt, token, worker endpoint, peer IP, path/query/header) is a label. A test drives every label combination the schema allows and asserts the series count per family is exactly the enumeration product.

Behavioural neutrality

Instrumentation does not change routing, status, headers, body, callback order, or cleanup semantics. The pre-existing wire-level integration test and all pre-existing unit tests pass unchanged.

Tests

cargo test -p dynamo-ext-proc --lib          # 177 passed; 0 failed   (baseline 160; +17)
cargo test -p dynamo-ext-proc --test ext_proc_test   # 1 passed; 0 failed
cargo clippy -p dynamo-ext-proc --all-targets -- -D warnings   # clean
cargo fmt -p dynamo-ext-proc -- --check                        # clean
git diff --check                                              # clean

New coverage:

  • terminal taxonomy: response_eos, upstream_http_error, early_reject, incomplete; exactly-once terminal; inflight back to zero; body-free stream; two streams sharing a client request id;
  • first body: recorded once, on the first non-empty chunk, never when no body arrives;
  • recorder: finish + Drop records one terminal, not two; isolated registries do not share samples; exposition declares HELP/TYPE for every family;
  • cardinality: every outcome, phase, operation, and result combination is driven once, and each family's series count equals its label product.

Follow-up commits

  • b8974c7c48 condenses the module documentation into one glossary plus the readings most likely to be
    misread, and drops two tests that asserted properties the compiler or the test helper already
    guarantees. No runtime path changed; 175 lib tests pass.

Base

402c2611ffcb3dbdeea344677d757dfed5469912

Not in this PR

Notes for review

  • ExtProcServer::new now takes the recorder instead of reaching a global, which is what lets the tests assert on an isolated registry. EppRouter::from_selector takes it for the same reason.
  • The :status capture only fires when the gateway forwards response headers; when it is absent the attempt is classified without it, and the glossary says so.
  • selection includes queue wait and render_tokenize includes renderer latency. They are named for the phase, not for compute, on purpose.

Extend the EPPs private Prometheus registry with the request, phase, and

Signed-off-by: 0z5a <Dezhen.lu@student.uni-tuebingen.de>
Give every ext_proc stream one RequestObservation and close it exactly once
with a finite terminal outcome.

Classification follows the control flow rather than the gRPC return value:
the handler returns Ok(()) both for a completed response and for an
immediate rejection it already wrote, so result.is_ok() cannot mean success.
The priority order is early rejection, then protocol error, then upstream
HTTP error status, then a completed response, then incomplete.

- The first non-empty response body chunk records first_response_body. It is
  the same edge the prefill-complete signal uses, and it is deliberately not
  described as time to first token: the chunk may be SSE metadata or a role
  chunk.
- Response header handling captures the `:status` pseudo-header when the
  gateway forwards it. A 4xx/5xx is attributed to the upstream instead of
  being folded into a completed response.
- A stream that ends without a provable backend terminal is `incomplete`,
  which is what a disconnect or force shutdown actually is. It is not
  reported as a client cancellation, which this layer cannot distinguish.
- A dropped stream task still closes its attempt, because the context owns
  the observation and the observation owns its own Drop.

The recorder is injected into ExtProcServer rather than reached through a
global, so tests assert on an isolated registry.

Instrumentation does not change routing, status, headers, body, or cleanup
behaviour: the existing wire-level assertions and the new metric assertions
both run against the same server.

Signed-off-by: 0z5a <Dezhen.lu@student.uni-tuebingen.de>
The standalone picker now reports the two phases it actually enters and the
lifecycle callbacks it actually invokes, and names where a request stopped
when routing fails.

- render_tokenize covers the renderer call and its wait, including the
  upstream renderer's latency. It is not pure tokenizer CPU time. A malformed
  client body fails the surrounding parse and is reported as a routing
  failure with an invalid_request code rather than as a renderer error.
- selection covers the call and its wait inside the selection service. Queue
  wait is not separable from scoring work here, so it is not named a compute
  time.
- A phase sample exists only for a phase the request entered. A request shed
  because the in-flight limit is saturated records no phase sample, only the
  capacity routing_failed event.
- lifecycle_callbacks_total counts callback invocations and their return. It
  is not an active-reservation gauge: free_reservation and prefill_complete
  are idempotent and report success for an unknown id without releasing
  anything.
- epp.routing_failed is emitted with a finite stage and error code, so a log
  line and the client-visible status always agree. No error message text,
  prompt, header, or worker address is logged.

Signed-off-by: 0z5a <Dezhen.lu@student.uni-tuebingen.de>
Extend the existing ext-proc test harness with the metric contract, reusing
the mock picker rather than adding a second one.

Metric contract:
- a complete non-streaming exchange is one response_eos terminal;
- an SSE response with several body chunks records one first-body sample and
  one terminal, and the first sample lands on the first *non-empty* chunk;
- an upstream 5xx status is upstream_http_error, not response_eos;
- an immediate rejection is early_reject even though the handler returns Ok;
- a stream closed mid-response is incomplete and leaves nothing inflight;
- a body-free header-only stream still terminates exactly once;
- two streams sharing a client-supplied request id are two attempts.

Recorder contract:
- finish followed by Drop records one terminal, not two;
- dropping without finish records `Incomplete` and returns the gauge to zero;
- no response body means no first-body sample, and a backend-reported zero
  cached_tokens is still a sample;
- repeated lifecycle callbacks count as calls;
- 5,000 observations across the closed enumerations produce one series per
  observed label combination and none beyond the enumeration product;
- isolated recorders do not share samples;
- the exposition declares HELP/TYPE for every family.

Wire the recorder through the runner so one process-wide instance serves the
router and the ext_proc server.

Signed-off-by: 0z5a <Dezhen.lu@student.uni-tuebingen.de>
@copy-pr-bot

copy-pr-bot Bot commented Sep 18, 2026

Copy link
Copy Markdown

This pull request requires additional validation before any workflows can run on NVIDIA's runners.

Pull request vetters can view their responsibilities here.

Contributors can view more details about this message here.

@0z5a
0z5a deployed to external_collaborator September 18, 2026 07:20 — with GitHub Actions Active
@0z5a
0z5a deployed to external_collaborator September 18, 2026 07:20 — with GitHub Actions Active
@github-actions github-actions Bot added the feat label Sep 18, 2026
@github-actions

Copy link
Copy Markdown
Contributor

👋 Hi 0z5a! Thank you for contributing to ai-dynamo/dynamo.

Just a reminder: The NVIDIA Test Github Validation CI runs an essential subset of the testing framework to quickly catch errors.Your PR reviewers may elect to test the changes comprehensively before approving your changes.

🚀

@github-actions github-actions Bot added the external-contribution Pull request is from an external contributor label Sep 18, 2026
Trim the observability module without changing any runtime path.

The module documentation carried five overlapping sections plus a second copy of
the metric table's own explanations. It is now one glossary table and the
readings a dashboard is most likely to get wrong: the phase timings that include
wait, the first-body metric that is not TTFT, the callback counter that is not a
state gauge, and the zero-versus-unknown rule. 96 lines of docs become 51.

Two tests asserted properties nothing can break:

- `every_label_value_is_distinct` compared the strings that `as_str` returns for
  a closed enum, which the compiler and the match arms already guarantee;
- `isolated_recorders_do_not_share_samples` tested that two separately built
  registries are separate, which is a property of the test helper rather than of
  the code under test.

The cardinality test drove 1000 rounds before asserting. It now drives each
outcome, phase, operation, and result once, so every series the label schema
allows is created and each is observed a known number of times, and the
assertions count the expected series per family instead of stating one bound.

Not touched: the request, phase, and callback observation paths, the exporter,
the model-label binding, and `cached_metrics` semantics. The whole diff is
documentation and test structure.

Signed-off-by: 0z5a <Dezhen.lu@student.uni-tuebingen.de>
@0z5a
0z5a deployed to external_collaborator September 18, 2026 18:24 — with GitHub Actions Active
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

external-contribution Pull request is from an external contributor feat size/XXL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant