← 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");
}
}