//! Frontend telemetry the browser cannot ship itself, honestly: the
//! ingest key never reaches client code, so the one thing a page can do is
//! ask this surface to relay a metric on its behalf.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use jac_core::{Clock as _, SystemClock};
use jac_telemetry::Envelope;
use serde::Deserialize;
use crate::error::ApiError;
use crate::state::SharedState;
/// One Core Web Vital, as `next/web-vitals` reports it: `name` is
/// `CLS|FCP|INP|LCP|TTFB` (case-insensitive), `value` is milliseconds
/// except for `CLS`, which is unitless.
#[derive(Debug, Deserialize)]
pub(crate) struct WebVitalRequest {
pub name: String,
pub value: f64,
pub pathname: String,
}
/// `POST /api/telemetry/web-vitals` — re-emitted as the `web_vitals` event
/// shape the console's Performance page groups by `context.pathname`.
pub(crate) async fn web_vitals(
State(state): State<SharedState>,
Json(req): Json<WebVitalRequest>,
) -> Result<StatusCode, ApiError> {
let measurement = match req.name.to_ascii_uppercase().as_str() {
"LCP" => "lcp_ms",
"INP" => "inp_ms",
"CLS" => "cls",
"TTFB" => "ttfb_ms",
"FCP" => "fcp_ms",
other => {
return Err(ApiError::invalid(format!(
"unknown web vital `{other}` (LCP|INP|CLS|TTFB|FCP)"
)));
}
};
state.telemetry.emit(
Envelope::event("web_vitals", SystemClock.now().as_millis())
.pathname(req.pathname)
.measurement(measurement, req.value),
);
Ok(StatusCode::ACCEPTED)
}
/// A browser-side failure the page could not recover from.
///
/// Reported by the client's global error handlers. The message and stack are
/// the page's own — they name code paths, never repository content — and the
/// pathname is a Next route, not a document the visitor was reading.
#[derive(Debug, Deserialize)]
pub(crate) struct BugRequest {
pub message: String,
#[serde(default)]
pub stack: Option<String>,
pub pathname: String,
/// `error`, `unhandledrejection`, or `boundary` — how it surfaced.
#[serde(default)]
pub kind: Option<String>,
}
/// Longer than this is a minified bundle, not a stack a person will read.
const MAX_STACK: usize = 4000;
const MAX_MESSAGE: usize = 500;
/// `POST /api/telemetry/bug` — relay a frontend crash to s10.
///
/// The browser cannot ship this itself: the ingest key never reaches client
/// code. So the page hands the failure here and this surface emits it under
/// the same tenant as everything else, which is what puts a Lovelace crash and
/// the request that caused it on one timeline.
pub(crate) async fn bug(
State(state): State<SharedState>,
Json(req): Json<BugRequest>,
) -> Result<StatusCode, ApiError> {
let message = req.message.trim();
if message.is_empty() {
return Err(ApiError::invalid("a bug report needs a message"));
}
let message: String = message.chars().take(MAX_MESSAGE).collect();
let stack = req
.stack
.map(|s| s.chars().take(MAX_STACK).collect::<String>())
.filter(|s| !s.trim().is_empty());
state.telemetry.emit(
Envelope::bug(message, stack, SystemClock.now().as_millis())
.pathname(req.pathname)
.attr("surface", "lovelace")
.attr("how", req.kind.as_deref().unwrap_or("error")),
);
Ok(StatusCode::ACCEPTED)
}