Skip to content

Performance & Metrics

Hex1b includes built-in OpenTelemetry-compatible metrics instrumentation via System.Diagnostics.Metrics. These metrics let you understand render performance, output throughput, and input rates — both at the frame level and per-widget level.

Setting Up Metrics

With Aspire

If your application uses Aspire, metrics are automatically exported when you register the Hex1b meter in your service defaults:

csharp
// In your ServiceDefaults project
builder.Services.ConfigureOpenTelemetryMeterProvider(meter =>
    meter.AddMeter("Hex1b"));

All hex1b.* metrics will appear in the Aspire dashboard under the Metrics tab.

With a MeterListener

For standalone applications or unit tests, attach a MeterListener to observe metrics in-process:

csharp
using System.Diagnostics.Metrics;

var listener = new MeterListener();
listener.InstrumentPublished = (instrument, listener) =>
{
    if (instrument.Meter.Name == "Hex1b")
        listener.EnableMeasurementEvents(instrument);
};
listener.SetMeasurementEventCallback<double>((inst, value, tags, state) =>
{
    Console.WriteLine($"{inst.Name}: {value:F2}ms");
});
listener.Start();

Frame-Level Metrics

These instruments are always active and have near-zero overhead when no listener is attached.

InstrumentTypeUnitDescription
hex1b.frame.durationHistogrammsTotal frame duration (build + reconcile + render)
hex1b.frame.build.durationHistogrammsWidget tree build phase
hex1b.frame.reconcile.durationHistogrammsWidget→Node reconciliation
hex1b.frame.render.durationHistogrammsSurface render + diff + serialize
hex1b.frame.countCounterframeTotal frames rendered

Output Metrics

InstrumentTypeUnitDescription
hex1b.output.cells_changedHistogramcellCells changed per surface diff
hex1b.output.tokensHistogramtokenANSI tokens produced per frame
hex1b.output.bytesHistogrambyteBytes written per frame

Input Metrics

InstrumentTypeUnitDescription
hex1b.input.countCountereventEvents processed (tag: type=key|mouse|resize)
hex1b.input.durationHistogrammsTime to process a single input event

Terminal I/O Metrics

InstrumentTypeUnitDescription
hex1b.terminal.output.bytesHistogrambyteBytes written to presentation per write
hex1b.terminal.output.tokensHistogramtokenTokens parsed from workload output
hex1b.terminal.input.bytesHistogrambyteRaw bytes read from presentation
hex1b.terminal.input.tokensHistogramtokenTokens parsed from raw input
hex1b.terminal.input.eventsCountereventEvents dispatched (tag: type=key|mouse|resize)

Surface Pipeline Metrics

These break down hex1b.frame.render.duration into sub-phases. Always active.

InstrumentTypeUnitDescription
hex1b.surface.diff.durationHistogrammsTime to diff previous vs current surface
hex1b.surface.tokens.durationHistogrammsTime to convert diff to ANSI tokens
hex1b.surface.serialize.durationHistogrammsTime to serialize tokens to ANSI string

Per-Node Metrics

Per-node metrics let you drill into which widgets are expensive. They record timing histograms for each node in the tree, tagged by a hierarchical metric path.

Local Development Only

Per-node metrics generate high tag cardinality (one time series per node in the tree). Enable them only during local development with Aspire — never in production.

Enabling Per-Node Metrics

csharp
var terminal = Hex1bTerminal.CreateBuilder()
    .WithHex1bApp((app, options) => ctx =>
        ctx.VStack(v => [
            v.Text("Hello")
        ]))
    .WithMetrics(options => options.EnablePerNodeMetrics = true)
    .Build();

Naming Widgets

Use .MetricName() to give widgets meaningful names in the metrics output:

csharp
ctx.VStack(main => [
    main.Border(
        main.Table(rows).MetricName("orders")
    ).MetricName("sidebar"),
    main.Editor(state).MetricName("editor")
]).MetricName("root")

This produces metric paths like root.sidebar.orders and root.editor.

Widgets without a MetricName get auto-generated names based on their type and child index — for example, VStack[0], TextBlock[2].

Per-Node Instruments

InstrumentTypeUnitTagDescription
hex1b.node.measure.durationHistogrammsnodeTime in Measure for one node
hex1b.node.arrange.durationHistogrammsnodeTime in Arrange for one node
hex1b.node.render.durationHistogrammsnodeTime in Render for one node
hex1b.node.reconcile.durationHistogrammsnodeTime in ReconcileAsync for one node

Surface Composition Instruments

For SurfaceWidget nodes with layered composition, these additional per-node instruments provide layer-level detail:

InstrumentTypeUnitTagsDescription
hex1b.surface.flatten.durationHistogrammsnodeTime to flatten all layers into a single surface
hex1b.surface.composite.durationHistogrammsnodeTime to blit flattened surface onto parent
hex1b.surface.layer.countHistogramintnodeNumber of layers per render
hex1b.surface.layer.durationHistogrammsnode, layer_index, layer_typeTime per layer (type: source, draw, computed, widget)

The node tag value is the hierarchical metric path (e.g., root.sidebar.orders.VStack[0]).

Performance Analysis Workflow

Step 1: Identify Slow Frames

Look at hex1b.frame.duration in the Aspire dashboard. Spikes above ~16ms (60 FPS target) indicate slow frames.

Check the phase breakdown — hex1b.frame.build.duration, hex1b.frame.reconcile.duration, and hex1b.frame.render.duration — to narrow down which phase is slow.

Step 2: Enable Per-Node Metrics

Add .WithMetrics(o => o.EnablePerNodeMetrics = true) to the terminal builder. Tag suspect widgets with .MetricName("name") so they're easy to find in the dashboard.

Step 3: Filter in Aspire

In the Aspire Metrics tab, filter hex1b.node.render.duration by the node tag. Use wildcards like *sidebar* to find all nodes under a named subtree.

Step 4: Drill Down

Add more .MetricName() tags to children of the slow widget, re-run, and filter by the deeper path to isolate the exact expensive node.

Step 5: Profile Without Names

Even without explicit MetricName calls, all nodes get auto-generated paths. Filter by type — for example, node=*Table* — to find all table render times across the tree.

Metrics in Unit Tests

Use MeterListener to verify metric behavior in tests. Each new Hex1bMetrics() creates an isolated Meter instance — use ReferenceEquals(instrument.Meter, metrics.Meter) to filter by instance and avoid crosstalk in parallel tests.

csharp
[Fact]
public void Frame_RecordsDuration()
{
    using var metrics = new Hex1bMetrics();
    var recorded = new List<double>();

    using var listener = new MeterListener();
    listener.InstrumentPublished = (instrument, listener) =>
    {
        if (ReferenceEquals(instrument.Meter, metrics.Meter))
            listener.EnableMeasurementEvents(instrument);
    };
    listener.SetMeasurementEventCallback<double>((inst, value, tags, state) =>
    {
        if (inst.Name == "hex1b.frame.duration") recorded.Add(value);
    });
    listener.Start();

    // ... drive app with this metrics instance ...
    Assert.NotEmpty(recorded);
    Assert.All(recorded, d => Assert.True(d > 0));
}

Next Steps

Released under the MIT License.