Skip to main content

detritus/
layer.rs

1use std::{
2    fmt,
3    path::PathBuf,
4    time::{Duration, SystemTime, UNIX_EPOCH},
5};
6
7use detritus_protocol::{
8    GRPC_VERSION_KEY, PROTOCOL_VERSION, SourceId,
9    otlp::{
10        common::{AnyValue, InstrumentationScope, KeyValue, any_value},
11        logs::{
12            ExportLogsServiceRequest, LogRecord, LogsServiceClient, ResourceLogs, ScopeLogs,
13            SeverityNumber,
14        },
15        resource::Resource,
16    },
17};
18use secrecy::{ExposeSecret, SecretString};
19use tokio::sync::{OnceCell, mpsc, oneshot};
20use tonic::{
21    codec::CompressionEncoding,
22    metadata::MetadataValue,
23    transport::{Channel, ClientTlsConfig, Endpoint},
24};
25use tracing::{Event, Subscriber};
26use tracing_core::{Level, field};
27use tracing_subscriber::{Layer as SubscriberLayer, layer::Context};
28use url::Url;
29
30use crate::spool;
31
32#[cfg(test)]
33mod tests;
34
35const DEFAULT_BATCH_SIZE: usize = 256;
36const DEFAULT_FLUSH_INTERVAL: Duration = Duration::from_secs(5);
37const DEFAULT_FLUSH_TIMEOUT: Duration = Duration::from_secs(2);
38const DEFAULT_CHANNEL_CAPACITY: usize = 4096;
39
40/// A tracing subscriber layer that exports events to the observability server.
41#[derive(Debug, Clone)]
42pub struct Layer {
43    sender: mpsc::Sender<WorkerMessage>,
44}
45
46impl Layer {
47    /// Starts a builder for an observability tracing layer.
48    #[must_use]
49    pub fn builder() -> LayerBuilder {
50        LayerBuilder::default()
51    }
52
53    /// Requests a best-effort flush of queued records.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`LayerError::WorkerStopped`] if the background exporter has
58    /// stopped, or [`LayerError::Flush`] if the flush could not complete (for
59    /// example it timed out or the export failed). Failed batches are spooled
60    /// for replay when the queue directory is writable.
61    pub async fn flush(&self) -> Result<(), LayerError> {
62        let (sender, receiver) = oneshot::channel();
63        self.sender
64            .send(WorkerMessage::Flush(sender))
65            .await
66            .map_err(|_| LayerError::WorkerStopped)?;
67        receiver.await.map_err(|_| LayerError::WorkerStopped)?
68    }
69}
70
71/// Builder for [`Layer`].
72#[derive(Debug, Clone)]
73pub struct LayerBuilder {
74    endpoint: Option<Url>,
75    token: Option<SecretString>,
76    source: Option<SourceId>,
77    batch_size: usize,
78    flush_interval: Duration,
79    flush_timeout: Duration,
80    queue_dir: Option<PathBuf>,
81    sample_rate: f64,
82    compression: Option<CompressionEncoding>,
83    tls_config: Option<ClientTlsConfig>,
84}
85
86impl Default for LayerBuilder {
87    fn default() -> Self {
88        Self {
89            endpoint: None,
90            token: None,
91            source: None,
92            batch_size: DEFAULT_BATCH_SIZE,
93            flush_interval: DEFAULT_FLUSH_INTERVAL,
94            flush_timeout: DEFAULT_FLUSH_TIMEOUT,
95            queue_dir: None,
96            sample_rate: 1.0,
97            compression: None,
98            tls_config: None,
99        }
100    }
101}
102
103impl LayerBuilder {
104    /// Sets the OTLP/gRPC endpoint, for example `http://127.0.0.1:4317`.
105    #[must_use]
106    pub fn endpoint(mut self, endpoint: Url) -> Self {
107        self.endpoint = Some(endpoint);
108        self
109    }
110
111    /// Sets the bearer token sent to the observability server.
112    #[must_use]
113    pub fn token(mut self, token: SecretString) -> Self {
114        self.token = Some(token);
115        self
116    }
117
118    /// Sets the source identity attached to every exported batch.
119    #[must_use]
120    pub fn source(mut self, source: SourceId) -> Self {
121        self.source = Some(source);
122        self
123    }
124
125    /// Sets the maximum records per export request.
126    #[must_use]
127    pub fn batch_size(mut self, batch_size: usize) -> Self {
128        self.batch_size = batch_size.max(1);
129        self
130    }
131
132    /// Sets the periodic flush interval.
133    #[must_use]
134    pub fn flush_interval(mut self, flush_interval: Duration) -> Self {
135        self.flush_interval = flush_interval;
136        self
137    }
138
139    /// Sets the timeout for each export or replay attempt, including requested flushes.
140    #[must_use]
141    pub fn flush_timeout(mut self, flush_timeout: Duration) -> Self {
142        self.flush_timeout = flush_timeout;
143        self
144    }
145
146    /// Sets the directory used for offline protobuf log batches.
147    #[must_use]
148    pub fn queue_dir(mut self, queue_dir: PathBuf) -> Self {
149        self.queue_dir = Some(queue_dir);
150        self
151    }
152
153    /// Sets a deterministic sample rate in the inclusive range `0.0..=1.0`.
154    #[must_use]
155    pub fn sample_rate(mut self, sample_rate: f64) -> Self {
156        self.sample_rate = sample_rate.clamp(0.0, 1.0);
157        self
158    }
159
160    /// Compresses exported gRPC messages with gzip or zstd.
161    ///
162    /// Requests are uncompressed by default for compatibility with older receivers.
163    #[must_use]
164    pub fn compression(mut self, encoding: CompressionEncoding) -> Self {
165        self.compression = Some(encoding);
166        self
167    }
168
169    /// Sets HTTPS transport options, including custom CA roots and mutual TLS.
170    ///
171    /// HTTPS endpoints use system trust roots by default. A custom configuration
172    /// can add a private CA, override the expected domain, or supply a client identity.
173    #[must_use]
174    pub fn tls_config(mut self, config: ClientTlsConfig) -> Self {
175        self.tls_config = Some(config);
176        self
177    }
178
179    /// Builds the layer and spawns its background exporter on the current Tokio runtime.
180    ///
181    /// # Errors
182    ///
183    /// Returns [`LayerError::MissingEndpoint`], [`LayerError::MissingToken`],
184    /// [`LayerError::MissingSource`], or [`LayerError::MissingQueueDir`] if the
185    /// corresponding builder field was not set.
186    pub fn build(self) -> Result<Layer, LayerError> {
187        let endpoint = self.endpoint.ok_or(LayerError::MissingEndpoint)?;
188        let token = self.token.ok_or(LayerError::MissingToken)?;
189        let source = self.source.ok_or(LayerError::MissingSource)?;
190        let queue_dir = self.queue_dir.ok_or(LayerError::MissingQueueDir)?;
191        let (sender, receiver) = mpsc::channel(DEFAULT_CHANNEL_CAPACITY);
192        let worker = Worker {
193            endpoint,
194            token,
195            source,
196            batch_size: self.batch_size,
197            flush_interval: self.flush_interval,
198            flush_timeout: self.flush_timeout,
199            queue_dir,
200            sample_rate: self.sample_rate,
201            compression: self.compression,
202            tls_config: self.tls_config,
203            channel: OnceCell::new(),
204            receiver,
205        };
206        // The exporter is a process-lifetime background task: it is intentionally
207        // detached. The handle is deliberately dropped rather than joined — the
208        // worker only returns once every `Layer` clone is dropped, so awaiting it
209        // here would hang, and `let _ =` on the `JoinHandle` future would trip
210        // `clippy::let_underscore_future`. Graceful shutdown is coordinated over
211        // the channel via `Layer::flush`.
212        tokio::spawn(worker.run());
213        Ok(Layer { sender })
214    }
215}
216
217/// Errors returned while building or flushing a [`Layer`].
218#[derive(Debug, thiserror::Error)]
219#[non_exhaustive]
220pub enum LayerError {
221    /// Builder is missing an endpoint.
222    #[error("observability layer endpoint is required")]
223    MissingEndpoint,
224    /// Builder is missing a bearer token.
225    #[error("observability layer token is required")]
226    MissingToken,
227    /// Builder is missing a source identity.
228    #[error("observability layer source is required")]
229    MissingSource,
230    /// Builder is missing a queue directory.
231    #[error("observability layer queue_dir is required")]
232    MissingQueueDir,
233    /// Background worker has stopped.
234    #[error("observability layer worker stopped")]
235    WorkerStopped,
236    /// A requested flush failed; its batch may have been spooled for replay.
237    #[error("observability layer flush failed: {0}")]
238    Flush(String),
239}
240
241impl<S> SubscriberLayer<S> for Layer
242where
243    S: Subscriber,
244{
245    fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
246        let record = event_to_log_record(event);
247        let _ = self.sender.try_send(WorkerMessage::Record(record));
248    }
249}
250
251#[derive(Debug)]
252enum WorkerMessage {
253    Record(LogRecord),
254    Flush(oneshot::Sender<Result<(), LayerError>>),
255}
256
257struct Worker {
258    endpoint: Url,
259    token: SecretString,
260    source: SourceId,
261    batch_size: usize,
262    flush_interval: Duration,
263    flush_timeout: Duration,
264    queue_dir: PathBuf,
265    sample_rate: f64,
266    receiver: mpsc::Receiver<WorkerMessage>,
267    compression: Option<CompressionEncoding>,
268    tls_config: Option<ClientTlsConfig>,
269    channel: OnceCell<Channel>,
270}
271
272impl Worker {
273    async fn run(mut self) {
274        let mut batch = Vec::with_capacity(self.batch_size);
275        let mut interval = tokio::time::interval(self.flush_interval);
276        interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
277        // Tokio intervals tick immediately once. Consume that startup tick so
278        // the first periodic export waits the configured batching interval.
279        interval.tick().await;
280        self.drain_spooled().await;
281
282        loop {
283            tokio::select! {
284                message = self.receiver.recv() => {
285                    let Some(message) = message else {
286                        self.export_or_spool(batch).await;
287                        break;
288                    };
289                    match message {
290                        WorkerMessage::Record(record) => {
291                            if self.should_sample(&record) {
292                                batch.push(record);
293                            }
294                            if batch.len() >= self.batch_size {
295                                self.export_or_spool(std::mem::take(&mut batch)).await;
296                            }
297                        }
298                        WorkerMessage::Flush(reply) => {
299                            let result = self.export_records(std::mem::take(&mut batch)).await;
300                            let _ = reply.send(result);
301                        }
302                    }
303                }
304                _ = interval.tick() => {
305                    self.export_or_spool(std::mem::take(&mut batch)).await;
306                }
307            }
308        }
309    }
310
311    fn should_sample(&self, record: &LogRecord) -> bool {
312        if self.sample_rate >= 1.0 {
313            true
314        } else if self.sample_rate <= 0.0 {
315            false
316        } else {
317            let bucket = record.time_unix_nano % 10_000;
318            (bucket as f64 / 10_000.0) < self.sample_rate
319        }
320    }
321
322    async fn drain_spooled(&self) {
323        let Ok(_lock) = spool::SpoolLock::acquire(&self.queue_dir) else {
324            return;
325        };
326        let Ok(paths) = spool::pending_log_batches(&self.queue_dir) else {
327            return;
328        };
329        for path in paths {
330            let Ok(request) = spool::read_log_batch(&path) else {
331                continue;
332            };
333            if matches!(
334                tokio::time::timeout(self.flush_timeout, self.export_request(request)).await,
335                Ok(Ok(()))
336            ) {
337                let _ = std::fs::remove_file(path);
338            }
339        }
340    }
341
342    async fn export_or_spool(&self, records: Vec<LogRecord>) {
343        if let Err(error) = self.export_records(records).await {
344            // stderr, not tracing: this runs inside the exporter worker, and the
345            // detritus Layer may be installed in the subscriber — reporting export
346            // failures via tracing would feed them straight back into this worker.
347            eprintln!("[observability] failed to export logs: {error}");
348        }
349    }
350
351    async fn export_records(&self, records: Vec<LogRecord>) -> Result<(), LayerError> {
352        if records.is_empty() {
353            return Ok(());
354        }
355        let request = export_request_for(&self.source, records);
356        let error =
357            match tokio::time::timeout(self.flush_timeout, self.export_request(request.clone()))
358                .await
359            {
360                Ok(Ok(())) => return Ok(()),
361                Ok(Err(error)) => error.to_string(),
362                Err(_) => "flush timed out".to_owned(),
363            };
364        spool::write_log_batch(&self.queue_dir, &request)
365            .map_err(|io| LayerError::Flush(io.to_string()))?;
366        Err(LayerError::Flush(error))
367    }
368
369    async fn export_request(&self, request: ExportLogsServiceRequest) -> Result<(), tonic::Status> {
370        // Channel clones share Tonic's connection and reconnect machinery. Cache
371        // only successful initialization so offline startup can recover on replay.
372        // https://docs.rs/tonic/0.14.6/tonic/transport/struct.Channel.html
373        let channel = self
374            .channel
375            .get_or_try_init(|| async {
376                crate::install_default_crypto_provider();
377                let connect = async {
378                    let mut endpoint = Endpoint::from_shared(self.endpoint.to_string())?
379                        .connect_timeout(self.flush_timeout)
380                        .timeout(self.flush_timeout);
381                    if let Some(config) = &self.tls_config {
382                        endpoint = endpoint.tls_config(config.clone())?;
383                    }
384                    endpoint.connect().await
385                };
386                connect.await.map_err(|error| {
387                    tonic::Status::unavailable(format!(
388                        "failed to connect to observability endpoint {}: {error}",
389                        self.endpoint
390                    ))
391                })
392            })
393            .await?;
394        let mut client = LogsServiceClient::new(channel.clone())
395            .accept_compressed(CompressionEncoding::Gzip)
396            .accept_compressed(CompressionEncoding::Zstd);
397        if let Some(encoding) = self.compression {
398            client = client.send_compressed(encoding);
399        }
400        let mut request = tonic::Request::new(request);
401        request.set_timeout(self.flush_timeout);
402        request.metadata_mut().insert(
403            GRPC_VERSION_KEY,
404            MetadataValue::try_from(PROTOCOL_VERSION.to_string()).map_err(|_| {
405                tonic::Status::internal("protocol version is not a valid gRPC metadata value")
406            })?,
407        );
408        // Sensitive metadata is omitted by Debug and marked never-indexed for HTTP/2.
409        let mut authorization = MetadataValue::try_from(format!(
410            "Bearer {}",
411            self.token.expose_secret()
412        ))
413        .map_err(|_| {
414            tonic::Status::internal("authorization bearer token is not a valid HTTP header value")
415        })?;
416        authorization.set_sensitive(true);
417        request
418            .metadata_mut()
419            .insert("authorization", authorization);
420        client.export(request).await?;
421        Ok(())
422    }
423}
424
425fn export_request_for(source: &SourceId, records: Vec<LogRecord>) -> ExportLogsServiceRequest {
426    ExportLogsServiceRequest {
427        resource_logs: vec![ResourceLogs {
428            resource: Some(Resource {
429                attributes: vec![
430                    string_attr("source.project", &source.project),
431                    string_attr("source.platform", &source.platform),
432                    string_attr("source.version", &source.version),
433                    string_attr("source.install_id", &source.install_id.to_string()),
434                ],
435                dropped_attributes_count: 0,
436                entity_refs: Vec::new(),
437            }),
438            scope_logs: vec![ScopeLogs {
439                scope: Some(InstrumentationScope {
440                    name: "detritus-client".to_owned(),
441                    version: env!("CARGO_PKG_VERSION").to_owned(),
442                    attributes: Vec::new(),
443                    dropped_attributes_count: 0,
444                }),
445                log_records: records,
446                schema_url: String::new(),
447            }],
448            schema_url: String::new(),
449        }],
450    }
451}
452
453fn event_to_log_record(event: &Event<'_>) -> LogRecord {
454    let now = unix_nanos(SystemTime::now());
455    let metadata = event.metadata();
456    let mut visitor = EventVisitor::default();
457    event.record(&mut visitor);
458    let severity_text = metadata.level().to_string();
459    let EventVisitor { message, fields } = visitor;
460    let body = message
461        .or_else(|| {
462            fields
463                .iter()
464                .find(|(key, _)| key == "message")
465                .map(|(_, value)| value.clone())
466        })
467        .unwrap_or_else(|| metadata.name().to_owned());
468    // Move the visited (key, value) strings straight into attributes rather than
469    // re-allocating both through string_attr's to_owned, and pre-size for the two
470    // trailing target/name attributes appended below.
471    let mut attributes = Vec::with_capacity(fields.len() + 2);
472    attributes.extend(
473        fields
474            .into_iter()
475            .map(|(key, value)| owned_string_attr(key, value)),
476    );
477    attributes.push(string_attr("target", metadata.target()));
478    attributes.push(string_attr("name", metadata.name()));
479
480    LogRecord {
481        time_unix_nano: now,
482        observed_time_unix_nano: now,
483        severity_number: severity_number(metadata.level()) as i32,
484        severity_text,
485        body: Some(AnyValue {
486            value: Some(any_value::Value::StringValue(body)),
487        }),
488        attributes,
489        dropped_attributes_count: 0,
490        flags: 0,
491        trace_id: Vec::new(),
492        span_id: Vec::new(),
493        event_name: metadata.name().to_owned(),
494    }
495}
496
497fn unix_nanos(time: SystemTime) -> u64 {
498    time.duration_since(UNIX_EPOCH)
499        .unwrap_or_default()
500        .as_nanos()
501        .min(u128::from(u64::MAX)) as u64
502}
503
504fn severity_number(level: &Level) -> SeverityNumber {
505    match *level {
506        Level::ERROR => SeverityNumber::Error,
507        Level::WARN => SeverityNumber::Warn,
508        Level::INFO => SeverityNumber::Info,
509        Level::DEBUG => SeverityNumber::Debug,
510        Level::TRACE => SeverityNumber::Trace,
511    }
512}
513
514fn string_attr(key: &str, value: &str) -> KeyValue {
515    owned_string_attr(key.to_owned(), value.to_owned())
516}
517
518fn owned_string_attr(key: String, value: String) -> KeyValue {
519    KeyValue {
520        key,
521        value: Some(AnyValue {
522            value: Some(any_value::Value::StringValue(value)),
523        }),
524    }
525}
526
527#[derive(Default)]
528struct EventVisitor {
529    message: Option<String>,
530    fields: Vec<(String, String)>,
531}
532
533impl field::Visit for EventVisitor {
534    fn record_debug(&mut self, field: &field::Field, value: &dyn fmt::Debug) {
535        let value = format!("{value:?}");
536        if field.name() == "message" {
537            self.message = Some(value.clone());
538        }
539        self.fields.push((field.name().to_owned(), value));
540    }
541
542    fn record_str(&mut self, field: &field::Field, value: &str) {
543        if field.name() == "message" {
544            self.message = Some(value.to_owned());
545        }
546        self.fields
547            .push((field.name().to_owned(), value.to_owned()));
548    }
549
550    fn record_i64(&mut self, field: &field::Field, value: i64) {
551        self.fields
552            .push((field.name().to_owned(), value.to_string()));
553    }
554
555    fn record_u64(&mut self, field: &field::Field, value: u64) {
556        self.fields
557            .push((field.name().to_owned(), value.to_string()));
558    }
559
560    fn record_bool(&mut self, field: &field::Field, value: bool) {
561        self.fields
562            .push((field.name().to_owned(), value.to_string()));
563    }
564}