bevy telemetry integration

Guide for bevy telemetry integration

Bevy Telemetry Integration

This document explains how to integrate custom telemetry (Prometheus, Sentry, tracing) with Bevy's plugin system.

The Problem

Bevy's DefaultPlugins includes LogPlugin, which initializes tracing-subscriber automatically. If you want to use custom telemetry with additional layers (Sentry, Prometheus), you'll encounter a conflict:
failed to set global default subscriber: SetGlobalDefaultError("a global default trace dispatcher has already been set")

The Solution

Disable Bevy's LogPlugin and initialize telemetry yourself via a custom plugin.

Implementation

Step 1: Create TelemetryPlugin

File: src/systems/telemetry.rs
use bevy::prelude::*;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use metrics_exporter_prometheus::PrometheusBuilder;

pub struct TelemetryPlugin;

impl Plugin for TelemetryPlugin {
    fn build(&self, _app: &mut App) {
        init_telemetry();
    }
}

pub fn init_telemetry() {
    // 1. Load environment variables
    dotenv::dotenv().ok();

    // 2. Initialize Sentry (error tracking)
    let sentry_dsn = std::env::var("SENTRY_DSN").unwrap_or_default();
    let _guard = if !sentry_dsn.is_empty() {
        Some(sentry::init((sentry_dsn, sentry::ClientOptions {
            release: sentry::release_name!(),
            ..Default::default()
        })))
    } else {
        None
    };

    // 3. Initialize tracing-subscriber with custom layers
    let env_filter = tracing_subscriber::EnvFilter::try_from_default_env()
        .unwrap_or_else(|_| "info,legends_client=debug".into());

    let fmt_layer = tracing_subscriber::fmt::layer()
        .with_target(false)
        .with_thread_ids(true)
        .with_level(true);

    let sentry_layer = sentry_tracing::layer();

    tracing_subscriber::registry()
        .with(env_filter)
        .with(fmt_layer)
        .with(sentry_layer)
        .init(); // ← Single initialization point

    // 4. Initialize Prometheus metrics exporter
    let builder = PrometheusBuilder::new();
    builder
        .with_http_listener(([0, 0, 0, 0], 9000))
        .install()
        .expect("failed to install Prometheus recorder");
        
    info!("Telemetry initialized. Metrics at http://localhost:9000/metrics");
}

Step 2: Disable LogPlugin in Main App

File: src/lib.rs
use bevy::prelude::*;
use bevy_egui::EguiPlugin;

pub fn run() {
    let mut app = App::new();

    // Add DefaultPlugins but DISABLE LogPlugin
    app.add_plugins(
        DefaultPlugins
            .set(WindowPlugin {
                primary_window: Some(Window {
                    title: "Legends of Hastinapur".into(),
                    resolution: (1280., 720.).into(),
                    ..default()
                }),
                ..default()
            })
            .disable::<bevy::log::LogPlugin>() // ← Critical: Disable Bevy's logger
    )
    .add_plugins(EguiPlugin);

    // Add custom telemetry BEFORE game systems
    app.add_plugins(TelemetryPlugin)
       .add_plugins(GamePlugins)
       .run();
}

Step 3: Register TelemetryPlugin Module

File: src/systems/mod.rs
pub mod telemetry;

Plugin Ordering

Important: Initialize TelemetryPlugin before game systems that might log:
app
    .add_plugins(DefaultPlugins.disable::<bevy::log::LogPlugin>())
    .add_plugins(TelemetryPlugin)  // ← First: Initialize telemetry
    .add_plugins(CameraPlugin)     // ← Then: Game systems can log
    .add_plugins(WorldPlugin)
    .add_plugins(PlayerPlugin)
    // ...

Using Telemetry in Game Systems

Logging with Tracing

use bevy::prelude::*;

pub fn my_game_system(query: Query<&Transform>) {
    info!("System started");
    
    for (i, transform) in query.iter().enumerate() {
        debug!(index = i, position = ?transform.translation, "Processing entity");
    }
    
    info!(entity_count = query.iter().count(), "System completed");
}

Recording Metrics

use bevy::prelude::*;
use metrics::{counter, histogram, gauge};

pub fn tick_metrics_system(
    time: Res<Time>,
    entities: Query<Entity>,
) {
    // Record tick duration
    histogram!("tick_duration_seconds").record(time.delta_seconds_f64());
    
    // Record entity count
    gauge!("entities_count").set(entities.iter().count() as f64);
    
    // Increment event counter
    counter!("game_ticks_total").increment(1);
}

Capturing Errors to Sentry

use bevy::prelude::*;

pub fn error_prone_system() {
    match risky_operation() {
        Ok(result) => info!("Success: {:?}", result),
        Err(e) => {
            error!("Operation failed: {}", e); // ← Automatically sent to Sentry
            sentry::capture_error(&e); // ← Explicit capture with context
        }
    }
}

Headless Mode Support

For headless servers (no rendering), use MinimalPlugins instead:
pub fn run() {
    let headless = std::env::var("HEADLESS_MODE")
        .unwrap_or_default()
        .to_lowercase() == "true";

    let mut app = App::new();

    if headless {
        // Headless: MinimalPlugins doesn't include LogPlugin
        app.add_plugins(MinimalPlugins)
           .add_plugins(bevy::asset::AssetPlugin::default());
    } else {
        // Normal: Disable LogPlugin in DefaultPlugins
        app.add_plugins(DefaultPlugins.disable::<bevy::log::LogPlugin>())
           .add_plugins(EguiPlugin);
    }

    app.add_plugins(TelemetryPlugin)
       .add_plugins(GamePlugins)
       .run();
}

Environment Variables

Configure telemetry via environment variables:
# Logging level
export RUST_LOG=info,legends_client=debug,bevy_render=warn

# Sentry error tracking
export SENTRY_DSN=https://<key>@sentry.io/<project>

# Run the game
cargo run --bin legends_client

Dependencies

File: Cargo.toml
[dependencies]
# Bevy
bevy = { version = "0.14", features = ["default"] }

# Observability
sentry = { version = "0.36", features = ["tracing"] }
sentry-tracing = "0.36"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
tracing-appender = "0.2"
tracing-log = "0.2"
metrics = "0.24"
metrics-exporter-prometheus = "0.16"
dotenv = "0.15"

Troubleshooting

Still getting double-initialization error?

Check:
  1. LogPlugin is disabled: .disable::<bevy::log::LogPlugin>()
  2. No other code calls tracing_subscriber::init() or .try_init()
  3. TelemetryPlugin is added before other plugins that might initialize tracing

Metrics endpoint not working?

Verify:
curl http://localhost:9000/metrics
Check that PrometheusBuilder installed successfully (no panic in logs).

Sentry not receiving errors?

Verify:
  1. SENTRY_DSN environment variable is set
  2. Sentry layer is added: .with(sentry_layer)
  3. Errors are logged at ERROR level or explicitly captured with sentry::capture_error()