cratefield_module_telemetry/handlers.rs
1//! HTTP handlers for `/v1/telemetry` (issue #413). The ingest route is
2//! public and unauthenticated by design — its caller is a CLI with no
3//! browser and no session — so every guard on it is mechanical: the
4//! `RateLimiter` port, the batch ceiling and a parser that admits only a
5//! closed grammar. The client IP touches the request only as an in-memory
6//! rate-limit key and is never written; `docs/PRIVACY.md` holds that rule
7//! for the whole harness, and it is the load-bearing one for the one route
8//! built to be safe to talk to.
9
10use std::sync::Arc;
11
12use axum::body::Bytes;
13use axum::extract::{FromRequest, State};
14use axum::response::{IntoResponse, Response};
15use axum::routing::{get, post};
16use http::HeaderMap;
17use serde_json::{Value, json};
18use time::OffsetDateTime;
19use time::format_description::well_known::Rfc3339;
20
21use cratefield_core::{
22 Clock, Json, ModuleContext, Problem, ProblemDef, RateLimiter, Scope, SystemClock, client_ip,
23 rate_limit_keys, rate_limited, require_admin,
24};
25
26use crate::consent;
27use crate::payload::{Batch, Rejection, Vocabulary};
28use crate::store;
29
30/// The event emitted after an accepted batch (issue #413): counts only,
31/// so a venture can react to volume without scraping its own tables.
32pub(crate) const EVENT_RECORDED: &str = "telemetry.recorded";
33
34/// A batch written in a schema version this collector does not parse. Its
35/// own slug, not a payload rejection: the fix is a client upgrade, not a
36/// payload fix, and an operator should be able to tell the two apart in
37/// logs at a glance.
38pub(crate) const SCHEMA_UNSUPPORTED: ProblemDef = ProblemDef {
39 slug: "telemetry-schema-unsupported",
40 status: axum::http::StatusCode::BAD_REQUEST,
41 title: "Telemetry schema not supported",
42 description: "The batch names a payload schema version this collector does not parse.",
43};
44
45/// A batch carrying a value outside the closed grammar the notice route
46/// publishes. The body's detail names the field path and the rule and
47/// never the value (`Rejection`'s rendering guarantees it).
48pub(crate) const PAYLOAD_REJECTED: ProblemDef = ProblemDef {
49 slug: "telemetry-payload-rejected",
50 status: axum::http::StatusCode::BAD_REQUEST,
51 title: "Telemetry payload rejected",
52 description: "The batch carries a value outside the closed grammar the notice route publishes.",
53};
54
55/// The fixed `detail` for a body that is not JSON at all. It must stay
56/// constant because the natural thing to put there — serde's parse error,
57/// as axum's `Json` extractor would surface it — is not: the error is
58/// prefixed with the JSON key the client chose, so the "message" is the
59/// client's own bytes, echoed back and written to logs. This is the one
60/// route built to keep `README.md`'s promise that rejections never echo
61/// what the client sent, so it answers a malformed body with the rule
62/// alone.
63const NOT_JSON_DETAIL: &str = "the request body was not valid JSON, so no field in it was read";
64
65/// The fixed `detail` for a request that did not name a JSON content
66/// type. `RawBody` restored the header check axum's `Json` extractor
67/// performs before it reads a body; the sentence stays constant for the
68/// same reason [`NOT_JSON_DETAIL`] does. Axum's own wording is fixed too,
69/// but this route owns every word of its rejections and states the rule
70/// rather than quote back anything the client sent — a content-type
71/// value is client-supplied bytes like any other. On its own axum
72/// answers this case with a bare 415, but core's `Json` wrapper turned
73/// every non-size rejection into the 400 `validation-failed` problem, so
74/// 400 is what callers saw before and what this route answers with, under
75/// its own payload slug.
76const NOT_JSON_CONTENT_TYPE_DETAIL: &str =
77 "the request did not name a JSON content type, so its body was not read";
78
79/// The builder's settings, cloned into the router state.
80#[derive(Clone)]
81pub struct Settings {
82 /// The event names and module names the collector will count. Both
83 /// lists default to empty, which rejects every batch naming an event
84 /// or a module — fail closed, on purpose (issue #413).
85 pub vocabulary: Vocabulary,
86 /// Retention for both tables, in days; the scheduled purge deletes
87 /// buckets older than this (default 180).
88 pub retention_days: u32,
89 /// The one-line command that turns reporting off, as a client prints it.
90 pub opt_out_command: String,
91 /// The one-line command that prints the would-be payload, as a client
92 /// prints it.
93 pub status_command: String,
94}
95
96pub(crate) struct ModuleState {
97 pub ctx: Arc<ModuleContext>,
98 pub settings: Settings,
99}
100
101/// The module's routes, mounted at `/v1/telemetry`.
102pub(crate) fn router(ctx: ModuleContext, settings: Settings) -> axum::Router {
103 let state = Arc::new(ModuleState {
104 ctx: Arc::new(ctx),
105 settings,
106 });
107 axum::Router::new()
108 .route("/events", post(record))
109 .route("/notice", get(notice))
110 .route("/admin/usage", get(admin_usage))
111 .with_state(state)
112}
113
114/// "Now" through the Clock port when present, `SystemClock` otherwise —
115/// the port is what makes the day buckets deterministic under the test
116/// kit's fixed clock (issue #413).
117pub(crate) fn now_of(ctx: &ModuleContext) -> OffsetDateTime {
118 ctx.ports
119 .clock
120 .as_ref()
121 .map_or_else(|| SystemClock.now(), |clock| clock.now())
122}
123
124/// The day bucket, `YYYY-MM-DD` — the retention and the aggregate axis.
125/// A string, because ISO dates compare lexicographically and both
126/// supported engines sort TEXT the same way.
127pub(crate) fn day_of(at: OffsetDateTime) -> String {
128 format!(
129 "{:04}-{:02}-{:02}",
130 at.year(),
131 u8::from(at.month()),
132 at.day()
133 )
134}
135
136/// `first_seen_at`/`last_seen_at`, seconds precision like every other
137/// timestamp in the harness.
138fn rfc3339_seconds(at: OffsetDateTime) -> String {
139 at.replace_nanosecond(0)
140 .expect("truncation stays in range")
141 .format(&Rfc3339)
142 .unwrap_or_default()
143}
144
145/// The `202 {"ok":true}` bytes, identical to every other accepting route
146/// in the harness.
147fn accepted() -> Response {
148 (
149 axum::http::StatusCode::ACCEPTED,
150 Json(json!({ "ok": true })),
151 )
152 .into_response()
153}
154
155fn internal(scope: &Scope) -> Problem {
156 Problem::internal().instance(&scope.request_id)
157}
158
159/// The 400 for a rejected batch. A schema mismatch is its own slug: the
160/// fix is a client upgrade, not a payload fix (issue #413).
161fn rejection_problem(scope: &Scope, rejection: Rejection) -> Problem {
162 match rejection {
163 Rejection::SchemaUnsupported => {
164 Problem::new(&SCHEMA_UNSUPPORTED).instance(&scope.request_id)
165 }
166 other => Problem::new(&PAYLOAD_REJECTED)
167 .with_detail(other.to_string())
168 .instance(&scope.request_id),
169 }
170}
171
172/// The 400 for a body that never parsed as JSON — same slug and status as
173/// a grammar rejection, with the one detail that is safe to say.
174fn not_json_problem(scope: &Scope) -> Problem {
175 Problem::new(&PAYLOAD_REJECTED)
176 .with_detail(NOT_JSON_DETAIL)
177 .instance(&scope.request_id)
178}
179
180/// The rate-limit check for the ingest route, mirroring
181/// `module-email-signup`: one check per key from the client IP, and
182/// warn-and-allow when the limiter itself errors — the closed grammar and
183/// the batch ceiling bound what a single request can do, so a limiter
184/// outage must not take the ingest route down. The IP is the only thing
185/// consulted, it is used in memory only, as a key, and it is never
186/// written: `docs/PRIVACY.md` ("no IP addresses in the database") applies
187/// doubly to the route that exists to carry nothing.
188async fn rate_limit(state: &ModuleState, headers: &HeaderMap) -> Option<Response> {
189 let limiter: Arc<dyn RateLimiter> = state.ctx.ports.rate_limiter.clone()?;
190 let ip = client_ip(headers);
191 for key in rate_limit_keys(ip.as_deref(), None) {
192 match limiter.limit(&key).await {
193 Ok(decision) if decision.ok => {}
194 Ok(decision) => {
195 return Some(rate_limited(&decision));
196 }
197 Err(err) => {
198 tracing::warn!(error = %err, key = %key, "rate limiter unavailable; allowing");
199 }
200 }
201 }
202 None
203}
204
205/// The ingest route's body, read as raw bytes rather than through
206/// `cratefield_core::Json`. That extractor is exactly right everywhere
207/// else, but its rejection path copies axum's `body_text()` — serde's
208/// parse error, prefixed with the client-chosen JSON key — into the
209/// problem `detail`, which would make this route echo its caller (see
210/// [`NOT_JSON_DETAIL`]). The bytes are parsed here instead, so the route
211/// owns every word of its own rejections. The one guard axum's extractor
212/// performed that a parse does not imply — the `Content-Type` header —
213/// is made here too ([`json_content_type`]), so a body is only read from
214/// a request that named JSON. A byte read that fails is the harness's
215/// 64 KiB cap or a client that went away mid-body; either maps to the
216/// same request-too-large slug core's `Json` uses.
217struct RawBody(Bytes);
218
219impl<S: Send + Sync> FromRequest<S> for RawBody {
220 type Rejection = Problem;
221
222 async fn from_request(
223 request: axum::extract::Request,
224 state: &S,
225 ) -> Result<Self, Self::Rejection> {
226 let instance = request
227 .extensions()
228 .get::<Scope>()
229 .map(|scope| scope.request_id.clone());
230 // Axum checks the header before it reads the body; so does this.
231 if !json_content_type(request.headers()) {
232 let problem = Problem::new(&PAYLOAD_REJECTED).with_detail(NOT_JSON_CONTENT_TYPE_DETAIL);
233 return Err(match instance {
234 Some(request_id) => problem.instance(&request_id),
235 None => problem,
236 });
237 }
238 if let Ok(bytes) = Bytes::from_request(request, state).await {
239 Ok(Self(bytes))
240 } else {
241 let mut problem = Problem::request_too_large();
242 if let Some(instance) = instance {
243 problem = problem.instance(&instance);
244 }
245 Err(problem)
246 }
247 }
248}
249
250/// Whether the request names a JSON content type, decided exactly as
251/// axum's `Json` extractor decides it: `application/json` and any
252/// `application/*+json` subtype, with parameters (`charset=utf-8`) and
253/// case differences accepted — the same `mime` crate, so a correct
254/// client cannot tell this restored check from the extractor's own.
255fn json_content_type(headers: &HeaderMap) -> bool {
256 headers
257 .get(http::header::CONTENT_TYPE)
258 .and_then(|value| value.to_str().ok())
259 .and_then(|value| value.parse::<mime::Mime>().ok())
260 .is_some_and(|mime| {
261 mime.type_() == "application"
262 && (mime.subtype() == "json" || mime.suffix().is_some_and(|name| name == "json"))
263 })
264}
265
266/// `POST /v1/telemetry/events` — a batch of counted events (issue #413).
267/// The body is parsed before anything else: a rejected batch costs
268/// nothing and writes nothing. An accepted batch writes its aggregates in
269/// one all-or-nothing call, then emits `telemetry.recorded` with counts
270/// only.
271async fn record(
272 scope: Scope,
273 State(state): State<Arc<ModuleState>>,
274 headers: HeaderMap,
275 RawBody(bytes): RawBody,
276) -> Result<Response, Problem> {
277 if let Some(denied) = rate_limit(&state, &headers).await {
278 return Ok(denied);
279 }
280 // Two different failures, two different sentences, neither of them
281 // serde's: a body that is not JSON at all gets the fixed one, and a
282 // body that parsed but broke the grammar gets `Rejection`'s.
283 let body: Value = serde_json::from_slice(&bytes).map_err(|_| not_json_problem(&scope))?;
284 let batch = Batch::parse(&body, &state.settings.vocabulary)
285 .map_err(|rejection| rejection_problem(&scope, rejection))?;
286 let Some(db) = state.ctx.ports.db.clone() else {
287 return Err(internal(&scope));
288 };
289 let now = now_of(&state.ctx);
290 store::record_batch(&*db, &batch, &day_of(now), &rfc3339_seconds(now)).await?;
291 state.ctx.events.emit_in(
292 &scope,
293 EVENT_RECORDED,
294 json!({"events": batch.events.len(), "modules": batch.modules.len()}),
295 );
296 Ok(accepted())
297}
298
299/// `GET /v1/telemetry/notice` — the canonical machine-readable consent
300/// notice (issue #413): the exact first-run text, the one-line opt-out and
301/// status commands, both declared vocabularies, and every payload field
302/// with its permitted grammar, generated from the schema so it cannot
303/// drift from the parser. Unauthenticated and read-only, like the privacy
304/// manifest: a notice a client must authenticate to read is not a notice,
305/// and this route holds nothing the module does not publish in its own
306/// binary anyway.
307async fn notice(State(state): State<Arc<ModuleState>>) -> Json<Value> {
308 Json(json!({
309 "schema": crate::payload::SCHEMA,
310 "notice": consent::notice(&state.settings),
311 "opt_out_command": state.settings.opt_out_command,
312 "status_command": state.settings.status_command,
313 "events": state.settings.vocabulary.events,
314 "modules": state.settings.vocabulary.modules,
315 "fields": crate::payload::fields(&state.settings.vocabulary),
316 }))
317}
318
319/// `GET /v1/telemetry/admin/usage` — the aggregate rows the collection is
320/// for (issue #413). Admin-guarded exactly like the waitlist export; the
321/// GROUP BY is the privacy control here, so the response has no per-install
322/// row to leak.
323async fn admin_usage(
324 scope: Scope,
325 State(state): State<Arc<ModuleState>>,
326 headers: HeaderMap,
327) -> Result<Response, Problem> {
328 require_admin(&*state.ctx.config, &headers)
329 .map_err(|problem| problem.instance(&scope.request_id))?;
330 let Some(db) = state.ctx.ports.db.clone() else {
331 return Err(internal(&scope));
332 };
333 let rows = store::usage(&*db).await?;
334 Ok(Json(json!({ "rows": rows })).into_response())
335}