use crate::TelemetryCtx;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RolloutEvent {
RevisionStaged,
RevisionWarmed,
RevisionDraining,
RevisionEvicted,
TrafficSplitApplied,
HealthGatePassed,
HealthGateFailed,
}
impl RolloutEvent {
pub const fn as_str(self) -> &'static str {
match self {
RolloutEvent::RevisionStaged => "rollout.revision.staged",
RolloutEvent::RevisionWarmed => "rollout.revision.warmed",
RolloutEvent::RevisionDraining => "rollout.revision.draining",
RolloutEvent::RevisionEvicted => "rollout.revision.evicted",
RolloutEvent::TrafficSplitApplied => "rollout.traffic_split.applied",
RolloutEvent::HealthGatePassed => "rollout.health_gate.passed",
RolloutEvent::HealthGateFailed => "rollout.health_gate.failed",
}
}
}
pub fn emit_rollout_event(event: RolloutEvent, tctx: &TelemetryCtx) {
let span = tracing::info_span!(
"greentic.rollout",
rollout.event = event.as_str(),
gt.tenant = tracing::field::Empty,
gt.team = tracing::field::Empty,
gt.session = tracing::field::Empty,
gt.flow = tracing::field::Empty,
gt.node = tracing::field::Empty,
gt.provider = tracing::field::Empty,
gt.env = tracing::field::Empty,
gt.customer_id = tracing::field::Empty,
gt.deployment_id = tracing::field::Empty,
gt.bundle_id = tracing::field::Empty,
gt.revision_id = tracing::field::Empty,
gt.pack_id = tracing::field::Empty,
gt.env_pack_kind = tracing::field::Empty,
gt.generation = tracing::field::Empty,
gt.messaging_endpoint_id = tracing::field::Empty,
);
for (key, value) in tctx.kv() {
if let Some(v) = value {
span.record(key, v);
}
}
let _enter = span.enter();
tracing::info!(
rollout.event = event.as_str(),
"rollout lifecycle transition"
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discriminants_are_stable_and_distinct() {
let all = [
RolloutEvent::RevisionStaged,
RolloutEvent::RevisionWarmed,
RolloutEvent::RevisionDraining,
RolloutEvent::RevisionEvicted,
RolloutEvent::TrafficSplitApplied,
RolloutEvent::HealthGatePassed,
RolloutEvent::HealthGateFailed,
];
let strs: Vec<&str> = all.iter().map(|e| e.as_str()).collect();
let mut sorted = strs.clone();
sorted.sort_unstable();
sorted.dedup();
assert_eq!(sorted.len(), all.len(), "discriminants must be distinct");
assert!(strs.iter().all(|s| s.starts_with("rollout.")));
}
#[test]
fn emit_does_not_panic_without_subscriber() {
let ctx = TelemetryCtx::new("acme")
.with_env("prod-eu")
.with_deployment_id("01JTKS")
.with_bundle_id("customer.support")
.with_revision_id("01JTKR")
.with_generation(3);
emit_rollout_event(RolloutEvent::TrafficSplitApplied, &ctx);
emit_rollout_event(RolloutEvent::HealthGateFailed, &ctx);
}
#[test]
fn rollout_fields_survive_on_non_otlp_subscriber() {
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use tracing::Subscriber;
use tracing::field::{Field, Visit};
use tracing::span::{Attributes, Id, Record};
use tracing_subscriber::layer::{Context, Layer};
use tracing_subscriber::prelude::*;
use tracing_subscriber::registry::LookupSpan;
#[derive(Default)]
struct Grab(BTreeMap<String, String>);
impl Visit for Grab {
fn record_str(&mut self, field: &Field, value: &str) {
self.0.insert(field.name().to_string(), value.to_string());
}
fn record_debug(&mut self, field: &Field, value: &dyn std::fmt::Debug) {
self.0
.entry(field.name().to_string())
.or_insert_with(|| format!("{value:?}"));
}
}
struct Capture(Arc<Mutex<BTreeMap<String, String>>>);
impl<S: Subscriber + for<'a> LookupSpan<'a>> Layer<S> for Capture {
fn on_new_span(&self, attrs: &Attributes<'_>, _id: &Id, _ctx: Context<'_, S>) {
let mut g = Grab::default();
attrs.record(&mut g);
self.0.lock().unwrap().extend(g.0);
}
fn on_record(&self, _id: &Id, values: &Record<'_>, _ctx: Context<'_, S>) {
let mut g = Grab::default();
values.record(&mut g);
self.0.lock().unwrap().extend(g.0);
}
}
let ctx = TelemetryCtx::new("acme")
.with_team("support")
.with_session("sess-1")
.with_flow("flow-1")
.with_node("node-7")
.with_provider("openai")
.with_env("prod-eu")
.with_customer_id("cust-acme")
.with_deployment_id("01JTKS")
.with_bundle_id("customer.support")
.with_revision_id("01JTKR")
.with_pack_id("customer.support@1.2.0")
.with_env_pack_kind("greentic.deployer.k8s")
.with_generation(3)
.with_messaging_endpoint_id("teams-legal");
let store = Arc::new(Mutex::new(BTreeMap::new()));
let subscriber = tracing_subscriber::registry().with(Capture(Arc::clone(&store)));
tracing::subscriber::with_default(subscriber, || {
emit_rollout_event(RolloutEvent::TrafficSplitApplied, &ctx);
});
let got = store.lock().unwrap();
assert_eq!(
got.get("rollout.event").map(String::as_str),
Some("rollout.traffic_split.applied")
);
assert_eq!(got.get("gt.tenant").map(String::as_str), Some("acme"));
assert_eq!(
got.get("gt.deployment_id").map(String::as_str),
Some("01JTKS")
);
assert_eq!(got.get("gt.generation").map(String::as_str), Some("3"));
let captured: BTreeSet<&str> = got
.keys()
.map(String::as_str)
.filter(|k| k.starts_with("gt."))
.collect();
let expected: BTreeSet<&str> = ctx
.kv()
.into_iter()
.filter_map(|(k, v)| v.map(|_| k))
.collect();
assert_eq!(
captured, expected,
"every gt.* key from kv() must be declared at the rollout span callsite"
);
}
}