gitdataai/lib/track/otel.rs

431 lines
14 KiB
Rust

use std::collections::HashMap;
use std::io;
use std::time::Duration;
use anyhow::{Context, bail};
use config::AppConfig;
use opentelemetry::KeyValue;
use opentelemetry::trace::TracerProvider;
use opentelemetry_otlp::{WithExportConfig, WithHttpConfig};
use opentelemetry_sdk::{
Resource, logs::SdkLoggerProvider, metrics::SdkMeterProvider,
trace::SdkTracerProvider,
};
use tracing_subscriber::{EnvFilter, Registry, layer::SubscriberExt};
const OTEL_EXPORT_TIMEOUT: Duration = Duration::from_secs(10);
type WorkerGuard = tracing_appender::non_blocking::WorkerGuard;
/// `io::Write` adapter that strips the path prefix down to `gitdataai/` for
/// cleaner log output.
#[derive(Clone)]
struct StripWriter<W: io::Write> {
inner: W,
}
impl<W: io::Write> StripWriter<W> {
fn new(inner: W) -> Self {
Self { inner }
}
}
impl<W: io::Write> io::Write for StripWriter<W> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let s = String::from_utf8_lossy(buf);
if let Some(pos) = s.rfind("gitdataai/") {
let start = pos + "gitdataai/".len();
self.inner.write_all(s[start..].as_bytes())?;
} else {
self.inner.write_all(buf)?;
}
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
fn strip_writer() -> impl for<'a> tracing_subscriber::fmt::MakeWriter<'a> {
|| StripWriter::new(io::stdout())
}
pub struct LgtmGuard {
tracer_provider: SdkTracerProvider,
logger_provider: SdkLoggerProvider,
meter_provider: SdkMeterProvider,
_file_guard: Option<WorkerGuard>,
}
struct OtlpEndpoints {
traces: String,
logs: String,
metrics: String,
}
impl Drop for LgtmGuard {
fn drop(&mut self) {
let mut had_error = false;
if let Err(err) = self.tracer_provider.shutdown() {
had_error = true;
eprintln!("failed to shutdown OTel tracer provider: {err}");
}
if let Err(err) = self.meter_provider.shutdown() {
had_error = true;
eprintln!("failed to shutdown OTel meter provider: {err}");
}
if let Err(err) = self.logger_provider.shutdown() {
had_error = true;
eprintln!("failed to shutdown OTel logger provider: {err}");
}
drop(self._file_guard.take());
if !had_error {
eprintln!("OpenTelemetry providers shut down");
}
}
}
pub fn init_lgtm(config: &AppConfig) -> anyhow::Result<Option<LgtmGuard>> {
let filter = EnvFilter::try_new(config.log_level()?)?;
let log_format = config.log_format()?;
if !config.otel_enabled()? {
let file_guard = init_fmt_subscriber(filter, &log_format, config)?;
if let Some(guard) = file_guard {
static FILE_GUARD: std::sync::OnceLock<WorkerGuard> =
std::sync::OnceLock::new();
let _ = FILE_GUARD.set(guard);
}
return Ok(None);
}
let (endpoint, tracer_provider, logger_provider, meter_provider) =
build_lgtm_guard(config)?;
let guard = LgtmGuard {
tracer_provider: tracer_provider.clone(),
logger_provider: logger_provider.clone(),
meter_provider: meter_provider.clone(),
_file_guard: None,
};
let file_guard =
install_otel_subscriber(filter, &guard, &log_format, config)?;
opentelemetry::global::set_meter_provider(meter_provider.clone());
tracing::info!(endpoint = %endpoint, "LGTM observability initialized");
Ok(Some(LgtmGuard {
tracer_provider,
logger_provider,
meter_provider,
_file_guard: file_guard,
}))
}
fn build_lgtm_guard(
config: &AppConfig,
) -> anyhow::Result<(
String,
SdkTracerProvider,
SdkLoggerProvider,
SdkMeterProvider,
)> {
let endpoint = config.otel_endpoint()?;
let endpoints = build_otlp_endpoints(&endpoint)?;
let headers = build_headers(config)?;
let resource = build_resource(config)?;
let trace_exporter = build_trace_exporter(&endpoints.traces, &headers)?;
let log_exporter = build_log_exporter(&endpoints.logs, &headers)?;
let metric_exporter = build_metric_exporter(&endpoints.metrics, headers)?;
let tracer_provider = SdkTracerProvider::builder()
.with_batch_exporter(trace_exporter)
.with_resource(resource.clone())
.build();
let logger_provider = SdkLoggerProvider::builder()
.with_batch_exporter(log_exporter)
.with_resource(resource.clone())
.build();
let meter_provider = SdkMeterProvider::builder()
.with_periodic_exporter(metric_exporter)
.with_resource(resource)
.build();
Ok((endpoint, tracer_provider, logger_provider, meter_provider))
}
fn install_otel_subscriber(
filter: EnvFilter,
guard: &LgtmGuard,
log_format: &str,
config: &AppConfig,
) -> anyhow::Result<Option<WorkerGuard>> {
let tracer = guard.tracer_provider.tracer(env!("CARGO_PKG_NAME"));
let otel_log_layer =
opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge::new(
&guard.logger_provider,
);
if config.log_file_enabled()? {
let dir = config
.log_file_path()
.unwrap_or_else(|_| "./logs".to_string());
let file_appender =
tracing_appender::rolling::daily(dir, "gitdataai.log");
let (non_blocking, file_guard) =
tracing_appender::non_blocking(file_appender);
let file_writer = {
let nb = non_blocking;
move || StripWriter::new(nb.clone())
};
if log_format.eq_ignore_ascii_case("json") {
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.json()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
)
.with(
tracing_subscriber::fmt::layer()
.json()
.with_ansi(false)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(file_writer),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(file_guard));
}
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
)
.with(
tracing_subscriber::fmt::layer()
.with_ansi(false)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(file_writer),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(file_guard));
}
if log_format.eq_ignore_ascii_case("json") {
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.json()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(None);
}
let subscriber = Registry::default()
.with(filter)
.with(tracing_opentelemetry::layer().with_tracer(tracer))
.with(otel_log_layer)
.with(
tracing_subscriber::fmt::layer()
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer()),
);
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
Ok(None)
}
fn build_trace_exporter(
endpoint: &str,
headers: &HashMap<String, String>,
) -> anyhow::Result<opentelemetry_otlp::SpanExporter> {
opentelemetry_otlp::SpanExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_timeout(OTEL_EXPORT_TIMEOUT)
.with_headers(headers.clone())
.build()
.context("failed to build OTLP trace exporter")
}
fn build_log_exporter(
endpoint: &str,
headers: &HashMap<String, String>,
) -> anyhow::Result<opentelemetry_otlp::LogExporter> {
opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_timeout(OTEL_EXPORT_TIMEOUT)
.with_headers(headers.clone())
.build()
.context("failed to build OTLP log exporter")
}
fn build_metric_exporter(
endpoint: &str,
headers: HashMap<String, String>,
) -> anyhow::Result<opentelemetry_otlp::MetricExporter> {
opentelemetry_otlp::MetricExporter::builder()
.with_http()
.with_endpoint(endpoint)
.with_timeout(OTEL_EXPORT_TIMEOUT)
.with_headers(headers)
.build()
.context("failed to build OTLP metric exporter")
}
fn init_fmt_subscriber(
filter: EnvFilter,
log_format: &str,
config: &AppConfig,
) -> anyhow::Result<Option<WorkerGuard>> {
let file_enabled = config.log_file_enabled()?;
if log_format.eq_ignore_ascii_case("json") {
if file_enabled {
let dir = config
.log_file_path()
.unwrap_or_else(|_| "./logs".to_string());
let file_appender =
tracing_appender::rolling::daily(dir, "gitdataai.log");
let (non_blocking, guard) =
tracing_appender::non_blocking(file_appender);
let subscriber = tracing_subscriber::fmt()
.json()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer({
let nb = non_blocking;
move || StripWriter::new(nb.clone())
})
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(guard));
}
let subscriber = tracing_subscriber::fmt()
.json()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer())
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(None);
}
if file_enabled {
let dir = config
.log_file_path()
.unwrap_or_else(|_| "./logs".to_string());
let file_appender =
tracing_appender::rolling::daily(dir, "gitdataai.log");
let (non_blocking, guard) =
tracing_appender::non_blocking(file_appender);
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer({
let nb = non_blocking;
move || StripWriter::new(nb.clone())
})
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
return Ok(Some(guard));
}
let subscriber = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_target(false)
.with_file(true)
.with_line_number(true)
.with_writer(strip_writer())
.finish();
tracing::subscriber::set_global_default(subscriber)
.context("failed to initialize tracing subscriber")?;
Ok(None)
}
fn build_resource(config: &AppConfig) -> anyhow::Result<Resource> {
Ok(Resource::builder()
.with_service_name(config.otel_service_name()?)
.with_attributes([KeyValue::new(
"service.version",
config.otel_service_version()?,
)])
.build())
}
fn build_headers(
config: &AppConfig,
) -> anyhow::Result<HashMap<String, String>> {
let mut headers = HashMap::new();
if let Some(auth) = config.otel_authorization()? {
headers.insert("Authorization".to_string(), auth);
} else if let Some(token) = config.otel_organization()? {
headers.insert(
"signoz-access-token".to_string(),
format!("ingest:{token}"),
);
}
Ok(headers)
}
fn build_otlp_endpoints(endpoint: &str) -> anyhow::Result<OtlpEndpoints> {
let base = normalize_otlp_base(endpoint)?;
Ok(OtlpEndpoints {
traces: format!("{base}/v1/traces"),
logs: format!("{base}/v1/logs"),
metrics: format!("{base}/v1/metrics"),
})
}
fn normalize_otlp_base(endpoint: &str) -> anyhow::Result<String> {
let endpoint = endpoint.trim().trim_end_matches('/');
if endpoint.is_empty() {
bail!("APP_OTEL_ENDPOINT must not be empty");
}
for suffix in ["/v1/traces", "/v1/logs", "/v1/metrics"] {
if let Some(base) = endpoint.strip_suffix(suffix) {
let base = base.trim_end_matches('/');
if base.is_empty() {
bail!("APP_OTEL_ENDPOINT base URL must not be empty");
}
return Ok(base.to_string());
}
}
Ok(endpoint.to_string())
}