Repository navigation
Conversation
Zheng-Lu
force-pushed
the
feat/340-telemetry-metrics
branch
from
October 9, 2026 14:38
899ed58 to
1e90af0
Compare
Zheng-Lu
marked this pull request as ready for review
October 9, 2026 15:07
Zheng-Lu
requested review from
bbrowning,
franciscojavierarceo,
haoshan98,
jiahuei,
leseb,
maralbahari,
noobHappylife,
qandrew and
tjtanaa
as code owners
October 9, 2026 15:07
haoshan98
pushed a commit
that referenced
this pull request
Oct 12, 2026
### Summary Fixes #377, taking option 1 from the issue: raise the declared MSRV to what the locked dependencies need. The documented MSRV was 1.85, but nothing declared or checked it, and the committed `Cargo.lock` needs more: - The default build needs 1.88: `idna_adapter` 1.2.2 needs 1.86, `process-wrap` 9 (for `rmcp`'s stdio MCP transport) needs 1.87, and `home` 0.5.12 and `icu_*` 2.3 need 1.88. - The `embedded-code-interpreter` feature (#368) needs 1.98.1: `eryx` 0.8.0 declares it, and wasmtime and cranelift 48 need 1.95. `rust-toolchain.toml` is already pinned to 1.98.1 for that feature. Cargo expects `rust-version` to cover every feature, so the MSRV is 1.98.1: - `rust-version = "1.98.1"` in `[workspace.package]`, inherited by every crate, so older toolchains fail with a clear message. `clippy.toml`'s `msrv` matches it. - A new `msrv` CI job reads `rust-version` and fails if `clippy.toml` differs. It prepares the Eryx runtime, because the `eryx` build script needs it, then runs `cargo check --workspace --all-targets --all-features --locked` on that toolchain. - Every workflow toolchain pin is now 1.98.1. Jobs pinned to 1.98.0 had been running 1.98.1 only because `rust-toolchain.toml` overrode them. - The container now builds on cargo-chef 0.1.78 with Rust 1.98.1. It had been building with 1.96.0, because the image doesn't copy `rust-toolchain.toml`, so the new MSRV would have rejected it. cargo-chef publishes Rust 1.98 images only from 0.1.78. - The clippy suggestions enabled by the new MSRV are applied in a separate commit: `if let` chains, `is_none_or`, `as_chunks`, `Duration::from_mins` and `is_multiple_of`. No behavior changes. - The MSRV is updated in `AGENTS.md`, the README badge, the getting-started and container docs, and the embedded code interpreter design doc. That doc now says the toolchain pin and the MSRV are both 1.98.1. The provider-aware reasoning design doc no longer says an MSRV repair is pending. - The observability docs, `OtlpProtocol` and the unsupported-protocol startup error no longer blame the MSRV for the missing gRPC exporter. The error used to say "gRPC is unavailable below Rust 1.88"; it now says the exporter is not compiled in, matching the zstd compression error. - The website's copy of the observability page is a release snapshot that `refresh-site-docs.mjs` regenerates, so it is left for the next refresh. ### Test Plan ```bash # With the Eryx runtime prepared (./scripts/setup-eryx-runtime.sh): cargo +1.98.1 check --workspace --all-targets --all-features --locked # passes cargo +1.98.0 check --workspace --locked # refused: "agentic-* requires rustc 1.98.1" cargo clippy --workspace --all-targets --all-features --locked -- -D warnings # clean with msrv = "1.98.1" cargo clippy --workspace --all-targets --locked -- -D warnings # clean (default features) cargo fmt -- --check cargo test --workspace --locked # 2281 passed, 1 failed, 18 ignored (see below) uvx pre-commit run --from-ref upstream/main --to-ref HEAD # includes the file-size ratchet uvx --with-requirements docs/requirements.txt mkdocs build --strict ``` - Without the Eryx runtime, the all-features check stops in `eryx`'s build script ("Pre-compiled runtime (runtime.cwasm) not found"). The `msrv` job prepares the runtime the same way the `embedded-code-interpreter` job does, and shares its cache key. - The one test failure is `web_fetch::http::tests::the_deadline_bounds_a_slow_origin`. It fails the same way on unmodified `main` under the full suite and passes on its own; this PR doesn't touch `web_fetch`. - I dry-ran the job's version-reading step locally: both files read `1.98.1`. - Container: on the old base (cargo-chef 0.1.77, Rust 1.96.0), `cargo chef cook` is refused with "rustc 1.96.0 is not supported … requires rustc 1.98.1". On the new base, the Rust build stage completes, and both binaries run (`agentic-server 0.9.0`). My local Docker has no BuildKit, so CI's image build covers the later `COPY --chmod` step. - Default features alone would still build on 1.88. On `main` at 2026-09-30, `cargo +1.88.0 check --workspace --all-targets --locked` passed, and on today's `main` every locked dependency outside `eryx`'s tree still declares at most 1.88. The MSRV follows Cargo's expectation that `rust-version` covers every feature. - Rebased onto `main` `8b27941`. The clippy autofix commit was redone against current `main`. #427 removed the `provider-upstream` job, so that job's toolchain pin is gone too. ### Merging alongside #375 #375 (#340) also edits `.github/workflows/rust.yml`; whichever lands second keeps both changes. Under `msrv = "1.98.1"`, #375 must also pass clippy, which may need the same mechanical autofixes. --------- Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Phase 3 of vllm-project#279 (vllm-project#340). The ExecutionSpan guard now records the execution metrics from Drop, on the same paths that finalize the agentic.execute span, so every execution is counted exactly once and metrics do not depend on trace sampling. - ExecutorMetrics, on ExecutionContext (with_metrics for embedders), owns every instrument: execution count, duration and active executions; delivery outcome and wait; stage durations; inference rounds per execution; and per-call token usage. - ExecutionClock records first upstream data, first client event, and first output text. The pipeline marks upstream data; the client-facing stream marks client events and measures delivery wait through InstrumentedStream. - Stage timers wrap rehydration, each inference round, each executed gateway call, compaction summaries, and persistence, including explicit compaction persistence. - Retained (WebSocket) responses take the same timings in their event stream. A multi-agent response records one rounds sample covering every agent, and each round's pipeline shares the execution clock. - The Messages loop and stream record rounds, inference stages, and per-round token usage. - The server records WebSocket connections and queue wait. Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Cover every terminal path on both APIs: completion, upstream failure with and without a stream, a dropped stream, WebSocket requests, disconnects and drains, and a gateway tool round. Each execution is counted once, the first-* timings are ordered, token usage comes from each upstream call, and every exported metric stays within the cardinality allow-list. Also cover the paths added since: a multi-agent response records one rounds sample counting every agent's rounds, and retained WebSocket responses record the first-* timings. The mock upstreams give each response fresh item IDs, as a real upstream does: stored history keys rows by item ID (vllm-project#385). Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
deploy/otel runs an OpenTelemetry Collector (and optional Jaeger) that exports metrics to Prometheus and logs every signal. The smoke test starts the gateway against a scripted upstream, sends streamed, blocking, proxied, and WebSocket requests, and checks the Collector's output against the same allow-list as the Rust tests. CI runs it after the CLI end-to-end test. scripts/otel-overhead.py measures the telemetry overhead. Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Describe every instrument, its attributes and boundaries, when each is recorded, the local Collector setup, and the measured overhead. Note that a multi-agent response's rounds sample counts every agent, and that a WebSocket response's delivery wait ends when it finalizes. Signed-off-by: Zheng Lu <Lz429671594@gmail.com>
Zheng-Lu
force-pushed
the
feat/340-telemetry-metrics
branch
from
October 12, 2026 19:53
1e90af0 to
e6dff9b
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase 3 of #279. Closes #340.
Rebuilt on
mainat5473e02(2026-10-09), then rebased on 2026-10-12 onto079a9a3(#432, #379).mainreorganized most of the code this PR instruments: #384, #386, #407, #410 and #423 changed the inference loop, multi-agent execution, the WebSocket path and client delivery. So each hook was re-applied at its current owner rather than merged textually, and the paths that did not exist before are now covered too. See "Changes in the rebuild" below.The rebase onto
079a9a3needed two small changes. #432 wrapspersist_if_neededinBox::pinat the two call sites where this PR passes&exec_ctx.metrics; both are kept.engine/multi_agent_tests.rsgets a file-size exception (limit 501) for this PR's two-linemetricsmodule declaration, because #432 brought the file to 499 lines; the tests themselves live inmulti_agent_tests/metrics.rs.This adds operational metrics that stay correct without trace sampling, a backend-neutral local Collector setup, and measured overhead. All instruments are typed in
agentic-server-coreand are recorded by the sameExecutionSpanguard that already finalizesagentic.execute. They therefore share its exactly-once terminal paths: completion, in-band upstream failure, disconnect, and shutdown. They are also independent of the sampling decision.agentic.execution.count,agentic.execution.durationagentic.api,agentic.route,agentic.stream,agentic.execution.outcome,error.typeon failureagentic.execution.activeagentic.api,agentic.route,agentic.streamagentic.delivery.countagentic.api,agentic.route,agentic.stream,agentic.delivery.outcomeagentic.delivery.wait.durationagentic.apiagentic.stage.durationagentic.stage(rehydrate,inference,tool,compaction,persist),agentic.tool.typeon tool samples,error.typeagentic.inference.roundsagentic.apigen_ai.client.token.usagegen_ai.operation.name=chat,gen_ai.token.type,agentic.apiagentic.time_to_first_upstream_data,agentic.time_to_first_client_event,agentic.time_to_first_textagentic.apiagentic.websocket.connections.activeagentic.websocket.queue.wait.durationExecutorMetricslives onExecutionContext. It defaults to the global meter provider, and embedding applications pass their own withExecutionContext::with_metrics. Without a provider every instrument is a no-op.usage, including compaction summaries. Nothing is recorded when the upstream omitsusageor a count, so no zeros are fabricated. For Messages, prompt tokens include cache reads and writes.ExecutionClockmeasured from when the execution was accepted:agentic.inference.roundssample counting every agent's rounds.ResponseEventSink::streamreturns, and measure delivery wait there.deploy/otel/docker-compose.yamlruns digest-pinned collector-contrib 0.161.0 with an OTLP/HTTP receiver,memory_limiter,batch, thedebugexporter, and a Prometheus endpoint carryingservice.name.--env-file deploy/otel/jaeger.envadds Jaeger 2.21.0. All ports bind to 127.0.0.1.scripts/tests/otel-smoke.shsends streamed, blocking, proxied, and WebSocket requests through a scripted upstream. It stops the gateway so it flushes, then checks the Collector'sdebugoutput and Prometheus endpoint. TherustCI job now runs the smoke test and the checker's unit tests.docs/deploying/observability.mdcovers every instrument, attribute value, recording rule, timing anchor, histogram boundary, the cardinality contract, the local Collector, and the overhead table.ARCHITECTURE.mdrecords which pipeline stage owns each telemetry mark, so the refactor: unify synchronous streaming ingestion and item lifecycle #243/refactor: extract StreamRelay and bound client delivery #244 refactors move marks instead of duplicating them.Defaults restated from the #279 design table
These got no reply on #279, so this PR uses the proposed defaults:
agentic.*namespace for project-specific metric and attribute names.Design departures from the #279 plan
first_upstream_data ≤ first_client_event ≤ first_textfor a text response. It also measures what a client sees. Ingestion still owns the classification:StreamEventcarries a typedtextflag set fromSSEEventType::OutputTextDelta.InstrumentedStreammeasures the time between handing a frame to the transport and the transport polling for the next one. That covers HTTP and WebSocket at one boundary with one sample per execution. Boundaries therefore extend to 60 s. A WebSocket response finalizes on its own task, so frames the connection takes after that are not included.gen_ai.request.model,gen_ai.response.model, orgen_ai.provider.name. Per-request-model labels are a feat: request, stage, and delivery metrics with a local Collector example (#279 phase 3) #340 non-goal. The gateway cannot know which provider serves its upstream, so it records no provider name rather than a guessed one. This deviates from the semantic convention's required provider attribute, and the docs say so.toolstage covers Responses gateway calls, one sample per tool call.agentic.execution.activeincludes requests queued behind another on the same stream, and duration includes queue wait.agentic.websocket.queue.wait.durationisolates the wait.Changes in the rebuild
AgentTurn::run_round, which both single-agent and multi-agent rounds use. The file moves the original PR made for size (engine/round.rs,gateway/input.rs,compaction/explicit.rs) are gone.SingleAgentRunandMultiAgentRun, one per execution.MultiAgentRunpasses the execution clock to every round's pipeline.retained.rsgives the root pipeline the clock and wraps its event stream for delivery timing.persist_if_neededtakes the metrics on all four call sites, including the retained one.GatewayScheduler::run_one, the same scope as itsagentic.tool.executespan. Calls refused bymax_tool_callsnever run, so they are not timed. The two timing methods live ingateway/timing.rsto keepgateway.rsunder the size limit.execute_and_emit_output_callstakes&ExecutionContextinstead of a policy and the metrics (clippy::too_many_arguments).StreamFramecarries the typedtextflag on both frame paths (HTTP and retained).messages_stream.rs:open_roundreplacesprime_messages_streamand the later-round branch, as before.MessagesStreamAccumulatorgains a fourth flag, so it takes#[allow(clippy::struct_excessive_bools)]like the other such structs in the repository.RequestPayloadtwice (clippy::large_futures).engine/multi_agent_tests.rs(see the top).ExecutionSpan::startandstart_linkedtake&ExecutorMetrics.Test Plan
web_fetch::http::tests::the_deadline_bounds_a_slow_originis timing-sensitive and fails intermittently on unmodifiedmainunder the full suite; after the rebase it failed in one of two full-suite runs and passes on its own. This PR doesn't touchweb_fetch. Clippy also passes with--features embedded-code-interpreterfor both crates, as in CI.New tests. Every test builds its own in-memory meter provider, so they run in parallel. Every assertion passes through a shared allow-list,
crates/agentic-server-core/tests/execution_metrics/harness.rs, which fails on any instrument, attribute key, or attribute value that isn't listed.agentic-server-core/tests/execution_metrics_test.rs(11 tests, no tracing subscriber installed):text_only/responses/resp-single-gpt-4o-streaming.yaml):first_upstream_data ≤ first_client_event ≤ first_text;usage: null.web_searchtool stage, and one usage sample per upstream call./v1/responses/compacttimes rehydrate, compaction, and persist, and records usage, but no execution.agentic-server/tests/execution_metrics_test.rs(4 tests, production router):response.createplus one failure on one WebSocket gives 4 executions, 4 queue-wait samples, andconnections.active= 1 while open, 0 after close.agentic-server/tests/telemetry_metrics_e2e_test.rsruns the real binary, configured only throughOTEL_*, withOTEL_TRACES_SAMPLER=parentbased_traceidratioandOTEL_TRACES_SAMPLER_ARG=0, exporting to an in-process OTLP stub. It sends HTTP and WebSocket requests, then SIGTERMs the gateway with one HTTP stream and one WebSocket request still in flight:service.name;connections.activeback to 0.multi_agent_execution_records_rounds_and_first_data_once(core unit test). A streamed multi-agent response, a root and two children, records one rounds sample. Its sum equals the number of inference stages across all agents, and first upstream data is recorded once.store: truefails with 500 after #370 #385).scripts/tests/test_otel_fixtures.py(10 tests): the Collector-log checker reads the allow-list from the Rust harness and rejects unlisted keys, values, and instruments.Mutation checks. For the code the rebuild re-placed, each new check fails when its wiring is removed:
The original branch (
9a9b052) had these checks. The tests behind them pass unchanged on the rebuild, but I did not repeat the mutations:ExecutionSpandrop never records execution metricsunwrap_or_default)usage: null; nothing is fabricated"connections.activeOTEL_TRACES_SAMPLER_ARG=1Local Collector.
scripts/tests/otel-smoke.shpasses against the compose stack after the rebase, on five cold Collector starts and a warm rerun: all 15 instruments and 29 data points are allow-listed, with the run'sservice.name. The very first run, which also created the Docker network, reported no metrics even though the Collector logged them a moment later; this did not reproduce. I did not rerun the Jaeger variant after the rebuild. This machine only hasdocker-composev1, so the script falls back to it locally. CI runsdocker composev2.Overhead
Measured on the 2026-10-09 rebuild and not re-measured after the rebase, which changed no instrumented code.
scripts/otel-overhead.py, release builds. Setup:parentbased_always_on) and exports traces and metrics over OTLP/HTTP to the local Collector.Measured again after the rebuild, two runs per build in the same session (second run shown). This branch:
mainat5473e02(traces, no metrics), same benchmark:main, this branch measured 0.01–0.13 ms higher at p50 and 0.01–0.14 ms more CPU per request, with telemetry enabled or disabled.Known gaps and follow-ups
msrv = "1.98.1".