Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

connect flecs to tracing ecosystem #200

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[workspace]
members = ["flecs_ecs", "flecs_ecs_derive", "flecs_ecs_sys", "test_crash_handler"]
members = ["flecs_ecs", "flecs_ecs_derive", "flecs_ecs_sys", "flecs_ecs_tracing", "test_crash_handler"]
resolver = "2"

exclude = [
Expand All @@ -26,6 +26,7 @@ rust.unused_lifetimes = "warn"
flecs_ecs = { version = "0.1.2", path = "flecs_ecs" }
flecs_ecs_derive = { version = "0.1.0", path = "flecs_ecs_derive" }
flecs_ecs_sys = { version = "0.1.2", path = "flecs_ecs_sys" }
hashbrown = "0.15.0"

[profile.release]
lto = true
Expand Down
2 changes: 1 addition & 1 deletion flecs_ecs/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ flecs_ecs_derive = { workspace = true }
flecs_ecs_sys = { workspace = true }
bitflags = "2.6.0"
compact_str = "0.8.0"
hashbrown = "0.15.0"
hashbrown = { workspace = true }

# used for backtraces upon hardware exceptions during test
# only used when "test-with-crash-handler" feature enabled
Expand Down
27 changes: 27 additions & 0 deletions flecs_ecs_tracing/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
[package]
name = "flecs_ecs_tracing"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true

[lints]
workspace = true

[dependencies]
flecs_ecs = { workspace = true }
hashbrown = { workspace = true }
tracing-core = "0.1.32"
tracing = { version = "0.1.40", default-features = false }

[dev-dependencies]
# for doctest/example
tracing-subscriber = { version = "0.3.18", features = [
"env-filter",
], default-features = false }
# for perf_trace doctest/example
tracing-tracy = { version = "0.11.3", default-features = false }

[features]
perf_trace = ["flecs_ecs/flecs_perf_trace"]
100 changes: 100 additions & 0 deletions flecs_ecs_tracing/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
//! Integrates Flecs ECS logging and performance tracing into the `tracing` ecosystem.

use flecs_ecs::prelude::*;

mod log;
mod metadata;
#[cfg(feature = "perf_trace")]
mod perf_trace;
mod util;

/// Send Flecs internal logging to `tracing` subscribers instead.
///
/// Note that the application will need to set up those subscribers; by default, logs will go nowhere.
///
/// This must be called before the first [`World`] is created anywhere in the process;
/// see [`ecs_os_api::add_init_hook`] for details on those limitations.
///
/// # Example
/// ```standalone
/// use flecs_ecs::prelude::*;
/// use flecs_ecs_tracing::log_to_tracing;
/// use tracing_subscriber::prelude::*;
///
/// tracing_subscriber::registry()
/// // Send logs to stdout
/// .with(
/// tracing_subscriber::fmt::layer()
/// // By default, hide anything below WARN log level from stdout
/// .with_filter(
/// tracing_subscriber::EnvFilter::builder()
/// .with_default_directive(tracing::level_filters::LevelFilter::WARN.into())
/// .from_env_lossy(),
/// ),
/// )
/// .init();
///
/// log_to_tracing();
/// let world = World::new();
/// ```
pub fn log_to_tracing() {
// Ensure that the registry is initialized now
metadata::init();

ecs_os_api::add_init_hook(Box::new(|api| {
api.log_ = Some(log::log_to_tracing);
}));
}

#[cfg(feature = "perf_trace")]
/// Send Flecs performance traces to `tracing` subscribers.
///
/// This must be called before the first [`World`] is created anywhere in the process;
/// see [`ecs_os_api::add_init_hook`] for details on those limitations.
///
/// # Example
/// ```standalone
/// use flecs_ecs::prelude::*;
/// use flecs_ecs_tracing::{log_to_tracing, perf_trace_to_tracing};
/// use tracing_subscriber::prelude::*;
///
/// tracing_subscriber::registry()
/// // Send logs to stdout
/// .with(
/// tracing_subscriber::fmt::layer()
/// // By default, hide anything below WARN log level from stdout
/// .with_filter(
/// tracing_subscriber::EnvFilter::builder()
/// .with_default_directive(tracing::level_filters::LevelFilter::WARN.into())
/// .from_env_lossy(),
/// ),
/// )
/// // Send logs and performance tracing data to the Tracy profiler
/// .with(
/// tracing_tracy::TracyLayer::default()
/// .with_filter(tracing::level_filters::LevelFilter::INFO),
/// )
/// .init();
///
/// log_to_tracing(); // optional, but recommended
/// perf_trace_to_tracing(tracing::Level::INFO);
///
/// let world = World::new();
/// ```
pub fn perf_trace_to_tracing(span_level: tracing_core::Level) {
// Ensure that the registry is initialized now
perf_trace::init();

if span_level < tracing::level_filters::STATIC_MAX_LEVEL {
// Early out due to static level constraint
return;
}

// Set the log level of performance tracing spans
perf_trace::SPAN_LEVEL.get_or_init(|| span_level);

ecs_os_api::add_init_hook(Box::new(|api| {
api.perf_trace_push_ = Some(perf_trace::perf_trace_push);
api.perf_trace_pop_ = Some(perf_trace::perf_trace_pop);
}));
}
88 changes: 88 additions & 0 deletions flecs_ecs_tracing/src/log.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
use std::borrow::Cow;

use crate::{metadata, util::*};
use tracing_core::{Dispatch, Metadata};

fn ensure_event_meta(
dispatch: &Dispatch,
request: metadata::MetadataRequest<'_>,
) -> &'static Metadata<'static> {
// Double-checked locking
{
let registry = metadata::REGISTRY.read().unwrap();
if let Some(existing) = registry.metadata.get(&request) {
return existing;
}
}

let mut registry = metadata::REGISTRY.write().unwrap();

if let Some(existing) = registry.metadata.get(&request) {
return existing;
}

let (metadata, request) = request.register(dispatch);
registry.metadata.insert(request, metadata);

metadata
}

fn get_event_meta(
dispatch: &Dispatch,
filename: Option<Cow<'_, str>>,
line: i32,
level: tracing_core::metadata::Level,
) -> &'static Metadata<'static> {
let request = metadata::MetadataRequest {
kind: metadata::MetadataKind::Event,
name: Cow::Borrowed("log"),
filename,
line: line.try_into().ok(),
level,
};

if let Some(existing) = metadata::REGISTRY.read().unwrap().metadata.get(&request) {
// existing metadata - fast path
return existing;
}

// new metadata - slow path
ensure_event_meta(dispatch, request)
}

pub(crate) unsafe extern "C-unwind" fn log_to_tracing(
level: i32,
c_file: *const i8,
line: i32,
c_msg: *const i8,
) {
let level = match level {
-4 | -3 => tracing_core::metadata::Level::ERROR,
-2 => tracing_core::metadata::Level::WARN,
0 => tracing_core::metadata::Level::INFO,
1..=3 => tracing_core::metadata::Level::DEBUG,
_ => tracing_core::metadata::Level::TRACE,
};

if level < tracing::level_filters::STATIC_MAX_LEVEL {
// Early out due to static level constraint
return;
}

tracing_core::dispatcher::get_default(|dispatch| {
let file = flecs_str(c_file);
let msg = flecs_str(c_msg).unwrap_or(Cow::Borrowed(""));

let meta = get_event_meta(dispatch, file, line, level);
if dispatch.enabled(meta) {
let message_field = meta.fields().iter().next().expect("FieldSet corrupted");
tracing_core::Event::dispatch(
meta,
&meta.fields().value_set(&[(
&message_field,
Some(&(format_args!("{msg}")) as &dyn tracing_core::field::Value),
)]),
);
}
});
}
156 changes: 156 additions & 0 deletions flecs_ecs_tracing/src/metadata.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
//! Construct [`tracing_core::Metadata`] objects in the way required by that ecosystem.
//!
//! Dynamic call site stuff based on <https://github.com/slowli/tracing-toolbox/>

use crate::util::*;
use hashbrown::HashMap;
use std::{
borrow::Cow,
sync::{LazyLock, OnceLock, RwLock},
};
use tracing_core::{field::FieldSet, metadata::Level, Dispatch, Kind, Metadata};

/// Registry of dynamic tracing metadata generated from Flecs
pub(crate) static REGISTRY: LazyLock<RwLock<Registry>> = LazyLock::new(Default::default);

/// Registry of dynamic tracing metadata generated from Flecs
pub(crate) struct Registry {
pub(crate) metadata: HashMap<MetadataRequest<'static>, &'static Metadata<'static>>,
}

impl Default for Registry {
fn default() -> Self {
Self {
// Capacity was chosen arbitrarily
metadata: HashMap::with_capacity(256),
}
}
}

pub(crate) fn init() {
LazyLock::force(&REGISTRY);
}

#[derive(Default)]
pub(crate) struct DynamicCallsite {
/// Due to the opaque [`tracing_core::callsite::Identifier`],
/// which borrows [`tracing_core::Callsite`] for `'static`, have to use interior mutability.
///
/// See [`MetadataRequest::register`] for details
pub(crate) metadata: OnceLock<Metadata<'static>>,
}

impl tracing_core::Callsite for DynamicCallsite {
fn set_interest(&self, _interest: tracing_core::Interest) {
// No-op
}

fn metadata(&self) -> &Metadata<'_> {
self.metadata.get().unwrap()
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum MetadataKind {
// hash/eq skip filename/line since they may differ between start & stop
// only used when perf_trace is enabled
#[cfg_attr(not(feature = "perf_trace"), allow(unused))]
Span,
Event,
}

#[derive(Clone, Debug)]
pub(crate) struct MetadataRequest<'a> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going by code below, filename and line are only valid when kind == MetadataKind::Event, so it seems like MetadataKind::Event should be holding that data.

In that case, then the PartialEq and Hash would be derived and not have a manual implementation.

If there is a reason for not doing the expected thing (what I just described), then that should be documented with a comment somewhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

filename and line are valid for both Event and Span, but for Span they are not part of the hash key, since the push and pop events will come from different line numbers (& possibly different file names).

It's a moot point at the moment, though, since metadata for spans is currently not cached in the metadata registry, only in the span registry which uses the span name as the key. This may change in the future; an alternative design would have one metadata per ecs_os_perf_trace_push callsite and put the span names into values (like log messages).

pub(crate) kind: MetadataKind,
pub(crate) level: Level,
pub(crate) name: Cow<'a, str>,
pub(crate) filename: Option<Cow<'a, str>>,
pub(crate) line: Option<u32>,
}

impl<'a> PartialEq for MetadataRequest<'a> {
fn eq(&self, other: &Self) -> bool {
self.kind == other.kind
&& self.name == other.name
&& self.level == other.level
&& match self.kind {
MetadataKind::Span => true,
MetadataKind::Event => self.filename == other.filename && self.line == other.line,
}
}
}

impl<'a> Eq for MetadataRequest<'a> {}

impl<'a> std::hash::Hash for MetadataRequest<'a> {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
self.kind.hash(state);
self.name.hash(state);
self.level.hash(state);
match self.kind {
MetadataKind::Span => {}
MetadataKind::Event => {
self.filename.hash(state);
self.line.hash(state);
}
}
}
}

