Skip to main content

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}