metrics: expose Prometheus exposition at /metrics - #124
Conversation
Wires a Prometheus registry into the router so the org's Prometheus / Victoria Metrics clusters can scrape this service directly, rather than inferring its state from a status page. Uses the `prometheus` crate pinned to the version dfinity/ic already depends on, since these series land in the same estate and two exposition dialects there would be nobody's win. What it reports: request counters and a latency histogram, taken from the facts the request-logging middleware already computed and previously only wrote to a log; the live and active session gauges that /version publishes, read at scrape time because they are derived state; build version and commit as labels on a build_info gauge, so any series can be attributed to an exact commit; process start time under the conventional name, so a redeploy is a step change rather than something to infer; and CPU, resident memory and file descriptors from the crate's process collector, compiled only on Linux since it reads /proc. Label cardinality is the whole design problem, not a detail. Every series is a row Prometheus holds in memory, so a label whose value an outsider picks is a memory-exhaustion primitive — and this service is internet-facing and continuously scanned, with a request log full of paths nobody here wrote. The route label is therefore never the requested path; it is axum's MatchedPath, the route template, which is bounded by the route table by construction and stays correct as routes are added. Unmatched requests have no template and collapse into one shared bucket. A test asserts that property rather than trusting the comment: 500 distinct unmatched paths produce exactly one series. Verified against a running server too, where 25 probe paths stayed one series while real routes kept their templates. Not published to the public internet. The app binds 0.0.0.0:8000, so a scraper reaches /metrics on the host's private address over the VPN — the same path the deploy already uses — and nothing needs exposing to make that work. Caddy returns 404 for the path rather than 403, so it is not advertised as existing. Metrics are not secret, but they are a free operational read (request volumes and error rates per route, session counts, process memory) and a public scrape target is also an amplification lever, since each request makes the process gather and encode its whole registry. The endpoint measures itself as well: a scrape that quietly got slow is how a target starts being dropped for timing out, and the resulting gap looks like an outage that never happened. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ZshwKmjD5fZ4Hs9dS6zh
There was a problem hiding this comment.
🟡 Changes recommended
Arbitrary HTTP methods can still create unbounded metric series, and the cardinality test does not exercise actual requests.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Adds Prometheus observability for HTTP traffic, sessions, build details, and process health.
Changes:
- Adds a Prometheus registry and
/metricsendpoint. - Records request latency/counts and session gauges.
- Blocks public
/metricsaccess through Caddy.
File summaries
| File | Description |
|---|---|
src/metrics.rs |
Defines collectors, exposition, and tests. |
src/main.rs |
Adds metrics routing and request instrumentation. |
deploy/native/Caddyfile |
Returns 404 for public metrics requests. |
Cargo.toml |
Adds the Prometheus dependency. |
Cargo.lock |
Locks Prometheus and transitive dependencies. |
Review details
- Files reviewed: 4/5 changed files
- Comments generated: 2
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Two review findings, both correct, and the first is a clean bypass of the guard this change was built around. The route label was bounded while the method label beside it was not. HTTP permits arbitrary extension method tokens and the public listener forwards them, so a request line reading WIBBLE / HTTP/1.1 minted its own series. Measured before fixing: twelve nonsense methods against a single path produced thirteen counter series and 182 histogram bucket lines, because a histogram multiplies every label set by its bucket count. The same twelve now produce one series and the buckets drop to 65. Methods are allow-listed to the standard nine and the helper returns &'static str, so the bound holds by type rather than by care. Worth naming the pattern rather than just the bug: I bounded the label I had been thinking about and left the one immediately next to it wide open, having written a module doc that presented cardinality as the central design problem. The doc now says the rule applies to every request-influenced label, and notes why status is the one that does not need it — the server chooses it, from a small closed set. The cardinality test was also vacuous, which is the more uncomfortable of the two. It built 500 distinct paths, discarded them, and called the recorder 500 times with a constant. That asserts a pure function is deterministic. It says nothing about the middleware deriving a bounded label from a hostile one, and would have passed unchanged if the middleware started using the raw URI — precisely the regression it existed to prevent. Both cardinality tests now drive real requests through an axum router with the real middleware installed. Confirmed by mutation: making the middleware use the raw path fails the path test and nothing else, and making the method label a pass-through fails the two method tests and nothing else. The old test survived the first of those mutations, which is the whole argument for the rewrite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ZshwKmjD5fZ4Hs9dS6zh
There was a problem hiding this comment.
🟡 Changes recommended
Concurrent scrapes can expose an inconsistent session gauge pair that violates the documented invariant.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
src/metrics.rs:214
liveandactiveare separate collectors and are updated independently while Axum can run another/metricsscrape concurrently. When counts decrease, a concurrentregistry.gather()can observe the newlivevalue and the oldactivevalue, exportingactive > livedespite the metric's stated invariant. Serialize each session-pair update with gathering (or publish both values from one synchronized collector/snapshot).
self.live_sessions.with_label_values(&[instance]).set(live);
self.active_sessions
.with_label_values(&[instance])
.set(active);
- Files reviewed: 4/5 changed files
- Comments generated: 0 new
- Review effort level: Balanced
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Addresses consumer feedback that none of this was reachable: `mod metrics;` lived in main.rs, so the module was private to the bin target and an embedder depending on the crate got nothing. It is `pub mod metrics` in lib.rs now. Split log_request into write_request_metrics and write_request_logs, both exported. They are separated because their constraints genuinely differ rather than for tidiness: metrics must bound every label, while a log line can afford the full request path and is more useful carrying it. Splitting also lets an embedder take either one alone. The log moves from info to debug — it fires on every request including the noise floor of an internet-facing service, so at info it drowns the handful of lines an operator actually wants. Metrics::new now registers into a Registry the caller supplies and keeps none of its own. A host embedding this crate already has a registry and already exposes it; series published into a private one would simply never be seen. Exposition follows the registry, so render() is gone and the binary gathers its own; the scrape-duration signal survives as observe_scrape, recorded by whoever renders. Registering the process collector also stops being the library's business. `process_*` is un-namespaced and describes the whole OS process, which belongs to the embedding application rather than to this crate, and would collide with a host that already has one. It is now an explicit register_process_collector the standalone binary calls, since the binary is the application. The metric prefix is factored into a macro rather than repeated. A macro over a const plus format! keeps the names &'static str and keeps them greppable in full, so searching an alert rule's imcp2_http_requests_total still finds the line that defines it. Fixed rather than caller-configurable on purpose: a metric name identifies the software emitting it, and one dashboard working across every deployment depends on that. Verified from outside the crate, not only by unit tests: a scratch consumer crate registers imcp2's collectors into its own registry alongside its own metric, does its own exposition, and asserts no process_* leaked in. Also confirmed the log line is absent at default level and present under RUST_LOG=imcp2=debug, and that the binary still serves /metrics with the process collector it registers itself. Two library-safety properties the review raised are now pinned by tests rather than assumed: a second Metrics::new against one registry returns AlreadyReg instead of panicking, and Metrics::new registers no process_* series. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ZshwKmjD5fZ4Hs9dS6zh
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 6 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/metrics.rs:221
- This gauge is named
imcp2_process_start_time_secondsbecausemetric!always adds the prefix, so it is not the conventionalprocess_start_time_secondsclaimed here. The binary also immediately registersProcessCollector::for_self(), which already exports the conventional series using the OS process start; this extra gauge instead records a later point after application initialization. Remove the duplicate custom gauge/constructor parameter and use the process collector's series, or rename and document it as an application-ready timestamp.
let start_time = IntGauge::new(
metric!("process_start_time_seconds"),
"Unix epoch seconds at which this process started, i.e. when the deployment \
last restarted. Every deploy restarts the service.",
)?;
registry.register(Box::new(start_time.clone()))?;
start_time.set(started_at as i64);
src/metrics.rs:245
observe_requestis public for embedders, but it writesmethoddirectly as a label. Only the provided middleware callsmethod_label, so a host using this recording API with a request method can reintroduce the unbounded attacker-controlled cardinality that this module promises to prevent. Normalize the method inside this method; the middleware's existing normalization is harmless if retained.
pub fn observe_request(&self, route: &str, method: &str, status: u16, elapsed_secs: f64) {
src/metrics.rs:351
- This changes the existing request log from
infotodebug. Bothdeploy/native/imcp2.serviceand the Docker image default toRUST_LOG=info, so deployed request lines—including the full paths that metrics intentionally discard—will disappear, contrary to the PR's claim that the existing log remains. Preserve theinfolevel, or update the runtime filters to enableimcp2::metrics=debugand document the behavior change.
tracing::debug!(%method, %path, status, elapsed_ms, "http request");
… deployed request log Three findings from review of the library move, all real. observe_request was public and took route and method as raw &str, writing both straight in as labels. Only the middleware normalised them, so the bound this module exists to enforce lived one direct call away from being bypassed — and an embedder reaching for the obvious-looking recording API would have bypassed it without noticing. That hole did not exist before the move, because nothing outside the binary could call it; making the module public created it. It is pub(crate) now, with the middleware as the supported entry point, and it normalises the method itself so the invariant holds at the recording site rather than depending on every caller. Reducing the surface now is also cheap in a way it will not be later: taking a public function away after release is a break. The process-start gauge claimed a conventional name it does not have. The prefix macro makes it imcp2_process_start_time_seconds, while the process collector the binary registers exports the actual conventional process_start_time_seconds — and the two measure different moments, observed three seconds apart on a real run: the collector reports OS process start, this reports when the server finished initialising and began serving, which is when a redeploy becomes visible to clients. Both are worth having and an embedder gets only ours, since the library does not register the collector. The comment now says that instead of the opposite. Moving the request log to debug would have silently deleted it in production. Both imcp2.service and the Dockerfile pin RUST_LOG=info, so the line that records what external clients actually probe would simply have stopped appearing on deployed hosts — the full paths the metrics deliberately discard, gone from the one place that keeps them. Both now set info,imcp2::metrics=debug: quiet by default for embedders and local runs, which is what moving it to debug was for, while the deployed hosts keep the record. Verified both filters against a running server: 0 lines under plain info, present with the target enabled. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01R8ZshwKmjD5fZ4Hs9dS6zh
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 8 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/metrics.rs:226
- This metric is named and documented as a process start time, but the bundled binary passes a timestamp captured only after agent/server construction and session-reaper startup (
src/main.rs:233-273); the surrounding comment here instead calls it the time serving began, which is also later than that timestamp. Consumers computing process uptime will therefore get a value with the wrong semantics. Either capture the actual process/service start at the beginning ofmain, or rename and document the metric to match the event being recorded.
let start_time = IntGauge::new(
metric!("process_start_time_seconds"),
"Unix epoch seconds at which this process started, i.e. when the deployment \
last restarted. Every deploy restarts the service.",
)?;
src/metrics.rs:151
- Registration is not atomic across this constructor. If an embedder's registry already contains a later name such as
imcp2_live_sessions, this call first registers the request collectors and then returnsAlreadyReg, leaving inaccessible collectors behind in the caller-owned registry and preventing a clean retry or fallback. Roll back every collector registered by this invocation when any subsequent registration fails (and add a test where the collision occurs after the first collector).
registry.register(Box::new(requests.clone()))?;
src/main.rs:413
- The scrape timer starts only after both
session_gauges()calls, even though each call scans the full session map and is part of producing the scrape. If those scans become the source of scrape latency or timeouts,imcp2_metrics_scrape_duration_secondswill stay low and miss exactly the degradation it is intended to expose. Start the timer at the beginning of the handler, before refreshing the gauges.
let started = std::time::Instant::now();
Summary
Prometheus instrumentation for this crate, usable from the library and not only from the bundled binary, plus a
GET /metricsexposition in that binary.Pinned to
prometheus = "0.14"with theprocessfeature — the version and featuresdfinity/icalready depends on, so these series land in the same estate without a second exposition dialect.Related issues
Complements the hosted status dashboard and #113's off-host probe rather than replacing either — those answer "is it up" from outside; this answers "what is it doing" from inside.
Changes
src/metrics.rs— collectors, label bounds, the two middlewares, tests.src/lib.rs—pub mod metrics.src/main.rs— owns aRegistry, applies the two layers, serves/metrics.deploy/native/Caddyfile— the path is not published publicly.Cargo.toml/Cargo.lock— the dependency.Built for embedders, after consumer feedback
The first version of this put everything in
main.rs, which made it invisible to anyone depending onimcp2as a library —mod metrics;in a bin target is private to that target. Four changes came out of that feedback:pub mod metrics, exposingMetrics, both middlewares,route_label,method_label,register_process_collector.log_requestsplit intowrite_request_metricsandwrite_request_logs. Their constraints genuinely differ — metrics must bound every label, while a log line can afford the full path and is more useful carrying it — so one combined layer forced the stricter rule on both. Split, an embedder can also take either alone.infotodebug. It fires on every request including the noise floor of an internet-facing service; atinfoit drowns the handful of lines an operator wants.Metrics::newborrows the caller'sRegistryrather than owning one. A host embedding this crate already has a registry and already exposes it; series published into a private one would never be seen.Two consequences of that last one, both deliberate:
render()is gone. Exposition follows the registry, so the binary gathers its own. The scrape-duration signal survives asobserve_scrape, recorded by whoever renders.ProcessCollector.process_*is un-namespaced and describes the whole OS process — the embedding application's process, not this crate's — and would collide with a host that already has one. It is now an explicitregister_process_collectorthat the standalone binary calls, because the binary is the application.The metric prefix is also factored into a
concat!macro rather than repeated seven times. Chosen overconst+format!because it keeps the names&'static strand greppable in full: searching an alert rule'simcp2_http_requests_totalstill lands on the defining line. Deliberately fixed rather than caller-configurable — a metric name identifies the software emitting it, and one dashboard working across every deployment depends on that.Label cardinality is the design problem
Every series is a row Prometheus holds in memory, so a label whose value an outsider picks is a memory-exhaustion primitive. Two labels are outsider-chosen and both are bounded:
routeis axum'sMatchedPath(the route template, never the requested path), andmethodis allow-listed to the standard nine, since HTTP permits arbitrary extension tokens.statusneeds no bound — the server chooses it from a small closed set.Testing
cargo test— 178 pass;cargo clippy --all-targets— 0 errors.imcp2registers its own metric andimcp2's into one caller-ownedRegistry, does its own exposition, and asserts noprocess_*leaked in. That is the thing that was impossible before, and it is checked rather than claimed.http requestlines at defaultRUST_LOG, present underRUST_LOG=imcp2=debug.MatchedPathfails the path test and nothing else;method_labelas pass-through fails the two method tests and nothing else.Metrics::newagainst one registry returnsAlreadyRegrather than panicking, andMetrics::newregisters noprocess_*./metricsserves, 32imcp2_*series, process collector present because the binary registers it.Known limitation, not addressed here
nest_service("/mcp", …)means axum omitsMatchedPathfor everything below the mount — verified against a running server:So all 26 MCP tools and all five OAuth endpoints currently share one label with scanner noise. The HTTP middleware structurally cannot see inside the mount, which is the real argument for instrumenting
McpServeritself — deliberately left to a follow-up rather than expanding this change.Open question for the reviewer
Does the scraper have a network path to the host's private address on
:8000? I could not verify that from here. If not, a blackbox probe of the already-public/status/api/statusneeds no path at all — and this endpoint stays useful for embedders and local debugging regardless.Generated by Claude Code