jacquardSnapshot

← snapshot

3472 bytes
//! 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)
}