Skip to content

Commit ffeedb6

Browse files
feat: expose Prometheus /metrics endpoint for usage dashboards (#102)
* feat: expose Prometheus /metrics endpoint for usage dashboards Adds a Prometheus text-format `GET /metrics` endpoint covering the metrics requested in #101: * cryptify_uploads_total{channel} * cryptify_upload_bytes_total{channel} * cryptify_storage_bytes (gauge, sampled periodically from data_dir) * cryptify_active_files (gauge, same source) * cryptify_expired_files_total (counter, purged-before-finalized) The channel label is derived from request headers: 1. X-Cryptify-Source explicit header 2. Authorization: Bearer / X-Api-Key -> "api" 3. Origin -> "staging-website" / "website" 4. User-Agent substring -> "outlook" / "thunderbird" 5. fallback "unknown" Values are sanitized (lower-case [a-z0-9_-], max 32 chars) to prevent label-injection and cardinality blowup. Storage gauges are sampled by a background task that walks data_dir every `metrics_scan_interval_secs` (default 60, configurable). Dashboard JSON ready for import into the Scaleway Grafana instance is shipped under `docs/grafana/`, alongside a Prometheus scrape-config example. No authentication on /metrics; restrict via firewall / proxy allow-list (documented in README and docs/grafana/README.md). Refs #101 * chore: cargo fmt + drop non-standard Monitoring README section Rule compliance pass: - cargo fmt --all (rust-run-cargo-fmt-before-push) - README "Monitoring" section removed; docs already live in docs/grafana/README.md (standardized-readmes) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix: correct indentation of production target in Grafana scrape config The production target's labels block was indented under targets instead of being a sibling, which would fail YAML parsing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * chore: drop docs/grafana from metrics PR Remove the Grafana dashboard JSON and scrape-config README per maintainer feedback — keep only the /metrics endpoint exposure. --------- Co-authored-by: dobby-yivi-agent[bot] <275734547+dobby-yivi-agent[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 9418c57 commit ffeedb6

6 files changed

Lines changed: 502 additions & 22 deletions

File tree

api-description.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ servers:
99
tags:
1010
- name: "Health"
1111
description: "Health check"
12+
- name: "Metrics"
13+
description: "Prometheus scrape endpoint"
1214
- name: "File upload"
1315
description: "Upload files"
1416
- name: "File download"
@@ -30,6 +32,37 @@ paths:
3032
schema:
3133
type: "string"
3234
example: "OK"
35+
/metrics:
36+
get:
37+
tags:
38+
- "Metrics"
39+
summary: "Prometheus text-format metrics"
40+
description: |
41+
Returns usage counters and gauges suitable for Prometheus scraping
42+
by the Grafana instance on Scaleway. Intended to be reachable only
43+
from the internal monitoring network; firewall or reverse-proxy
44+
allow-list in front of Cryptify.
45+
46+
Exposed metrics:
47+
* `cryptify_uploads_total{channel}` — counter of finalized uploads.
48+
* `cryptify_upload_bytes_total{channel}` — counter of bytes.
49+
* `cryptify_storage_bytes` — gauge, current disk usage.
50+
* `cryptify_active_files` — gauge, current file count.
51+
* `cryptify_expired_files_total` — counter of uploads purged
52+
before finalization.
53+
54+
The `channel` label is derived from the `X-Cryptify-Source` header,
55+
falling back to `Authorization`/`X-Api-Key` (→ `api`), then the
56+
`Origin` header (`website` / `staging-website`), then `User-Agent`
57+
(`outlook` / `thunderbird`), then `unknown`.
58+
operationId: "metrics"
59+
responses:
60+
"200":
61+
description: "Prometheus text exposition format"
62+
content:
63+
text/plain:
64+
schema:
65+
type: "string"
3366
/fileupload/init:
3467
post:
3568
tags:

src/config.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ pub struct RawCryptifyConfig {
1212
smtp_tls: Option<bool>,
1313
allowed_origins: String,
1414
pkg_url: String,
15+
metrics_scan_interval_secs: Option<u64>,
1516
chunk_size: Option<u64>,
1617
session_ttl_secs: Option<u64>,
1718
staging_mode: Option<bool>,
@@ -30,6 +31,7 @@ pub struct CryptifyConfig {
3031
smtp_tls: bool,
3132
allowed_origins: String,
3233
pkg_url: String,
34+
metrics_scan_interval_secs: u64,
3335
chunk_size: u64,
3436
session_ttl_secs: u64,
3537
staging_mode: bool,
@@ -51,6 +53,7 @@ impl From<RawCryptifyConfig> for CryptifyConfig {
5153
smtp_tls: config.smtp_tls.unwrap_or(true),
5254
allowed_origins: config.allowed_origins,
5355
pkg_url: config.pkg_url,
56+
metrics_scan_interval_secs: config.metrics_scan_interval_secs.unwrap_or(60),
5457
chunk_size: config.chunk_size.unwrap_or(5_000_000),
5558
session_ttl_secs: config.session_ttl_secs.unwrap_or(3600),
5659
staging_mode: config.staging_mode.unwrap_or(false),
@@ -99,6 +102,10 @@ impl CryptifyConfig {
99102
&self.pkg_url
100103
}
101104

105+
pub fn metrics_scan_interval_secs(&self) -> u64 {
106+
self.metrics_scan_interval_secs
107+
}
108+
102109
pub fn chunk_size(&self) -> u64 {
103110
self.chunk_size
104111
}
@@ -124,6 +131,7 @@ impl CryptifyConfig {
124131
smtp_tls: false,
125132
allowed_origins: String::new(),
126133
pkg_url: String::new(),
134+
metrics_scan_interval_secs: 60,
127135
chunk_size: 5_000_000,
128136
session_ttl_secs: 3600,
129137
staging_mode,

src/email.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ mod tests {
419419
("phone".to_owned(), "+31123".to_owned()),
420420
],
421421
confirm: true,
422+
source_channel: String::new(),
422423
notify_recipients: true,
423424
api_key_tenant: None,
424425
api_key_validation_failed: false,

src/main.rs

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
mod config;
22
mod email;
33
mod error;
4+
mod metrics;
45
mod store;
56

7+
use std::sync::Arc;
8+
use std::time::Duration;
9+
610
use crate::config::CryptifyConfig;
711
use crate::email::send_email;
812
use crate::error::{Error, PayloadTooLargeBody};
13+
use crate::metrics::{detect_channel, storage_sampler, Metrics};
914
use crate::store::{
1015
API_KEY_PER_UPLOAD_LIMIT, API_KEY_ROLLING_LIMIT, PER_UPLOAD_LIMIT, ROLLING_LIMIT,
1116
ROLLING_WINDOW_SECS,
@@ -38,7 +43,6 @@ use rocket::http::Method;
3843
use rocket_cors::{AllowedHeaders, AllowedOrigins, CorsOptions};
3944

4045
use serde::{Deserialize, Serialize};
41-
use std::time::Duration;
4246
use store::{FileState, LastChunkRecord, Store};
4347

4448
#[derive(Serialize, Deserialize)]
@@ -89,11 +93,35 @@ struct InitResponder {
8993
cryptify_token: CryptifyToken,
9094
}
9195

96+
/// Request guard that derives the traffic source channel from the request
97+
/// headers for metrics labelling.
98+
struct ClientHeaders {
99+
channel: String,
100+
}
101+
102+
#[rocket::async_trait]
103+
impl<'r> FromRequest<'r> for ClientHeaders {
104+
type Error = std::convert::Infallible;
105+
106+
async fn from_request(
107+
request: &'r rocket::Request<'_>,
108+
) -> rocket::request::Outcome<Self, Self::Error> {
109+
rocket::request::Outcome::Success(ClientHeaders {
110+
channel: detect_channel(request.headers()),
111+
})
112+
}
113+
}
114+
92115
#[get("/health")]
93116
fn health() -> &'static str {
94117
"OK"
95118
}
96119

120+
#[get("/metrics")]
121+
fn metrics_endpoint(metrics: &State<Arc<Metrics>>) -> rocket::response::content::RawText<String> {
122+
rocket::response::content::RawText(metrics.render())
123+
}
124+
97125
/// Extract a `PG-…` bearer token from an Authorization header value, or
98126
/// `None` for any other shape (missing, wrong scheme, non-PG prefix). Kept
99127
/// as a pure helper so the parsing rules are unit-testable without HTTP.
@@ -258,6 +286,7 @@ async fn upload_init(
258286
store: &State<Store>,
259287
api_key: ApiKey,
260288
request: Json<InitBody>,
289+
client_headers: ClientHeaders,
261290
) -> Result<InitResponder, Error> {
262291
let current_time = chrono::offset::Utc::now().timestamp();
263292

@@ -288,6 +317,7 @@ async fn upload_init(
288317
sender: None,
289318
sender_attributes: Vec::new(),
290319
confirm: request.confirm,
320+
source_channel: client_headers.channel,
291321
notify_recipients: request.notify_recipients,
292322
api_key_tenant: api_key.tenant,
293323
api_key_validation_failed: api_key.validation_failed,
@@ -694,6 +724,7 @@ async fn upload_finalize(
694724
config: &State<CryptifyConfig>,
695725
store: &State<Store>,
696726
vk: &State<Parameters<VerifyingKey>>,
727+
metrics: &State<Arc<Metrics>>,
697728
headers: FinalizeHeaders,
698729
uuid: &str,
699730
) -> Result<(), Error> {
@@ -792,6 +823,8 @@ async fn upload_finalize(
792823
Error::InternalServerError(Some("could not send email".to_owned()))
793824
})?;
794825

826+
metrics.record_upload(&state.source_channel, state.uploaded);
827+
795828
if let Some(key) = accounting_key {
796829
store.record_upload(key, state.uploaded, now_secs);
797830
}
@@ -1141,6 +1174,13 @@ pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Bu
11411174
.to_cors()
11421175
.expect("unable to configure CORS");
11431176

1177+
let metrics = Arc::new(Metrics::new());
1178+
rocket::tokio::spawn(storage_sampler(
1179+
metrics.clone(),
1180+
std::path::PathBuf::from(config.data_dir()),
1181+
Duration::from_secs(config.metrics_scan_interval_secs()),
1182+
));
1183+
11441184
let pkg_client = PkgClient::new(config.pkg_url().to_string());
11451185

11461186
rocket
@@ -1149,6 +1189,7 @@ pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Bu
11491189
"/",
11501190
routes![
11511191
health,
1192+
metrics_endpoint,
11521193
upload_init,
11531194
upload_chunk,
11541195
upload_finalize,
@@ -1158,11 +1199,13 @@ pub fn build_rocket(figment: Figment, vk: Parameters<VerifyingKey>) -> Rocket<Bu
11581199
],
11591200
)
11601201
.attach(AdHoc::config::<CryptifyConfig>())
1161-
.manage(Store::with_idle_ttl(std::time::Duration::from_secs(
1162-
config.session_ttl_secs(),
1163-
)))
1202+
.manage(Store::with_idle_ttl(
1203+
std::time::Duration::from_secs(config.session_ttl_secs()),
1204+
metrics.clone(),
1205+
))
11641206
.manage(vk)
11651207
.manage(pkg_client)
1208+
.manage(metrics)
11661209
}
11671210

11681211
#[launch]
@@ -1361,7 +1404,7 @@ mod tests {
13611404
let rocket = rocket::custom(figment)
13621405
.mount("/", routes![upload_init])
13631406
.attach(AdHoc::config::<CryptifyConfig>())
1364-
.manage(Store::new());
1407+
.manage(Store::new(Arc::new(Metrics::new())));
13651408

13661409
Client::tracked(rocket).await.expect("valid rocket")
13671410
}
@@ -1450,7 +1493,7 @@ mod tests {
14501493
let rocket = rocket::custom(figment)
14511494
.mount("/", routes![upload_init, upload_status])
14521495
.attach(AdHoc::config::<CryptifyConfig>())
1453-
.manage(Store::new());
1496+
.manage(Store::new(Arc::new(Metrics::new())));
14541497

14551498
Client::tracked(rocket).await.expect("valid rocket")
14561499
}
@@ -1497,7 +1540,7 @@ mod tests {
14971540
.attach(cors)
14981541
.mount("/", routes![upload_init, upload_status])
14991542
.attach(AdHoc::config::<CryptifyConfig>())
1500-
.manage(Store::new());
1543+
.manage(Store::new(Arc::new(Metrics::new())));
15011544

15021545
Client::tracked(rocket).await.expect("valid rocket")
15031546
}
@@ -1787,6 +1830,7 @@ mod tests {
17871830
sender: None,
17881831
sender_attributes: Vec::new(),
17891832
confirm: false,
1833+
source_channel: String::new(),
17901834
notify_recipients: true,
17911835
api_key_tenant: None,
17921836
api_key_validation_failed: false,

0 commit comments

Comments
 (0)