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#[derive(Debug, Clone)]
42pub struct Layer {
43 sender: mpsc::Sender<WorkerMessage>,
44}
45
46impl Layer {
47 #[must_use]
49 pub fn builder() -> LayerBuilder {
50 LayerBuilder::default()
51 }
52
53 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#[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 #[must_use]
106 pub fn endpoint(mut self, endpoint: Url) -> Self {
107 self.endpoint = Some(endpoint);
108 self
109 }
110
111 #[must_use]
113 pub fn token(mut self, token: SecretString) -> Self {
114 self.token = Some(token);
115 self
116 }
117
118 #[must_use]
120 pub fn source(mut self, source: SourceId) -> Self {
121 self.source = Some(source);
122 self
123 }
124
125 #[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 #[must_use]
134 pub fn flush_interval(mut self, flush_interval: Duration) -> Self {
135 self.flush_interval = flush_interval;
136 self
137 }
138
139 #[must_use]
141 pub fn flush_timeout(mut self, flush_timeout: Duration) -> Self {
142 self.flush_timeout = flush_timeout;
143 self
144 }
145
146 #[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 #[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 #[must_use]
164 pub fn compression(mut self, encoding: CompressionEncoding) -> Self {
165 self.compression = Some(encoding);
166 self
167 }
168
169 #[must_use]
174 pub fn tls_config(mut self, config: ClientTlsConfig) -> Self {
175 self.tls_config = Some(config);
176 self
177 }
178
179 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 tokio::spawn(worker.run());
213 Ok(Layer { sender })
214 }
215}
216
217#[derive(Debug, thiserror::Error)]
219#[non_exhaustive]
220pub enum LayerError {
221 #[error("observability layer endpoint is required")]
223 MissingEndpoint,
224 #[error("observability layer token is required")]
226 MissingToken,
227 #[error("observability layer source is required")]
229 MissingSource,
230 #[error("observability layer queue_dir is required")]
232 MissingQueueDir,
233 #[error("observability layer worker stopped")]
235 WorkerStopped,
236 #[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 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 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 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 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 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}