55//!
66//! Emits exactly one OTLP `LogRecord` per `AuditRecord`. The exporter is
77//! constructed once at sink init (not per emit). Network I/O happens on the
8- //! SDK's internal batch processor; `emit()` is non-blocking enqueue.
8+ //! SDK's internal batch processor; `emit()` is non-blocking enqueue. The audit
9+ //! worker calls `force_flush()` after draining on shutdown, but abrupt process
10+ //! teardown can still lose buffered OTLP records.
911//!
1012//! Transport follows `OTEL_EXPORTER_OTLP_LOGS_PROTOCOL` with
1113//! `OTEL_EXPORTER_OTLP_PROTOCOL` as fallback. Supported values are
@@ -22,6 +24,7 @@ use axum::http::HeaderValue;
2224use dynamo_runtime:: config:: environment_names:: {
2325 llm:: audit as env_audit, logging:: otlp as env_otlp,
2426} ;
27+ use opentelemetry:: Context ;
2528use opentelemetry:: logs:: { AnyValue , LogRecord , Logger , LoggerProvider , Severity } ;
2629use opentelemetry_otlp:: { Protocol , WithExportConfig } ;
2730use opentelemetry_sdk:: Resource ;
@@ -79,11 +82,8 @@ const AUDIT_INSTRUMENTATION_SCOPE: &str = "dynamo.payload";
7982const DEFAULT_SERVICE_NAME : & str = "dynamo" ;
8083
8184pub struct OtelSink {
82- /// Held so the SDK's batch processor flushes when the sink is dropped on
83- /// audit-bus shutdown. The field is never read directly — its job is to
84- /// keep the provider alive for the sink's lifetime. TODO(phase D): wire
85- /// an explicit `force_flush` hook on the worker cancellation path so
86- /// records aren't lost if the runtime is torn down before Drop runs.
85+ /// Held so the SDK's batch processor stays alive for the sink's lifetime
86+ /// and can be force-flushed when the audit worker shuts down.
8787 #[ allow( dead_code) ]
8888 provider : SdkLoggerProvider ,
8989 logger : SdkLogger ,
@@ -175,6 +175,24 @@ impl OtlpLogsProtocol {
175175 }
176176}
177177
178+ fn logs_endpoint_from_env ( protocol : OtlpLogsProtocol ) -> String {
179+ if let Ok ( endpoint) = std:: env:: var ( env_otlp:: OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ) {
180+ return endpoint;
181+ }
182+
183+ if let Ok ( endpoint) = std:: env:: var ( env_otlp:: OTEL_EXPORTER_OTLP_ENDPOINT ) {
184+ return match protocol {
185+ OtlpLogsProtocol :: HttpProtobuf => {
186+ let trimmed = endpoint. trim_end_matches ( '/' ) ;
187+ format ! ( "{trimmed}/v1/logs" )
188+ }
189+ OtlpLogsProtocol :: Grpc => endpoint,
190+ } ;
191+ }
192+
193+ protocol. default_endpoint ( ) . to_string ( )
194+ }
195+
178196fn render_header_value ( name : & str , value : & HeaderValue , policy : & OtelHeaderPolicy ) -> String {
179197 if policy. should_redact ( name) {
180198 return REDACTED_HEADER_VALUE . to_string ( ) ;
@@ -267,9 +285,7 @@ impl OtelSink {
267285
268286 pub async fn from_policy ( policy : & AuditPolicy ) -> Result < Self > {
269287 let protocol = OtlpLogsProtocol :: from_env ( ) ;
270- let endpoint = std:: env:: var ( env_otlp:: OTEL_EXPORTER_OTLP_LOGS_ENDPOINT )
271- . or_else ( |_| std:: env:: var ( env_otlp:: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ) )
272- . unwrap_or_else ( |_| protocol. default_endpoint ( ) . to_string ( ) ) ;
288+ let endpoint = logs_endpoint_from_env ( protocol) ;
273289
274290 let exporter = match protocol {
275291 OtlpLogsProtocol :: HttpProtobuf => opentelemetry_otlp:: LogExporter :: builder ( )
@@ -468,8 +484,23 @@ impl AuditSink for OtelSink {
468484 record. add_attribute ( "audit_drop_reason" , AnyValue :: String ( reason. into ( ) ) ) ;
469485 }
470486 record. add_attribute ( "payload" , AnyValue :: String ( payload. into ( ) ) ) ;
487+
488+ // Audit OTLP export is an explicit sink, not telemetry generated while
489+ // exporting telemetry. Use a fresh context so a globally suppressed
490+ // tracing bridge cannot cause the direct LogRecord emit to be skipped.
491+ let _guard = Context :: new ( ) . attach ( ) ;
471492 self . logger . emit ( record) ;
472493 }
494+
495+ async fn shutdown ( & self ) {
496+ if let Err ( err) = self . provider . force_flush ( ) {
497+ tracing:: warn!(
498+ target: "dynamo_llm::audit" ,
499+ error = %err,
500+ "audit otel: force_flush failed during shutdown"
501+ ) ;
502+ }
503+ }
473504}
474505
475506#[ cfg( test) ]
@@ -815,4 +846,59 @@ mod tests {
815846 || assert_eq ! ( OtlpLogsProtocol :: from_env( ) , OtlpLogsProtocol :: Grpc ) ,
816847 ) ;
817848 }
849+
850+ #[ test]
851+ #[ serial]
852+ fn logs_endpoint_uses_signal_specific_endpoint_first ( ) {
853+ temp_env:: with_vars (
854+ [
855+ (
856+ env_otlp:: OTEL_EXPORTER_OTLP_LOGS_ENDPOINT ,
857+ Some ( "http://collector:9999/custom/logs" ) ,
858+ ) ,
859+ (
860+ env_otlp:: OTEL_EXPORTER_OTLP_ENDPOINT ,
861+ Some ( "http://collector:4318" ) ,
862+ ) ,
863+ (
864+ env_otlp:: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ,
865+ Some ( "http://collector:4317/v1/traces" ) ,
866+ ) ,
867+ ] ,
868+ || {
869+ assert_eq ! (
870+ logs_endpoint_from_env( OtlpLogsProtocol :: HttpProtobuf ) ,
871+ "http://collector:9999/custom/logs"
872+ ) ;
873+ } ,
874+ ) ;
875+ }
876+
877+ #[ test]
878+ #[ serial]
879+ fn logs_endpoint_falls_back_to_generic_endpoint_not_traces_endpoint ( ) {
880+ temp_env:: with_vars (
881+ [
882+ ( env_otlp:: OTEL_EXPORTER_OTLP_LOGS_ENDPOINT , None :: < & str > ) ,
883+ (
884+ env_otlp:: OTEL_EXPORTER_OTLP_ENDPOINT ,
885+ Some ( "http://collector:4318" ) ,
886+ ) ,
887+ (
888+ env_otlp:: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ,
889+ Some ( "http://collector:4317/v1/traces" ) ,
890+ ) ,
891+ ] ,
892+ || {
893+ assert_eq ! (
894+ logs_endpoint_from_env( OtlpLogsProtocol :: HttpProtobuf ) ,
895+ "http://collector:4318/v1/logs"
896+ ) ;
897+ assert_eq ! (
898+ logs_endpoint_from_env( OtlpLogsProtocol :: Grpc ) ,
899+ "http://collector:4318"
900+ ) ;
901+ } ,
902+ ) ;
903+ }
818904}
0 commit comments