diff --git a/crates/codegen/src/cxx_bridge.rs b/crates/codegen/src/cxx_bridge.rs index a953ee710..960c93eb3 100644 --- a/crates/codegen/src/cxx_bridge.rs +++ b/crates/codegen/src/cxx_bridge.rs @@ -530,7 +530,7 @@ fn emit_context_bridge( let (#(#build_fields,)*) = match opts { None => (#(#build_wraps(#q::Observer::<#build_event_tys>::noop()),)*), Some(options) => { - let inner = #q::Context::try_new(id).map_err(|e| e.to_string())?; + let inner = #q::ContextInner::try_new(id).map_err(|e| e.to_string())?; #q::write_sidecar( &options, id, diff --git a/crates/codegen/src/pyo3_bridge.rs b/crates/codegen/src/pyo3_bridge.rs index 8c735d58e..dd1f4596b 100644 --- a/crates/codegen/src/pyo3_bridge.rs +++ b/crates/codegen/src/pyo3_bridge.rs @@ -685,7 +685,7 @@ fn emit_context( quote! { #[pyclass(name = "Context")] pub struct PyContext { - inner: Option<#q::Context>, + inner: Option<#q::ContextInner>, #(#struct_fields,)* id: #q::uuid::Uuid, } @@ -715,9 +715,9 @@ fn emit_context( }; let id = #q::uuid::Uuid::now_v7(); let inner = match &opts { - Some(_) => #q::Context::try_new(id) + Some(_) => #q::ContextInner::try_new(id) .map_err(|err| pyo3::exceptions::PyRuntimeError::new_err(err.to_string()))?, - None => #q::Context::noop(id), + None => #q::ContextInner::noop(id), }; // Single sync/async bridge: build every entity's observer (each // constructing its exporter from the options, bound to the id) diff --git a/crates/instrumentation-build/example/src/main.rs b/crates/instrumentation-build/example/src/main.rs index 235d691f7..a417b99d8 100644 --- a/crates/instrumentation-build/example/src/main.rs +++ b/crates/instrumentation-build/example/src/main.rs @@ -3,7 +3,7 @@ use quent_instrumentation::{EventCallback, ExporterOptions}; -use crate::demo::{ConnectionHandle, ConnectionObserver, DemoContext, Uuid}; +use crate::demo::{Connection, Context, Demo, Handle, Observer, Query, Server, Uuid}; #[allow(unused)] mod demo { @@ -12,16 +12,16 @@ mod demo { fn main() -> Result<(), Box> { // The context owns the exporter and exposes one observer per entity type. - let context: DemoContext = demo::DemoContext::try_new(Some(debug_printing_exporter()))?; + let context: Context = Context::try_new(Some(debug_printing_exporter()))?; // `observer.handle()` creates a fresh entity instance to events emit for. - let mut server = context.server_observer().handle(); + let mut server = context.observer::().handle(); server.booted()?; - let observer: ConnectionObserver = context.connection_observer(); + let observer: Observer = context.observer::(); // Once-cardinality events take `&mut self` and may fire only once, tracked // by the handle, hence it is mut: - let mut conn: ConnectionHandle = observer.handle(); + let mut conn: Handle = observer.handle(); // One method per entity event: conn.opened( @@ -57,7 +57,7 @@ fn main() -> Result<(), Box> { // // FSMs will get typestate pattern handles in the future, also see // https://github.com/rapidsai/quent/issues/416 - let mut query = context.query_observer().handle(); + let mut query = context.observer::().handle(); query.submitted("select 1".to_owned(), conn.as_entity_ref())?; query.running(10)?; query.ready(true)?; diff --git a/crates/instrumentation-build/src/any_event.rs b/crates/instrumentation-build/src/any_event.rs index eaccbb826..b42ef6872 100644 --- a/crates/instrumentation-build/src/any_event.rs +++ b/crates/instrumentation-build/src/any_event.rs @@ -6,11 +6,13 @@ use convert_case::Case; use proc_macro2::TokenStream; -use quent_schema::Schema; use quote::quote; use syn::Ident; -use crate::common::{derive_attr, raw_ident, to_case}; +use crate::common::{ + derive_attr, module_ident, path_name_pascal, raw_ident, relative_type_path, to_case, +}; +use crate::namespace::Namespace; use crate::{GenerateError, Options}; /// Generate `AnyEvent` and its `from_any` decoder, carrying the event enums' @@ -20,18 +22,32 @@ use crate::{GenerateError, Options}; /// /// Returns [`GenerateError`] if a derive entry is not a parseable Rust path. pub(crate) fn generate_any_event( - schema: &Schema, + namespace: &Namespace<'_>, opts: &Options, ) -> Result { let derives = derive_attr(opts.event_derives)?; - let variants: Vec<(Ident, Ident)> = schema + let variants: Vec<(Ident, TokenStream)> = namespace .entities() + .iter() .map(|entity| { - let pascal = to_case(entity.path().name(), Case::Pascal); + let variant = raw_ident(path_name_pascal(entity.path())); + let event = relative_type_path(entity.path(), namespace.path(), "Event"); + (variant, event) + }) + .collect(); + let children: Vec<(Ident, Ident)> = namespace + .children() + .iter() + .filter(|child| child.has_entities()) + .map(|child| { + let segment = child + .path() + .last() + .expect("child namespaces extend their parent"); ( - raw_ident(pascal.clone()), - raw_ident(format!("{pascal}Event")), + raw_ident(to_case(segment, Case::Pascal)), + module_ident(segment), ) }) .collect(); @@ -39,10 +55,20 @@ pub(crate) fn generate_any_event( let decls = variants.iter().map(|(variant, event)| { quote! { #variant(&'a ::quent_instrumentation::Event<#event>) } }); - let arms = variants.iter().map(|(variant, event)| { + let child_decls = children.iter().map(|(variant, module)| { + quote! { #variant(#module::AnyEvent<'a>) } + }); + let direct_arms = variants.iter().map(|(variant, event)| { quote! { if let Some(event) = any.downcast_ref::<::quent_instrumentation::Event<#event>>() { - return Some(AnyEvent::#variant(event)); + return Some(Self::#variant(event)); + } + } + }); + let child_arms = children.iter().map(|(variant, module)| { + quote! { + if let Some(event) = #module::AnyEvent::from_any(any) { + return Some(Self::#variant(event)); } } }); @@ -50,11 +76,13 @@ pub(crate) fn generate_any_event( Ok(quote! { #derives pub enum AnyEvent<'a> { - #(#decls),* + #(#decls,)* + #(#child_decls,)* } impl<'a> AnyEvent<'a> { - pub fn from_any(any: &'a (dyn ::core::any::Any)) -> Option> { - #(#arms)* + pub fn from_any(any: &'a dyn ::core::any::Any) -> Option { + #(#direct_arms)* + #(#child_arms)* None } } @@ -65,31 +93,21 @@ pub(crate) fn generate_any_event( mod tests { use super::*; use crate::common::pretty; - use quent_schema::builder::{EntityBuilder, EventBuilder, SchemaBuilder}; - use quent_schema::{Cardinality, test_utils::ident}; - - fn entity(name: &str, event: &str) -> quent_schema::Entity { - EntityBuilder::new(ident(name)) - .with_event( - EventBuilder::new(ident(event), Cardinality::Once) - .build() - .unwrap(), - ) - .build() - .unwrap() - } + use quent_schema::builder::SchemaBuilder; + use quent_schema::test_utils::{entity, event, ident}; #[test] fn emits_a_variant_and_arm_per_entity() { let schema = SchemaBuilder::new(ident("Demo")) - .with_entity(entity("Query", "submitted")) - .with_entity(entity("Server", "booted")) + .with_entity(entity("Query", [event("submitted", [])])) + .with_entity(entity("Server", [event("booted", [])])) .build() .unwrap(); let opts = Options { event_derives: &["Debug"], ..Options::default() }; + let namespaces = Namespace::root(&schema); let expected = quote! { #[derive(Debug)] pub enum AnyEvent<'a> { @@ -98,23 +116,23 @@ mod tests { } impl<'a> AnyEvent<'a> { - pub fn from_any(any: &'a (dyn ::core::any::Any)) -> Option> { + pub fn from_any(any: &'a dyn ::core::any::Any) -> Option { if let Some(event) = any.downcast_ref::<::quent_instrumentation::Event>() { - return Some(AnyEvent::Query(event)); + return Some(Self::Query(event)); } if let Some(event) = any.downcast_ref::<::quent_instrumentation::Event>() { - return Some(AnyEvent::Server(event)); + return Some(Self::Server(event)); } None } } }; assert_eq!( - pretty(generate_any_event(&schema, &opts).unwrap()), + pretty(generate_any_event(&namespaces, &opts).unwrap()), pretty(expected) ); } diff --git a/crates/instrumentation-build/src/common.rs b/crates/instrumentation-build/src/common.rs index b86049111..33a1c2737 100644 --- a/crates/instrumentation-build/src/common.rs +++ b/crates/instrumentation-build/src/common.rs @@ -5,7 +5,7 @@ use convert_case::{Boundary, Case, Casing}; use proc_macro2::{Span, TokenStream}; -use quent_schema::Identifier; +use quent_schema::{Identifier, Path}; use quote::quote; use syn::Ident; @@ -52,6 +52,46 @@ pub(crate) fn to_case(id: &Identifier, case: Case) -> String { .to_case(case) } +/// Return the Pascal-case type name for the final path segment. +pub(crate) fn path_name_pascal(path: &Path) -> String { + to_case(path.name(), Case::Pascal) +} + +/// Return the Rust module name for a path segment. +pub(crate) fn module_ident(segment: &Identifier) -> Ident { + raw_ident(to_case(segment, Case::Snake)) +} + +/// Return a generated type path relative to `source_namespace`. +pub(crate) fn relative_type_path( + path: &Path, + source_namespace: &[Identifier], + suffix: &str, +) -> TokenStream { + let common = path + .namespace() + .iter() + .zip(source_namespace) + .take_while(|(left, right)| left == right) + .count(); + let mut segments = Vec::new(); + segments.extend((common..source_namespace.len()).map(|_| quote! { super })); + for segment in &path.namespace()[common..] { + let module = module_ident(segment); + segments.push(quote! { #module }); + } + let ty = raw_ident(format!("{}{}", path_name_pascal(path), suffix)); + segments.push(quote! { #ty }); + quote! { #(#segments)::* } +} + +/// Return a root type path relative to `source_namespace`. +pub(crate) fn relative_root_type(name: &str, source_namespace: &[Identifier]) -> TokenStream { + let parents = source_namespace.iter().map(|_| quote! { super }); + let ty = raw_ident(name.to_owned()); + quote! { #(#parents::)* #ty } +} + /// Build an identifier from an already-cased name, raw-escaping Rust keywords. /// The keywords that cannot be raw (`crate`, `self`, `super`, `Self`) instead /// receive a trailing underscore. diff --git a/crates/instrumentation-build/src/data_type.rs b/crates/instrumentation-build/src/data_type.rs index 3250278b7..0aad4d24b 100644 --- a/crates/instrumentation-build/src/data_type.rs +++ b/crates/instrumentation-build/src/data_type.rs @@ -3,13 +3,12 @@ //! Mapping from schema [`DataType`]s to Rust type tokens. -use convert_case::Case; use proc_macro2::TokenStream; use quent_ref_target::RefTarget; use quent_schema::{Annotations, DataType}; use quote::quote; -use crate::common::{raw_ident, to_case}; +use crate::common::{relative_root_type, relative_type_path}; /// Maximum nesting depth of `Option`/`List`/`EntityRef` wrappers a single field /// type may have, far above any realistic schema. Self-referential records are @@ -23,7 +22,11 @@ pub(crate) const MAX_TYPE_DEPTH: usize = 64; /// # Panics /// /// Panics if `ty` nests deeper than [`MAX_TYPE_DEPTH`]. -pub(crate) fn map_data_type(ty: &DataType, depth: usize) -> TokenStream { +pub(crate) fn map_data_type( + ty: &DataType, + depth: usize, + source_namespace: &[quent_schema::Identifier], +) -> TokenStream { assert!( depth <= MAX_TYPE_DEPTH, "field type nesting exceeds the maximum depth of {MAX_TYPE_DEPTH}" @@ -43,23 +46,20 @@ pub(crate) fn map_data_type(ty: &DataType, depth: usize) -> TokenStream { DataType::F32 => quote! { f32 }, DataType::F64 => quote! { f64 }, DataType::Option(inner) => { - let inner = map_data_type(inner, depth + 1); + let inner = map_data_type(inner, depth + 1, source_namespace); quote! { Option<#inner> } } DataType::List(inner) => { - let inner = map_data_type(inner, depth + 1); + let inner = map_data_type(inner, depth + 1, source_namespace); quote! { Vec<#inner> } } - DataType::Record(name) => { - let ident = raw_ident(to_case(name.name(), Case::Pascal)); - quote! { #ident } - } + DataType::Record(path) => relative_type_path(path, source_namespace, ""), DataType::DynamicRecord => quote! { ::quent_instrumentation::DynamicAttributes }, DataType::EntityRef { data, annotations } => { - let target = ref_target_marker(annotations); + let target = ref_target_marker(annotations, source_namespace); match data { Some(inner) => { - let inner = map_data_type(inner, depth + 1); + let inner = map_data_type(inner, depth + 1, source_namespace); quote! { ::quent_instrumentation::EntityRef<#target, #inner> } } None => quote! { ::quent_instrumentation::EntityRef<#target> }, @@ -71,13 +71,13 @@ pub(crate) fn map_data_type(ty: &DataType, depth: usize) -> TokenStream { /// The target-entity marker type for an entity reference, taken from its /// ref-target constraint, or the `AnyEntity` marker when it is not restricted /// to a target entity. -fn ref_target_marker(annotations: &Annotations) -> TokenStream { +fn ref_target_marker( + annotations: &Annotations, + source_namespace: &[quent_schema::Identifier], +) -> TokenStream { match RefTarget::from_annotations(annotations) { - Some(entity) => { - let marker = raw_ident(to_case(entity.as_ref().name(), Case::Pascal)); - quote! { #marker } - } - None => quote! { AnyEntity }, + Some(entity) => relative_type_path(entity.as_ref(), source_namespace, ""), + None => relative_root_type("AnyEntity", source_namespace), } } @@ -95,7 +95,7 @@ mod tests { for _ in 0..(MAX_TYPE_DEPTH + 5) { ty = DataType::Option(Box::new(ty)); } - let _ = map_data_type(&ty, 0); + let _ = map_data_type(&ty, 0, &[]); } #[test] @@ -108,7 +108,7 @@ mod tests { data: Some(Box::new(DataType::U64)), annotations: annotations.build().unwrap(), }; - let tokens = map_data_type(&ty, 0).to_string(); + let tokens = map_data_type(&ty, 0, &[]).to_string(); assert!(tokens.contains("EntityRef < Cluster , u64 >"), "{tokens}"); } } diff --git a/crates/instrumentation-build/src/events.rs b/crates/instrumentation-build/src/events.rs index cdd53c525..b55c56461 100644 --- a/crates/instrumentation-build/src/events.rs +++ b/crates/instrumentation-build/src/events.rs @@ -5,30 +5,22 @@ use convert_case::Case; use proc_macro2::TokenStream; -use quent_schema::{Entity, Schema}; +use quent_schema::Entity; use quote::quote; -use crate::common::{derive_attr, doc_attr, doc_attr_or, raw_ident, to_case}; +use crate::common::{derive_attr, doc_attr, doc_attr_or, path_name_pascal, raw_ident, to_case}; use crate::data_type::map_data_type; use crate::{GenerateError, Options}; -pub(crate) fn generate_event_types( - schema: &Schema, +pub(crate) fn entity_event_enum( + entity: &Entity, opts: &Options, ) -> Result { - let enums: Vec = schema - .entities() - .map(|entity| entity_event_enum(entity, opts)) - .collect::>()?; - Ok(quote! { #(#enums)* }) -} - -fn entity_event_enum(entity: &Entity, opts: &Options) -> Result { - let entity_pascal = to_case(entity.path().name(), Case::Pascal); + let entity_pascal = path_name_pascal(entity.path()); let enum_ident = raw_ident(format!("{entity_pascal}Event")); let docs = doc_attr_or( entity.annotations().docs(), - &format!("Events emitted by `{entity_pascal}` entities."), + &format!("Events emitted by `{}` entities.", entity.path()), ); let derives = derive_attr(opts.event_derives)?; let variants: Vec = entity @@ -43,7 +35,7 @@ fn entity_event_enum(entity: &Entity, opts: &Options) -> Result String { - pretty(generate_event_types(s, &Options::default()).unwrap()) + fn event_src(entity: &Entity) -> String { + pretty(entity_event_enum(entity, &Options::default()).unwrap()) } #[test] @@ -91,7 +83,7 @@ mod tests { field("n", DataType::U32), field("opt", DataType::Option(Box::new(DataType::I32))), field("list", DataType::List(Box::new(DataType::String))), - field("rec", DataType::Record(ident("SomeRecord").into())), + field("rec", record_type("SomeRecord")), field("dynrec", DataType::DynamicRecord), field( "eref", @@ -130,7 +122,7 @@ mod tests { } } }; - assert_eq!(events_src(&s), pretty(expected)); + assert_eq!(event_src(s.entities().next().unwrap()), pretty(expected)); } #[test] @@ -162,32 +154,7 @@ mod tests { } } }; - assert_eq!(events_src(&s), pretty(expected)); - } - - #[test] - fn multiple_entities_emit_in_declaration_order() { - let s = schema( - "M", - [ - entity("Alpha", [event("started", [field("id", DataType::U32)])]), - entity("Beta", [event("ended", [])]), - ], - [], - ); - let expected = quote! { - #[doc = "Events emitted by `Alpha` entities."] - pub enum AlphaEvent { - #[doc = "The `started` event."] - Started { id: u32 } - } - #[doc = "Events emitted by `Beta` entities."] - pub enum BetaEvent { - #[doc = "The `ended` event."] - Ended - } - }; - assert_eq!(events_src(&s), pretty(expected)); + assert_eq!(event_src(s.entities().next().unwrap()), pretty(expected)); } #[test] @@ -227,7 +194,7 @@ mod tests { } } }; - assert_eq!(events_src(&s), pretty(expected)); + assert_eq!(event_src(s.entities().next().unwrap()), pretty(expected)); } #[test] @@ -261,6 +228,6 @@ mod tests { } } }; - assert_eq!(events_src(&s), pretty(expected)); + assert_eq!(event_src(s.entities().next().unwrap()), pretty(expected)); } } diff --git a/crates/instrumentation-build/src/lib.rs b/crates/instrumentation-build/src/lib.rs index 00b67533e..2b3a8f779 100644 --- a/crates/instrumentation-build/src/lib.rs +++ b/crates/instrumentation-build/src/lib.rs @@ -40,9 +40,6 @@ //! //! # Restrictions //! -//! Qualified record and entity paths are not supported; generation fails with -//! [`GenerateError::UnsupportedTypePath`]. -//! //! The schema does not limit how many events an entity declares, but this //! generator caps once-cardinality //! ([`Cardinality::Once`](quent_schema::Cardinality::Once)) events at 64 per @@ -58,6 +55,7 @@ mod any_event; mod common; mod data_type; mod events; +mod namespace; mod records; mod runtime; @@ -67,10 +65,6 @@ use quent_constraints::{BaseConstraintsError, Report, validate}; use quent_schema::{Path, Schema}; use quote::quote; -use events::generate_event_types; -use records::generate_record_types; -use runtime::generate_runtime_types; - /// Options controlling instrumentation library generation. pub struct Options { /// Derives applied to every generated event payload enum. @@ -95,8 +89,8 @@ pub struct Options { /// `None`. pub file_name: Option, - /// Emit `AnyEvent` and `AnyEvent::from_any`, a decoder from a type-erased - /// `&dyn Any` back to the concrete `Event`. Carries [`Self::event_derives`]. + /// Emit root and namespace-local `AnyEvent` enums that decode type-erased + /// events. Each enum carries [`Self::event_derives`]. pub any_event: bool, } @@ -126,11 +120,6 @@ pub enum GenerateError { }, #[error("generated code did not form a valid Rust file")] InvalidGeneratedCode(#[source] syn::Error), - #[error("qualified type path `{path}` is not supported")] - UnsupportedTypePath { - /// The unsupported record or entity path. - path: Path, - }, #[error( "entity `{entity}` declares {count} once-events, exceeding the maximum of {max}", max = crate::runtime::MAX_ONCE_EVENTS @@ -141,6 +130,15 @@ pub enum GenerateError { /// The number of once-cardinality events the entity declares. count: usize, }, + #[error("`AnyEvent` generation requires at least one entity")] + NoEntitiesForAnyEvent, + #[error("generated observer type `{generated}` conflicts with schema type `{schema_path}`")] + GeneratedTypeCollision { + /// The generated Rust type name. + generated: String, + /// The schema type whose generated name conflicts. + schema_path: Path, + }, #[error("failed to write generated file")] Io(#[from] std::io::Error), } @@ -177,36 +175,353 @@ pub fn generate(schema: &Schema, opts: &Options) -> Result Result { - ensure_unqualified_type_paths(schema)?; + let namespaces = namespace::Namespace::root(schema); + if opts.any_event && !namespaces.has_entities() { + return Err(GenerateError::NoEntitiesForAnyEvent); + } - // record structs, event enums, then the live instrumentation surface let reexports = runtime::reexports(); - let records = generate_record_types(schema, opts)?; - let events = generate_event_types(schema, opts)?; - let runtime = generate_runtime_types(schema)?; + let entity_types = runtime::entity_types(schema); + let types = generate_namespace(schema, opts, &namespaces, false)?; + let model = runtime::generate_model(schema, &namespaces); let any_event = if opts.any_event { - any_event::generate_any_event(schema, opts)? + any_event::generate_any_event(&namespaces, opts)? } else { quote! {} }; - let file = syn::parse2::(quote! { #reexports #records #events #runtime #any_event }) - .map_err(GenerateError::InvalidGeneratedCode)?; - Ok(prettyplease::unparse(&file)) + let file = syn::parse2::(quote! { + #reexports + #entity_types + #types + #model + #any_event + }) + .map_err(GenerateError::InvalidGeneratedCode)?; + Ok(format_generated_source(prettyplease::unparse(&file))) +} + +fn format_generated_source(source: String) -> String { + let mut output = Vec::new(); + let mut module_indents = vec![0]; + let mut previous_was_prefix = false; + let mut block_doc_indent = None; + + for line in source.lines() { + let line = normalize_doc_comment(line); + let indent = line.len() - line.trim_start().len(); + let trimmed = line.trim_start(); + while !trimmed.is_empty() + && module_indents.len() > 1 + && indent < module_indents.last().copied().unwrap_or(0) + { + module_indents.pop(); + } + + let at_module_scope = indent == module_indents.last().copied().unwrap_or(0); + let inside_block_doc = block_doc_indent.is_some(); + let starts_block_doc = trimmed.starts_with("/**"); + let is_prefix = at_module_scope + && (inside_block_doc + || starts_block_doc + || trimmed.starts_with("///") + || trimmed.starts_with("#[")); + let is_item = at_module_scope && is_item_start(trimmed); + + if (is_prefix || is_item) + && !previous_was_prefix + && output + .last() + .is_some_and(|previous: &String| !previous.is_empty()) + { + output.push(String::new()); + } + + if starts_block_doc && !trimmed.contains("*/") { + block_doc_indent = Some(indent); + } else if inside_block_doc && trimmed.contains("*/") { + block_doc_indent = None; + } + + previous_was_prefix = is_prefix; + if at_module_scope && is_module_start(trimmed) { + module_indents.push(indent + 4); + previous_was_prefix = false; + } + output.push(line); + } + + output.push(String::new()); + output.join("\n") +} + +fn normalize_doc_comment(line: &str) -> String { + let indent = line.len() - line.trim_start().len(); + let (whitespace, trimmed) = line.split_at(indent); + for marker in ["///", "//!", "/**"] { + if let Some(comment) = trimmed.strip_prefix(marker) + && !comment.is_empty() + && !comment.starts_with(char::is_whitespace) + && !comment.starts_with('/') + { + return format!("{whitespace}{marker} {comment}"); + } + } + line.to_owned() +} + +fn is_item_start(line: &str) -> bool { + [ + "const ", "enum ", "extern ", "fn ", "impl ", "impl<", "mod ", "pub ", "pub(", "static ", + "struct ", "trait ", "type ", "union ", "unsafe ", "use ", + ] + .iter() + .any(|prefix| line.starts_with(prefix)) +} + +fn is_module_start(line: &str) -> bool { + let line = line.strip_prefix("pub ").unwrap_or(line); + line.starts_with("mod ") && line.ends_with('{') } -fn ensure_unqualified_type_paths(schema: &Schema) -> Result<(), GenerateError> { - let qualified = schema +fn generate_namespace( + schema: &Schema, + opts: &Options, + namespace: &namespace::Namespace<'_>, + include_any_event: bool, +) -> Result { + let records = namespace .records() - .map(|record| record.path()) - .chain(schema.entities().map(|entity| entity.path())) - .find(|path| !path.namespace().is_empty()); + .iter() + .map(|record| records::record_struct(record, opts)) + .collect::, _>>()?; + let events = namespace + .entities() + .iter() + .map(|entity| events::entity_event_enum(entity, opts)) + .collect::, _>>()?; + let runtime = namespace + .entities() + .iter() + .map(|entity| runtime::entity_runtime_types(schema, entity)) + .collect::, _>>()?; + let children = namespace + .children() + .iter() + .map(|child| { + let segment = child + .path() + .last() + .expect("child namespaces extend their parent"); + let module = common::module_ident(segment); + let contents = generate_namespace(schema, opts, child, true)?; + Ok::<_, GenerateError>(quote! { + pub mod #module { + #contents + } + }) + }) + .collect::, _>>()?; + let any_event = if include_any_event && opts.any_event && namespace.has_entities() { + any_event::generate_any_event(namespace, opts)? + } else { + quote! {} + }; + let observer_storage = runtime::observer_storage(schema, namespace)?; + Ok(quote! { + #(#records)* + #(#events)* + #(#runtime)* + #(#children)* + #observer_storage + #any_event + }) +} + +#[cfg(test)] +mod path_tests { + use super::*; + use quent_constraints::Constraint; + use quent_ref_target::RefTargetConstraint; + use quent_schema::builder::AnnotationsBuilder; + use quent_schema::builder::SchemaBuilder; + use quent_schema::test_utils::{entity, event, field, path, record, record_type}; + use quent_schema::{Annotations, DataType}; + + #[test] + fn places_entity_types_in_path_modules() { + let schema = SchemaBuilder::try_new("Demo") + .unwrap() + .with_entity(entity("Foo::Query", [event("event", [])])) + .build() + .unwrap(); + + let source = generate_str(&schema, &Options::default()).unwrap(); + assert!(source.contains("pub mod foo")); + assert!(source.contains("pub enum QueryEvent")); + assert!(!source.contains("pub type Observer")); + assert!(source.contains( + "pub struct Handle>>" + )); + assert!(source.contains("impl super::Handle")); + assert!(source.contains("impl ::quent_instrumentation::Entity for Query")); + assert!(source.contains("type Context = super::Context")); + assert!(source.contains("pub struct DemoObservers")); + assert!(source.contains("struct FooObservers")); + assert!(source.contains("foo_observers: foo::FooObservers")); + assert!(source.contains("query_observer: ::quent_instrumentation::Observer")); + assert!(source.contains( + "impl ::quent_instrumentation::ObserverAccess for DemoObservers" + )); + assert!(source.contains(r#"const NAME: &'static str = "Foo::Query""#)); + assert!(!source.contains("foo_query_observer")); + } + + #[test] + fn separates_types_with_colliding_flattened_paths() { + let schema = SchemaBuilder::try_new("Demo") + .unwrap() + .with_record(record("Foo::BarBaz", [])) + .with_record(record("FooBar::Baz", [])) + .build() + .unwrap(); + + let source = generate_str(&schema, &Options::default()).unwrap(); + assert!(source.contains("pub mod foo")); + assert!(source.contains("pub struct BarBaz")); + assert!(source.contains("pub mod foo_bar")); + assert!(source.contains("pub struct Baz")); + } + + #[test] + fn rejects_observer_type_collisions() { + let conflicting_path = path("Foo::FooObservers"); + let schema = SchemaBuilder::try_new("Demo") + .unwrap() + .with_record(record("Foo::FooObservers", [])) + .with_entity(entity("Foo::Query", [event("event", [])])) + .build() + .unwrap(); + + assert!(matches!( + generate_str(&schema, &Options::default()), + Err(GenerateError::GeneratedTypeCollision { + generated, + schema_path, + }) if generated == "FooObservers" && schema_path == conflicting_path + )); + } + + #[test] + fn does_not_merge_namespaces_that_share_a_rust_name() { + let schema = SchemaBuilder::try_new("Demo") + .unwrap() + .with_record(record("FooBar::First", [])) + .with_record(record("foo_bar::Second", [])) + .build() + .unwrap(); + + let source = generate_str(&schema, &Options::default()).unwrap(); + assert_eq!(source.matches("pub mod foo_bar").count(), 2); + } + + #[test] + fn qualifies_types_across_path_modules() { + let target_annotations = AnnotationsBuilder::new() + .with_constraint(RefTargetConstraint::NAME, Some("Foo::Worker".to_string())) + .build() + .unwrap(); + let schema = SchemaBuilder::try_new("Demo") + .unwrap() + .with_record(record("Bar::Meta", [])) + .with_record(record("Foo::Parent", [])) + .with_record(record("Foo::Nested::Local", [])) + .with_record(record("Foo::Nested::Child::Value", [])) + .with_record(record("Foo::Sibling::Value", [])) + .with_entity(entity("Foo::Worker", [event("created", [])])) + .with_entity(entity( + "Foo::Nested::Task", + [event( + "created", + [ + field("meta", record_type("Bar::Meta")), + field("parent", record_type("Foo::Parent")), + field("local", record_type("Foo::Nested::Local")), + field("child", record_type("Foo::Nested::Child::Value")), + field("sibling", record_type("Foo::Sibling::Value")), + field( + "worker", + DataType::EntityRef { + data: None, + annotations: target_annotations, + }, + ), + field( + "any", + DataType::EntityRef { + data: None, + annotations: Annotations::default(), + }, + ), + ], + )], + )) + .build() + .unwrap(); + + let source = generate_str(&schema, &Options::default()).unwrap(); + assert!(source.contains("meta: super::super::bar::Meta")); + assert!(source.contains("parent: super::Parent")); + assert!(source.contains("local: Local")); + assert!(source.contains("child: child::Value")); + assert!(source.contains("sibling: super::sibling::Value")); + assert!(source.contains("worker: ::quent_instrumentation::EntityRef")); + assert!( + source.contains("any: ::quent_instrumentation::EntityRef") + ); + } + + #[test] + fn generates_any_event_per_entity_namespace() { + let schema = SchemaBuilder::try_new("Demo") + .unwrap() + .with_entity(entity("Root", [event("created", [])])) + .with_entity(entity("Foo::Query", [event("created", [])])) + .with_entity(entity("Foo::Nested::Task", [event("created", [])])) + .build() + .unwrap(); + let opts = Options { + any_event: true, + ..Options::default() + }; + + let source = generate_str(&schema, &opts).unwrap(); + assert!(source.contains("Root(&'a ::quent_instrumentation::Event)")); + assert!(source.contains("Foo(foo::AnyEvent<'a>)")); + assert!(source.contains("Query(&'a ::quent_instrumentation::Event)")); + assert!(source.contains("Nested(nested::AnyEvent<'a>)")); + assert!(source.contains("Task(&'a ::quent_instrumentation::Event)")); + assert!(source.contains("foo::AnyEvent::from_any(any)")); + assert!(source.contains("nested::AnyEvent::from_any(any)")); + assert!( + source.rfind("pub enum AnyEvent") > source.rfind("impl ::quent_instrumentation::Model") + ); + } + + #[test] + fn rejects_any_event_without_entities() { + let schema = SchemaBuilder::try_new("Demo").unwrap().build().unwrap(); + let opts = Options { + any_event: true, + ..Options::default() + }; - match qualified { - Some(path) => Err(GenerateError::UnsupportedTypePath { path: path.clone() }), - None => Ok(()), + assert!(matches!( + generate_str(&schema, &opts), + Err(GenerateError::NoEntitiesForAnyEvent) + )); } } diff --git a/crates/instrumentation-build/src/namespace.rs b/crates/instrumentation-build/src/namespace.rs new file mode 100644 index 000000000..b9a352208 --- /dev/null +++ b/crates/instrumentation-build/src/namespace.rs @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +use quent_schema::{Entity, Identifier, Record, Schema}; + +/// A tree of Rust namespaces containing schema records and entities. +pub(crate) struct Namespace<'schema> { + path: Vec, + records: Vec<&'schema Record>, + entities: Vec<&'schema Entity>, + children: Vec, +} + +impl<'schema> Namespace<'schema> { + pub(crate) fn root(schema: &'schema Schema) -> Self { + let mut root = Self::new(Vec::new()); + for record in schema.records() { + root.namespace_mut(record.path().namespace()) + .records + .push(record); + } + for entity in schema.entities() { + root.namespace_mut(entity.path().namespace()) + .entities + .push(entity); + } + root + } + + pub(crate) fn path(&self) -> &[Identifier] { + &self.path + } + + pub(crate) fn records(&self) -> &[&'schema Record] { + &self.records + } + + pub(crate) fn entities(&self) -> &[&'schema Entity] { + &self.entities + } + + pub(crate) fn children(&self) -> &[Self] { + &self.children + } + + pub(crate) fn has_entities(&self) -> bool { + !self.entities.is_empty() || self.children.iter().any(Self::has_entities) + } + + fn new(path: Vec) -> Self { + Self { + path, + records: Vec::new(), + entities: Vec::new(), + children: Vec::new(), + } + } + + fn namespace_mut(&mut self, path: &[Identifier]) -> &mut Self { + let mut namespace = self; + for segment in path { + let index = match namespace + .children + .iter() + .position(|child| child.path.last() == Some(segment)) + { + Some(index) => index, + None => { + let mut child_path = namespace.path.clone(); + child_path.push(segment.clone()); + namespace.children.push(Self::new(child_path)); + namespace.children.len() - 1 + } + }; + namespace = &mut namespace.children[index]; + } + namespace + } +} diff --git a/crates/instrumentation-build/src/records.rs b/crates/instrumentation-build/src/records.rs index 103000c61..46cc1d46e 100644 --- a/crates/instrumentation-build/src/records.rs +++ b/crates/instrumentation-build/src/records.rs @@ -5,42 +5,26 @@ use convert_case::Case; use proc_macro2::TokenStream; -use quent_schema::{Record, Schema}; +use quent_schema::Record; use quote::quote; -use crate::common::{derive_attr, doc_attr, doc_attr_or, raw_ident, to_case}; +use crate::common::{derive_attr, doc_attr, doc_attr_or, path_name_pascal, raw_ident, to_case}; use crate::data_type::map_data_type; use crate::{GenerateError, Options}; -/// Record structs, as tokens, in declaration order. -/// -/// # Panics -/// -/// Panics if a field type nests deeper than [`crate::data_type::MAX_TYPE_DEPTH`]. -pub(crate) fn generate_record_types( - schema: &Schema, - opts: &Options, -) -> Result { - let records: Vec = schema - .records() - .map(|record| record_struct(record, opts)) - .collect::>()?; - Ok(quote! { #(#records)* }) -} - -fn record_struct(record: &Record, opts: &Options) -> Result { - let record_pascal = to_case(record.path().name(), Case::Pascal); +pub(crate) fn record_struct(record: &Record, opts: &Options) -> Result { + let record_pascal = path_name_pascal(record.path()); let ident = raw_ident(record_pascal.clone()); let docs = doc_attr_or( record.annotations().docs(), - &format!("The `{record_pascal}` record."), + &format!("The `{}` record.", record.path()), ); let derives = derive_attr(opts.record_derives)?; let fields: Vec = record .fields() .map(|field| { let name = raw_ident(to_case(field.name(), Case::Snake)); - let ty = map_data_type(field.ty(), 0); + let ty = map_data_type(field.ty(), 0, record.path().namespace()); let field_docs = doc_attr(field.annotations().docs()); quote! { #field_docs pub #name: #ty } }) @@ -63,40 +47,26 @@ mod tests { use super::*; use crate::common::pretty; use quent_schema::DataType; - use quent_schema::test_utils::{field, ident, record, schema}; + use quent_schema::test_utils::{field, record, record_type}; #[test] - fn test_generate_record_types() { - let s = schema( - "M", - [], + fn generates_record_struct() { + let record = record( + "Nested", [ - record("OnePrim", [field("a", DataType::U8)]), - record( - "Nested", - [ - field("inner", DataType::Record(ident("OnePrim").into())), - field("list", DataType::List(Box::new(DataType::String))), - ], - ), - record("Empty", []), + field("inner", record_type("OnePrim")), + field("list", DataType::List(Box::new(DataType::String))), ], ); let expected = quote! { - #[doc = "The `OnePrim` record."] - pub struct OnePrim { - pub a: u8 - } #[doc = "The `Nested` record."] pub struct Nested { pub inner: OnePrim, pub list: Vec } - #[doc = "The `Empty` record."] - pub struct Empty; }; assert_eq!( - pretty(generate_record_types(&s, &Options::default()).unwrap()), + pretty(record_struct(&record, &Options::default()).unwrap()), pretty(expected) ); } diff --git a/crates/instrumentation-build/src/runtime/context.rs b/crates/instrumentation-build/src/runtime/context.rs index a7c444ac4..80c1aaeeb 100644 --- a/crates/instrumentation-build/src/runtime/context.rs +++ b/crates/instrumentation-build/src/runtime/context.rs @@ -1,107 +1,140 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! Generation of the schema context — builds every entity's observer on -//! construction and hands out cheap clones. +//! Generation of the schema model used by the generic instrumentation context. use convert_case::Case; use proc_macro2::TokenStream; -use quent_schema::Schema; +use quent_schema::{Entity, Schema}; use quote::quote; +use syn::Ident; -use super::{event_ident, observer_ident}; -use crate::common::{raw_ident, to_case}; +use super::model_ident; +use crate::GenerateError; +use crate::common::{module_ident, path_name_pascal, raw_ident, relative_type_path, to_case}; +use crate::namespace::Namespace; -/// Generate the declaration of an {Schema}Context and its impls. -pub(super) fn schema_context(schema: &Schema) -> TokenStream { - let schema_pascal = to_case(schema.name(), Case::Pascal); - let context_ty = raw_ident(format!("{schema_pascal}Context")); - let model_name = schema.name().to_string(); +/// Generate observer storage for one schema namespace. +pub(super) fn observer_storage( + schema: &Schema, + namespace: &Namespace<'_>, +) -> Result { + if !namespace.path().is_empty() && !namespace.has_entities() { + return Ok(quote! {}); + } - let fields: Vec<_> = schema - .entities() - .map(|e| raw_ident(to_case(e.path().name(), Case::Snake))) - .collect(); - let observer_tys: Vec<_> = schema.entities().map(observer_ident).collect(); - let event_tys: Vec<_> = schema.entities().map(event_ident).collect(); - let accessors: Vec<_> = schema - .entities() - .map(|e| { - raw_ident(format!( - "{}_observer", - to_case(e.path().name(), Case::Snake) - )) - }) - .collect(); - let accessor_docs: Vec = schema + let storage = observers_ident(schema, namespace); + let storage_name = storage.to_string(); + if let Some(schema_path) = namespace + .records() + .iter() + .map(|record| record.path()) + .chain(namespace.entities().iter().map(|entity| entity.path())) + .find(|path| path_name_pascal(path) == storage_name) + { + return Err(GenerateError::GeneratedTypeCollision { + generated: storage_name, + schema_path: schema_path.clone(), + }); + } + + let (visibility, field_visibility) = storage_visibility(namespace); + let description = if namespace.path().is_empty() { + format!( + "Observers for the `{}` instrumentation model.", + schema.name() + ) + } else { + format!( + "Observers for the `{}` namespace.", + namespace + .path() + .iter() + .map(ToString::to_string) + .collect::>() + .join("::") + ) + }; + let hidden_docs = + "This type is hidden because the model context provides typed observer access."; + let entity_fields = namespace.entities().iter().map(|entity| { + let field = entity_observer_field(entity); + let entity_ty = relative_type_path(entity.path(), namespace.path(), ""); + quote! { + #field_visibility #field: ::quent_instrumentation::Observer<#entity_ty> + } + }); + let namespace_fields = namespace + .children() + .iter() + .filter(|child| child.has_entities()) + .map(|child| { + let segment = child + .path() + .last() + .expect("child namespaces extend their parent"); + let field = namespace_observers_field(segment); + let module = module_ident(segment); + let child_storage = observers_ident(schema, child); + quote! { + #field_visibility #field: #module::#child_storage + } + }); + + Ok(quote! { + #[doc = #description] + #[doc = ""] + #[doc = #hidden_docs] + #[doc(hidden)] + #visibility struct #storage { + #(#entity_fields,)* + #(#namespace_fields,)* + } + }) +} + +/// Generate the model marker and its runtime integration. +pub(super) fn schema_model(schema: &Schema, namespaces: &Namespace<'_>) -> TokenStream { + let model = model_ident(schema); + let model_name = schema.name().to_string(); + let observers = observers_ident(schema, namespaces); + let active_observers = observer_storage_initializer(schema, namespaces, true); + let noop_observers = observer_storage_initializer(schema, namespaces, false); + let observer_impls = schema .entities() - .map(|e| { - format!( - "Observer for `{}` entities.", - to_case(e.path().name(), Case::Pascal) - ) - }) - .collect(); + .map(|entity| observer_storage_impl(schema, entity)); - let context_doc = format!( - "Instrumentation context for the `{model_name}` model. Construct it with \ - [`Self::try_new`], then call a `*_observer()` accessor to get an entity's \ - event observer, which creates the per-instance handles that emit events." - ); + let model_doc = format!("The `{model_name}` instrumentation model."); quote! { - #[doc = #context_doc] - pub struct #context_ty { - #(#fields: #observer_tys,)* - _inner: ::quent_instrumentation::Context, - } + #[doc = #model_doc] + pub struct #model; - impl #context_ty { - /// Create a context, building every entity's exporter pipeline. - /// Pass `None` for a no-op context that discards events. - pub fn try_new( - exporter: ::core::option::Option<::quent_instrumentation::ExporterOptions>, - ) -> ::core::result::Result> { - Self::try_with_id(::quent_instrumentation::Uuid::now_v7(), exporter) - } + #(#observer_impls)* - /// Create a context that adopts an existing `id` rather than - /// generating one. - pub fn try_with_id( - id: ::quent_instrumentation::Uuid, - exporter: ::core::option::Option<::quent_instrumentation::ExporterOptions>, - ) -> ::core::result::Result> { - // With an exporter, build an active context, write the provenance - // sidecar, then build each entity's observer using the exporter - // options as its provider. `None` builds a no-op context and - // no-op observers. - let ( _inner, #(#fields,)* ) = match &exporter { + impl ::quent_instrumentation::Model for #model { + type Observers = #observers; + + fn build_observers( + context: &::quent_instrumentation::ContextInner, + exporter: ::core::option::Option<&::quent_instrumentation::ExporterOptions>, + ) -> ::core::result::Result< + Self::Observers, + ::std::boxed::Box, + > { + match exporter { ::core::option::Option::Some(options) => { - let context = ::quent_instrumentation::Context::try_new(id)?; - ::quent_instrumentation::write_sidecar(options, id, Self::model_info()); - let ( #(#fields,)* ) = context.block_on(async { + context.block_on(async { ::core::result::Result::< _, ::std::boxed::Box, - >::Ok(( - #( - context - .observer::<#event_tys>(::core::clone::Clone::clone(options)) - .await?, - )* - )) - })?; - ( context, #(#fields,)* ) + >::Ok(#active_observers) + }) } - ::core::option::Option::None => ( - ::quent_instrumentation::Context::noop(id), - #( ::quent_instrumentation::Observer::<#event_tys>::noop(), )* - ), - }; - ::core::result::Result::Ok(Self { - #( #fields: #observer_tys { inner: ::std::sync::Arc::new(#fields) }, )* - _inner, - }) + ::core::option::Option::None => { + ::core::result::Result::Ok(#noop_observers) + } + } } fn model_info() -> ::quent_instrumentation::build_info::ModelInfo { @@ -123,18 +156,112 @@ pub(super) fn schema_context(schema: &Schema) -> TokenStream { analyzer_package: ::core::option::Option::None, } } + } + } +} - /// Identity of this context. - pub fn id(&self) -> ::quent_instrumentation::Uuid { - self._inner.id() +fn observer_storage_initializer( + schema: &Schema, + namespace: &Namespace<'_>, + active: bool, +) -> TokenStream { + let storage = observers_path(schema, namespace); + let entity_fields = namespace.entities().iter().map(|entity| { + let field = entity_observer_field(entity); + let entity_ty = relative_type_path(entity.path(), &[], ""); + let event_ty = relative_type_path(entity.path(), &[], "Event"); + let observer = if active { + quote! { + context + .observer::<#event_ty>(::core::clone::Clone::clone(options)) + .await? + } + } else { + quote! { + ::quent_instrumentation::ObserverInner::<#event_ty>::noop() } + }; + quote! { + #field: ::quent_instrumentation::Observer::<#entity_ty>::new( + ::std::sync::Arc::new(#observer), + ) + } + }); + let namespace_fields = namespace + .children() + .iter() + .filter(|child| child.has_entities()) + .map(|child| { + let segment = child + .path() + .last() + .expect("child namespaces extend their parent"); + let field = namespace_observers_field(segment); + let value = observer_storage_initializer(schema, child, active); + quote! { #field: #value } + }); + quote! { + #storage { + #(#entity_fields,)* + #(#namespace_fields,)* + } + } +} - #( - #[doc = #accessor_docs] - pub fn #accessors(&self) -> #observer_tys { - ::core::clone::Clone::clone(&self.#fields) - } - )* +fn observer_storage_impl(schema: &Schema, entity: &Entity) -> TokenStream { + let storage = root_observers_ident(schema); + let entity_ty = relative_type_path(entity.path(), &[], ""); + let mut observer = quote! { self }; + for segment in entity.path().namespace() { + let field = namespace_observers_field(segment); + observer = quote! { #observer.#field }; + } + let field = entity_observer_field(entity); + observer = quote! { #observer.#field }; + + quote! { + impl ::quent_instrumentation::ObserverAccess<#entity_ty> for #storage { + fn observer(&self) -> ::quent_instrumentation::Observer<#entity_ty> { + ::core::clone::Clone::clone(&#observer) + } } } } + +fn observers_ident(schema: &Schema, namespace: &Namespace<'_>) -> Ident { + match namespace.path().last() { + Some(segment) => raw_ident(format!("{}Observers", to_case(segment, Case::Pascal))), + None => root_observers_ident(schema), + } +} + +fn root_observers_ident(schema: &Schema) -> Ident { + raw_ident(format!("{}Observers", to_case(schema.name(), Case::Pascal))) +} + +fn observers_path(schema: &Schema, namespace: &Namespace<'_>) -> TokenStream { + let modules = namespace.path().iter().map(module_ident); + let storage = observers_ident(schema, namespace); + quote! { #(#modules::)* #storage } +} + +fn entity_observer_field(entity: &Entity) -> Ident { + raw_ident(format!( + "{}_observer", + to_case(entity.path().name(), Case::Snake) + )) +} + +fn namespace_observers_field(segment: &quent_schema::Identifier) -> Ident { + raw_ident(format!("{}_observers", to_case(segment, Case::Snake))) +} + +fn storage_visibility(namespace: &Namespace<'_>) -> (TokenStream, TokenStream) { + if namespace.path().is_empty() { + return (quote! { pub }, quote! {}); + } + let parents = namespace.path().iter().map(|_| quote! { super }); + let root = quote! { #(#parents)::* }; + let visibility = quote! { pub(in #root) }; + (visibility.clone(), visibility) +} diff --git a/crates/instrumentation-build/src/runtime/handle.rs b/crates/instrumentation-build/src/runtime/handle.rs index 9f5ff4b02..b896d11cc 100644 --- a/crates/instrumentation-build/src/runtime/handle.rs +++ b/crates/instrumentation-build/src/runtime/handle.rs @@ -8,26 +8,25 @@ use proc_macro2::{Literal, TokenStream}; use quent_schema::{Cardinality, Entity}; use quote::quote; -use super::{event_ident, handle_ident, marker_ident}; +use super::{event_ident, marker_ident}; use crate::GenerateError; -use crate::common::{doc_attr_or, raw_ident, to_case}; +use crate::common::{doc_attr_or, raw_ident, relative_root_type, to_case}; use crate::data_type::map_data_type; /// The maximum once-events an entity may declare: one bit per event in the /// handle's `u64` once-flag word. pub(crate) const MAX_ONCE_EVENTS: usize = u64::BITS as usize; -/// Generate the declaration of an {Entity}Handle and its impls. +/// Generate entity-specific methods on the generic handle. /// /// # Errors /// /// Returns [`GenerateError::TooManyOnceEvents`] if the entity declares more /// once-cardinality events than fit the once-flag word. pub(super) fn entity_handle(entity: &Entity) -> Result { - let entity_pascal = to_case(entity.path().name(), Case::Pascal); let event_ty = event_ident(entity); - let handle_ty = handle_ident(entity); let marker_ty = marker_ident(entity); + let handle_ty = relative_root_type("Handle", entity.path().namespace()); let once_count = entity .events() @@ -63,7 +62,7 @@ pub(super) fn entity_handle(entity: &Entity) -> Result Result, - } - - impl #handle_ty { - /// Id of the entity instance this handle emits for. - pub fn uuid(&self) -> ::quent_instrumentation::Uuid { - self.inner.id() - } - - /// A typed reference to this instance, carrying no data. - pub fn as_entity_ref(&self) -> ::quent_instrumentation::EntityRef<#marker_ty> { - ::quent_instrumentation::EntityRef::new(self.uuid(), ()) - } - - /// A typed reference to this instance, carrying `data`. - pub fn as_entity_ref_with(&self, data: T) -> ::quent_instrumentation::EntityRef<#marker_ty, T> { - ::quent_instrumentation::EntityRef::new(self.uuid(), data) - } - - /// A reference to this instance for a field not restricted to a - /// target entity type, carrying no data. - pub fn as_any_entity_ref(&self) -> ::quent_instrumentation::EntityRef<::quent_instrumentation::AnyEntity> { - ::quent_instrumentation::EntityRef::new(self.uuid(), ()) - } - - /// A reference to this instance for a field not restricted to a - /// target entity type, carrying `data`. - pub fn as_any_entity_ref_with(&self, data: T) -> ::quent_instrumentation::EntityRef<::quent_instrumentation::AnyEntity, T> { - ::quent_instrumentation::EntityRef::new(self.uuid(), data) - } - + impl #handle_ty<#marker_ty> { #(#methods)* } }) diff --git a/crates/instrumentation-build/src/runtime/mod.rs b/crates/instrumentation-build/src/runtime/mod.rs index 380ff6751..aabf0d205 100644 --- a/crates/instrumentation-build/src/runtime/mod.rs +++ b/crates/instrumentation-build/src/runtime/mod.rs @@ -11,45 +11,74 @@ use quote::quote; use syn::Ident; use crate::GenerateError; -use crate::common::{raw_ident, to_case}; +use crate::common::{path_name_pascal, raw_ident, relative_root_type, to_case}; mod context; mod handle; -mod observer; pub(crate) use handle::MAX_ONCE_EVENTS; -/// The full instrumentation surface for `schema`: per entity, an `EntityEvent` -/// impl, an observer, and a handle; then the `{Schema}Context` that builds and -/// hands out the observers. -/// -/// # Errors -/// -/// Returns [`GenerateError::TooManyOnceEvents`] if an entity declares more -/// once-cardinality events than the per-handle flag word holds. -pub(crate) fn generate_runtime_types(schema: &Schema) -> Result { - let entities: Vec = schema - .entities() - .map(|entity| { - let marker = entity_marker(entity); - let event_impl = entity_event_impl(entity); - let observer = observer::entity_observer(entity); - let handle = handle::entity_handle(entity)?; - Ok::<_, GenerateError>(quote! { - #marker - #event_impl - #observer - #handle - }) - }) - .collect::>()?; - let context = context::schema_context(schema); +pub(crate) fn entity_runtime_types( + schema: &Schema, + entity: &Entity, +) -> Result { + let marker = entity_marker(entity); + let event_impl = entity_event_impl(entity); + let handle = handle::entity_handle(entity)?; + let entity_impl = entity_impl(schema, entity); Ok(quote! { - #(#entities)* - #context + #marker + #event_impl + #handle + #entity_impl }) } +pub(crate) fn generate_model( + schema: &Schema, + namespaces: &crate::namespace::Namespace<'_>, +) -> TokenStream { + context::schema_model(schema, namespaces) +} + +pub(crate) fn observer_storage( + schema: &Schema, + namespace: &crate::namespace::Namespace<'_>, +) -> Result { + context::observer_storage(schema, namespace) +} + +pub(crate) fn entity_types(schema: &Schema) -> TokenStream { + let model = model_ident(schema); + let model_name = schema.name().to_string(); + let handle_docs = + format!("Handle to one entity instance in the `{model_name}` instrumentation model."); + quote! { + #[doc = #handle_docs] + pub struct Handle>> { + inner: ::quent_instrumentation::HandleInner, + } + + impl>> + ::core::convert::From<::quent_instrumentation::HandleInner> for Handle + { + fn from(inner: ::quent_instrumentation::HandleInner) -> Self { + Self { inner } + } + } + + impl>> ::core::ops::Deref + for Handle + { + type Target = ::quent_instrumentation::HandleInner; + + fn deref(&self) -> &Self::Target { + &self.inner + } + } + } +} + /// Re-export the always-available runtime types that appear in the generated /// API, so consumers reference them through the generated module rather than /// `quent_instrumentation`. Opt-in types like the callback exporter are @@ -57,7 +86,7 @@ pub(crate) fn generate_runtime_types(schema: &Schema) -> Result TokenStream { quote! { pub use ::quent_instrumentation::{ - AnyEntity, DynamicAttributes, EntityRef, Event, HandleError, Uuid, + AnyEntity, Context, DynamicAttributes, EntityRef, Event, HandleError, Observer, Uuid, }; } } @@ -66,10 +95,7 @@ pub(crate) fn reexports() -> TokenStream { /// type of [`EntityRef`](quent_instrumentation::EntityRef) fields that point at it. fn entity_marker(entity: &Entity) -> TokenStream { let marker = marker_ident(entity); - let doc = format!( - "Marker type for the `{}` entity.", - to_case(entity.path().name(), Case::Pascal) - ); + let doc = format!("Marker type for the `{}` entity.", entity.path()); quote! { #[doc = #doc] #[derive(Debug, Clone, Copy)] @@ -77,10 +103,10 @@ fn entity_marker(entity: &Entity) -> TokenStream { } } -/// Tie an entity's event enum to its stream name (the entity's snake-case name). +/// Tie an entity's event enum to its canonical schema path. fn entity_event_impl(entity: &Entity) -> TokenStream { let event_ty = event_ident(entity); - let stream_name = to_case(entity.path().name(), Case::Snake); + let stream_name = entity.path().to_string(); quote! { impl ::quent_instrumentation::EntityEvent for #event_ty { const NAME: &'static str = #stream_name; @@ -90,31 +116,33 @@ fn entity_event_impl(entity: &Entity) -> TokenStream { /// `{Entity}Event` — the entity's event enum. fn event_ident(entity: &Entity) -> Ident { - raw_ident(format!( - "{}Event", - to_case(entity.path().name(), Case::Pascal) - )) + raw_ident(format!("{}Event", path_name_pascal(entity.path()))) } /// `{Entity}` — the entity's ref-target marker type. fn marker_ident(entity: &Entity) -> Ident { - raw_ident(to_case(entity.path().name(), Case::Pascal)) + raw_ident(path_name_pascal(entity.path())) } -/// `{Entity}Observer`. -fn observer_ident(entity: &Entity) -> Ident { - raw_ident(format!( - "{}Observer", - to_case(entity.path().name(), Case::Pascal) - )) +pub(super) fn model_ident(schema: &Schema) -> Ident { + raw_ident(to_case(schema.name(), Case::Pascal)) } -/// `{Entity}Handle`. -fn handle_ident(entity: &Entity) -> Ident { - raw_ident(format!( - "{}Handle", - to_case(entity.path().name(), Case::Pascal) - )) +fn entity_impl(schema: &Schema, entity: &Entity) -> TokenStream { + let namespace = entity.path().namespace(); + let marker = marker_ident(entity); + let event = event_ident(entity); + let context = relative_root_type("Context", namespace); + let model_name = model_ident(schema).to_string(); + let model = relative_root_type(&model_name, namespace); + let handle = relative_root_type("Handle", namespace); + quote! { + impl ::quent_instrumentation::Entity for #marker { + type Event = #event; + type Context = #context<#model>; + type Handle = #handle; + } + } } #[cfg(test)] @@ -141,11 +169,18 @@ mod tests { .with_entity(connection) .build() .unwrap(); - let src = pretty(generate_runtime_types(&s).unwrap()); + let entity = s.entities().next().unwrap(); + let entity_types = entity_runtime_types(&s, entity).unwrap(); + let namespaces = crate::namespace::Namespace::root(&s); + let model = generate_model(&s, &namespaces); + let src = pretty(quote! { + #entity_types + #model + }); assert!(src.contains("impl ::quent_instrumentation::EntityEvent for ConnectionEvent")); - assert!(src.contains(r#"const NAME: &'static str = "connection""#)); - assert!(src.contains("pub struct ConnectionObserver")); - assert!(src.contains("pub struct ConnectionHandle")); - assert!(src.contains("pub struct DemoContext")); + assert!(src.contains(r#"const NAME: &'static str = "Connection""#)); + assert!(src.contains("type Event = ConnectionEvent")); + assert!(src.contains("impl Handle")); + assert!(src.contains("pub struct Demo")); } } diff --git a/crates/instrumentation-build/src/runtime/observer.rs b/crates/instrumentation-build/src/runtime/observer.rs deleted file mode 100644 index ab9f8d680..000000000 --- a/crates/instrumentation-build/src/runtime/observer.rs +++ /dev/null @@ -1,57 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! Generation of per-entity observers — the cheap-clone factories for handles. - -use convert_case::Case; -use proc_macro2::TokenStream; -use quent_schema::Entity; -use quote::quote; - -use super::{event_ident, handle_ident, observer_ident}; -use crate::common::to_case; - -/// Generate the declaration of an {Entity}Observer and its impls. -pub(super) fn entity_observer(entity: &Entity) -> TokenStream { - let entity_pascal = to_case(entity.path().name(), Case::Pascal); - let event_ty = event_ident(entity); - let observer_ty = observer_ident(entity); - let handle_ty = handle_ident(entity); - - let observer_doc = format!( - "Observer for `{entity_pascal}` entities. Obtain a per-instance handle \ - with [`Self::handle`]." - ); - let handle_fn_doc = format!("Create a handle for a fresh `{entity_pascal}` instance."); - let handle_with_id_doc = - format!("Create a handle for the `{entity_pascal}` instance identified by `id`."); - - quote! { - #[doc = #observer_doc] - #[derive(Clone)] - pub struct #observer_ty { - inner: ::std::sync::Arc<::quent_instrumentation::Observer<#event_ty>>, - } - - impl #observer_ty { - #[doc = #handle_fn_doc] - pub fn handle(&self) -> #handle_ty { - #handle_ty { - inner: ::quent_instrumentation::Handle::new( - ::core::clone::Clone::clone(&self.inner), - ), - } - } - - #[doc = #handle_with_id_doc] - pub fn handle_with_id(&self, id: ::quent_instrumentation::Uuid) -> #handle_ty { - #handle_ty { - inner: ::quent_instrumentation::Handle::with_id( - id, - ::core::clone::Clone::clone(&self.inner), - ), - } - } - } - } -} diff --git a/crates/instrumentation/benches/event_emit.rs b/crates/instrumentation/benches/event_emit.rs index 1ad3ab8d6..8d6a0a23a 100644 --- a/crates/instrumentation/benches/event_emit.rs +++ b/crates/instrumentation/benches/event_emit.rs @@ -5,7 +5,7 @@ //! client emitting events. //! //! Single `emit` group with one entry per exporter backing (plus `noop`): -//! - `noop` — `Context::try_new(None)`; the cost a caller pays when +//! - `noop` — `ContextInner::noop`; the cost a caller pays when //! instrumentation is compiled in but not active. //! - `ndjson` / `msgpack` / `postcard` — write to a temp dir that is cleaned //! up when the bench function returns. @@ -26,7 +26,7 @@ use pprof::ProfilerGuard; use quent_collector::{CollectorSink, deserialize_event, server::CollectorService}; use quent_collector_proto::collector_server::CollectorServer; use quent_events::EntityEvent; -use quent_instrumentation::{Context, Observer}; +use quent_instrumentation::{ContextInner, ObserverInner}; use quent_io::filesystem::{self, Format}; use quent_io::{CollectorExporterOptions, ExporterOptions}; use serde::{Deserialize, Serialize}; @@ -84,11 +84,11 @@ impl EntityEvent for BenchEvent { fn build_observer( id: Uuid, exporter: Option, -) -> BenchResult<(Context, Observer)> { +) -> BenchResult<(ContextInner, ObserverInner)> { let Some(options) = exporter else { - return Ok((Context::noop(id), Observer::noop())); + return Ok((ContextInner::noop(id), ObserverInner::noop())); }; - let ctx = Context::try_new(id)?; + let ctx = ContextInner::try_new(id)?; let observer = ctx.block_on(async { ctx.observer::(options).await })?; Ok((ctx, observer)) } @@ -96,7 +96,7 @@ fn build_observer( // The in-process collector server runs this sink per source: it decodes received // `BenchEvent`s and records them through a local ndjson observer, built up front. struct BenchSink { - observer: Observer, + observer: ObserverInner, } impl BenchSink { diff --git a/crates/instrumentation/src/context.rs b/crates/instrumentation/src/context.rs index c2c67f705..199ce72b3 100644 --- a/crates/instrumentation/src/context.rs +++ b/crates/instrumentation/src/context.rs @@ -3,18 +3,18 @@ //! The runtime host that observers of a model instance run on. -use crate::observer::{Observer, spawn_forwarder}; +use crate::observer_inner::{ObserverInner, spawn_forwarder}; use quent_events::EntityEvent; use quent_io::ExporterProvider; use std::future::Future; use std::sync::Arc; -use tokio::runtime::{Handle, Runtime}; +use tokio::runtime::{Handle, Runtime as TokioRuntime}; use tracing::debug; use uuid::Uuid; /// The runtime an active context's observers run on. #[derive(Clone)] -pub(crate) enum BackendRuntime { +pub(crate) enum Runtime { /// A handle to a runtime owned elsewhere (`#[tokio::main]`, a caller-managed /// one) and kept alive by that owner. Borrowed(Handle), @@ -24,11 +24,11 @@ pub(crate) enum BackendRuntime { handle: Handle, /// `Option` only so `Drop` can move the `Arc` out of `&mut self`; `Some` /// for the value's whole life until then. - runtime: Option>, + runtime: Option>, }, } -impl BackendRuntime { +impl Runtime { /// The handle observers spawn and block on. pub(crate) fn handle(&self) -> Handle { match self { @@ -37,11 +37,11 @@ impl BackendRuntime { } } -impl Drop for BackendRuntime { +impl Drop for Runtime { fn drop(&mut self) { // On the last holder of a spawned runtime, shut it down without blocking, // since a blocking `Runtime` drop panics on a runtime worker thread. - // `into_inner` yields the `Runtime` only when this was the final `Arc`. + // `into_inner` yields the Tokio runtime only when this was the final `Arc`. // Safe to abandon tasks here: the observers' forwarders have already // flushed by the time the last holder drops. if let Self::Owned { runtime, .. } = self @@ -52,23 +52,18 @@ impl Drop for BackendRuntime { } } -/// What a context does with events. `Noop` drops them; `Active` runs its -/// observers' forwarders on the carried runtime. -enum Backend { - Noop, - Active { runtime: BackendRuntime }, -} - -/// A context responsible for providing an asynchronous back-end to a -/// synchronous context generated from an application event model. +/// The runtime host for a synchronous context generated from an application +/// event model. /// /// Instrumented application code should not interact with this type directly /// unless there is a very special reason. Instead, it should interact with the /// generated context only through a fully synchronous API. /// +/// This type is hidden because [`crate::Context`] provides that model-level API. +/// /// What it is responsible for: /// - Resolving the runtime its observers run on. It borrows an ambient one if -/// present, otherwise spawns its own (see [`BackendRuntime`]). +/// present, otherwise spawns its own (see [`Runtime`]). /// - Being the single sync→async bridge for async observer construction and /// the drop-time flush. /// @@ -77,33 +72,27 @@ enum Backend { /// The blocking sync/async crossings work off a runtime or on a multi-threaded /// one, but panic on a current-thread runtime. #[doc(hidden)] -pub struct Context { +pub struct ContextInner { /// Unique identifier of this context. id: Uuid, - /// The asynchronous run-time the observers produced by this context operate - /// on. - backend: Backend, + /// The asynchronous runtime used by active observers. + runtime: Option, } -impl Context { +impl ContextInner { /// Construct an active context adopting `id`, with a runtime for its /// observers' forwarders. pub fn try_new(id: Uuid) -> Result> { - Ok(Context { + Ok(Self { id, - backend: Backend::Active { - runtime: resolve_runtime()?, - }, + runtime: Some(resolve_runtime()?), }) } /// Construct a no-op context: observers built from it discard events. pub fn noop(id: Uuid) -> Self { debug!("using noop context"); - Context { - id, - backend: Backend::Noop, - } + Self { id, runtime: None } } /// Return the universally unique identifier of this context. @@ -137,14 +126,11 @@ impl Context { } /// The runtime backing an active context; `None` for noop. - fn runtime(&self) -> Option<&BackendRuntime> { - match &self.backend { - Backend::Active { runtime } => Some(runtime), - Backend::Noop => None, - } + fn runtime(&self) -> Option<&Runtime> { + self.runtime.as_ref() } - /// Create an [`Observer`] of events of one *type* of entity `T`, building its + /// Creates an [`ObserverInner`] for one entity event type `T`, building its /// exporter from `provider` bound to this context's id. /// /// The exporter is constructed here (so construction errors surface through @@ -153,12 +139,12 @@ impl Context { pub async fn observer( &self, provider: impl ExporterProvider, - ) -> Result, Box> + ) -> Result, Box> where T: Send + EntityEvent + 'static, { let Some(runtime) = self.runtime() else { - return Ok(Observer::noop()); + return Ok(ObserverInner::noop()); }; let exporter = provider.create_exporter(self.id).await?; Ok(spawn_forwarder(runtime, exporter)) @@ -167,14 +153,15 @@ impl Context { /// Resolve the runtime observers run on: borrow an ambient one if present, /// otherwise spawn a fresh owned runtime. -fn resolve_runtime() -> Result> { +fn resolve_runtime() -> Result> { if let Ok(handle) = Handle::try_current() { debug!("using existing async runtime"); - Ok(BackendRuntime::Borrowed(handle)) + Ok(Runtime::Borrowed(handle)) } else { debug!("spawning new async runtime"); - let runtime = Runtime::new().map_err(|e| format!("unable to spawn async runtime: {e}"))?; - Ok(BackendRuntime::Owned { + let runtime = + TokioRuntime::new().map_err(|e| format!("unable to spawn async runtime: {e}"))?; + Ok(Runtime::Owned { handle: runtime.handle().clone(), runtime: Some(Arc::new(runtime)), }) @@ -201,8 +188,8 @@ mod tests { use super::*; #[test] - fn noop_context_has_noop_backend() { - let ctx = Context::noop(Uuid::now_v7()); - assert!(matches!(ctx.backend, Backend::Noop)); + fn noop_context_has_no_runtime() { + let ctx = ContextInner::noop(Uuid::now_v7()); + assert!(ctx.runtime.is_none()); } } diff --git a/crates/instrumentation/src/entity.rs b/crates/instrumentation/src/entity.rs new file mode 100644 index 000000000..8dfb613a6 --- /dev/null +++ b/crates/instrumentation/src/entity.rs @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Entity markers used by generated instrumentation libraries. + +use std::sync::Arc; + +use quent_events::EntityEvent; + +use crate::ObserverInner; + +/// Associates a generated entity marker with its event type and context. +pub trait Entity: Sized { + /// Events emitted for this entity. + type Event: EntityEvent; + + /// Instrumentation context containing this entity. + type Context; + + /// Generated handle for this entity. + type Handle: From>; +} + +/// Provides handles for an entity type through its shared event observer. +pub struct Observer { + inner: Arc>, +} + +impl Clone for Observer { + fn clone(&self) -> Self { + Self { + inner: Arc::clone(&self.inner), + } + } +} + +impl Observer { + /// Creates an observer backed by `inner`. + /// + /// This method is hidden because generated model implementations construct + /// observers while callers obtain them through their model context. + #[doc(hidden)] + pub fn new(inner: Arc>) -> Self { + Self { inner } + } + + /// Creates a handle for a fresh entity instance. + pub fn handle(&self) -> E::Handle { + HandleInner::new(Arc::clone(&self.inner)).into() + } + + /// Creates a handle for the entity instance identified by `id`. + pub fn handle_with_id(&self, id: crate::Uuid) -> E::Handle { + HandleInner::with_id(id, Arc::clone(&self.inner)).into() + } +} + +/// An error from emitting through a generated entity handle. +#[derive(Debug, thiserror::Error)] +pub enum HandleError { + /// A once-cardinality event was emitted more than once for one entity + /// instance. + #[error("once-event `{event}` already emitted for this entity instance")] + OnceAlreadyEmitted { + /// Name of the event that was re-emitted. + event: &'static str, + }, +} + +/// Common operations for generated handles. +/// +/// Generated local newtypes wrap this type so they can add inherent +/// entity-specific event methods. +/// +/// This type is hidden because those newtypes are the application-facing +/// handle API. +#[doc(hidden)] +pub struct HandleInner { + id: crate::Uuid, + /// One bit per once-cardinality event, set once that event is emitted. + once_flags: u64, + observer: Arc>, +} + +impl HandleInner { + fn new(observer: Arc>) -> Self { + Self::with_id(crate::Uuid::now_v7(), observer) + } + + fn with_id(id: crate::Uuid, observer: Arc>) -> Self { + Self { + id, + once_flags: 0, + observer, + } + } + + /// Returns the entity instance ID. + pub fn uuid(&self) -> crate::Uuid { + self.id + } + + /// Returns a typed reference to this instance carrying no data. + pub fn as_entity_ref(&self) -> crate::EntityRef { + crate::EntityRef::new(self.uuid(), ()) + } + + /// Returns a typed reference to this instance carrying `data`. + pub fn as_entity_ref_with(&self, data: T) -> crate::EntityRef { + crate::EntityRef::new(self.uuid(), data) + } + + /// Returns an untyped reference to this instance carrying no data. + pub fn as_any_entity_ref(&self) -> crate::EntityRef { + crate::EntityRef::new(self.uuid(), ()) + } + + /// Returns an untyped reference to this instance carrying `data`. + pub fn as_any_entity_ref_with(&self, data: T) -> crate::EntityRef { + crate::EntityRef::new(self.uuid(), data) + } + + /// Emits an event without cardinality tracking. + /// + /// This is hidden because generated event methods provide the typed API. + #[doc(hidden)] + pub fn emit(&self, event: E::Event) { + self.observer.emit(self.id, event); + } + + /// Emits an event unless the bit at `INDEX` was previously set. + /// + /// This is hidden because generated once-event methods provide the typed API. + /// + /// # Errors + /// + /// Returns [`HandleError`](crate::HandleError) when the event was already emitted. + #[doc(hidden)] + pub fn emit_once( + &mut self, + event_name: &'static str, + event: E::Event, + ) -> Result<(), HandleError> { + const { assert!(INDEX < u64::BITS, "once-event bit index out of range") }; + let mask = 1u64 << INDEX; + if self.once_flags & mask != 0 { + return Err(HandleError::OnceAlreadyEmitted { event: event_name }); + } + self.once_flags |= mask; + self.observer.emit(self.id, event); + Ok(()) + } + + /// Returns whether the bit at `INDEX` has been set. + /// + /// This is hidden because generated once-event methods expose named checks. + #[doc(hidden)] + pub fn is_emitted(&self) -> bool { + const { assert!(INDEX < u64::BITS, "once-event bit index out of range") }; + self.once_flags & (1u64 << INDEX) != 0 + } +} diff --git a/crates/instrumentation/src/handle.rs b/crates/instrumentation/src/handle.rs deleted file mode 100644 index 3253aa5ab..000000000 --- a/crates/instrumentation/src/handle.rs +++ /dev/null @@ -1,88 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -//! A per-entity instance handle forwarding events to the observer's event -//! pipeline. - -use std::sync::Arc; - -use uuid::Uuid; - -use crate::observer::Observer; - -/// An error from emitting through a [`Handle`]. -#[derive(Debug, thiserror::Error)] -pub enum HandleError { - /// A once-cardinality event was emitted more than once for one entity - /// instance. - #[error("once-event `{event}` already emitted for this entity instance")] - OnceAlreadyEmitted { - /// Name of the event that was re-emitted. - event: &'static str, - }, -} - -/// A handle to one entity instance. -/// -/// Exports this instance's events through an [`Observer`] shared with other -/// handles. -/// Enforces once-cardinality events are sent at most once. -#[doc(hidden)] -pub struct Handle { - id: Uuid, - /// One bit per once-cardinality event, set once that event is emitted. - once_flags: u64, - observer: Arc>, -} - -impl Handle { - /// Create a handle for a new entity instance, with a generated id. - pub fn new(observer: Arc>) -> Self { - Self::with_id(Uuid::now_v7(), observer) - } - - /// Create a handle for the entity instance identified by `id`. - pub fn with_id(id: Uuid, observer: Arc>) -> Self { - Self { - id, - once_flags: 0, - observer, - } - } - - /// The entity instance id this handle emits for. - pub fn id(&self) -> Uuid { - self.id - } - - /// Emit a multi-cardinality event for this instance. - pub fn emit(&self, event: E) { - self.observer.emit(self.id, event); - } - - /// Emit a once-cardinality event. - /// - /// Returns [`HandleError::OnceAlreadyEmitted`] if this handle previously - /// emitted an event with the same `INDEX`. - pub fn emit_once( - &mut self, - event_name: &'static str, - event: E, - ) -> Result<(), HandleError> { - const { assert!(INDEX < u64::BITS, "once-event bit index out of range") }; - let mask = 1u64 << INDEX; - if self.once_flags & mask != 0 { - return Err(HandleError::OnceAlreadyEmitted { event: event_name }); - } - self.once_flags |= mask; - self.observer.emit(self.id, event); - Ok(()) - } - - /// Whether the once-cardinality event tracked by `INDEX` has already been - /// emitted for this instance. - pub fn is_emitted(&self) -> bool { - const { assert!(INDEX < u64::BITS, "once-event bit index out of range") }; - self.once_flags & (1u64 << INDEX) != 0 - } -} diff --git a/crates/instrumentation/src/lib.rs b/crates/instrumentation/src/lib.rs index 7229f11b6..4cc2ad259 100644 --- a/crates/instrumentation/src/lib.rs +++ b/crates/instrumentation/src/lib.rs @@ -8,15 +8,17 @@ //! generated instrumentation library only. mod context; +mod entity; mod entity_ref; -mod handle; -mod observer; +mod model; +mod observer_inner; mod sidecar; -pub use context::Context; +pub use context::ContextInner; +pub use entity::{Entity, HandleError, HandleInner, Observer}; pub use entity_ref::{AnyEntity, EntityRef}; -pub use handle::{Handle, HandleError}; -pub use observer::{EventSender, Observer}; +pub use model::{Context, Model, ObserverAccess}; +pub use observer_inner::{EventSender, ObserverInner}; pub use sidecar::write_sidecar; // Re-export everything the generated instrumentation code references, so a @@ -62,7 +64,7 @@ mod tests { fn e2e_filesystem_export() { let dir = tempfile::tempdir().unwrap(); let id = Uuid::now_v7(); - let ctx = Context::try_new(id).unwrap(); + let ctx = ContextInner::try_new(id).unwrap(); let options = ExporterOptions::FileSystem(FileSystemExporterOptions::new( FileSystemFormat::Ndjson, dir.path().to_path_buf(), diff --git a/crates/instrumentation/src/model.rs b/crates/instrumentation/src/model.rs new file mode 100644 index 000000000..5362109d1 --- /dev/null +++ b/crates/instrumentation/src/model.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//! Instrumentation models and their contexts. + +use crate::{ContextInner, Entity, ExporterOptions, Observer, Uuid, build_info, write_sidecar}; + +/// Provides typed access to an entity observer in a generated model. +/// +/// This trait is hidden because generated observer collections implement it +/// while callers use [`Context::observer`]. +#[doc(hidden)] +pub trait ObserverAccess { + /// Returns the observer stored for `E`. + fn observer(&self) -> Observer; +} + +/// Supplies schema-specific observers and metadata to an instrumentation context. +pub trait Model: Sized { + /// Generated observers for this model. + /// + /// This associated type is hidden because callers access observers through + /// [`Context::observer`]. + #[doc(hidden)] + type Observers; + + /// Builds the observers for this model. + /// + /// `exporter` is `None` for a no-op context. + /// + /// This is hidden because [`Context`] invokes it during construction. + /// + /// # Errors + /// + /// Returns an error when an observer or its exporter cannot be constructed. + #[doc(hidden)] + fn build_observers( + context: &ContextInner, + exporter: Option<&ExporterOptions>, + ) -> Result>; + + /// Returns metadata describing this instrumentation model. + fn model_info() -> build_info::ModelInfo; +} + +/// Instrumentation context for a generated model. +pub struct Context { + observers: M::Observers, + inner: ContextInner, +} + +impl Context { + /// Creates a context and builds every entity's exporter pipeline. + /// + /// Passing `None` creates a no-op context that discards events. + pub fn try_new(exporter: Option) -> Result> { + Self::try_with_id(Uuid::now_v7(), exporter) + } + + /// Creates a context with the supplied ID. + pub fn try_with_id( + id: Uuid, + exporter: Option, + ) -> Result> { + let inner = if exporter.is_some() { + ContextInner::try_new(id)? + } else { + ContextInner::noop(id) + }; + if let Some(options) = &exporter { + write_sidecar(options, id, M::model_info()); + } + let observers = M::build_observers(&inner, exporter.as_ref())?; + Ok(Self { observers, inner }) + } + + /// Returns the context ID. + pub fn id(&self) -> Uuid { + self.inner.id() + } + + /// Returns the observer associated with entity marker `E`. + pub fn observer(&self) -> Observer + where + E: Entity, + M::Observers: ObserverAccess, + { + self.observers.observer() + } +} diff --git a/crates/instrumentation/src/observer.rs b/crates/instrumentation/src/observer_inner.rs similarity index 82% rename from crates/instrumentation/src/observer.rs rename to crates/instrumentation/src/observer_inner.rs index 55d930952..8cfad9a7a 100644 --- a/crates/instrumentation/src/observer.rs +++ b/crates/instrumentation/src/observer_inner.rs @@ -1,9 +1,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -//! The per-entity event pipeline that forwards events to an exporter. +//! Shared event forwarding state for entity observers. -use crate::context::{BackendRuntime, drive}; +use crate::context::{Runtime, drive}; use quent_events::{EntityEvent, Event}; use quent_io::Exporter; use std::sync::{ @@ -71,34 +71,30 @@ impl EventSender { } } -/// Provides an event pipeline to "observe" events of one *type* of entity `T` -/// and export them. +/// Backs an entity observer with event forwarding and exporter lifecycle management. /// -/// Instrumented application code should not interact with this type directly -/// unless they have a very special reason. Instead, it interacts with the -/// generated observer only. +/// A context creates one instance per entity type and shares it among that +/// entity's observer and handles. Dropping the final shared owner cancels the +/// forwarder and flushes the exporter. /// -/// Generated code constructs and shares this type. Instrumented application -/// code uses the generated observer and its per-instance entity handles -/// instead. Those manage the shared ownership and flush-on-last-drop this type -/// relies on, so holding or dropping it directly can lose or prematurely flush -/// events. +/// This type is hidden because generated libraries expose that shared lifecycle +/// through their `Observer` and `Handle` types. #[doc(hidden)] -pub struct Observer { +pub struct ObserverInner { events_sender: EventSender, cancellation_token: CancellationToken, forwarder_handle: Option>, - /// The runtime this observer's forwarder runs on; `None` for a no-op - /// observer. An `Owned` runtime is kept alive here for the observer's + /// The runtime this pipeline's forwarder runs on; `None` for a no-op + /// pipeline. An `Owned` runtime is kept alive here for the pipeline's /// lifetime, so its drop flush is valid even after the [`Context`] is gone. /// /// [`Context`]: crate::Context - runtime: Option, + runtime: Option, } -impl Observer { - /// Construct a no-op observer that discards events and holds no runtime - /// resources whatesoever. +impl ObserverInner { + /// Construct a no-op pipeline that discards events and holds no runtime + /// resources whatsoever. pub fn noop() -> Self { Self { events_sender: EventSender::noop(), @@ -118,18 +114,18 @@ impl Observer { self.events_sender.emit(id, event); } - /// A cloned [`EventSender`] feeding this observer's pipeline. + /// A cloned [`EventSender`] feeding this pipeline. /// - /// Lets a `'static` producer emit into the observer while the caller keeps + /// Lets a `'static` producer emit into the pipeline while the caller keeps /// ownership (and still flushes on drop). The sender does not keep the - /// observer alive; sends after it is dropped are discarded (the first logs + /// pipeline alive; sends after it is dropped are discarded (the first logs /// an error via `tracing`, then further ones are suppressed). pub fn sender(&self) -> EventSender { self.events_sender.clone() } } -impl Drop for Observer { +impl Drop for ObserverInner { fn drop(&mut self) { self.cancellation_token.cancel(); @@ -148,11 +144,11 @@ impl Drop for Observer { } /// Spawn the forwarder task for `exporter` on `runtime` and wrap it in an -/// [`Observer`]. The task drains and flushes the exporter on cancellation. +/// [`ObserverInner`]. The task drains and flushes the exporter on cancellation. pub(crate) fn spawn_forwarder( - runtime: &BackendRuntime, + runtime: &Runtime, mut exporter: Box>, -) -> Observer +) -> ObserverInner where T: Send + EntityEvent + 'static, { @@ -204,7 +200,7 @@ where } }); - Observer { + ObserverInner { events_sender: EventSender { tx: Some(events_sender), disable_error_log: Arc::new(AtomicBool::new(false)), @@ -226,7 +222,7 @@ mod tests { #[test] fn noop_observer_holds_no_sender_and_discards_events() { - let observer = Observer::::noop(); + let observer = ObserverInner::::noop(); assert!(observer.events_sender.tx.is_none()); // Emitting is a silent no-op. observer.emit(Uuid::now_v7(), TestEvent); diff --git a/crates/instrumentation/tests/collector_roundtrip.rs b/crates/instrumentation/tests/collector_roundtrip.rs index 0faa119e8..57049ec8a 100644 --- a/crates/instrumentation/tests/collector_roundtrip.rs +++ b/crates/instrumentation/tests/collector_roundtrip.rs @@ -16,7 +16,7 @@ use common::TestEvent; use quent_collector::{CollectorSink, deserialize_event, server::CollectorService}; use quent_collector_proto::collector_server::CollectorServer; use quent_events::{EntityEvent, Event}; -use quent_instrumentation::Context; +use quent_instrumentation::ContextInner; use quent_io::{CollectorExporterOptions, ExporterOptions}; use tokio_stream::wrappers::TcpListenerStream; use tonic::transport::Server as GrpcServer; @@ -81,7 +81,7 @@ fn collector_client_flushes_all_events_on_drop() { // A plain sync client (no ambient runtime); the context spawns its own. let id = Uuid::now_v7(); - let ctx = Context::try_new(id).unwrap(); + let ctx = ContextInner::try_new(id).unwrap(); let options = ExporterOptions::Collector(CollectorExporterOptions::new(address)); { let observer = ctx diff --git a/crates/instrumentation/tests/runtime_flavors.rs b/crates/instrumentation/tests/runtime_flavors.rs index 463906611..9ed0e707d 100644 --- a/crates/instrumentation/tests/runtime_flavors.rs +++ b/crates/instrumentation/tests/runtime_flavors.rs @@ -10,7 +10,7 @@ mod common; use std::path::Path; use common::TestEvent; -use quent_instrumentation::{Context, Observer}; +use quent_instrumentation::{ContextInner, ObserverInner}; use quent_io::ExporterOptions; use quent_io::filesystem::{self, Format}; use uuid::Uuid; @@ -24,16 +24,16 @@ fn fs_opts(root: &Path) -> ExporterOptions { /// Build an active context for `root`, mirroring what a generated /// `{App}Context::try_new` does (minus sidecar write). -fn active(root: &Path) -> (Context, ExporterOptions, Uuid) { +fn active(root: &Path) -> (ContextInner, ExporterOptions, Uuid) { let id = Uuid::now_v7(); - let ctx = Context::try_new(id).unwrap(); + let ctx = ContextInner::try_new(id).unwrap(); let exporter_opts = fs_opts(root); (ctx, exporter_opts, id) } /// Build an observer through the one bridge: the context builds the exporter /// from the options (bound to its id) and hosts it on its runtime. -fn build(ctx: &Context, exporter_opts: &ExporterOptions) -> Observer { +fn build(ctx: &ContextInner, exporter_opts: &ExporterOptions) -> ObserverInner { ctx.block_on(async { ctx.observer::(exporter_opts.clone()).await }) .unwrap() } diff --git a/crates/model-macros/src/model_macro.rs b/crates/model-macros/src/model_macro.rs index 90a87862c..706b1903b 100644 --- a/crates/model-macros/src/model_macro.rs +++ b/crates/model-macros/src/model_macro.rs @@ -331,7 +331,7 @@ pub fn expand(input: TokenStream) -> syn::Result { #(#observer_fields: #observer_types::new( quent_model::Observer::<#event_types>::noop(), ),)* - _inner: quent_model::Context::noop(id), + _inner: quent_model::ContextInner::noop(id), } } } @@ -470,7 +470,7 @@ pub fn expand(input: TokenStream) -> syn::Result { #[doc(alias = "context")] pub struct #context_type { #(#observer_field_decls,)* - _inner: quent_model::Context, + _inner: quent_model::ContextInner, } impl #context_type { @@ -482,7 +482,7 @@ pub fn expand(input: TokenStream) -> syn::Result { id: quent_model::uuid::Uuid, options: quent_model::io::ExporterOptions, ) -> Result> { - let inner = quent_model::Context::try_new(id)?; + let inner = quent_model::ContextInner::try_new(id)?; let ( #(#observer_fields,)* ) = inner.block_on(async { let ( #(#observer_fields,)* ) = quent_model::tokio::try_join!( #( diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 2d83364e3..146c2afbc 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -88,7 +88,7 @@ pub use quent_build_info as build_info; pub use quent_collector_client::{CollectorSink, deserialize_event}; pub use quent_dynamic_attributes as attributes; pub use quent_events::{EntityEvent, Event}; -pub use quent_instrumentation::{Context, Observer, write_sidecar}; +pub use quent_instrumentation::{ContextInner, ObserverInner as Observer, write_sidecar}; pub use quent_io as io; pub use quent_time::timestamp; #[cfg(feature = "serde")] diff --git a/integrations/nvtx/example/src/lib.rs b/integrations/nvtx/example/src/lib.rs index 6b158592b..1e0ff5eae 100644 --- a/integrations/nvtx/example/src/lib.rs +++ b/integrations/nvtx/example/src/lib.rs @@ -3,40 +3,42 @@ //! In-process NVTX capture, driven by the application. //! -//! [`run_capture`] wires the NVTX injection hook into a Quent `Observer` built -//! on a caller-supplied exporter, runs a fixed set of NVTX annotations, and -//! flushes. The binary debug-prints captured events; the test reuses the same -//! routine with a collecting exporter — one code path, no subprocess or files. +//! [`run_capture`] wires the NVTX injection hook into a Quent event pipeline +//! built on a caller-supplied exporter, runs a fixed set of NVTX annotations, +//! and flushes. The binary debug-prints captured events; the test reuses the +//! same routine with a collecting exporter — one code path, no subprocess or +//! files. //! //! Capture is in-process: this crate links `nvtx-injection` with its //! `static-injection` feature, so NVTX initializes injection at the first NVTX //! call in whatever binary links the crate. use nvtx_bridge::NvtxEventEntity; -use quent_instrumentation::{Context, EventCallback}; +use quent_instrumentation::{ContextInner, EventCallback}; use uuid::Uuid; /// Capture the NVTX events produced by the fixed annotation sequence into /// `exporter`. /// -/// Builds a Quent context and observer on `exporter`, installs the injection +/// Builds a Quent context and event pipeline on `exporter`, installs the injection /// hook (one-shot per process) to forward each event, runs the annotations, and -/// drops the observer to flush. +/// drops the pipeline to flush. pub fn run_capture( session: Uuid, exporter: EventCallback, ) -> Result<(), Box> { - let ctx = Context::try_new(session)?; - let observer = ctx.block_on(async { ctx.observer::(exporter).await })?; + let context = ContextInner::try_new(session)?; + let pipeline = + context.block_on(async { context.observer::(exporter).await })?; - // Forward each captured event into the observer, before the first NVTX call. - let sender = observer.sender(); + // Forward each captured event into the pipeline, before the first NVTX call. + let sender = pipeline.sender(); nvtx_injection::install_hook(move |event| sender.emit(session, event))?; annotated_work(); - // Dropping the observer drains and flushes the exporter. - drop(observer); + // Dropping the pipeline drains and flushes the exporter. + drop(pipeline); Ok(()) }