use heck::ToShoutySnakeCase;
use obs_build::{LintField, LintInput, LintProtoType};
use obs_types::{Cardinality, Classification, FieldKind, Tier};
use proc_macro2::{Span, TokenStream};
use quote::{ToTokens, format_ident, quote};
use syn::{
Attribute, Data, DataStruct, DeriveInput, Field, Fields, Ident, LitStr, Meta, Token, Type,
parse2, punctuated::Punctuated,
};
pub(crate) fn expand(input: TokenStream) -> syn::Result<TokenStream> {
let derive: DeriveInput = parse2(input)?;
let name = derive.ident.clone();
let vis = derive.vis.clone();
let Data::Struct(DataStruct {
fields: Fields::Named(named),
..
}) = &derive.data
else {
return Err(syn::Error::new_spanned(
&derive.ident,
"#[derive(Event)] only supports structs with named fields",
));
};
let container = parse_container_attrs(&derive.attrs)?;
let fields = named
.named
.iter()
.map(parse_field)
.collect::<syn::Result<Vec<_>>>()?;
let full_name_lit = container.full_name(&name);
let tier = container.tier_path();
let default_sev = container.sev_path();
let schema_hash = compute_schema_hash(&full_name_lit.value(), &container, &fields);
let fields_const = fields_const_array(&fields);
let project_body = project_impl(&fields);
let project_metrics_body = project_metrics_impl(&full_name_lit.value(), &fields);
let encode_body = encode_payload_impl(&fields);
let paired_with_expr: TokenStream = match container.paired_with.as_deref() {
Some(s) if !s.is_empty() => quote!(::std::option::Option::Some(#s)),
_ => quote!(::std::option::Option::None),
};
let lints = lint_block(&name, &container, &fields)?;
let schema_static_ident =
format_ident!("__OBS_SCHEMA_{}", name.to_string().to_shouty_snake_case());
let erased_struct_ident = format_ident!("{}Schema", name);
let builder_ident = format_ident!("{}Builder", name);
let setter_methods = fields.iter().map(|f| {
let ident = &f.ident;
let ty = &f.ty;
quote! {
#[doc = "Setter generated by `#[derive(Event)]`."]
#vis fn #ident(mut self, value: impl ::std::convert::Into<#ty>) -> Self {
self.inner.#ident = value.into();
self
}
}
});
let out = quote! {
impl ::obs_core::EventSchema for #name {
const FULL_NAME: &'static str = #full_name_lit;
const TIER: ::obs_core::__private::Tier = #tier;
const DEFAULT_SEV: ::obs_core::__private::Severity = #default_sev;
const FIELDS: &'static [::obs_core::FieldMeta] = &#fields_const;
const SCHEMA_HASH: u64 = #schema_hash;
const SPANS_PAIRED_WITH: ::std::option::Option<&'static str> = #paired_with_expr;
fn encode_payload(&self, buf: &mut ::obs_core::__private::BytesMut) {
#encode_body
}
fn project(&self, env: &mut ::obs_core::ObsEnvelope) {
#project_body
}
fn project_metrics(&self, sink: &mut dyn ::obs_core::MetricEmitter) {
#project_metrics_body
}
}
#[doc(hidden)]
#[allow(non_camel_case_types)]
#vis struct #erased_struct_ident;
impl ::obs_core::__private::Sealed for #erased_struct_ident {}
impl ::obs_core::__private::EventSchemaErased for #erased_struct_ident {
fn full_name(&self) -> &'static str {
<#name as ::obs_core::EventSchema>::FULL_NAME
}
fn schema_hash(&self) -> u64 {
<#name as ::obs_core::EventSchema>::SCHEMA_HASH
}
fn tier(&self) -> ::obs_core::__private::Tier {
<#name as ::obs_core::EventSchema>::TIER
}
fn default_sev(&self) -> ::obs_core::__private::Severity {
<#name as ::obs_core::EventSchema>::DEFAULT_SEV
}
fn fields(&self) -> &'static [::obs_core::FieldMeta] {
<#name as ::obs_core::EventSchema>::FIELDS
}
fn spans_paired_with(&self) -> ::std::option::Option<&'static str> {
<#name as ::obs_core::EventSchema>::SPANS_PAIRED_WITH
}
}
#[::obs_core::__private::linkme::distributed_slice(::obs_core::__private::EVENT_SCHEMAS)]
#[linkme(crate = ::obs_core::__private::linkme)]
#[doc(hidden)]
static #schema_static_ident: &'static dyn ::obs_core::__private::EventSchemaErased
= &#erased_struct_ident;
#lints
#[doc = "Builder generated by `#[derive(Event)]`. Each setter takes the"]
#[doc = "field's concrete type. Call `.emit()` to ship the event through"]
#[doc = "`obs::observer()`."]
#vis struct #builder_ident {
inner: #name,
}
impl #builder_ident {
#(#setter_methods)*
#vis fn build(self) -> #name {
self.inner
}
#[allow(clippy::let_underscore_must_use, dead_code)]
#vis fn emit(self) {
let evt = self.build();
static __CALLSITE: ::obs_core::__private::ObsCallsite =
::obs_core::__private::ObsCallsite::new(
<#name as ::obs_core::EventSchema>::FULL_NAME,
<#name as ::obs_core::EventSchema>::DEFAULT_SEV,
module_path!(),
file!(),
line!(),
);
::obs_core::emit::emit_with_callsite::<#name>(
&__CALLSITE,
&evt,
<#name as ::obs_core::EventSchema>::DEFAULT_SEV,
);
}
#[allow(clippy::let_underscore_must_use, dead_code)]
#vis fn emit_at(self, sev: ::obs_core::__private::Severity) {
let evt = self.build();
static __CALLSITE: ::obs_core::__private::ObsCallsite =
::obs_core::__private::ObsCallsite::new(
<#name as ::obs_core::EventSchema>::FULL_NAME,
<#name as ::obs_core::EventSchema>::DEFAULT_SEV,
module_path!(),
file!(),
line!(),
);
::obs_core::emit::emit_with_callsite::<#name>(&__CALLSITE, &evt, sev);
}
}
impl #name {
#vis fn builder() -> #builder_ident {
#builder_ident {
inner: <Self as ::std::default::Default>::default(),
}
}
}
};
Ok(out)
}
#[derive(Debug)]
struct ContainerAttrs {
tier: String,
default_sev: String,
full_name: Option<String>,
paired_with: Option<String>,
}
impl ContainerAttrs {
fn full_name(&self, ident: &Ident) -> LitStr {
let value = self.full_name.clone().unwrap_or_else(|| {
let pkg = std::env::var("CARGO_PKG_NAME").unwrap_or_else(|_| "anon".to_string());
let pkg_norm = pkg
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>();
format!("{pkg_norm}.v1.{ident}")
});
LitStr::new(&value, Span::call_site())
}
fn tier_path(&self) -> TokenStream {
match self.tier.to_ascii_lowercase().as_str() {
"log" => quote!(::obs_core::__private::Tier::Log),
"metric" => quote!(::obs_core::__private::Tier::Metric),
"trace" => quote!(::obs_core::__private::Tier::Trace),
"audit" => quote!(::obs_core::__private::Tier::Audit),
other => {
let msg = format!("obs: unknown tier `{other}`; expected log|metric|trace|audit");
quote!(compile_error!(#msg))
}
}
}
fn sev_path(&self) -> TokenStream {
match self.default_sev.to_ascii_lowercase().as_str() {
"trace" => quote!(::obs_core::__private::Severity::Trace),
"debug" => quote!(::obs_core::__private::Severity::Debug),
"info" => quote!(::obs_core::__private::Severity::Info),
"warn" => quote!(::obs_core::__private::Severity::Warn),
"error" => quote!(::obs_core::__private::Severity::Error),
"fatal" => quote!(::obs_core::__private::Severity::Fatal),
other => {
let msg = format!(
"obs: unknown default_sev `{other}`; expected \
trace|debug|info|warn|error|fatal"
);
quote!(compile_error!(#msg))
}
}
}
}
fn parse_container_attrs(attrs: &[Attribute]) -> syn::Result<ContainerAttrs> {
let mut tier = String::from("log");
let mut default_sev = String::from("info");
let mut full_name: Option<String> = None;
let mut paired_with: Option<String> = None;
for attr in attrs {
if !attr.path().is_ident("event") {
continue;
}
let pairs: Punctuated<Meta, Token![,]> =
attr.parse_args_with(Punctuated::parse_terminated)?;
for meta in pairs {
match meta {
Meta::NameValue(nv) if nv.path.is_ident("tier") => {
tier = lit_to_string(&nv.value)?;
}
Meta::NameValue(nv) if nv.path.is_ident("default_sev") => {
default_sev = lit_to_string(&nv.value)?;
}
Meta::NameValue(nv) if nv.path.is_ident("full_name") => {
full_name = Some(lit_to_string(&nv.value)?);
}
Meta::NameValue(nv) if nv.path.is_ident("paired_with") => {
paired_with = Some(lit_to_string(&nv.value)?);
}
other => {
return Err(syn::Error::new_spanned(
other,
"#[event(...)] supports tier / default_sev / full_name / paired_with",
));
}
}
}
}
Ok(ContainerAttrs {
tier,
default_sev,
full_name,
paired_with,
})
}
struct ObsField {
ident: Ident,
ty: Type,
role: String,
cardinality: String,
classification: String,
proto_number: u32,
metric_kind: String,
unit: Option<String>,
bounds: Vec<f64>,
}
impl ObsField {
fn role_token(&self) -> TokenStream {
match self.role.as_str() {
"label" => quote!(::obs_core::FieldRole::Label),
"attribute" => quote!(::obs_core::FieldRole::Attribute),
"measurement" => quote!(::obs_core::FieldRole::Measurement),
"trace_id" => quote!(::obs_core::FieldRole::TraceId),
"span_id" => quote!(::obs_core::FieldRole::SpanId),
"parent_span_id" => quote!(::obs_core::FieldRole::ParentSpanId),
"timestamp_ns" => quote!(::obs_core::FieldRole::TimestampNs),
"duration_ns" => quote!(::obs_core::FieldRole::DurationNs),
"forensic" => quote!(::obs_core::FieldRole::Forensic),
_ => quote!(::obs_core::FieldRole::Attribute),
}
}
fn cardinality_token(&self) -> TokenStream {
match self.cardinality.as_str() {
"low" => quote!(::obs_core::__private::Cardinality::Low),
"medium" => quote!(::obs_core::__private::Cardinality::Medium),
"high" => quote!(::obs_core::__private::Cardinality::High),
"unbounded" => quote!(::obs_core::__private::Cardinality::Unbounded),
_ => quote!(::obs_core::__private::Cardinality::Unspecified),
}
}
fn classification_token(&self) -> TokenStream {
match self.classification.as_str() {
"pii" => quote!(::obs_core::__private::Classification::Pii),
"secret" => quote!(::obs_core::__private::Classification::Secret),
"internal" => quote!(::obs_core::__private::Classification::Internal),
_ => quote!(::obs_core::__private::Classification::Internal),
}
}
}
fn parse_field(field: &Field) -> syn::Result<ObsField> {
let ident = field
.ident
.clone()
.ok_or_else(|| syn::Error::new_spanned(field, "obs: only named fields are supported"))?;
let mut role = String::new();
let mut cardinality = String::new();
let mut classification = String::new();
let mut number: u32 = 0;
let mut metric_kind = String::new();
let mut unit: Option<String> = None;
let mut bounds: Vec<f64> = Vec::new();
for attr in &field.attrs {
if !attr.path().is_ident("obs") {
continue;
}
let pairs: Punctuated<Meta, Token![,]> =
attr.parse_args_with(Punctuated::parse_terminated)?;
for meta in pairs {
match meta {
Meta::Path(p) => {
role = p.get_ident().map(Ident::to_string).unwrap_or_default();
}
Meta::NameValue(nv) if nv.path.is_ident("cardinality") => {
cardinality = lit_to_string(&nv.value)?;
}
Meta::NameValue(nv) if nv.path.is_ident("classification") => {
classification = lit_to_string(&nv.value)?;
}
Meta::NameValue(nv) if nv.path.is_ident("number") => {
number = lit_to_string(&nv.value)?
.parse::<u32>()
.map_err(|e| syn::Error::new_spanned(nv, format!("invalid number: {e}")))?;
}
Meta::NameValue(nv) if nv.path.is_ident("metric") => {
metric_kind = lit_to_string(&nv.value)?;
}
Meta::NameValue(nv) if nv.path.is_ident("unit") => {
unit = Some(lit_to_string(&nv.value)?);
}
Meta::NameValue(nv) if nv.path.is_ident("bounds") => {
let raw = lit_to_string(&nv.value)?;
bounds = raw
.split(',')
.filter_map(|s| s.trim().parse::<f64>().ok())
.collect();
}
other => {
return Err(syn::Error::new_spanned(
other,
"#[obs(...)] supports the role keyword (label|attribute|measurement|trace_id|span_id|parent_span_id|timestamp_ns|duration_ns|forensic) plus cardinality / classification / number / metric / unit / bounds",
));
}
}
}
}
Ok(ObsField {
ident,
ty: field.ty.clone(),
role,
cardinality,
classification,
proto_number: number,
metric_kind,
unit,
bounds,
})
}
fn lit_to_string(expr: &syn::Expr) -> syn::Result<String> {
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Str(s),
..
}) = expr
{
return Ok(s.value());
}
if let syn::Expr::Lit(syn::ExprLit {
lit: syn::Lit::Int(i),
..
}) = expr
{
return Ok(i.base10_digits().to_string());
}
Err(syn::Error::new_spanned(expr, "expected string literal"))
}
fn fields_const_array(fields: &[ObsField]) -> TokenStream {
let entries = fields.iter().enumerate().map(|(i, f)| {
let name_lit = LitStr::new(&f.ident.to_string(), Span::call_site());
let role = f.role_token();
let card = f.cardinality_token();
let classn = f.classification_token();
let number: u32 = if f.proto_number == 0 {
(i as u32) + 1
} else {
f.proto_number
};
quote! {
::obs_core::FieldMeta::new(
#name_lit,
#number,
#role,
#card,
#classn,
)
}
});
quote!([#(#entries),*])
}
fn project_impl(fields: &[ObsField]) -> TokenStream {
let mut stmts: Vec<TokenStream> = Vec::new();
for f in fields {
let ident = &f.ident;
let name = LitStr::new(&ident.to_string(), Span::call_site());
match f.role.as_str() {
"label" => stmts.push(quote! {
{
let v = ::std::string::ToString::to_string(&self.#ident);
env.labels.insert(#name.to_string(), v);
}
}),
"trace_id" => stmts.push(quote! {
env.trace_id = ::std::string::ToString::to_string(&self.#ident);
}),
"span_id" => stmts.push(quote! {
env.span_id = ::std::string::ToString::to_string(&self.#ident);
}),
"parent_span_id" => stmts.push(quote! {
env.parent_span_id = ::std::string::ToString::to_string(&self.#ident);
}),
_ => {}
}
}
quote! { #(#stmts)* }
}
fn project_metrics_impl(full_name: &str, fields: &[ObsField]) -> TokenStream {
let mut stmts: Vec<TokenStream> = Vec::new();
for f in fields {
if f.role != "measurement" {
continue;
}
let ident = &f.ident;
let instrument = format!("{full_name}.{}", ident);
let unit_expr: TokenStream = match f.unit.as_deref() {
Some(u) if !u.is_empty() => quote!(::std::option::Option::Some(#u)),
_ => quote!(::std::option::Option::None),
};
let kind = f.metric_kind.to_ascii_lowercase();
match kind.as_str() {
"gauge" => stmts.push(quote! {
sink.record_gauge_u64(#instrument, self.#ident as u64, #unit_expr);
}),
"histogram" => {
let bounds = &f.bounds;
stmts.push(quote! {
static BOUNDS: &[f64] = &[#(#bounds),*];
sink.record_histogram(
#instrument,
self.#ident as f64,
#unit_expr,
BOUNDS,
);
});
}
_ => stmts.push(quote! {
sink.record_counter(#instrument, self.#ident as u64, #unit_expr);
}),
}
}
if stmts.is_empty() {
quote! { let _ = sink; }
} else {
quote! { #(#stmts)* }
}
}
fn encode_payload_impl(fields: &[ObsField]) -> TokenStream {
let stmts = fields.iter().enumerate().map(|(i, f)| {
let ident = &f.ident;
let number: u32 = if f.proto_number == 0 {
(i as u32) + 1
} else {
f.proto_number
};
quote! {
<_ as ::obs_core::__private::BuffaEncodeField>::buffa_encode_field(
&self.#ident,
#number,
buf,
);
}
});
quote! {
#(#stmts)*
}
}
fn lint_block(
name: &Ident,
container: &ContainerAttrs,
fields: &[ObsField],
) -> syn::Result<TokenStream> {
let prefix = std::env::var("OBS_EVENT_PREFIX").unwrap_or_else(|_| "Obs".to_string());
let input = LintInput {
event_name: name.to_string(),
tier: parse_tier(&container.tier),
event_prefix: prefix,
fields: fields.iter().map(field_to_lint).collect(),
};
let asserts: Vec<TokenStream> = obs_build::emit_lints(&input)
.into_iter()
.map(|err| {
let msg = err.message;
quote! { const _: () = ::std::panic!(#msg); }
})
.collect();
Ok(quote! { #(#asserts)* })
}
fn parse_tier(s: &str) -> Tier {
match s.to_ascii_lowercase().as_str() {
"log" => Tier::Log,
"metric" => Tier::Metric,
"trace" => Tier::Trace,
"audit" => Tier::Audit,
_ => Tier::Unspecified,
}
}
fn parse_field_kind(s: &str) -> FieldKind {
match s {
"label" => FieldKind::Label,
"attribute" => FieldKind::Attribute,
"measurement" => FieldKind::Measurement,
"trace_id" => FieldKind::TraceId,
"span_id" => FieldKind::SpanId,
"parent_span_id" => FieldKind::ParentSpanId,
"timestamp_ns" => FieldKind::TimestampNs,
"duration_ns" => FieldKind::DurationNs,
"forensic" => FieldKind::Forensic,
_ => FieldKind::Attribute,
}
}
fn parse_cardinality(s: &str) -> Cardinality {
match s {
"low" => Cardinality::Low,
"medium" => Cardinality::Medium,
"high" => Cardinality::High,
"unbounded" => Cardinality::Unbounded,
_ => Cardinality::Unspecified,
}
}
fn parse_classification(s: &str) -> Classification {
match s {
"pii" => Classification::Pii,
"secret" => Classification::Secret,
"internal" => Classification::Internal,
_ => Classification::Internal,
}
}
fn field_to_lint(f: &ObsField) -> LintField {
let proto_type = LintProtoType::from_rust_token(&f.ty.to_token_stream().to_string());
LintField {
name: f.ident.to_string(),
kind: parse_field_kind(&f.role),
cardinality: parse_cardinality(&f.cardinality),
classification: parse_classification(&f.classification),
has_metric: !f.metric_kind.is_empty(),
proto_type: Some(proto_type),
}
}
fn compute_schema_hash(full_name: &str, container: &ContainerAttrs, fields: &[ObsField]) -> u64 {
let mut s = String::new();
s.push_str(full_name);
s.push('|');
s.push_str(&container.tier);
s.push('|');
s.push_str(&container.default_sev);
s.push('|');
for f in fields {
s.push_str(&f.ident.to_string());
s.push(':');
s.push_str(&f.role);
s.push(':');
s.push_str(&f.cardinality);
s.push(':');
s.push_str(&f.classification);
s.push(',');
}
let h = blake3::hash(s.as_bytes());
let bytes = h.as_bytes();
let arr = <[u8; 8]>::try_from(&bytes[..8]).expect("blake3 always produces 32 bytes");
u64::from_le_bytes(arr)
}