use crate::configuration::{OtlpProtocol, OtlpSettings};
use opentelemetry::logs::{AnyValue, LogRecord, Severity};
use opentelemetry::{InstrumentationScope, KeyValue};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::WithExportConfig;
use opentelemetry_sdk::Resource;
use opentelemetry_sdk::error::OTelSdkResult;
use opentelemetry_sdk::logs::{
BatchLogProcessor, LogBatch, LogExporter, LogProcessor, SdkLogRecord, SdkLoggerProvider,
};
use prometheus::{IntCounter, Registry};
use std::sync::LazyLock;
use std::time::Duration;
use tracing::Subscriber;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{EnvFilter, Layer};
static OTLP_SUPPRESSED_LOG_RECORDS: LazyLock<IntCounter> = LazyLock::new(|| {
IntCounter::new(
"aviso_otlp_suppressed_log_records_total",
"Log records withheld from OTLP export by the redaction guard \
(sensitive attribute key or credentialed URL value). The stdout \
copy of each suppressed record is still emitted with field-level \
redaction. A non-zero rate means OpenSearch is missing records \
that stdout has; check for new call sites logging sensitive \
field names.",
)
.expect("metric definition must be valid")
});
static OTLP_EXPORT_FAILURES: LazyLock<IntCounter> = LazyLock::new(|| {
IntCounter::new(
"aviso_otlp_export_failures_total",
"Failed OTLP log batch export attempts. Each increment is one \
batch (up to 512 records) that the collector did not accept; \
those records are dropped from the push path while stdout stays \
complete. A sustained non-zero rate means the collector endpoint \
is unreachable or rejecting writes.",
)
.expect("metric definition must be valid")
});
pub(crate) fn register_otlp_metrics(registry: &Registry) {
registry
.register(Box::new(OTLP_SUPPRESSED_LOG_RECORDS.clone()))
.expect("metric must register");
registry
.register(Box::new(OTLP_EXPORT_FAILURES.clone()))
.expect("metric must register");
}
#[derive(Debug, thiserror::Error)]
pub enum OtlpInitError {
#[error("logging.otlp.enabled=true requires logging.otlp.endpoint")]
MissingEndpoint,
#[error("failed to build OTLP log exporter for endpoint {endpoint:?}: {source}")]
ExporterBuild {
endpoint: String,
#[source]
source: opentelemetry_otlp::ExporterBuildError,
},
}
pub(super) fn build_layer<S>(
settings: &OtlpSettings,
service_name: &str,
) -> Result<(impl Layer<S> + use<S>, SdkLoggerProvider), OtlpInitError>
where
S: Subscriber + for<'span> LookupSpan<'span>,
{
let endpoint = settings
.endpoint
.as_deref()
.map(str::trim)
.filter(|endpoint| !endpoint.is_empty())
.ok_or(OtlpInitError::MissingEndpoint)?;
let endpoint = normalize_endpoint(endpoint, settings.protocol);
let exporter = match settings.protocol {
OtlpProtocol::Grpc => opentelemetry_otlp::LogExporter::builder()
.with_tonic()
.with_endpoint(&endpoint)
.build(),
OtlpProtocol::Http => opentelemetry_otlp::LogExporter::builder()
.with_http()
.with_endpoint(&endpoint)
.with_protocol(opentelemetry_otlp::Protocol::HttpBinary)
.build(),
}
.map_err(|source| OtlpInitError::ExporterBuild {
endpoint: endpoint.clone(),
source,
})?;
let provider = SdkLoggerProvider::builder()
.with_resource(build_resource(service_name))
.with_log_processor(RedactingLogProcessor::new(
BatchLogProcessor::builder(MeteredLogExporter::new(exporter)).build(),
))
.build();
let bridge = OpenTelemetryTracingBridge::new(&provider);
let layer = bridge.with_filter(export_noise_filter());
Ok((layer, provider))
}
fn normalize_endpoint(raw: &str, protocol: OtlpProtocol) -> String {
let with_scheme = if raw.contains("://") {
raw.to_string()
} else {
format!("http://{raw}")
};
match protocol {
OtlpProtocol::Grpc => with_scheme,
OtlpProtocol::Http => {
let base = with_scheme.trim_end_matches('/');
if base.ends_with("/v1/logs") {
base.to_string()
} else {
format!("{base}/v1/logs")
}
}
}
}
fn build_resource(service_name: &str) -> Resource {
let mut attributes = vec![KeyValue::new(
"service.version",
super::SERVICE_VERSION.to_string(),
)];
if let Ok(namespace) = std::env::var("K8S_NAMESPACE_NAME") {
attributes.push(KeyValue::new("k8s.namespace.name", namespace));
}
if let Some(pod_name) = std::env::var("K8S_POD_NAME")
.ok()
.or_else(|| std::env::var("HOSTNAME").ok())
{
attributes.push(KeyValue::new("k8s.pod.name", pod_name));
}
Resource::builder()
.with_service_name(service_name.to_string())
.with_attributes(attributes)
.build()
}
const EXPORT_NOISE_TARGETS: &[&str] = &[
"opentelemetry",
"opentelemetry_sdk",
"opentelemetry-otlp",
"opentelemetry_otlp",
"opentelemetry-appender-tracing",
"opentelemetry_appender_tracing",
"tonic",
"h2",
"hyper",
"hyper_util",
"tower",
"reqwest",
];
fn export_noise_filter() -> EnvFilter {
let mut filter = EnvFilter::new("trace");
for target in EXPORT_NOISE_TARGETS {
let directive_str = format!("{target}=off");
match directive_str.parse() {
Ok(directive) => filter = filter.add_directive(directive),
Err(error) => {
eprintln!(
"warning: failed to parse OTLP noise directive {directive_str:?} \
({error}); skipping"
);
}
}
}
filter
}
struct RedactingLogProcessor<P> {
inner: P,
}
impl<P> RedactingLogProcessor<P> {
fn new(inner: P) -> Self {
Self { inner }
}
}
impl<P: LogProcessor> LogProcessor for RedactingLogProcessor<P> {
fn emit(&self, record: &mut SdkLogRecord, instrumentation: &InstrumentationScope) {
if record_carries_sensitive_attributes(record) {
OTLP_SUPPRESSED_LOG_RECORDS.inc();
return;
}
redact_body(record);
self.inner.emit(record, instrumentation);
}
fn force_flush(&self) -> OTelSdkResult {
self.inner.force_flush()
}
fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
self.inner.shutdown_with_timeout(timeout)
}
fn event_enabled(&self, level: Severity, target: &str, name: Option<&str>) -> bool {
self.inner.event_enabled(level, target, name)
}
fn set_resource(&mut self, resource: &Resource) {
self.inner.set_resource(resource);
}
}
impl<P> std::fmt::Debug for RedactingLogProcessor<P> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RedactingLogProcessor")
.finish_non_exhaustive()
}
}
#[derive(Debug)]
struct MeteredLogExporter<E> {
inner: E,
}
impl<E> MeteredLogExporter<E> {
fn new(inner: E) -> Self {
Self { inner }
}
}
impl<E: LogExporter> LogExporter for MeteredLogExporter<E> {
async fn export(&self, batch: LogBatch<'_>) -> OTelSdkResult {
let result = self.inner.export(batch).await;
if result.is_err() {
OTLP_EXPORT_FAILURES.inc();
}
result
}
fn shutdown_with_timeout(&self, timeout: Duration) -> OTelSdkResult {
self.inner.shutdown_with_timeout(timeout)
}
fn event_enabled(&self, level: Severity, target: &str, name: Option<&str>) -> bool {
self.inner.event_enabled(level, target, name)
}
fn set_resource(&mut self, resource: &Resource) {
self.inner.set_resource(resource);
}
}
fn record_carries_sensitive_attributes(record: &SdkLogRecord) -> bool {
record.attributes_iter().any(|(key, value)| {
if super::is_sensitive_key(key.as_str()) {
return true;
}
match value {
AnyValue::String(s) => super::redact_url_userinfo(s.as_str()).is_some(),
_ => false,
}
})
}
fn redact_body(record: &mut SdkLogRecord) {
let redacted = match record.body() {
Some(AnyValue::String(s)) => {
let redacted = super::redact_message(s.as_str());
(redacted != s.as_str()).then_some(redacted)
}
_ => None,
};
if let Some(redacted) = redacted {
record.set_body(AnyValue::String(redacted.into()));
}
}
#[cfg(test)]
mod tests {
use super::*;
use opentelemetry::logs::{LogRecord, Logger, LoggerProvider};
use std::sync::{Arc, Mutex};
#[test]
fn normalize_endpoint_adds_scheme_when_missing() {
assert_eq!(
normalize_endpoint("collector:4317", OtlpProtocol::Grpc),
"http://collector:4317"
);
assert_eq!(
normalize_endpoint("http://collector:4317", OtlpProtocol::Grpc),
"http://collector:4317"
);
assert_eq!(
normalize_endpoint("https://collector:4317", OtlpProtocol::Grpc),
"https://collector:4317"
);
}
#[test]
fn normalize_endpoint_appends_logs_path_for_http() {
assert_eq!(
normalize_endpoint("http://collector:4318", OtlpProtocol::Http),
"http://collector:4318/v1/logs"
);
assert_eq!(
normalize_endpoint("http://collector:4318/", OtlpProtocol::Http),
"http://collector:4318/v1/logs"
);
assert_eq!(
normalize_endpoint("http://collector:4318/v1/logs", OtlpProtocol::Http),
"http://collector:4318/v1/logs"
);
assert_eq!(
normalize_endpoint("collector:4318", OtlpProtocol::Http),
"http://collector:4318/v1/logs"
);
}
#[test]
fn export_noise_targets_assemble_to_valid_directives() {
for target in EXPORT_NOISE_TARGETS {
let directive_str = format!("{target}=off");
directive_str
.parse::<tracing_subscriber::filter::Directive>()
.unwrap_or_else(|error| {
panic!("noise directive {directive_str:?} must parse: {error}")
});
}
}
#[test]
fn missing_endpoint_fails_layer_construction() {
let settings = OtlpSettings {
enabled: true,
endpoint: None,
protocol: OtlpProtocol::Grpc,
};
let result = build_layer::<tracing_subscriber::Registry>(&settings, "test");
assert!(matches!(result, Err(OtlpInitError::MissingEndpoint)));
let settings = OtlpSettings {
enabled: true,
endpoint: Some(" ".to_string()),
protocol: OtlpProtocol::Grpc,
};
let result = build_layer::<tracing_subscriber::Registry>(&settings, "test");
assert!(matches!(result, Err(OtlpInitError::MissingEndpoint)));
}
#[derive(Debug, Clone, Default)]
struct CapturingProcessor {
records: Arc<Mutex<Vec<SdkLogRecord>>>,
resources: Arc<Mutex<Vec<Resource>>>,
}
impl LogProcessor for CapturingProcessor {
fn emit(&self, record: &mut SdkLogRecord, _instrumentation: &InstrumentationScope) {
self.records
.lock()
.expect("test lock poisoned")
.push(record.clone());
}
fn force_flush(&self) -> OTelSdkResult {
Ok(())
}
fn shutdown_with_timeout(&self, _timeout: Duration) -> OTelSdkResult {
Ok(())
}
fn set_resource(&mut self, resource: &Resource) {
self.resources
.lock()
.expect("test lock poisoned")
.push(resource.clone());
}
}
fn new_record() -> SdkLogRecord {
SdkLoggerProvider::builder()
.build()
.logger("test")
.create_log_record()
}
fn scope() -> InstrumentationScope {
InstrumentationScope::builder("test").build()
}
fn emitted_through_wrapper(record: &mut SdkLogRecord) -> Vec<SdkLogRecord> {
let capturing = CapturingProcessor::default();
let records = capturing.records.clone();
let wrapper = RedactingLogProcessor::new(capturing);
wrapper.emit(record, &scope());
let captured = records.lock().expect("test lock poisoned");
captured.clone()
}
#[test]
fn benign_record_passes_through_unchanged() {
let mut record = new_record();
record.set_body(AnyValue::String("request handled".into()));
record.add_attribute("event.name", "api.request.succeeded");
let forwarded = emitted_through_wrapper(&mut record);
assert_eq!(forwarded.len(), 1);
assert_eq!(
forwarded[0].body(),
Some(&AnyValue::String("request handled".into()))
);
}
#[test]
fn sensitive_attribute_key_suppresses_record_and_counts_it() {
let suppressed_before = OTLP_SUPPRESSED_LOG_RECORDS.get();
let mut record = new_record();
record.set_body(AnyValue::String("auth check".into()));
record.add_attribute("api_key", "super-secret");
let forwarded = emitted_through_wrapper(&mut record);
assert!(
forwarded.is_empty(),
"records with sensitive attribute keys must not be exported"
);
assert!(
OTLP_SUPPRESSED_LOG_RECORDS.get() > suppressed_before,
"suppression must increment the counter"
);
}
#[test]
fn url_userinfo_attribute_value_suppresses_record() {
let mut record = new_record();
record.set_body(AnyValue::String("backend connected".into()));
record.add_attribute("nats_url", "nats://user:pass@nats:4222");
let forwarded = emitted_through_wrapper(&mut record);
assert!(
forwarded.is_empty(),
"records with credentialed URLs in attributes must not be exported"
);
}
#[test]
fn credential_free_url_attribute_is_not_suppressed() {
let mut record = new_record();
record.set_body(AnyValue::String("backend connected".into()));
record.add_attribute("nats_url", "nats://nats:4222");
let forwarded = emitted_through_wrapper(&mut record);
assert_eq!(forwarded.len(), 1);
}
#[test]
fn body_is_pattern_redacted_before_export() {
let mut record = new_record();
record.set_body(AnyValue::String("login with password=hunter2 done".into()));
let forwarded = emitted_through_wrapper(&mut record);
assert_eq!(forwarded.len(), 1);
assert_eq!(
forwarded[0].body(),
Some(&AnyValue::String(
"login with password=[REDACTED] done".into()
))
);
}
#[test]
fn set_resource_is_forwarded_to_inner_processor() {
let capturing = CapturingProcessor::default();
let resources = capturing.resources.clone();
let mut wrapper = RedactingLogProcessor::new(capturing);
let resource = Resource::builder().with_service_name("svc").build();
wrapper.set_resource(&resource);
assert_eq!(resources.lock().expect("test lock poisoned").len(), 1);
}
#[derive(Debug)]
struct ScriptedExporter {
fail: bool,
}
impl LogExporter for ScriptedExporter {
async fn export(&self, _batch: LogBatch<'_>) -> OTelSdkResult {
if self.fail {
Err(opentelemetry_sdk::error::OTelSdkError::InternalFailure(
"scripted failure".to_string(),
))
} else {
Ok(())
}
}
}
fn export_once(exporter: &MeteredLogExporter<ScriptedExporter>) -> OTelSdkResult {
let record = new_record();
let scope = scope();
let data = [(&record, &scope)];
futures::executor::block_on(exporter.export(LogBatch::new(&data)))
}
#[test]
fn metered_exporter_counts_failed_exports_only() {
let failures_before = OTLP_EXPORT_FAILURES.get();
let failing = MeteredLogExporter::new(ScriptedExporter { fail: true });
assert!(export_once(&failing).is_err());
assert_eq!(
OTLP_EXPORT_FAILURES.get(),
failures_before + 1,
"failed export must increment the counter"
);
let succeeding = MeteredLogExporter::new(ScriptedExporter { fail: false });
assert!(export_once(&succeeding).is_ok());
assert_eq!(
OTLP_EXPORT_FAILURES.get(),
failures_before + 1,
"successful export must not increment the counter"
);
}
#[test]
fn register_otlp_metrics_exposes_both_counters() {
let registry = Registry::new();
register_otlp_metrics(®istry);
use prometheus::Encoder;
let mut buf = Vec::new();
prometheus::TextEncoder::new()
.encode(®istry.gather(), &mut buf)
.expect("encode ok");
let output = String::from_utf8(buf).expect("valid utf8");
assert!(output.contains("aviso_otlp_suppressed_log_records_total"));
assert!(output.contains("aviso_otlp_export_failures_total"));
}
}