Skip to main content

myko_server/
telemetry.rs

1//! Logging/tracing init: always-on console output, optional OTLP export.
2//!
3//! Host processes (e.g. rship-control-plane) call [`init_from_env`] once at
4//! startup — before `CellServer::builder()...build()` — replacing the
5//! `env_logger::init()` call from before the log→tracing migration. See
6//! `README.md`'s Environment table for `MYKO_TRACING_ENDPOINT` /
7//! `MYKO_MEM_PROFILE_INTERVAL_SECS`.
8
9use std::{sync::Arc, time::Duration};
10
11use hyphae::Gettable;
12use myko::store::StoreRegistry;
13use opentelemetry::{KeyValue, global, trace::TracerProvider};
14use opentelemetry_otlp::WithExportConfig;
15use opentelemetry_sdk::{Resource, metrics::SdkMeterProvider, trace::SdkTracerProvider};
16use tracing_subscriber::{
17    EnvFilter, Layer, layer::SubscriberExt, registry::LookupSpan, util::SubscriberInitExt,
18};
19
20const DEFAULT_METRICS_INTERVAL_SECS: u64 = 60;
21
22/// Holds the OTLP provider handles alive for the process lifetime.
23///
24/// Bind the return value of [`init_from_env`] to a variable in `main()` —
25/// dropping it immediately (e.g. `let _ = init_from_env();`) shuts the
26/// providers down before anything is exported. `Drop` flushes the last
27/// batch of spans/metrics before the process exits.
28pub struct TelemetryGuard {
29    tracer_provider: Option<SdkTracerProvider>,
30    meter_provider: Option<SdkMeterProvider>,
31}
32
33impl Drop for TelemetryGuard {
34    fn drop(&mut self) {
35        if let Some(provider) = self.tracer_provider.take()
36            && let Err(e) = provider.shutdown()
37        {
38            eprintln!("myko telemetry: tracer provider shutdown error: {e}");
39        }
40        if let Some(provider) = self.meter_provider.take()
41            && let Err(e) = provider.shutdown()
42        {
43            eprintln!("myko telemetry: meter provider shutdown error: {e}");
44        }
45    }
46}
47
48/// Initialize logging/tracing from environment — the simple-case wrapper
49/// around [`otel_layer_from_env`] for host processes that don't already
50/// compose their own `tracing_subscriber` (no existing fmt layer, no Tracy/
51/// other tracing consumer to combine with — see [`otel_layer_from_env`] if
52/// you do).
53///
54/// Always installs a `tracing_subscriber` fmt layer filtered by
55/// `RUST_LOG`/`EnvFilter::from_default_env()` — identical semantics to the
56/// `env_logger::init()` this replaces, so existing runbooks/ops tooling that
57/// set `RUST_LOG` keep working unchanged.
58///
59/// If `MYKO_TRACING_ENDPOINT` is set, additionally builds an OTLP/HTTP trace
60/// exporter (bridged into the same `tracing` spans via `tracing-opentelemetry`)
61/// and an OTLP/HTTP metrics exporter behind a periodic reader — export
62/// interval from `MYKO_MEM_PROFILE_INTERVAL_SECS` (seconds, default 60). If
63/// unset, telemetry stays local-only (console logging), matching the prior
64/// dev-loop behavior.
65pub fn init_from_env() -> TelemetryGuard {
66    let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
67    let fmt_layer = tracing_subscriber::fmt::layer();
68    let (otel_layer, guard) = otel_layer_from_env();
69
70    tracing_subscriber::registry()
71        .with(filter)
72        .with(fmt_layer)
73        .with(otel_layer)
74        .init();
75
76    guard.unwrap_or(TelemetryGuard {
77        tracer_provider: None,
78        meter_provider: None,
79    })
80}
81
82/// Build just the OTLP trace layer (+ register the OTLP metrics
83/// `MeterProvider` globally, as a side effect) from `MYKO_TRACING_ENDPOINT`/
84/// `MYKO_MEM_PROFILE_INTERVAL_SECS` — for host processes that compose their
85/// *own* `tracing_subscriber::registry()` (an existing custom fmt layer, a
86/// Tracy layer for live profiling sessions, etc.) instead of ceding the
87/// whole subscriber to [`init_from_env`]'s monolithic `.init()`.
88///
89/// Metrics don't compose via `Layer` the way traces do — there's only ever
90/// one global `MeterProvider` — so this registers it globally as a side
91/// effect regardless of whether the caller uses the returned trace layer.
92///
93/// Returns `(None, None)` when `MYKO_TRACING_ENDPOINT` is unset: no layer to
94/// add, no meter provider registered (metrics recording calls elsewhere in
95/// myko fall back to a no-op meter, same as always). Hold the returned
96/// [`TelemetryGuard`] for the process lifetime, same as [`init_from_env`].
97///
98/// ```rust,no_run
99/// use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
100/// let (otel_layer, _guard) = myko_server::telemetry::otel_layer_from_env();
101/// tracing_subscriber::registry()
102///     .with(tracing_subscriber::fmt::layer()) // your own fmt layer, unchanged
103///     .with(otel_layer)                       // adds OTLP export alongside it
104///     .init();
105/// ```
106pub fn otel_layer_from_env<S>() -> (Option<impl Layer<S> + Send + Sync>, Option<TelemetryGuard>)
107where
108    S: tracing::Subscriber + for<'a> LookupSpan<'a> + Send + Sync,
109{
110    let Ok(endpoint) = std::env::var("MYKO_TRACING_ENDPOINT") else {
111        return (None, None);
112    };
113
114    let resource = Resource::builder().with_service_name("myko-server").build();
115    let tracer_provider = build_tracer_provider(&endpoint, resource.clone());
116    let meter_provider = build_meter_provider(&endpoint, resource);
117
118    global::set_meter_provider(meter_provider.clone());
119
120    let tracer = tracer_provider.tracer("myko-server");
121    let otel_layer = tracing_opentelemetry::layer().with_tracer(tracer);
122
123    (
124        Some(otel_layer),
125        Some(TelemetryGuard {
126            tracer_provider: Some(tracer_provider),
127            meter_provider: Some(meter_provider),
128        }),
129    )
130}
131
132fn build_tracer_provider(endpoint: &str, resource: Resource) -> SdkTracerProvider {
133    let exporter = opentelemetry_otlp::SpanExporter::builder()
134        .with_http()
135        // `.with_endpoint` is the exact per-signal URL (opentelemetry-otlp does NOT
136        // append the signal path when set programmatically), so append `/v1/traces`
137        // to the base gateway endpoint — otherwise it POSTs to `/` and gets 404.
138        .with_endpoint(format!("{}/v1/traces", endpoint.trim_end_matches('/')))
139        .build()
140        .expect("failed to build OTLP/HTTP trace exporter");
141
142    SdkTracerProvider::builder()
143        .with_batch_exporter(exporter)
144        .with_resource(resource)
145        .build()
146}
147
148/// Registers an OTLP `ObservableGauge` reporting live per-entity-type item
149/// counts (`myko.store.item_count`, tagged `entity_type`) — the Rust
150/// equivalent of the old TS gateway's `itemCountsGuage`/`repo.getItemCount()`.
151///
152/// Sampled on each metrics export (interval set by [`init_from_env`] from
153/// `MYKO_MEM_PROFILE_INTERVAL_SECS`), not on its own timer — this reuses the
154/// OTLP SDK's own periodic reader instead of a bespoke background thread.
155/// Cheap/no-op when no real `MeterProvider` is registered (i.e.
156/// `MYKO_TRACING_ENDPOINT` unset): `opentelemetry::global::meter` falls back
157/// to a no-op meter in that case, so this is safe to call unconditionally.
158///
159/// The callback is owned by the `Meter`/`MeterProvider` itself (this crate's
160/// `ObservableGauge` handle carries no `Drop` — dropping it here does not
161/// unregister the callback), so the return value doesn't need to be held.
162pub fn register_item_count_gauge(registry: Arc<StoreRegistry>) {
163    let meter = global::meter("myko-server");
164    let _gauge = meter
165        .u64_observable_gauge("myko.store.item_count")
166        .with_description("Live entity count per store, sampled on each metrics export")
167        .with_callback(move |observer| {
168            for entity_type in registry.entity_types() {
169                let count = registry.get_or_create(&entity_type).len().get() as u64;
170                observer.observe(
171                    count,
172                    &[KeyValue::new("entity_type", entity_type.to_string())],
173                );
174            }
175        })
176        .build();
177}
178
179const MALLOC_TRIM_INTERVAL_ENV: &str = "MYKO_MALLOC_TRIM_INTERVAL_SECS";
180
181/// Periodic `malloc_trim(0)` probe: logs RSS before/after asking glibc to
182/// return free arena pages to the OS. Opt-in via `MYKO_MALLOC_TRIM_INTERVAL_SECS`
183/// (seconds); unset or 0 = disabled, no thread spawned.
184///
185/// This exists to interpret RSS observations of deployed servers. The M1
186/// amplification harness (2026-07) showed a myko process at 5,167 MB RSS while
187/// referencing 5.81 MB of live heap — an 889× gap that was pure glibc arena
188/// retention, collapsing to 13 MB on trim. If a deployment's "huge RSS"
189/// collapses the same way here, the number was allocator behaviour, not
190/// retention; if it doesn't, something is genuinely holding the memory.
191///
192/// Note the probe is not passive: each tick returns free pages to the OS, so
193/// enabling it lowers steady-state RSS (that release is the measurement).
194/// glibc-only — on other libcs the env var logs a warning and does nothing.
195///
196/// **This measures glibc's arenas only.** If the host binary installs a
197/// different `#[global_allocator]` (rship sets tikv-jemallocator), Rust
198/// allocations never touch glibc, `malloc_trim` has ~nothing to release, and
199/// `released≈0` says nothing about retention — the probe warns once when a
200/// tick looks like that instead of letting it read as a clean result. Under
201/// jemalloc, use its own allocated-vs-resident stats (rship's mem_profile
202/// ticks) as the discriminator.
203pub fn start_malloc_trim_probe() {
204    let Some(interval_secs) = std::env::var(MALLOC_TRIM_INTERVAL_ENV)
205        .ok()
206        .and_then(|s| s.parse::<u64>().ok())
207        .filter(|&s| s > 0)
208    else {
209        return;
210    };
211
212    #[cfg(all(target_os = "linux", target_env = "gnu"))]
213    {
214        use std::sync::OnceLock;
215        static STARTED: OnceLock<()> = OnceLock::new();
216        if STARTED.set(()).is_err() {
217            return;
218        }
219        // Positive detection for the most likely foreign allocator: tikv-
220        // jemallocator doesn't replace the C `malloc` symbol, but it does
221        // export jemalloc's prefixed control API, so resolving _rjem_mallctl
222        // proves jemalloc is linked. Refuse at startup with the reason rather
223        // than tick forever measuring arenas that hold nothing. (Other custom
224        // allocators aren't detectable this way — the in-loop low-release
225        // warning is the backstop for those.)
226        if jemalloc_linked() {
227            tracing::warn!(
228                target: "myko_server::mem_probe",
229                "{MALLOC_TRIM_INTERVAL_ENV} is set but this binary links jemalloc \
230                 (_rjem_mallctl resolved) — malloc_trim only trims glibc arenas, which \
231                 jemalloc bypasses. Probe disabled; use jemalloc's allocated-vs-resident \
232                 stats instead."
233            );
234            return;
235        }
236        let _ = std::thread::Builder::new()
237            .name("myko-malloc-trim".to_string())
238            .spawn(move || run_malloc_trim_loop(interval_secs))
239            .map_err(|e| {
240                tracing::warn!(
241                    target: "myko_server::mem_probe",
242                    "Failed to spawn malloc_trim probe thread: {e}"
243                )
244            });
245    }
246
247    #[cfg(not(all(target_os = "linux", target_env = "gnu")))]
248    {
249        let _ = interval_secs;
250        tracing::warn!(
251            target: "myko_server::mem_probe",
252            "{MALLOC_TRIM_INTERVAL_ENV} is set but malloc_trim is glibc-only; probe disabled"
253        );
254    }
255}
256
257#[cfg(all(target_os = "linux", target_env = "gnu"))]
258fn run_malloc_trim_loop(interval_secs: u64) {
259    unsafe extern "C" {
260        fn malloc_trim(pad: usize) -> i32;
261    }
262
263    let mb = |bytes: u64| bytes as f64 / (1024.0 * 1024.0);
264    let mut warned_wrong_allocator = false;
265
266    loop {
267        std::thread::sleep(Duration::from_secs(interval_secs));
268
269        let before = rss_bytes();
270        // Returns 1 if any memory was actually released back to the system.
271        let released = unsafe { malloc_trim(0) } == 1;
272        let after = rss_bytes();
273
274        if let (Some(before), Some(after)) = (before, after) {
275            let released_bytes = before.saturating_sub(after);
276            tracing::info!(
277                target: "myko_server::mem_probe",
278                "[malloc_trim] rss_before={:.2}MB rss_after={:.2}MB released={:.2}MB ({})",
279                mb(before),
280                mb(after),
281                mb(released_bytes),
282                if released { "pages returned" } else { "no-op" },
283            );
284            // A large RSS that trim barely dents is ambiguous: either the heap
285            // is genuinely live, or a non-glibc #[global_allocator] owns the
286            // memory and malloc_trim never touched it. Say so once, loudly —
287            // "released=0" must not read as "no retention" on its own.
288            if !warned_wrong_allocator
289                && released_bytes < 16 * 1024 * 1024
290                && after > 1024 * 1024 * 1024
291            {
292                warned_wrong_allocator = true;
293                tracing::warn!(
294                    target: "myko_server::mem_probe",
295                    "[malloc_trim] trim released almost nothing against {:.0}MB RSS. Either \
296                     this heap is genuinely live, or this binary sets a non-glibc \
297                     #[global_allocator] (e.g. jemalloc) that malloc_trim cannot touch — \
298                     check the host's main.rs before drawing conclusions; under jemalloc \
299                     use its allocated-vs-resident stats instead of this probe.",
300                    mb(after),
301                );
302            }
303        } else {
304            tracing::warn!(
305                target: "myko_server::mem_probe",
306                "[malloc_trim] VmRSS unavailable in /proc/self/status; trim ran unmeasured"
307            );
308        }
309    }
310}
311
312/// True when jemalloc is linked into this process, detected by resolving its
313/// prefixed control symbol via `dlsym`. `RTLD_DEFAULT` is a null handle on
314/// Linux/glibc, and libdl is in Rust's default linux-gnu link set, so this
315/// carries no extra link requirement or glibc version floor.
316#[cfg(all(target_os = "linux", target_env = "gnu"))]
317fn jemalloc_linked() -> bool {
318    use core::ffi::{c_char, c_void};
319    unsafe extern "C" {
320        fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
321    }
322    unsafe { !dlsym(std::ptr::null_mut(), c"_rjem_mallctl".as_ptr()).is_null() }
323}
324
325/// Resident set size in bytes, from `VmRSS` in `/proc/self/status`. Reported
326/// in kB by the kernel, so no page-size assumption is needed (statm counts
327/// pages, and page size varies across arm64 kernels).
328#[cfg(all(target_os = "linux", target_env = "gnu"))]
329fn rss_bytes() -> Option<u64> {
330    let status = std::fs::read_to_string("/proc/self/status").ok()?;
331    let line = status.lines().find(|l| l.starts_with("VmRSS:"))?;
332    let kb = line.split_whitespace().nth(1)?.parse::<u64>().ok()?;
333    Some(kb * 1024)
334}
335
336fn build_meter_provider(endpoint: &str, resource: Resource) -> SdkMeterProvider {
337    let interval_secs = std::env::var("MYKO_MEM_PROFILE_INTERVAL_SECS")
338        .ok()
339        .and_then(|s| s.parse::<u64>().ok())
340        .unwrap_or(DEFAULT_METRICS_INTERVAL_SECS);
341
342    let exporter = opentelemetry_otlp::MetricExporter::builder()
343        .with_http()
344        // See build_tracer_provider: append the `/v1/metrics` signal path to the base
345        // gateway endpoint, else the exporter POSTs to `/` and gets 404.
346        .with_endpoint(format!("{}/v1/metrics", endpoint.trim_end_matches('/')))
347        .build()
348        .expect("failed to build OTLP/HTTP metrics exporter");
349
350    let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(exporter)
351        .with_interval(Duration::from_secs(interval_secs))
352        .build();
353
354    SdkMeterProvider::builder()
355        .with_reader(reader)
356        .with_resource(resource)
357        .build()
358}