DEV Community

Cover image for How I Instrumented a Bevy Game with Telemetry (Spans, Metrics, Logs)
Marc-Antoine Desroches
Marc-Antoine Desroches

Posted on

How I Instrumented a Bevy Game with Telemetry (Spans, Metrics, Logs)

I wanted to document how to instrument a Bevy game with telemetry, so I ended up writing one.

The plan was simple — write a tutorial showing how to integrate Micromegas (a telemetry framework) into a Bevy game. Spans, metrics, logs, the full picture. Problem: I needed a game to instrument.

So I built one over the weekend. A Pac-Man clone loosely based on Voltaire's Candide — weapons escalate from brass knuckles to chainsaw, luxury items make you look ridiculous, and the narrator loses his mind as the violence escalates. The final level is a quiet garden with no enemies.

But the point is the instrumentation.

Why Observability in a Game?

Games generate thousands of events every 1/60th of a second. Frame times, physics ticks, AI decisions, input handling — when something goes wrong, println! debugging won't cut it. You need structured telemetry you can query after the fact.

Micromegas collects logs, metrics, and traces in a unified format with 20ns overhead per event. It can handle 100k events/second per process and stores everything in cheap object storage (S3/Parquet). You query it with SQL thanks to DataFusion.

The Integration

Here's how to wire Micromegas into a Bevy game, using this project as a reference.

1. Dependencies

Use the micromegas umbrella crate and enable Bevy's trace feature so it emits tracing spans for schedules and systems.

[dependencies]
bevy = { version = "0.18", features = ["trace"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["registry"] }
micromegas = "0.21"
Enter fullscreen mode Exit fullscreen mode

2. Bootstrap Order

Micromegas must be initialized before Bevy. The setup has three phases that must happen in order:

use bevy::log::LogPlugin;
use bevy::prelude::*;
use bevy::tasks::{ComputeTaskPool, TaskPoolBuilder};
use micromegas::telemetry_sink::TelemetryGuardBuilder;
use micromegas::telemetry_sink::tracing_interop::TracingCaptureLayer;
use micromegas::tracing::dispatch::init_thread_stream;
use micromegas::tracing::levels::LevelFilter;
use tracing_subscriber::Registry;
use tracing_subscriber::layer::SubscriberExt;

fn main() {
    // Phase 1: Initialize the telemetry sink.
    // The guard must live for the entire program.
    let _telemetry_guard = TelemetryGuardBuilder::default()
        .with_install_tracing_capture(false)
        .build()
        .expect("failed to initialize telemetry");

    // Phase 2: Set a global tracing subscriber that captures
    // Bevy's schedule spans and log events into Micromegas.
    let log_layer = TracingCaptureLayer {
        max_level: LevelFilter::Info,
    };
    let subscriber = Registry::default()
        .with(MicromegasBridgeLayer)
        .with(log_layer);
    tracing::subscriber::set_global_default(subscriber)
        .expect("failed to set tracing subscriber");

    // Phase 3: Pre-init the ComputeTaskPool with Micromegas
    // thread callbacks. This MUST happen before App::new().
    ComputeTaskPool::get_or_init(|| {
        TaskPoolBuilder::new()
            .on_thread_spawn(|| init_thread_stream())
            .on_thread_destroy(|| {
                micromegas::tracing::dispatch::flush_thread_buffer();
                micromegas::tracing::dispatch::unregister_thread_stream();
            })
            .build()
    });

    // Phase 4: Run Bevy — disable LogPlugin since we handle it.
    App::new()
        .add_plugins(DefaultPlugins.build().disable::<LogPlugin>())
        .add_plugins(MyGamePlugin)
        .run();
}
Enter fullscreen mode Exit fullscreen mode

Note: Spans require MICROMEGAS_ENABLE_CPU_TRACING=true. Without it, spans are silently dropped. Logs and metrics always work.

3. Bridge Bevy Schedule Spans

With Bevy's trace feature enabled, every schedule run (Update, FixedUpdate, etc.) emits a tracing span. A small Layer forwards these into Micromegas:

use micromegas::tracing::dispatch::{on_begin_named_scope, on_end_named_scope};
use micromegas::tracing::intern_string::intern_string;

pub struct MicromegasBridgeLayer;

impl<S> Layer<S> for MicromegasBridgeLayer
where
    S: Subscriber + for<'a> LookupSpan<'a>,
{
    fn on_new_span(&self, attrs: &Attributes<'_>, id: &Id, ctx: Context<'_, S>) {
        if attrs.metadata().name() != "schedule" { return; }
        let label = /* extract "name" field from attrs */;
        let interned = intern_string(&label);
        if let Some(span) = ctx.span(id) {
            span.extensions_mut().insert(ScheduleSpanData { name: interned });
        }
    }

    fn on_enter(&self, id: &Id, ctx: Context<'_, S>) {
        if let Some(span) = ctx.span(id) {
            if let Some(data) = span.extensions().get::<ScheduleSpanData>() {
                on_begin_named_scope(&BRIDGE_LOCATION, data.name);
            }
        }
    }

    fn on_exit(&self, id: &Id, ctx: Context<'_, S>) {
        // symmetric — call on_end_named_scope
    }
}
Enter fullscreen mode Exit fullscreen mode

4. Instrument Systems with #[span_fn]

Annotate every Bevy system with #[span_fn] to get per-system spans in the trace timeline:

use micromegas::tracing::prelude::*;

#[span_fn]
fn move_player(
    mut query: Query<&mut Transform, With<Player>>,
    time: Res<Time>,
) {
    // ...
}
Enter fullscreen mode Exit fullscreen mode

5. Emit Metrics

Use fmetric! for floating-point values and imetric! for integer counters:

use micromegas::tracing::prelude::*;

#[span_fn]
fn frame_telemetry(time: Res<Time>) {
    let dt_ms = time.delta_secs_f64() * 1000.0;
    fmetric!("frame_time_ms", "ms", dt_ms);
}

imetric!("kills", "count", total_kills as u64);
Enter fullscreen mode Exit fullscreen mode

6. Logging

Micromegas provides its own info! macro. Since Bevy also exports info! via its prelude, disambiguate at call sites:

// In files that import Bevy's prelude
micromegas::tracing::prelude::info!("money_collected: score={}", score.0);

// In files that don't
use micromegas::tracing::prelude::info;
info!("maze loaded: {} ({}x{})", path, width, height);
Enter fullscreen mode Exit fullscreen mode

What You Get

Everything is wired in from the start, not bolted on after the fact. Every system function has a span. Frame times, kill counts, and gameplay events are all metrics. Log events flow through the same pipeline.

After a play session, you can query it all with SQL:

SELECT time, target, msg
FROM log_entries
WHERE msg LIKE '%money_collected%'
ORDER BY time
Enter fullscreen mode Exit fullscreen mode

Or look at frame time distribution, find the slowest systems, correlate AI decisions with frame spikes — all from the same data.

The AI Elephant in the Room

I'll be honest — there's no way I build a full game in a weekend without AI. Claude wrote most of the code while I focused on architecture and design decisions. The art pipeline (Quaternius 3D models → Blender → sprite sheets), procedural harpsichord music via FluidSynth, A* enemy AI — all AI-assisted.

The instrumentation design was the human part. Where to put spans, what metrics matter, how to bridge Bevy's tracing into Micromegas — that required understanding both systems deeply.

Try It

Top comments (0)