jacquardSnapshot

← snapshot

6502 bytes
//! The s10 `SignalEnvelope` (`s10/2`), reduced to the fields we emit.

use std::collections::BTreeMap;

use serde::Serialize;

use crate::rfc3339::format_rfc3339_ms;

/// Span status labels s10 recognises.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum SpanStatus {
    /// Completed normally.
    Ok,
    /// Failed — the console's error-rate charts count these.
    Error,
}

#[derive(Debug, Clone, Serialize)]
struct SpanInfo {
    start_time: i64,
    end_time: i64,
    status: SpanStatus,
}

#[derive(Debug, Clone, Serialize)]
struct BugInfo {
    #[serde(skip_serializing_if = "Option::is_none")]
    stack: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
struct Resource {
    service_name: &'static str,
}

#[derive(Debug, Clone, Serialize)]
struct Context {
    pathname: String,
}

/// One signal, ready to serialise as an NDJSON line.
#[derive(Debug, Clone, Serialize)]
pub struct Envelope {
    schema_version: &'static str,
    signal_id: String,
    occurred_at: String,
    kind: &'static str,
    #[serde(skip_serializing_if = "Option::is_none")]
    name: Option<String>,
    #[serde(skip_serializing_if = "Option::is_none")]
    category: Option<&'static str>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    attributes: BTreeMap<String, serde_json::Value>,
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    measurements: BTreeMap<String, f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    span: Option<SpanInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    bug: Option<BugInfo>,
    #[serde(skip_serializing_if = "Option::is_none")]
    resource: Option<Resource>,
    #[serde(skip_serializing_if = "Option::is_none")]
    context: Option<Context>,
}

impl Envelope {
    fn new(kind: &'static str, name: impl Into<String>, at_ms: i64) -> Self {
        Self {
            schema_version: "s10/2",
            signal_id: ulid::Ulid::new().to_string(),
            occurred_at: format_rfc3339_ms(at_ms),
            kind,
            name: Some(name.into()),
            category: None,
            attributes: BTreeMap::new(),
            measurements: BTreeMap::new(),
            span: None,
            bug: None,
            resource: None,
            context: None,
        }
    }

    /// A product event. Use constant dotted names (`jac.commit.created`) —
    /// never interpolate ids into names; the ingest caps attribute-key
    /// cardinality per `(kind, name)`.
    #[must_use]
    pub fn event(name: impl Into<String>, at_ms: i64) -> Self {
        let mut e = Self::new("event", name, at_ms);
        e.category = Some("product");
        e
    }

    /// A span. The console's `/perf` page recognises the constant name
    /// `http.server.request` with `http.method`/`http.route`/
    /// `http.status_code` attributes.
    #[must_use]
    pub fn span(name: impl Into<String>, start_ms: i64, end_ms: i64, status: SpanStatus) -> Self {
        let mut e = Self::new("span", name, end_ms);
        e.category = Some("performance");
        e.span = Some(SpanInfo {
            start_time: start_ms,
            end_time: end_ms,
            status,
        });
        e
    }

    /// A bug: `name` is the error message, `stack` the backtrace when one
    /// exists. The server fingerprints for issue grouping.
    #[must_use]
    pub fn bug(message: impl Into<String>, stack: Option<String>, at_ms: i64) -> Self {
        let mut e = Self::new("bug", message, at_ms);
        e.category = Some("error");
        e.bug = Some(BugInfo { stack });
        e
    }

    /// Adds a string attribute.
    #[must_use]
    pub fn attr(mut self, key: &str, value: impl Into<String>) -> Self {
        self.attributes
            .insert(key.to_owned(), serde_json::Value::String(value.into()));
        self
    }

    /// Adds a boolean attribute.
    #[must_use]
    pub fn attr_bool(mut self, key: &str, value: bool) -> Self {
        self.attributes
            .insert(key.to_owned(), serde_json::Value::Bool(value));
        self
    }

    /// Adds a numeric measurement.
    #[must_use]
    pub fn measurement(mut self, key: &str, value: f64) -> Self {
        self.measurements.insert(key.to_owned(), value);
        self
    }

    /// Sets `context.pathname` — the field the console's per-page breakdowns
    /// (web vitals, top pages) group by.
    #[must_use]
    pub fn pathname(mut self, pathname: impl Into<String>) -> Self {
        self.context = Some(Context {
            pathname: pathname.into(),
        });
        self
    }

    /// Stamps the emitting service. Called by the handle, not call sites.
    #[must_use]
    pub(crate) const fn resource(mut self, service_name: &'static str) -> Self {
        self.resource = Some(Resource { service_name });
        self
    }

    /// Serialises to one NDJSON line. Infallible in practice; a value that
    /// somehow fails serialisation becomes an empty line the ingest skips.
    #[must_use]
    pub fn to_ndjson_line(&self) -> String {
        serde_json::to_string(self).unwrap_or_default()
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn event_shape_matches_the_wire_contract() {
        let line = Envelope::event("jac.commit.created", 1_747_000_000_000)
            .attr("repo", "meridian-systems")
            .measurement("files", 2.0)
            .to_ndjson_line();
        let v: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
        assert_eq!(v["schema_version"], "s10/2");
        assert_eq!(v["kind"], "event");
        assert_eq!(v["name"], "jac.commit.created");
        assert_eq!(v["category"], "product");
        assert_eq!(v["attributes"]["repo"], "meridian-systems");
        assert_eq!(v["measurements"]["files"], 2.0);
        assert_eq!(
            v["signal_id"].as_str().map(str::len),
            Some(26),
            "signal ids are 26-char ULIDs"
        );
        assert_eq!(v["occurred_at"], "2025-05-11T21:46:40.000Z");
    }

    #[test]
    fn span_carries_times_and_status() {
        let line =
            Envelope::span("http.server.request", 1_000, 1_350, SpanStatus::Error).to_ndjson_line();
        let v: serde_json::Value = serde_json::from_str(&line).unwrap_or_default();
        assert_eq!(v["span"]["start_time"], 1_000);
        assert_eq!(v["span"]["end_time"], 1_350);
        assert_eq!(v["span"]["status"], "error");
        assert_eq!(v["category"], "performance");
    }
}