Skip to content

DEP: Standalone EPP observability  #15058

Open

Description

@flpanbin

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_* to llm_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

  1. 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.
  2. No DRT dependency. The standalone EPP holds no DistributedRuntime past startup; all new series go on the existing private registry — same rationale as dynamo_epp_cached_tokens.
  3. Cardinality safety. All identity labels are bound at startup or drawn from closed enums:
    • model — bound once via set_served_model() (existing mechanism; never from the request body).
    • inference_pool — const label bound once at startup from DYN_EPP_INFERENCE_POOL_NAME. This is EPP-owned deployment identity: it separates several pools in one cluster.
    • worker_type — closed enum, values taken from WorkerType (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 no DistributedRuntime.
    • No worker_id, pod name, or endpoint labels on hot-path metrics. Per-worker debugging is served by logs and by the worker-side.
  4. Labels are added, never renamed or removed (PR fix(frontend): update frontend prometheus metrics to respect dynamo namespacing #13143 discipline)
  5. Availability is documented per configuration. Metric groups register/populate differently across aggregated vs disaggregated, KV events on/off, single vs multi replica — following the existing "Availability by configuration" matrix format in metrics-catalog.mdx.

Proposed metrics

⚠️ Prefix pending. Some Series are listed without their prefix. New series use dynamo_epp_, The prefix for the (inherited) rows is still open: dynamo_epp_ (this DEP's proposal) or dynamo_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

  1. Use the runtime logging init for EPP and sidecar: replace the local tracing_subscriber::fmt() + EnvFilter with call logging::init() (lib/runtime/src/logging.rs).
  2. One span per ext_proc request, following the frontend InflightGuard.span convention.
  3. Level discipline. Request path: trace/debug only. Lifecycle: info. Recoverable anomalies (such as watch reconnect): warn. Operator action required (server exit, init failure): error.
  4. Rate-limited warnings for per-event faults that can fire indefinitely, following the record_unsupported_residency_event pattern.
  5. 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

  1. changed the title [-]DEP:[/-] [+]DEP: Standalone EPP observability [/+] on Sep 18, 2026
  2. added
    routerRelates to routing, KV-aware routing, etc.
    observabilityRelated to metrics, tracing, logging
    enhancementNew feature or request
    language::rustIssues/PRs that reference Rust code
    deployment::k8sRelates to dynamo deployment in kubernetes
    eppCODEOWNER area -> @ai-dynamo/dynamo-epp-codeowners
    backend::vllmRelates to the vllm backend
    on Sep 18, 2026
  3. 0z5a commented on Sep 18, 2026

    @0z5a

    I feel free to take any task from this. ^^

  4. changed the title [-]DEP: Standalone EPP observability [/-] [+][WIP] DEP: Standalone EPP observability [/+] on Sep 18, 2026
  5. tryambak2019 commented on Sep 21, 2026

    @tryambak2019

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

  6. tmonty12 commented on Sep 22, 2026

    @tmonty12
    Contributor

    Hey @flpanbin, great proposal. A few thoughts:

    Exposing dynamo_component_router_* on the EPP port

    The RouterRequestMetrics struct in lib/llm/src/kv_router/metrics.rs already tracks request count, TTFT, ITL, ISL, OSL, KV hit rate, and overlap metrics — exactly the series this DEP re-proposes under dynamo_epp_*. Right now these are gated behind from_component() which requires a DistributedRuntime, which standalone EPP does not have.

    The fix is a second constructor from_registry(registry, prefix, const_labels) that takes a plain prometheus::Registry and registers the same series with no DRT dependency. The standalone EPP passes in its existing private registry (already serving dynamo_epp_cached_tokens on port 9090) with appropriate const labels.

    On naming: rather than rename these to dynamo_epp_*, I would suggest keeping them as dynamo_component_router_* even on the EPP port. This keeps naming consistent with the DRT/frontend mode (which keeps dynamo_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_total and dynamo_epp_time_to_first_token_seconds should 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.

    Remove Covered by
    dynamo_epp_request_input_tokens dynamo_component_router_input_sequence_tokens
    dynamo_epp_request_output_tokens dynamo_component_router_output_sequence_tokens

    ITL, 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):

    Metric Type Notes
    dynamo_epp_tokenizer_duration_seconds Histogram EPP owned tokenization step; distinguishes slow tokenization from slow selection
    dynamo_epp_selection_duration_seconds Histogram Time inside SelectionService.pick(). Equivalent of dynamo_routing_overhead_total which is frontend only today
    dynamo_epp_selection_errors_total Counter Selection failures by reason: no_workers, queue_full, timeout, capacity_exceeded. Needed to diagnose 503s without log parsing
    dynamo_epp_workers_registered Gauge Live worker count in the SelectionService. No analog on EPP port today
    dynamo_epp_workers_ready Gauge Ready subset of registered workers. Gap between this and workers_registered surfaces stale discovery or failed health checks

    Labels for all EPP metrics

    Const labels (bound once at startup, never from request body):

    • model — from set_served_model(), existing mechanism
    • inference_pool — from DYN_EPP_INFERENCE_POOL_NAME

    dynamo_epp_selection_errors_total adds reason (closed enum).
    dynamo_epp_workers_* adds worker_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.

  7. 0z5a commented on Sep 22, 2026

    @0z5a

    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.

  8. flpanbin commented on Sep 22, 2026

    @flpanbin
    ContributorAuthor

    Hi @tmonty12 Thanks for the review. Point by point:

    1. from_registry — agreed. Reusing RouterRequestMetrics gives us the definitions, but it does not give us the observations. Most of those series is fed from inside RoutingHost / 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 in from_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 could from_registry take the observer name, or a help override?

    2. Naming — would prefix = dynamo_epp be better than dynamo_component_router_?​
      Same struct, same buckets, only the prefix differs. Reasons:

      • Precedent inside Dynamo. Frontend and the KV router already observe the same request-level quantities from the same frontend_service::* constants, and emit two names, such as: dynamo_frontend_input_sequence_tokens vs dynamo_component_router_input_sequence_tokens. The EPP is a third observer, like the frontend, it has no dynamo_namespace / dynamo_component labels. So dynamo_epp_* follows the same rule.
      • dynamo_component_router_ implies a registered Dynamo component, which the EPP is not. It carries no dynamo_namespace / dynamo_component labels. Same name with a different label set silently breaks consumers, include the planner and Grafana, EPP metrics samples would be summed into the same distribution with no error.
      • llm-d does the same — its endpoint picker uses llm_d_epp_* for these quantities.
    3. 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_lost needs 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 register from_registry only a subset. Not every series in RouterRequestMetrics can be fed on day one, and their priorities differ. We can add the rest as each producer lands.

    4. 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_total you metioned, I noticed the SelectionError — bad_request / not_ready / not_found / conflict / internal / scheduler / sequence defined. 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.

  9. changed the title [-][WIP] DEP: Standalone EPP observability [/-] [+]DEP: Standalone EPP observability [/+] on Sep 24, 2026
  10. kvnloo commented on Oct 1, 2026

    @kvnloo

    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 pairing inc() / dec() at individual return sites.

    That gives clean semantics for picks_inflight:

    • semaphore rejection → increment an overload/shed counter, never inflight;
    • admitted pick → inflight +1;
    • success, selection error, tokenizer error, stale-worker rejection, or cancelled future → inflight -1 exactly once.

    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: SelectionError reason 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.

  11. flpanbin commented on Oct 15, 2026

    @flpanbin
    ContributorAuthor

    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 pairing inc() / dec() at individual return sites.

    That gives clean semantics for picks_inflight:

    • semaphore rejection → increment an overload/shed counter, never inflight;
    • admitted pick → inflight +1;
    • success, selection error, tokenizer error, stale-worker rejection, or cancelled future → inflight -1 exactly once.

    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: SelectionError reason 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.

    @kvnloo Thanks for the comments.

    lifecycle metrics should be cancellation-safe, not just response-path-safe.

    Agreed. dynamo_epp_requests_inflight now share the request-scope RAII guard with dynamo_epp_requests_total and dynamo_epp_request_duration_seconds.

    semaphore rejection → increment an overload/shed counter, never inflight;

    I added a extra dynamo_epp_request_rejection_total on PickError::Overloaded; shed requests never touch picks_inflight.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    backend::sglangRelates to the sglang backendbackend::trtllmRelates to the trtllm backendbackend::vllmRelates to the vllm backenddep:draftDEP in draft statusdeployment::k8sRelates to dynamo deployment in kubernetesenhancementNew feature or requesteppCODEOWNER area -> @ai-dynamo/dynamo-epp-codeownerslanguage::rustIssues/PRs that reference Rust codemetricsobservabilityRelated to metrics, tracing, loggingrouterRelates to routing, KV-aware routing, etc.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions