Tracing and Profiling#

OMMX emits OpenTelemetry spans at selected entry points. Two thin wrappers in ommx.tracing turn that stream into something you can actually read:

  • %%ommx_trace — a Jupyter cell magic that renders the spans produced during a single cell as a nested text tree, plus a download link for the full trace in Chrome Trace Event Format.

  • capture_trace / traced() — a context manager and decorator for the same workflow from plain Python scripts, tests, and CI.

Both entry points share one in-process collector. You do not need to install an OTel exporter or configure anything at import time: the collector installs itself lazily on first use. Ship the trace to a full OTel backend only when you need to — see Exporting to an External OTel Backend below.

Quick Tour#

Cell magic (%%ommx_trace)#

Load the extension once per notebook (typically in the first cell):

%load_ext ommx.tracing

Then prefix any cell with %%ommx_trace:

%%ommx_trace
from ommx.v1 import Instance, DecisionVariable

x = DecisionVariable.binary(0, name="x")
y = DecisionVariable.binary(1, name="y")
instance = Instance.from_components(
    decision_variables=[x, y],
    objective=x + y,
    constraints={},
    sense=Instance.MAXIMIZE,
)
solution = instance.evaluate({0: 1.0, 1: 1.0})
└── ommx_trace_cell (14.77 ms) {scope=ommx.tracing}
    └── evaluate (1.33 ms) {scope=ommx}

Download Chrome Trace JSON (0.6 KB) — open in Perfetto, speedscope, or chrome://tracing.

The cell output shows two things:

  1. A nested text tree of every span produced in the cell, annotated with duration and the most useful span attributes.

  2. A download link for the full trace in Chrome Trace Event Format. Drop that JSON file into Perfetto, speedscope, or chrome://tracing to explore the trace as a flame graph.

Note

The rendered cell output (text tree + download link) is a minimal starting point and is expected to evolve — for example, an inline interactive flame graph is on the roadmap. Treat the exact layout and markup as unstable.

When the cell raises, the trace HTML is still rendered (with [ERROR] marking the failing span) and the exception is re-raised so notebook automation — nbconvert --execute, papermill, pytest-nbval — still sees the failure.

Context manager (capture_trace)#

The same machinery is available from plain Python:

from ommx.tracing import capture_trace, save_chrome_trace
from ommx.v1 import Instance, DecisionVariable

x = DecisionVariable.binary(0, name="x")
y = DecisionVariable.binary(1, name="y")

instance = Instance.from_components(
    decision_variables=[x, y],
    objective=x + y,
    constraints={},
    sense=Instance.MAXIMIZE,
)

with capture_trace() as trace:
    solution = instance.evaluate({0: 1.0, 1: 1.0})

trace
└── ommx_trace_block (240.0 µs) {scope=ommx.tracing}
    └── evaluate (10.0 µs) {scope=ommx}

Download Chrome Trace JSON (0.5 KB) — open in Perfetto, speedscope, or chrome://tracing.

trace is a TraceResult populated when the block exits:

  • request — the exported OTLP ExportTraceServiceRequest held by the result.

  • spans — flattened exported OTLP protobuf spans from request.

  • otlp_protobuf() — returns the same OTLP export request as protobuf bytes, which is the payload stored by Experiment traces.

In a notebook, evaluating trace displays the same text tree through its __repr__. Rendering and file export are also available as explicit functions:

If the block raises, trace.request is still populated (with the failing span flagged as [ERROR] by the renderer), so you can inspect or save it from an outer except or finally. The original exception propagates unchanged — OMMX never swallows.

import tempfile
from pathlib import Path

output_path = Path(tempfile.gettempdir()) / "ommx_trace.json"
save_chrome_trace(trace, output_path)

Decorator (@traced)#

traced() is sugar on top of capture_trace:

import tempfile
from pathlib import Path

from ommx.tracing import traced

evaluate_output = Path(tempfile.gettempdir()) / "evaluate_trace.json"

@traced(output=str(evaluate_output))
def evaluate_once(inst):
    return inst.evaluate({0: 1.0, 1: 1.0})

solution = evaluate_once(instance)

All three call shapes are supported:

@traced
def f(): ...

@traced()
def f(): ...

@traced(name="build_qubo", output="qubo.json")
def f(): ...

Key properties:

  • If name is omitted, the root span name defaults to fn.__qualname__, so traces from multiple decorated functions are easy to tell apart.

  • When output is set, the Chrome Trace JSON is written on normal return, and the decorator also attempts to write it on exception. On the exception path, save errors (e.g. I/O failures) are intentionally suppressed so they do not replace the original exception — so saving on exception is best-effort.

  • async def is supported. The decorator detects coroutine functions with inspect.iscoroutinefunction and awaits them inside the trace block; without that detection, the capture window would close before the coroutine ran and every span would be dropped.

Storing Run Traces in Experiments#

The APIs above capture a trace for immediate inspection or for writing a standalone Chrome Trace JSON file. When the trace should travel with an experiment record, enable trace storage on Experiment:

from ommx.experiment import Experiment
from ommx_highs_adapter import OMMXHighsAdapter

with Experiment.with_temp_local_registry(store_trace=True) as experiment:
    with experiment.run() as run:
        run.log_parameter("solver", "highs")
        solution = run.log_solve(OMMXHighsAdapter, instance, verbose=False)
        run.log_parameter("objective", solution.objective)

trace = experiment.runs[0].trace
assert trace is not None
trace
└── Run (15.56 ms) {scope=ommx.experiment} [ommx.run.id=0]
    └── solve (4.93 ms) {scope=ommx.adapter.highs} [adapter='ommx_highs_adapter.adapter.OMMXHighsAdapter']
        ├── convert (4.58 ms) {scope=ommx.adapter.highs}
        │   └── reduce_capabilities (6.0 µs) {scope=ommx}
        ├── call (93.0 µs) {scope=ommx.adapter.highs}
        └── decode (184.0 µs) {scope=ommx.adapter.highs}
            └── evaluate (15.0 µs) {scope=ommx}

Download Chrome Trace JSON (1.6 KB) — open in Perfetto, speedscope, or chrome://tracing.

store_trace=True opens an capture_trace block named Run around each Run context manager created from the Experiment. The span represents the Run lifetime created by with experiment.run() as run:, not the duration of the experiment.run() method call itself. When the Run exits normally, OMMX stores the completed TraceResult as OTLP protobuf bytes in the Experiment Artifact. Loading the Experiment later restores the same data through trace:

loaded = Experiment.from_artifact(experiment.artifact)
trace = loaded.runs[0].trace
assert trace is not None
trace
└── Run (15.56 ms) {scope=ommx.experiment} [ommx.run.id=0]
    └── solve (4.93 ms) {scope=ommx.adapter.highs} [adapter='ommx_highs_adapter.adapter.OMMXHighsAdapter']
        ├── convert (4.58 ms) {scope=ommx.adapter.highs}
        │   └── reduce_capabilities (6.0 µs) {scope=ommx}
        ├── call (93.0 µs) {scope=ommx.adapter.highs}
        └── decode (184.0 µs) {scope=ommx.adapter.highs}
            └── evaluate (15.0 µs) {scope=ommx}

Download Chrome Trace JSON (1.6 KB) — open in Perfetto, speedscope, or chrome://tracing.

Trace storage is deliberately Run-scoped:

  • It is disabled by default. Without store_trace=True, OMMX can still emit spans to an active OpenTelemetry provider, but no trace payload is stored in the Artifact.

  • The stored boundary is with experiment.run() as run:. It does not include the whole Experiment lifetime, manual commit() calls, or notebook idle time between cells.

  • store_trace=True requires using Run as a context manager, because the Run boundary is what closes and stores the trace. Creating a Run and later calling finish() manually is rejected in this mode.

  • Existing Run traces are carried when a committed Experiment is forked. Pass store_trace=True to fork() when newly added Runs in the child Experiment should store traces as well.

The text tree is a compact summary of the trace. Each line shows the span name, duration, instrumentation scope, and selected attributes. For example, Run is the lifetime of with experiment.run() as run:, solve is the adapter API call, and convert / call / decode are adapter stages. {scope=...} identifies the package or module that emitted the span, while attributes such as adapter, and ommx.run.id identify the Run or backend involved.

Exporting to an External OTel Backend#

Run trace storage is separate from exporting spans to an external OTel backend. If you need spans to flow to OTLP, Jaeger, Honeycomb, or another backend, configure an SDK TracerProvider before the first OMMX call and add your exporter to it:

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter

provider = TracerProvider()
provider.add_span_processor(BatchSpanProcessor(OTLPSpanExporter()))
trace.set_tracer_provider(provider)

from ommx.v1 import Instance

%%ommx_trace, capture_trace, and Experiment trace storage attach to the active SDK provider, so the same spans can be rendered locally and exported to your backend. Configure the provider at the top of the script or notebook kernel before importing and calling OMMX.