Quent Tutorial
Welcome to the Quent tutorial!
What is Quent?
Quent is a framework that helps build application-specific performance analysis tools in order to reduce the time to arrive to a conclusion about how an application is performing.
Quent is typically used by developers, but the things you can build with it can also be leveraged by users.
This tutorial mainly focuses on how software developers can integrate it into their system.
How does Quent work?
At a very high level, using Quent work as follows:
- Model the application events: Write an Application Event Schema that expresses what events your application emits and what the related semantics are.
- Generate typed libraries. From the schema, a code generation step produces a statically-typed application-specific instrumentation library and an (WIP) analysis library.
- Emit events. The application emits events through the generated instrumentation API at run-time. The instrumentation library exports the events through any of the provided exporters into their associated storage.
- Analyze behavior. An application-specific analysis service imports the events later and digests them into useful insights, e.g. to feed a UI + human or an agent in the loop of a performance optimization effort.
- Semantic modules. At any level of the stack, semantic modules can influence how things work. They can contribute to generating a more robust instrumentation API for certain types of events, add easy-to-use analysis functionality to an analysis library, or add UI components and more. They represent well-curated, opt-in vertical slices of Quent’s entire stack.
What are the key features of Quent?
- Instrumentation-based. You explicitly place instrumentation in your code.
- Schema-driven. You define your application’s entities, events, and attributes once, then generate instrumentation APIs from that model.
- Application-specific. Your events can describe the abstractions and behavior that matter to your application instead of fitting a fixed set of general-purpose event semantics.
- Statically typed, end-to-end. Generated instrumentation APIs, the event export path, and analysis all rely on the schema. This way compilers can catch mismatches early and avoid unnecessary runtime work.
- Composable. Semantic modules add curated vertical slices to your application event model. These modules can affect every layer, including code generation, instrumentation API, analysis support, and visualizations.
- Cross-language. Quent generates Rust instrumentation APIs, with experimental C++ and Python bindings for instrumenting mixed-language systems.
Why use Quent?
- You want to build a performance analysis tool that speaks in the same abstractions as your application.
- You think answer questions about performance is best done through domain-specific or application-specific relationships found in your event data.
- You need more flexibility than general-purpose logs, metrics, traces, or call-stack profiles provide on their own.
- You often dig through general-purpose telemetry/profiling data in which it takes you a lot of time to properly correlate and understand everything, and you’re looking for a rigid solution to do more of this automatically.
- You want to ensure event producers and consumers stay in sync as the application evolves.
- You are willing to add explicit instrumentation in exchange for precise, structured event data.
- You want to build on Quent’s in-tree interactive user interface components 🤩.
Why not use Quent?
- You cannot or do not want to modify the source code of your application.
- Existing tools that help produce and analyze logs, metrics, traces, or (sampled) call stacks already answer the questions you care about quickly enough.
- A few log statements and analysis scripts provide all the structure you need.
- You need a mature, stable performance analysis platform today. Quent is currently an experimental project.
What this tutorial covers
In this tutorial, we will explore how to model application behavior by defining an Application Event Schema through the use of Quent’s YAML-based DSL.
You will learn how to:
- Define entities, events, and typed attributes.
- Leverage semantic modules, including those to define FSMs, special types of references between entities, and resources.
- Use instrumentation APIs in Rust, C++, and/or Python.
Every lesson shows the complete YAML model beside the generated instrumentation API. Instrumentation examples are available in Rust, C++, and Python. Use the tabs above each example to select a language.
To keep the lessons focused, the displayed snippets omit license headers and language-specific build wiring. The complete buildable sources remain available on GitHub in the Rust examples, C++ examples, and Python examples and are linked for each lesson at the bottom as well.
The key takeaway for each newly introduced concept is placed in this kind of box.
Quent has an experimental Schema Explorer to inspect an Application Event Schema interactively.
Use the arrow on the right or the sidebar to begin.
Instrumentation
The canonical way to use Quent is to first instrument your code with an application-specific instrumentation library. Quent generates this library entirely from an Application Event Schema. From here on, this tutorial refers to it simply as a schema.
You can define a schema in several ways, including with a YAML-based DSL or
programmatically. This tutorial focuses on the YAML-based approach only. To do
it programmatically, check the
quent-schema crate documentation.
Schema
An Application Event Schema describes the event data that an application can emit. Quent uses it to generate an application-specific, typed instrumentation API.
Entities
An entity is anything you want to emit events about. It typically represents a control-flow or data-flow object in your code, such as a task, request, buffer, or worker. It can also represent a function call, a metric source, or another event-producing concept.
Each entity instance has a universally unique identity represented by a UUID, which keeps its events separate from events emitted by other instances. Processes can assign these identities independently without coordinating through a central allocator, shared counter, or global process state.
Entities exist to group related events around the thing they describe.
Events
An event is something that happens to an entity at a particular point in time, such as a task starting or ending.
For each event, the generated instrumentation API provides a named call that application code uses to emit it. Depending on the target language, this call may be exposed as a method or function.
In statically typed target languages, these generated calls are fully type-safe. The compiler checks that each event receives the expected number and types of attributes. This makes it harder for instrumentation changes to accidentally alter event semantics or break downstream analysis.
In this respect, Quent resembles structured logging: each event has a known name and typed data. Quent can also export events through an end-to-end statically typed pipeline. Exporters do not necessarily need to attach runtime type information to each value, which can reduce runtime work and improve performance.
Events exist to record how an entity behaves over time.
Attributes
An attribute is a typed value captured when an event occurs, such as a task name or result code. Each attribute becomes a typed argument to the generated event call.
Attributes exist to record the details needed to interpret an event.
Records
A field is a named, typed value inside a record. A record is a named,
reusable group of fields. The generated API represents a record as a struct
in Rust and C++. In Python, records are dictionaries with generated TypedDict
type hints.
Records exist to keep related values together and avoid repeating the same field definitions.
Event cardinality
Event cardinality defines how often an event can occur for one entity
instance. A once event occurs at most once. A multi event may occur
repeatedly.
Cardinality exists to distinguish unique events from repeatable events.
Events and performance analysis
While Quent provides generated instrumentation libraries for emitting events, event collection and event analysis are separate concerns. Quent’s analysis does not depend on how events were collected, as long as they are represented by the schema and an adapter can load them for analysis. Other sources could include statistical profilers, instrumentation-based profilers, NVTX events, CUDA API calls captured through CUPTI, OpenTelemetry signals, or eBPF probes. These are examples of possible sources, not a list of currently available Quent adapters.
Minimal model
Every model declares the YAML format version and a model name. This model has
one entity type, Task, with two events. Events are emitted at most once per
entity instance unless the model says otherwise.
YAML model
quent: alpha
model: minimal
entities:
Task:
events:
started: {}
ended: {}
Instrumentation API
The context provides an observer for each entity type. Calling .handle() on
an observer creates a handle for a new entity instance and assigns it a fresh
UUID. The handle exposes one method per event.
Every context is created with an exporter, which determines where emitted events go. These examples use the no-op exporter, which discards every event. It keeps the examples focused on the generated API without creating files or starting another service. Applications replace it with an exporter that stores or sends their events.
use instrumentation::{Context, Minimal, Noop, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<Minimal>::try_new(Noop)?;
let mut task = context.observer::<Task>().handle();
task.started()?;
task.ended()?;
Ok(())
}
#include "quent-tutorial-minimal-model-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
quent::Handle<quent::Task> task = context.task_observer()->handle();
task.started();
task.ended();
return 0;
}
import quent_tutorial_minimal_model as quent
def main() -> None:
with quent.Context() as context:
task = context.task_observer().handle()
task.started()
task.ended()
if __name__ == "__main__":
main()
The two events have no declared ordering constraint, so either event may be emitted first.
Check yourself
Full code
Where are the events?
An instrumented application chooses an exporter when it creates the Quent context. The examples in this tutorial use the no-op exporter, so their events are discarded. In this lesson, we’ll show how to use an actually useful exporter - the NDJSON exporter - to export events in a human-readible self-describing form.
Instrumentation API
These snippets show only how context construction changes when NDJSON support is enabled for the generated instrumentation library. They assume the generated module declaration and language-specific build wiring from the complete examples.
use instrumentation::{Context, Minimal};
use quent_instrumentation::{
ExporterOptions, FileSystemExporterOptions, FileSystemFormat,
};
let exporter = ExporterOptions::FileSystem(FileSystemExporterOptions::new(
FileSystemFormat::Ndjson,
"./events".into(),
));
let context = Context::<Minimal>::try_new(exporter)?;
auto context = quent::Context::ndjson("./events");
with quent.Context(quent.ExporterOptions.ndjson("./events")) as context:
...
Output
If we choose the NDJSON exporter with ./events as its output root like above,
for the minimal schema of the previous page, Quent creates a directory like
this:
events/
└── 0199a1c2-3456-7890-abcd-ef0123456789/
├── model.qmi
└── Task/
└── 0199a1c2-4567-7890-abcd-ef0123456789.ndjson
The first UUID identifies the instrumentation context. This keeps events from separate application runs or contexts isolated.
Each entity event stream gets its own directory, such as Task, containing one
or more event files. By convention, filesystem exporters give each file a
UUIDv7 name, which provides a unique filename with a generation timestamp.
The filename does not identify an entity: events for multiple Task instances
can share a file, and each event’s id identifies the instance it belongs to.
After the minimal model emits started and ended through the NDJSON exporter,
its Task file looks like this (with shortened example values):
{"id":"0199...6789","timestamp":1789675200000000000,"data":"Started"}
{"id":"0199...6789","timestamp":1789675200000000123,"data":"Ended"}
Both events have the same id because they belong to the same Task entity
instance. timestamp is the event time in nanoseconds since the Unix epoch,
and data contains the generated event payload. Later lessons add attributes,
references, and other semantics to that payload.
Drop the context and any remaining handles before reading the files at the end of a short-lived program. This gives the background exporter time to drain and flush all queued events.
NDJSON is just one exporter option. Other options currently include MessagePack, Postcard, a collector for distributed processes, a callback exporter for inprocess consumption and tests, and we’ll be adding a few more soon.
The output root contains one directory per instrumentation context, with one subdirectory per entity event stream.
model.qmi records all sorts of information about the build of the application
and Quent used to produce these events. This makes it easy for additional tools
such as quent-open
to always open event files using the same builds as the ones used to produce and
analyze the events.
Event data
An event’s attributes describe the data captured when that event occurs. The
generated event method receives one typed argument for each attribute, in
declaration order.
Data types
The scalar types are:
| YAML type | Value |
|---|---|
bool | true or false |
u8, u16, u32, u64 | Unsigned integers of the indicated width |
i8, i16, i32, i64 | Signed integers of the indicated width |
f32, f64 | Floating-point numbers of the indicated width |
string | Text |
uuid | A universally unique identifier |
Types can also be composed or refer to generated types:
| YAML type | Value |
|---|---|
{ option: T } | A value of type T that may be absent |
{ list: T } | An ordered collection of values of type T |
| A record name | An instance of that record |
dynamic | String-keyed values whose names and types are chosen at runtime |
ref | A reference to any entity instance |
Semantic modules add more specific reference forms. These are introduced with targeted references, scoped references, and resources.
YAML model
quent: alpha
model: event_data
entities:
Task:
events:
started:
attributes:
enabled: bool
byte: u8
short_count: u16
attempt: u32
item_count: u64
small_offset: i8
short_offset: i16
offset: i32
large_offset: i64
ended:
attributes:
ratio: f32
score: f64
message: string
run_id: uuid
retry_after: { option: u64 }
tags: { list: string }
extra: dynamic
Instrumentation API
The generated API maps each YAML type to the corresponding type in the selected
programming language. Options, lists, records, and references remain typed.
dynamic is the exception: it deliberately accepts values whose names and
types are determined at runtime.
use instrumentation::{Context, DynamicAttributes, EventData, Noop, Task, Uuid};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<EventData>::try_new(Noop)?;
let mut task = context.observer::<Task>().handle();
task.started(true, 1, 2, 3, 4, -1, -2, -3, -4)?;
let mut extra = DynamicAttributes::new();
extra.add("worker", "alpha");
extra.add("queue_depth", 3_u64);
task.ended(
0.5,
0.95,
"complete".to_owned(),
Uuid::now_v7(),
None,
vec!["batch".to_owned(), "priority".to_owned()],
extra,
)?;
Ok(())
}
#include "quent-tutorial-event-data-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto task = context.task_observer()->handle();
task.started(quent::task::Started{
.enabled = true,
.byte = 1,
.short_count = 2,
.attempt = 3,
.item_count = 4,
.small_offset = -1,
.short_offset = -2,
.offset = -3,
.large_offset = -4,
});
quent::DynamicAttributes extra;
extra.add("worker", "alpha");
extra.add("queue_depth", std::uint64_t{3});
task.ended(quent::task::Ended{
.ratio = 0.5F,
.score = 0.95,
.message = "complete",
.run_id = quent::now_v7(),
.retry_after = std::nullopt,
.tags = {"batch", "priority"},
.extra = std::move(extra),
});
return 0;
}
import quent_tutorial_event_data as quent
def main() -> None:
with quent.Context() as context:
task = context.task_observer().handle()
task.started(
enabled=True,
byte=1,
short_count=2,
attempt=3,
item_count=4,
small_offset=-1,
short_offset=-2,
offset=-3,
large_offset=-4,
)
task.ended(
ratio=0.5,
score=0.95,
message="complete",
run_id=quent.now_v7(),
retry_after=None,
tags=["batch", "priority"],
extra={
"worker": "alpha",
"queue_depth": quent.DynamicValue.u64(3),
},
)
if __name__ == "__main__":
main()
Event attributes produce typed parameters in the generated instrumentation API.
Check yourself
Full code
Dynamic attributes
Most event attributes have names and types fixed by the schema. This lets the generated instrumentation API check their use before the application runs.
The dynamic type provides a typed container whose keys and value types are
chosen when the event is emitted. It is useful when the available details
cannot be known while writing the schema. The generated event method still
requires a dynamic attribute container, but the schema does not check the keys
or types placed inside it. Prefer regular attributes for stable event data.
YAML model
quent: alpha
model: dynamic_data
entities:
Task:
events:
started:
attributes:
details: dynamic
ended:
attributes:
details: dynamic
The schema declares one dynamic container on each event. It does not declare the individual keys that the containers will hold.
Instrumentation API
use instrumentation::{Context, DynamicAttributes, DynamicData, Noop, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<DynamicData>::try_new(Noop)?;
let mut task = context.observer::<Task>().handle();
let mut started_details = DynamicAttributes::new();
started_details.add("queue", "priority");
started_details.add("attempt", 2_u64);
task.started(started_details)?;
let mut ended_details = DynamicAttributes::new();
ended_details.add("cached", false);
ended_details.add("items_processed", 128_u64);
task.ended(ended_details)?;
Ok(())
}
#include "quent-tutorial-dynamic-attributes-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto task = context.task_observer()->handle();
quent::DynamicAttributes started_details;
started_details.add("queue", "priority");
started_details.add("attempt", std::uint64_t{2});
task.started(quent::task::Started{.details = std::move(started_details)});
quent::DynamicAttributes ended_details;
ended_details.add("cached", false);
ended_details.add("items_processed", std::uint64_t{128});
task.ended(quent::task::Ended{.details = std::move(ended_details)});
return 0;
}
import quent_tutorial_dynamic_attributes as quent
def main() -> None:
with quent.Context() as context:
task = context.task_observer().handle()
task.started(
details={"queue": "priority", "attempt": quent.DynamicValue.u64(2)}
)
task.ended(
details={
"cached": False,
"items_processed": quent.DynamicValue.u64(128),
}
)
if __name__ == "__main__":
main()
The application adds a string and an integer to the started event, then a
boolean and an integer to the ended event. Each value retains its runtime
type. The target-language API provides wrappers or conversions for selecting an
exact numeric type. Dynamic attributes also support null values, which do not
retain an intended value type.
Use dynamic attributes for event details that cannot be defined in advance.
Check yourself
Full code
Repeated events
Events are once by default. Set multi: true when one entity instance may
emit the same event repeatedly.
YAML model
quent: alpha
model: repeated_events
entities:
Task:
events:
started:
attributes:
command: string
progress:
multi: true
attributes:
items_processed: u64
ended:
attributes:
success: bool
Instrumentation API
The same Task handle emits progress more than once. Repeating started or
ended on that handle would return an error.
use instrumentation::{Context, Noop, RepeatedEvents, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<RepeatedEvents>::try_new(Noop)?;
let mut task = context.observer::<Task>().handle();
task.started("compile".to_owned())?;
// `started` is a once event, so a second call would return an error.
// task.started("compile".to_owned())?;
task.progress(64)?;
task.progress(128)?;
task.ended(true)?;
Ok(())
}
#include "quent-tutorial-repeated-events-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto task = context.task_observer()->handle();
task.started(quent::task::Started{.command = "compile"});
task.progress(quent::task::Progress{.items_processed = 64});
task.progress(quent::task::Progress{.items_processed = 128});
task.ended(quent::task::Ended{.success = true});
return 0;
}
import quent_tutorial_repeated_events as quent
def main() -> None:
with quent.Context() as context:
task = context.task_observer().handle()
task.started(command="compile")
task.progress(items_processed=64)
task.progress(items_processed=128)
task.ended(success=True)
if __name__ == "__main__":
main()
multi: true permits an event to be emitted more than once for an entity.
Check yourself
Full code
Records
A record groups related fields into a named, reusable type. Event attributes can use a record name wherever they can use a scalar type.
YAML model
quent: alpha
model: records
records:
# Reused by events on both Task and Batch.
WorkResult:
fields:
success: bool
items_processed: u64
entities:
Task:
events:
started: {}
ended:
attributes:
result: WorkResult
Batch:
events:
started: {}
ended:
attributes:
result: WorkResult
Instrumentation API
The generated API represents WorkResult as a target-language record type.
Both Task and Batch accept that type when emitting ended.
use instrumentation::{Batch, Context, Noop, Records, Task, WorkResult};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<Records>::try_new(Noop)?;
let mut task = context.observer::<Task>().handle();
let mut batch = context.observer::<Batch>().handle();
task.started()?;
task.ended(WorkResult {
success: true,
items_processed: 128,
})?;
batch.started()?;
batch.ended(WorkResult {
success: true,
items_processed: 512,
})?;
Ok(())
}
#include "quent-tutorial-records-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto task = context.task_observer()->handle();
auto batch = context.batch_observer()->handle();
task.started();
task.ended(quent::task::Ended{
.result = quent::records::WorkResult{
.success = true,
.items_processed = 128,
},
});
batch.started();
batch.ended(quent::batch::Ended{
.result = quent::records::WorkResult{
.success = true,
.items_processed = 512,
},
});
return 0;
}
import quent_tutorial_records as quent
def main() -> None:
with quent.Context() as context:
task = context.task_observer().handle()
batch = context.batch_observer().handle()
task.started()
task.ended(result={"success": True, "items_processed": 128})
batch.started()
batch.ended(result={"success": True, "items_processed": 512})
if __name__ == "__main__":
main()
A named record provides one reusable structure for event attributes.
Check yourself
Full code
Entity references
An attribute of type ref identifies another entity instance by its UUID. The
reference is type-erased: the generated API knows that it is an entity
reference, but does not restrict which entity type it targets. This is useful
when any kind of entity is a valid target.
YAML model
quent: alpha
model: untyped_references
entities:
Worker:
events:
started: {}
ended: {}
Task:
events:
started:
attributes:
source: ref
ended: {}
Instrumentation API
Every entity handle exposes its identity as a type-erased reference. Here, the
task’s started event receives a reference to the Worker instance.
use instrumentation::{Context, Noop, Task, UntypedReferences, Worker};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<UntypedReferences>::try_new(Noop)?;
let mut worker = context.observer::<Worker>().handle();
worker.started()?;
let mut task = context.observer::<Task>().handle();
task.started(worker.as_any_entity_ref())?;
task.ended()?;
worker.ended()?;
Ok(())
}
#include "quent-tutorial-untyped-entity-references-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto worker = context.worker_observer()->handle();
worker.started();
auto task = context.task_observer()->handle();
task.started(quent::task::Started{.source = worker.id().raw()});
task.ended();
worker.ended();
return 0;
}
import quent_tutorial_untyped_entity_references as quent
def main() -> None:
with quent.Context() as context:
worker = context.worker_observer().handle()
worker.started()
task = context.task_observer().handle()
task.started(source=worker.id)
task.ended()
worker.ended()
if __name__ == "__main__":
main()
When an attribute must target a particular entity type, the Reference Target semantic module adds that restriction to the generated API.
A type-erased reference preserves an entity's identity without restricting its entity type.
Check yourself
Full code
Semantic Modules
Semantic modules, or mods, are curated vertical slices of Quent’s stack. A mod adds reusable meaning and constraints to basic schema elements, then lets instrumentation, analysis, and user-interface tooling interpret (the semantics of) those elements consistently. Quent currently develops and curates these modules as integrated parts of the telemetry stack. Mods are the primary way Quent is intended to evolve, incrementally adding capabilities without requiring major changes to the core framework.
The YAML-based DSL provides additional syntax that makes it easy to start leveraging these semantic modules. An optional, more detailed explanation of how this relates to YAML-based schema capture follows below. It is not necessary to understand these details in order to benefit from semantic modules. Feel free to skip to the next lesson.
YAML Syntax for Semantic Modules
Semantic modules express their additional rules as opaque annotations on schema elements. They may also add ordinary schema elements such as records, entities, events, or fields. As described above, components in the various layers of Quent interpret only annotations they understand, so adding support for new semantics in one component does not necessarily affect other components.
For example, exporters and importers in the event I/O layer typically serialize event data without interpreting its semantics. At the same time, the code-generation layer can generate instrumentation APIs that, e.g. through leveraging the type system of the target language, enforce certain rules about events, preventing applications from emitting events that violate the module’s semantics.
Necessary data for semantic modules can be added to the schema directly through YAML. For example, the expanded schema form of a small FSM (explained in more detail in the Finite-State Machine lesson) contains state events and an opaque constraint describing its transitions:
quent: alpha
model: query
entities:
Query:
constraints:
quent.fsm.v0.1.0: '{"initial_state":"queued","transitions":[{"source":"queued","target":"running"}]}'
events:
queued:
attributes:
seq: u16 # required by the FSM constraint
running:
attributes:
seq: u16
This is not very readable and it adds a lot of noisy details. To express these semantics more concisely, the YAML-based DSL provides module-specific syntax.
For the example above, we can also write it as:
quent: alpha
model: query
fsms:
Query:
states:
queued:
initial: true
to: [running]
running: {}
Thus, the YAML-based DSL parser will often provide syntactic sugar for semantic modules. This additional syntax related to semantic modules can appear at different levels of the YAML tree. It typically starts with a short key corresponding to the name of the semantic module. For example:
- Finite-State Machine uses
fsms: ...at the model root. - Resource uses
resource: ...on an entity. - Operating System uses
os: ...as a field type.
Its value and any nested fields are specific to that mod, and are documented in more detail in the YAML reference as well as the next few lessons in this chapter.
This type of syntactic sugar over plain schema elements typically provides a concise way to:
- declare certain (potentially relatively complicated) event semantics
- e.g. that events must be emitted in a sequence specified by some Finite-State Machine
- e.g. that an entity represents something that provides certain Resource to other entities
- add a large number of standardized elements (typically records)
- e.g. to add schema records representing events defined by NVTX
Semantic modules sometimes depend on each other. One piece of YAML syntax can therefore add annotations from multiple mods to a schema element.
Reference Target
The Reference Target semantic module constrains an entity reference to a
specific entity type. A targeted ref links one entity to another entity of a
declared type. Here, the task’s started event records which Worker runs it.
YAML model
quent: alpha
model: references
entities:
Worker:
events:
registered: {}
Task:
events:
started:
attributes:
worker: { ref: Worker }
ended: {}
Instrumentation API
The generated started method only accepts the target-language representation
of a reference to a Worker.
use instrumentation::{Context, Noop, References, Task, Worker};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<References>::try_new(Noop)?;
let mut worker = context.observer::<Worker>().handle();
worker.registered()?;
let mut task = context.observer::<Task>().handle();
// A `Task` reference has the wrong target type, so this would not compile:
// let other_task = context.observer::<Task>().handle();
// task.started(other_task.as_entity_ref())?;
task.started(worker.as_entity_ref())?;
task.ended()?;
Ok(())
}
#include "quent-tutorial-entity-references-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto worker = context.worker_observer()->handle();
worker.registered();
auto task = context.task_observer()->handle();
task.started(quent::task::Started{.worker = worker.id()});
task.ended();
return 0;
}
import quent_tutorial_entity_references as quent
def main() -> None:
with quent.Context() as context:
worker = context.worker_observer().handle()
worker.registered()
task = context.task_observer().handle()
task.started(worker=worker)
task.ended()
if __name__ == "__main__":
main()
An entity reference identifies a specific entity and preserves its type.
Check yourself
Full code
Reference Tree
The Reference Tree semantic module marks entity references that form
parent-child relationships. A scope-ref applies both the reference-target and
reference-tree constraints. The parser validates all scoped references together
as one tree.
Why is a target type required?
A type-erased ref cannot form part of the
Reference Tree. A scope-ref must name its target entity type so Quent can
validate the complete tree when it processes the schema.
Without a declared target type, different instances of the same child entity type could refer to different parent entity types at runtime. The generated instrumentation API could then no longer guarantee that the resulting relationships form the tree declared by the schema.
This gives analysis tools a preferred path from one root entity to every related entity and its events. In the model below, a task points to the pipeline that contains it. A user interface can open one pipeline and list its tasks, while analysis can associate each task’s events with that pipeline. Other tools can use the same hierarchy for their own purposes.
YAML model
quent: alpha
model: scoped_references
entities:
Pipeline:
events:
created: {}
Task:
events:
started:
attributes:
parent: { scope-ref: Pipeline }
ended: {}
Instrumentation API
The parent entity’s handle provides the reference. The additional hierarchy meaning belongs to the model and its constraints.
use instrumentation::{Context, Noop, Pipeline, ScopedReferences, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<ScopedReferences>::try_new(Noop)?;
let mut pipeline = context.observer::<Pipeline>().handle();
pipeline.created()?;
let mut task = context.observer::<Task>().handle();
task.started(pipeline.as_entity_ref())?;
task.ended()?;
Ok(())
}
#include "quent-tutorial-scoped-references-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto pipeline = context.pipeline_observer()->handle();
pipeline.created();
auto task = context.task_observer()->handle();
task.started(quent::task::Started{.parent = pipeline.id()});
task.ended();
return 0;
}
import quent_tutorial_scoped_references as quent
def main() -> None:
with quent.Context() as context:
pipeline = context.pipeline_observer().handle()
pipeline.created()
task = context.task_observer().handle()
task.started(parent=pipeline)
task.ended()
if __name__ == "__main__":
main()
A scoped reference defines a parent relationship in a validated entity tree.
Check yourself
Full code
Finite-State Machine
The Finite-State Machine semantic module defines an entity lifecycle as states and allowed transitions. The parser validates the topology and derives event cardinality from it. The generated instrumentation APIs use that topology to enforce transition order.
For example, a task might move from queued to running, then to either
completed or failed. Making that lifecycle part of the schema gives analysis
tools enough meaning to detect unexpected transitions, find work that never
reached a final state, and measure how long entities remained in each state. A
user interface can also present the declared lifecycle and the observed path
through it.
When an API must return an FSM whose current state is selected at runtime, the Dynamic state lesson shows how to preserve that state while moving transition validation to runtime.
The Resource lessons later show how an FSM state can declare the resources an entity uses while it remains in that state.
Basic lifecycle
An FSM puts lifecycle topology in the model. It declares an initial state, allowed transitions, and a reachable final state.
YAML model
quent: alpha
model: finite_state_machine
fsms:
Job:
states:
queued:
initial: true
to: [loading_input, restoring_checkpoint]
loading_input:
to: [running]
restoring_checkpoint:
to: [running]
running:
to: [completed]
completed: {}
The parser rejects missing initial states, unreachable states, invalid targets, and FSMs without a reachable final state. It also derives event cardinality from the topology.
Instrumentation API
Entering a state emits its generated event. The generated API represents the current FSM state in the handle’s type. Each transition consumes that handle and returns a handle for the target state. Only transitions allowed from the current state are available to the compiler or type checker. This pattern is called typestate.
The Rust and C++ examples show the state-specific handle types directly. The generated Python type stubs expose the same transition constraints to type checkers and editors.
When control flow must return an FSM in a state selected at runtime, use a dynamic-state handle (see next lesson).
use instrumentation::{Context, FiniteStateMachine, Job, Noop};
#[rustfmt::skip]
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<FiniteStateMachine>::try_new(Noop)?;
let _job = context
.observer::<Job>()
.handle() // FsmHandle<Job>
.queued() // FsmHandle<Job, job_state::Queued>
.loading_input() // FsmHandle<Job, job_state::LoadingInput>
.running() // FsmHandle<Job, job_state::Running>
.completed(); // FsmHandle<Job, job_state::Completed>
Ok(())
}
#include "quent-tutorial-finite-state-machine-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
quent::FsmHandle<quent::Job> job = context.job_observer()->handle();
// A transition consumes its move-only state handle and returns the handle
// type for the new state. Named C++ objects require std::move to be consumed.
quent::FsmHandle<quent::Job, quent::job_state::Queued> queued =
std::move(job).queued();
quent::FsmHandle<quent::Job, quent::job_state::LoadingInput> loading =
std::move(queued).loading_input();
quent::FsmHandle<quent::Job, quent::job_state::Running> running =
std::move(loading).running();
quent::FsmHandle<quent::Job, quent::job_state::Completed> completed =
std::move(running).completed();
return 0;
}
import quent_tutorial_finite_state_machine as quent
def main() -> None:
with quent.Context() as context:
completed = (
context.job_observer()
.handle()
.queued()
.loading_input()
.running()
.completed()
)
if __name__ == "__main__":
main()
The generated handle exposes only the transitions allowed from its current state.
Check yourself
Full code
Dynamic state
Typestate handles are the preferred FSM API because they make invalid transitions impossible to express at compile-time. Sometimes, however, a function can finish in one of several states selected at runtime.
Consider a function that prepares the Job from the
basic lifecycle. It either loads input or restores a
checkpoint:
queued ─┬─> loading_input ───────────┐
└─> restoring_checkpoint ────┴─> running ─> completed
YAML model
quent: alpha
model: fsm_dynamic_state
fsms:
Job:
states:
queued:
initial: true
to: [loading_input, restoring_checkpoint]
loading_input:
to: [running]
restoring_checkpoint:
to: [running]
running:
to: [completed]
completed: {}
The two branches return different Rust types:
fn prepare_job(
job: FsmHandle<Job, job_state::Queued>,
restore: bool,
) -> /* no single typestate handle works here */ {
if restore {
job.restoring_checkpoint() // FsmHandle<Job, RestoringCheckpoint>
} else {
job.loading_input() // FsmHandle<Job, LoadingInput>
}
}
An enum containing both handle types could represent this result, but it would be specific to this function’s two possible outcomes. Quent knows the FSM topology, but it cannot know in advance which subsets and combinations of states application control flow will need to return. Generating enums for every possible combination would cause the API surface to grow rapidly, while asking applications to define them creates a different wrapper type for each such boundary. Consumers would also have to match each enum before doing common work.
Instrumentation API
Calling into_dynamic() consumes any typestate handle and returns the common
DynamicFsmHandle<Job> type. The handle stores its current state and exposes
all transitions declared by the FSM. Each example below returns the dynamic
handle from the same runtime branch:
use instrumentation::{
Context, DynamicFsmHandle, FsmDynamicState, FsmHandle, Job, Noop, job_state,
};
fn prepare_job(
job: FsmHandle<Job, job_state::Queued>,
restore_from_checkpoint: bool,
) -> DynamicFsmHandle<Job> {
if restore_from_checkpoint {
job.restoring_checkpoint().into_dynamic()
} else {
job.loading_input().into_dynamic()
}
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let restore_from_checkpoint = std::env::args().any(|arg| arg == "--restore");
let context = Context::<FsmDynamicState>::try_new(Noop)?;
let queued = context.observer::<Job>().handle().queued();
let mut job = prepare_job(queued, restore_from_checkpoint);
job.running()?;
let job = job.try_into::<job_state::Running>()?;
job.completed();
Ok(())
}
#include "quent-tutorial-fsm-dynamic-state-cpp-bridge/gen/quent.hpp"
#include <iostream>
quent::DynamicFsmHandle<quent::Job> prepare_job(
quent::FsmHandle<quent::Job, quent::job_state::Queued> job,
bool restore_from_checkpoint) {
if (restore_from_checkpoint) {
return std::move(job).restoring_checkpoint().into_dynamic();
}
return std::move(job).loading_input().into_dynamic();
}
int main(int argc, char**) {
const bool restore_from_checkpoint = argc > 1;
auto context = quent::Context::none();
auto queued = context.job_observer()->handle().queued();
auto job = prepare_job(std::move(queued), restore_from_checkpoint);
job.running();
auto running = std::move(job).try_into<quent::job_state::Running>();
if (!running) {
std::cerr << "job did not reach the running state\n";
return 1;
}
std::move(*running).completed();
return 0;
}
import sys
import quent_tutorial_fsm_dynamic_state as quent
def prepare_job(
job: quent.JobQueuedHandle, restore_from_checkpoint: bool
) -> quent.JobDynamicFsmHandle:
if restore_from_checkpoint:
return job.restoring_checkpoint().into_dynamic()
return job.loading_input().into_dynamic()
def main() -> None:
restore_from_checkpoint = "--restore" in sys.argv
with quent.Context() as context:
queued = context.job_observer().handle().queued()
job = prepare_job(queued, restore_from_checkpoint)
job.running()
running = job.try_into_running()
running.completed()
if __name__ == "__main__":
main()
The tradeoff is when transition correctness is checked. FsmHandle lets the
compiler (if any) check it through the Rust or C++ type system, or through
typechecks for Python type stubs. DynamicFsmHandle checks it when the
transition method is called. An invalid transition returns FsmTransitionError
in Rust, throws from the C++ method, or raises InvalidFsmTransitionError in
Python. A failed transition does not emit an event or change the stored state.
Converting a typestate handle preserves its current state and entity ID. In Python, the generated type stubs still distinguish each typestate handle, while the dynamic handle gives type checkers a common return type for both branches.
When the current state becomes known again, convert back to a typestate handle.
Rust uses try_into::<job_state::Running>(), C++ uses
try_into<quent::job_state::Running>(), and Python exposes the state-specific
try_into_running() method. A successful conversion consumes the dynamic
handle. A mismatch does not: Rust returns it in FsmStateMismatch, C++ returns
an empty std::optional without moving from the handle, and Python raises
InvalidFsmStateError without consuming it.
Instrumentation supports up to 255 declared states in one FSM. Dynamic handles use a compact state index, with one additional value for a handle that has not entered its initial state. Code generation rejects an FSM with 256 or more states.
When to use it
Use DynamicFsmHandle when an API boundary must represent multiple possible
current states, such as a function that resumes persisted work, performs an
optional preparation phase, or reconstructs state from runtime input.
Keep FsmHandle when the current state is known to the caller. It provides the
stronger guarantee, offers only valid transitions in editor completion, and
does not require handling runtime transition errors.
Convert to DynamicFsmHandle at the boundary where control flow prevents one typestate return type, not earlier.
Check yourself
Full code
Self-loops
A direct self-loop transitions from a state back to itself, such as
running → running. A state can also be part of an indirect cycle, such as
running → paused → running. Both forms allow states on the cycle to be
entered repeatedly, so their generated events have multi cardinality.
YAML model
quent: alpha
model: fsm_self_loop
fsms:
Task:
states:
running:
initial: true
attributes:
items_processed: u64
to: [running, paused, completed]
paused:
to: [running]
completed: {}
Instrumentation API
The task first repeats running through its direct self-loop. It then follows
the indirect cycle through paused and back to running before entering
completed once.
use instrumentation::{Context, FsmSelfLoop, Noop, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<FsmSelfLoop>::try_new(Noop)?;
let task = context.observer::<Task>().handle().running(0);
// Direct self-loop: running -> running.
let task = task.running(64);
// Indirect cycle: running -> paused -> running.
let task = task.paused().running(128);
let _task = task.completed();
Ok(())
}
#include "quent-tutorial-fsm-self-loop-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto task = context.task_observer()->handle().running(
quent::task::Running{.items_processed = 0});
task = std::move(task).running(
quent::task::Running{.items_processed = 64});
auto paused = std::move(task).paused();
task = std::move(paused).running(
quent::task::Running{.items_processed = 128});
auto completed = std::move(task).completed();
return 0;
}
import quent_tutorial_fsm_self_loop as quent
def main() -> None:
with quent.Context() as context:
task = context.task_observer().handle()
running = task.running(items_processed=0)
running = running.running(items_processed=64)
paused = running.paused()
running = paused.running(items_processed=128)
completed = running.completed()
if __name__ == "__main__":
main()
Every state on a direct or indirect cycle has a repeatable state-entry event.
Check yourself
Full code
Resource
The Resource semantic module describes what an application provides and what its work consumes:
- A resource is an entity that can be claimed, such as a thread, worker, or memory pool.
- A capacity is a named quantity provided by a resource.
- An occupancy is a quantity held throughout a usage. For example, a task might occupy 256 bytes of a memory pool until it leaves its current state.
- A rate records a total quantity processed during a usage. For example, a transfer might process 1,024 bytes. Dividing that value by the usage duration gives the observed transfer rate.
- A resource with no named capacities is a unit resource. Each usage claims the entire resource instance.
- A usage is a claim on a specific resource instance. It identifies the resource and records how much of each capacity is claimed.
- A bound is a reported upper limit for a capacity. Bounds belong to the resource and may be updated by its events.
A resource can be declared on an entity under either entities or fsms. An
FSM can therefore provide a resource while also modeling its own lifecycle.
Only entities modeled as FSMs can use resources.
Why can only FSMs use resources?
Entering a state starts the usages declared by that state, and leaving it ends them. A final state cannot start a usage. The validated FSM topology therefore gives every usage a modeled way to end.
This is a schema-level guarantee, not a runtime guarantee. The instrumenting code remains responsible for emitting a valid transition out of a state that uses resources. If it does not, the event stream contains a logically unended usage.
Unit resources
resource: true declares an indivisible resource. This model places each
Thread under a ThreadPool, then places a running Task under the specific
thread it claims.
YAML model
quent: alpha
model: unit_resource
entities:
ThreadPool:
events:
created: {}
Thread:
resource: true
events:
registered:
attributes:
pool: { scope-ref: ThreadPool }
fsms:
Task:
states:
running:
initial: true
attributes:
# ThreadUsage is generated from the Thread resource declaration.
thread: { scope-ref: Thread, data: ThreadUsage }
to: [completed]
completed: {}
The ThreadUsage record is generated automatically. It has no fields because a
unit resource is claimed as a whole.
Instrumentation API
The generated API attaches the usage record to the scoped thread reference.
use instrumentation::{Context, Noop, Task, Thread, ThreadPool, ThreadUsage, UnitResource};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<UnitResource>::try_new(Noop)?;
let mut pool = context.observer::<ThreadPool>().handle();
pool.created()?;
let mut thread = context.observer::<Thread>().handle();
thread.registered(pool.as_entity_ref())?;
let _task = context
.observer::<Task>()
.handle()
.running(thread.as_entity_ref_with(ThreadUsage))
.completed();
Ok(())
}
#include "quent-tutorial-unit-resource-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto pool = context.thread_pool_observer()->handle();
pool.created();
auto thread = context.thread_observer()->handle();
thread.registered(quent::thread::Registered{.pool = pool.id()});
auto running = context.task_observer()->handle().running(
quent::task::Running{
.thread = quent::refs::ThreadUsageRef{
.target = thread.id(),
.data = quent::records::ThreadUsage{},
},
});
auto completed = std::move(running).completed();
return 0;
}
import quent_tutorial_unit_resource as quent
def main() -> None:
with quent.Context() as context:
pool = context.thread_pool_observer().handle()
pool.created()
thread = context.thread_observer().handle()
thread.registered(pool=pool)
task = context.task_observer().handle()
running = task.running(thread={"target": thread, "data": {}})
completed = running.completed()
if __name__ == "__main__":
main()
A unit resource represents one indivisible resource instance.
Check yourself
Full code
Resource capacities
A resource can expose measured capacities. occupancy describes a quantity
held over the usage span, such as bytes of memory held while a task runs.
YAML model
quent: alpha
model: resource_capacity
entities:
Memory:
resource:
bytes:
kind: occupancy
events:
created: {}
fsms:
Task:
states:
running:
initial: true
attributes:
memory: { uses: Memory }
to: [completed]
completed: {}
Declaring the bytes capacity generates a MemoryUsage record with a bytes
field.
Instrumentation API
The task’s reference to Memory carries the quantity it claims.
use instrumentation::{Context, Memory, MemoryUsage, Noop, ResourceCapacity, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<ResourceCapacity>::try_new(Noop)?;
let mut memory = context.observer::<Memory>().handle();
memory.created()?;
let _task = context
.observer::<Task>()
.handle()
.running(memory.as_entity_ref_with(MemoryUsage { bytes: 512_000_000 }))
.completed();
Ok(())
}
#include "quent-tutorial-resource-capacity-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto memory = context.memory_observer()->handle();
memory.created();
auto running = context.task_observer()->handle().running(
quent::task::Running{
.memory = quent::refs::MemoryUsageRef{
.target = memory.id(),
.data = quent::records::MemoryUsage{
.bytes = 512'000'000,
},
},
});
auto completed = std::move(running).completed();
return 0;
}
import quent_tutorial_resource_capacity as quent
def main() -> None:
with quent.Context() as context:
memory = context.memory_observer().handle()
memory.created()
task = context.task_observer().handle()
running = task.running(
memory={
"target": memory,
"data": {"bytes": 512_000_000},
}
)
completed = running.completed()
if __name__ == "__main__":
main()
A capacity resource records the quantity of a resource used by an entity.
Check yourself
Full code
Bounded resources
known-bounds: true states that a capacity has an explicit bound. An event or
FSM state attribute marked with sets-resource-bounds: true carries the
generated bounds record whenever that limit changes.
YAML model
quent: alpha
model: bounded_resource
entities:
Memory:
resource:
bytes:
kind: occupancy
known-bounds: true
events:
resized:
multi: true
attributes:
# MemoryBounds is generated because bytes has known bounds.
limits: { sets-resource-bounds: true }
fsms:
Task:
states:
running:
initial: true
attributes:
memory: { uses: Memory }
to: [completed]
completed: {}
The resource declaration generates both MemoryUsage and MemoryBounds. Both
records use u64 for the bytes field. Resource declarations do not currently
support selecting another numeric width.
Instrumentation API
The memory entity publishes its current bound. The task separately records how much of that capacity it claims.
use instrumentation::{BoundedResource, Context, Memory, MemoryBounds, MemoryUsage, Noop, Task};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<BoundedResource>::try_new(Noop)?;
let memory = context.observer::<Memory>().handle();
memory.resized(MemoryBounds {
bytes: 8_000_000_000,
})?;
let _task = context
.observer::<Task>()
.handle()
.running(memory.as_entity_ref_with(MemoryUsage { bytes: 512_000_000 }))
.completed();
Ok(())
}
#include "quent-tutorial-bounded-resource-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto memory = context.memory_observer()->handle();
memory.resized(quent::memory::Resized{
.limits = quent::records::MemoryBounds{.bytes = 8'000'000'000},
});
auto running = context.task_observer()->handle().running(
quent::task::Running{
.memory = quent::refs::MemoryUsageRef{
.target = memory.id(),
.data = quent::records::MemoryUsage{
.bytes = 512'000'000,
},
},
});
auto completed = std::move(running).completed();
return 0;
}
import quent_tutorial_bounded_resource as quent
def main() -> None:
with quent.Context() as context:
memory = context.memory_observer().handle()
memory.resized(limits={"bytes": 8_000_000_000})
task = context.task_observer().handle()
running = task.running(
memory={
"target": memory,
"data": {"bytes": 512_000_000},
}
)
completed = running.completed()
if __name__ == "__main__":
main()
A known bound records the available capacity separately from resource usage.
Check yourself
Full code
Log
Sometimes you just want to simply log something human-readable. The log module provides a simple way to declare an entity that behaves as a general purpose log sink. At run-time, an instance of this entity will typically be leveraged as a general-purpose logging API. It could also receive messages from an already existing logging API that supports different backends.
The benefit is that those logs can follow the same export and analysis path as all other types of events, such that they can be correlated at analysis-time. For example, a UI could display them right alongside resource utilization metrics.
YAML model
Add an entry under the top-level logs: key to declare a log sink. Each item
under levels creates a repeatable event with the same name. Quent adds a
message: string attribute to every level event. There is no separate level
attribute: an info message is an info event, and an error message is an
error event.
The order of levels sets their ranks. The first level has rank 0, the next has
rank 1, and so on.
quent: alpha
model: logging_sink
logs:
AppLog:
doc: Application logging sink.
attributes:
target: string
file: string
line: u32
module: string
thread_name: string
levels:
- name: trace
- name: debug
- name: info
doc: Informational messages.
- name: warning
attributes:
category: string
- name: error
attributes:
error_code: u32
AppLog defines the levels and attributes supported by this kind of log sink.
Each AppLog instance has its own runtime identity.
Quent adds a required message: string attribute to every level event, so each
generated level method takes the message as an argument.
The target, file, line, module, and thread_name fields in this example
are arbitrary attributes. Because they are declared directly under
logs.AppLog.attributes, they are added to every level.
Attributes under a level are also arbitrary, but are added only to that level.
Here, category is added to warning, while error_code is added to error.
Instrumentation API
The generated API has one method for each level. This example calls info and
warning directly. A backend for an existing logging API could instead
translate each log record into the matching generated method call.
use instrumentation::{AppLog, Context, LoggingSink, Noop};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<LoggingSink>::try_new(Noop)?;
let log = context.observer::<AppLog>().handle();
// A logging API would typically use a macro to capture `file!()`, `line!()`,
// and any other call-site context required by the schema.
log.info(
"application started".to_owned(),
"startup".to_owned(),
file!().to_owned(),
line!(),
module_path!().to_owned(),
"main".to_owned(),
)?;
log.warning(
"retrying request".to_owned(),
"network".to_owned(),
file!().to_owned(),
line!(),
module_path!().to_owned(),
"main".to_owned(),
"transient".to_owned(),
)?;
Ok(())
}
#include "quent-tutorial-log-sink-cpp-bridge/gen/quent.hpp"
#include <cstdint>
#include <source_location>
#include <string>
#include <string_view>
using AppLog = quent::Handle<quent::AppLog>;
void info(const AppLog& log, std::string_view message, std::string_view target,
std::source_location source = std::source_location::current()) {
log.info(quent::app_log::Info{
.message = std::string{message},
.target = std::string{target},
.file = source.file_name(),
.line = static_cast<std::uint32_t>(source.line()),
.module = source.function_name(),
.thread_name = "main",
});
}
void warning(const AppLog& log, std::string_view message,
std::string_view target, std::string_view category,
std::source_location source = std::source_location::current()) {
log.warning(quent::app_log::Warning{
.message = std::string{message},
.target = std::string{target},
.file = source.file_name(),
.line = static_cast<std::uint32_t>(source.line()),
.module = source.function_name(),
.thread_name = "main",
.category = std::string{category},
});
}
int main() {
auto context = quent::Context::none();
auto log = context.app_log_observer()->handle();
// Default arguments based on std::source_location capture each call site.
info(log, "application started", "startup");
warning(log, "retrying request", "network", "transient");
return 0;
}
import quent_tutorial_log_sink as quent
def main() -> None:
with quent.Context() as context:
log = context.app_log_observer().handle()
# A logging API would typically capture the file, line, and any other
# call-site context required by the schema.
log.info(
message="application started",
target="startup",
file=__file__,
line=13,
module=__name__,
thread_name="main",
)
log.warning(
message="retrying request",
target="network",
file=__file__,
line=21,
module=__name__,
thread_name="main",
category="transient",
)
if __name__ == "__main__":
main()
The entity identifies the sink. Each declared level identifies one log event.
Check yourself
Advanced example
A log sink can have events unrelated to the events produced from the level
definitions. This can be useful to add other, arbitrary events (e.g.
initialized) to the sink.
Such events may even be required if you’re also leveraging the Reference Tree module, because that module adds the rule that all entities (indirectly) refer to some root entity in the shape of a tree.
Below is a typical example where a log sink is nested under an entity representing a process through the Operating System module:
entities:
CoolApp:
events:
started:
attributes:
process: { os: process }
logs:
AppLog:
events:
initialized:
attributes:
process: { scope-ref: CoolApp }
flushed: {}
levels:
- name: info
- name: error
# etc.
Full code
Operating System
The Operating System semantic module identifies a Quent entity as a specific operating-system process or thread. This allows entities to be correlated with data from general-purpose event sources that identify processes and threads by their native operating-system IDs rather than Quent UUIDs, such as captured NVTX annotations or CPU stack samples.
Use this module when:
- An entity represents an entire process in a multi-process system, such as a worker process in a process pool.
- An entity represents a specific operating-system thread, such as a worker or main thread.
- The entity must be matched with captured NVTX annotations, CPU stack samples, or another source that identifies processes and threads by native IDs.
Do not use this module when:
- An error event only needs to report the process or thread in which the error occurred.
- A task, request, or connection event records which process or thread handled it, but the entity itself does not represent that process or thread.
- A process or thread ID is only a diagnostic field or metric dimension and does not define what the entity represents.
Use ordinary attributes for those values instead.
YAML model
quent: alpha
model: operating_system
entities:
Process:
events:
started:
attributes:
process: { os: process }
Thread:
events:
started:
attributes:
thread: { os: thread }
process: { scope-ref: Process }
Using the os as an event field type adds Quent’s standard operating-system
records to the schema and uses it as the field’s type:
{ os: process }adds thequent::os::Processrecord.{ os: thread }adds thequent::os::Threadrecord.
The generated event API requires values for these fields when the event is emitted.
We use these standard records so event analysis tools know that the entity itself represents a process or thread. Because every model uses the same record names and fields, generated APIs and other tools can handle them consistently.
These fields can also be used as attributes of an FSM state. That state must not be part of a cycle because states on a cycle may occur more than once.
Each identity record must be carried by a once-only event. A thread that does
not also represent a process must be scoped under its containing process. The
process reference on Thread.started establishes that relationship here.
Instrumentation API
The event producer reports IDs from the native platform APIs. The generated API then associates those IDs with the process and thread entities. These snippets focus on the instrumentation API; the full sources linked below contain the platform-specific code that reads the native IDs.
use instrumentation::{Context, Noop, OperatingSystem, Process, Thread};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<OperatingSystem>::try_new(Noop)?;
let mut process = context.observer::<Process>().handle();
// This is the native OS process ID, not the Quent entity ID.
process.started(instrumentation::quent::os::Process {
native_id: std::process::id(),
})?;
let process = process.as_entity_ref();
let mut thread = context.observer::<Thread>().handle();
std::thread::spawn(move || {
// This is the worker's native OS thread ID, not the Quent entity ID.
let native_id = current_native_thread_id()?;
thread
.started(instrumentation::quent::os::Thread { native_id }, process)
.map_err(std::io::Error::other)
})
.join()
.unwrap()?;
Ok(())
}
#include "quent-tutorial-operating-system-cpp-bridge/gen/quent.hpp"
#include <thread>
#include <utility>
int main() {
auto context = quent::Context::none();
auto process = context.process_observer()->handle();
// This is the native OS process ID, not the Quent entity ID.
process.started(quent::process::Started{
.process = quent::records::QuentOsProcess{
.native_id = native_process_id(),
},
});
// This is the Quent entity ID used to refer to the process entity.
auto process_id = process.id();
auto thread = context.thread_observer()->handle();
std::thread worker([thread = std::move(thread), process_id]() mutable {
// This is the worker's native OS thread ID, not the Quent entity ID.
thread.started(quent::thread::Started{
.thread = quent::records::QuentOsThread{
.native_id = native_thread_id(),
},
.process = process_id,
});
});
worker.join();
return 0;
}
import os
import threading
import quent_tutorial_operating_system as quent
def main() -> None:
with quent.Context() as context:
process = context.process_observer().handle()
# This is the native OS process ID, not the Quent entity ID.
process.started(process={"native_id": os.getpid()})
thread = context.thread_observer().handle()
def start_thread() -> None:
# This is the worker's native OS thread ID, not the Quent entity ID.
thread.started(
thread={"native_id": threading.get_native_id()},
process=process,
)
worker = threading.Thread(target=start_thread)
worker.start()
worker.join()
Platform considerations
Native IDs identify operating-system objects only within the scope and lifetime defined by the platform. They are not universally unique identifiers.
On Linux, the process ID and the main thread’s thread ID have the same numeric value. macOS and Windows obtain process and thread IDs through separate native APIs. Consumers must distinguish the two identities by their record types, not by comparing their numeric values.
An entity can carry both records when it represents both a process and its main thread. This example uses separate entities so the process-to-thread scope is explicit.
OS identity connects an entity to an external process or thread identity; it does not add IDs to every event.
Check yourself
Full code
Combining modules
Module constraints can be used together in one model. The job workload combines a finite-state lifecycle with bounded resource usage.
This example combines an FSM, event attributes, and measured resource usage. A worker publishes its thread limit. A job records how many threads it requests and how many it occupies while running.
YAML model
quent: alpha
model: job_workload
entities:
Worker:
# Generates WorkerUsage and WorkerBounds.
resource:
threads:
kind: occupancy
known-bounds: true
events:
ready:
attributes:
name: string
limits: { sets-resource-bounds: true }
fsms:
Job:
states:
queued:
initial: true
attributes:
name: string
requested_threads: u64
to: [running]
running:
attributes:
worker: { uses: Worker }
to: [completed]
completed: {}
Instrumentation API
The generated API distinguishes the worker’s WorkerBounds from the job’s
WorkerUsage. No event names or payload keys are assembled at runtime.
use instrumentation::{Context, Job, JobWorkload, Noop, Worker, WorkerBounds, WorkerUsage};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let context = Context::<JobWorkload>::try_new(Noop)?;
let mut worker = context.observer::<Worker>().handle();
worker.ready("worker-1".to_owned(), WorkerBounds { threads: 16 })?;
let _job = context
.observer::<Job>()
.handle()
.queued("compile".to_owned(), 4)
.running(worker.as_entity_ref_with(WorkerUsage { threads: 4 }))
.completed();
Ok(())
}
#include "quent-tutorial-job-workload-cpp-bridge/gen/quent.hpp"
int main() {
auto context = quent::Context::none();
auto worker = context.worker_observer()->handle();
worker.ready(quent::worker::Ready{
.name = "worker-1",
.limits = quent::records::WorkerBounds{.threads = 16},
});
auto queued = context.job_observer()->handle().queued(quent::job::Queued{
.name = "compile",
.requested_threads = 4,
});
auto running = std::move(queued).running(quent::job::Running{
.worker = quent::refs::WorkerUsageRef{
.target = worker.id(),
.data = quent::records::WorkerUsage{.threads = 4},
},
});
auto completed = std::move(running).completed();
return 0;
}
import quent_tutorial_job_workload as quent
def main() -> None:
with quent.Context() as context:
worker = context.worker_observer().handle()
worker.ready(name="worker-1", limits={"threads": 16})
job = context.job_observer().handle()
queued = job.queued(name="compile", requested_threads=4)
running = queued.running(
worker={
"target": worker,
"data": {"threads": 4},
}
)
completed = running.completed()
if __name__ == "__main__":
main()
The job records its requested thread count and its usage of a bounded worker resource.
Check yourself
Full code
Performance
TL;DR:
What is the Quent configuration with the lowest possible latency?
Enable the
channel-per-threadandclock-quantafeatures onquent-instrumentationat your own risk (explained below).What else can I do to reduce overhead?
Consider how long your program takes to calculate attribute values. This might add overhead to the program that wasn’t there before you instrumented it, since you might not have been doing those calculations before. Also, don’t spam too many events from your critical path.
Quent is very fast by default, but it can be made even faster by leveraging certain approaches enabled by Cargo features. The trade-offs are explained here.
The default features are chosen such that the highest level of guarantees about properly measuring time and ensuring all events are exported are provided.
Only if you need more performance, it might be interesting to read on. If your program behaves well enough, these Cargo features can help easily gain some performance without changing anything else.
What happens when I emit an event through the instrumentation API?
By default, Quent uses a concurrent multi-producer, single-consumer channel from Tokio to move events created by instrumentation calls into a task running on a background thread that deals with exporting events.
What is the overhead of Quent?
The default MPSC channel used keeps the latency of instrumentation calls low,
since it moves most of the work involved in exporting to a background thread. In
isolation, instrumentation calls do not slow down the instrumented program much.
Depending on the system, the event pattern and especially on how many threads
are emitting events simultaneously, the latency is typically in the order of
tens to hundreds of nanoseconds per call. You can measure this with
quent-bench for your system.
However, Quent is instrumentation-based, which means you decide where you call instrumentation events in your code, how many attributes you put in your events, and how much work is involved in obtaining those attributes.
Therefore, there is no way to answer the question “What is the overhead of Quent?” without asking yourself these questions first:
- How many events are you going to produce in your critical path?
The minimum amount of overhead added is this number multiplied by the latency of bare instrumentation calls. But if many threads emit events at the same time, there can be some contention on writing to the channel and the latency can significantly increase.
- How long does it take you to obtain the attribute values of those events in your critical path?
Add this latency too.
- How many events are you going to create per second (throughput)?
This is tricky to do back-of-the-envelope math for to arrive at some estimation. The overhead depends on many factors related to how exporters work. Nevertheless, it is important to understand that if you produce an excessive amount of events, then the background threads dealing with exporting will start eating up a lot of your system’s resources. So even if the latency of instrumentation calls remains low, it might slow down the entire program as the background threads will eat up a lot of resources such as memory, CPU cycles, and I/O.
Once you have a clear answer to these questions, and you have carefully considered what you are doing to instrument your program, you may still not be satisfied with the overhead that Quent adds. In this case, read on.
Can I reduce the latency of instrumentation calls?
If many threads create events at the same time, they can slow each other down
when they write to the default channel. You can enable the channel-per-thread
feature to give each thread its own queue which consists of multiple segments of
ring buffers. This can significantly reduce the time spent in an instrumentation
call, especially when many threads are active, since they don’t have to contend
to write to the same channel.
It also means that each thread needs memory for its queue, including segments kept for reuse after a burst, so if that is not an objection, you can consider using this channel.
Quent also reads a clock to timestamp every event. By default, it uses Rust’s
std::time::Instant. You can enable clock-quanta to use Quanta’s clock
instead, which may make timestamp reads faster on your system.
What happens when I stop instrumentation?
Quent continues forwarding an entity’s events until the last object that owns
its instrumentation is dropped. Dropping its model Context may not start
shutdown if cloned observers or entity handles are still alive.
The guarantees below say what has happened by the time shutdown returns. When an event reaches the exporter, Quent has handed it to the code that writes (or sends) it out. It does not mean that a file or remote collector has saved it. Also, instrumentation calls do not tell you whether their events were accepted, so a call can return normally even if its event is not exported.
Quent currently provides two levels of shutdown guarantee:
| Level | Guaranteed to reach the exporter before shutdown returns | Worst-case behavior |
|---|---|---|
| 1 | Events from calls completed before shutdown begins. | Calls overlapping or following shutdown may enqueue events that never reach the exporter. |
| 2 | Every event accepted by the channel. | Sends racing with channel closure may be rejected. Sends after closure are always rejected. |
The available channels provide these levels:
| Channel | Level | What this means for you |
|---|---|---|
| Default Tokio channel | 2 | An event is either rejected by the channel or forwarded to the exporter before shutdown returns. |
channel-per-thread | 1 | A thread may still accept events after shutdown returns. Those events may not reach the exporter. |
Keep the default Tokio channel if sends attempted after instrumentation shutdown must be rejected rather than queued without an active exporter.
If you stop and join all threads that can still create events before dropping the last owner of the instrumentation, which is good practice in general, Level 1 guarantees that every event from those completed calls reaches the exporter before shutdown returns. Both channels log exporter failures instead of reporting them to the caller.
With channel-per-thread, shutdown drains at most the buffered event count
observed during closure plus one segment’s capacity per producer queue.
It stops earlier if no events are available. Later sends do not extend this
budget, so producers cannot keep shutdown draining indefinitely by refilling
their queues.
To use channel-per-thread, add it to the feature list of your existing
quent-instrumentation dependency. Cargo features apply to the whole build. If
any dependency enables this feature, all uses of quent-instrumentation use the
new channel. You cannot choose a different channel for each Context.
In what order will my events be exported?
This totally depends on the exporter. The simple “filesystem” exporters
ndjson, postcard, and messagepack simply export in the order at which
events arrive on the receiving side of the event channel.
With channel-per-thread, events from one thread stay in order except during
thread-local destruction, when events may reach the exporter before that
thread’s earlier events. Events from different threads may reach the exporter
in a different order from when they were created.
Since every event has a timestamp (and for finite-state-machines also a sequence
number), this usually doesn’t matter, as in analysis you’ll order them by
timestamp anyway. But if two threads emit events through separate handles for
the same entity, coordinate those threads if event order matters,
because even the most “correct” timestamping mechanism that Quent could possibly
use today has caveats (also see the quent-time crate, this is a whole
rabbithole on its own). You can only make this potentially worse through
different / faster clock configurations. If you want to be sure, do not use
clock-quanta.
The background exporter checks these queues every 1 ms when it has no events to process. This saves the work of waking it for every event, but an event may wait for the next check before it is exported.
Analysis
No analysis API lessons are included in this tutorial yet. This is Work-In-Progress.