DEP: Standalone EPP observability #15058
Description
Summary
Propose the metrics and structured-log observability design for the standalone EPP, covering DEP #11661 Milestone 9: Embedded EPP Observability. This issue is the design artifact before implementation: it draws from the existing Dynamo Frontend/router telemetry and from the llm-d EPP, and defines exactly which series are added, where they are emitted, and how they behave across EPP deployment modes.
Motivation
Today the standalone EPP is nearly unobservable. It exposes exactly one metric — dynamo_epp_cached_tokens{model} — on a private prometheus::Registry served at DYN_EPP_METRICS_PORT (default 9090), and its logging is tracing_subscriber::fmt() with RUST_LOG, text-only (no JSONL), unlike the Dynamo runtime (DYN_LOGGING_JSONL / DYN_LOGGING_CONSOLE_FORMAT). The decode sidecar (deploy/inference-gateway/sidecar/) has zero metrics, no metrics port, and only a littletracing calls.
Scope (from #11661 M9): "The standalone EPP provides metrics and structured-log observability at parity with the relevant Dynamo Frontend/router behavior for equivalent request paths. Operators can diagnose EPP-owned request processing, routing, selection, reservation lifecycle, replica behavior, and disaggregated handoff without requiring the Dynamo runtime."
Target configuration. DYN_EPP_MODE=standalone with vanilla (non-Dynamo) engines — the on-ramp example's backend is "Raw vLLM aggregated worker pool. Vanilla image" (examples/onramp/agg.yaml:57-58).
Goals. Cover the M9 diagnostic domains with Prometheus metrics; structured logs at parity with the runtime (JSONL, consistent fields, span-carried identity); every metric documented in metrics-catalog.mdx with an availability-by-configuration matrix.
Proposal
Design inputs
-
Dynamo Frontend/router telemetry:
- lib/llm/src/kv_router/metrics.rs — router metric families.
- lib/llm/src/http/service/metrics.rs — frontend request metrics, InflightGuard.
- docs/fern/pages/reference/observability/metrics-catalog.mdx and metric-labels.mdx.
-
llm-d EPP metrics: the reference implementation we compare against. llm-d is mid-migration from
inference_*tollm_d_epp_*with both names registered — a useful precedent if Dynamo ever renames an EPP series. -
PR #13143 (open, not yet merged): frontend metrics namespace attribution. This design adopts its post-merge shape as the parity baseline and follows its compatibility discipline.
-
DEP DEP: EPP Embedded SelectionService Interface #11661 component boundary: EPP owns the Envoy ext_proc/GAIE protocol, tokenization, Kubernetes worker discovery, and selection-lifecycle reporting; embedded SelectionService owns the catalog, KV index, scoring, queue, and reservation state.
Design principles
- Parity, not duplication. The EPP exposes the service-level view of the same quantities the frontend/router expose — parity of diagnosability, not name-for-name parity.
- No DRT dependency. The standalone EPP holds no
DistributedRuntimepast startup; all new series go on the existing private registry — same rationale asdynamo_epp_cached_tokens. - Cardinality safety. All identity labels are bound at startup or drawn from closed enums:
model— bound once viaset_served_model()(existing mechanism; never from the request body).inference_pool— const label bound once at startup fromDYN_EPP_INFERENCE_POOL_NAME. This is EPP-owned deployment identity: it separates several pools in one cluster.worker_type— closed enum, values taken fromWorkerType(aggregated/prefill/decode/encode. But the standalone EPP builds exactly one Aggregated SelectionService today, so this label is currently constant.reason/error_type/status/action/event_type— closed enums only.dynamo_namespace— not carried: because standalone holds noDistributedRuntime.- No
worker_id, pod name, or endpoint labels on hot-path metrics. Per-worker debugging is served by logs and by the worker-side.
- Labels are added, never renamed or removed (PR fix(frontend): update frontend prometheus metrics to respect dynamo namespacing #13143 discipline)
- Availability is documented per configuration. Metric groups register/populate differently across
aggregatedvsdisaggregated, KV events on/off, single vs multi replica — following the existing "Availability by configuration" matrix format inmetrics-catalog.mdx.
Proposed metrics
⚠️ Prefix pending. Some Series are listed without their prefix. New series usedynamo_epp_, The prefix for the (inherited) rows is still open:dynamo_epp_(this DEP's proposal) ordynamo_component_router_(inherit the router's namespace). Every other decision here is independent of that choice.
All new series take the dynamo_epp_ prefix and the startup-bound const label set model + inference_pool (only variable labels shown in tables for brevity).
Histogram buckets are frozen at design time and aligned to the existing Frontend/Router series.
All reason / phase label values in this doc are proposed naming introduced by this issue, not existing code identifiers.
Reusing RouterRequestMetrics. The EPP inherits the definitions, not the observations. RouterRequestMetrics (lib/llm/src/kv_router/metrics.rs) is built by a single constructor, from_component(component). The EPP has no Component, so we propose a sibling from_registry(registry, prefix, const_labels) registering the same definitions on its private registry. Each inherited row still needs its own EPP-side observe(); register only rows that have a producer.
Priority: P0 = required in M9a; P1 = M9b; P2 = M9c or undecided.
Domain 1 — Request processing
| Metric | Type | Labels | Priority | Where (file::fn) | Notes |
|---|---|---|---|---|---|
dynamo_epp_requests_total |
counter | error_type, status |
P0 | server.rs ext_proc stream handlers, via request-scope RAII guard |
status is success/error, matching dynamo_frontend_requests_total. error_type mirrors PickError, set only when status="error" |
dynamo_epp_request_rejection_total |
counter | — | P0 | epp_router.rs::pick permit acquire |
Fires on PickError::Overloaded. Modelled on dynamo_frontend_model_rejection_total. |
dynamo_epp_request_duration_seconds |
histogram | — | P0 | server.rs RAII guard |
Buckets: same as dynamo_frontend_request_duration_seconds (defaults). Full request, received → response complete/cancel/error. |
time_to_first_token_seconds (inherited) |
histogram | — | P0 | server.rs first non-empty response body (the event that drives prefill_complete) |
Buckets: same as dynamo_frontend_time_to_first_token_seconds. Under non-streaming the sample degenerates to the full duration. |
dynamo_epp_picks_inflight |
gauge | — | P0 | epp_router.rs::pick (permit acquire/release) |
Requests currently inside pick(). |
dynamo_epp_requests_inflight |
gauge | — | P1 | server.rs request-scope RAII guard |
Modelled on llm-d llm_d_epp_extproc_streams_inflight and Frontend dynamo_frontend_inflight_requests. Not redundant with picks_inflight, which only covers the pick window. |
input_sequence_tokens (inherited) |
histogram | — | P1 | epp_router.rs::tokenize response |
Buckets: same as dynamo_frontend_input_sequence_tokens (defaults). |
output_sequence_tokens (inherited) |
histogram | — | P1 | server.rs response usage parse |
Buckets: same as dynamo_frontend_output_sequence_tokens. From the backend-reported completion_tokens. |
dynamo_epp_streams_total |
counter | code |
P1 | server.rs::process stream exit |
Modelled on llm-d llm_d_epp_extproc_streams_total{code}. code = gRPC status (OK/Canceled/DeadlineExceeded/Unknown…). Requires a source change: server.rs currently downgrades the stream status to Status::unknown. |
dynamo_epp_cached_tokens (existing) |
histogram | — | — | already emitted | Align with current design - same model + inference_pool const labels. Buckets: same as dynamo_frontend_cached_tokens. |
Domain 2 — Routing: tokenization and selection overhead
| Metric | Type | Labels | Priority | Where (file::fn) | Notes |
|---|---|---|---|---|---|
dynamo_epp_tokenizer_duration_seconds |
histogram | protocol (vllm-render/sglang-renderer) |
P0 | epp_router.rs::tokenize; vllm_render_client.rs / sglang_renderer_client.rs |
Buckets: same as dynamo_frontend_tokenize_seconds , EPP owned tokenization step; distinguishes slow tokenization from slow selection. |
dynamo_epp_tokenize_errors_total |
counter | protocol, reason |
P0 | same as tokenize_duration_seconds |
reason = the four PickError tokenizer variants (unavailable/timeout/upstream_error/invalid_request). |
dynamo_epp_selection_duration_seconds |
histogram | worker_type |
P0 | selector.rs::select_and_reserve wrapper |
Time inside SelectionService.pick(). Equivalent of dynamo_routing_overhead_total which is frontend only today. Buckets: same as dynamo_frontend_stage_duration_seconds. |
dynamo_epp_selection_overlap_blocks |
histogram | worker_type |
P0 | same as selection_duration_seconds, from SelectResponse.overlap.longest_matched |
Buckets: same as dynamo_frontend_router_overlap_blocks_lost. Predicted overlap in blocks. |
dynamo_epp_selection_errors_total |
counter | worker_type, reason |
P0 | same as selection_duration_seconds |
reason = no_workers / queue_full / timeout / capacity_exceeded / internal, Needed to diagnose 503s without log parsing. |
non_max_overlap_selections_total (inherited) |
counter | worker_type |
P2 | requires a crate change | How often the scheduler picks a worker with less KV overlap than the best candidate — what load balancing costs in cache hits |
overlap_blocks_lost (inherited) |
histogram | worker_type |
P2 | requires a crate change | Blocks given up by the row above. Both are MetricVecs, so they emit nothing until a label combination is touched |
dynamo_epp_approximate_* (name TBD) |
— | — | P2 | dynamo-kv-router |
Placeholder, not a defined series. The EPP runs an approximate-LRU index in-process, but the crate registers no Prometheus metrics — needs an observability hook |
Domain 3 — Reservation lifecycle
| Metric | Type | Labels | Priority | Where (file::fn) | Notes |
|---|---|---|---|---|---|
dynamo_epp_active_reservations |
gauge | worker_type |
P0 | inc at selector.rs::select_and_reserve success; dec Selector::free_reservation |
How many requests currently hold worker capacity. |
dynamo_epp_reservation_duration_seconds |
histogram | worker_type |
P1 | selector.rs::free_reservation |
How long a request holds worker capacity. Buckets: same as request_duration_seconds. |
Domain 4 — Worker discovery and catalog
| Metric | Type | Labels | Priority | Where (file::fn) | Notes |
|---|---|---|---|---|---|
dynamo_epp_workers_registered |
gauge | worker_type |
P0 | new call site Selector.service. ready() |
Live worker count in the SelectionService. No analog on EPP port today. |
dynamo_epp_workers_ready |
gauge | worker_type |
P0 | same snapshot ReadyResponse. schedulable_workers |
Ready subset of registered workers. Gap between this and workers_registered surfaces stale discovery or failed health checks. |
dynamo_epp_discovery_errors_total |
counter | source (pod_watch / inference_pool / peer_watch) |
P1 | pod_discovery.rs, inference_pool.rs, peer_discovery.rs |
Counts failures of the watches that feed the EPP's worker view — some tracing::warn! sites today, so a failing watch is invisible unless someone reads logs. |
Domain 5 — Replica behavior
| Metric | Type | Labels | Priority | Where (file::fn) | Notes |
|---|---|---|---|---|---|
dynamo_epp_peers |
gauge | — | P0 | peer_discovery.rs ::reconcile_once |
How many sibling EPP replicas this one syncs load with — self excluded. |
dynamo_epp_peer_errors_total |
counter | operation (register / deregister), reason |
P1 | peer_discovery.rs |
Failed attempts to add or remove a peer. reason is mirror the ReplicaPeerError: invalid_endpoint / disabled / unavailable. |
Domain 6 — Disaggregated handoff (decode sidecar)
The sidecar is a separate binary, an HTTP proxy inside the decode pod. It exposes no metrics endpoint today, hence the new env DYN_SIDECAR_METRICS_PORT (default 9091, 0 disables — same semantics as DYN_EPP_METRICS_PORT=0), same private-registry pattern. Its series take the prefix dynamo_sidecar_.
P/D handoff is not implemented yet: the sidecar binary ships with UnavailablePdAdapter.
| Metric | Type | Labels | Priority | Where (file::fn) | Notes |
|---|---|---|---|---|---|
dynamo_sidecar_requests_total |
counter | error_type |
P2 | server.rs::handle |
How many requests the sidecar handled, split by failure reason. |
dynamo_sidecar_request_duration_seconds |
histogram | — | P2 | same as requests_total | How long the sidecar waits for the upstream to start responding — from entering the handler to the upstream response headers arriving. Buckets: same as EPP's request_duration_seconds |
Structured logging
Logs should reach parity with the Dynamo runtime on three axes: format (same JSONL init and field style), fields (request identity), and behavior (the same events at the same lifecycle moments, at the same levels).
Design decisions
- Use the runtime logging init for EPP and sidecar: replace the local
tracing_subscriber::fmt()+EnvFilterwith calllogging::init()(lib/runtime/src/logging.rs). - One span per ext_proc request, following the frontend
InflightGuard.spanconvention. - Level discipline. Request path:
trace/debugonly. Lifecycle:info. Recoverable anomalies (such as watch reconnect):warn. Operator action required (server exit, init failure):error. - Rate-limited warnings for per-event faults that can fire indefinitely, following the
record_unsupported_residency_eventpattern. - Payload hygiene Logs and metrics carry identifiers only, like
request_id,reservation_id,worker_id,model— never request bodies, prompts, token IDs, or response content.
Log events are co-located with the metric instrumentation sites defined above — logs and metrics are emitted together, not designed separately.
Activity
[-]DEP:[/-][+]DEP: Standalone EPP observability [/+]0z5a commentedon Sep 18, 2026
I feel free to take any task from this. ^^
[-]DEP: Standalone EPP observability [/-][+][WIP] DEP: Standalone EPP observability [/+]tryambak2019 commentedon Sep 21, 2026
@flpanbin I checked the current draft against @0z5a’s #15056. That PR already covers request totals/duration, first-response timing, stream accounting, etc.
Once the metric contract is settled, I can take one bounded non-overlapping slice-preferably JSONL logging/config parity, or otherwise picks_inflight with the catalog and availability tests. Please tag me when you split the implementation.
tmonty12 commentedon Sep 22, 2026
Hey @flpanbin, great proposal. A few thoughts:
Exposing
dynamo_component_router_*on the EPP portThe
RouterRequestMetricsstruct inlib/llm/src/kv_router/metrics.rsalready tracks request count, TTFT, ITL, ISL, OSL, KV hit rate, and overlap metrics — exactly the series this DEP re-proposes underdynamo_epp_*. Right now these are gated behindfrom_component()which requires aDistributedRuntime, which standalone EPP does not have.The fix is a second constructor
from_registry(registry, prefix, const_labels)that takes a plainprometheus::Registryand registers the same series with no DRT dependency. The standalone EPP passes in its existing private registry (already servingdynamo_epp_cached_tokenson port 9090) with appropriate const labels.On naming: rather than rename these to
dynamo_epp_*, I would suggest keeping them asdynamo_component_router_*even on the EPP port. This keeps naming consistent with the DRT/frontend mode (which keepsdynamo_component_router_*on port 8000), avoids a parallel renamed set, and makes it clear the same router logic is running regardless of deployment mode.Metrics to remove from the DEP (covered by
dynamo_component_router_*):Note:
dynamo_epp_requests_totalanddynamo_epp_time_to_first_token_secondsshould stay as EPP owned series. The EPP request total counts at the ext_proc boundary and includes requests that fail before reaching the SelectionService (tokenization failure, semaphore rejection, etc.) — the router counter only increments after admission. Similarly, EPP TTFT is service level (ext_proc receipt to first token back) while the router TTFT starts after tokenization and selection. Both are useful but they measure different things. ITL and OSL are the same in both: they come from the same token stream and response usage field.dynamo_epp_request_input_tokensdynamo_component_router_input_sequence_tokensdynamo_epp_request_output_tokensdynamo_component_router_output_sequence_tokensITL, KV hit rate, shared cache hit rate, and non-max-overlap selections also come for free.
Metrics to add (not covered by router metrics, genuinely EPP owned):
dynamo_epp_tokenizer_duration_secondsdynamo_epp_selection_duration_secondsSelectionService.pick(). Equivalent ofdynamo_routing_overhead_totalwhich is frontend only todaydynamo_epp_selection_errors_totalreason:no_workers,queue_full,timeout,capacity_exceeded. Needed to diagnose 503s without log parsingdynamo_epp_workers_registereddynamo_epp_workers_readyworkers_registeredsurfaces stale discovery or failed health checksLabels for all EPP metrics
Const labels (bound once at startup, never from request body):
model— fromset_served_model(), existing mechanisminference_pool— fromDYN_EPP_INFERENCE_POOL_NAMEdynamo_epp_selection_errors_totaladdsreason(closed enum).dynamo_epp_workers_*addsworker_type(closed enum:aggregated/prefill/decode/encode).No
worker_id, pod name, or endpoint labels on hot path metrics per the cardinality safety principle already in this DEP.0z5a commentedon Sep 22, 2026
Hi @flpanbin @tmonty12, I'd like to take the DRT-free
RouterRequestMetrics construction and standalone EPP integration
slice: the proposed from_registry path, wiring into the existing
private registry, and the corresponding compatibility, registry
isolation, and observation-path tests.
Following the proposed naming direction, this would reuse
dynamo_component_router_* rather than introduce a parallel renamed
set, while preserving the existing DRT/component path.
The request-boundary instrumentation remains in #15056. The
tokenizer/selection timing proposals also need to be reconciled
with its existing phase instrumentation to avoid duplicate
measurement paths.
As a separate follow-up, I'd also like to take the
workers_registered/workers_ready gauges, with the authoritative
state source, counting unit, and readiness semantics agreed first.
I'll avoid JSONL/config parity and picks_inflight, which
@tryambak2019 has expressed interest in.
Please flag any implementation already underway for the registry
or worker-state slices so the scopes can be coordinated.
flpanbin commentedon Sep 22, 2026
Hi @tmonty12 Thanks for the review. Point by point:
from_registry — agreed. Reusing
RouterRequestMetricsgives us the definitions, but it does not give us the observations. Most of those series is fed from insideRoutingHost/RequestGuard, none of which the EPP runs. So each series still needs an EPP-side observe call at a point where the EPP actually holds the data. That's why I listed "where (file::fn)" per row in the DEP. And even the definitions need one change. The help strings are hardcoded infrom_component— six of the twelve say "observed at the router" or "processed by the router", which isn't true of the EPP, Frontend and router already share the name constant while writing their own help text, so couldfrom_registrytake the observer name, or a help override?Naming — would
prefix = dynamo_eppbe better thandynamo_component_router_?Same struct, same buckets, only the prefix differs. Reasons:
frontend_service::*constants, and emit two names, such as:dynamo_frontend_input_sequence_tokensvsdynamo_component_router_input_sequence_tokens. The EPP is a third observer, like the frontend, it has nodynamo_namespace/dynamo_componentlabels. Sodynamo_epp_*follows the same rule.dynamo_namespace/dynamo_componentlabels. Same name with a different label set silently breaks consumers, include theplannerandGrafana, EPP metrics samples would be summed into the same distribution with no error.llm_d_epp_*for these quantities.On "ITL, KV hit rate, shared cache hit rate, non-max-overlap" come for free — free for registration, not for data. such as
non-max-overlap / overlap_blocks_lostneeds a crate change: the value is a scheduler-internal comparison (highest-overlap worker vs. the one selected) that isn't in the selection result. So may be we can registerfrom_registryonly a subset. Not every series inRouterRequestMetricscan be fed on day one, and their priorities differ. We can add the rest as each producer lands.Remaining metrics - still in progress I'm still working through the other domains (such as reservation lifecycle, Worker discovery and catalog, Disaggregated handoff). There are some details need to confirm, like the observation point, and which values a label can take. One example: For
dynamo_epp_selection_errors_totalyou metioned, I noticed theSelectionError—bad_request / not_ready / not_found / conflict / internal / scheduler / sequencedefined. The values in your comment don't match that set. But I think the reasons you suggested makes more sense. I have updated in DEP.[-][WIP] DEP: Standalone EPP observability [/-][+]DEP: Standalone EPP observability [/+]kvnloo commentedon Oct 1, 2026
One implementation detail that may be worth making explicit in the DEP: lifecycle metrics should be cancellation-safe, not just response-path-safe.
The current standalone
EppRouter::pick()already has the right ownership shape for this:try_acquire_owned()gives a permit whose lifetime is exactly the admitted pick, including early error and future cancellation/drop. I would mirror that with an RAII metrics guard rather than pairinginc()/dec()at individual return sites.That gives clean semantics for
picks_inflight:I think the same principle should apply one level up at the ext_proc stream boundary. A request-duration / active-request guard should finalize on stream drop as well as normal response completion; otherwise client disconnects and Envoy cancellations disappear from the denominator or leak gauges.
One other split I would keep:
SelectionErrorreason describes the scheduler/selection failure; the ext_proc outcome describes the externally visible request result. They are related but shouldn't be forced into one label enum.This also keeps per-request identity in tracing/log fields rather than metric labels, consistent with the cardinality rules already in the DEP.
AI assistance disclosure: I used an AI assistant to inspect the current EPP lifecycle/RAII paths and organize this note; I checked the referenced code paths before posting.
flpanbin commentedon Oct 15, 2026
@kvnloo Thanks for the comments.
Agreed.
dynamo_epp_requests_inflightnow share the request-scope RAII guard withdynamo_epp_requests_totalanddynamo_epp_request_duration_seconds.I added a extra
dynamo_epp_request_rejection_totalonPickError::Overloaded; shed requests never touch picks_inflight.