#![allow(clippy::cast_precision_loss, clippy::cast_possible_truncation)]
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
pub const TELEMETRY_BUFFER_CAP: usize = 64;
const FRAME_SAMPLE_CAP: usize = 128;
pub const RENDER_METRIC_IPC_INTERVAL: u32 = 60;
pub fn generate_ulid_ids() -> (String, String) {
let timestamp_ms = js_sys::Date::now() as u64;
let mut random_bytes = [0u8; 10];
getrandom::getrandom(&mut random_bytes)
.expect("getrandom is always available in WASM with the 'js' feature");
let random: u128 = random_bytes
.iter()
.fold(0u128, |acc, &b| (acc << 8) | u128::from(b));
let id = ulid::Ulid::from_parts(timestamp_ms, random);
let session_id = id.to_string();
let trace_id = format!("{:032x}", id.0);
(session_id, trace_id)
}
#[derive(serde::Serialize, Clone)]
pub struct TelemetryEventJson {
pub timestamp_ms: f64,
pub level: u32, pub target: String,
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub rtt_ms: Option<f64>,
pub trace_id: String,
pub span_name: String,
}
#[derive(serde::Serialize)]
struct TelemetryBatchJson {
events: Vec<TelemetryEventJson>,
session_id: String,
}
#[derive(serde::Serialize)]
pub struct MetricsSnapshot {
pub fps: f64,
pub frame_time_p99: f64,
pub sim_time_p99: f64,
pub rtt_ms: Option<f64>,
pub entity_count: u32,
pub snapshot_count: u32,
pub dropped_events: u32,
}
pub struct EventRing {
buf: Vec<TelemetryEventJson>,
head: usize, len: usize, pub dropped: u32,
}
impl EventRing {
pub fn new() -> Self {
Self {
buf: Vec::with_capacity(TELEMETRY_BUFFER_CAP),
head: 0,
len: 0,
dropped: 0,
}
}
pub fn push(&mut self, event: TelemetryEventJson) {
if self.buf.len() < TELEMETRY_BUFFER_CAP {
self.buf.push(event);
self.len = self.buf.len();
} else {
self.buf[self.head] = event;
self.head = (self.head + 1) % TELEMETRY_BUFFER_CAP;
self.dropped += 1;
}
}
pub fn drain(&mut self) -> Vec<TelemetryEventJson> {
if self.buf.is_empty() {
return Vec::new();
}
let cap = self.buf.len();
let mut out = Vec::with_capacity(cap);
if self.len < cap {
out.extend(self.buf.drain(..));
} else {
for i in 0..cap {
out.push(self.buf[(self.head + i) % cap].clone());
}
self.buf.clear();
}
self.head = 0;
self.len = 0;
out
}
pub fn is_empty(&self) -> bool {
self.len == 0 && self.buf.is_empty()
}
}
struct FrameSampler {
samples: Vec<f64>,
cursor: usize,
}
impl FrameSampler {
fn new() -> Self {
Self {
samples: Vec::with_capacity(FRAME_SAMPLE_CAP),
cursor: 0,
}
}
fn push(&mut self, v: f64) {
if self.samples.len() < FRAME_SAMPLE_CAP {
self.samples.push(v);
} else {
self.samples[self.cursor] = v;
self.cursor = (self.cursor + 1) % FRAME_SAMPLE_CAP;
}
}
fn p99(&mut self) -> f64 {
if self.samples.is_empty() {
return 0.0;
}
let len = self.samples.len();
let idx = ((len as f64 * 0.99) as usize).min(len - 1);
let (_, p99_val, _) = self.samples.select_nth_unstable_by(idx, |a, b| {
a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)
});
*p99_val
}
}
pub struct MetricsCollector {
ring: EventRing,
frame_sampler: FrameSampler,
fps_current: f64,
sim_sampler: FrameSampler,
last_rtt_ms: Option<f64>,
entity_count: u32,
snapshot_count: u32,
telemetry_url: String,
pub session_id: String,
pub trace_id: String,
}
impl MetricsCollector {
pub fn new(telemetry_url: String) -> Self {
let (session_id, trace_id) = generate_ulid_ids();
Self {
ring: EventRing::new(),
frame_sampler: FrameSampler::new(),
fps_current: 0.0,
sim_sampler: FrameSampler::new(),
last_rtt_ms: None,
entity_count: 0,
snapshot_count: 0,
telemetry_url,
session_id,
trace_id,
}
}
pub fn record_frame(&mut self, frame_time_ms: f64, fps: f64) {
self.frame_sampler.push(frame_time_ms);
self.fps_current = fps;
}
pub fn record_sim(&mut self, sim_time_ms: f64) {
self.sim_sampler.push(sim_time_ms);
}
pub fn update_rtt(&mut self, rtt_ms: f64) {
self.last_rtt_ms = Some(rtt_ms);
}
pub fn update_entity_count(&mut self, count: u32) {
self.entity_count = count;
}
pub fn update_snapshot_count(&mut self, count: u32) {
self.snapshot_count = count;
}
pub fn push_event(
&mut self,
level: u32,
target: &str,
message: &str,
span_name: &str,
rtt_ms: Option<f64>,
) {
self.ring.push(TelemetryEventJson {
timestamp_ms: js_sys::Date::now(),
level,
target: target.to_string(),
message: message.to_string(),
rtt_ms,
trace_id: self.trace_id.clone(),
span_name: span_name.to_string(),
});
}
pub fn flush(&mut self) {
self.flush_internal(false);
}
fn flush_internal(&mut self, keepalive: bool) {
if self.telemetry_url.is_empty() {
return;
}
let frame_p99 = self.frame_sampler.p99();
let sim_p99 = self.sim_sampler.p99();
let summary = format!(
"fps={:.1} frame_p99={:.2}ms sim_p99={:.2}ms rtt={} entities={} snapshots={} dropped={}",
self.fps_current,
frame_p99,
sim_p99,
self.last_rtt_ms
.map_or_else(|| "N/A".to_string(), |r| format!("{r:.1}ms")),
self.entity_count,
self.snapshot_count,
self.ring.dropped,
);
self.push_event(1, "metrics", &summary, "metrics_snapshot", self.last_rtt_ms);
let events = self.ring.drain();
let _ = std::mem::replace(&mut self.ring.dropped, 0);
let batch = TelemetryBatchJson {
events,
session_id: self.session_id.clone(),
};
let url = format!("{}/telemetry/json", self.telemetry_url);
spawn_local(async move {
if let Err(e) = post_telemetry(&url, &batch, keepalive).await {
web_sys::console::warn_1(&format!("[Metrics] flush failed: {e}").into());
}
});
}
pub fn flush_sync_fire_and_forget(&mut self) {
self.flush_internal(true);
}
pub fn snapshot(&mut self) -> MetricsSnapshot {
MetricsSnapshot {
fps: self.fps_current,
frame_time_p99: self.frame_sampler.p99(),
sim_time_p99: self.sim_sampler.p99(),
rtt_ms: self.last_rtt_ms,
entity_count: self.entity_count,
snapshot_count: self.snapshot_count,
dropped_events: self.ring.dropped,
}
}
}
async fn post_telemetry(
url: &str,
batch: &TelemetryBatchJson,
keepalive: bool,
) -> Result<(), String> {
use wasm_bindgen::JsCast;
let body = serde_json::to_string(batch).map_err(|e| format!("serialize: {e}"))?;
let headers = web_sys::Headers::new().map_err(|e| format!("headers: {e:?}"))?;
headers
.set("Content-Type", "application/json")
.map_err(|e| format!("header set: {e:?}"))?;
let controller =
web_sys::AbortController::new().map_err(|e| format!("abort_controller: {e:?}"))?;
let signal = controller.signal();
let init = web_sys::RequestInit::new();
init.set_method("POST");
init.set_body(&JsValue::from_str(&body));
init.set_headers(&headers);
init.set_signal(Some(&signal));
if keepalive {
js_sys::Reflect::set(
init.as_ref(),
&JsValue::from_str("keepalive"),
&JsValue::TRUE,
)
.map_err(|e| format!("keepalive: {e:?}"))?;
}
let request = web_sys::Request::new_with_str_and_init(url, &init).map_err(|e| {
controller.abort();
format!("request: {e:?}")
})?;
let global = js_sys::global();
let promise = if let Ok(scope) = global.clone().dyn_into::<web_sys::WorkerGlobalScope>() {
scope.fetch_with_request(&request)
} else {
global
.dyn_into::<web_sys::Window>()
.map_err(|_| {
controller.abort();
"no fetch context".to_string()
})?
.fetch_with_request(&request)
};
let resp_val = wasm_bindgen_futures::JsFuture::from(promise)
.await
.map_err(|e| {
controller.abort();
format!("fetch: {e:?}")
})?;
let resp: web_sys::Response = resp_val.dyn_into().map_err(|_| {
controller.abort();
"response cast failed".to_string()
})?;
if !resp.ok() {
return Err(format!("HTTP {}", resp.status()));
}
Ok(())
}
#[wasm_bindgen]
pub fn wasm_flush_telemetry() {
COLLECTOR.with(|c| {
if let Ok(mut col) = c.try_borrow_mut() {
if let Some(collector) = col.as_mut() {
collector.flush();
}
}
});
}
#[wasm_bindgen]
pub fn wasm_init_telemetry(telemetry_url: String) {
COLLECTOR.with(|c| {
let mut col = c.borrow_mut();
if col.is_none() {
*col = Some(MetricsCollector::new(telemetry_url));
} else {
web_sys::console::debug_1(
&"wasm_init_telemetry: collector already initialized, ignoring".into(),
);
}
});
}
#[wasm_bindgen]
pub fn wasm_push_telemetry_event(level: u32, target: String, message: String, span_name: String) {
COLLECTOR.with(|c| {
if let Ok(mut col) = c.try_borrow_mut() {
if let Some(collector) = col.as_mut() {
collector.push_event(level, &target, &message, &span_name, None);
}
}
});
}
#[wasm_bindgen]
pub fn wasm_record_frame_time(frame_time_ms: f64, fps: f64) {
COLLECTOR.with(|c| {
if let Ok(mut col) = c.try_borrow_mut() {
if let Some(collector) = col.as_mut() {
collector.record_frame(frame_time_ms, fps);
}
}
});
}
#[wasm_bindgen]
pub fn wasm_get_metrics() -> JsValue {
COLLECTOR.with(|c| {
if let Ok(mut col) = c.try_borrow_mut() {
if let Some(collector) = col.as_mut() {
let snap = collector.snapshot();
return serde_wasm_bindgen::to_value(&snap).unwrap_or(JsValue::NULL);
}
}
JsValue::NULL
})
}
thread_local! {
static COLLECTOR: std::cell::RefCell<Option<MetricsCollector>> =
std::cell::RefCell::new(None);
}
pub fn with_collector<F: FnOnce(&mut MetricsCollector)>(f: F) {
COLLECTOR.with(|c| {
if let Ok(mut col) = c.try_borrow_mut() {
if let Some(collector) = col.as_mut() {
f(collector);
}
}
});
}