impl<'a> MetadataRequest<'a> {
/// Construct a new [`Metadata`].
///
/// Does not interact with [`crate::REGISTRY`] by design to avoid having to deal with locking;
/// the caller should handle memoization if necessary.
pub(crate) fn register(
self,
dispatch: &Dispatch,
) -> (&'static Metadata<'static>, MetadataRequest<'static>) {
// `Dispatch::register_callsite` demands `&'static Metadata`, this is the simplest way to get that
let callsite = Box::leak(Box::new(DynamicCallsite::default()));

// This macro is the only 'public' way of constructing [`Identifier`]
let id = tracing_core::identify_callsite!(callsite);

let name = leak_cowstr(self.name);
let filename: Option<&'static str> = self.filename.map(leak_cowstr);

let metadata = Metadata::new(
name,
"flecs",
self.level,
filename,
self.line,
Some("flecs_ecs_tracing"),
match self.kind {
MetadataKind::Span { .. } => FieldSet::new(&[], id),
MetadataKind::Event { .. } => FieldSet::new(&["message"], id),
},
match self.kind {
MetadataKind::Span { .. } => Kind::SPAN,
MetadataKind::Event { .. } => Kind::EVENT,
},
);

// Store the new Metadata
callsite.metadata.set(metadata).unwrap();

// Since the `DynamicCallsite` is alive forever we can also use it to get
// a `&'static` to the metadata, without extra allocations
let metadata: &'static Metadata = callsite.metadata.get().unwrap();

// Tell `tracing` subscribers about the new callsite
dispatch.register_callsite(metadata);

(
metadata,
MetadataRequest {
name: Cow::Borrowed(name),
kind: self.kind,
filename: filename.map(Cow::Borrowed),
line: self.line,
level: self.level,
},
)
}
}
Loading