jacquardSnapshot

← snapshot

3055 bytes
//! Telemetry shipping to an s10 ingest endpoint.
//!
//! One wire type — the s10 `SignalEnvelope` (`s10/2`) — posted as NDJSON to
//! `{S10_INGEST_URL}/v2/envelopes` with a bearer key, batched over a bounded
//! channel. Configuration comes from the environment; when `S10_INGEST_URL`
//! or `S10_INGEST_KEY` is unset, every handle is a silent no-op.
//!
//! # Privacy invariant
//!
//! Envelopes carry ids, slugs, labels, and counts — never file content,
//! commit messages, rationale, statements, or repository paths. s10's PII
//! scrub does not run on ordinary event attributes, and jacquard's own
//! posture is that only sketches leave the org; telemetry does not get a
//! wider channel than the registry.
//!
//! # Shipper invariant
//!
//! The shipper never propagates failures and never logs at error level: a
//! telemetry outage must not become an outage, and an error-shipping loop
//! must not feed itself.

mod envelope;
mod rfc3339;
mod shipper;

pub use envelope::{Envelope, SpanStatus};

/// Where telemetry goes: an s10 ingest endpoint and its bearer key.
#[derive(Debug, Clone)]
pub struct S10Config {
    /// Base URL, e.g. `http://localhost:8080`.
    pub url: String,
    /// The bearer key; the tenant is derived from it server-side.
    pub key: String,
}

impl S10Config {
    /// Reads `S10_INGEST_URL` / `S10_INGEST_KEY`. `None` when either is
    /// unset or empty — the conventional "telemetry off" state.
    #[must_use]
    pub fn from_env() -> Option<Self> {
        let url = std::env::var("S10_INGEST_URL")
            .ok()
            .filter(|s| !s.is_empty())?;
        let key = std::env::var("S10_INGEST_KEY")
            .ok()
            .filter(|s| !s.is_empty())?;
        Some(Self { url, key })
    }
}

/// A cheap-to-clone telemetry handle. Disabled handles drop everything.
#[derive(Debug, Clone)]
pub struct Telemetry {
    tx: Option<tokio::sync::mpsc::Sender<Envelope>>,
    service: &'static str,
}

impl Telemetry {
    /// Starts the background shipper. Must be called inside a Tokio
    /// runtime. With `config: None`, returns a no-op handle.
    #[must_use]
    pub fn start(config: Option<S10Config>, service: &'static str) -> Self {
        let tx = config.map(|config| {
            let (tx, rx) = tokio::sync::mpsc::channel(shipper::QUEUE_CAP);
            tokio::spawn(shipper::run(config, rx));
            tx
        });
        Self { tx, service }
    }

    /// A handle that ships nothing, for tests.
    #[must_use]
    pub const fn disabled() -> Self {
        Self {
            tx: None,
            service: "disabled",
        }
    }

    /// Whether envelopes actually leave the process.
    #[must_use]
    pub const fn enabled(&self) -> bool {
        self.tx.is_some()
    }

    /// Enqueues an envelope. Fire-and-forget: a full queue drops the
    /// envelope rather than blocking the caller.
    pub fn emit(&self, envelope: Envelope) {
        if let Some(tx) = &self.tx {
            let _ = tx.try_send(envelope.resource(self.service));
        }
    }
}