Skip to main content

miden_node_tracing_macro/
lib.rs

1use std::collections::BTreeSet;
2
3use proc_macro::TokenStream;
4use proc_macro2::{Delimiter, Group, TokenStream as TokenStream2, TokenTree};
5use quote::{ToTokens, quote};
6use syn::parse::{Parse, ParseStream};
7use syn::punctuated::Punctuated;
8use syn::token::Dot;
9use syn::visit::Visit;
10use syn::{
11    Attribute,
12    Block,
13    Expr,
14    Ident,
15    ItemFn,
16    LitStr,
17    Macro,
18    Meta,
19    Result,
20    Token,
21    parse_macro_input,
22    parse_quote,
23};
24
25/// Instruments a function using canonical tracing attributes.
26///
27/// Field values must implement `RecordAttribute`, and their names must be registered for the value
28/// type. A field whose name ends in `.count` accepts any `usize` without registration. Append
29/// `#[nonstandard]` to any other field value to permit an unregistered name while retaining its
30/// canonical encoding.
31#[proc_macro_attribute]
32pub fn miden_instrument(attr: TokenStream, item: TokenStream) -> TokenStream {
33    let attr = match rewrite_explicit_fields(TokenStream2::from(attr)) {
34        Ok(attr) => attr,
35        Err(error) => return error.into_compile_error().into(),
36    };
37    let mut function = parse_macro_input!(item as ItemFn);
38    let fields = collect_recorded_fields(&function);
39    let args = match merge_inferred_fields(attr, &fields) {
40        Ok(args) => args,
41        Err(error) => return error.into_compile_error().into(),
42    };
43    let statements = &function.block.stmts;
44    let block: Block = parse_quote! {{
45        #[allow(unused_macros)]
46        macro_rules! __miden_span_record_must_be_used_within_miden_instrument {
47            () => {};
48        }
49
50        #(#statements)*
51    }};
52    *function.block = block;
53
54    let expanded = quote! {
55        #[::tracing::instrument(#args)]
56        #function
57    };
58
59    expanded.into()
60}
61
62/// Emits a trace-level event.
63///
64/// An optional first argument may provide an error implementing `ErrorReport`. Its display value
65/// and source chain are recorded as `exception.message`; callers do not provide that attribute
66/// themselves.
67///
68/// The event name is required and must be a string literal. When an error is provided, optional
69/// `target:` and `parent:` arguments go between the error and name, in that order. Without an
70/// error, they precede the name. Attributes follow the name and must use a registered field name
71/// and a value implementing `RecordAttribute`. Append `#[nonstandard]` to a field value to permit
72/// an unregistered name while retaining its canonical encoding. Tracing format specifiers and
73/// trailing commas are not supported.
74///
75/// The name is recorded as tracing's `message` field, which the OpenTelemetry tracing layer uses
76/// as the event name.
77///
78/// ```rust,ignore
79/// use miden_node_utils::tracing::trace;
80///
81/// trace!(target: "node", "block.received", block.number = 42_u32);
82///
83/// let source = std::io::Error::other("invalid block");
84/// trace!(&source, "block.rejected", block.number = 42_u32);
85/// ```
86#[proc_macro]
87pub fn trace(input: TokenStream) -> TokenStream {
88    expand_event(input, "trace", false)
89}
90
91/// Emits a debug-level event.
92///
93/// An optional first argument may provide an error implementing `ErrorReport`. Its display value
94/// and source chain are recorded as `exception.message`; callers do not provide that attribute
95/// themselves.
96///
97/// The event name is required and must be a string literal. When an error is provided, optional
98/// `target:` and `parent:` arguments go between the error and name, in that order. Without an
99/// error, they precede the name. Attributes follow the name and must use a registered field name
100/// and a value implementing `RecordAttribute`. Append `#[nonstandard]` to a field value to permit
101/// an unregistered name while retaining its canonical encoding. Tracing format specifiers and
102/// trailing commas are not supported.
103///
104/// The name is recorded as tracing's `message` field, which the OpenTelemetry tracing layer uses
105/// as the event name.
106///
107/// ```rust,ignore
108/// use miden_node_utils::tracing::debug;
109///
110/// debug!("block.queued", block.number = 42_u32);
111///
112/// let source = std::io::Error::other("upstream unavailable");
113/// debug!(&source, "block.retrying", block.number = 42_u32);
114/// ```
115#[proc_macro]
116pub fn debug(input: TokenStream) -> TokenStream {
117    expand_event(input, "debug", false)
118}
119
120/// Emits an info-level event.
121///
122/// An optional first argument may provide an error implementing `ErrorReport`. Its display value
123/// and source chain are recorded as `exception.message`; callers do not provide that attribute
124/// themselves.
125///
126/// The event name is required and must be a string literal. When an error is provided, optional
127/// `target:` and `parent:` arguments go between the error and name, in that order. Without an
128/// error, they precede the name. Attributes follow the name and must use a registered field name
129/// and a value implementing `RecordAttribute`. Append `#[nonstandard]` to a field value to permit
130/// an unregistered name while retaining its canonical encoding. Tracing format specifiers and
131/// trailing commas are not supported.
132///
133/// The name is recorded as tracing's `message` field, which the OpenTelemetry tracing layer uses
134/// as the event name.
135///
136/// ```rust,ignore
137/// use miden_node_utils::tracing::info;
138///
139/// let parent = tracing::info_span!("block");
140/// info!(parent: &parent, "block.accepted", block.number = 42_u32);
141///
142/// let source = std::io::Error::other("used fallback");
143/// info!(&source, "block.fallback_used", block.number = 42_u32);
144/// ```
145#[proc_macro]
146pub fn info(input: TokenStream) -> TokenStream {
147    expand_event(input, "info", false)
148}
149
150/// Emits a warning-level event.
151///
152/// An optional first argument may provide an error implementing `ErrorReport`. Its display value
153/// and source chain are recorded as `exception.message`; callers do not provide that attribute
154/// themselves.
155///
156/// The event name is required and must be a string literal. When an error is provided, optional
157/// `target:` and `parent:` arguments go between the error and name, in that order. Without an
158/// error, they precede the name. Attributes follow the name and must use a registered field name
159/// and a value implementing `RecordAttribute`. Append `#[nonstandard]` to a field value to permit
160/// an unregistered name while retaining its canonical encoding. Tracing format specifiers and
161/// trailing commas are not supported.
162///
163/// The name is recorded as tracing's `message` field, which the OpenTelemetry tracing layer uses
164/// as the event name.
165///
166/// ```rust,ignore
167/// use miden_node_utils::tracing::warn;
168///
169/// warn!("block.delayed", block.number = 42_u32);
170///
171/// let source = std::io::Error::other("upstream unavailable");
172/// warn!(&source, "block.retrying", block.number = 42_u32);
173/// ```
174#[proc_macro]
175pub fn warn(input: TokenStream) -> TokenStream {
176    expand_event(input, "warn", false)
177}
178
179/// Emits an error-level event with a complete error report.
180///
181/// The first argument is required and must implement `ErrorReport`. Its display value and source
182/// chain are recorded as `exception.message`; callers do not provide that attribute themselves.
183///
184/// The event name follows the error and must be a string literal. Optional `target:` and `parent:`
185/// arguments go between the error and name, in that order. Additional attributes follow the name
186/// and must use a registered field name and a value implementing `RecordAttribute`. Append
187/// `#[nonstandard]` to a field value to permit an unregistered name while retaining its canonical
188/// encoding. Tracing format specifiers and trailing commas are not supported.
189///
190/// The name is recorded as tracing's `message` field, which the OpenTelemetry tracing layer uses
191/// as the event name.
192///
193/// ```rust,ignore
194/// use miden_node_utils::tracing::error;
195///
196/// let source = std::io::Error::other("database unavailable");
197/// error!(source, target: "node", "block.store_failed", block.number = 42_u32);
198/// ```
199#[proc_macro]
200pub fn error(input: TokenStream) -> TokenStream {
201    expand_event(input, "error", true)
202}
203
204fn expand_event(input: TokenStream, level: &str, error_required: bool) -> TokenStream {
205    let event = if error_required {
206        syn::parse::<ErrorEvent>(input).map(|event| event.0)
207    } else {
208        syn::parse::<OptionalErrorEvent>(input).map(|event| event.0)
209    };
210    let event = match event {
211        Ok(event) => event,
212        Err(error) => return error.into_compile_error().into(),
213    };
214
215    event.tokens(&Ident::new(level, proc_macro2::Span::call_site())).into()
216}
217
218struct ErrorEvent(Event);
219
220impl Parse for ErrorEvent {
221    fn parse(input: ParseStream<'_>) -> Result<Self> {
222        let event = parse_error_event(input)?;
223        event.reject_exception_message()?;
224
225        Ok(Self(event))
226    }
227}
228
229struct OptionalErrorEvent(Event);
230
231impl Parse for OptionalErrorEvent {
232    fn parse(input: ParseStream<'_>) -> Result<Self> {
233        let event = if starts_without_error(input) {
234            Event::parse_after_error(input, None)?
235        } else {
236            parse_error_event(input)?
237        };
238        event.reject_exception_message()?;
239
240        Ok(Self(event))
241    }
242}
243
244fn parse_error_event(input: ParseStream<'_>) -> Result<Event> {
245    if input.is_empty() {
246        return Err(input.error("expected an error expression"));
247    }
248
249    let error = input.parse()?;
250    if input.is_empty() {
251        return Err(syn::Error::new_spanned(error, "expected a static event name string literal"));
252    }
253    input.parse::<Token![,]>()?;
254
255    Event::parse_after_error(input, Some(error))
256}
257
258struct Event {
259    error: Option<Expr>,
260    target: Option<Expr>,
261    parent: Option<Expr>,
262    name: LitStr,
263    fields: Vec<RecordField>,
264}
265
266impl Event {
267    fn parse_after_error(input: ParseStream<'_>, error: Option<Expr>) -> Result<Self> {
268        let target = if input.peek(event_kw::target) {
269            input.parse::<event_kw::target>()?;
270            input.parse::<Token![:]>()?;
271            let target = input.parse()?;
272            input.parse::<Token![,]>()?;
273            Some(target)
274        } else {
275            None
276        };
277
278        let parent = if input.peek(event_kw::parent) {
279            input.parse::<event_kw::parent>()?;
280            input.parse::<Token![:]>()?;
281            let parent = input.parse()?;
282            input.parse::<Token![,]>()?;
283            Some(parent)
284        } else {
285            None
286        };
287
288        let name = input
289            .parse::<LitStr>()
290            .map_err(|_| input.error("expected a static event name string literal"))?;
291        let mut fields = Vec::new();
292
293        if !input.is_empty() {
294            let comma = input.parse::<Token![,]>()?;
295            if input.is_empty() {
296                return Err(syn::Error::new_spanned(comma, "trailing commas are not supported"));
297            }
298
299            loop {
300                fields.push(RecordField::parse(input, true)?);
301                if input.is_empty() {
302                    break;
303                }
304
305                let comma = input.parse::<Token![,]>()?;
306                if input.is_empty() {
307                    return Err(syn::Error::new_spanned(
308                        comma,
309                        "trailing commas are not supported",
310                    ));
311                }
312            }
313        }
314
315        Ok(Self { error, target, parent, name, fields })
316    }
317
318    fn reject_exception_message(&self) -> Result<()> {
319        if let Some(field) =
320            self.fields.iter().find(|field| field.path.name() == "exception.message")
321        {
322            Err(syn::Error::new_spanned(
323                &field.path,
324                "pass the error as the first argument instead of recording `exception.message`",
325            ))
326        } else {
327            Ok(())
328        }
329    }
330
331    fn tokens(&self, level: &Ident) -> TokenStream2 {
332        let target = self.target.as_ref().map(|target| quote! { target: #target, });
333        let parent = self.parent.as_ref().map(|parent| quote! { parent: #parent, });
334        let name = &self.name;
335        let error = self.error.as_ref().map(|error| {
336            quote! {
337                , exception.message = ::miden_node_utils::tracing::record_attribute(
338                    &({
339                        use ::miden_node_utils::ErrorReport as _;
340                        (#error).as_report()
341                    })
342                )
343            }
344        });
345        let fields = self.fields.iter().map(RecordField::instrument_tokens);
346
347        quote! {
348            ::tracing::#level!(
349                #target
350                #parent
351                message = #name
352                #error
353                #(, #fields)*
354            )
355        }
356    }
357}
358
359mod event_kw {
360    syn::custom_keyword!(parent);
361    syn::custom_keyword!(target);
362}
363
364fn starts_without_error(input: ParseStream<'_>) -> bool {
365    if input.peek(LitStr) {
366        return true;
367    }
368
369    let ahead = input.fork();
370    let starts_with_target =
371        ahead.parse::<event_kw::target>().is_ok() && ahead.parse::<Token![:]>().is_ok();
372    if starts_with_target {
373        return true;
374    }
375
376    let ahead = input.fork();
377    ahead.parse::<event_kw::parent>().is_ok() && ahead.parse::<Token![:]>().is_ok()
378}
379
380fn merge_inferred_fields(attr: TokenStream2, fields: &[FieldPath]) -> Result<TokenStream2> {
381    let mut args = split_top_level_args(attr);
382    reject_skip_directives(&args)?;
383
384    // Function arguments often contain large or sensitive values. Always skip them so spans only
385    // contain fields explicitly declared by the caller or inferred from `miden_span_record!`.
386    args.push(quote! { skip_all });
387
388    if fields.is_empty() {
389        return Ok(quote! { #(#args),* });
390    }
391
392    let inferred_fields = quote! { #(#fields = ::tracing::field::Empty),* };
393    let mut merged_existing_fields = false;
394    let args = args
395        .into_iter()
396        .map(|arg| {
397            if let Some(group) = fields_group(&arg) {
398                merged_existing_fields = true;
399                let existing_fields = group.stream();
400                let merged_fields = if existing_fields.is_empty() {
401                    inferred_fields.clone()
402                } else if ends_with_comma(&existing_fields) {
403                    quote! { #existing_fields #inferred_fields }
404                } else {
405                    quote! { #existing_fields, #inferred_fields }
406                };
407                let mut merged_group = Group::new(Delimiter::Parenthesis, merged_fields);
408                merged_group.set_span(group.span());
409                quote! { fields #merged_group }
410            } else {
411                arg
412            }
413        })
414        .collect::<Vec<_>>();
415
416    if merged_existing_fields {
417        Ok(quote! { #(#args),* })
418    } else {
419        Ok(quote! { #(#args,)* fields(#inferred_fields) })
420    }
421}
422
423fn reject_skip_directives(args: &[TokenStream2]) -> Result<()> {
424    for arg in args {
425        let Some(TokenTree::Ident(ident)) = arg.clone().into_iter().next() else {
426            continue;
427        };
428        if ident == "skip" || ident == "skip_all" {
429            return Err(syn::Error::new_spanned(
430                arg,
431                format!(
432                    "`{ident}` is not supported by `miden_instrument`; function arguments are \
433                     always skipped, record fields explicitly with `fields(...)`"
434                ),
435            ));
436        }
437    }
438
439    Ok(())
440}
441
442fn rewrite_explicit_fields(attr: TokenStream2) -> Result<TokenStream2> {
443    let args = split_top_level_args(attr)
444        .into_iter()
445        .map(|arg| {
446            if let Some(group) = fields_group(&arg) {
447                let fields = syn::parse2::<InstrumentFields>(group.stream())?;
448                let fields = fields.fields.iter().map(RecordField::instrument_tokens);
449                let mut rewritten = Group::new(Delimiter::Parenthesis, quote! { #(#fields),* });
450                rewritten.set_span(group.span());
451                Ok(quote! { fields #rewritten })
452            } else {
453                Ok(arg)
454            }
455        })
456        .collect::<Result<Vec<_>>>()?;
457
458    Ok(quote! { #(#args),* })
459}
460
461fn reject_formatter(input: ParseStream<'_>) -> Result<()> {
462    let formatter = if input.peek(Token![%]) {
463        Some(input.parse::<Token![%]>()?.span)
464    } else if input.peek(Token![?]) {
465        Some(input.parse::<Token![?]>()?.span)
466    } else {
467        None
468    };
469
470    if let Some(span) = formatter {
471        Err(syn::Error::new(
472            span,
473            "tracing format specifiers are not supported; implement `RecordAttribute` to define \
474             the type's canonical encoding",
475        ))
476    } else {
477        Ok(())
478    }
479}
480
481impl RecordField {
482    fn instrument_tokens(&self) -> TokenStream2 {
483        let path = &self.path;
484        if let Some(value) = &self.value {
485            let value = value.value_tokens(&self.path.name(), self.path.is_count());
486            quote! { #path = #value }
487        } else {
488            quote! { #path }
489        }
490    }
491}
492
493fn split_top_level_args(tokens: TokenStream2) -> Vec<TokenStream2> {
494    let mut args = Vec::new();
495    let mut current = TokenStream2::new();
496
497    for token in tokens {
498        match &token {
499            TokenTree::Punct(punct) if punct.as_char() == ',' => {
500                args.push(current);
501                current = TokenStream2::new();
502            },
503            _ => current.extend([token]),
504        }
505    }
506
507    if !current.is_empty() {
508        args.push(current);
509    }
510
511    args
512}
513
514fn fields_group(arg: &TokenStream2) -> Option<Group> {
515    let mut tokens = arg.clone().into_iter();
516    let Some(TokenTree::Ident(ident)) = tokens.next() else {
517        return None;
518    };
519    if ident != "fields" {
520        return None;
521    }
522
523    let Some(TokenTree::Group(group)) = tokens.next() else {
524        return None;
525    };
526    if group.delimiter() != Delimiter::Parenthesis || tokens.next().is_some() {
527        return None;
528    }
529
530    Some(group)
531}
532
533fn ends_with_comma(tokens: &TokenStream2) -> bool {
534    matches!(
535        tokens.clone().into_iter().last(),
536        Some(TokenTree::Punct(punct)) if punct.as_char() == ','
537    )
538}
539
540/// Records canonical attributes on the current `miden_instrument` span.
541///
542/// Field values must implement `RecordAttribute`, and their names must be registered for the value
543/// type. A field whose name ends in `.count` accepts any `usize` without registration. Append
544/// `#[nonstandard]` to any other field value to permit an unregistered name while retaining its
545/// canonical encoding.
546#[proc_macro]
547pub fn miden_span_record(input: TokenStream) -> TokenStream {
548    let records = parse_macro_input!(input as RecordFields);
549    let records = records.fields.into_iter().map(|field| {
550        let name = field.path.name();
551        let value = field
552            .value
553            .expect("record fields are parsed with required values")
554            .value_tokens(&name, field.path.is_count());
555
556        quote! {
557            ::tracing::Span::current().record(#name, #value);
558        }
559    });
560
561    quote! {
562        __miden_span_record_must_be_used_within_miden_instrument!();
563        #(#records)*
564    }
565    .into()
566}
567
568fn collect_recorded_fields(function: &ItemFn) -> Vec<FieldPath> {
569    let mut visitor = MacroVisitor::default();
570    visitor.visit_block(&function.block);
571
572    let mut names = BTreeSet::new();
573    visitor.fields.into_iter().filter(|field| names.insert(field.name())).collect()
574}
575
576#[derive(Default)]
577struct MacroVisitor {
578    fields: Vec<FieldPath>,
579}
580
581impl<'ast> Visit<'ast> for MacroVisitor {
582    fn visit_macro(&mut self, mac: &'ast Macro) {
583        if mac
584            .path
585            .segments
586            .last()
587            .is_some_and(|segment| segment.ident == "miden_span_record")
588        {
589            if let Ok(records) = syn::parse2::<RecordFields>(mac.tokens.clone()) {
590                self.fields.extend(records.fields.into_iter().map(|field| field.path));
591            }
592        }
593
594        syn::visit::visit_macro(self, mac);
595    }
596}
597
598type InstrumentFields = Fields<false>;
599type RecordFields = Fields<true>;
600
601struct Fields<const VALUE_REQUIRED: bool> {
602    fields: Punctuated<RecordField, Token![,]>,
603}
604
605impl<const VALUE_REQUIRED: bool> Parse for Fields<VALUE_REQUIRED> {
606    fn parse(input: ParseStream<'_>) -> Result<Self> {
607        Ok(Self {
608            fields: Punctuated::parse_terminated_with(input, |input| {
609                RecordField::parse(input, VALUE_REQUIRED)
610            })?,
611        })
612    }
613}
614
615struct RecordField {
616    path: FieldPath,
617    value: Option<RecordValue>,
618}
619
620impl RecordField {
621    fn parse(input: ParseStream<'_>, value_required: bool) -> Result<Self> {
622        reject_formatter(input)?;
623        let path = input.parse()?;
624        let value = if value_required || input.peek(Token![=]) {
625            input.parse::<Token![=]>()?;
626            reject_formatter(input)?;
627            Some(input.parse()?)
628        } else {
629            None
630        };
631
632        Ok(Self { path, value })
633    }
634}
635
636struct FieldPath {
637    first: Ident,
638    rest: Vec<(Dot, Ident)>,
639}
640
641impl FieldPath {
642    fn name(&self) -> String {
643        std::iter::once(&self.first)
644            .chain(self.rest.iter().map(|(_, ident)| ident))
645            .map(ToString::to_string)
646            .collect::<Vec<_>>()
647            .join(".")
648    }
649
650    fn is_count(&self) -> bool {
651        self.rest.last().is_some_and(|(_, ident)| ident == "count")
652    }
653}
654
655impl Parse for FieldPath {
656    fn parse(input: ParseStream<'_>) -> Result<Self> {
657        let first = input.parse()?;
658        let mut rest = Vec::new();
659
660        while input.peek(Token![.]) {
661            rest.push((input.parse()?, input.parse()?));
662        }
663
664        Ok(Self { first, rest })
665    }
666}
667
668impl ToTokens for FieldPath {
669    fn to_tokens(&self, tokens: &mut TokenStream2) {
670        self.first.to_tokens(tokens);
671        for (dot, ident) in &self.rest {
672            dot.to_tokens(tokens);
673            ident.to_tokens(tokens);
674        }
675    }
676}
677
678struct RecordValue {
679    expr: Expr,
680    nonstandard: bool,
681}
682
683impl RecordValue {
684    fn value_tokens(&self, field_name: &str, is_count: bool) -> TokenStream2 {
685        let expr = &self.expr;
686        let assert_field_name = (!self.nonstandard && !is_count).then(|| {
687            quote! {
688                fn __miden_assert_field_name<T>(_: &T)
689                where
690                    T: ::miden_node_utils::tracing::RecordAttribute + ?Sized,
691                {
692                    const {
693                        assert!(
694                            ::miden_node_utils::tracing::field_name_allowed(
695                                T::FIELD_NAMES,
696                                #field_name,
697                                T::PLURALIZE_FIELD_NAMES,
698                            ),
699                            concat!(
700                                "tracing field `",
701                                #field_name,
702                                "` is not allowed for this attribute type",
703                            ),
704                        );
705                    }
706                }
707
708                __miden_assert_field_name(value);
709            }
710        });
711        let assert_count = is_count.then(|| {
712            quote! {
713                fn __miden_assert_count(_: &usize) {}
714
715                __miden_assert_count(value);
716            }
717        });
718
719        quote! {
720            match &(#expr) {
721                value => {
722                    #assert_field_name
723                    #assert_count
724                    ::miden_node_utils::tracing::record_attribute(value)
725                }
726            }
727        }
728    }
729}
730
731impl Parse for RecordValue {
732    fn parse(input: ParseStream<'_>) -> Result<Self> {
733        let expr = input.parse()?;
734        let attributes = input.call(Attribute::parse_outer)?;
735        let nonstandard = match attributes.as_slice() {
736            [] => false,
737            [attribute] if matches!(&attribute.meta, Meta::Path(path) if path.is_ident("nonstandard")) => {
738                true
739            },
740            [attribute, ..] => {
741                return Err(syn::Error::new_spanned(
742                    attribute,
743                    "only `#[nonstandard]` is supported after a tracing field value",
744                ));
745            },
746        };
747
748        Ok(Self { expr, nonstandard })
749    }
750}