From a932c49ca8a37f218093f89728e8920460099235 Mon Sep 17 00:00:00 2001 From: tipogi Date: Thu, 20 Aug 2026 07:34:12 +0200 Subject: [PATCH 1/5] feat: add HTTP RED metrics and bound label cardinality in webapi --- .../src/routes/middlewares/tracing.rs | 202 ++++++++++++++++-- nexus-webapi/src/routes/mod.rs | 2 + 2 files changed, 188 insertions(+), 16 deletions(-) diff --git a/nexus-webapi/src/routes/middlewares/tracing.rs b/nexus-webapi/src/routes/middlewares/tracing.rs index 918181751..7e00feed4 100644 --- a/nexus-webapi/src/routes/middlewares/tracing.rs +++ b/nexus-webapi/src/routes/middlewares/tracing.rs @@ -1,41 +1,211 @@ +//! HTTP tracing and metrics middleware. +//! +//! Incoming W3C `traceparent` is intentionally ignored. This is a public +//! unauthenticated API, so clients must not inject trace context; each request +//! starts a new root span via the tracing subscriber. + +use std::{sync::LazyLock, time::Instant}; + use axum::{ extract::{MatchedPath, Request}, + http::Method, middleware::Next, response::Response, }; - +use opentelemetry::metrics::{Counter, Histogram}; +use opentelemetry::{global, KeyValue}; use tracing::Instrument; -// middleware for tracing +const METER_NAME: &str = "nexus"; + +/// HTTP request instruments. No-ops until an `SdkMeterProvider` is installed. +/// +/// `requests` duplicates the histogram's count (same attributes, same call +/// site) but is kept for cheaper PromQL. `errors` is the availability SLI +/// (see [`is_failed_request`]). `duration` runs until the handler returns a +/// `Response` — headers ready, not end-of-body. +struct HttpMetrics { + requests: Counter, + errors: Counter, + duration: Histogram, +} + +impl HttpMetrics { + fn new() -> Self { + let meter = global::meter(METER_NAME); + Self { + requests: meter + .u64_counter("http.server.requests") + .with_description( + "Total HTTP requests handled by Nexus, by method/route/status", + ) + .build(), + errors: meter + .u64_counter("http.server.errors") + .with_description( + "HTTP requests that failed from the server's perspective (5xx and 408 timeouts), by method/route/status", + ) + .build(), + duration: meter + .f64_histogram("http.server.request.duration") + .with_description("Duration of HTTP server requests") + .with_unit("s") + // Explicit buckets from the HTTP semantic conventions (seconds). + .with_boundaries(vec![ + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, + 10.0, + ]) + .build(), + } + } +} + +/// Lazy on purpose: instruments bind to whatever meter provider is global at +/// creation time, and stay no-ops forever if that is still the default one. +/// First use is the first HTTP request, safely after `setup_metrics`. +static METRICS: LazyLock = LazyLock::new(HttpMetrics::new); + +/// Availability failure: 5xx, plus 408 because it times out *our* work. +/// +/// Other 4xx (bad input, missing entities, blacklist 403, oversized 413, +/// rate-limit 429) are ordinary on a public unauthenticated API. They stay on +/// `http.server.requests` via `http.response.status_code` without polluting +/// the error rate — same policy as `Error` logging in `error.rs`. +fn is_failed_request(status: u16) -> bool { + matches!(status, 408 | 500..=599) +} + +/// Low-cardinality route template, or `unmatched`. Never the raw URI: +/// `/v0/user/` would explode series cardinality on scanner 404s. +/// +/// `MatchedPath` is only set for middleware on the router that declared the +/// routes (see `build_app`). A nested copy of this layer would see `unmatched`. +fn http_route(request: &Request) -> String { + request + .extensions() + .get::() + .map(|pattern| pattern.as_str().to_string()) + .unwrap_or_else(|| "unmatched".to_string()) +} + +/// Known methods per OTel semconv, `_OTHER` for the rest. Clients can send +/// arbitrary extension methods, which would otherwise mint unbounded metric +/// series. The raw value goes on the span as `http.request.method_original`. +fn http_method(method: &Method) -> &'static str { + match *method { + Method::GET => "GET", + Method::HEAD => "HEAD", + Method::POST => "POST", + Method::PUT => "PUT", + Method::DELETE => "DELETE", + Method::CONNECT => "CONNECT", + Method::OPTIONS => "OPTIONS", + Method::TRACE => "TRACE", + Method::PATCH => "PATCH", + _ => "_OTHER", + } +} + +fn record_http_request(method: &str, route: &str, status: u16, elapsed_secs: f64) { + let attrs = [ + KeyValue::new("http.request.method", method.to_string()), + KeyValue::new("http.route", route.to_string()), + KeyValue::new("http.response.status_code", i64::from(status)), + ]; + METRICS.requests.add(1, &attrs); + METRICS.duration.record(elapsed_secs, &attrs); + if is_failed_request(status) { + METRICS.errors.add(1, &attrs); + } +} + +/// Root span plus request/error/duration metrics for every request. pub async fn tracing_middleware(request: Request, next: Next) -> Response { - let route = request.uri().path().to_string(); - let route_pattern = request.extensions().get::(); - let span_name = match route_pattern { - Some(pattern) => pattern.as_str().to_string(), - _ => route.clone(), - }; - let query = request.uri().query().unwrap_or("").to_string(); - let method = request.method().to_string(); + let route = http_route(&request); + let method = http_method(request.method()); + // Query strings are deliberately not recorded: they carry search text, + // pubkeys, and filter values, with no cardinality bound in Tempo. let span = tracing::info_span!( "http.request", - otel.name = %span_name, + // Semconv wants a bare `{method}` when no route matched; we keep + // `unmatched` so scanner 404s stay searchable in Tempo. + otel.name = %format!("{method} {route}"), http.request.method = %method, + http.request.method_original = tracing::field::Empty, http.route = %route, - http.query = %query, http.response.status_code = tracing::field::Empty, otel.status_code = tracing::field::Empty, otel.status_message = tracing::field::Empty, ); + if method == "_OTHER" { + span.record( + "http.request.method_original", + request.method().to_string(), + ); + } + let started = Instant::now(); let response = next.run(request).instrument(span.clone()).await; - let status = response.status().as_u16(); - span.record("http.response.status_code", status); - if (500..=599).contains(&status) { + let status = response.status(); + span.record("http.response.status_code", status.as_u16()); + if is_failed_request(status.as_u16()) { span.record("otel.status_code", "ERROR"); - span.record("otel.status_message", "Internal Server Error"); + span.record( + "otel.status_message", + status.canonical_reason().unwrap_or("error"), + ); } + record_http_request( + method, + &route, + status.as_u16(), + started.elapsed().as_secs_f64(), + ); + response } + +#[cfg(test)] +mod tests { + use super::{http_method, http_route, is_failed_request, Method, Request}; + use axum::body::Body; + + // The matched case can't be unit-tested: `MatchedPath` has no public + // constructor and only exists inside a real router. + #[test] + fn route_without_matched_path_is_unmatched() { + let request = Request::builder() + .uri("/wp-admin") + .body(Body::empty()) + .unwrap(); + assert_eq!(http_route(&request), "unmatched"); + } + + #[test] + fn extension_methods_collapse_to_other() { + assert_eq!(http_method(&Method::GET), "GET"); + assert_eq!(http_method(&Method::PATCH), "PATCH"); + + let custom = Method::from_bytes(b"PROPFIND").unwrap(); + assert_eq!(http_method(&custom), "_OTHER"); + } + + #[test] + fn failed_request_is_5xx_and_timeout_only() { + assert!(is_failed_request(500)); + assert!(is_failed_request(502)); + assert!(is_failed_request(503)); + assert!(is_failed_request(408)); + + assert!(!is_failed_request(200)); + assert!(!is_failed_request(204)); + assert!(!is_failed_request(400)); + assert!(!is_failed_request(403)); + assert!(!is_failed_request(404)); + assert!(!is_failed_request(413)); + assert!(!is_failed_request(429)); + } +} diff --git a/nexus-webapi/src/routes/mod.rs b/nexus-webapi/src/routes/mod.rs index ba2524c0f..476d3b540 100644 --- a/nexus-webapi/src/routes/mod.rs +++ b/nexus-webapi/src/routes/mod.rs @@ -168,6 +168,8 @@ pub fn build_app( Duration::from_secs(request_timeout_secs.max(1)), )) .layer(cors) + // Must stay on this outer router: `MatchedPath` is only populated for + // middleware layered here, not if a nested sub-router copies this layer. .layer(axum::middleware::from_fn( middlewares::tracing::tracing_middleware, )) From a36df273d4fb5c008d5980411f179a41bfcc4271 Mon Sep 17 00:00:00 2001 From: tipogi Date: Thu, 20 Aug 2026 08:22:23 +0200 Subject: [PATCH 2/5] feat: add neo4j.query.requests counter for a first-class query total --- nexus-common/src/db/graph/instrumented.rs | 9 +++++++++ nexus-webapi/src/routes/middlewares/tracing.rs | 13 +++---------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/nexus-common/src/db/graph/instrumented.rs b/nexus-common/src/db/graph/instrumented.rs index c3e409df1..ba17c44db 100644 --- a/nexus-common/src/db/graph/instrumented.rs +++ b/nexus-common/src/db/graph/instrumented.rs @@ -33,6 +33,8 @@ struct GraphMetrics { execute_duration: Histogram, /// Number of rows returned by a query. rows: Histogram, + /// Query completions (stream drop, execute failure, or run); error-rate denominator. + requests: Counter, /// Incremented on every failed `execute()` or `run()` call. errors: Counter, /// Incremented when a query's total duration exceeds the slow-query threshold. @@ -73,6 +75,10 @@ impl GraphMetrics { .with_description("Number of rows returned per Neo4j query") .with_unit("{row}") .build(), + requests: meter + .u64_counter("neo4j.query.requests") + .with_description("Total Neo4j query executions, by query label") + .build(), errors: meter .u64_counter("neo4j.query.errors") .with_description("Total number of failed Neo4j query executions") @@ -155,6 +161,7 @@ impl Drop for InstrumentedStream { let attrs: &[KeyValue] = &query_attrs(self.label); // Always record metrics (no-op when OTLP is not configured). + self.metrics.requests.add(1, attrs); self.metrics.duration.record(ms(total), attrs); self.metrics .execute_duration @@ -302,6 +309,7 @@ impl GraphOps for InstrumentedGraph { } Err(e) => { let attrs: &[KeyValue] = &query_attrs(label); + self.metrics.requests.add(1, attrs); self.metrics.duration.record(ms(execute_duration), attrs); self.metrics .execute_duration @@ -339,6 +347,7 @@ impl GraphOps for InstrumentedGraph { let elapsed = start.elapsed(); let attrs: &[KeyValue] = &query_attrs(label); + self.metrics.requests.add(1, attrs); self.metrics.duration.record(ms(elapsed), attrs); self.metrics.execute_duration.record(ms(elapsed), attrs); diff --git a/nexus-webapi/src/routes/middlewares/tracing.rs b/nexus-webapi/src/routes/middlewares/tracing.rs index 7e00feed4..0da8a9359 100644 --- a/nexus-webapi/src/routes/middlewares/tracing.rs +++ b/nexus-webapi/src/routes/middlewares/tracing.rs @@ -18,12 +18,8 @@ use tracing::Instrument; const METER_NAME: &str = "nexus"; -/// HTTP request instruments. No-ops until an `SdkMeterProvider` is installed. -/// -/// `requests` duplicates the histogram's count (same attributes, same call -/// site) but is kept for cheaper PromQL. `errors` is the availability SLI -/// (see [`is_failed_request`]). `duration` runs until the handler returns a -/// `Response` — headers ready, not end-of-body. +/// Request total, availability failures, and time until response headers are ready. +/// Instruments are no-ops until an `SdkMeterProvider` is installed. struct HttpMetrics { requests: Counter, errors: Counter, @@ -139,10 +135,7 @@ pub async fn tracing_middleware(request: Request, next: Next) -> Response { otel.status_message = tracing::field::Empty, ); if method == "_OTHER" { - span.record( - "http.request.method_original", - request.method().to_string(), - ); + span.record("http.request.method_original", request.method().to_string()); } let started = Instant::now(); From 4c48f15550202ec67f94667a21e3d91346cf5764 Mon Sep 17 00:00:00 2001 From: tipogi Date: Thu, 20 Aug 2026 08:59:42 +0200 Subject: [PATCH 3/5] feat: split reach-scoped Neo4j query labels by reach and WoT depth --- nexus-common/src/db/graph/instrumented.rs | 2 +- nexus-common/src/db/graph/queries/get.rs | 78 ++++++++++++++++--- .../src/routes/middlewares/tracing.rs | 44 ++++------- nexus-webapi/src/routes/mod.rs | 3 +- 4 files changed, 81 insertions(+), 46 deletions(-) diff --git a/nexus-common/src/db/graph/instrumented.rs b/nexus-common/src/db/graph/instrumented.rs index ba17c44db..bfca91c1f 100644 --- a/nexus-common/src/db/graph/instrumented.rs +++ b/nexus-common/src/db/graph/instrumented.rs @@ -33,7 +33,7 @@ struct GraphMetrics { execute_duration: Histogram, /// Number of rows returned by a query. rows: Histogram, - /// Query completions (stream drop, execute failure, or run); error-rate denominator. + /// Total queries (error-rate denominator). Same sites as `duration`. requests: Counter, /// Incremented on every failed `execute()` or `run()` call. errors: Counter, diff --git a/nexus-common/src/db/graph/queries/get.rs b/nexus-common/src/db/graph/queries/get.rs index 8c3a03624..a9f665412 100644 --- a/nexus-common/src/db/graph/queries/get.rs +++ b/nexus-common/src/db/graph/queries/get.rs @@ -758,6 +758,25 @@ pub fn get_tags() -> Query { ) } +/// Metric label `{base}_{reach}` / `{base}_wot_{depth}`. Depth lives here +/// (unlike `post_stream_wot`, these queries have no companion meter). A new +/// WoT depth must get its own arm — never reuse an existing label. +macro_rules! reach_query_label { + ($base:literal, $reach:expr) => { + match $reach { + StreamReach::Followers => concat!($base, "_followers"), + StreamReach::Following => concat!($base, "_following"), + StreamReach::Friends => concat!($base, "_friends"), + StreamReach::Wot(depth) => match depth.get() { + 1 => concat!($base, "_wot_1"), + 2 => concat!($base, "_wot_2"), + 3 => concat!($base, "_wot_3"), + other => unreachable!("WotDepth out of range: {other}"), + }, + } + }; +} + pub fn get_tag_taggers_by_reach( label: &str, user_id: &str, @@ -785,11 +804,14 @@ pub fn get_tag_taggers_by_reach( ", stream_reach_to_graph_subquery(&reach) ); - Query::new("get_tag_taggers_by_reach", &cypher) - .param("label", label) - .param("user_id", user_id) - .param("skip", skip as i64) - .param("limit", limit as i64) + Query::new( + reach_query_label!("get_tag_taggers_by_reach", reach), + &cypher, + ) + .param("label", label) + .param("user_id", user_id) + .param("skip", skip as i64) + .param("limit", limit as i64) } pub fn get_hot_tags_by_reach( @@ -827,7 +849,7 @@ pub fn get_hot_tags_by_reach( input_tagged_type, tags_query.taggers_limit ); - Query::new("get_hot_tags_by_reach", &cypher) + Query::new(reach_query_label!("get_hot_tags_by_reach", reach), &cypher) .param("user_id", user_id) .param("skip", tags_query.skip as i64) .param("limit", tags_query.limit as i64) @@ -911,12 +933,15 @@ pub fn get_influencers_by_reach( ", stream_reach_to_graph_subquery(&reach), ); - Query::new("get_influencers_by_reach", &cypher) - .param("user_id", user_id) - .param("skip", skip as i64) - .param("limit", limit as i64) - .param("from", from) - .param("to", to) + Query::new( + reach_query_label!("get_influencers_by_reach", reach), + &cypher, + ) + .param("user_id", user_id) + .param("skip", skip as i64) + .param("limit", limit as i64) + .param("from", from) + .param("to", to) } pub fn get_global_influencers(skip: usize, limit: usize, timeframe: &Timeframe) -> Query { @@ -1480,6 +1505,35 @@ mod tests { .to_cypher_populated() } + #[test] + fn reach_queries_split_label_by_reach_and_depth() { + let cases = [ + (StreamReach::Followers, "get_influencers_by_reach_followers"), + (StreamReach::Following, "get_influencers_by_reach_following"), + (StreamReach::Friends, "get_influencers_by_reach_friends"), + ( + StreamReach::Wot(WotDepth::new(1).unwrap()), + "get_influencers_by_reach_wot_1", + ), + ( + StreamReach::Wot(WotDepth::new(3).unwrap()), + "get_influencers_by_reach_wot_3", + ), + ]; + for (reach, expected) in cases { + let query = get_influencers_by_reach("user", reach, 0, 10, &Timeframe::AllTime); + assert_eq!(query.label(), Some(expected)); + } + + let taggers = + get_tag_taggers_by_reach("tag", "user", StreamReach::Wot(WotDepth::default()), 0, 10); + assert_eq!(taggers.label(), Some("get_tag_taggers_by_reach_wot_2")); + + let hot_tags_input = HotTagsInputDTO::new(Timeframe::AllTime, 10, 0, 5, None); + let hot_tags = get_hot_tags_by_reach("user", StreamReach::Friends, &hot_tags_input); + assert_eq!(hot_tags.label(), Some("get_hot_tags_by_reach_friends")); + } + /// The trust traversal must bind and dedupe authors before the posts MATCH. /// A CALL subquery runs once per incoming row (the planner cannot hoist it) /// and a variable-length traversal yields one row per path, so either one diff --git a/nexus-webapi/src/routes/middlewares/tracing.rs b/nexus-webapi/src/routes/middlewares/tracing.rs index 0da8a9359..e81a13443 100644 --- a/nexus-webapi/src/routes/middlewares/tracing.rs +++ b/nexus-webapi/src/routes/middlewares/tracing.rs @@ -56,26 +56,16 @@ impl HttpMetrics { } } -/// Lazy on purpose: instruments bind to whatever meter provider is global at -/// creation time, and stay no-ops forever if that is still the default one. -/// First use is the first HTTP request, safely after `setup_metrics`. +/// Bound after `setup_metrics`; no-ops if OTLP was never configured. static METRICS: LazyLock = LazyLock::new(HttpMetrics::new); -/// Availability failure: 5xx, plus 408 because it times out *our* work. -/// -/// Other 4xx (bad input, missing entities, blacklist 403, oversized 413, -/// rate-limit 429) are ordinary on a public unauthenticated API. They stay on -/// `http.server.requests` via `http.response.status_code` without polluting -/// the error rate — same policy as `Error` logging in `error.rs`. +/// 5xx, plus 408 (our timeout). Other 4xx stay on `requests` by status code. fn is_failed_request(status: u16) -> bool { matches!(status, 408 | 500..=599) } -/// Low-cardinality route template, or `unmatched`. Never the raw URI: -/// `/v0/user/` would explode series cardinality on scanner 404s. -/// -/// `MatchedPath` is only set for middleware on the router that declared the -/// routes (see `build_app`). A nested copy of this layer would see `unmatched`. +/// Matched route template, or `unmatched`. Never the raw URI (cardinality). +/// Only set when this middleware sits on the router that declared the routes. fn http_route(request: &Request) -> String { request .extensions() @@ -84,9 +74,7 @@ fn http_route(request: &Request) -> String { .unwrap_or_else(|| "unmatched".to_string()) } -/// Known methods per OTel semconv, `_OTHER` for the rest. Clients can send -/// arbitrary extension methods, which would otherwise mint unbounded metric -/// series. The raw value goes on the span as `http.request.method_original`. +/// Semconv well-known methods; anything else is `_OTHER` (raw value on the span). fn http_method(method: &Method) -> &'static str { match *method { Method::GET => "GET", @@ -115,17 +103,15 @@ fn record_http_request(method: &str, route: &str, status: u16, elapsed_secs: f64 } } -/// Root span plus request/error/duration metrics for every request. pub async fn tracing_middleware(request: Request, next: Next) -> Response { let route = http_route(&request); let method = http_method(request.method()); - // Query strings are deliberately not recorded: they carry search text, - // pubkeys, and filter values, with no cardinality bound in Tempo. + // No query string: search text / pubkeys / filters, unbounded in Tempo. let span = tracing::info_span!( "http.request", - // Semconv wants a bare `{method}` when no route matched; we keep - // `unmatched` so scanner 404s stay searchable in Tempo. + // Semconv would use a bare `{method}` when unmatched; keep the token + // so scanner 404s stay searchable. otel.name = %format!("{method} {route}"), http.request.method = %method, http.request.method_original = tracing::field::Empty, @@ -135,15 +121,16 @@ pub async fn tracing_middleware(request: Request, next: Next) -> Response { otel.status_message = tracing::field::Empty, ); if method == "_OTHER" { - span.record("http.request.method_original", request.method().to_string()); + span.record("http.request.method_original", request.method().as_str()); } let started = Instant::now(); let response = next.run(request).instrument(span.clone()).await; let status = response.status(); - span.record("http.response.status_code", status.as_u16()); - if is_failed_request(status.as_u16()) { + let status_code = status.as_u16(); + span.record("http.response.status_code", status_code); + if is_failed_request(status_code) { span.record("otel.status_code", "ERROR"); span.record( "otel.status_message", @@ -151,12 +138,7 @@ pub async fn tracing_middleware(request: Request, next: Next) -> Response { ); } - record_http_request( - method, - &route, - status.as_u16(), - started.elapsed().as_secs_f64(), - ); + record_http_request(method, &route, status_code, started.elapsed().as_secs_f64()); response } diff --git a/nexus-webapi/src/routes/mod.rs b/nexus-webapi/src/routes/mod.rs index 476d3b540..cdf723ef6 100644 --- a/nexus-webapi/src/routes/mod.rs +++ b/nexus-webapi/src/routes/mod.rs @@ -168,8 +168,7 @@ pub fn build_app( Duration::from_secs(request_timeout_secs.max(1)), )) .layer(cors) - // Must stay on this outer router: `MatchedPath` is only populated for - // middleware layered here, not if a nested sub-router copies this layer. + // Outer router only: a nested copy of this layer would see `unmatched`. .layer(axum::middleware::from_fn( middlewares::tracing::tracing_middleware, )) From 74717389705ab964ae89487efaf437a3e9e1404e Mon Sep 17 00:00:00 2001 From: tipogi Date: Fri, 21 Aug 2026 09:37:04 +0200 Subject: [PATCH 4/5] chore: drop http.server.errors; status on requests is enough --- .../src/routes/middlewares/tracing.rs | 21 ++++--------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/nexus-webapi/src/routes/middlewares/tracing.rs b/nexus-webapi/src/routes/middlewares/tracing.rs index e81a13443..6842ef062 100644 --- a/nexus-webapi/src/routes/middlewares/tracing.rs +++ b/nexus-webapi/src/routes/middlewares/tracing.rs @@ -18,11 +18,10 @@ use tracing::Instrument; const METER_NAME: &str = "nexus"; -/// Request total, availability failures, and time until response headers are ready. +/// Request count and time until response headers are ready. /// Instruments are no-ops until an `SdkMeterProvider` is installed. struct HttpMetrics { requests: Counter, - errors: Counter, duration: Histogram, } @@ -32,15 +31,7 @@ impl HttpMetrics { Self { requests: meter .u64_counter("http.server.requests") - .with_description( - "Total HTTP requests handled by Nexus, by method/route/status", - ) - .build(), - errors: meter - .u64_counter("http.server.errors") - .with_description( - "HTTP requests that failed from the server's perspective (5xx and 408 timeouts), by method/route/status", - ) + .with_description("Total HTTP requests handled by Nexus, by method/route/status") .build(), duration: meter .f64_histogram("http.server.request.duration") @@ -48,8 +39,7 @@ impl HttpMetrics { .with_unit("s") // Explicit buckets from the HTTP semantic conventions (seconds). .with_boundaries(vec![ - 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, - 10.0, + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1.0, 2.5, 5.0, 7.5, 10.0, ]) .build(), } @@ -59,7 +49,7 @@ impl HttpMetrics { /// Bound after `setup_metrics`; no-ops if OTLP was never configured. static METRICS: LazyLock = LazyLock::new(HttpMetrics::new); -/// 5xx, plus 408 (our timeout). Other 4xx stay on `requests` by status code. +/// 5xx, plus 408 (our timeout). Used to mark the span ERROR; metrics use status labels. fn is_failed_request(status: u16) -> bool { matches!(status, 408 | 500..=599) } @@ -98,9 +88,6 @@ fn record_http_request(method: &str, route: &str, status: u16, elapsed_secs: f64 ]; METRICS.requests.add(1, &attrs); METRICS.duration.record(elapsed_secs, &attrs); - if is_failed_request(status) { - METRICS.errors.add(1, &attrs); - } } pub async fn tracing_middleware(request: Request, next: Next) -> Response { From e435f2e7f904cf45fca840f2bd5af26b51d63fa9 Mon Sep 17 00:00:00 2001 From: tipogi Date: Tue, 25 Aug 2026 13:01:36 +0200 Subject: [PATCH 5/5] refactor: replace encoded Neo4j query labels with structured telemetry attributes --- Cargo.lock | 2 +- nexus-common/src/db/graph/instrumented.rs | 190 +++++++++++++------ nexus-common/src/db/graph/queries/get.rs | 214 +++++++++++++++------- nexus-common/src/db/graph/query.rs | 142 +++++++++++--- nexus-common/src/models/post/stream.rs | 23 +++ nexus-common/src/types/mod.rs | 12 ++ 6 files changed, 432 insertions(+), 151 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 26becff36..f59c1a034 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3064,7 +3064,7 @@ dependencies = [ "redis", "serde", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.20", "tokio", "tokio-shared-rt", "tracing", diff --git a/nexus-common/src/db/graph/instrumented.rs b/nexus-common/src/db/graph/instrumented.rs index bfca91c1f..8e2125cf9 100644 --- a/nexus-common/src/db/graph/instrumented.rs +++ b/nexus-common/src/db/graph/instrumented.rs @@ -11,7 +11,7 @@ use std::time::{Duration, Instant}; use tracing::warn; use super::ops::{Graph, GraphOps}; -use super::query::Query; +use super::query::{Query, TelemetryValue}; use crate::utils::ms; /// The OpenTelemetry meter name used by all Neo4j graph metrics. @@ -41,9 +41,30 @@ struct GraphMetrics { slow: Counter, } -/// Returns the single-element attribute slice used for all Neo4j metric recordings. -fn query_attrs(label: Option<&'static str>) -> [KeyValue; 1] { - [KeyValue::new("query", label.unwrap_or("unknown"))] +/// Builds the shared metric, span, and log attributes: `query=