#![allow(
clippy::format_push_string,
clippy::uninlined_format_args,
clippy::format_in_format_args
)]
use heck::ToShoutySnakeCase;
use obs_types::{Cardinality, Classification, FieldKind, Severity, Tier};
use crate::{
lints::{self, LintField, LintInput, LintProtoType},
options::{EventOptions, FieldOptions},
};
#[derive(Debug)]
pub(crate) struct EventDecl {
pub full_name: String,
pub event: EventOptions,
pub fields: Vec<FieldDecl>,
}
#[derive(Debug)]
pub(crate) struct FieldDecl {
pub name: String,
pub number: u32,
pub options: FieldOptions,
pub proto_type: Option<LintProtoType>,
pub wire_rust_type: Option<&'static str>,
pub enum_rust_path: Option<String>,
}
impl EventDecl {
pub(crate) fn rust_name(&self) -> &str {
self.full_name.rsplit('.').next().unwrap_or(&self.full_name)
}
pub(crate) fn rust_path(&self) -> String {
self.full_name.replace('.', "::")
}
pub(crate) fn schema_static_ident(&self) -> String {
format!("__OBS_SCHEMA_{}", self.rust_name().to_shouty_snake_case())
}
pub(crate) fn erased_struct_ident(&self) -> String {
format!("{}Schema", self.rust_name())
}
pub(crate) fn builder_struct_ident(&self) -> String {
format!("{}Builder", self.rust_name())
}
pub(crate) fn schema_hash(&self) -> u64 {
let mut s = String::new();
s.push_str(&self.full_name);
s.push('|');
s.push_str(self.event.tier.unwrap_or(Tier::Log).as_str());
s.push('|');
s.push_str(self.event.default_sev.unwrap_or(Severity::Info).as_str());
s.push('|');
for f in &self.fields {
s.push_str(&f.name);
s.push(':');
s.push_str(f.options.kind.unwrap_or(FieldKind::Attribute).as_str());
s.push(':');
s.push_str(
f.options
.cardinality
.unwrap_or(Cardinality::Unspecified)
.as_str(),
);
s.push(':');
s.push_str(
f.options
.classification
.unwrap_or(Classification::Internal)
.as_str(),
);
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)
}
}
pub(crate) fn render_schemas(events: &[EventDecl]) -> String {
let mut out = String::new();
out.push_str("// @generated by obs-build. DO NOT EDIT.\n");
out.push_str("// File: schemas.rs — EventSchemaErased impls + linkme registrations.\n\n");
for evt in events {
out.push_str(&render_one_schema(evt));
out.push('\n');
}
out
}
fn render_one_schema(decl: &EventDecl) -> String {
let erased_struct = decl.erased_struct_ident();
let static_ident = decl.schema_static_ident();
let rust_path = decl.rust_path();
let tier = decl.event.tier.unwrap_or(Tier::Log);
let sev = decl.event.default_sev.unwrap_or(Severity::Info);
let schema_hash = decl.schema_hash();
let mut fields_lit = String::from("&[");
for f in &decl.fields {
let role_str = field_role_path(f.options.kind.unwrap_or(FieldKind::Attribute));
let card = cardinality_path(f.options.cardinality.unwrap_or(Cardinality::Unspecified));
let classn =
classification_path(f.options.classification.unwrap_or(Classification::Internal));
fields_lit.push_str(&format!(
"::obs_core::FieldMeta::new(\"{name}\", {num}, {role}, {card}, {classn}),",
name = f.name,
num = f.number,
role = role_str,
card = card,
classn = classn,
));
}
fields_lit.push(']');
let project_body = render_project_body(&decl.fields);
let project_metrics_body = render_project_metrics_body(&decl.full_name, &decl.fields);
let paired_with_lit = match decl.event.paired_with.as_deref() {
Some(s) if !s.is_empty() => format!("::std::option::Option::Some({})", rust_str_lit(s)),
_ => "::std::option::Option::None".to_string(),
};
format!(
r#"impl ::obs_core::EventSchema for {rust_path} {{
const FULL_NAME: &'static str = "{full_name}";
const TIER: ::obs_core::__private::Tier = {tier_path};
const DEFAULT_SEV: ::obs_core::__private::Severity = {sev_path};
const FIELDS: &'static [::obs_core::FieldMeta] = {fields};
const SCHEMA_HASH: u64 = {hash}u64;
const SPANS_PAIRED_WITH: ::std::option::Option<&'static str> = {paired_with};
fn encode_payload(&self, buf: &mut ::obs_core::__private::BytesMut) {{
// Delegate to buffa's `Message::write_to`. The size cache is
// a per-call scratch; we accept the small allocation cost
// here in exchange for keeping `EventSchema` infallible.
let mut __cache = ::buffa::SizeCache::default();
let _ = <Self as ::buffa::Message>::compute_size(self, &mut __cache);
<Self as ::buffa::Message>::write_to(self, &mut __cache, buf);
}}
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)]
#[derive(Debug)]
#[allow(non_camel_case_types)]
pub struct {erased_struct};
impl ::obs_core::__private::Sealed for {erased_struct} {{}}
impl ::obs_core::__private::EventSchemaErased for {erased_struct} {{
fn full_name(&self) -> &'static str {{ <{rust_path} as ::obs_core::EventSchema>::FULL_NAME }}
fn schema_hash(&self) -> u64 {{ <{rust_path} as ::obs_core::EventSchema>::SCHEMA_HASH }}
fn tier(&self) -> ::obs_core::__private::Tier {{ <{rust_path} as ::obs_core::EventSchema>::TIER }}
fn default_sev(&self) -> ::obs_core::__private::Severity {{ <{rust_path} as ::obs_core::EventSchema>::DEFAULT_SEV }}
fn fields(&self) -> &'static [::obs_core::FieldMeta] {{ <{rust_path} as ::obs_core::EventSchema>::FIELDS }}
fn spans_paired_with(&self) -> ::std::option::Option<&'static str> {{
<{rust_path} 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 {static_ident}: &'static dyn ::obs_core::__private::EventSchemaErased = &{erased_struct};
"#,
erased_struct = erased_struct,
full_name = decl.full_name,
rust_path = rust_path,
hash = schema_hash,
tier_path = tier_path(tier),
sev_path = sev_path(sev),
fields = fields_lit,
static_ident = static_ident,
project_body = project_body,
project_metrics_body = project_metrics_body,
paired_with = paired_with_lit,
)
}
fn render_project_metrics_body(full_name: &str, fields: &[FieldDecl]) -> String {
use obs_types::MetricKind;
let mut out = String::new();
let mut had_any = false;
for f in fields {
if !matches!(
f.options.kind.unwrap_or(FieldKind::Attribute),
FieldKind::Measurement
) {
continue;
}
had_any = true;
let instrument_lit = format!("{full_name}.{}", f.name);
let (kind, unit, bounds) = match &f.options.metric {
Some(spec) => (
spec.kind.unwrap_or(MetricKind::Counter),
spec.unit.clone(),
spec.bounds.clone(),
),
None => (MetricKind::Counter, None, Vec::new()),
};
let unit_expr = match unit.as_deref() {
Some(u) if !u.is_empty() => {
format!("::std::option::Option::Some({})", rust_str_lit(u))
}
_ => "::std::option::Option::None".to_string(),
};
match kind {
MetricKind::Counter => {
out.push_str(&format!(
" sink.record_counter({inst}, self.{name} as u64, {unit});\n",
inst = rust_str_lit(&instrument_lit),
name = f.name,
unit = unit_expr,
));
}
MetricKind::Gauge => {
out.push_str(&format!(
" sink.record_gauge_u64({inst}, self.{name} as u64, {unit});\n",
inst = rust_str_lit(&instrument_lit),
name = f.name,
unit = unit_expr,
));
}
MetricKind::Histogram => {
let bounds_lit = if bounds.is_empty() {
"&[] as &[f64]".to_string()
} else {
let parts: Vec<String> = bounds.iter().map(|b| format!("{b:?}")).collect();
format!("&[{}] as &[f64]", parts.join(", "))
};
let static_ident = format!(
"__OBS_HIST_BOUNDS_{}_{}",
full_name.replace('.', "_").to_uppercase(),
f.name.to_uppercase()
);
out.push_str(&format!(
" static {ident}: &[f64] = {bounds};\n \
sink.record_histogram({inst}, self.{name} as f64, {unit}, {ident});\n",
ident = static_ident,
bounds = bounds_lit,
inst = rust_str_lit(&instrument_lit),
name = f.name,
unit = unit_expr,
));
}
_ => {
out.push_str(&format!(
" sink.record_counter({inst}, self.{name} as u64, {unit});\n",
inst = rust_str_lit(&instrument_lit),
name = f.name,
unit = unit_expr,
));
}
}
}
if !had_any {
out.push_str(" let _ = sink;\n");
}
out
}
fn render_project_body(fields: &[FieldDecl]) -> String {
let mut out = String::new();
for f in fields {
let kind = f.options.kind.unwrap_or(FieldKind::Attribute);
match kind {
FieldKind::Label => {
out.push_str(&format!(
" env.labels.insert(\"{name}\".to_string(), \
::std::string::ToString::to_string(&self.{name}));\n",
name = f.name
));
}
FieldKind::TraceId => {
out.push_str(&format!(
" env.trace_id = ::std::string::ToString::to_string(&self.{name});\n",
name = f.name
));
}
FieldKind::SpanId => {
out.push_str(&format!(
" env.span_id = ::std::string::ToString::to_string(&self.{name});\n",
name = f.name
));
}
FieldKind::ParentSpanId => {
out.push_str(&format!(
" env.parent_span_id = \
::std::string::ToString::to_string(&self.{name});\n",
name = f.name
));
}
_ => {}
}
}
if out.is_empty() {
out.push_str(" let _ = env;\n");
}
out
}
pub(crate) fn render_builders(events: &[EventDecl]) -> String {
let mut out = String::new();
out.push_str("// @generated by obs-build. DO NOT EDIT.\n");
out.push_str("// File: builders.rs — fluent builder + .emit() / .emit_at() per event.\n\n");
for evt in events {
out.push_str(&render_one_builder(evt));
out.push('\n');
}
out
}
fn render_one_builder(decl: &EventDecl) -> String {
let rust_path = decl.rust_path();
let builder = decl.builder_struct_ident();
let setters: String = decl
.fields
.iter()
.map(|f| render_setter(f, &rust_path))
.collect();
format!(
r#"/// Builder generated by `obs-build` for `{full}`.
#[derive(Debug)]
pub struct {builder} {{
inner: {rust_path},
}}
impl {builder} {{
{setters}
/// Finalise the builder.
pub fn build(self) -> {rust_path} {{ self.inner }}
/// Build and emit at the schema-declared default severity.
pub fn emit(self) {{
let evt = self.build();
static __CALLSITE: ::obs_core::__private::ObsCallsite =
::obs_core::__private::ObsCallsite::new(
<{rust_path} as ::obs_core::EventSchema>::FULL_NAME,
<{rust_path} as ::obs_core::EventSchema>::DEFAULT_SEV,
module_path!(),
file!(),
line!(),
);
::obs_core::emit::emit_with_callsite::<{rust_path}>(
&__CALLSITE,
&evt,
<{rust_path} as ::obs_core::EventSchema>::DEFAULT_SEV,
);
}}
/// Build and emit at a specific severity.
pub fn emit_at(self, sev: ::obs_core::__private::Severity) {{
let evt = self.build();
static __CALLSITE: ::obs_core::__private::ObsCallsite =
::obs_core::__private::ObsCallsite::new(
<{rust_path} as ::obs_core::EventSchema>::FULL_NAME,
<{rust_path} as ::obs_core::EventSchema>::DEFAULT_SEV,
module_path!(),
file!(),
line!(),
);
::obs_core::emit::emit_with_callsite::<{rust_path}>(&__CALLSITE, &evt, sev);
}}
}}
impl {rust_path} {{
/// Begin building a `{full}` event.
pub fn builder() -> {builder} {{
{builder} {{ inner: <{rust_path} as ::std::default::Default>::default() }}
}}
}}
"#,
full = decl.full_name,
rust_path = rust_path,
builder = builder,
setters = setters,
)
}
fn render_setter(field: &FieldDecl, rust_path: &str) -> String {
let kind = field.options.kind.unwrap_or(FieldKind::Attribute);
let proto_type = field.proto_type.as_ref();
if let Some(enum_path) = field.enum_rust_path.as_deref() {
let setter_param = format!(
"impl ::std::convert::Into<::buffa::EnumValue<{}>>",
enum_path
);
let assign = format!("self.inner.{} = value.into();", field.name);
return format!(
" /// Setter for `{name}` (proto field {num}); generated for `{rust_path}`.\n \
#[allow(clippy::needless_pass_by_value, clippy::missing_const_for_fn)]\n pub fn \
{name}(mut self, value: {param}) -> Self {{ {assign} self }}\n",
name = field.name,
num = field.number,
rust_path = rust_path,
param = setter_param,
assign = assign,
);
}
let typed_setter = match (kind, proto_type, field.wire_rust_type) {
(
FieldKind::Measurement | FieldKind::TimestampNs | FieldKind::DurationNs,
_,
Some(rust_ty),
) => Some(rust_ty),
(FieldKind::Measurement | FieldKind::TimestampNs | FieldKind::DurationNs, _, None) => {
Some("u64")
}
(
FieldKind::Label | FieldKind::Attribute | FieldKind::Forensic,
Some(LintProtoType::Bool),
_,
) => Some("bool"),
(
FieldKind::Label | FieldKind::Attribute | FieldKind::Forensic,
Some(LintProtoType::Numeric),
Some(rust_ty),
) => Some(rust_ty),
(
FieldKind::Label | FieldKind::Attribute | FieldKind::Forensic,
Some(LintProtoType::Numeric),
None,
) => Some("u64"),
(
FieldKind::Label | FieldKind::Attribute | FieldKind::Forensic,
Some(LintProtoType::Bytes),
_,
) => Some("::std::vec::Vec<u8>"),
_ => None,
};
let (setter_param, assign) = match typed_setter {
Some(ty) => (
ty.to_string(),
format!("self.inner.{} = value;", field.name),
),
None => (
"impl ::std::convert::Into<::std::string::String>".to_string(),
format!("self.inner.{} = value.into();", field.name),
),
};
let setter_param = setter_param.as_str();
let assign = assign.as_str();
format!(
" /// Setter for `{name}` (proto field {num}); generated for `{rust_path}`.\n \
#[allow(clippy::needless_pass_by_value, clippy::missing_const_for_fn)]\n pub fn \
{name}(mut self, value: {param}) -> Self {{ {assign} self }}\n",
name = field.name,
num = field.number,
rust_path = rust_path,
param = setter_param,
assign = assign,
)
}
pub(crate) fn render_lints(events: &[EventDecl], event_prefix: &str) -> String {
let mut out = String::new();
out.push_str("// @generated by obs-build. DO NOT EDIT.\n");
out.push_str(
"// File: lints.rs — const-eval lint asserts (spec 12 § 3.4 / spec 95 § 2.1 D8-1).\n// A \
failure here is a hard compile error.\n\n",
);
for evt in events {
out.push_str(&render_one_lint(evt, event_prefix));
out.push('\n');
}
out.push_str(&render_l013(events));
out
}
fn render_one_lint(decl: &EventDecl, event_prefix: &str) -> String {
let input = build_lint_input(decl, event_prefix);
let mut s = String::new();
for err in lints::emit_lints(&input) {
s.push_str(&format!(
"const _: () = ::std::panic!({});\n",
rust_str_lit(&err.message)
));
}
s
}
fn build_lint_input(decl: &EventDecl, event_prefix: &str) -> LintInput {
LintInput {
event_name: decl.rust_name().to_string(),
tier: decl.event.tier.unwrap_or(Tier::Log),
event_prefix: event_prefix.to_string(),
fields: decl
.fields
.iter()
.map(|f| LintField {
name: f.name.clone(),
kind: f.options.kind.unwrap_or(FieldKind::Attribute),
cardinality: f.options.cardinality.unwrap_or(Cardinality::Unspecified),
classification: f.options.classification.unwrap_or(Classification::Internal),
has_metric: f.options.metric.is_some(),
proto_type: f.proto_type.clone(),
})
.collect(),
}
}
fn render_l013(events: &[EventDecl]) -> String {
if events.len() < 2 {
return String::new();
}
let pairs: Vec<(String, u64)> = events
.iter()
.map(|e| (e.full_name.clone(), e.schema_hash()))
.collect();
let errs = lints::emit_cross_event_lints(&pairs);
if errs.is_empty() {
return String::new();
}
let mut s = String::new();
s.push_str("\n// L013: schema_hash uniqueness across this codegen unit. Spec 95 § 2.1.\n");
for err in errs {
s.push_str(&format!(
"const _: () = ::std::panic!({});\n",
rust_str_lit(&err.message)
));
}
s
}
fn rust_str_lit(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 2);
out.push('"');
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{{{:x}}}", c as u32)),
c => out.push(c),
}
}
out.push('"');
out
}
pub(crate) fn render_arrow_schema(events: &[EventDecl]) -> String {
let mut out = String::new();
out.push_str("// @generated by obs-build. DO NOT EDIT.\n");
out.push_str(
"// File: arrow_schema.rs — per-event Arrow Field fragment table.\n//\n// Phase-2 ships \
the dispatch table only; the real Arrow Field\n// construction lands in Phase 4A \
(obs-parquet). The table is\n// referenced by ParquetSink::new at startup; until that \
lands,\n// the per-event stubs always return `None` (sparse-NULL behaviour).\n\n",
);
out.push_str(
"/// Returns the Arrow `Field` fragment for `full_name`, when the schema is known to this \
codegen unit.\n",
);
out.push_str(
"/// Phase-2 stub: every entry returns `None`. Phase 4A populates the static fragments.\n",
);
out.push_str("#[must_use]\n");
out.push_str(
"pub fn payload_struct_for(full_name: &str) -> ::std::option::Option<&'static str> {\n",
);
out.push_str(" match full_name {\n");
for evt in events {
out.push_str(&format!(
" \"{}\" => ::std::option::Option::Some(\"{}\"),\n",
evt.full_name, evt.full_name
));
}
out.push_str(" _ => ::std::option::Option::None,\n");
out.push_str(" }\n}\n\n");
out.push_str(
"/// Iterate every `full_name` this codegen unit declares an Arrow fragment for.\n",
);
out.push_str("/// Used by `obs migrate parquet` and `obs migrate clickhouse` (Phase 4A).\n");
out.push_str("pub fn all_payload_full_names() -> &'static [&'static str] {\n");
out.push_str(" &[\n");
for evt in events {
out.push_str(&format!(" \"{}\",\n", evt.full_name));
}
out.push_str(" ]\n}\n");
out
}
pub(crate) fn tier_path(t: Tier) -> &'static str {
match t {
Tier::Log => "::obs_core::__private::Tier::Log",
Tier::Metric => "::obs_core::__private::Tier::Metric",
Tier::Trace => "::obs_core::__private::Tier::Trace",
Tier::Audit => "::obs_core::__private::Tier::Audit",
_ => "::obs_core::__private::Tier::Unspecified",
}
}
pub(crate) fn sev_path(s: Severity) -> &'static str {
match s {
Severity::Trace => "::obs_core::__private::Severity::Trace",
Severity::Debug => "::obs_core::__private::Severity::Debug",
Severity::Info => "::obs_core::__private::Severity::Info",
Severity::Warn => "::obs_core::__private::Severity::Warn",
Severity::Error => "::obs_core::__private::Severity::Error",
Severity::Fatal => "::obs_core::__private::Severity::Fatal",
_ => "::obs_core::__private::Severity::Unspecified",
}
}
pub(crate) fn field_role_path(k: FieldKind) -> &'static str {
match k {
FieldKind::Label => "::obs_core::FieldRole::Label",
FieldKind::Attribute => "::obs_core::FieldRole::Attribute",
FieldKind::Measurement => "::obs_core::FieldRole::Measurement",
FieldKind::TraceId => "::obs_core::FieldRole::TraceId",
FieldKind::SpanId => "::obs_core::FieldRole::SpanId",
FieldKind::ParentSpanId => "::obs_core::FieldRole::ParentSpanId",
FieldKind::TimestampNs => "::obs_core::FieldRole::TimestampNs",
FieldKind::DurationNs => "::obs_core::FieldRole::DurationNs",
FieldKind::Forensic => "::obs_core::FieldRole::Forensic",
_ => "::obs_core::FieldRole::Attribute",
}
}
pub(crate) fn cardinality_path(c: Cardinality) -> &'static str {
match c {
Cardinality::Low => "::obs_core::__private::Cardinality::Low",
Cardinality::Medium => "::obs_core::__private::Cardinality::Medium",
Cardinality::High => "::obs_core::__private::Cardinality::High",
Cardinality::Unbounded => "::obs_core::__private::Cardinality::Unbounded",
_ => "::obs_core::__private::Cardinality::Unspecified",
}
}
pub(crate) fn classification_path(c: Classification) -> &'static str {
match c {
Classification::Pii => "::obs_core::__private::Classification::Pii",
Classification::Secret => "::obs_core::__private::Classification::Secret",
Classification::Internal => "::obs_core::__private::Classification::Internal",
_ => "::obs_core::__private::Classification::Unspecified",
}
}