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"
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();
}
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
}
}
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>,
) {
// ...
}
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);
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);
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
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
- Game repo (Optimism) — full source with instrumentation
- Micromegas — the telemetry framework
- Documentation
Top comments (0)