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(endpoint)
136 .build()
137 .expect("failed to build OTLP/HTTP trace exporter");
138
139 SdkTracerProvider::builder()
140 .with_batch_exporter(exporter)
141 .with_resource(resource)
142 .build()
143}
144
145/// Registers an OTLP `ObservableGauge` reporting live per-entity-type item
146/// counts (`myko.store.item_count`, tagged `entity_type`) — the Rust
147/// equivalent of the old TS gateway's `itemCountsGuage`/`repo.getItemCount()`.
148///
149/// Sampled on each metrics export (interval set by [`init_from_env`] from
150/// `MYKO_MEM_PROFILE_INTERVAL_SECS`), not on its own timer — this reuses the
151/// OTLP SDK's own periodic reader instead of a bespoke background thread.
152/// Cheap/no-op when no real `MeterProvider` is registered (i.e.
153/// `MYKO_TRACING_ENDPOINT` unset): `opentelemetry::global::meter` falls back
154/// to a no-op meter in that case, so this is safe to call unconditionally.
155///
156/// The callback is owned by the `Meter`/`MeterProvider` itself (this crate's
157/// `ObservableGauge` handle carries no `Drop` — dropping it here does not
158/// unregister the callback), so the return value doesn't need to be held.
159pub fn register_item_count_gauge(registry: Arc<StoreRegistry>) {
160 let meter = global::meter("myko-server");
161 let _gauge = meter
162 .u64_observable_gauge("myko.store.item_count")
163 .with_description("Live entity count per store, sampled on each metrics export")
164 .with_callback(move |observer| {
165 for entity_type in registry.entity_types() {
166 let count = registry.get_or_create(&entity_type).len().get() as u64;
167 observer.observe(
168 count,
169 &[KeyValue::new("entity_type", entity_type.to_string())],
170 );
171 }
172 })
173 .build();
174}
175
176fn build_meter_provider(endpoint: &str, resource: Resource) -> SdkMeterProvider {
177 let interval_secs = std::env::var("MYKO_MEM_PROFILE_INTERVAL_SECS")
178 .ok()
179 .and_then(|s| s.parse::<u64>().ok())
180 .unwrap_or(DEFAULT_METRICS_INTERVAL_SECS);
181
182 let exporter = opentelemetry_otlp::MetricExporter::builder()
183 .with_http()
184 .with_endpoint(endpoint)
185 .build()
186 .expect("failed to build OTLP/HTTP metrics exporter");
187
188 let reader = opentelemetry_sdk::metrics::PeriodicReader::builder(exporter)
189 .with_interval(Duration::from_secs(interval_secs))
190 .build();
191
192 SdkMeterProvider::builder()
193 .with_reader(reader)
194 .with_resource(resource)
195 .build()
196}