use crate::diagnostic::{ErrorCode, VeloqDiagnostic};
use crate::meta::ResponseMeta;
use serde::Serialize;
use std::borrow::Cow;
use std::error::Error;
pub const ENVELOPE_VERSION: &str = "v1";
#[derive(Debug, Clone, Copy, Serialize)]
pub struct TraceSpan {
pub origin_ns: i64,
pub span_ns: i64,
}
#[derive(Debug, Serialize)]
pub struct Envelope<T: Serialize> {
pub schema: &'static str,
pub source: SourceRef,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<EnvelopeTraceRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_span: Option<TraceSpan>,
#[serde(skip_serializing_if = "meta_is_absent")]
pub meta: Option<ResponseMeta>,
pub data: T,
}
fn meta_is_absent(m: &Option<ResponseMeta>) -> bool {
match m {
None => true,
Some(m) => m.is_empty(),
}
}
#[derive(Debug, Clone, Copy, Serialize)]
pub struct SourceRef {
pub kind: &'static str,
pub version: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct EnvelopeTraceRef {
pub kind: &'static str,
pub path: String,
}
#[derive(Debug, Serialize)]
pub struct EnvelopeError {
pub schema: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<SourceRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<EnvelopeTraceRef>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_span: Option<TraceSpan>,
#[serde(skip_serializing_if = "meta_is_absent")]
pub meta: Option<ResponseMeta>,
pub error: EnvelopeErrorDetails,
}
#[derive(Debug, Serialize)]
pub struct EnvelopeErrorDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<ErrorCode>,
pub message: String,
pub chain: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub hint: Option<String>,
}
impl<T: Serialize> Envelope<T> {
pub fn new(
source: SourceRef,
command: impl Into<String>,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
meta: Option<ResponseMeta>,
data: T,
) -> Self {
Self {
schema: ENVELOPE_VERSION,
source,
command: command.into(),
trace,
trace_span,
meta,
data,
}
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
}
pub fn emit_envelope<T: Serialize>(
source: SourceRef,
command: impl Into<String>,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
meta: Option<ResponseMeta>,
data: T,
) -> serde_json::Result<()> {
let env = Envelope::new(source, command, trace, trace_span, meta, data);
println!("{}", env.to_json_pretty()?);
Ok(())
}
impl EnvelopeError {
pub fn new(
source: Option<SourceRef>,
command: Option<String>,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
meta: Option<ResponseMeta>,
message: impl Into<String>,
chain: Vec<String>,
) -> Self {
Self {
schema: ENVELOPE_VERSION,
source,
command,
trace,
trace_span,
meta,
error: EnvelopeErrorDetails {
code: None,
message: message.into(),
chain,
hint: None,
},
}
}
pub fn from_error(
source: Option<SourceRef>,
command: Option<String>,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
err: &(dyn Error + 'static),
) -> Self {
let message = err.to_string();
let chain = std_error_chain(err);
Self::new(source, command, trace, trace_span, None, message, chain)
}
pub fn from_diagnostic<E>(
source: Option<SourceRef>,
command: Option<String>,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
err: &E,
) -> Self
where
E: VeloqDiagnostic,
{
let mut env = Self::new(
source,
command,
trace,
trace_span,
None,
err.message(),
std_error_chain(err),
);
env.error.code = Some(err.code());
env.error.hint = err.hint().map(Cow::into_owned);
env
}
pub fn to_json(&self) -> serde_json::Result<String> {
serde_json::to_string(self)
}
pub fn to_json_pretty(&self) -> serde_json::Result<String> {
serde_json::to_string_pretty(self)
}
}
fn std_error_chain(err: &(dyn Error + 'static)) -> Vec<String> {
let mut chain = Vec::new();
let mut current = err.source();
while let Some(source) = current {
chain.push(source.to_string());
current = source.source();
}
chain
}
pub fn write_error_envelope(
source: SourceRef,
verb: &str,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
err: &(dyn Error + 'static),
fmt: crate::OutputFormat,
) {
let env = EnvelopeError::from_error(
Some(source),
Some(format!("{}.{verb}", source.kind)),
trace,
trace_span,
err,
);
if !matches!(fmt, crate::OutputFormat::Json) {
eprintln!("veloq: {err}");
}
if let Ok(s) = env.to_json_pretty() {
println!("{s}");
}
}
pub fn write_diagnostic_error_envelope<E>(
source: SourceRef,
verb: &str,
trace: Option<EnvelopeTraceRef>,
trace_span: Option<TraceSpan>,
err: &E,
fmt: crate::OutputFormat,
) where
E: VeloqDiagnostic,
{
let env = EnvelopeError::from_diagnostic(
Some(source),
Some(format!("{}.{verb}", source.kind)),
trace,
trace_span,
err,
);
if !matches!(fmt, crate::OutputFormat::Json) {
eprintln!("veloq: {err}");
}
if let Ok(s) = env.to_json_pretty() {
println!("{s}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
fn source() -> SourceRef {
SourceRef {
kind: "nsys",
version: "v0",
}
}
fn trace() -> EnvelopeTraceRef {
EnvelopeTraceRef {
kind: "nsys",
path: "/tmp/trace.nsys-rep".into(),
}
}
fn at<'a>(v: &'a Value, ptr: &str) -> anyhow::Result<&'a Value> {
v.pointer(ptr)
.ok_or_else(|| anyhow::anyhow!("missing pointer `{ptr}` in {v}"))
}
fn at_str<'a>(v: &'a Value, ptr: &str) -> anyhow::Result<&'a str> {
at(v, ptr)?
.as_str()
.ok_or_else(|| anyhow::anyhow!("not a string at `{ptr}` in {v}"))
}
fn span() -> TraceSpan {
TraceSpan {
origin_ns: 1_000_000,
span_ns: 5_000_000_000,
}
}
#[test]
fn envelope_shape_has_qualified_command_and_source() -> anyhow::Result<()> {
let env = Envelope::new(
source(),
"nsys.stats",
Some(trace()),
Some(span()),
None,
serde_json::json!({"rows": []}),
);
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert_eq!(at_str(&v, "/schema")?, "v1");
assert_eq!(at_str(&v, "/source/kind")?, "nsys");
assert_eq!(at_str(&v, "/source/version")?, "v0");
assert_eq!(at_str(&v, "/command")?, "nsys.stats");
assert_eq!(at_str(&v, "/trace/kind")?, "nsys");
assert_eq!(at_str(&v, "/trace/path")?, "/tmp/trace.nsys-rep");
assert_eq!(at(&v, "/trace_span/origin_ns")?.as_i64(), Some(1_000_000));
assert_eq!(at(&v, "/trace_span/span_ns")?.as_i64(), Some(5_000_000_000));
assert!(
v.get("meta").is_none(),
"meta with None payload must be omitted: {v}"
);
assert!(at(&v, "/data/rows")?.is_array());
Ok(())
}
#[test]
fn envelope_trace_and_span_omitted_when_none() -> anyhow::Result<()> {
let env = Envelope::new(source(), "sources", None, None, None, serde_json::json!({}));
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert!(v.get("trace").is_none(), "got: {v}");
assert!(v.get("trace_span").is_none(), "got: {v}");
assert!(v.get("meta").is_none(), "got: {v}");
Ok(())
}
#[test]
fn envelope_meta_serialises_when_applied_scope_set() -> anyhow::Result<()> {
use crate::meta::{AppliedScope, ResponseMeta};
let meta = ResponseMeta {
applied_scope: Some(AppliedScope {
device: Some(0),
kind: Some("kernel".into()),
..AppliedScope::default()
}),
..ResponseMeta::default()
};
let env = Envelope::new(
source(),
"nsys.stats",
Some(trace()),
Some(span()),
Some(meta),
serde_json::json!({"rows": []}),
);
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert_eq!(at(&v, "/meta/applied_scope/device")?.as_i64(), Some(0));
assert_eq!(at_str(&v, "/meta/applied_scope/kind")?, "kernel");
assert!(v.pointer("/meta/next_steps").is_none(), "got: {v}");
assert!(v.pointer("/meta/warnings").is_none(), "got: {v}");
Ok(())
}
#[test]
fn envelope_meta_omitted_when_every_subfield_empty() -> anyhow::Result<()> {
use crate::meta::ResponseMeta;
let env = Envelope::new(
source(),
"nsys.stats",
Some(trace()),
Some(span()),
Some(ResponseMeta::default()),
serde_json::json!({"rows": []}),
);
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert!(
v.get("meta").is_none(),
"Some(empty ResponseMeta) must serialise as absent: {v}"
);
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error("disk full")]
struct DiskFull;
#[derive(Debug, thiserror::Error)]
#[error("writing parquet sidecar")]
struct SidecarWriteError(#[source] DiskFull);
#[derive(Debug, thiserror::Error)]
#[error("caching trace summary")]
struct TraceSummaryError(#[source] SidecarWriteError);
#[test]
fn envelope_error_carries_chain_from_std_error() -> anyhow::Result<()> {
let wrapped = TraceSummaryError(SidecarWriteError(DiskFull));
let env = EnvelopeError::from_error(
Some(source()),
Some("nsys.stats".into()),
None,
None,
&wrapped,
);
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert_eq!(at_str(&v, "/schema")?, "v1");
assert_eq!(at_str(&v, "/error/message")?, "caching trace summary");
let chain = at(&v, "/error/chain")?
.as_array()
.ok_or_else(|| anyhow::anyhow!("chain not an array"))?;
let msgs: Vec<&str> = chain.iter().filter_map(|c| c.as_str()).collect();
assert!(msgs.contains(&"writing parquet sidecar"), "got: {msgs:?}");
assert!(msgs.contains(&"disk full"), "got: {msgs:?}");
Ok(())
}
#[derive(Debug, thiserror::Error)]
#[error("demo failed")]
struct DemoDiagnostic {
#[source]
source: std::io::Error,
}
impl VeloqDiagnostic for DemoDiagnostic {
fn code(&self) -> ErrorCode {
ErrorCode::new("demo.failed")
}
}
#[test]
fn envelope_error_carries_code_from_diagnostic() -> anyhow::Result<()> {
let err = DemoDiagnostic {
source: std::io::Error::other("inner cause"),
};
let env = EnvelopeError::from_diagnostic(
Some(source()),
Some("nsys.stats".into()),
None,
None,
&err,
);
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert_eq!(at_str(&v, "/error/code")?, "demo.failed");
assert_eq!(at_str(&v, "/error/message")?, "demo failed");
let chain = at(&v, "/error/chain")?
.as_array()
.ok_or_else(|| anyhow::anyhow!("chain not an array"))?;
let msgs: Vec<&str> = chain.iter().filter_map(|c| c.as_str()).collect();
assert!(msgs.contains(&"inner cause"), "got: {msgs:?}");
Ok(())
}
#[test]
fn envelope_error_omits_optional_fields_when_unset() -> anyhow::Result<()> {
let env = EnvelopeError::new(None, None, None, None, None, "boom", Vec::new());
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert!(v.get("source").is_none());
assert!(v.get("command").is_none());
assert!(v.get("trace").is_none());
assert!(v.get("trace_span").is_none());
assert_eq!(at_str(&v, "/error/message")?, "boom");
Ok(())
}
#[test]
fn envelope_error_emits_trace_span_when_set() -> anyhow::Result<()> {
let env = EnvelopeError::new(
Some(source()),
Some("nsys.stats".into()),
Some(trace()),
Some(span()),
None,
"boom",
Vec::new(),
);
let v: Value = serde_json::from_str(&env.to_json()?)?;
assert_eq!(at(&v, "/trace_span/origin_ns")?.as_i64(), Some(1_000_000));
assert_eq!(at(&v, "/trace_span/span_ns")?.as_i64(), Some(5_000_000_000));
Ok(())
}
}