Skip to content

metrics: expose Prometheus exposition at /metrics - #124

Open
aterga wants to merge 4 commits into
mainfrom
claude/prometheus-metrics
Open

metrics: expose Prometheus exposition at /metrics#124
aterga wants to merge 4 commits into
mainfrom
claude/prometheus-metrics

Conversation

@aterga

@aterga aterga commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

Prometheus instrumentation for this crate, usable from the library and not only from the bundled binary, plus a GET /metrics exposition in that binary.

Pinned to prometheus = "0.14" with the process feature — the version and features dfinity/ic already 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.rspub mod metrics.
  • src/main.rs — owns a Registry, 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 on imcp2 as a library — mod metrics; in a bin target is private to that target. Four changes came out of that feedback:

  • Exported from the crate. pub mod metrics, exposing Metrics, both middlewares, route_label, method_label, register_process_collector.
  • log_request split into write_request_metrics and write_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.
  • The log moved from info to debug. It fires on every request including the noise floor of an internet-facing service; at info it drowns the handful of lines an operator wants.
  • Metrics::new borrows the caller's Registry rather 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 as observe_scrape, recorded by whoever renders.
  • The library no longer registers 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 explicit register_process_collector that 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 over const + format! because it keeps the names &'static str and greppable in full: searching an alert rule's imcp2_http_requests_total still 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: route is axum's MatchedPath (the route template, never the requested path), and method is allow-listed to the standard nine, since HTTP permits arbitrary extension tokens. status needs no bound — the server chooses it from a small closed set.

Testing

  • cargo test178 pass; cargo clippy --all-targets — 0 errors.
  • Proven usable from outside the crate. A scratch consumer crate depending on imcp2 registers its own metric and imcp2's into one caller-owned Registry, does its own exposition, and asserts no process_* leaked in. That is the thing that was impossible before, and it is checked rather than claimed.
  • Log level verified in both directions: 0 http request lines at default RUST_LOG, present under RUST_LOG=imcp2=debug.
  • Cardinality bounds tested end to end through a router with the real middleware — not by calling the recorder with a pre-computed label, which only proves the recorder is deterministic. Confirmed by mutation: raw path instead of MatchedPath fails the path test and nothing else; method_label as pass-through fails the two method tests and nothing else.
  • The method test also pins the histogram bucket count, where a cardinality bug does ~14× the damage a counter shows.
  • Two library-safety properties pinned by tests: a second Metrics::new against one registry returns AlreadyReg rather than panicking, and Metrics::new registers no process_*.
  • Binary re-verified live: /metrics serves, 32 imcp2_* series, process collector present because the binary registers it.

Known limitation, not addressed here

nest_service("/mcp", …) means axum omits MatchedPath for everything below the mount — verified against a running server:

/version              → route="/version"
/mcp/oauth/token      → route="other"
/mcp/oauth/authorize  → route="other"
/wp-login.php         → route="other"

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 McpServer itself — 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/status needs no path at all — and this endpoint stays useful for embedders and local debugging regardless.


Generated by Claude Code

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
@aterga
aterga requested a balanced review from Copilot August 5, 2026 19:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 /metrics endpoint.
  • Records request latency/counts and session gauges.
  • Blocks public /metrics access 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.

Comment thread src/main.rs Outdated
Comment thread src/metrics.rs Outdated
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
@aterga
aterga marked this pull request as ready for review August 6, 2026 13:46
@aterga
aterga requested review from a team and Copilot August 6, 2026 13:46

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • live and active are separate collectors and are updated independently while Axum can run another /metrics scrape concurrently. When counts decrease, a concurrent registry.gather() can observe the new live value and the old active value, exporting active > live despite 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.

Comment thread src/metrics.rs Outdated
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
Copilot AI review requested due to automatic review settings August 7, 2026 13:56

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_seconds because metric! always adds the prefix, so it is not the conventional process_start_time_seconds claimed here. The binary also immediately registers ProcessCollector::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_request is public for embedders, but it writes method directly as a label. Only the provided middleware calls method_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 info to debug. Both deploy/native/imcp2.service and the Docker image default to RUST_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 the info level, or update the runtime filters to enable imcp2::metrics=debug and document the behavior change.
    tracing::debug!(%method, %path, status, elapsed_ms, "http request");

sea-snake
sea-snake previously approved these changes Aug 7, 2026
… 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
Copilot AI review requested due to automatic review settings August 7, 2026 15:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 of main, 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 returns AlreadyReg, 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_seconds will 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();

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants