Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 27 additions & 25 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ members = [
"crates/exporter/ndjson",
"crates/exporter/postcard",
"crates/exporter/types",
"crates/instrumentation",
"crates/instrumentation-runtime",
"crates/model",
"crates/model-macros",
"crates/stdlib",
Expand Down Expand Up @@ -77,7 +77,7 @@ default-members = [
"crates/exporter/ndjson",
"crates/exporter/postcard",
"crates/exporter/types",
"crates/instrumentation",
"crates/instrumentation-runtime",
"crates/model",
"crates/model-macros",
"crates/stdlib",
Expand Down
1 change: 1 addition & 0 deletions crates/instrumentation-build/example/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ publish.workspace = true

[dependencies]
quent-attributes = { path = "../../attributes" }
quent-instrumentation-runtime = { path = "../../instrumentation-runtime" }
serde = { workspace = true }
serde_json = { workspace = true }
uuid = { workspace = true, features = ["serde"] }
Expand Down
6 changes: 4 additions & 2 deletions crates/instrumentation-build/example/build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@ fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {

let schema = demo_schema()?;

// Observers require the event type to be `Serialize`; records embedded in
// events must be too.
let opts = Options {
event_derives: &["Debug"],
record_derives: &["Debug"],
event_derives: &["Debug", "Clone", "::serde::Serialize"],
record_derives: &["Debug", "Clone", "::serde::Serialize"],
..Default::default()
};

Expand Down
25 changes: 19 additions & 6 deletions crates/instrumentation-build/example/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,26 @@ pub mod demo {
include!(concat!(env!("OUT_DIR"), "/demo.rs"));
}

fn main() {
let opened = demo::ConnectionEvent::Opened {
peer: demo::Endpoint {
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = demo::DemoContext::try_new(None)?;
let observer = context.connection_observer();
let mut conn = observer.handle();

// The handle (may) hold per-instance state that enforces once-cardinality,
// hence it is mut so it can update it state after producing a once-event.
conn.opened(
demo::Endpoint {
host: "localhost".to_owned(),
port: 8080,
},
session: uuid::Uuid::nil(),
};
dbg!(opened);
uuid::Uuid::nil(),
)?;
conn.data(1234, None)?;
conn.data(5678, None)?;
conn.closed()?;

// Emitting a once-event a second time fails.
assert!(conn.closed().is_err());

Ok(())
}
27 changes: 24 additions & 3 deletions crates/instrumentation-build/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,30 @@
//! include!(concat!(env!("OUT_DIR"), "/demo.rs"));
//! }
//! ```
//!
//! # Restrictions
//!
//! 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
//! entity; beyond that, generation fails with
//! [`GenerateError::TooManyOnceEvents`].

mod common;
mod data_type;
mod events;
mod records;
mod runtime;

use std::path::PathBuf;

use quent_constraints::{BaseConstraintsError, Report, validate};
use quent_schema::Schema;
use quent_schema::{Identifier, 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 {
Expand Down Expand Up @@ -99,6 +109,16 @@ pub enum GenerateError {
},
#[error("generated code did not form a valid Rust file")]
InvalidGeneratedCode(#[source] syn::Error),
#[error(
"entity `{entity}` declares {count} once-events, exceeding the maximum of {max}",
max = crate::runtime::MAX_ONCE_EVENTS
)]
TooManyOnceEvents {
/// The offending entity.
entity: Identifier,
/// The number of once-cardinality events the entity declares.
count: usize,
},
#[error("failed to write generated file")]
Io(#[from] std::io::Error),
}
Expand Down Expand Up @@ -138,10 +158,11 @@ pub fn generate(schema: &Schema, opts: &Options) -> Result<GenerateInfo, Generat
/// Returns [`GenerateError`] if a derive entry is not a parseable Rust path, or
/// if the generated code is not a valid Rust file.
pub fn generate_str(schema: &Schema, opts: &Options) -> Result<String, GenerateError> {
// record structs first, then event enums
// record structs, event enums, then the live instrumentation surface
let records = generate_record_types(schema, opts)?;
let events = generate_event_types(schema, opts)?;
let file = syn::parse2::<syn::File>(quote! { #records #events })
let runtime = generate_runtime_types(schema)?;
let file = syn::parse2::<syn::File>(quote! { #records #events #runtime })
.map_err(GenerateError::InvalidGeneratedCode)?;
Ok(prettyplease::unparse(&file))
}
156 changes: 156 additions & 0 deletions crates/instrumentation-build/src/runtime/context.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
// 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.

use convert_case::Case;
use proc_macro2::TokenStream;
use quent_schema::Schema;
use quote::quote;

use super::{event_ident, observer_ident};
use crate::common::{raw_ident, to_case};

/// The `{Schema}Context`: builds one observer per entity on construction and
/// hands out cheap clones via `{entity}_observer()`.
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();

let fields: Vec<_> = schema
.entities()
.map(|e| raw_ident(to_case(e.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.name(), Case::Snake))))
.collect();
let accessor_docs: Vec<String> = schema
.entities()
.map(|e| {
format!(
"Observer for `{}` entities.",
to_case(e.name(), Case::Pascal)
)
})
.collect();

let context_doc = format!(
"Instrumentation context for the `{model_name}` model. Construct it with \
[`Self::try_new`], then call the `*_observer()` accessors to emit events."
);

quote! {
#[doc = #context_doc]
pub struct #context_ty {
#(#fields: #observer_tys,)*
_inner: ::quent_instrumentation_runtime::Context,
}

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_runtime::ExporterOptions>,
) -> ::core::result::Result<Self, ::std::boxed::Box<dyn ::std::error::Error>> {
Self::assemble(::quent_instrumentation_runtime::Context::try_new(
Self::model_info(),
exporter,
)?)
}

/// Create a context that adopts an existing `id` rather than
/// generating one.
pub fn try_with_id(
id: ::uuid::Uuid,
exporter: ::core::option::Option<::quent_instrumentation_runtime::ExporterOptions>,
) -> ::core::result::Result<Self, ::std::boxed::Box<dyn ::std::error::Error>> {
Self::assemble(::quent_instrumentation_runtime::Context::try_with_id(
id,
Self::model_info(),
exporter,
)?)
}

fn model_info() -> ::quent_instrumentation_runtime::build_info::ModelInfo {
::quent_instrumentation_runtime::build_info::ModelInfo {
name: #model_name.to_string(),
package: env!("CARGO_PKG_NAME").to_string(),
// No umbrella event enum on the schema-driven path; record
// the module the generated library is included into.
type_path: module_path!().to_string(),
source: ::quent_instrumentation_runtime::build_info::source_or_quent(
env!("CARGO_PKG_VERSION"),
option_env!("QUENT_SOURCE_REMOTE"),
option_env!("QUENT_SOURCE_COMMIT"),
option_env!("QUENT_SOURCE_BRANCH"),
option_env!("QUENT_SOURCE_DIRTY"),
option_env!("QUENT_SOURCE_BUILT_AT"),
),
}
}

fn assemble(
inner: ::quent_instrumentation_runtime::Context,
) -> ::core::result::Result<Self, ::std::boxed::Box<dyn ::std::error::Error>> {
let ( #(#fields,)* ) = inner.block_on(async {
::core::result::Result::<_, ::std::boxed::Box<dyn ::std::error::Error>>::Ok((
#( inner.observer::<#event_tys>().await?, )*
))
})?;
::core::result::Result::Ok(Self {
#( #fields: #observer_tys { inner: ::std::sync::Arc::new(#fields) }, )*
_inner: inner,
})
}

/// Identity of this context.
pub fn id(&self) -> ::uuid::Uuid {
self._inner.id()
}

#(
#[doc = #accessor_docs]
pub fn #accessors(&self) -> #observer_tys {
::core::clone::Clone::clone(&self.#fields)
}
)*
}
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::common::pretty;
use quent_schema::DataType;
use quent_schema::builder::SchemaBuilder;
use quent_schema::test_utils::{entity, event, field, ident};

#[test]
fn context_builds_and_exposes_one_observer_per_entity() {
let s = SchemaBuilder::new(ident("Demo"))
.entities([
entity(
"Connection",
[event("data", [field("bytes", DataType::U64)])],
),
entity("Sensor", [event("reading", [field("v", DataType::F64)])]),
])
.unwrap()
.build();
let src = pretty(schema_context(&s));
assert!(src.contains("pub struct DemoContext"));
assert!(src.contains("connection: ConnectionObserver"));
assert!(src.contains("sensor: SensorObserver"));
assert!(src.contains("inner.observer::<ConnectionEvent>().await?"));
assert!(src.contains("inner.observer::<SensorEvent>().await?"));
assert!(src.contains("pub fn connection_observer(&self) -> ConnectionObserver"));
assert!(src.contains("pub fn sensor_observer(&self) -> SensorObserver"));
assert!(src.contains(r#"name: "Demo".to_string()"#));
}
}
Loading
Loading