diff --git a/crates/pixi_build_backend/src/cli.rs b/crates/pixi_build_backend/src/cli.rs index 1698364607..a58237739b 100644 --- a/crates/pixi_build_backend/src/cli.rs +++ b/crates/pixi_build_backend/src/cli.rs @@ -3,12 +3,13 @@ use clap_verbosity_flag::{InfoLevel, Verbosity}; use miette::IntoDiagnostic; use pixi_build_types::{ BackendCapabilities, FrontendCapabilities, + log::{BACKEND_LOG_FORMAT_ENV, BACKEND_LOG_FORMAT_JSON}, procedures::negotiate_capabilities::NegotiateCapabilitiesParams, }; use rattler_build_core::console_utils::{LoggingOutputHandler, get_default_env_filter}; -use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +use tracing_subscriber::{Layer, layer::SubscriberExt, util::SubscriberInitExt}; -use crate::{protocol::ProtocolInstantiator, server::Server}; +use crate::{json_log_layer::JsonLogLayer, protocol::ProtocolInstantiator, server::Server}; #[allow(missing_docs)] #[derive(Parser)] @@ -55,7 +56,26 @@ pub(crate) async fn main_impl, + message: Option, + extract_message: bool, +} + +impl FieldVisitor { + fn new_event() -> Self { + Self { + fields: Map::new(), + message: None, + extract_message: true, + } + } + + fn new_span() -> Self { + Self { + fields: Map::new(), + message: None, + extract_message: false, + } + } + + fn insert(&mut self, name: &str, value: Value) { + if self.extract_message && name == "message" { + // Tracing's `message` field is always serialised through the + // dedicated `message` slot on event records, not in `fields`. + self.message = Some(match value { + Value::String(s) => s, + other => other.to_string(), + }); + } else { + self.fields.insert(name.to_string(), value); + } + } +} + +impl Visit for FieldVisitor { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.insert(field.name(), Value::String(value.to_owned())); + } + + fn record_bool(&mut self, field: &tracing::field::Field, value: bool) { + self.insert(field.name(), Value::Bool(value)); + } + + fn record_i64(&mut self, field: &tracing::field::Field, value: i64) { + self.insert(field.name(), Value::Number(value.into())); + } + + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + self.insert(field.name(), Value::Number(value.into())); + } + + fn record_f64(&mut self, field: &tracing::field::Field, value: f64) { + match serde_json::Number::from_f64(value) { + Some(n) => self.insert(field.name(), Value::Number(n)), + None => self.insert(field.name(), Value::String(value.to_string())), + } + } + + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.insert(field.name(), Value::String(format!("{:?}", value))); + } +} + +fn level_to_backend(level: &Level) -> BackendLogLevel { + match *level { + Level::TRACE => BackendLogLevel::Trace, + Level::DEBUG => BackendLogLevel::Debug, + Level::INFO => BackendLogLevel::Info, + Level::WARN => BackendLogLevel::Warn, + Level::ERROR => BackendLogLevel::Error, + } +} + +fn now_rfc3339() -> Option { + Some(Utc::now().to_rfc3339()) +} + +/// Write a single record as `{sentinel}{json}\n` to stderr. The whole line +/// goes through one `write_all` call so the prefix and payload stay together +/// — but note that on a pipe this is only guaranteed atomic up to +/// `PIPE_BUF` (4 KiB on Linux). Larger payloads may interleave with concurrent +/// stderr writers; rare in practice but worth knowing. +fn emit(record: &BackendLogRecord) { + let Ok(json) = serde_json::to_string(record) else { + return; + }; + let mut line = String::with_capacity(BACKEND_LOG_SENTINEL.len() + json.len() + 1); + line.push_str(BACKEND_LOG_SENTINEL); + line.push_str(&json); + line.push('\n'); + let _ = std::io::stderr().lock().write_all(line.as_bytes()); +} + +impl Layer for JsonLogLayer +where + S: Subscriber + for<'a> LookupSpan<'a>, +{ + fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) { + let mut visitor = FieldVisitor::new_span(); + attrs.record(&mut visitor); + let metadata = attrs.metadata(); + // Prefer the explicit parent set via `parent:` on the macro; fall back + // to the contextually-current span (the default in tracing). + let parent_id = attrs + .parent() + .cloned() + .or_else(|| ctx.current_span().id().cloned()) + .map(|i| i.into_u64()); + + emit(&BackendLogRecord::SpanOpen(BackendSpanOpenRecord { + id: id.into_u64(), + parent_id, + level: level_to_backend(metadata.level()), + target: metadata.target().to_string(), + name: metadata.name().to_string(), + fields: visitor.fields, + timestamp: now_rfc3339(), + })); + } + + fn on_close(&self, id: Id, _ctx: Context<'_, S>) { + emit(&BackendLogRecord::SpanClose(BackendSpanCloseRecord { + id: id.into_u64(), + })); + } + + fn on_event(&self, event: &Event<'_>, ctx: Context<'_, S>) { + let metadata = event.metadata(); + // INFO events are rattler-build's plaintext build-output channel — + // they're rendered by `LoggingOutputHandler` to stderr directly and + // reach the frontend as raw output. Skipping them here avoids + // double-encoding the same content as both pretty text and a + // structured event. + if *metadata.level() == Level::INFO { + return; + } + + let mut visitor = FieldVisitor::new_event(); + event.record(&mut visitor); + let span_id = ctx.event_span(event).map(|s| s.id().into_u64()); + + emit(&BackendLogRecord::Event(BackendEventRecord { + level: level_to_backend(metadata.level()), + target: metadata.target().to_string(), + message: visitor.message.unwrap_or_default(), + fields: visitor.fields, + timestamp: now_rfc3339(), + span_id, + })); + } +} diff --git a/crates/pixi_build_backend/src/lib.rs b/crates/pixi_build_backend/src/lib.rs index 7fa23e5566..fcf842099f 100644 --- a/crates/pixi_build_backend/src/lib.rs +++ b/crates/pixi_build_backend/src/lib.rs @@ -1,6 +1,7 @@ pub mod cli; pub mod generated_recipe; pub mod intermediate_backend; +pub mod json_log_layer; pub mod package_dependency; pub mod protocol; pub mod rattler_build_integration; diff --git a/crates/pixi_build_frontend/src/backend/json_rpc.rs b/crates/pixi_build_frontend/src/backend/json_rpc.rs index 2f354dc625..cf68f05e8c 100644 --- a/crates/pixi_build_frontend/src/backend/json_rpc.rs +++ b/crates/pixi_build_frontend/src/backend/json_rpc.rs @@ -15,6 +15,7 @@ use miette::Diagnostic; use ordermap::OrderMap; use pixi_build_types::{ BackendCapabilities, FrontendCapabilities, ProjectModel, TargetSelector, + log::{BACKEND_LOG_FORMAT_ENV, BACKEND_LOG_FORMAT_JSON}, procedures::{ self, conda_build_v1::{CondaBuildV1Params, CondaBuildV1Result}, @@ -159,7 +160,10 @@ impl JsonRpcBackend { debug_assert!(workspace_root.is_absolute()); debug_assert!(checkout_root.as_ref().is_none_or(|p| p.is_absolute())); // Spawn the tool and capture stdin/stdout. - let command = tool.command(); + let mut command = tool.command(); + // Ask the backend to emit `tracing` events as sentinel-tagged JSON on + // stderr so we can re-emit them through the frontend's subscriber. + command.env(BACKEND_LOG_FORMAT_ENV, BACKEND_LOG_FORMAT_JSON); let program_name = command.get_program().to_string_lossy().into_owned(); let mut process = match tokio::process::Command::from(command) .stdout(std::process::Stdio::piped()) diff --git a/crates/pixi_build_frontend/src/backend/stderr.rs b/crates/pixi_build_frontend/src/backend/stderr.rs index 050078ebfe..d72344a549 100644 --- a/crates/pixi_build_frontend/src/backend/stderr.rs +++ b/crates/pixi_build_frontend/src/backend/stderr.rs @@ -1,39 +1,316 @@ -use std::sync::Arc; +use std::{collections::HashMap, sync::Arc}; use crate::BackendOutputStream; +use pixi_build_types::log::{ + BACKEND_LOG_SENTINEL, BackendEventRecord, BackendLogLevel, BackendLogRecord, +}; use tokio::{ io::{BufReader, Lines}, process::ChildStderr, sync::{Mutex, oneshot}, }; +use tracing::Span; /// Stderr stream that captures the stderr output of the backend and stores it -/// in a buffer for later use. +/// in a buffer for later use. Lines tagged with [`BACKEND_LOG_SENTINEL`] are +/// parsed as structured backend log records and replayed through the +/// frontend's `tracing` subscriber, preserving the backend's span hierarchy; +/// other lines are forwarded to `on_log` unchanged. pub(crate) async fn stream_stderr( buffer: Arc>>>, cancel: oneshot::Receiver<()>, mut on_log: W, ) -> Result { - // Create a future that continuously read from the buffer and stores the lines - // until all data is received. let mut lines = Vec::new(); let read_and_buffer = async { let mut buffer = buffer.lock().await; + let mut spans: HashMap = HashMap::new(); while let Some(line) = buffer.next_line().await? { - on_log.on_line(line.clone()); - lines.push(line); + match classify_line(&line) { + ClassifiedLine::Record(record) => apply_record(record, &mut spans), + ClassifiedLine::Raw => { + on_log.on_line(line.clone()); + lines.push(line); + } + } } + // Spans drop here in arbitrary order — fine, because the backend + // process has exited and there's no one left to receive close events. Ok(lines.join("\n")) }; - // Either wait until the cancel signal is received or the `read_and_buffer` - // finishes which means there is no more data to read. tokio::select! { - _ = cancel => { - Ok(lines.join("\n")) + _ = cancel => Ok(lines.join("\n")), + result = read_and_buffer => result, + } +} + +enum ClassifiedLine { + Record(BackendLogRecord), + Raw, +} + +fn classify_line(line: &str) -> ClassifiedLine { + line.strip_prefix(BACKEND_LOG_SENTINEL) + .and_then(|json| serde_json::from_str::(json).ok()) + .map(ClassifiedLine::Record) + .unwrap_or(ClassifiedLine::Raw) +} + +fn apply_record(record: BackendLogRecord, spans: &mut HashMap) { + match record { + BackendLogRecord::SpanOpen(open) => { + let parent_id = open + .parent_id + .and_then(|p| spans.get(&p)) + .and_then(Span::id); + let span = dyn_span::create(&open.name, parent_id); + spans.insert(open.id, span); + } + BackendLogRecord::SpanClose(close) => { + // Dropping the Span here signals close to the subscriber. Late + // events referencing a closed id will simply emit without a + // parent. + spans.remove(&close.id); + } + BackendLogRecord::Event(event) => { + let parent = event.span_id.and_then(|id| spans.get(&id)); + emit_event(&event, parent); + } + } +} + +/// Emit a backend event through the frontend's `tracing` dispatcher, scoped +/// inside `parent` if known. `tracing::event!` needs a const level, so we +/// dispatch via a small per-level macro. +fn emit_event(event: &BackendEventRecord, parent: Option<&Span>) { + let fields = if event.fields.is_empty() { + String::new() + } else { + serde_json::to_string(&event.fields).unwrap_or_default() + }; + let target = format!("pixi_build_backend::{}", event.target); + + macro_rules! emit { + ($lvl:expr) => {{ + tracing::event!( + target: "pixi_build_backend", + $lvl, + backend.target = %target, + backend.fields = %fields, + "{}", + event.message, + ); + }}; + } + + let do_emit = || match event.level { + BackendLogLevel::Trace => emit!(tracing::Level::TRACE), + BackendLogLevel::Debug => emit!(tracing::Level::DEBUG), + BackendLogLevel::Info => emit!(tracing::Level::INFO), + BackendLogLevel::Warn => emit!(tracing::Level::WARN), + BackendLogLevel::Error => emit!(tracing::Level::ERROR), + }; + + match parent { + Some(span) => span.in_scope(do_emit), + None => do_emit(), + } +} + +/// Runtime-named `tracing` spans, used to mirror the backend's span hierarchy. +/// +/// `tracing`'s public macros require span names to be `&'static str` baked in +/// at the call site. To get arbitrary names through the dispatch system we +/// build the `Metadata`/`Callsite` pair by hand, leaking one per unique name. +/// Backends only have a small, bounded set of span names in practice, so the +/// per-process leak is negligible — and lifecycle propagation means we open +/// each span once per actual lifetime rather than once per event. +mod dyn_span { + use std::{ + collections::HashMap, + sync::{Mutex, OnceLock}, + }; + + use tracing::{ + Metadata, + callsite::{Callsite, Identifier}, + field::FieldSet, + metadata::Kind, + span::{Id, Span}, + }; + + pub(super) struct DynCallsite { + name: &'static str, + metadata: OnceLock>, + } + + impl DynCallsite { + fn metadata_ref(&'static self) -> &'static Metadata<'static> { + self.metadata.get_or_init(|| { + Metadata::new( + self.name, + "pixi_build_backend", + tracing::Level::TRACE, + None, + None, + None, + FieldSet::new(&[], Identifier(self)), + Kind::SPAN, + ) + }) } - result = read_and_buffer => { - result + } + + impl Callsite for DynCallsite { + fn set_interest(&self, _: tracing::subscriber::Interest) {} + fn metadata(&self) -> &Metadata<'_> { + self.metadata + .get() + .expect("metadata initialised before first use") + } + } + + pub(super) fn callsite_for(name: &str) -> &'static DynCallsite { + static INTERN: OnceLock>> = OnceLock::new(); + let intern = INTERN.get_or_init(|| Mutex::new(HashMap::new())); + let mut guard = intern.lock().expect("dyn_span intern poisoned"); + if let Some(&cs) = guard.get(name) { + return cs; } + let leaked_name: &'static str = Box::leak(name.to_owned().into_boxed_str()); + let cs: &'static DynCallsite = Box::leak(Box::new(DynCallsite { + name: leaked_name, + metadata: OnceLock::new(), + })); + let _ = cs.metadata_ref(); + guard.insert(name.to_owned(), cs); + cs + } + + pub(super) fn create(name: &str, parent: Option) -> Span { + let cs = callsite_for(name); + let meta = cs.metadata_ref(); + let value_set = meta.fields().value_set(&[]); + Span::child_of(parent, meta, &value_set) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pixi_build_types::log::{BackendEventRecord, BackendSpanCloseRecord, BackendSpanOpenRecord}; + + #[test] + fn raw_line_without_sentinel_is_forwarded() { + assert!(matches!( + classify_line("cargo: compiling foo"), + ClassifiedLine::Raw + )); + } + + #[test] + fn sentinel_event_line_parses() { + let payload = r#"{"kind":"event","level":"INFO","target":"pixi_build::recipe","message":"hello"}"#; + let line = format!("{}{}", BACKEND_LOG_SENTINEL, payload); + let ClassifiedLine::Record(BackendLogRecord::Event(event)) = classify_line(&line) else { + panic!("expected an event record"); + }; + assert_eq!(event.level, BackendLogLevel::Info); + assert_eq!(event.target, "pixi_build::recipe"); + assert_eq!(event.message, "hello"); + } + + #[test] + fn sentinel_span_open_line_parses() { + let payload = r#"{"kind":"span_open","id":3,"parent_id":1,"level":"INFO","target":"t","name":"build"}"#; + let line = format!("{}{}", BACKEND_LOG_SENTINEL, payload); + let ClassifiedLine::Record(BackendLogRecord::SpanOpen(open)) = classify_line(&line) else { + panic!("expected a span_open record"); + }; + assert_eq!(open.id, 3); + assert_eq!(open.parent_id, Some(1)); + assert_eq!(open.name, "build"); + } + + #[test] + fn malformed_sentinel_line_falls_back_to_raw() { + let line = format!("{}not json", BACKEND_LOG_SENTINEL); + assert!(matches!(classify_line(&line), ClassifiedLine::Raw)); + } + + #[test] + fn applying_lifecycle_records_inserts_and_removes_spans() { + let mut spans: HashMap = HashMap::new(); + + apply_record( + BackendLogRecord::SpanOpen(BackendSpanOpenRecord { + id: 1, + parent_id: None, + level: BackendLogLevel::Info, + target: "t".into(), + name: "outer".into(), + fields: Default::default(), + timestamp: None, + }), + &mut spans, + ); + apply_record( + BackendLogRecord::SpanOpen(BackendSpanOpenRecord { + id: 2, + parent_id: Some(1), + level: BackendLogLevel::Info, + target: "t".into(), + name: "inner".into(), + fields: Default::default(), + timestamp: None, + }), + &mut spans, + ); + assert!(spans.contains_key(&1)); + assert!(spans.contains_key(&2)); + + // Event referencing an existing span — must not panic. + apply_record( + BackendLogRecord::Event(BackendEventRecord { + level: BackendLogLevel::Info, + target: "t".into(), + message: "m".into(), + fields: Default::default(), + timestamp: None, + span_id: Some(2), + }), + &mut spans, + ); + + // Closing an inner span removes only that entry. + apply_record( + BackendLogRecord::SpanClose(BackendSpanCloseRecord { id: 2 }), + &mut spans, + ); + assert!(spans.contains_key(&1)); + assert!(!spans.contains_key(&2)); + + // Event referencing a closed span — must not panic; emitted parentless. + apply_record( + BackendLogRecord::Event(BackendEventRecord { + level: BackendLogLevel::Warn, + target: "t".into(), + message: "late".into(), + fields: Default::default(), + timestamp: None, + span_id: Some(2), + }), + &mut spans, + ); + } + + #[test] + fn dyn_span_interns_by_name() { + let a1 = dyn_span::callsite_for("build"); + let a2 = dyn_span::callsite_for("build"); + let b = dyn_span::callsite_for("render"); + assert!(std::ptr::eq(a1, a2), "same name should reuse the callsite"); + assert!(!std::ptr::eq(a1, b), "distinct names get distinct callsites"); } } diff --git a/crates/pixi_build_types/src/lib.rs b/crates/pixi_build_types/src/lib.rs index ec590ad7b6..c725d33d67 100644 --- a/crates/pixi_build_types/src/lib.rs +++ b/crates/pixi_build_types/src/lib.rs @@ -4,6 +4,7 @@ mod channel_configuration; mod conda_package_metadata; mod extra_group_name; mod input_glob_set; +pub mod log; pub mod procedures; mod project_model; mod variant; diff --git a/crates/pixi_build_types/src/log.rs b/crates/pixi_build_types/src/log.rs new file mode 100644 index 0000000000..32562eb6cf --- /dev/null +++ b/crates/pixi_build_types/src/log.rs @@ -0,0 +1,159 @@ +//! Structured log records emitted by a build backend over stderr. +//! +//! When the frontend launches a backend it sets [`BACKEND_LOG_FORMAT_ENV`] to +//! [`BACKEND_LOG_FORMAT_JSON`]. The backend then serialises every `tracing` +//! event and span lifecycle transition as a [`BackendLogRecord`] preceded by +//! [`BACKEND_LOG_SENTINEL`] and followed by a newline. Stderr lines without +//! the sentinel are treated as raw build output (e.g. compiler stdout/stderr +//! forwarded by the backend). + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +/// Sentinel prefix that marks a stderr line as a structured [`BackendLogRecord`]. +/// +/// The two `U+001F` (Unit Separator) bytes around the tag make it +/// vanishingly unlikely to occur in normal build output while staying +/// printable enough to recognise when eyeballing a log. +pub const BACKEND_LOG_SENTINEL: &str = "\u{1f}pixi-log\u{1f}"; + +/// Environment variable read by the backend to pick a stderr log format. +pub const BACKEND_LOG_FORMAT_ENV: &str = "PIXI_BUILD_BACKEND_LOG_FORMAT"; + +/// Value of [`BACKEND_LOG_FORMAT_ENV`] that selects sentinel-tagged JSON. +pub const BACKEND_LOG_FORMAT_JSON: &str = "json"; + +/// A single record emitted by the backend on stderr. Events are emitted as +/// they fire; span lifecycle records bracket their corresponding events so +/// the frontend can mirror the backend's span hierarchy with real +/// `tracing::Span`s rather than per-event fakes. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum BackendLogRecord { + Event(BackendEventRecord), + SpanOpen(BackendSpanOpenRecord), + SpanClose(BackendSpanCloseRecord), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendEventRecord { + pub level: BackendLogLevel, + pub target: String, + pub message: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub fields: Map, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, + /// Id of the span this event was emitted inside, if any. References a + /// [`BackendSpanOpenRecord::id`] previously seen on the wire. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub span_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendSpanOpenRecord { + pub id: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_id: Option, + pub level: BackendLogLevel, + pub target: String, + pub name: String, + #[serde(default, skip_serializing_if = "Map::is_empty")] + pub fields: Map, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timestamp: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BackendSpanCloseRecord { + pub id: u64, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "UPPERCASE")] +pub enum BackendLogLevel { + Trace, + Debug, + Info, + Warn, + Error, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn event_record_round_trips_through_json() { + let mut fields = Map::new(); + fields.insert("count".to_string(), Value::from(7)); + let event = BackendEventRecord { + level: BackendLogLevel::Warn, + target: "pixi_build::recipe".to_string(), + message: "skipping unsupported variant".to_string(), + fields, + timestamp: Some("2026-06-09T12:00:00+00:00".to_string()), + span_id: Some(42), + }; + let record = BackendLogRecord::Event(event); + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("\"kind\":\"event\"")); + let parsed: BackendLogRecord = serde_json::from_str(&json).unwrap(); + let BackendLogRecord::Event(parsed_event) = parsed else { + panic!("expected Event variant"); + }; + assert_eq!(parsed_event.level, BackendLogLevel::Warn); + assert_eq!(parsed_event.span_id, Some(42)); + assert_eq!( + parsed_event.fields.get("count").and_then(|v| v.as_i64()), + Some(7) + ); + } + + #[test] + fn span_open_round_trips() { + let record = BackendLogRecord::SpanOpen(BackendSpanOpenRecord { + id: 7, + parent_id: Some(3), + level: BackendLogLevel::Info, + target: "pixi_build::build".to_string(), + name: "render".to_string(), + fields: Map::new(), + timestamp: None, + }); + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("\"kind\":\"span_open\"")); + let parsed: BackendLogRecord = serde_json::from_str(&json).unwrap(); + let BackendLogRecord::SpanOpen(open) = parsed else { + panic!("expected SpanOpen variant"); + }; + assert_eq!(open.id, 7); + assert_eq!(open.parent_id, Some(3)); + assert_eq!(open.name, "render"); + } + + #[test] + fn span_close_round_trips() { + let record = BackendLogRecord::SpanClose(BackendSpanCloseRecord { id: 11 }); + let json = serde_json::to_string(&record).unwrap(); + assert!(json.contains("\"kind\":\"span_close\"")); + let parsed: BackendLogRecord = serde_json::from_str(&json).unwrap(); + assert!(matches!(parsed, BackendLogRecord::SpanClose(c) if c.id == 11)); + } + + #[test] + fn empty_collections_omitted_in_wire_format() { + let record = BackendLogRecord::Event(BackendEventRecord { + level: BackendLogLevel::Info, + target: "t".into(), + message: "m".into(), + fields: Map::new(), + timestamp: None, + span_id: None, + }); + let json = serde_json::to_string(&record).unwrap(); + assert!(!json.contains("fields")); + assert!(!json.contains("timestamp")); + assert!(!json.contains("span_id")); + } +}