• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

divviup / divviup-api / 26540494363

27 May 2026 09:43PM UTC coverage: 69.209% (+0.4%) from 68.787%
26540494363

push

github

web-flow
Migrate from Trillium [part 10]: OTel 0.32 upgrade, OTLP metrics, HTTP metrics middleware (#2278)

Upgrade the OpenTelemetry ecosystem from 0.27 to 0.32 and switch from
Prometheus scrape-based metrics export to OTLP push. Prometheus 3.x in
prod supports native OTLP ingestion, so the prometheus and
opentelemetry-prometheus crates are removed entirely.

Changes:

- Upgrade opentelemetry, opentelemetry_sdk, and opentelemetry-otlp to
  0.32. Use the new Resource::builder() and SdkMeterProvider APIs.

- Replace the Prometheus exporter with an OTLP HTTP MetricExporter
  using periodic push (endpoint via OTEL_EXPORTER_OTLP_ENDPOINT).

- Remove the /metrics endpoint from the monitoring server; it now only
  serves /traceconfig.

- Add a custom HTTP metrics middleware (src/handler/http_metrics.rs)
  that records http.server.request.duration and
  http.server.active_requests following OTel HTTP semantic conventions,
  including url.scheme, http.request.method (normalized to known
  methods or _OTHER), http.route (omitted for unmatched paths), and
  http.response.status_code.

- Propagate client IP into trace spans by parsing the rightmost
  X-Forwarded-For value in a custom TraceLayer make_span_with.

- Return a TelemetryProviders struct from install_telemetry() so both
  the meter provider and the OTLP tracer provider (when otlp-trace is
  enabled) are shut down gracefully on exit.

- Remove stale RUSTSEC advisory ignores from deny.toml.

- Use tracing::field::Empty for client.address when XFF header absent
- Add OTel semconv advisory histogram buckets to request duration
- Comment on HttpMetrics::new() to prevent per-request re-registration

78 of 116 new or added lines in 4 files covered. (67.24%)

2 existing lines in 2 files now uncovered.

4500 of 6502 relevant lines covered (69.21%)

71.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.41
/src/handler.rs
1
pub(crate) mod account_bearer_token;
2
#[cfg(assets)]
3
pub(crate) mod assets;
4
pub(crate) mod cors;
5
pub(crate) mod custom_mime_types;
6
pub(crate) mod error;
7
pub(crate) mod extract;
8
pub(crate) mod http_metrics;
9
pub(crate) mod oauth2;
10
pub(crate) mod session_store;
11

12
use crate::{
13
    clients::{Auth0Client, HttpClient},
14
    routes::{axum_routes, health_check},
15
    Config, Crypter, Db, FeatureFlags,
16
};
17
use axum::{
18
    body::Body,
19
    extract::{DefaultBodyLimit, FromRef},
20
    http::{header, HeaderValue, Request},
21
    routing,
22
};
23
use cors::axum_cors_layer;
24
use http_metrics::HttpMetrics;
25
use oauth2::OauthClient;
26
use session_store::axum_session_layer;
27
use std::sync::Arc;
28
use tower::ServiceBuilder;
29
use tower_http::{
30
    compression::CompressionLayer, set_header::SetResponseHeaderLayer, trace::TraceLayer,
31
};
32

33
pub use error::Error;
34

35
/// Shared state for the Axum application.
36
#[derive(Clone, Debug)]
37
pub struct AxumAppState {
38
    pub(crate) db: Db,
39
    pub(crate) config: Arc<Config>,
40
    pub(crate) auth0_client: Auth0Client,
41
    pub(crate) oauth_client: OauthClient,
42
    pub(crate) crypter: Crypter,
43
    pub(crate) feature_flags: FeatureFlags,
44
    pub(crate) client: HttpClient,
45
}
46

47
impl FromRef<AxumAppState> for Db {
48
    fn from_ref(state: &AxumAppState) -> Self {
776✔
49
        state.db.clone()
776✔
50
    }
776✔
51
}
52

53
impl FromRef<AxumAppState> for Arc<Config> {
54
    fn from_ref(state: &AxumAppState) -> Self {
3✔
55
        state.config.clone()
3✔
56
    }
3✔
57
}
58

59
impl FromRef<AxumAppState> for Auth0Client {
60
    fn from_ref(state: &AxumAppState) -> Self {
×
61
        state.auth0_client.clone()
×
62
    }
×
63
}
64

65
impl FromRef<AxumAppState> for OauthClient {
66
    fn from_ref(state: &AxumAppState) -> Self {
2✔
67
        state.oauth_client.clone()
2✔
68
    }
2✔
69
}
70

71
impl FromRef<AxumAppState> for Crypter {
72
    fn from_ref(state: &AxumAppState) -> Self {
65✔
73
        state.crypter.clone()
65✔
74
    }
65✔
75
}
76

77
impl FromRef<AxumAppState> for FeatureFlags {
78
    fn from_ref(state: &AxumAppState) -> Self {
20✔
79
        state.feature_flags
20✔
80
    }
20✔
81
}
82

83
impl FromRef<AxumAppState> for HttpClient {
84
    fn from_ref(state: &AxumAppState) -> Self {
65✔
85
        state.client.clone()
65✔
86
    }
65✔
87
}
88

89
/// 1 MiB — this JSON API never needs bodies larger than this under normal operation.
90
const MAX_REQUEST_BODY_SIZE: usize = 1024 * 1024;
91

92
/// The result of [`build_app`]: an Axum router ready to serve, plus the
93
/// shared state that callers (e.g. the queue) need.
94
#[derive(Debug)]
95
pub struct BuiltApp {
96
    pub router: axum::Router,
97
    pub db: Db,
98
    pub config: Arc<Config>,
99
}
100

101
/// Build the Axum application router and connect to the database.
102
pub async fn build_app(config: Config) -> BuiltApp {
277✔
103
    let config = Arc::new(config);
277✔
104
    let db = Db::connect(config.database_url.as_ref()).await;
277✔
105

106
    let auth0_client = Auth0Client::new(&config);
277✔
107
    let axum_state = AxumAppState {
277✔
108
        db: db.clone(),
277✔
109
        config: config.clone(),
277✔
110
        auth0_client,
277✔
111
        oauth_client: OauthClient::new(&config.oauth_config()),
277✔
112
        crypter: config.crypter.clone(),
277✔
113
        feature_flags: config.feature_flags(),
277✔
114
        client: config.client.clone(),
277✔
115
    };
277✔
116

117
    let middleware = ServiceBuilder::new()
277✔
118
        .layer(axum::middleware::from_fn_with_state(
277✔
119
            HttpMetrics::new(), // instruments are registered once here, then cloned per request
277✔
120
            http_metrics::http_metrics_middleware,
121
        ))
122
        .layer(
277✔
123
            TraceLayer::new_for_http().make_span_with(|request: &Request<Body>| {
278✔
124
                let client_ip = request
278✔
125
                    .headers()
278✔
126
                    .get("x-forwarded-for")
278✔
127
                    .and_then(|v| v.to_str().ok())
278✔
128
                    .and_then(|s| s.rsplit(',').next())
278✔
129
                    .map(str::trim);
278✔
130
                let span = tracing::debug_span!(
278✔
131
                    "request",
NEW
132
                    method = %request.method(),
×
NEW
133
                    uri = %request.uri(),
×
NEW
134
                    version = ?request.version(),
×
135
                    client.address = tracing::field::Empty,
136
                );
137
                if let Some(ip) = client_ip {
278✔
NEW
138
                    span.record("client.address", ip);
×
139
                }
278✔
140
                span
278✔
141
            }),
278✔
142
        )
143
        .layer(DefaultBodyLimit::max(MAX_REQUEST_BODY_SIZE))
277✔
144
        .layer(CompressionLayer::new())
277✔
145
        .layer(SetResponseHeaderLayer::if_not_present(
277✔
146
            header::CACHE_CONTROL,
277✔
147
            HeaderValue::from_static("private, must-revalidate"),
277✔
148
        ))
149
        .layer(axum_cors_layer(&config))
277✔
150
        .layer(axum_session_layer(db.clone(), &config.session_secrets));
277✔
151

152
    #[cfg(feature = "integration-testing")]
153
    let middleware = middleware.layer(axum::middleware::from_fn(inject_integration_testing_user));
154

155
    #[cfg(assets)]
156
    let middleware = middleware.layer(axum::middleware::from_fn_with_state(
277✔
157
        assets::AssetConfig::new(&config.api_url, &config.app_url),
277✔
158
        assets::serve_assets,
159
    ));
160

161
    let router = axum::Router::new()
277✔
162
        .route("/health", routing::get(health_check))
277✔
163
        .route("/login", routing::get(oauth2::redirect))
277✔
164
        .route("/logout", routing::get(oauth2::logout))
277✔
165
        .route("/callback", routing::get(oauth2::callback))
277✔
166
        .nest("/api", axum_routes::api_router(&axum_state))
277✔
167
        .layer(middleware)
277✔
168
        .with_state(axum_state);
277✔
169

170
    BuiltApp { router, db, config }
277✔
171
}
277✔
172

173
/// Axum middleware that injects an admin [`User`](crate::User) into every
174
/// request that doesn't already have one in extensions.
175
///
176
/// Only compiled under `--features integration-testing` (enabled by
177
/// `compose.dev.override.yaml`). Never compiled into deployed builds.
178
#[cfg(feature = "integration-testing")]
179
async fn inject_integration_testing_user(
180
    mut request: axum::extract::Request,
181
    next: axum::middleware::Next,
182
) -> axum::response::Response {
183
    if request.extensions().get::<crate::User>().is_none() {
184
        request
185
            .extensions_mut()
186
            .insert(crate::User::for_integration_testing());
187
    }
188
    next.run(request).await
189
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc