Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. Model the application events: Write an Application Event Schema that expresses what events your application emits and what the related semantics are.
  2. Generate typed libraries. From the schema, a code generation step produces a statically-typed application-specific instrumentation library and an (WIP) analysis library.
  3. 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.
  4. 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.
  5. 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()
Key point

The two events have no declared ordering constraint, so either event may be emitted first.

Check yourself

How often can started be emitted for one Task handle?

What does task represent in the application?

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.

Key point

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 typeValue
booltrue or false
u8, u16, u32, u64Unsigned integers of the indicated width
i8, i16, i32, i64Signed integers of the indicated width
f32, f64Floating-point numbers of the indicated width
stringText
uuidA universally unique identifier

Types can also be composed or refer to generated types:

YAML typeValue
{ option: T }A value of type T that may be absent
{ list: T }An ordered collection of values of type T
A record nameAn instance of that record
dynamicString-keyed values whose names and types are chosen at runtime
refA 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()
Key point

Event attributes produce typed parameters in the generated instrumentation API.

Check yourself

Which declaration permits an attribute value to be absent?

Which declaration represents several ordered values of the same type?

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.

Key point

Use dynamic attributes for event details that cannot be defined in advance.

Check yourself

Where are keys such as queue and cached defined?

What is the main tradeoff of using dynamic?

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()
Key point

multi: true permits an event to be emitted more than once for an entity.

Check yourself

Why can progress be called repeatedly?

What happens when started is emitted twice on one handle?

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()
Key point

A named record provides one reusable structure for event attributes.

Check yourself

What type do the Task and Batch ended events expect for result?

Why declare a record instead of repeating its fields?

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.

Key point

A type-erased reference preserves an entity's identity without restricting its entity type.

Check yourself

What does a type-erased entity reference preserve?

Does a bare ref require its target to be a Worker?

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:

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()
Key point

An entity reference identifies a specific entity and preserves its type.

Check yourself

Which entity type may the worker attribute target?

Does ref: Worker place Task under Worker in a hierarchy?

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()
Key point

A scoped reference defines a parent relationship in a validated entity tree.

Check yourself

What does scope-ref add beyond a normal targeted ref?

Which value is passed as the task's parent?

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()
Key point

The generated handle exposes only the transitions allowed from its current state.

Check yourself

Which property does the parser validate for this FSM?

Which states can directly precede running?

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.

Key point

Convert to DynamicFsmHandle at the boundary where control flow prevents one typestate return type, not earlier.

Check yourself

When should a function return a DynamicFsmHandle?

What happens when a dynamic-state handle attempts an invalid transition?

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()
Key point

Every state on a direct or indirect cycle has a repeatable state-entry event.

Check yourself

Which transition is a direct self-loop?

Why does paused have multi cardinality?

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()
Key point

A unit resource represents one indivisible resource instance.

Check yourself

What does resource: true mean for Thread?

Which hierarchy does the model define?

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()
Key point

A capacity resource records the quantity of a resource used by an entity.

Check yourself

What does an occupancy capacity measure?

Where is the task's claimed byte quantity declared?

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()
Key point

A known bound records the available capacity separately from resource usage.

Check yourself

What additional generated record comes from known-bounds: true?

What does sets-resource-bounds: true identify?

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()
Key point

The entity identifies the sink. Each declared level identifies one log event.

Check yourself

How does a log level appear in the generated schema?

Where do you declare an arbitrary attribute used by every level?

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 the quent::os::Process record.
  • { os: thread } adds the quent::os::Thread record.

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.

Key point

OS identity connects an entity to an external process or thread identity; it does not add IDs to every event.

Check yourself

Why should a model use an OS process or thread identity?

How is a thread-only entity associated with its containing process?

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()
Key point

The job records its requested thread count and its usage of a bounded worker resource.

Check yourself

What does WorkerBounds { threads: 16 } represent?

Which call records where the job runs and how many threads it occupies?

Full code

Performance

TL;DR:

What is the Quent configuration with the lowest possible latency?

Enable the channel-per-thread and clock-quanta features on quent-instrumentation at 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:

  1. 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.

  1. How long does it take you to obtain the attribute values of those events in your critical path?

Add this latency too.

  1. 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:

LevelGuaranteed to reach the exporter before shutdown returnsWorst-case behavior
1Events from calls completed before shutdown begins.Calls overlapping or following shutdown may enqueue events that never reach the exporter.
2Every 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:

ChannelLevelWhat this means for you
Default Tokio channel2An event is either rejected by the channel or forwarded to the exporter before shutdown returns.
channel-per-thread1A 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.