1use std::{
16 collections::HashMap,
17 sync::{Arc, OnceLock, atomic::AtomicBool},
18 time::Duration,
19};
20
21use anyhow::Context;
22use clap::Parser;
23use derivative::Derivative;
24use jf_signature::bls_over_bn254::{SignKey, VerKey};
25use opentelemetry::KeyValue;
26use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
27use opentelemetry_otlp::{Compression, LogExporter, Protocol, WithExportConfig, WithHttpConfig};
28use opentelemetry_sdk::{Resource, logs::SdkLoggerProvider};
29use prometheus::Registry;
30use tokio::sync::oneshot;
31use tracing::Subscriber;
32use tracing_subscriber::{EnvFilter, Layer, registry::LookupSpan};
33use url::Url;
34
35use crate::{UnauthenticatedToken, push_task, remote_write::Label};
36
37const SERVICE_NAME: &str = "espresso-node";
38const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10);
39
40const PUBKEY_SLUG_LEN: usize = 24;
41
42fn pubkey_slug(staking_key: &SignKey) -> String {
43 VerKey::from(staking_key)
44 .to_string()
45 .chars()
46 .take(PUBKEY_SLUG_LEN)
47 .collect()
48}
49
50fn segment(value: Option<&str>) -> String {
51 match value.unwrap_or("").trim() {
52 "" => "unk".to_owned(),
53 v => v.replace(' ', "-"),
54 }
55}
56
57fn instance_id(company: Option<&str>, node: Option<&str>, staking_key: &SignKey) -> String {
58 format!(
59 "{}-{}-{}",
60 segment(company),
61 segment(node),
62 pubkey_slug(staking_key)
63 )
64 .to_lowercase()
65}
66
67fn join_bounded(thread: std::thread::JoinHandle<()>, what: &str) {
70 let (done_tx, done_rx) = std::sync::mpsc::sync_channel::<()>(0);
71 if let Err(e) = std::thread::Builder::new()
72 .name("espresso-telemetry-join".into())
73 .spawn(move || {
74 let _ = thread.join();
75 let _ = done_tx.send(());
76 })
77 {
78 tracing::warn!(error = %e, "telemetry: cannot spawn {what} join watcher");
79 return;
80 }
81 if done_rx.recv_timeout(SHUTDOWN_TIMEOUT).is_err() {
82 tracing::warn!(
83 timeout_secs = SHUTDOWN_TIMEOUT.as_secs(),
84 "telemetry: {what} shutdown timed out; detaching thread",
85 );
86 }
87}
88
89static REGISTRY: OnceLock<Arc<Registry>> = OnceLock::new();
96
97pub fn set_registry(registry: Arc<Registry>) {
100 let _ = REGISTRY.set(registry);
101}
102
103pub fn registry() -> Option<Arc<Registry>> {
106 REGISTRY.get().cloned()
107}
108
109#[derive(Parser, Clone, Derivative)]
111#[derivative(Debug)]
112pub struct TelemetryOptions {
113 #[clap(
115 long,
116 env = "ESPRESSO_NODE_TELEMETRY_LOGS_ENABLE",
117 default_value = "false"
118 )]
119 pub logs_enable: bool,
120
121 #[clap(
123 long,
124 env = "ESPRESSO_NODE_TELEMETRY_METRICS_ENABLE",
125 default_value = "false"
126 )]
127 pub metrics_enable: bool,
128
129 #[clap(long, env = "ESPRESSO_NODE_TELEMETRY_ENDPOINT")]
132 pub endpoint: Option<Url>,
133
134 #[clap(long, env = "ESPRESSO_NODE_TELEMETRY_LOG", default_value = "warn")]
138 pub log_filter: String,
139
140 #[clap(
142 long,
143 env = "ESPRESSO_NODE_TELEMETRY_METRICS_INTERVAL",
144 default_value = "60"
145 )]
146 pub metrics_interval_secs: u64,
147}
148
149impl Default for TelemetryOptions {
150 fn default() -> Self {
151 Self {
152 logs_enable: false,
153 metrics_enable: false,
154 endpoint: None,
155 log_filter: "warn".to_owned(),
156 metrics_interval_secs: 60,
157 }
158 }
159}
160
161struct MetricsPushHandle {
164 shutdown: oneshot::Sender<()>,
165 thread: std::thread::JoinHandle<()>,
166}
167
168pub struct TelemetryHandle {
173 logger_provider: Option<SdkLoggerProvider>,
175 log_filter: String,
176 jwt: String,
177 endpoint: String,
178 metrics_interval: Duration,
179 metrics_push: Option<MetricsPushHandle>,
180 metrics_enabled: bool,
182 metrics_external_labels: Vec<Label>,
186 rate_limit_warned: Arc<AtomicBool>,
191 telemetry_log_filter: Arc<String>,
194}
195
196impl std::fmt::Debug for TelemetryHandle {
197 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198 f.debug_struct("TelemetryHandle")
199 .field("log_filter", &self.log_filter)
200 .field("metrics_push_active", &self.metrics_push.is_some())
201 .finish()
202 }
203}
204
205impl TelemetryHandle {
206 pub fn tracing_layer<S>(&self) -> Option<impl Layer<S> + Send + Sync + 'static>
209 where
210 S: Subscriber + for<'a> LookupSpan<'a>,
211 {
212 let provider = self.logger_provider.as_ref()?;
213 let bridge = OpenTelemetryTracingBridge::new(provider);
214 Some(bridge.with_filter(EnvFilter::new(self.log_filter.clone())))
215 }
216
217 pub fn attach_metrics_push(&mut self, registry: Arc<Registry>) {
220 let mut deferred = Vec::new();
221 self.attach_metrics_push_buffered(registry, &mut deferred);
222 for w in deferred {
223 tracing::warn!("{w}");
224 }
225 }
226
227 fn attach_metrics_push_buffered(
231 &mut self,
232 registry: Arc<Registry>,
233 deferred: &mut Vec<String>,
234 ) {
235 if !self.metrics_enabled {
236 return;
237 }
238 if self.metrics_push.is_some() {
239 return;
240 }
241 let push_endpoint: Url = match self.endpoint.parse() {
242 Ok(u) => u,
243 Err(e) => {
244 deferred.push(format!(
245 "telemetry: cannot parse endpoint {endpoint:?} as URL ({e}); skipping metrics \
246 push",
247 endpoint = self.endpoint,
248 ));
249 return;
250 },
251 };
252 let (shutdown_tx, shutdown_rx) = oneshot::channel();
253 let jwt = self.jwt.clone();
254 let interval = self.metrics_interval;
255 let rate_limit_warned = self.rate_limit_warned.clone();
256 let telemetry_log_filter = self.telemetry_log_filter.clone();
257 let external_labels = self.metrics_external_labels.clone();
258 let thread = match std::thread::Builder::new()
259 .name("espresso-telemetry-metrics".into())
260 .spawn(move || {
261 let rt = match tokio::runtime::Builder::new_current_thread()
262 .enable_all()
263 .build()
264 {
265 Ok(rt) => rt,
266 Err(e) => {
267 tracing::warn!(
268 error = %e,
269 "telemetry: cannot build dedicated runtime; metrics push disabled"
270 );
271 return;
272 },
273 };
274 rt.block_on(push_task::run(
275 registry,
276 push_endpoint,
277 jwt,
278 interval,
279 external_labels,
280 rate_limit_warned,
281 telemetry_log_filter,
282 shutdown_rx,
283 ));
284 }) {
285 Ok(t) => t,
286 Err(e) => {
287 deferred.push(format!("telemetry: cannot spawn metrics push thread: {e}"));
288 return;
289 },
290 };
291 self.metrics_push = Some(MetricsPushHandle {
292 shutdown: shutdown_tx,
293 thread,
294 });
295 }
296
297 pub fn shutdown(self) {
302 if let Some(MetricsPushHandle { shutdown, thread }) = self.metrics_push {
303 let _ = shutdown.send(());
304 join_bounded(thread, "metrics push");
305 }
306 if let Some(provider) = self.logger_provider {
307 match std::thread::Builder::new()
308 .name("espresso-telemetry-shutdown".into())
309 .spawn(move || {
310 if let Err(e) = provider.shutdown() {
311 tracing::warn!(error = %e, "telemetry: logger provider shutdown error");
312 }
313 }) {
314 Ok(thread) => join_bounded(thread, "logger provider"),
315 Err(e) => tracing::warn!(error = %e, "telemetry: cannot spawn shutdown thread"),
316 }
317 }
318 }
319
320 #[doc(hidden)]
322 pub fn metrics_push_active(&self) -> bool {
323 self.metrics_push.is_some()
324 }
325
326 #[doc(hidden)]
328 pub fn metrics_enabled(&self) -> bool {
329 self.metrics_enabled
330 }
331}
332
333pub fn init(
343 opts: &TelemetryOptions,
344 staking_key: &SignKey,
345 node_name: Option<&str>,
346 company_name: Option<&str>,
347 endpoint: &Url,
348 registry: Option<Arc<Registry>>,
349) -> anyhow::Result<(Option<TelemetryHandle>, Vec<String>)> {
350 let logs_on = opts.logs_enable;
351 let metrics_on = opts.metrics_enable;
352
353 if !logs_on && !metrics_on {
354 return Ok((None, Vec::new()));
355 }
356
357 let mut deferred: Vec<String> = Vec::new();
358
359 if logs_on && let Err(e) = EnvFilter::try_new(&opts.log_filter) {
360 deferred.push(format!(
361 "telemetry: invalid log_filter {filter:?} ({e}); falling back to lossy parse, some \
362 directives may be ignored",
363 filter = opts.log_filter,
364 ));
365 }
366
367 let jwt = UnauthenticatedToken::generate_with(staking_key, node_name, company_name)
368 .context("mint telemetry JWT")?
369 .encode();
370
371 let scheme = endpoint.scheme();
374 if scheme != "http" && scheme != "https" {
375 anyhow::bail!(
376 "telemetry endpoint must use http or https scheme, got {scheme:?}: {endpoint}"
377 );
378 }
379 let endpoint = endpoint.as_str().to_owned();
380
381 let telemetry_log_filter: Arc<String> = Arc::new(opts.log_filter.clone());
382 let rate_limit_warned: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
383
384 let instance = instance_id(company_name, node_name, staking_key);
385
386 let logger_provider = if logs_on {
387 Some(build_logger_provider(jwt.clone(), &endpoint, &instance)?)
388 } else {
389 None
390 };
391
392 let metrics_interval = Duration::from_secs(opts.metrics_interval_secs.max(1));
393 let metrics_external_labels = vec![
394 Label {
395 name: "service".to_owned(),
396 value: SERVICE_NAME.to_owned(),
397 },
398 Label {
399 name: "instance".to_owned(),
400 value: instance,
401 },
402 ];
403 let mut handle = TelemetryHandle {
404 logger_provider,
405 log_filter: opts.log_filter.clone(),
406 jwt,
407 endpoint,
408 metrics_interval,
409 metrics_push: None,
410 metrics_enabled: metrics_on,
411 metrics_external_labels,
412 rate_limit_warned,
413 telemetry_log_filter,
414 };
415
416 if let Some(registry) = registry {
417 handle.attach_metrics_push_buffered(registry, &mut deferred);
418 }
419
420 Ok((Some(handle), deferred))
421}
422
423fn build_logger_provider(
424 jwt: String,
425 endpoint: &str,
426 instance: &str,
427) -> anyhow::Result<SdkLoggerProvider> {
428 let logs_endpoint = format!("{}/v1/logs", endpoint.trim_end_matches('/'));
429
430 let mut headers = HashMap::new();
431 headers.insert("authorization".to_string(), format!("Bearer {jwt}"));
432
433 let exporter = LogExporter::builder()
435 .with_http()
436 .with_protocol(Protocol::HttpBinary)
437 .with_compression(Compression::Gzip)
438 .with_endpoint(logs_endpoint)
439 .with_headers(headers)
440 .build()
441 .context("build OTLP log exporter")?;
442
443 let resource = Resource::builder()
444 .with_service_name(SERVICE_NAME)
445 .with_attribute(KeyValue::new("service.instance.id", instance.to_owned()));
446
447 Ok(SdkLoggerProvider::builder()
448 .with_resource(resource.build())
449 .with_batch_exporter(exporter)
450 .build())
451}
452
453#[cfg(test)]
454mod tests {
455 use jf_signature::{SignatureScheme, bls_over_bn254::BLSOverBN254CurveSignatureScheme};
456
457 use super::*;
458
459 #[test]
460 fn instance_id_appends_pubkey_slug() {
461 let key = BLSOverBN254CurveSignatureScheme::key_gen(&(), &mut rand::thread_rng())
462 .unwrap()
463 .0;
464 let slug = pubkey_slug(&key);
465 assert_eq!(slug.len(), PUBKEY_SLUG_LEN);
466 assert!(slug.starts_with("BLS_VER_KEY~"));
467
468 let slug = slug.to_lowercase();
469 assert_eq!(
470 instance_id(Some("Acme Corp"), Some("Node 42"), &key),
471 format!("acme-corp-node-42-{slug}")
472 );
473 assert_eq!(instance_id(None, None, &key), format!("unk-unk-{slug}"));
474 assert_eq!(
475 instance_id(Some(" "), Some("node-1"), &key),
476 format!("unk-node-1-{slug}")
477 );
478 }
479}