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