Skip to main content

backbeat_macros/
lib.rs

1// Copyright (c) 2026 Cameron Bytheway
2// SPDX-License-Identifier: MIT
3
4//! `#[derive(Event)]` for backbeat.
5//!
6//! Annotate a `#[repr(C)]` struct to make it a traceable event. The derive reflects the struct's
7//! fields into a `const` [`EventSchema`] (offsets via [`core::mem::offset_of!`], widths via
8//! [`core::mem::size_of`]), computes a content-addressed [`EventId`] by hashing that whole schema,
9//! and implements [`backbeat::Event`] — whose `zerocopy::IntoBytes` bound makes recording a single
10//! memcpy and rejects padded layouts the reader can't describe.
11//!
12//! ```ignore
13//! use backbeat::{Event, EventEnum};
14//! use backbeat::zerocopy::{Immutable, IntoBytes};
15//!
16//! /// Which way a frame is going.
17//! #[derive(EventEnum, IntoBytes, Immutable, Clone, Copy)]
18//! #[repr(u8)]
19//! enum Direction { Incoming = 0, Outgoing = 1 }
20//!
21//! /// A frame was queued for sending.
22//! #[derive(Event, IntoBytes, Immutable)]
23//! #[event(namespace = "my_crate::frame")]
24//! #[repr(C)]
25//! struct QueueData {
26//!     #[event(key)]            packet_number: u64,
27//!     /// Offset into the stream.
28//!     #[event(unit = "bytes")] offset: u64,
29//!     direction: Direction,    // a strongly-typed enum field
30//!     is_fin: bool,
31//! }
32//! ```
33//!
34//! The generated code exposes `QueueData::SCHEMA` (an `EventSchema`), `QueueData::ID` (its
35//! `EventId`), and `QueueData::QUALIFIED_NAME`, and implements `Event`. Because the id hashes the
36//! whole schema, two builds whose layout or field metadata differ get distinct ids and never alias
37//! in a dump's registry.
38//!
39//! Container attributes (on the struct):
40//!
41//! * `#[event(namespace = "…")]` — required; the event's namespace prefix.
42//! * `#[event(span = enter)]` / `#[event(span = exit)]` — mark this event as one half of a span, so
43//!   the trace converter can pair begin/end records into a duration slice. A spanned event must
44//!   carry exactly one `#[event(span_id)]` field.
45//!
46//! Field attributes (mutually-exclusive *roles* — at most one per field):
47//!
48//! * `#[event(key)]` — promote this field to a top-level join/index column in the output table.
49//! * `#[event(span_id)]` — this `u64` is the span's own id (required on a `span = enter|exit`
50//!   event; the enter and exit halves carry the same value so the converter pairs them).
51//! * `#[event(parent_span_id)]` — this `u64` is the enclosing span's id, linking this event under
52//!   its parent. Allowed on any event, including plain (non-span) ones.
53//!
54//! Other field attributes (combine with a role):
55//!
56//! * `#[event(unit = "…")]` — attach a unit hint (`"bytes"`, `"ns"`, …) carried into the output.
57//! * `#[event(interned)]` / `#[event(interned(dynamic))]` — the (`u32`) field is an intern id
58//!   resolved against the dump's intern table; `dynamic` marks runtime-built values.
59//!
60//! Enum-typed fields use a separate `#[derive(EventEnum)]` on the (`#[repr(uN)]`, fieldless) enum;
61//! the field then carries the strong type and the schema records its value→label map automatically.
62//!
63//! Field and struct doc comments (`///`) are lifted verbatim into the schema's `description`
64//! fields, so the embedded registry documents itself.
65
66use proc_macro::TokenStream;
67use proc_macro2::TokenStream as TokenStream2;
68use quote::quote;
69use syn::{parse_macro_input, Data, DeriveInput, Expr, ExprLit, Fields, Lit, LitStr, Type};
70
71/// A field's role, mirroring `backbeat::schema::FieldRole` on the macro side.
72#[derive(Clone, Copy, PartialEq, Eq)]
73enum Role {
74    Key,
75    SpanId,
76    ParentSpanId,
77}
78
79impl Role {
80    /// The `backbeat::schema::FieldRole` variant tokens this role emits.
81    fn tokens(self) -> TokenStream2 {
82        match self {
83            Role::Key => quote! { ::backbeat::schema::FieldRole::Key },
84            Role::SpanId => quote! { ::backbeat::schema::FieldRole::SpanId },
85            Role::ParentSpanId => quote! { ::backbeat::schema::FieldRole::ParentSpanId },
86        }
87    }
88}
89
90/// A struct's span phase, mirroring `backbeat::schema::Phase`.
91#[derive(Clone, Copy, PartialEq, Eq)]
92enum Phase {
93    None,
94    Enter,
95    Exit,
96}
97
98impl Phase {
99    fn tokens(self) -> TokenStream2 {
100        match self {
101            Phase::None => quote! { ::backbeat::schema::Phase::None },
102            Phase::Enter => quote! { ::backbeat::schema::Phase::Enter },
103            Phase::Exit => quote! { ::backbeat::schema::Phase::Exit },
104        }
105    }
106}
107
108/// Derives `Event` for a struct: its compile-time `EventId`, a reflected `EventSchema`, and an
109/// `impl Event`.
110#[proc_macro_derive(Event, attributes(event))]
111pub fn derive_event(input: TokenStream) -> TokenStream {
112    let input = parse_macro_input!(input as DeriveInput);
113    match expand(input) {
114        Ok(ts) => ts.into(),
115        Err(e) => e.to_compile_error().into(),
116    }
117}
118
119/// Derives `EventEnum` for a fieldless `#[repr(u8|u16|u32|u64)]` enum, so it can be a strongly-typed
120/// event field. Emits the variant→label map and the repr width.
121#[proc_macro_derive(EventEnum)]
122pub fn derive_event_enum(input: TokenStream) -> TokenStream {
123    let input = parse_macro_input!(input as DeriveInput);
124    match expand_event_enum(input) {
125        Ok(ts) => ts.into(),
126        Err(e) => e.to_compile_error().into(),
127    }
128}
129
130fn expand_event_enum(input: DeriveInput) -> syn::Result<TokenStream2> {
131    let name = &input.ident;
132
133    let data = match &input.data {
134        Data::Enum(e) => e,
135        _ => {
136            return Err(syn::Error::new_spanned(
137                &input,
138                "`#[derive(EventEnum)]` can only be applied to an enum",
139            ))
140        }
141    };
142
143    // The discriminant repr width must be an explicit `#[repr(u8|u16|u32|u64)]` — the recorder
144    // stores the enum inline at that width, and the reader needs it to read the discriminant.
145    let repr = enum_repr_width(&input)?;
146
147    // Each variant must be fieldless (it sits inline as a bare discriminant) and contributes a
148    // value→label pair. An explicit discriminant sets the value; otherwise it follows C rules
149    // (previous + 1, starting at 0). We require explicit discriminants so the on-disk value is
150    // never silently shifted by reordering — the value is part of the event's identity.
151    let mut labels = Vec::with_capacity(data.variants.len());
152    for variant in &data.variants {
153        if !matches!(variant.fields, Fields::Unit) {
154            return Err(syn::Error::new_spanned(
155                variant,
156                "`#[derive(EventEnum)]` requires fieldless variants",
157            ));
158        }
159        let label = variant.ident.to_string();
160        let value =
161            match &variant.discriminant {
162                Some((_, expr)) => expr.clone(),
163                None => return Err(syn::Error::new_spanned(
164                    variant,
165                    "`#[derive(EventEnum)]` requires an explicit discriminant, e.g. `Variant = 0` \
166                     (the value is part of the event's on-disk identity)",
167                )),
168            };
169        labels.push(quote! {
170            ::backbeat::schema::EnumLabel { value: (#value) as u64, label: #label }
171        });
172    }
173
174    Ok(quote! {
175        impl ::backbeat::EventEnum for #name {
176            const REPR: u8 = #repr;
177            const LABELS: &'static [::backbeat::schema::EnumLabel] = &[ #(#labels),* ];
178        }
179    })
180}
181
182/// Reads the discriminant byte-width from a `#[repr(uN)]` attribute on an enum.
183fn enum_repr_width(input: &DeriveInput) -> syn::Result<u8> {
184    for attr in &input.attrs {
185        if !attr.path().is_ident("repr") {
186            continue;
187        }
188        let mut width = None;
189        attr.parse_nested_meta(|meta| {
190            width = meta
191                .path
192                .get_ident()
193                .and_then(|i| match i.to_string().as_str() {
194                    "u8" => Some(1),
195                    "u16" => Some(2),
196                    "u32" => Some(4),
197                    "u64" => Some(8),
198                    _ => None,
199                });
200            Ok(())
201        })?;
202        if let Some(w) = width {
203            return Ok(w);
204        }
205    }
206    Err(syn::Error::new_spanned(
207        input,
208        "`#[derive(EventEnum)]` requires an explicit `#[repr(u8|u16|u32|u64)]`",
209    ))
210}
211
212fn expand(input: DeriveInput) -> syn::Result<TokenStream2> {
213    let name = &input.ident;
214
215    // Container attributes: namespace (required) and span phase (optional).
216    let container = ContainerAttrs::parse(&input)?;
217
218    // `#[derive(Event)]` describes a fixed C layout; anything else has no stable offsets to reflect.
219    let fields =
220        match &input.data {
221            Data::Struct(s) => match &s.fields {
222                Fields::Named(named) => named.named.iter().collect::<Vec<_>>(),
223                // No fields is legal only for a plain marker event — a span needs a `span_id` field.
224                Fields::Unit => Vec::new(),
225                Fields::Unnamed(_) => return Err(syn::Error::new_spanned(
226                    &s.fields,
227                    "`#[derive(Event)]` requires named fields (tuple structs have no field names \
228                     to use as column names)",
229                )),
230            },
231            _ => {
232                return Err(syn::Error::new_spanned(
233                    &input,
234                    "`#[derive(Event)]` can only be applied to a struct",
235                ))
236            }
237        };
238
239    let description = doc_of(&input.attrs);
240
241    let mut field_defs = Vec::with_capacity(fields.len());
242    // Track span-role fields for cross-field validation after the loop.
243    let mut span_id_fields = 0usize;
244    let mut parent_span_fields = 0usize;
245    for field in &fields {
246        let ident = field.ident.as_ref().expect("named field");
247        let fname = ident.to_string();
248        let fdesc = doc_of(&field.attrs);
249        let attrs = FieldAttrs::parse(&field.attrs)?;
250
251        // A span-id / parent-span-id field must be a bare `u64`: the converter compares ids for
252        // equality across the enter and exit structs, so a uniform width avoids cross-struct
253        // mismatch, and these roles are meaningless on non-integer/narrower types.
254        if matches!(attrs.role, Some(Role::SpanId | Role::ParentSpanId)) {
255            require_u64(&field.ty, attrs.role.unwrap())?;
256        }
257        match attrs.role {
258            Some(Role::SpanId) => span_id_fields += 1,
259            Some(Role::ParentSpanId) => parent_span_fields += 1,
260            _ => {}
261        }
262
263        let fty = &field.ty;
264
265        // Resolve the field's `FieldType` and labels. An `#[event(interned)]` field is a `u32`
266        // intern id (attribute-driven). Everything else goes through the `FieldTy` trait, resolved
267        // at const-eval: primitives, `[u8; N]`, and any `#[derive(EventEnum)]` type all implement
268        // it, so the macro doesn't need to know the field's concrete type — including enums, whose
269        // variants it cannot see.
270        let (ty_expr, labels_expr) = if let Some(dynamic) = attrs.interned {
271            (
272                quote! { ::backbeat::schema::FieldType::Interned { dynamic: #dynamic } },
273                quote! { &[] },
274            )
275        } else {
276            (
277                quote! { <#fty as ::backbeat::FieldTy>::FIELD_TYPE },
278                quote! { <#fty as ::backbeat::FieldTy>::LABELS },
279            )
280        };
281
282        let desc_expr = opt_str(fdesc.as_deref());
283        let unit_expr = opt_str(attrs.unit.as_deref());
284        let role_expr = match attrs.role {
285            Some(r) => r.tokens(),
286            None => quote! { ::backbeat::schema::FieldRole::None },
287        };
288
289        field_defs.push(quote! {
290            ::backbeat::schema::FieldSchema {
291                name: #fname,
292                description: #desc_expr,
293                ty: #ty_expr,
294                offset: ::backbeat::schema::layout_u16(::core::mem::offset_of!(#name, #ident)),
295                width: ::backbeat::schema::layout_u16(::core::mem::size_of::<#fty>()),
296                role: #role_expr,
297                unit: #unit_expr,
298                enum_labels: #labels_expr,
299            }
300        });
301    }
302
303    // Cross-field span validation.
304    if span_id_fields > 1 {
305        return Err(syn::Error::new_spanned(
306            &input,
307            "an event may declare at most one `#[event(span_id)]` field",
308        ));
309    }
310    if parent_span_fields > 1 {
311        return Err(syn::Error::new_spanned(
312            &input,
313            "an event may declare at most one `#[event(parent_span_id)]` field",
314        ));
315    }
316    match container.phase {
317        // A spanned event must carry exactly one span id (the value enter/exit are paired by).
318        Phase::Enter | Phase::Exit if span_id_fields == 0 => {
319            return Err(syn::Error::new_spanned(
320                &input,
321                "`#[event(span = enter|exit)]` requires exactly one `#[event(span_id)]` field",
322            ));
323        }
324        // A span id only has meaning on an enter/exit event; a plain event associates with a span
325        // via `parent_span_id` instead.
326        Phase::None if span_id_fields > 0 => {
327            return Err(syn::Error::new_spanned(
328                &input,
329                "`#[event(span_id)]` requires the event to be a span \
330                 (`#[event(span = enter)]` or `#[event(span = exit)]`)",
331            ));
332        }
333        _ => {}
334    }
335
336    Ok(emit(name, &container, description, field_defs))
337}
338
339/// Emits the inherent consts (`ID`, `QUALIFIED_NAME`, `SCHEMA`) and the `Event` impl.
340fn emit(
341    name: &syn::Ident,
342    container: &ContainerAttrs,
343    description: Option<String>,
344    field_defs: Vec<TokenStream2>,
345) -> TokenStream2 {
346    let qualified = format!("{}::{name}", container.namespace);
347    let desc_expr = opt_str(description.as_deref());
348    let phase_expr = container.phase.tokens();
349
350    quote! {
351        impl #name {
352            /// Fully-qualified event name, `"namespace::TypeName"`.
353            pub const QUALIFIED_NAME: &'static str = #qualified;
354
355            /// The reflected field layout (also held by [`Self::SCHEMA`]). Named so [`Self::ID`] can
356            /// hash it without referencing `SCHEMA` (which embeds the id — that would be circular).
357            const FIELDS: &'static [::backbeat::schema::FieldSchema] = &[ #(#field_defs),* ];
358
359            /// Content-addressed event id: a hash of the whole schema (name, phase, every field's
360            /// name/type/offset/width/role/unit and any enum labels). Two builds with differing
361            /// layouts get distinct ids and are treated as separate event types sharing a name.
362            pub const ID: ::backbeat::id::EventId =
363                ::backbeat::schema::EventSchema::compute_id(
364                    Self::QUALIFIED_NAME,
365                    #phase_expr,
366                    Self::FIELDS,
367                );
368
369            /// Self-describing layout of this event, reflected from its fields at compile time.
370            pub const SCHEMA: ::backbeat::schema::EventSchema =
371                ::backbeat::schema::EventSchema {
372                    id: Self::ID,
373                    qualified_name: Self::QUALIFIED_NAME,
374                    description: #desc_expr,
375                    record_size: ::backbeat::schema::layout_u16(::core::mem::size_of::<#name>()),
376                    phase: #phase_expr,
377                    fields: Self::FIELDS,
378                };
379        }
380
381        impl ::backbeat::Event for #name {
382            const SCHEMA: ::backbeat::schema::EventSchema = Self::SCHEMA;
383            const ID: ::backbeat::id::EventId = Self::ID;
384            const QUALIFIED_NAME: &'static str = Self::QUALIFIED_NAME;
385        }
386
387        // Register the type so the dumper can self-populate its schema registry. Expands to a
388        // `submit!` under `std` and to nothing on `no_std` (see `backbeat::register_event!`).
389        ::backbeat::register_event!(#name);
390    }
391}
392
393/// Field-level `#[event(...)]` attributes.
394#[derive(Default)]
395struct FieldAttrs {
396    /// The field's role, if any (`key`/`span_id`/`parent_span_id`). At most one — a second is an
397    /// error, so the illegal combinations are unrepresentable.
398    role: Option<Role>,
399    unit: Option<String>,
400    /// `Some(dynamic)` if the field is interned.
401    interned: Option<bool>,
402}
403
404impl FieldAttrs {
405    fn parse(attrs: &[syn::Attribute]) -> syn::Result<Self> {
406        let mut out = FieldAttrs::default();
407        for attr in attrs {
408            if !attr.path().is_ident("event") {
409                continue;
410            }
411            attr.parse_nested_meta(|meta| {
412                if meta.path.is_ident("key") {
413                    out.set_role(Role::Key, &meta)?;
414                    Ok(())
415                } else if meta.path.is_ident("span_id") {
416                    out.set_role(Role::SpanId, &meta)?;
417                    Ok(())
418                } else if meta.path.is_ident("parent_span_id") {
419                    out.set_role(Role::ParentSpanId, &meta)?;
420                    Ok(())
421                } else if meta.path.is_ident("unit") {
422                    let lit: LitStr = meta.value()?.parse()?;
423                    out.unit = Some(lit.value());
424                    Ok(())
425                } else if meta.path.is_ident("interned") {
426                    // `interned` or `interned(dynamic)`.
427                    let mut dynamic = false;
428                    if meta.input.peek(syn::token::Paren) {
429                        meta.parse_nested_meta(|inner| {
430                            if inner.path.is_ident("dynamic") {
431                                dynamic = true;
432                                Ok(())
433                            } else {
434                                Err(inner.error("expected `dynamic`"))
435                            }
436                        })?;
437                    }
438                    out.interned = Some(dynamic);
439                    Ok(())
440                } else {
441                    Err(meta.error("unknown `#[event(...)]` field attribute"))
442                }
443            })?;
444        }
445        Ok(out)
446    }
447
448    /// Sets the field's role, erroring if one was already set (roles are mutually exclusive).
449    fn set_role(&mut self, role: Role, meta: &syn::meta::ParseNestedMeta<'_>) -> syn::Result<()> {
450        if self.role.is_some() {
451            return Err(meta.error(
452                "a field may have at most one role \
453                 (`key` / `span_id` / `parent_span_id` are mutually exclusive)",
454            ));
455        }
456        self.role = Some(role);
457        Ok(())
458    }
459}
460
461/// Container-level (`#[event(...)]` on the struct) attributes: the required namespace and the
462/// optional span phase.
463struct ContainerAttrs {
464    namespace: String,
465    phase: Phase,
466}
467
468impl ContainerAttrs {
469    fn parse(input: &DeriveInput) -> syn::Result<Self> {
470        let mut namespace = None;
471        let mut phase = Phase::None;
472        for attr in &input.attrs {
473            if !attr.path().is_ident("event") {
474                continue;
475            }
476            attr.parse_nested_meta(|meta| {
477                if meta.path.is_ident("namespace") {
478                    let lit: LitStr = meta.value()?.parse()?;
479                    namespace = Some(lit.value());
480                    Ok(())
481                } else if meta.path.is_ident("span") {
482                    // `span = enter` or `span = exit`.
483                    let ident: syn::Ident = meta.value()?.parse()?;
484                    phase = match ident.to_string().as_str() {
485                        "enter" => Phase::Enter,
486                        "exit" => Phase::Exit,
487                        _ => {
488                            return Err(meta.error("`span` must be `enter` or `exit`"));
489                        }
490                    };
491                    Ok(())
492                } else {
493                    Err(meta.error("unknown container-level `#[event(...)]` attribute"))
494                }
495            })?;
496        }
497        let namespace = namespace.ok_or_else(|| {
498            syn::Error::new_spanned(
499                input,
500                "`#[derive(Event)]` requires `#[event(namespace = \"...\")]`",
501            )
502        })?;
503        Ok(Self { namespace, phase })
504    }
505}
506
507/// Enforces that a `span_id` / `parent_span_id` field is a bare `u64`.
508fn require_u64(ty: &Type, role: Role) -> syn::Result<()> {
509    let is_u64 = matches!(ty, Type::Path(p) if p.path.is_ident("u64"));
510    if is_u64 {
511        Ok(())
512    } else {
513        let attr = match role {
514            Role::SpanId => "span_id",
515            Role::ParentSpanId => "parent_span_id",
516            Role::Key => unreachable!("require_u64 is only called for span roles"),
517        };
518        Err(syn::Error::new_spanned(
519            ty,
520            format!(
521                "`#[event({attr})]` fields must be `u64` (span ids are compared for equality \
522                 across the enter/exit events, so they need a uniform width)"
523            ),
524        ))
525    }
526}
527
528/// Joins the `///` doc-comment lines on `attrs` into a single trimmed string, or `None` if there
529/// are none. Each `///` line is a `#[doc = "..."]` attribute with a leading space.
530fn doc_of(attrs: &[syn::Attribute]) -> Option<String> {
531    let mut lines = Vec::new();
532    for attr in attrs {
533        if !attr.path().is_ident("doc") {
534            continue;
535        }
536        if let syn::Meta::NameValue(nv) = &attr.meta {
537            if let Expr::Lit(ExprLit {
538                lit: Lit::Str(s), ..
539            }) = &nv.value
540            {
541                lines.push(s.value().trim().to_string());
542            }
543        }
544    }
545    if lines.is_empty() {
546        None
547    } else {
548        Some(lines.join("\n"))
549    }
550}
551
552/// `Some("…")` → `::core::option::Option::Some("…")`, `None` → `::core::option::Option::None`.
553fn opt_str(s: Option<&str>) -> TokenStream2 {
554    match s {
555        Some(s) => quote! { ::core::option::Option::Some(#s) },
556        None => quote! { ::core::option::Option::None },
557    }
558}