1use std::borrow::Cow;
22use std::collections::{HashMap, HashSet};
23use std::future::Future;
24use std::net::IpAddr;
25use std::panic::{AssertUnwindSafe, catch_unwind};
26use std::path::{Component, Path, PathBuf};
27use std::pin::Pin;
28#[cfg(feature = "object-store")]
29use std::sync::atomic::{AtomicU8, Ordering};
30use std::sync::{Arc, Mutex};
31use std::time::Duration;
32
33use serde::{Deserialize, Serialize};
34use serde_json::{Map, Value as Json};
35use uuid::Uuid;
36
37use crate::api::event::{Event, ScopeCategory};
38use crate::api::runtime::{EventSubscriberFn, current_scope_stack, global_context};
39use crate::api::scope::ScopeType;
40use crate::api::subscriber::{
41 flush_subscribers, scope_deregister_subscriber, try_scope_deregister_subscriber,
42 try_scope_register_subscriber,
43};
44use crate::config_editor::{
45 EditorConfig, EditorFieldKind, EditorFieldSpec, EditorListItemSpec, EditorSchema,
46 EditorTaggedUnionSpec, EditorVariantSpec,
47};
48use crate::error::FlowError;
49use crate::observability::atif::{AtifAgentInfo, AtifExporter};
50use crate::observability::atof::{
51 AtofEndpointFieldNamePolicy, AtofEndpointTransport, AtofExporter,
52 AtofExporterConfig as CoreAtofExporterConfig, AtofExporterMode, AtofFileSinkConfig,
53 AtofSinkConfig as CoreAtofSinkConfig, AtofStreamSinkConfig,
54};
55use crate::observability::otel::{
56 OpenTelemetryConfig as CoreOpenTelemetryConfig, OpenTelemetrySubscriber, OtlpTransport,
57 resolve_http_trace_endpoint,
58};
59use crate::observability::{
60 MarkProjection, OpenTelemetryType, OtlpAttributeMapping, default_mark_exclude_names,
61 validate_attribute_mappings,
62};
63use crate::plugin::{
64 ATIF_RUNTIME_DELIVERY_FAILURE_MARKER, ConfigDiagnostic, ConfigPolicy, DiagnosticLevel,
65 OTEL_RUNTIME_DELIVERY_FAILURE_MARKER, Plugin, PluginComponentSpec, PluginError,
66 PluginRegistration, PluginRegistrationCleanupOutcome, PluginRegistrationContext,
67 Result as PluginResult, UnsupportedBehavior, apply_global_config_policy, deregister_plugin,
68 register_builtin_plugin,
69};
70use crate::plugin::{RuntimeDiagnostic, record_active_plugin_runtime_diagnostic};
71
72pub const OBSERVABILITY_PLUGIN_KIND: &str = "observability";
74#[derive(Debug, Clone)]
80pub struct ComponentSpec {
81 pub enabled: bool,
83 pub config: ObservabilityConfig,
85}
86
87impl ComponentSpec {
88 pub fn new(config: ObservabilityConfig) -> Self {
93 Self {
94 enabled: true,
95 config,
96 }
97 }
98}
99
100impl From<ComponentSpec> for PluginComponentSpec {
101 fn from(value: ComponentSpec) -> Self {
102 let Json::Object(config) = serde_json::to_value(value.config)
103 .expect("observability config should serialize to object")
104 else {
105 unreachable!("observability config must serialize to object");
106 };
107
108 PluginComponentSpec {
109 kind: OBSERVABILITY_PLUGIN_KIND.to_string(),
110 enabled: value.enabled,
111 config,
112 }
113 }
114}
115
116#[derive(Debug, Clone, Serialize, Deserialize)]
122#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
123pub struct ObservabilityConfig {
124 #[serde(default = "default_observability_config_version")]
126 pub version: u32,
127 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub atof: Option<AtofSectionConfig>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub atif: Option<AtifSectionConfig>,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub opentelemetry: Option<OpenTelemetrySectionConfig>,
136 #[serde(default)]
138 pub policy: ConfigPolicy,
139 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
141 pub enable_full_payloads: bool,
142}
143
144impl Default for ObservabilityConfig {
145 fn default() -> Self {
146 Self {
147 version: default_observability_config_version(),
148 atof: None,
149 atif: None,
150 opentelemetry: None,
151 policy: ConfigPolicy::default(),
152 enable_full_payloads: false,
153 }
154 }
155}
156
157#[derive(Debug, Clone, Default, Serialize, Deserialize)]
159#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
160pub struct OpenTelemetrySectionConfig {
161 #[serde(default)]
163 pub enabled: bool,
164 #[serde(default, skip_serializing_if = "Vec::is_empty")]
166 pub endpoints: Vec<OpenTelemetryEndpointConfig>,
167}
168
169#[derive(Debug, Clone, Serialize, Deserialize)]
171#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
172pub struct OpenTelemetryEndpointConfig {
173 #[serde(rename = "type")]
175 pub otel_type: OpenTelemetryType,
176 pub endpoint: String,
178 #[serde(default)]
180 #[cfg_attr(feature = "schema", schemars(schema_with = "mark_projection_schema"))]
181 pub mark_projection: MarkProjection,
182 #[serde(default = "default_mark_exclude_names")]
184 pub mark_exclude_names: Vec<String>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub attribute_mappings: Vec<OtlpAttributeMapping>,
188 #[serde(default = "default_otlp_transport")]
190 #[cfg_attr(feature = "schema", schemars(schema_with = "otlp_transport_schema"))]
191 pub transport: String,
192 #[serde(default)]
194 pub headers: HashMap<String, String>,
195 #[serde(default)]
197 pub header_env: HashMap<String, String>,
198 #[serde(default)]
200 pub resource_attributes: HashMap<String, String>,
201 #[serde(default = "default_otel_service_name")]
203 pub service_name: String,
204 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub service_namespace: Option<String>,
207 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub service_version: Option<String>,
210 #[serde(default = "default_otel_instrumentation_scope")]
212 pub instrumentation_scope: String,
213 #[serde(default = "default_timeout_millis")]
215 pub timeout_millis: u64,
216}
217
218#[derive(Debug, Clone, Default, Serialize, Deserialize)]
224#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
225pub struct AtofSectionConfig {
226 #[serde(default)]
228 pub enabled: bool,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub sinks: Vec<AtofSinkSectionConfig>,
232}
233
234#[derive(Debug, Clone, Serialize, Deserialize)]
236#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
237#[serde(tag = "type", rename_all = "snake_case")]
238pub enum AtofSinkSectionConfig {
239 File(AtofFileSinkSectionConfig),
241 Stream(AtofStreamSinkSectionConfig),
243}
244
245#[derive(Debug, Clone, Serialize, Deserialize)]
247#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
248pub struct AtofFileSinkSectionConfig {
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub output_directory: Option<PathBuf>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub filename: Option<String>,
255 #[serde(default = "default_atof_mode")]
257 #[cfg_attr(feature = "schema", schemars(schema_with = "atof_mode_schema"))]
258 pub mode: String,
259}
260
261#[derive(Debug, Clone, Serialize, Deserialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264pub struct AtofStreamSinkSectionConfig {
265 pub url: String,
267 #[serde(default = "default_atof_endpoint_transport")]
269 #[cfg_attr(
270 feature = "schema",
271 schemars(schema_with = "atof_endpoint_transport_schema")
272 )]
273 pub transport: String,
274 #[serde(default)]
276 pub headers: HashMap<String, String>,
277 #[serde(default)]
279 pub header_env: HashMap<String, String>,
280 #[serde(default = "default_timeout_millis")]
282 pub timeout_millis: u64,
283 #[serde(default = "default_atof_endpoint_field_name_policy")]
285 pub field_name_policy: String,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub name: Option<String>,
289}
290
291#[derive(Debug, Clone, Serialize, Deserialize)]
298#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
299pub struct AtifSectionConfig {
300 #[serde(default)]
302 pub enabled: bool,
303 #[serde(default = "default_agent_name")]
305 pub agent_name: String,
306 #[serde(default = "default_agent_version")]
308 pub agent_version: String,
309 #[serde(default = "default_model_name")]
311 pub model_name: String,
312 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub tool_definitions: Option<Vec<Json>>,
315 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub extra: Option<Json>,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub output_directory: Option<PathBuf>,
323 #[serde(default = "default_atif_filename_template")]
330 pub filename_template: String,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
340 pub storage: Vec<AtifStorageConfig>,
341}
342
343impl Default for AtifSectionConfig {
344 fn default() -> Self {
345 Self {
346 enabled: false,
347 agent_name: default_agent_name(),
348 agent_version: default_agent_version(),
349 model_name: default_model_name(),
350 tool_definitions: None,
351 extra: None,
352 output_directory: None,
353 filename_template: default_atif_filename_template(),
354 storage: Vec::new(),
355 }
356 }
357}
358
359#[derive(Debug, Clone, Serialize, Deserialize)]
367#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
368#[serde(tag = "type", rename_all = "snake_case")]
369pub enum AtifStorageConfig {
370 Http(HttpStorageConfig),
372 S3(S3StorageConfig),
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
395#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
396pub struct S3StorageConfig {
397 pub bucket: String,
399 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub key_prefix: Option<String>,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
405 pub access_key_id: Option<String>,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub secret_access_key_var: Option<String>,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub session_token_var: Option<String>,
416 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub region: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub endpoint_url: Option<String>,
423 #[serde(default, skip_serializing_if = "Option::is_none")]
425 pub allow_http: Option<bool>,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
435#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
436pub struct HttpStorageConfig {
437 pub endpoint: String,
439 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
441 pub headers: HashMap<String, String>,
442 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
444 pub header_env: HashMap<String, String>,
445 #[serde(default = "default_timeout_millis")]
447 pub timeout_millis: u64,
448}
449
450crate::editor_config! {
451 impl ObservabilityConfig {
452 atof => {
453 label: "ATOF",
454 kind: Section,
455 optional: true,
456 nested: AtofSectionConfig,
457 default: AtofSectionConfig,
458 },
459 atif => {
460 label: "ATIF",
461 kind: Section,
462 optional: true,
463 nested: AtifSectionConfig,
464 default: AtifSectionConfig,
465 },
466 opentelemetry => {
467 label: "OpenTelemetry",
468 kind: Section,
469 optional: true,
470 nested: OpenTelemetrySectionConfig,
471 default: OpenTelemetrySectionConfig,
472 },
473 policy => {
474 label: "policy",
475 kind: Section,
476 nested: ConfigPolicy,
477 default: ConfigPolicy,
478 },
479 enable_full_payloads => { label: "enable_full_payloads", kind: Boolean },
480 }
481}
482
483crate::editor_config! {
484 impl OpenTelemetrySectionConfig {
485 enabled => { label: "enabled", kind: Boolean },
486 endpoints => { label: "endpoints", kind: List, list: &OPENTELEMETRY_ENDPOINT_LIST },
487 }
488}
489
490const fn otel_editor_field(
491 name: &'static str,
492 kind: EditorFieldKind,
493 enum_values: &'static [&'static str],
494 optional: bool,
495) -> EditorFieldSpec {
496 EditorFieldSpec {
497 name,
498 label: name,
499 kind,
500 enum_values,
501 optional,
502 nested_schema: None,
503 nested_default: None,
504 list_item: None,
505 tagged_union: None,
506 }
507}
508
509impl EditorConfig for OpenTelemetryEndpointConfig {
510 fn editor_schema() -> &'static EditorSchema {
511 static SCHEMA: EditorSchema = EditorSchema {
512 fields: &[
513 otel_editor_field(
514 "type",
515 EditorFieldKind::Enum,
516 &["full", "gen_ai", "openinference"],
517 false,
518 ),
519 otel_editor_field("endpoint", EditorFieldKind::String, &[], false),
520 otel_editor_field(
521 "mark_projection",
522 EditorFieldKind::Enum,
523 &["inherit", "event", "tool"],
524 false,
525 ),
526 otel_editor_field("mark_exclude_names", EditorFieldKind::Json, &[], false),
527 otel_editor_field("attribute_mappings", EditorFieldKind::List, &[], false),
528 otel_editor_field(
529 "transport",
530 EditorFieldKind::Enum,
531 &["http_binary", "grpc"],
532 false,
533 ),
534 otel_editor_field("service_name", EditorFieldKind::String, &[], false),
535 otel_editor_field("service_namespace", EditorFieldKind::String, &[], true),
536 otel_editor_field("service_version", EditorFieldKind::String, &[], true),
537 otel_editor_field("instrumentation_scope", EditorFieldKind::String, &[], false),
538 otel_editor_field("timeout_millis", EditorFieldKind::Integer, &[], false),
539 otel_editor_field("headers", EditorFieldKind::StringMap, &[], false),
540 otel_editor_field("header_env", EditorFieldKind::StringMap, &[], false),
541 otel_editor_field(
542 "resource_attributes",
543 EditorFieldKind::StringMap,
544 &[],
545 false,
546 ),
547 ],
548 };
549 &SCHEMA
550 }
551}
552
553fn default_opentelemetry_endpoint_editor_value() -> Json {
554 serde_json::json!({
555 "type": "full",
556 "endpoint": "",
557 "transport": "http_binary",
558 "service_name": "unknown_service",
559 "instrumentation_scope": "opentelemetry",
560 "timeout_millis": 3000,
561 "headers": {},
562 "header_env": {},
563 "resource_attributes": {},
564 })
565}
566
567static OPENTELEMETRY_ENDPOINT_LIST: EditorListItemSpec = EditorListItemSpec {
568 kind: EditorFieldKind::Section,
569 schema: Some(<OpenTelemetryEndpointConfig as EditorConfig>::editor_schema),
570 default: Some(default_opentelemetry_endpoint_editor_value),
571 tagged_union: None,
572 list_item: None,
573};
574
575crate::editor_config! {
576 impl AtofSectionConfig {
577 enabled => { label: "enabled", kind: Boolean },
578 sinks => { label: "sinks", kind: List, list: &ATOF_SINK_LIST },
579 }
580}
581
582crate::editor_config! {
583 impl AtofFileSinkSectionConfig {
584 output_directory => { label: "output_directory", kind: String, optional: true },
585 filename => { label: "filename", kind: String, optional: true },
586 mode => { label: "mode", kind: Enum, values: ["append", "overwrite"] },
587 }
588}
589
590crate::editor_config! {
591 impl AtofStreamSinkSectionConfig {
592 url => { label: "url", kind: String },
593 transport => { label: "transport", kind: Enum, values: ["http_post", "websocket", "ndjson"] },
594 headers => { label: "headers", kind: StringMap },
595 header_env => { label: "header_env", kind: StringMap },
596 timeout_millis => { label: "timeout_millis", kind: Integer },
597 field_name_policy => { label: "field_name_policy", kind: Enum, values: ["preserve", "replace_dots"] },
598 name => { label: "name", kind: String, optional: true },
599 }
600}
601
602crate::editor_config! {
603 impl AtifSectionConfig {
604 enabled => { label: "enabled", kind: Boolean },
605 agent_name => { label: "agent_name", kind: String },
606 agent_version => { label: "agent_version", kind: String },
607 model_name => { label: "model_name", kind: String },
608 tool_definitions => { label: "tool_definitions", kind: Json, optional: true },
609 extra => { label: "extra", kind: Json, optional: true },
610 output_directory => { label: "output_directory", kind: String, optional: true },
611 filename_template => { label: "filename_template", kind: String },
612 storage => { label: "storage", kind: Json, optional: true },
613 }
614}
615
616fn default_atof_file_sink_editor_value() -> Json {
617 serde_json::json!({"type": "file", "mode": "append"})
618}
619
620fn default_atof_stream_sink_editor_value() -> Json {
621 serde_json::json!({
622 "type": "stream",
623 "url": "",
624 "transport": "http_post",
625 "headers": {},
626 "header_env": {},
627 "timeout_millis": 3000,
628 "field_name_policy": "preserve",
629 })
630}
631
632static ATOF_SINK_VARIANTS: [EditorVariantSpec; 2] = [
633 EditorVariantSpec {
634 label: "File",
635 tag: "file",
636 schema: <AtofFileSinkSectionConfig as EditorConfig>::editor_schema,
637 default: default_atof_file_sink_editor_value,
638 },
639 EditorVariantSpec {
640 label: "Stream",
641 tag: "stream",
642 schema: <AtofStreamSinkSectionConfig as EditorConfig>::editor_schema,
643 default: default_atof_stream_sink_editor_value,
644 },
645];
646
647static ATOF_SINK_TAGGED_UNION: EditorTaggedUnionSpec = EditorTaggedUnionSpec {
648 discriminator: "type",
649 variants: &ATOF_SINK_VARIANTS,
650};
651
652static ATOF_SINK_LIST: EditorListItemSpec = EditorListItemSpec {
653 kind: EditorFieldKind::Section,
654 schema: None,
655 default: None,
656 tagged_union: Some(&ATOF_SINK_TAGGED_UNION),
657 list_item: None,
658};
659
660struct ObservabilityPlugin;
661
662impl Plugin for ObservabilityPlugin {
663 fn plugin_kind(&self) -> &str {
664 OBSERVABILITY_PLUGIN_KIND
665 }
666
667 fn allows_multiple_components(&self) -> bool {
668 false
669 }
670
671 fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
672 validate_observability_plugin_config(plugin_config)
673 }
674
675 fn validate_with_policy(
676 &self,
677 plugin_config: &Map<String, Json>,
678 policy: &ConfigPolicy,
679 ) -> Vec<ConfigDiagnostic> {
680 validate_observability_plugin_config_with_policy(plugin_config, Some(policy))
681 }
682
683 fn register<'a>(
684 &'a self,
685 plugin_config: &Map<String, Json>,
686 ctx: &'a mut PluginRegistrationContext,
687 ) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
688 let plugin_config = plugin_config.clone();
689 Box::pin(async move {
690 let config = parse_observability_config(&plugin_config)?;
691 register_observability(config, ctx)
692 })
693 }
694}
695
696pub fn register_observability_component() -> PluginResult<()> {
702 register_builtin_plugin(Arc::new(ObservabilityPlugin))
703}
704
705pub fn deregister_observability_component() -> bool {
711 deregister_plugin(OBSERVABILITY_PLUGIN_KIND)
712}
713
714#[cfg(feature = "schema")]
716pub fn observability_config_schema() -> serde_json::Value {
717 serde_json::to_value(schemars::schema_for!(ObservabilityConfig))
718 .expect("observability config schema should serialize")
719}
720
721#[cfg(feature = "schema")]
722fn atof_mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
723 string_enum_schema(generator, &["append", "overwrite"], Some("append"))
724}
725
726#[cfg(feature = "schema")]
727fn atof_endpoint_transport_schema(
728 generator: &mut schemars::r#gen::SchemaGenerator,
729) -> schemars::schema::Schema {
730 string_enum_schema(
731 generator,
732 &["http_post", "websocket", "ndjson"],
733 Some("http_post"),
734 )
735}
736
737#[cfg(feature = "schema")]
738fn otlp_transport_schema(
739 generator: &mut schemars::r#gen::SchemaGenerator,
740) -> schemars::schema::Schema {
741 string_enum_schema(generator, &["http_binary", "grpc"], Some("http_binary"))
742}
743
744#[cfg(feature = "schema")]
745fn mark_projection_schema(
746 generator: &mut schemars::r#gen::SchemaGenerator,
747) -> schemars::schema::Schema {
748 string_enum_schema(generator, &["inherit", "event", "tool"], Some("inherit"))
749}
750
751#[cfg(feature = "schema")]
752fn string_enum_schema(
753 generator: &mut schemars::r#gen::SchemaGenerator,
754 values: &[&str],
755 default: Option<&str>,
756) -> schemars::schema::Schema {
757 let mut schema: schemars::schema::SchemaObject =
758 <String as schemars::JsonSchema>::json_schema(generator).into();
759 schema.enum_values = Some(
760 values
761 .iter()
762 .map(|value| Json::String((*value).into()))
763 .collect(),
764 );
765 if let Some(default) = default {
766 schema.metadata().default = Some(Json::String(default.into()));
767 }
768 schema.into()
769}
770
771fn register_observability(
772 config: ObservabilityConfig,
773 ctx: &mut PluginRegistrationContext,
774) -> PluginResult<()> {
775 register_full_payload_policy(config.enable_full_payloads, ctx)?;
776 if let Some(atof) = config.atof.filter(|section| section.enabled) {
777 register_atof_exporter(atof, ctx)?;
778 }
779 if let Some(atif) = config.atif.filter(|section| section.enabled) {
780 register_atif_dispatcher(atif, ctx)?;
781 }
782 if let Some(otel) = config.opentelemetry.filter(|section| section.enabled) {
783 register_opentelemetry(otel, ctx)?;
784 }
785 Ok(())
786}
787
788fn register_full_payload_policy(
789 enabled: bool,
790 ctx: &mut PluginRegistrationContext,
791) -> PluginResult<()> {
792 let context = global_context();
793 context
794 .write()
795 .map_err(|error| PluginError::RegistrationFailed(error.to_string()))?
796 .observability_full_payloads_enabled = enabled;
797 ctx.add_registration(PluginRegistration::new(
798 "observability",
799 ctx.qualify_name("payload-policy"),
800 Box::new(|| {
801 global_context()
802 .write()
803 .map_err(|error| PluginError::RegistrationFailed(error.to_string()))?
804 .observability_full_payloads_enabled = false;
805 Ok(())
806 }),
807 ));
808 Ok(())
809}
810
811fn register_atof_exporter(
812 section: AtofSectionConfig,
813 ctx: &mut PluginRegistrationContext,
814) -> PluginResult<()> {
815 let exporters = section
816 .sinks
817 .into_iter()
818 .enumerate()
819 .map(|(index, sink)| {
820 let config = CoreAtofExporterConfig {
821 sink: build_atof_sink_config(index, sink)?,
822 };
823 AtofExporter::new(config)
824 .map(Arc::new)
825 .map_err(observability_registration_error)
826 })
827 .collect::<PluginResult<Vec<_>>>()?;
828 let subscribers = exporters
829 .iter()
830 .map(|exporter| exporter.subscriber())
831 .collect::<Vec<_>>();
832 let subscriber: EventSubscriberFn = Arc::new(move |event| {
833 for subscriber in &subscribers {
834 subscriber(event);
835 }
836 });
837
838 ctx.register_subscriber("atof", subscriber)?;
839 ctx.add_registration(PluginRegistration::new(
840 "observability",
841 ctx.qualify_name("atof.shutdown"),
842 Box::new(move || {
843 let mut first_error = None;
844 for exporter in &exporters {
845 if let Err(error) = exporter.shutdown() {
846 first_error.get_or_insert_with(|| observability_registration_error(error));
847 }
848 }
849 first_error.map_or(Ok(()), Err)
850 }),
851 ));
852 Ok(())
853}
854
855fn build_atof_sink_config(
856 index: usize,
857 sink: AtofSinkSectionConfig,
858) -> PluginResult<CoreAtofSinkConfig> {
859 match sink {
860 AtofSinkSectionConfig::File(file) => {
861 let mode = AtofExporterMode::parse(&file.mode).ok_or_else(|| {
862 PluginError::InvalidConfig(format!(
863 "ATOF sinks[{index}].mode must be 'append' or 'overwrite'"
864 ))
865 })?;
866 let mut sink = AtofFileSinkConfig::new();
867 sink.mode = mode;
868 if let Some(output_directory) = file.output_directory {
869 sink.output_directory = output_directory;
870 }
871 if let Some(filename) = file.filename {
872 sink.filename = filename;
873 }
874 Ok(CoreAtofSinkConfig::File(sink))
875 }
876 AtofSinkSectionConfig::Stream(stream) => {
877 let transport = AtofEndpointTransport::parse(&stream.transport).ok_or_else(|| {
878 PluginError::InvalidConfig(format!(
879 "ATOF sinks[{index}].transport must be 'http_post', 'websocket', or 'ndjson'"
880 ))
881 })?;
882 let field_name_policy = AtofEndpointFieldNamePolicy::parse(&stream.field_name_policy)
883 .ok_or_else(|| {
884 PluginError::InvalidConfig(format!(
885 "ATOF sinks[{index}].field_name_policy must be 'preserve' or 'replace_dots'"
886 ))
887 })?;
888 let mut config = AtofStreamSinkConfig::new(stream.url, transport)
889 .with_timeout_millis(stream.timeout_millis)
890 .with_field_name_policy(field_name_policy);
891 for (key, value) in stream.headers {
892 config = config.with_header(key, value);
893 }
894 for (key, variable) in stream.header_env {
895 config = config.with_header_env(key, variable);
896 }
897 Ok(CoreAtofSinkConfig::Stream(config))
898 }
899 }
900}
901
902type AtifStorageList = Arc<Vec<Arc<AtifRemoteStorage>>>;
903
904fn register_atif_dispatcher(
905 section: AtifSectionConfig,
906 ctx: &mut PluginRegistrationContext,
907) -> PluginResult<()> {
908 validate_atif_filename_template(§ion.filename_template)
909 .map_err(PluginError::InvalidConfig)?;
910
911 let mut storage_vec = Vec::with_capacity(section.storage.len());
912 for (index, entry) in section.storage.iter().enumerate() {
913 storage_vec.push(build_atif_storage(index, entry)?);
914 }
915 let storage: AtifStorageList = Arc::new(storage_vec);
916
917 let manager = Arc::new(Mutex::new(AtifDispatcher::new(section)));
918 let dispatcher = atif_dispatcher_subscriber(
919 Arc::clone(&manager),
920 ctx.qualify_name("atif-"),
921 Arc::clone(&storage),
922 );
923 ctx.register_subscriber("atif", dispatcher)?;
924 let shutdown_storage = Arc::clone(&storage);
925 ctx.add_registration(PluginRegistration::new_with_outcome(
926 "observability",
927 ctx.qualify_name("atif.shutdown"),
928 Box::new(move || {
929 let work = match (|| -> PluginResult<_> {
930 let work = {
931 let mut guard = manager.lock().map_err(|err| {
932 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
933 })?;
934 guard.flush_open_agents()
935 };
936 for (scope_uuid, name) in &work.scope_subscribers {
937 deregister_atif_shutdown_subscriber(scope_uuid, name)?;
938 }
939 Ok(work)
940 })() {
941 Ok(work) => work,
942 Err(error) => return PluginRegistrationCleanupOutcome::NotRemoved(error),
943 };
944
945 let delivery = (|| -> PluginResult<()> {
946 for export in work.exports {
947 let write = prepare_atif_shutdown_file(&export, Arc::clone(&manager))
948 .map_err(observability_registration_error)?;
949 let agent_uuid = write.agent_uuid;
950 let targets = {
951 let guard = manager.lock().map_err(|err| {
952 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
953 })?;
954 guard.sink_targets()
955 };
956 let results = write_atif(&write, shutdown_storage.as_slice(), &targets);
957 let mut guard = manager.lock().map_err(|err| {
958 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
959 })?;
960 let _ = guard.complete_scope_write(agent_uuid, results);
961 }
962 Ok(())
963 })();
964 if let Err(error) = delivery {
965 return PluginRegistrationCleanupOutcome::RemovedWithError(error);
966 }
967 let guard = match manager.lock() {
968 Ok(guard) => guard,
969 Err(error) => {
970 return PluginRegistrationCleanupOutcome::RemovedWithError(
971 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {error}")),
972 );
973 }
974 };
975 match guard.last_error_result() {
976 Ok(()) => PluginRegistrationCleanupOutcome::Removed,
977 Err(error) => PluginRegistrationCleanupOutcome::RemovedWithError(
978 observability_registration_error(error),
979 ),
980 }
981 }),
982 ));
983 Ok(())
984}
985
986fn deregister_atif_shutdown_subscriber(scope_uuid: &Uuid, name: &str) -> PluginResult<()> {
987 match scope_deregister_subscriber(scope_uuid, name) {
988 Ok(_) | Err(FlowError::NotFound(_)) => Ok(()),
989 Err(error) => Err(observability_registration_error(error)),
990 }
991}
992
993#[cfg(feature = "object-store")]
994fn build_atif_storage(
995 index: usize,
996 config: &AtifStorageConfig,
997) -> PluginResult<Arc<AtifRemoteStorage>> {
998 let storage = AtifRemoteStorage::from_config(index, config)
999 .map(Arc::new)
1000 .map_err(observability_registration_error)?;
1001 log::info!(
1002 target: "nemo_relay.plugin",
1003 event = "plugin_resource_access_pending",
1004 plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1005 resource_kind = storage.resource_kind,
1006 resource_index = index,
1007 permission = "write";
1008 "Plugin resource access will be validated on first use"
1009 );
1010 Ok(storage)
1011}
1012
1013#[cfg(not(feature = "object-store"))]
1014fn build_atif_storage(
1015 _index: usize,
1016 _config: &AtifStorageConfig,
1017) -> PluginResult<Arc<AtifRemoteStorage>> {
1018 Err(PluginError::InvalidConfig(
1019 "ATIF storage support is not enabled in this build".to_string(),
1020 ))
1021}
1022
1023fn register_opentelemetry(
1024 section: OpenTelemetrySectionConfig,
1025 ctx: &mut PluginRegistrationContext,
1026) -> PluginResult<()> {
1027 if section.endpoints.is_empty() {
1028 return Err(PluginError::InvalidConfig(
1029 "enabled OpenTelemetry section requires at least one endpoint".to_string(),
1030 ));
1031 }
1032 validate_distinct_opentelemetry_destinations(§ion.endpoints)?;
1033 let subscribers = build_opentelemetry_subscribers(section.endpoints)?;
1034 for (index, _) in subscribers.iter().enumerate() {
1035 log::info!(
1036 target: "nemo_relay.plugin",
1037 event = "plugin_resource_access_pending",
1038 plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1039 resource_kind = "otlp_endpoint",
1040 exporter = "opentelemetry",
1041 resource_index = index,
1042 permission = "write";
1043 "Plugin resource access will be validated during export"
1044 );
1045 }
1046 let callbacks = subscribers
1047 .iter()
1048 .map(|subscriber| subscriber.subscriber())
1049 .collect::<Vec<_>>();
1050 let delivery_subscribers = subscribers.clone();
1053 ctx.add_registration(PluginRegistration::new_with_outcome(
1054 "observability",
1055 ctx.qualify_name("opentelemetry.shutdown"),
1056 Box::new(
1057 move || match shutdown_opentelemetry_subscribers(&subscribers) {
1058 None => PluginRegistrationCleanupOutcome::Removed,
1059 Some(OpenTelemetryShutdownFailure::Delivery(error)) => {
1060 PluginRegistrationCleanupOutcome::RemovedWithError(error)
1061 }
1062 Some(OpenTelemetryShutdownFailure::Other(error)) => {
1063 PluginRegistrationCleanupOutcome::NotRemoved(error)
1064 }
1065 },
1066 ),
1067 ));
1068 ctx.register_subscriber(
1069 "opentelemetry",
1070 Arc::new(move |event| {
1071 let _keep_exporters_alive = &delivery_subscribers;
1072 deliver_opentelemetry_event(&callbacks, event);
1073 }),
1074 )?;
1075 Ok(())
1076}
1077
1078fn deliver_opentelemetry_event(callbacks: &[EventSubscriberFn], event: &Event) {
1079 for (index, callback) in callbacks.iter().enumerate() {
1080 if catch_unwind(AssertUnwindSafe(|| callback(event))).is_err() {
1081 log::error!(
1082 target: "nemo_relay.plugin",
1083 event = "opentelemetry_endpoint_callback_panicked",
1084 plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1085 resource_kind = "otlp_endpoint",
1086 resource_index = index;
1087 "OpenTelemetry endpoint callback panicked; delivery continued to remaining endpoints"
1088 );
1089 }
1090 }
1091}
1092
1093fn build_opentelemetry_subscribers(
1094 endpoints: Vec<OpenTelemetryEndpointConfig>,
1095) -> PluginResult<Vec<Arc<OpenTelemetrySubscriber>>> {
1096 let mut subscribers = Vec::with_capacity(endpoints.len());
1097 for (index, endpoint) in endpoints.into_iter().enumerate() {
1098 let subscriber = build_otel_config(index, endpoint).and_then(|config| {
1099 OpenTelemetrySubscriber::new_for_plugin(config, index)
1100 .map(Arc::new)
1101 .map_err(observability_registration_error)
1102 });
1103 match subscriber {
1104 Ok(subscriber) => subscribers.push(subscriber),
1105 Err(error) => {
1106 if !shutdown_opentelemetry_providers(&subscribers).is_empty() {
1107 log::warn!(
1108 target: "nemo_relay.plugin",
1109 event = "plugin_resource_rollback_failed",
1110 plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1111 resource_kind = "otlp_endpoint",
1112 reason = "shutdown_failed";
1113 "OpenTelemetry construction rollback could not shut down every endpoint"
1114 );
1115 }
1116 return Err(error);
1117 }
1118 }
1119 }
1120 Ok(subscribers)
1121}
1122
1123enum OpenTelemetryShutdownFailure {
1124 Delivery(PluginError),
1125 Other(PluginError),
1126}
1127
1128fn shutdown_opentelemetry_subscribers(
1129 subscribers: &[Arc<OpenTelemetrySubscriber>],
1130) -> Option<OpenTelemetryShutdownFailure> {
1131 let mut errors = Vec::new();
1132 if let Err(error) = flush_subscribers() {
1133 errors.push(crate::observability::otel::OpenTelemetryError::Core(error));
1134 }
1135 errors.extend(shutdown_opentelemetry_providers(subscribers));
1136 if errors.is_empty() {
1137 return None;
1138 }
1139
1140 let all_delivery_failures = errors.iter().all(|error| {
1141 error
1142 .to_string()
1143 .contains(OTEL_RUNTIME_DELIVERY_FAILURE_MARKER)
1144 });
1145 let summary = errors
1146 .into_iter()
1147 .map(|error| error.to_string())
1148 .collect::<Vec<_>>()
1149 .join("; ");
1150 let message = if all_delivery_failures {
1151 format!("{OTEL_RUNTIME_DELIVERY_FAILURE_MARKER}: {summary}")
1152 } else {
1153 format!("OpenTelemetry shutdown failures: {summary}")
1154 };
1155 let error = PluginError::RegistrationFailed(message);
1156 Some(if all_delivery_failures {
1157 OpenTelemetryShutdownFailure::Delivery(error)
1158 } else {
1159 OpenTelemetryShutdownFailure::Other(error)
1160 })
1161}
1162
1163fn shutdown_opentelemetry_providers(
1164 subscribers: &[Arc<OpenTelemetrySubscriber>],
1165) -> Vec<crate::observability::otel::OpenTelemetryError> {
1166 let mut errors = Vec::new();
1167 for subscriber in subscribers {
1168 if let Err(error) = subscriber.shutdown_provider() {
1169 errors.push(error);
1170 }
1171 }
1172 errors
1173}
1174
1175struct AtifDispatcher {
1176 config: AtifSectionConfig,
1177 agents: HashMap<Uuid, ManagedAtifExporter>,
1178 scope_owners: HashMap<Uuid, Uuid>,
1179 scope_subscribers: HashMap<Uuid, String>,
1180 fatal_error: Option<String>,
1184 runtime_failures: Vec<RuntimeDiagnostic>,
1185 validated_sinks: HashSet<SinkLabel>,
1186}
1187
1188struct ManagedAtifExporter {
1189 exporter: AtifExporter,
1190 filename: String,
1191 local_path: Option<PathBuf>,
1192 correlation: AtifCorrelation,
1193 observed_events: Vec<Event>,
1194 observed_event_keys: HashSet<String>,
1195 written: bool,
1196}
1197
1198struct PendingAtifWrite {
1199 agent_uuid: Uuid,
1200 #[cfg_attr(not(feature = "object-store"), allow(dead_code))]
1201 session_id: String,
1202 #[cfg_attr(not(feature = "object-store"), allow(dead_code))]
1205 filename: String,
1206 local_path: Option<PathBuf>,
1207 payload: Vec<u8>,
1208}
1209
1210struct AtifFlushWork {
1211 exports: Vec<PendingAtifExport>,
1212 scope_subscribers: Vec<(Uuid, String)>,
1213}
1214
1215struct PendingAtifExport {
1216 agent_uuid: Uuid,
1217 exporter: AtifExporter,
1218 filename: String,
1219 local_path: Option<PathBuf>,
1220 correlation: AtifCorrelation,
1221}
1222
1223#[derive(Clone)]
1224struct AtifCorrelation {
1225 session_id: Option<String>,
1226 session_instance_id: Option<String>,
1227 user_id: Option<String>,
1228}
1229
1230impl AtifCorrelation {
1231 fn from_event(event: &Event) -> Self {
1232 let metadata = event.metadata();
1233 Self {
1234 session_id: metadata
1235 .and_then(|value| value.get("session_id"))
1236 .and_then(Json::as_str)
1237 .map(ToString::to_string),
1238 session_instance_id: current_scope_stack()
1239 .read()
1240 .ok()
1241 .map(|stack| stack.root_uuid().to_string()),
1242 user_id: metadata
1243 .and_then(|value| value.get("user_id"))
1244 .and_then(Json::as_str)
1245 .map(ToString::to_string),
1246 }
1247 }
1248
1249 fn to_json(&self) -> Json {
1250 let mut fields = Map::new();
1251 if let Some(session_id) = &self.session_id {
1252 fields.insert("session_id".to_string(), Json::String(session_id.clone()));
1253 }
1254 if let Some(session_instance_id) = &self.session_instance_id {
1255 fields.insert(
1256 "session_instance_id".to_string(),
1257 Json::String(session_instance_id.clone()),
1258 );
1259 }
1260 if let Some(user_id) = &self.user_id {
1261 fields.insert("user_id".to_string(), Json::String(user_id.clone()));
1262 }
1263 Json::Object(fields)
1264 }
1265}
1266
1267#[derive(Clone, Debug, PartialEq, Eq, Hash)]
1271enum SinkLabel {
1272 Local,
1273 Remote(usize),
1274}
1275
1276impl AtifDispatcher {
1277 fn new(config: AtifSectionConfig) -> Self {
1278 Self {
1279 config,
1280 agents: HashMap::new(),
1281 scope_owners: HashMap::new(),
1282 scope_subscribers: HashMap::new(),
1283 fatal_error: None,
1284 runtime_failures: Vec::new(),
1285 validated_sinks: HashSet::new(),
1286 }
1287 }
1288
1289 fn observe_global(
1290 &mut self,
1291 event: &Event,
1292 subscriber_prefix: &str,
1293 state: Arc<Mutex<Self>>,
1294 storage: AtifStorageList,
1295 ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
1296 if self.fatal_error.is_some() {
1297 return None;
1298 }
1299
1300 if !is_top_level_trajectory_start(event) {
1301 return self.observe_descendant_from_global(event);
1302 }
1303
1304 if self.agents.contains_key(&event.uuid()) {
1305 return None;
1306 }
1307
1308 let session_id = event.uuid().to_string();
1313 let (filename, local_path) = match self.prepare_destination(&session_id, event.metadata()) {
1314 Ok(destination) => destination,
1315 Err(error) => {
1316 self.record_runtime_failure(
1317 "atif.destination_render_failed",
1318 Some("filename_template".into()),
1319 error.clone(),
1320 Some(session_id.clone()),
1321 );
1322 log::warn!(
1323 target: "nemo_relay.observability",
1324 event = "atif_destination_render_failed",
1325 plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1326 exporter = "atif",
1327 session_id = session_id.as_str();
1328 "ATIF destination rendering failed: {error}"
1329 );
1330 return None;
1331 }
1332 };
1333 let exporter = AtifExporter::new(session_id.clone(), self.agent_info());
1334 (exporter.subscriber())(event);
1335 let correlation = AtifCorrelation::from_event(event);
1336 self.scope_owners.insert(event.uuid(), event.uuid());
1337 self.agents.insert(
1338 event.uuid(),
1339 ManagedAtifExporter {
1340 exporter,
1341 filename,
1342 local_path,
1343 correlation,
1344 observed_events: vec![event.clone()],
1345 observed_event_keys: HashSet::from([event_observation_key(event)]),
1346 written: false,
1347 },
1348 );
1349
1350 let agent_uuid = event.uuid();
1351 let name = format!("{subscriber_prefix}{agent_uuid}");
1352 let callback = atif_scope_subscriber(state, agent_uuid, storage);
1353 if try_scope_register_subscriber(&agent_uuid, &name, callback).is_ok() {
1359 self.scope_subscribers.insert(agent_uuid, name);
1360 }
1361 None
1362 }
1363
1364 fn observe_descendant_from_global(
1365 &mut self,
1366 event: &Event,
1367 ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
1368 let owner = self.scope_owners.get(&event.uuid()).copied().or_else(|| {
1369 event
1370 .parent_uuid()
1371 .and_then(|uuid| self.scope_owners.get(&uuid).copied())
1372 })?;
1373
1374 if event.scope_category() == Some(ScopeCategory::Start) {
1375 self.scope_owners.insert(event.uuid(), owner);
1376 }
1377
1378 let pending_write = self.observe_scope(event, owner);
1379
1380 if event.scope_category() == Some(ScopeCategory::End) && event.uuid() != owner {
1381 self.scope_owners.remove(&event.uuid());
1382 }
1383
1384 pending_write
1385 }
1386
1387 fn observe_scope(
1388 &mut self,
1389 event: &Event,
1390 agent_uuid: Uuid,
1391 ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
1392 if self.fatal_error.is_some() {
1393 return None;
1394 }
1395 let should_finalize =
1396 event.uuid() == agent_uuid && event.scope_category() == Some(ScopeCategory::End);
1397 let agent = self.agents.get_mut(&agent_uuid)?;
1398 if !agent
1399 .observed_event_keys
1400 .insert(event_observation_key(event))
1401 {
1402 return None;
1403 }
1404 (agent.exporter.subscriber())(event);
1405 agent.observed_events.push(event.clone());
1406 if !should_finalize || agent.written {
1407 return None;
1408 }
1409 let write = match prepare_atif_file(agent_uuid, agent) {
1410 Ok(write) => write,
1411 Err(err) => {
1412 self.fatal_error = Some(err.to_string());
1413 return None;
1414 }
1415 };
1416 let targets = self.sink_targets();
1417 Some((write, targets))
1418 }
1419
1420 fn complete_scope_write(
1421 &mut self,
1422 agent_uuid: Uuid,
1423 results: Vec<(SinkLabel, std::io::Result<()>)>,
1424 ) -> Option<(Uuid, String)> {
1425 let is_remote_fallback = results
1426 .iter()
1427 .any(|(label, _)| matches!(label, SinkLabel::Remote(_)));
1428 for (label, result) in results {
1429 if result.is_ok()
1430 && label == SinkLabel::Local
1431 && self.validated_sinks.insert(label.clone())
1432 {
1433 log::info!(
1434 target: "nemo_relay.observability",
1435 event = "storage_access_validated",
1436 plugin_kind = "observability",
1437 exporter = "atif",
1438 resource_kind = "local_file",
1439 permission = "write";
1440 "ATIF storage access validated"
1441 );
1442 } else if let Err(err) = result {
1443 let (field, message) = match &label {
1444 SinkLabel::Local => ("output_directory".to_string(), err.to_string()),
1445 SinkLabel::Remote(index) => (format!("storage[{index}]"), err.to_string()),
1446 };
1447 match &label {
1448 SinkLabel::Local => log::warn!(
1449 target: "nemo_relay.observability",
1450 event = "storage_access_failed",
1451 plugin_kind = "observability",
1452 exporter = "atif",
1453 resource_kind = "local_file",
1454 permission = "write",
1455 reason = "write_failed";
1456 "ATIF storage access failed"
1457 ),
1458 SinkLabel::Remote(index) => log::warn!(
1459 target: "nemo_relay.observability",
1460 event = "atif_remote_delivery_failed",
1461 plugin_kind = OBSERVABILITY_PLUGIN_KIND,
1462 exporter = "atif",
1463 storage_index = *index;
1464 "ATIF remote storage upload failed"
1465 ),
1466 }
1467 self.record_runtime_failure(
1468 match &label {
1469 SinkLabel::Local if is_remote_fallback => "atif.local_fallback_failed",
1470 SinkLabel::Local => "atif.local_write_failed",
1471 SinkLabel::Remote(_) => "atif.remote_delivery_failed",
1472 },
1473 Some(field),
1474 message,
1475 Some(agent_uuid.to_string()),
1476 );
1477 }
1478 }
1479 if let Some(agent) = self.agents.get_mut(&agent_uuid) {
1480 agent.observed_events.clear();
1481 }
1482 self.agents.remove(&agent_uuid);
1483 self.scope_owners.retain(|_, owner| *owner != agent_uuid);
1484 self.scope_subscribers
1485 .remove(&agent_uuid)
1486 .map(|name| (agent_uuid, name))
1487 }
1488
1489 fn flush_open_agents(&mut self) -> AtifFlushWork {
1490 let scope_subscribers = std::mem::take(&mut self.scope_subscribers)
1494 .into_iter()
1495 .collect();
1496 let agent_uuids = self
1497 .agents
1498 .iter()
1499 .filter_map(|(agent_uuid, agent)| (!agent.written).then_some(*agent_uuid))
1500 .collect::<Vec<_>>();
1501 let mut exports = Vec::with_capacity(agent_uuids.len());
1502 for agent_uuid in agent_uuids {
1503 if let Some(agent) = self.agents.get_mut(&agent_uuid) {
1504 agent.written = true;
1505 exports.push(PendingAtifExport {
1506 agent_uuid,
1507 exporter: agent.exporter.clone(),
1508 filename: agent.filename.clone(),
1509 local_path: agent.local_path.clone(),
1510 correlation: agent.correlation.clone(),
1511 });
1512 }
1513 }
1514 AtifFlushWork {
1515 exports,
1516 scope_subscribers,
1517 }
1518 }
1519
1520 fn observed_events(&self, agent_uuid: Uuid) -> Vec<Event> {
1521 self.agents
1522 .get(&agent_uuid)
1523 .map(|agent| agent.observed_events.clone())
1524 .unwrap_or_default()
1525 }
1526
1527 fn last_error_result(&self) -> std::io::Result<()> {
1528 if let Some(message) = &self.fatal_error {
1529 return Err(std::io::Error::other(message.clone()));
1530 }
1531 if !self.runtime_failures.is_empty() {
1532 return Err(std::io::Error::other(format!(
1533 "{ATIF_RUNTIME_DELIVERY_FAILURE_MARKER}: {}",
1534 self.runtime_failures
1535 .iter()
1536 .map(|diagnostic| format!("{} ({})", diagnostic.code, diagnostic.count))
1537 .collect::<Vec<_>>()
1538 .join(", ")
1539 )));
1540 }
1541 Ok(())
1542 }
1543
1544 fn agent_info(&self) -> AtifAgentInfo {
1545 AtifAgentInfo {
1546 name: self.config.agent_name.clone(),
1547 version: self.config.agent_version.clone(),
1548 model_name: Some(self.config.model_name.clone()),
1549 tool_definitions: self.config.tool_definitions.clone(),
1550 extra: self.config.extra.clone(),
1551 }
1552 }
1553
1554 fn prepare_destination(
1555 &self,
1556 session_id: &str,
1557 metadata: Option<&Json>,
1558 ) -> Result<(String, Option<PathBuf>), String> {
1559 validate_atif_filename_template(&self.config.filename_template)?;
1560 let filename = render_atif_filename(&self.config.filename_template, session_id, metadata)?;
1561 let directory = self
1562 .config
1563 .output_directory
1564 .clone()
1565 .unwrap_or_else(default_output_directory);
1566 let path = directory.join(&filename);
1567 Ok((filename, Some(path)))
1568 }
1569
1570 fn sink_targets(&self) -> Vec<SinkLabel> {
1571 if self.config.storage.is_empty() {
1572 vec![SinkLabel::Local]
1573 } else {
1574 (0..self.config.storage.len())
1575 .map(SinkLabel::Remote)
1576 .collect()
1577 }
1578 }
1579
1580 fn record_runtime_failure(
1581 &mut self,
1582 code: &str,
1583 field: Option<String>,
1584 message: String,
1585 session_id: Option<String>,
1586 ) {
1587 let diagnostic = RuntimeDiagnostic {
1588 code: code.to_string(),
1589 component: OBSERVABILITY_PLUGIN_KIND.to_string(),
1590 field,
1591 message,
1592 session_id,
1593 count: 1,
1594 };
1595 if let Some(existing) = self
1596 .runtime_failures
1597 .iter_mut()
1598 .find(|existing| existing.code == diagnostic.code && existing.field == diagnostic.field)
1599 {
1600 existing.message = diagnostic.message.clone();
1601 existing.session_id = diagnostic.session_id.clone();
1602 existing.count += 1;
1603 } else {
1604 self.runtime_failures.push(diagnostic.clone());
1605 }
1606 record_active_plugin_runtime_diagnostic(diagnostic);
1607 }
1608}
1609
1610fn is_valid_atif_metadata_selector(selector: &str) -> bool {
1611 !selector.is_empty()
1612 && selector.split('.').all(|segment| {
1613 !segment.is_empty()
1614 && segment
1615 .bytes()
1616 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
1617 })
1618}
1619
1620fn parse_atif_metadata_expression(expression: &str) -> Result<(&str, Option<&str>), String> {
1621 let (selector, fallback) = expression
1622 .split_once(":-")
1623 .map_or((expression, None), |(key, value)| (key, Some(value)));
1624 if !is_valid_atif_metadata_selector(selector) {
1625 return Err(format!(
1626 "ATIF filename_template metadata placeholder '{{metadata.{selector}}}' must contain a dot-separated path of ASCII letters, digits, '-' or '_'"
1627 ));
1628 }
1629 Ok((selector, fallback))
1630}
1631
1632fn validate_atif_filename_template(template: &str) -> Result<(), String> {
1633 const PREFIX: &str = "{metadata.";
1634
1635 if !template.contains("{session_id}") {
1636 return Err("ATIF filename_template must contain '{session_id}'".to_string());
1637 }
1638
1639 let literal_path = template
1640 .replace("{session_id}", "session")
1641 .replace("{metadata.", "metadata.");
1642 if Path::new(&literal_path).is_absolute()
1643 || Path::new(&literal_path).components().any(|component| {
1644 matches!(
1645 component,
1646 Component::ParentDir
1647 | Component::CurDir
1648 | Component::RootDir
1649 | Component::Prefix(_)
1650 )
1651 })
1652 {
1653 return Err("ATIF filename_template must be a path-safe relative path".to_string());
1654 }
1655
1656 let mut cursor = 0;
1657 while let Some(relative_start) = template[cursor..].find(PREFIX) {
1658 let selector_start = cursor + relative_start + PREFIX.len();
1659 let end = template[selector_start..]
1660 .find('}')
1661 .map(|relative_end| selector_start + relative_end)
1662 .ok_or_else(|| {
1663 "ATIF filename_template contains an unclosed metadata placeholder".to_string()
1664 })?;
1665 let (_, fallback) = parse_atif_metadata_expression(&template[selector_start..end])?;
1666 if let Some(fallback) = fallback
1667 && !is_safe_atif_metadata_path(fallback)
1668 {
1669 return Err(format!(
1670 "ATIF filename_template fallback '{fallback}' must be a path-safe relative fragment"
1671 ));
1672 }
1673 cursor = end + 1;
1674 }
1675 Ok(())
1676}
1677
1678fn render_atif_filename(
1679 template: &str,
1680 session_id: &str,
1681 metadata: Option<&Json>,
1682) -> Result<String, String> {
1683 const PREFIX: &str = "{metadata.";
1684
1685 let mut rendered = template.replace("{session_id}", session_id);
1686 let mut cursor = 0;
1687 while let Some(relative_start) = rendered[cursor..].find(PREFIX) {
1688 let start = cursor + relative_start;
1689 let selector_start = start + PREFIX.len();
1690 let end = rendered[selector_start..]
1691 .find('}')
1692 .map(|relative_end| selector_start + relative_end)
1693 .ok_or_else(|| {
1694 "ATIF filename_template contains an unclosed metadata placeholder".to_string()
1695 })?;
1696 let expression = rendered[selector_start..end].to_string();
1697 let (selector, fallback) = parse_atif_metadata_expression(&expression)?;
1698 let mut resolved = metadata;
1699 for segment in selector.split('.') {
1700 resolved = match resolved {
1701 Some(Json::Object(object)) => object.get(segment),
1702 None | Some(Json::Null) => break,
1703 Some(_) => {
1704 return Err(format!(
1705 "filename_template placeholder '{{metadata.{selector}}}' traversed a non-object value"
1706 ));
1707 }
1708 };
1709 }
1710 let value = match resolved {
1711 Some(Json::String(value)) => value.as_str(),
1712 None | Some(Json::Null) => fallback.ok_or_else(|| {
1713 format!(
1714 "filename_template placeholder '{{metadata.{selector}}}' must resolve to a string"
1715 )
1716 })?,
1717 Some(_) => {
1718 return Err(format!(
1719 "filename_template placeholder '{{metadata.{selector}}}' resolved to a non-string value"
1720 ));
1721 }
1722 };
1723 if !is_safe_atif_metadata_path(value) {
1724 return Err(format!(
1725 "metadata path '{selector}' must be a path-safe relative fragment"
1726 ));
1727 }
1728 rendered.replace_range(start..=end, value);
1729 cursor = start + value.len();
1730 }
1731 Ok(rendered)
1732}
1733
1734fn is_safe_atif_metadata_path(value: &str) -> bool {
1735 !value.is_empty()
1736 && value.split('/').all(|segment| {
1737 !matches!(segment, "" | "." | "..")
1738 && segment.bytes().all(|byte| {
1739 byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
1740 })
1741 })
1742}
1743
1744fn atif_dispatcher_subscriber(
1745 manager: Arc<Mutex<AtifDispatcher>>,
1746 subscriber_prefix: String,
1747 storage: AtifStorageList,
1748) -> EventSubscriberFn {
1749 Arc::new(move |event: &Event| {
1750 let pending = {
1751 let Ok(mut guard) = manager.lock() else {
1752 return;
1753 };
1754 guard.observe_global(
1755 event,
1756 &subscriber_prefix,
1757 Arc::clone(&manager),
1758 Arc::clone(&storage),
1759 )
1760 };
1761 let Some((write, targets)) = pending else {
1762 return;
1763 };
1764 let results = write_atif(&write, storage.as_slice(), &targets);
1765 let scope_subscriber = {
1766 let Ok(mut guard) = manager.lock() else {
1767 return;
1768 };
1769 guard.complete_scope_write(write.agent_uuid, results)
1770 };
1771 if let Some((scope_uuid, name)) = scope_subscriber {
1772 let _ = try_scope_deregister_subscriber(&scope_uuid, &name);
1773 }
1774 })
1775}
1776
1777fn atif_scope_subscriber(
1778 manager: Arc<Mutex<AtifDispatcher>>,
1779 agent_uuid: Uuid,
1780 storage: AtifStorageList,
1781) -> EventSubscriberFn {
1782 Arc::new(move |event: &Event| {
1783 let pending = {
1784 let Ok(mut guard) = manager.lock() else {
1785 return;
1786 };
1787 guard.observe_scope(event, agent_uuid)
1788 };
1789 let Some((write, targets)) = pending else {
1790 return;
1791 };
1792 let results = write_atif(&write, storage.as_slice(), &targets);
1793 let scope_subscriber = {
1794 let Ok(mut guard) = manager.lock() else {
1795 return;
1796 };
1797 guard.complete_scope_write(write.agent_uuid, results)
1798 };
1799 if let Some((scope_uuid, name)) = scope_subscriber {
1800 let _ = try_scope_deregister_subscriber(&scope_uuid, &name);
1801 }
1802 })
1803}
1804
1805fn prepare_atif_file(
1806 agent_uuid: Uuid,
1807 agent: &mut ManagedAtifExporter,
1808) -> std::io::Result<PendingAtifWrite> {
1809 let trajectory = agent
1810 .exporter
1811 .try_export()
1812 .map_err(|error| std::io::Error::other(error.to_string()))?;
1813 let observed_events = agent.observed_events.clone();
1814 agent.written = true;
1815 prepare_atif_payload(
1816 agent_uuid,
1817 agent.filename.clone(),
1818 agent.local_path.clone(),
1819 trajectory,
1820 observed_events,
1821 agent.correlation.clone(),
1822 )
1823}
1824
1825fn prepare_atif_shutdown_file(
1826 export: &PendingAtifExport,
1827 manager: Arc<Mutex<AtifDispatcher>>,
1828) -> std::io::Result<PendingAtifWrite> {
1829 let trajectory = export
1830 .exporter
1831 .try_export()
1832 .map_err(|error| std::io::Error::other(error.to_string()))?;
1833 let observed_events = {
1834 let guard = manager.lock().map_err(|err| {
1835 std::io::Error::other(format!("ATIF dispatcher lock poisoned: {err}"))
1836 })?;
1837 guard.observed_events(export.agent_uuid)
1838 };
1839 prepare_atif_payload(
1840 export.agent_uuid,
1841 export.filename.clone(),
1842 export.local_path.clone(),
1843 trajectory,
1844 observed_events,
1845 export.correlation.clone(),
1846 )
1847}
1848
1849fn prepare_atif_payload(
1850 agent_uuid: Uuid,
1851 filename: String,
1852 local_path: Option<PathBuf>,
1853 trajectory: crate::observability::atif::AtifTrajectory,
1854 observed_events: Vec<Event>,
1855 correlation: AtifCorrelation,
1856) -> std::io::Result<PendingAtifWrite> {
1857 let mut value = serde_json::to_value(trajectory)?;
1858 if let Some(object) = value.as_object_mut() {
1859 let existing_extra = object.remove("extra");
1860 let mut extra = match existing_extra {
1861 Some(Json::Object(fields)) => fields,
1862 Some(value) => Map::from_iter([("trajectory_extra".to_string(), value)]),
1863 None => Map::new(),
1864 };
1865 extra.insert(
1866 "observed_events".to_string(),
1867 serde_json::to_value(observed_events)?,
1868 );
1869 let mut nemo_relay = match extra.remove("nemo_relay") {
1870 Some(Json::Object(fields)) => fields,
1871 Some(value) => Map::from_iter([("trajectory_extra".to_string(), value)]),
1872 None => Map::new(),
1873 };
1874 if let Json::Object(correlation_fields) = correlation.to_json() {
1875 nemo_relay.extend(correlation_fields);
1876 }
1877 extra.insert("nemo_relay".to_string(), Json::Object(nemo_relay));
1878 object.insert("extra".to_string(), Json::Object(extra));
1879 }
1880 let payload = serde_json::to_vec_pretty(&value)?;
1881 Ok(PendingAtifWrite {
1882 agent_uuid,
1883 session_id: agent_uuid.to_string(),
1884 filename,
1885 local_path,
1886 payload,
1887 })
1888}
1889
1890fn write_atif(
1891 write: &PendingAtifWrite,
1892 storage: &[Arc<AtifRemoteStorage>],
1893 targets: &[SinkLabel],
1894) -> Vec<(SinkLabel, std::io::Result<()>)> {
1895 let mut results = targets
1896 .iter()
1897 .map(|label| {
1898 let result = match label {
1899 SinkLabel::Local => match &write.local_path {
1900 Some(path) => write_atif_local(path, &write.payload),
1901 None => Err(std::io::Error::other(
1902 "ATIF local destination has no output path",
1903 )),
1904 },
1905 SinkLabel::Remote(index) => write_atif_remote(storage, *index, write),
1906 };
1907 (label.clone(), result)
1908 })
1909 .collect::<Vec<_>>();
1910 if !targets.is_empty()
1911 && targets
1912 .iter()
1913 .all(|label| matches!(label, SinkLabel::Remote(_)))
1914 && results.iter().all(|(_, result)| result.is_err())
1915 {
1916 let fallback = match &write.local_path {
1917 Some(path) => write_atif_local(path, &write.payload),
1918 None => Err(std::io::Error::other(
1919 "ATIF local fallback has no output path",
1920 )),
1921 };
1922 results.push((SinkLabel::Local, fallback));
1923 }
1924 results
1925}
1926
1927fn write_atif_local(path: &PathBuf, payload: &[u8]) -> std::io::Result<()> {
1928 if let Some(parent) = path.parent() {
1929 std::fs::create_dir_all(parent)?;
1930 }
1931 std::fs::write(path, payload)
1932}
1933
1934#[cfg(feature = "object-store")]
1935fn write_atif_remote(
1936 storage: &[Arc<AtifRemoteStorage>],
1937 index: usize,
1938 write: &PendingAtifWrite,
1939) -> std::io::Result<()> {
1940 let sink = storage
1941 .get(index)
1942 .ok_or_else(|| std::io::Error::other(format!("ATIF storage[{index}] is not registered")))?;
1943 sink.put(&write.filename, &write.session_id, &write.payload)
1944}
1945
1946#[cfg(not(feature = "object-store"))]
1947fn write_atif_remote(
1948 _storage: &[Arc<AtifRemoteStorage>],
1949 _index: usize,
1950 _write: &PendingAtifWrite,
1951) -> std::io::Result<()> {
1952 Err(std::io::Error::other(
1953 "ATIF storage support is not enabled in this build",
1954 ))
1955}
1956
1957fn event_observation_key(event: &Event) -> String {
1958 format!(
1959 "{}:{}:{:?}",
1960 event.kind(),
1961 event.uuid(),
1962 event.scope_category()
1963 )
1964}
1965
1966fn is_top_level_trajectory_start(event: &Event) -> bool {
1967 if event.scope_category() != Some(ScopeCategory::Start) {
1968 return false;
1969 }
1970 let is_agent_scope = event.scope_type() == Some(ScopeType::Agent);
1971 let is_turn_scope = event.scope_type() == Some(ScopeType::Custom)
1972 && event
1973 .metadata()
1974 .and_then(|metadata| metadata.get("nemo_relay_scope_role"))
1975 .and_then(Json::as_str)
1976 == Some("turn");
1977 if !is_agent_scope && !is_turn_scope {
1978 return false;
1979 }
1980 let Some(parent_uuid) = event.parent_uuid() else {
1981 return false;
1982 };
1983 current_scope_stack()
1984 .read()
1985 .map(|stack| stack.root_uuid() == parent_uuid)
1986 .unwrap_or(false)
1987}
1988
1989fn build_otel_config(
1990 index: usize,
1991 section: OpenTelemetryEndpointConfig,
1992) -> PluginResult<CoreOpenTelemetryConfig> {
1993 if section.endpoint.trim().is_empty() {
1994 return Err(PluginError::InvalidConfig(
1995 "OpenTelemetry endpoint must be a nonblank string".to_string(),
1996 ));
1997 }
1998 let transport = match section.transport.as_str() {
1999 "http_binary" => OtlpTransport::HttpBinary,
2000 "grpc" => OtlpTransport::Grpc,
2001 other => {
2002 return Err(PluginError::InvalidConfig(format!(
2003 "OpenTelemetry transport must be 'http_binary' or 'grpc', got {other:?}"
2004 )));
2005 }
2006 };
2007 validate_otel_header_env(index, §ion)?;
2008 let mut config = CoreOpenTelemetryConfig::new(section.otel_type, section.endpoint)
2009 .with_transport(transport)
2010 .with_service_name(section.service_name)
2011 .with_timeout(Duration::from_millis(section.timeout_millis))
2012 .with_instrumentation_scope(section.instrumentation_scope)
2013 .with_mark_projection(section.mark_projection)
2014 .with_mark_exclude_names(section.mark_exclude_names)
2015 .with_attribute_mappings(section.attribute_mappings);
2016 if let Some(namespace) = section.service_namespace {
2017 config = config.with_service_namespace(namespace);
2018 }
2019 if let Some(version) = section.service_version {
2020 config = config.with_service_version(version);
2021 }
2022 for (key, value) in section.headers {
2023 config = config.with_header(key, value);
2024 }
2025 config = apply_otel_environment_headers(config, index, section.header_env)?;
2026 for (key, value) in section.resource_attributes {
2027 config = config.with_resource_attribute(key, value);
2028 }
2029 Ok(config)
2030}
2031
2032fn validate_otel_header_env(
2033 index: usize,
2034 section: &OpenTelemetryEndpointConfig,
2035) -> PluginResult<()> {
2036 for (header, variable) in §ion.header_env {
2037 if variable.trim().is_empty() || variable.trim() != variable {
2038 return Err(PluginError::InvalidConfig(format!(
2039 "OpenTelemetry endpoints[{index}].header_env.{header} must name a nonblank environment variable without surrounding whitespace"
2040 )));
2041 }
2042 if section
2043 .headers
2044 .keys()
2045 .any(|configured| configured.eq_ignore_ascii_case(header))
2046 {
2047 return Err(PluginError::InvalidConfig(format!(
2048 "OpenTelemetry endpoints[{index}] header {header:?} cannot appear in both headers and header_env"
2049 )));
2050 }
2051 }
2052 Ok(())
2053}
2054
2055fn apply_otel_environment_headers(
2056 mut config: CoreOpenTelemetryConfig,
2057 index: usize,
2058 header_env: HashMap<String, String>,
2059) -> PluginResult<CoreOpenTelemetryConfig> {
2060 for (key, variable) in header_env {
2061 let value = std::env::var(&variable).map_err(|error| {
2062 PluginError::InvalidConfig(format!(
2063 "OpenTelemetry endpoints[{index}].header_env.{key} could not read environment variable {variable:?}: {error}"
2064 ))
2065 })?;
2066 if value.trim().is_empty() || value.trim() != value {
2067 return Err(PluginError::InvalidConfig(format!(
2068 "OpenTelemetry endpoints[{index}].header_env.{key} references a blank or padded environment variable {variable:?}"
2069 )));
2070 }
2071 config = config.with_header(key, value);
2072 }
2073 Ok(config)
2074}
2075
2076fn parse_observability_config(
2077 plugin_config: &Map<String, Json>,
2078) -> PluginResult<ObservabilityConfig> {
2079 serde_json::from_value(Json::Object(plugin_config.clone())).map_err(|err| {
2080 PluginError::InvalidConfig(format!("invalid observability plugin config: {err}"))
2081 })
2082}
2083
2084fn validate_observability_plugin_config(
2085 plugin_config: &Map<String, Json>,
2086) -> Vec<ConfigDiagnostic> {
2087 validate_observability_plugin_config_with_policy(plugin_config, None)
2088}
2089
2090fn validate_observability_plugin_config_with_policy(
2091 plugin_config: &Map<String, Json>,
2092 policy: Option<&ConfigPolicy>,
2093) -> Vec<ConfigDiagnostic> {
2094 let mut config = match parse_observability_config(plugin_config) {
2095 Ok(config) => config,
2096 Err(err) => {
2097 return vec![ConfigDiagnostic {
2098 level: DiagnosticLevel::Error,
2099 code: "observability.invalid_plugin_config".to_string(),
2100 component: Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2101 field: None,
2102 message: err.to_string(),
2103 }];
2104 }
2105 };
2106 if let Some(policy) = policy {
2107 config.policy = apply_global_config_policy(config.policy, policy);
2108 }
2109
2110 let mut diagnostics = vec![];
2111 validate_top_level_observability_fields(&mut diagnostics, &config.policy, plugin_config);
2112 validate_version(&mut diagnostics, &config.policy, config.version);
2113 validate_policy_fields(&mut diagnostics, &config.policy, plugin_config);
2114 validate_observability_section_fields(&mut diagnostics, &config.policy, plugin_config);
2115 validate_observability_section_values(&mut diagnostics, &config);
2116
2117 diagnostics
2118}
2119
2120fn validate_top_level_observability_fields(
2121 diagnostics: &mut Vec<ConfigDiagnostic>,
2122 policy: &ConfigPolicy,
2123 plugin_config: &Map<String, Json>,
2124) {
2125 validate_unknown_fields(
2126 diagnostics,
2127 policy,
2128 Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2129 plugin_config,
2130 &[
2131 "version",
2132 "atof",
2133 "atif",
2134 "opentelemetry",
2135 "openinference",
2136 "policy",
2137 "enable_full_payloads",
2138 ],
2139 );
2140 if plugin_config.contains_key("openinference") {
2141 push_policy_diag(
2142 diagnostics,
2143 UnsupportedBehavior::Error,
2144 "observability.legacy_openinference_section",
2145 Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2146 Some("openinference".to_string()),
2147 "the standalone OpenInference section was removed in observability config version 3; configure an opentelemetry endpoint with type = \"openinference\"".to_string(),
2148 );
2149 }
2150}
2151
2152fn validate_observability_section_fields(
2153 diagnostics: &mut Vec<ConfigDiagnostic>,
2154 policy: &ConfigPolicy,
2155 plugin_config: &Map<String, Json>,
2156) {
2157 validate_section_fields(
2158 diagnostics,
2159 policy,
2160 plugin_config,
2161 "atof",
2162 &["enabled", "sinks"],
2163 );
2164 if let Some(atof) = plugin_config.get("atof").and_then(Json::as_object) {
2165 for legacy_field in ["output_directory", "filename", "mode", "endpoints"] {
2166 if atof.contains_key(legacy_field) {
2167 push_policy_diag(
2168 diagnostics,
2169 UnsupportedBehavior::Error,
2170 "observability.legacy_atof_field",
2171 Some("atof".to_string()),
2172 Some(legacy_field.to_string()),
2173 format!(
2174 "ATOF {legacy_field} was removed in observability config version 2; configure typed ATOF sinks instead"
2175 ),
2176 );
2177 }
2178 }
2179 }
2180 validate_section_fields(
2181 diagnostics,
2182 policy,
2183 plugin_config,
2184 "atif",
2185 &[
2186 "enabled",
2187 "agent_name",
2188 "agent_version",
2189 "model_name",
2190 "tool_definitions",
2191 "extra",
2192 "output_directory",
2193 "filename_template",
2194 "storage",
2195 ],
2196 );
2197 validate_section_fields(
2198 diagnostics,
2199 policy,
2200 plugin_config,
2201 "opentelemetry",
2202 &[
2203 "enabled",
2204 "endpoints",
2205 "mark_projection",
2206 "mark_exclude_names",
2207 "attribute_mappings",
2208 "transport",
2209 "endpoint",
2210 "headers",
2211 "resource_attributes",
2212 "service_name",
2213 "service_namespace",
2214 "service_version",
2215 "instrumentation_scope",
2216 "timeout_millis",
2217 ],
2218 );
2219 if let Some(opentelemetry) = plugin_config.get("opentelemetry").and_then(Json::as_object) {
2220 validate_opentelemetry_endpoint_fields(diagnostics, policy, opentelemetry);
2221 for legacy_field in [
2222 "mark_projection",
2223 "mark_exclude_names",
2224 "attribute_mappings",
2225 "transport",
2226 "endpoint",
2227 "headers",
2228 "resource_attributes",
2229 "service_name",
2230 "service_namespace",
2231 "service_version",
2232 "instrumentation_scope",
2233 "timeout_millis",
2234 ] {
2235 if opentelemetry.contains_key(legacy_field) {
2236 push_policy_diag(
2237 diagnostics,
2238 UnsupportedBehavior::Error,
2239 "observability.legacy_opentelemetry_field",
2240 Some("opentelemetry".to_string()),
2241 Some(legacy_field.to_string()),
2242 format!(
2243 "OpenTelemetry {legacy_field} moved into each typed endpoint in observability config version 3"
2244 ),
2245 );
2246 }
2247 }
2248 }
2249}
2250
2251fn validate_opentelemetry_endpoint_fields(
2252 diagnostics: &mut Vec<ConfigDiagnostic>,
2253 policy: &ConfigPolicy,
2254 opentelemetry: &Map<String, Json>,
2255) {
2256 const ALLOWED: &[&str] = &[
2257 "type",
2258 "endpoint",
2259 "mark_projection",
2260 "mark_exclude_names",
2261 "attribute_mappings",
2262 "transport",
2263 "headers",
2264 "header_env",
2265 "resource_attributes",
2266 "service_name",
2267 "service_namespace",
2268 "service_version",
2269 "instrumentation_scope",
2270 "timeout_millis",
2271 ];
2272 const REMOVED: &[&str] = &["semantic_selector", "capture_content"];
2273 let Some(endpoints) = opentelemetry.get("endpoints").and_then(Json::as_array) else {
2274 return;
2275 };
2276 for (index, endpoint) in endpoints.iter().enumerate() {
2277 let Some(endpoint) = endpoint.as_object() else {
2278 continue;
2279 };
2280 for field in endpoint
2281 .keys()
2282 .filter(|field| !ALLOWED.contains(&field.as_str()))
2283 {
2284 let behavior = if REMOVED.contains(&field.as_str()) {
2285 UnsupportedBehavior::Error
2286 } else {
2287 policy.unknown_field
2288 };
2289 push_policy_diag(
2290 diagnostics,
2291 behavior,
2292 if REMOVED.contains(&field.as_str()) {
2293 "observability.legacy_opentelemetry_field"
2294 } else {
2295 "observability.unknown_field"
2296 },
2297 Some("opentelemetry".to_string()),
2298 Some(format!("endpoints[{index}].{field}")),
2299 format!("unknown OpenTelemetry endpoint field {field:?}"),
2300 );
2301 }
2302 }
2303}
2304
2305fn validate_observability_section_values(
2306 diagnostics: &mut Vec<ConfigDiagnostic>,
2307 config: &ObservabilityConfig,
2308) {
2309 if let Some(section) = &config.atof {
2310 validate_atof_section(diagnostics, &config.policy, section);
2311 }
2312 if let Some(section) = &config.atif {
2313 validate_atif_section(diagnostics, &config.policy, section);
2314 }
2315 if let Some(section) = &config.opentelemetry {
2316 validate_opentelemetry_section(diagnostics, &config.policy, section);
2317 }
2318}
2319
2320fn validate_atof_section(
2321 diagnostics: &mut Vec<ConfigDiagnostic>,
2322 policy: &ConfigPolicy,
2323 section: &AtofSectionConfig,
2324) {
2325 validate_atof_values(diagnostics, policy, section);
2326 validate_atof_feature_support(diagnostics, policy, section);
2327}
2328
2329#[cfg(not(feature = "atof-streaming"))]
2330fn validate_atof_feature_support(
2331 diagnostics: &mut Vec<ConfigDiagnostic>,
2332 policy: &ConfigPolicy,
2333 section: &AtofSectionConfig,
2334) {
2335 if section.enabled
2336 && section
2337 .sinks
2338 .iter()
2339 .any(|sink| matches!(sink, AtofSinkSectionConfig::Stream(_)))
2340 {
2341 push_policy_diag(
2342 diagnostics,
2343 policy.unsupported_value,
2344 "observability.unsupported_value",
2345 Some("atof".to_string()),
2346 Some("sinks".to_string()),
2347 "ATOF stream sinks are not enabled in this build".to_string(),
2348 );
2349 }
2350}
2351
2352#[cfg(feature = "atof-streaming")]
2353fn validate_atof_feature_support(
2354 _diagnostics: &mut Vec<ConfigDiagnostic>,
2355 _policy: &ConfigPolicy,
2356 _section: &AtofSectionConfig,
2357) {
2358}
2359
2360fn validate_atif_section(
2361 diagnostics: &mut Vec<ConfigDiagnostic>,
2362 policy: &ConfigPolicy,
2363 section: &AtifSectionConfig,
2364) {
2365 validate_atif_values(diagnostics, policy, section);
2366 validate_atif_file_export_support(diagnostics, policy, section);
2367 validate_atif_storage_support(diagnostics, policy, section);
2368}
2369
2370fn validate_atif_file_export_support(
2371 _diagnostics: &mut Vec<ConfigDiagnostic>,
2372 _policy: &ConfigPolicy,
2373 _section: &AtifSectionConfig,
2374) {
2375}
2376
2377#[cfg(not(feature = "object-store"))]
2378fn validate_atif_storage_support(
2379 diagnostics: &mut Vec<ConfigDiagnostic>,
2380 policy: &ConfigPolicy,
2381 section: &AtifSectionConfig,
2382) {
2383 if section.enabled && !section.storage.is_empty() {
2384 push_policy_diag(
2385 diagnostics,
2386 policy.unsupported_value,
2387 "observability.feature_disabled",
2388 Some("atif".to_string()),
2389 Some("storage".to_string()),
2390 "ATIF storage support is not enabled in this build".to_string(),
2391 );
2392 }
2393}
2394
2395#[cfg(feature = "object-store")]
2396fn validate_atif_storage_support(
2397 _diagnostics: &mut Vec<ConfigDiagnostic>,
2398 _policy: &ConfigPolicy,
2399 _section: &AtifSectionConfig,
2400) {
2401}
2402
2403fn validate_opentelemetry_section(
2404 diagnostics: &mut Vec<ConfigDiagnostic>,
2405 policy: &ConfigPolicy,
2406 section: &OpenTelemetrySectionConfig,
2407) {
2408 if section.enabled && section.endpoints.is_empty() {
2409 push_policy_diag(
2410 diagnostics,
2411 policy.unsupported_value,
2412 "observability.unsupported_value",
2413 Some("opentelemetry".to_string()),
2414 Some("endpoints".to_string()),
2415 "enabled OpenTelemetry section requires at least one endpoint".to_string(),
2416 );
2417 }
2418 for (index, endpoint) in section.endpoints.iter().enumerate() {
2419 if endpoint.endpoint.trim().is_empty() {
2420 push_policy_diag(
2421 diagnostics,
2422 policy.unsupported_value,
2423 "observability.unsupported_value",
2424 Some("opentelemetry".to_string()),
2425 Some(format!("endpoints[{index}].endpoint")),
2426 "OpenTelemetry endpoint must be a nonblank string".to_string(),
2427 );
2428 }
2429 if !matches!(endpoint.transport.as_str(), "http_binary" | "grpc") {
2430 push_policy_diag(
2431 diagnostics,
2432 policy.unsupported_value,
2433 "observability.unsupported_value",
2434 Some("opentelemetry".to_string()),
2435 Some(format!("endpoints[{index}].transport")),
2436 "OpenTelemetry endpoint transport must be 'http_binary' or 'grpc'".to_string(),
2437 );
2438 }
2439 if let Err(error) = validate_attribute_mappings(&endpoint.attribute_mappings) {
2440 push_policy_diag(
2441 diagnostics,
2442 policy.unsupported_value,
2443 "observability.unsupported_value",
2444 Some("opentelemetry".to_string()),
2445 Some(format!("endpoints[{index}].attribute_mappings")),
2446 error,
2447 );
2448 }
2449 validate_opentelemetry_headers(diagnostics, policy, index, endpoint);
2450 }
2451 for error in opentelemetry_destination_collision_errors(§ion.endpoints) {
2452 diagnostics.push(ConfigDiagnostic {
2453 level: DiagnosticLevel::Error,
2454 code: "observability.unsafe_otel_destination_collision".to_string(),
2455 component: Some("opentelemetry".to_string()),
2456 field: Some(format!("endpoints[{}].endpoint", error.index)),
2457 message: error.message,
2458 });
2459 }
2460 validate_opentelemetry_feature_support(diagnostics, policy, section);
2461}
2462
2463struct OpenTelemetryDestinationCollision {
2464 index: usize,
2465 message: String,
2466}
2467
2468#[derive(Debug, PartialEq, Eq)]
2469enum OpenTelemetryDestinationKey {
2470 Url {
2471 scheme: String,
2472 host: String,
2473 port: Option<u16>,
2474 path: String,
2475 query: Option<String>,
2476 },
2477 Raw(String),
2478}
2479
2480struct OpenTelemetryDestination {
2481 key: OpenTelemetryDestinationKey,
2482 display: String,
2483}
2484
2485fn validate_distinct_opentelemetry_destinations(
2486 endpoints: &[OpenTelemetryEndpointConfig],
2487) -> PluginResult<()> {
2488 if let Some(error) = opentelemetry_destination_collision_errors(endpoints)
2489 .into_iter()
2490 .next()
2491 {
2492 return Err(PluginError::InvalidConfig(error.message));
2493 }
2494 Ok(())
2495}
2496
2497fn opentelemetry_destination_collision_errors(
2498 endpoints: &[OpenTelemetryEndpointConfig],
2499) -> Vec<OpenTelemetryDestinationCollision> {
2500 let mut errors = Vec::new();
2501 for (index, endpoint) in endpoints.iter().enumerate() {
2502 for (other_index, other) in endpoints[..index].iter().enumerate() {
2503 let endpoint_destination = opentelemetry_destination(endpoint);
2504 let other_destination = opentelemetry_destination(other);
2505 if endpoint.transport == other.transport
2506 && endpoint_destination.key == other_destination.key
2507 && endpoint.otel_type != other.otel_type
2508 {
2509 errors.push(OpenTelemetryDestinationCollision {
2510 index,
2511 message: format!(
2512 "OpenTelemetry endpoints[{other_index}] ({}) and endpoints[{index}] ({}) use the same {} destination {:?}; different projection types must use independent destinations",
2513 opentelemetry_type_name(other.otel_type),
2514 opentelemetry_type_name(endpoint.otel_type),
2515 endpoint.transport,
2516 endpoint_destination.display,
2517 ),
2518 });
2519 }
2520 }
2521 }
2522 errors
2523}
2524
2525fn opentelemetry_destination(endpoint: &OpenTelemetryEndpointConfig) -> OpenTelemetryDestination {
2526 let configured_endpoint = endpoint.endpoint.trim();
2527 let effective_endpoint = if endpoint.transport == "http_binary" {
2528 resolve_http_trace_endpoint(configured_endpoint)
2529 } else {
2530 Cow::Borrowed(configured_endpoint)
2531 };
2532 canonicalize_opentelemetry_destination(&effective_endpoint)
2533}
2534
2535fn canonicalize_opentelemetry_destination(endpoint: &str) -> OpenTelemetryDestination {
2536 let Ok(url) = reqwest::Url::parse(endpoint) else {
2537 return raw_opentelemetry_destination(endpoint);
2538 };
2539 if !matches!(url.scheme(), "http" | "https") {
2540 return raw_opentelemetry_destination(endpoint);
2541 }
2542 let Some(url_host) = url.host_str() else {
2543 return raw_opentelemetry_destination(endpoint);
2544 };
2545
2546 let scheme = url.scheme().to_string();
2547 let host = canonical_opentelemetry_host(url_host);
2548 let port = url.port_or_known_default();
2549 let path = normalize_opentelemetry_path(url.path());
2550 let query = url.query().map(str::to_string);
2551 let display = format!(
2552 "{scheme}://{host}{}{path}{}",
2553 port.map(|port| format!(":{port}")).unwrap_or_default(),
2554 query
2555 .as_deref()
2556 .map(|query| format!("?{query}"))
2557 .unwrap_or_default(),
2558 );
2559 OpenTelemetryDestination {
2560 key: OpenTelemetryDestinationKey::Url {
2561 scheme,
2562 host,
2563 port,
2564 path,
2565 query,
2566 },
2567 display,
2568 }
2569}
2570
2571fn raw_opentelemetry_destination(endpoint: &str) -> OpenTelemetryDestination {
2572 OpenTelemetryDestination {
2573 key: OpenTelemetryDestinationKey::Raw(endpoint.to_string()),
2574 display: endpoint.to_string(),
2575 }
2576}
2577
2578fn canonical_opentelemetry_host(host: &str) -> String {
2579 let domain = host.strip_suffix('.').unwrap_or(host);
2580 let unbracketed = host
2581 .strip_prefix('[')
2582 .and_then(|host| host.strip_suffix(']'))
2583 .unwrap_or(host);
2584 let is_loopback_domain = domain == "localhost" || domain.ends_with(".localhost");
2585 let is_loopback_address = unbracketed
2586 .parse::<IpAddr>()
2587 .is_ok_and(|address| address.is_loopback());
2588 if is_loopback_domain || is_loopback_address {
2589 "<loopback>".to_string()
2590 } else {
2591 host.to_string()
2592 }
2593}
2594
2595fn normalize_opentelemetry_path(path: &str) -> String {
2596 let mut normalized = String::with_capacity(path.len());
2597 let mut previous_was_slash = false;
2598 for character in path.chars() {
2599 if character == '/' {
2600 if !previous_was_slash {
2601 normalized.push(character);
2602 }
2603 previous_was_slash = true;
2604 } else {
2605 normalized.push(character);
2606 previous_was_slash = false;
2607 }
2608 }
2609 while normalized.len() > 1 && normalized.ends_with('/') {
2610 normalized.pop();
2611 }
2612 normalized
2613}
2614
2615const fn opentelemetry_type_name(otel_type: OpenTelemetryType) -> &'static str {
2616 match otel_type {
2617 OpenTelemetryType::Full => "full",
2618 OpenTelemetryType::GenAi => "gen_ai",
2619 OpenTelemetryType::OpenInference => "openinference",
2620 }
2621}
2622
2623fn validate_opentelemetry_headers(
2624 diagnostics: &mut Vec<ConfigDiagnostic>,
2625 policy: &ConfigPolicy,
2626 index: usize,
2627 endpoint: &OpenTelemetryEndpointConfig,
2628) {
2629 validate_case_insensitive_header_duplicates(
2630 diagnostics,
2631 policy,
2632 index,
2633 "headers",
2634 endpoint.headers.keys(),
2635 );
2636 validate_case_insensitive_header_duplicates(
2637 diagnostics,
2638 policy,
2639 index,
2640 "header_env",
2641 endpoint.header_env.keys(),
2642 );
2643 for (header, value) in &endpoint.headers {
2644 let field = format!("endpoints[{index}].headers.{header}");
2645 validate_opentelemetry_header_name(diagnostics, policy, &field, header);
2646 validate_opentelemetry_header_value(diagnostics, policy, &field, header, value);
2647 }
2648 for (header, variable) in &endpoint.header_env {
2649 let field = format!("endpoints[{index}].header_env.{header}");
2650 validate_opentelemetry_header_name(diagnostics, policy, &field, header);
2651 if endpoint
2652 .headers
2653 .keys()
2654 .any(|configured| configured.eq_ignore_ascii_case(header))
2655 {
2656 push_policy_diag(
2657 diagnostics,
2658 policy.unsupported_value,
2659 "observability.unsupported_value",
2660 Some("opentelemetry".to_string()),
2661 Some(field.clone()),
2662 format!(
2663 "OpenTelemetry endpoints[{index}] header {header:?} cannot appear in both headers and header_env"
2664 ),
2665 );
2666 }
2667 validate_opentelemetry_header_env(diagnostics, policy, &field, variable);
2668 }
2669}
2670
2671fn validate_case_insensitive_header_duplicates<'a>(
2672 diagnostics: &mut Vec<ConfigDiagnostic>,
2673 policy: &ConfigPolicy,
2674 index: usize,
2675 map_name: &str,
2676 headers: impl Iterator<Item = &'a String>,
2677) {
2678 let mut normalized = HashSet::new();
2679 for header in headers {
2680 if !normalized.insert(header.to_ascii_lowercase()) {
2681 push_policy_diag(
2682 diagnostics,
2683 policy.unsupported_value,
2684 "observability.unsupported_value",
2685 Some("opentelemetry".to_string()),
2686 Some(format!("endpoints[{index}].{map_name}.{header}")),
2687 format!(
2688 "OpenTelemetry endpoints[{index}].{map_name} contains duplicate header {header:?} ignoring ASCII case"
2689 ),
2690 );
2691 }
2692 }
2693}
2694
2695fn validate_opentelemetry_header_name(
2696 diagnostics: &mut Vec<ConfigDiagnostic>,
2697 policy: &ConfigPolicy,
2698 field: &str,
2699 header: &str,
2700) {
2701 if header.trim().is_empty()
2702 || header.trim() != header
2703 || reqwest::header::HeaderName::from_bytes(header.as_bytes()).is_err()
2704 {
2705 push_policy_diag(
2706 diagnostics,
2707 policy.unsupported_value,
2708 "observability.unsupported_value",
2709 Some("opentelemetry".to_string()),
2710 Some(field.to_string()),
2711 format!("OpenTelemetry {field} header name {header:?} is invalid"),
2712 );
2713 }
2714}
2715
2716fn validate_opentelemetry_header_value(
2717 diagnostics: &mut Vec<ConfigDiagnostic>,
2718 policy: &ConfigPolicy,
2719 field: &str,
2720 header: &str,
2721 value: &str,
2722) {
2723 if reqwest::header::HeaderValue::from_str(value).is_err() {
2724 push_policy_diag(
2725 diagnostics,
2726 policy.unsupported_value,
2727 "observability.unsupported_value",
2728 Some("opentelemetry".to_string()),
2729 Some(field.to_string()),
2730 format!("OpenTelemetry header {header:?} has an invalid value"),
2731 );
2732 }
2733}
2734
2735fn validate_opentelemetry_header_env(
2736 diagnostics: &mut Vec<ConfigDiagnostic>,
2737 policy: &ConfigPolicy,
2738 field: &str,
2739 variable: &str,
2740) {
2741 let trimmed = variable.trim();
2742 let error = if trimmed.is_empty() {
2743 Some("must name a non-empty environment variable".to_string())
2744 } else if trimmed != variable {
2745 Some(format!(
2746 "must not have surrounding whitespace; got {variable:?}"
2747 ))
2748 } else {
2749 None
2750 };
2751 if let Some(error) = error {
2752 push_policy_diag(
2753 diagnostics,
2754 policy.unsupported_value,
2755 "observability.unsupported_value",
2756 Some("opentelemetry".to_string()),
2757 Some(field.to_string()),
2758 format!("OpenTelemetry {field} {error}"),
2759 );
2760 }
2761}
2762
2763fn validate_opentelemetry_feature_support(
2764 _diagnostics: &mut Vec<ConfigDiagnostic>,
2765 _policy: &ConfigPolicy,
2766 _section: &OpenTelemetrySectionConfig,
2767) {
2768}
2769
2770fn validate_version(diagnostics: &mut Vec<ConfigDiagnostic>, policy: &ConfigPolicy, version: u32) {
2771 if version != 3 {
2772 push_policy_diag(
2773 diagnostics,
2774 policy.unsupported_value,
2775 "observability.unsupported_config_version",
2776 Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
2777 Some("version".to_string()),
2778 format!(
2779 "observability config version {version} is unsupported; use version 3 and migrate OpenTelemetry and OpenInference exporters into opentelemetry.endpoints"
2780 ),
2781 );
2782 }
2783}
2784
2785fn validate_policy_fields(
2786 diagnostics: &mut Vec<ConfigDiagnostic>,
2787 policy: &ConfigPolicy,
2788 plugin_config: &Map<String, Json>,
2789) {
2790 if let Some(policy_json) = plugin_config.get("policy").and_then(Json::as_object) {
2791 validate_unknown_fields(
2792 diagnostics,
2793 policy,
2794 Some("policy".to_string()),
2795 policy_json,
2796 &["unknown_component", "unknown_field", "unsupported_value"],
2797 );
2798 }
2799}
2800
2801fn validate_section_fields(
2802 diagnostics: &mut Vec<ConfigDiagnostic>,
2803 policy: &ConfigPolicy,
2804 plugin_config: &Map<String, Json>,
2805 section: &str,
2806 known_fields: &[&str],
2807) {
2808 if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) {
2809 validate_unknown_fields(
2810 diagnostics,
2811 policy,
2812 Some(section.to_string()),
2813 section_json,
2814 known_fields,
2815 );
2816 }
2817}
2818
2819fn validate_atof_values(
2820 diagnostics: &mut Vec<ConfigDiagnostic>,
2821 policy: &ConfigPolicy,
2822 section: &AtofSectionConfig,
2823) {
2824 if section.enabled && section.sinks.is_empty() {
2825 push_policy_diag(
2826 diagnostics,
2827 policy.unsupported_value,
2828 "observability.unsupported_value",
2829 Some("atof".to_string()),
2830 Some("sinks".to_string()),
2831 "ATOF requires at least one configured sink when enabled".to_string(),
2832 );
2833 }
2834 let mut stream_sink_names = HashSet::new();
2835 for (index, sink) in section.sinks.iter().enumerate() {
2836 validate_atof_sink(diagnostics, policy, index, sink, &mut stream_sink_names);
2837 }
2838}
2839
2840fn validate_atof_sink<'a>(
2841 diagnostics: &mut Vec<ConfigDiagnostic>,
2842 policy: &ConfigPolicy,
2843 index: usize,
2844 sink: &'a AtofSinkSectionConfig,
2845 stream_sink_names: &mut HashSet<&'a str>,
2846) {
2847 match sink {
2848 AtofSinkSectionConfig::File(file) => {
2849 if AtofExporterMode::parse(&file.mode).is_none() {
2850 push_policy_diag(
2851 diagnostics,
2852 policy.unsupported_value,
2853 "observability.unsupported_value",
2854 Some("atof".to_string()),
2855 Some(format!("sinks[{index}].mode")),
2856 format!("ATOF sinks[{index}].mode must be 'append' or 'overwrite'"),
2857 );
2858 }
2859 }
2860 AtofSinkSectionConfig::Stream(stream) => {
2861 validate_atof_stream_sink_name(diagnostics, policy, index, stream, stream_sink_names);
2862 validate_atof_stream_sink_values(diagnostics, policy, index, stream);
2863 }
2864 }
2865}
2866
2867fn validate_atof_stream_sink_name<'a>(
2868 diagnostics: &mut Vec<ConfigDiagnostic>,
2869 policy: &ConfigPolicy,
2870 index: usize,
2871 stream: &'a AtofStreamSinkSectionConfig,
2872 stream_sink_names: &mut HashSet<&'a str>,
2873) {
2874 let Some(name) = stream.name.as_deref() else {
2875 return;
2876 };
2877 let trimmed = name.trim();
2878 let message = if trimmed.is_empty() {
2879 Some(format!("ATOF sinks[{index}].name must be non-empty"))
2880 } else if name != trimmed {
2881 Some(format!(
2882 "ATOF sinks[{index}].name must not have leading or trailing whitespace"
2883 ))
2884 } else if !stream_sink_names.insert(name) {
2885 Some(format!("ATOF stream sink name {name:?} must be unique"))
2886 } else {
2887 None
2888 };
2889 if let Some(message) = message {
2890 push_policy_diag(
2891 diagnostics,
2892 policy.unsupported_value,
2893 "observability.unsupported_value",
2894 Some("atof".to_string()),
2895 Some(format!("sinks[{index}].name")),
2896 message,
2897 );
2898 }
2899}
2900
2901fn validate_atof_stream_sink_values(
2902 diagnostics: &mut Vec<ConfigDiagnostic>,
2903 policy: &ConfigPolicy,
2904 index: usize,
2905 endpoint: &AtofStreamSinkSectionConfig,
2906) {
2907 let transport = AtofEndpointTransport::parse(&endpoint.transport);
2908 if endpoint.url.trim().is_empty() {
2909 push_policy_diag(
2910 diagnostics,
2911 policy.unsupported_value,
2912 "observability.unsupported_value",
2913 Some("atof".to_string()),
2914 Some(format!("sinks[{index}].url")),
2915 format!("ATOF sinks[{index}].url must be non-empty"),
2916 );
2917 } else if transport.is_some_and(|transport| !is_valid_atof_stream_url(&endpoint.url, transport))
2918 {
2919 push_policy_diag(
2920 diagnostics,
2921 policy.unsupported_value,
2922 "observability.unsupported_value",
2923 Some("atof".to_string()),
2924 Some(format!("sinks[{index}].url")),
2925 format!(
2926 "ATOF sinks[{index}].url must be a valid URL for transport {:?}",
2927 endpoint.transport
2928 ),
2929 );
2930 }
2931 if transport.is_none() {
2932 push_policy_diag(
2933 diagnostics,
2934 policy.unsupported_value,
2935 "observability.unsupported_value",
2936 Some("atof".to_string()),
2937 Some(format!("sinks[{index}].transport")),
2938 format!("ATOF sinks[{index}].transport must be 'http_post', 'websocket', or 'ndjson'"),
2939 );
2940 }
2941 if endpoint.timeout_millis == 0 {
2942 push_policy_diag(
2943 diagnostics,
2944 policy.unsupported_value,
2945 "observability.unsupported_value",
2946 Some("atof".to_string()),
2947 Some(format!("sinks[{index}].timeout_millis")),
2948 format!("ATOF sinks[{index}].timeout_millis must be greater than 0"),
2949 );
2950 }
2951 if AtofEndpointFieldNamePolicy::parse(&endpoint.field_name_policy).is_none() {
2952 push_policy_diag(
2953 diagnostics,
2954 policy.unsupported_value,
2955 "observability.unsupported_value",
2956 Some("atof".to_string()),
2957 Some(format!("sinks[{index}].field_name_policy")),
2958 format!("ATOF sinks[{index}].field_name_policy must be 'preserve' or 'replace_dots'"),
2959 );
2960 }
2961 for (header, value) in &endpoint.headers {
2962 validate_atof_stream_header(
2963 diagnostics,
2964 policy,
2965 &format!("sinks[{index}].headers.{header}"),
2966 header,
2967 value,
2968 );
2969 }
2970 for (header, variable) in &endpoint.header_env {
2971 let field = format!("sinks[{index}].header_env.{header}");
2972 validate_atof_stream_header_name(diagnostics, policy, &field, header);
2973 if endpoint
2974 .headers
2975 .keys()
2976 .any(|configured| configured.eq_ignore_ascii_case(header))
2977 {
2978 push_policy_diag(
2979 diagnostics,
2980 policy.unsupported_value,
2981 "observability.unsupported_value",
2982 Some("atof".to_string()),
2983 Some(field.clone()),
2984 format!(
2985 "ATOF sinks[{index}] header {header:?} cannot appear in both headers and header_env"
2986 ),
2987 );
2988 }
2989 validate_atof_stream_header_env(diagnostics, policy, &field, variable);
2990 }
2991}
2992
2993#[cfg(feature = "atof-streaming")]
2994fn is_valid_atof_stream_url(url: &str, transport: AtofEndpointTransport) -> bool {
2995 let Ok(url) = reqwest::Url::parse(url) else {
2996 return false;
2997 };
2998 url.host_str().is_some()
2999 && match transport {
3000 AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
3001 matches!(url.scheme(), "http" | "https")
3002 }
3003 AtofEndpointTransport::Websocket => matches!(url.scheme(), "ws" | "wss"),
3004 }
3005}
3006
3007#[cfg(not(feature = "atof-streaming"))]
3008fn is_valid_atof_stream_url(url: &str, transport: AtofEndpointTransport) -> bool {
3009 let Some((scheme, rest)) = url.split_once("://") else {
3010 return false;
3011 };
3012 !rest.is_empty()
3013 && !rest.starts_with('/')
3014 && match transport {
3015 AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
3016 matches!(scheme, "http" | "https")
3017 }
3018 AtofEndpointTransport::Websocket => matches!(scheme, "ws" | "wss"),
3019 }
3020}
3021
3022fn validate_atof_stream_header(
3023 diagnostics: &mut Vec<ConfigDiagnostic>,
3024 policy: &ConfigPolicy,
3025 field: &str,
3026 header: &str,
3027 value: &str,
3028) {
3029 validate_atof_stream_header_name(diagnostics, policy, field, header);
3030 #[cfg(not(feature = "atof-streaming"))]
3031 let _ = value;
3032 #[cfg(feature = "atof-streaming")]
3033 if let Err(error) = reqwest::header::HeaderValue::from_str(value) {
3034 push_policy_diag(
3035 diagnostics,
3036 policy.unsupported_value,
3037 "observability.unsupported_value",
3038 Some("atof".to_string()),
3039 Some(field.to_string()),
3040 format!("ATOF {field} value is invalid: {error}"),
3041 );
3042 }
3043}
3044
3045fn validate_atof_stream_header_name(
3046 diagnostics: &mut Vec<ConfigDiagnostic>,
3047 policy: &ConfigPolicy,
3048 field: &str,
3049 header: &str,
3050) {
3051 #[cfg(feature = "atof-streaming")]
3052 let is_valid = reqwest::header::HeaderName::from_bytes(header.as_bytes()).is_ok();
3053 #[cfg(not(feature = "atof-streaming"))]
3054 let is_valid = !header.trim().is_empty() && header.trim() == header;
3055 if !is_valid {
3056 push_policy_diag(
3057 diagnostics,
3058 policy.unsupported_value,
3059 "observability.unsupported_value",
3060 Some("atof".to_string()),
3061 Some(field.to_string()),
3062 format!("ATOF {field} header name '{header}' is invalid"),
3063 );
3064 }
3065}
3066
3067fn validate_atof_stream_header_env(
3068 diagnostics: &mut Vec<ConfigDiagnostic>,
3069 policy: &ConfigPolicy,
3070 field: &str,
3071 variable: &str,
3072) {
3073 let trimmed = variable.trim();
3074 if trimmed.is_empty() {
3075 push_policy_diag(
3076 diagnostics,
3077 policy.unsupported_value,
3078 "observability.unsupported_value",
3079 Some("atof".to_string()),
3080 Some(field.to_string()),
3081 format!("ATOF {field} must name a non-empty environment variable"),
3082 );
3083 } else if trimmed != variable {
3084 push_policy_diag(
3085 diagnostics,
3086 policy.unsupported_value,
3087 "observability.unsupported_value",
3088 Some("atof".to_string()),
3089 Some(field.to_string()),
3090 format!("ATOF {field} must not have surrounding whitespace; got '{variable}'"),
3091 );
3092 } else {
3093 match std::env::var(variable) {
3094 Ok(value) if value.trim().is_empty() => push_policy_diag(
3095 diagnostics,
3096 policy.unsupported_value,
3097 "observability.unsupported_value",
3098 Some("atof".to_string()),
3099 Some(field.to_string()),
3100 format!("ATOF {field} references an environment variable that is blank"),
3101 ),
3102 Ok(_) => {}
3103 Err(error) => push_policy_diag(
3104 diagnostics,
3105 policy.unsupported_value,
3106 "observability.unsupported_value",
3107 Some("atof".to_string()),
3108 Some(field.to_string()),
3109 format!("ATOF {field} references an environment variable that is not set: {error}"),
3110 ),
3111 }
3112 }
3113}
3114
3115fn validate_atif_values(
3116 diagnostics: &mut Vec<ConfigDiagnostic>,
3117 policy: &ConfigPolicy,
3118 section: &AtifSectionConfig,
3119) {
3120 if let Err(message) = validate_atif_filename_template(§ion.filename_template) {
3121 push_policy_diag(
3122 diagnostics,
3123 policy.unsupported_value,
3124 "observability.unsupported_value",
3125 Some("atif".to_string()),
3126 Some("filename_template".to_string()),
3127 message,
3128 );
3129 }
3130 for (index, storage) in section.storage.iter().enumerate() {
3131 validate_atif_storage_values(diagnostics, policy, index, storage);
3132 }
3133}
3134
3135fn validate_atif_storage_values(
3136 diagnostics: &mut Vec<ConfigDiagnostic>,
3137 policy: &ConfigPolicy,
3138 index: usize,
3139 storage: &AtifStorageConfig,
3140) {
3141 match storage {
3142 AtifStorageConfig::Http(http) => {
3143 validate_atif_http_endpoint(
3144 diagnostics,
3145 policy,
3146 &format!("storage[{index}].endpoint"),
3147 &http.endpoint,
3148 );
3149 if http.timeout_millis == 0 {
3150 push_policy_diag(
3151 diagnostics,
3152 policy.unsupported_value,
3153 "observability.unsupported_value",
3154 Some("atif".to_string()),
3155 Some(format!("storage[{index}].timeout_millis")),
3156 format!("ATIF storage[{index}].timeout_millis must be positive"),
3157 );
3158 }
3159 for (header, value) in &http.headers {
3160 validate_atif_http_header(
3161 diagnostics,
3162 policy,
3163 &format!("storage[{index}].headers.{header}"),
3164 header,
3165 value,
3166 );
3167 }
3168 for (header, var_name) in &http.header_env {
3169 validate_atif_http_header_name(
3170 diagnostics,
3171 policy,
3172 &format!("storage[{index}].header_env.{header}"),
3173 header,
3174 );
3175 validate_atif_storage_env_var(
3176 diagnostics,
3177 policy,
3178 &format!("storage[{index}].header_env.{header}"),
3179 Some(var_name.as_str()),
3180 );
3181 }
3182 }
3183 AtifStorageConfig::S3(s3) => {
3184 if s3.bucket.trim().is_empty() {
3185 push_policy_diag(
3186 diagnostics,
3187 policy.unsupported_value,
3188 "observability.unsupported_value",
3189 Some("atif".to_string()),
3190 Some(format!("storage[{index}].bucket")),
3191 format!("ATIF storage[{index}].bucket must be non-empty"),
3192 );
3193 }
3194 validate_atif_storage_env_var(
3195 diagnostics,
3196 policy,
3197 &format!("storage[{index}].secret_access_key_var"),
3198 s3.secret_access_key_var.as_deref(),
3199 );
3200 validate_atif_storage_env_var(
3201 diagnostics,
3202 policy,
3203 &format!("storage[{index}].session_token_var"),
3204 s3.session_token_var.as_deref(),
3205 );
3206 }
3207 }
3208}
3209
3210fn validate_atif_http_header(
3211 diagnostics: &mut Vec<ConfigDiagnostic>,
3212 policy: &ConfigPolicy,
3213 field: &str,
3214 header: &str,
3215 _value: &str,
3216) {
3217 validate_atif_http_header_name(diagnostics, policy, field, header);
3218 #[cfg(feature = "object-store")]
3219 if let Err(err) = reqwest::header::HeaderValue::from_str(_value) {
3220 push_policy_diag(
3221 diagnostics,
3222 policy.unsupported_value,
3223 "observability.unsupported_value",
3224 Some("atif".to_string()),
3225 Some(field.to_string()),
3226 format!("ATIF {field} value is invalid: {err}"),
3227 );
3228 }
3229}
3230
3231fn validate_atif_http_header_name(
3232 diagnostics: &mut Vec<ConfigDiagnostic>,
3233 policy: &ConfigPolicy,
3234 field: &str,
3235 header: &str,
3236) {
3237 #[cfg(feature = "object-store")]
3238 let is_valid = reqwest::header::HeaderName::from_bytes(header.as_bytes()).is_ok();
3239 #[cfg(not(feature = "object-store"))]
3240 let is_valid = !header.trim().is_empty() && header.trim() == header;
3241 if !is_valid {
3242 push_policy_diag(
3243 diagnostics,
3244 policy.unsupported_value,
3245 "observability.unsupported_value",
3246 Some("atif".to_string()),
3247 Some(field.to_string()),
3248 format!("ATIF {field} header name '{header}' is invalid"),
3249 );
3250 }
3251}
3252
3253fn validate_atif_http_endpoint(
3254 diagnostics: &mut Vec<ConfigDiagnostic>,
3255 policy: &ConfigPolicy,
3256 field: &str,
3257 endpoint: &str,
3258) {
3259 let trimmed = endpoint.trim();
3260 let mut is_valid = !trimmed.is_empty() && trimmed == endpoint;
3261 #[cfg(feature = "object-store")]
3262 {
3263 is_valid = is_valid
3264 && reqwest::Url::parse(endpoint)
3265 .map(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
3266 .unwrap_or(false);
3267 }
3268 #[cfg(not(feature = "object-store"))]
3269 {
3270 let valid_scheme = trimmed.starts_with("http://") || trimmed.starts_with("https://");
3271 let has_host = trimmed
3272 .split_once("://")
3273 .map(|(_, rest)| !rest.is_empty() && !rest.starts_with('/'))
3274 .unwrap_or(false);
3275 is_valid = is_valid && valid_scheme && has_host;
3276 }
3277 if !is_valid {
3278 push_policy_diag(
3279 diagnostics,
3280 policy.unsupported_value,
3281 "observability.unsupported_value",
3282 Some("atif".to_string()),
3283 Some(field.to_string()),
3284 format!("ATIF {field} must be a valid http:// or https:// URL"),
3285 );
3286 }
3287}
3288
3289fn validate_atif_storage_env_var(
3290 diagnostics: &mut Vec<ConfigDiagnostic>,
3291 policy: &ConfigPolicy,
3292 field: &str,
3293 var_name: Option<&str>,
3294) {
3295 let Some(var_name) = var_name else {
3296 return;
3297 };
3298 let trimmed = var_name.trim();
3299 if trimmed.is_empty() {
3300 push_policy_diag(
3301 diagnostics,
3302 policy.unsupported_value,
3303 "observability.unsupported_value",
3304 Some("atif".to_string()),
3305 Some(field.to_string()),
3306 format!("ATIF {field} must be the name of an environment variable, not empty"),
3307 );
3308 return;
3309 }
3310 if trimmed != var_name {
3311 push_policy_diag(
3312 diagnostics,
3313 policy.unsupported_value,
3314 "observability.unsupported_value",
3315 Some("atif".to_string()),
3316 Some(field.to_string()),
3317 format!("ATIF {field} must not have surrounding whitespace; got '{var_name}'"),
3318 );
3319 return;
3320 }
3321 match std::env::var(var_name) {
3322 Ok(value) if !value.is_empty() => {}
3323 Ok(_) => {
3324 push_policy_diag(
3325 diagnostics,
3326 policy.unsupported_value,
3327 "observability.unsupported_value",
3328 Some("atif".to_string()),
3329 Some(field.to_string()),
3330 format!(
3331 "ATIF {field}='{var_name}' references an environment variable that is set but empty"
3332 ),
3333 );
3334 }
3335 Err(_) => {
3336 push_policy_diag(
3337 diagnostics,
3338 policy.unsupported_value,
3339 "observability.unsupported_value",
3340 Some("atif".to_string()),
3341 Some(field.to_string()),
3342 format!(
3343 "ATIF {field}='{var_name}' references an environment variable that is not set"
3344 ),
3345 );
3346 }
3347 }
3348}
3349
3350fn validate_unknown_fields(
3351 diagnostics: &mut Vec<ConfigDiagnostic>,
3352 policy: &ConfigPolicy,
3353 component: Option<String>,
3354 config: &Map<String, Json>,
3355 known_fields: &[&str],
3356) {
3357 for field in config.keys() {
3358 if !known_fields.contains(&field.as_str()) {
3359 push_policy_diag(
3360 diagnostics,
3361 policy.unknown_field,
3362 "observability.unknown_field",
3363 component.clone(),
3364 Some(field.clone()),
3365 format!(
3366 "field '{}' is not recognized for '{}'",
3367 field,
3368 component.as_deref().unwrap_or("unknown")
3369 ),
3370 );
3371 }
3372 }
3373}
3374
3375fn push_policy_diag(
3376 diagnostics: &mut Vec<ConfigDiagnostic>,
3377 behavior: UnsupportedBehavior,
3378 code: &str,
3379 component: Option<String>,
3380 field: Option<String>,
3381 message: String,
3382) {
3383 let level = match behavior {
3384 UnsupportedBehavior::Ignore => return,
3385 UnsupportedBehavior::Warn => DiagnosticLevel::Warning,
3386 UnsupportedBehavior::Error => DiagnosticLevel::Error,
3387 };
3388 diagnostics.push(ConfigDiagnostic {
3389 level,
3390 code: code.to_string(),
3391 component,
3392 field,
3393 message,
3394 });
3395}
3396
3397fn observability_registration_error(error: impl std::fmt::Display) -> PluginError {
3398 PluginError::RegistrationFailed(error.to_string())
3399}
3400
3401fn default_observability_config_version() -> u32 {
3402 3
3403}
3404
3405fn default_atof_mode() -> String {
3406 "append".to_string()
3407}
3408
3409fn default_atof_endpoint_transport() -> String {
3410 AtofEndpointTransport::default().as_str().to_string()
3411}
3412
3413fn default_atof_endpoint_field_name_policy() -> String {
3414 AtofEndpointFieldNamePolicy::default().as_str().to_string()
3415}
3416
3417fn default_agent_name() -> String {
3418 "NeMo Relay".to_string()
3419}
3420
3421fn default_agent_version() -> String {
3422 env!("CARGO_PKG_VERSION").to_string()
3423}
3424
3425fn default_model_name() -> String {
3426 "unknown".to_string()
3427}
3428
3429fn default_atif_filename_template() -> String {
3430 "nemo-relay-atif-{session_id}.json".to_string()
3431}
3432
3433fn default_otlp_transport() -> String {
3434 "http_binary".to_string()
3435}
3436
3437fn default_otel_service_name() -> String {
3438 "unknown_service".to_string()
3439}
3440
3441fn default_otel_instrumentation_scope() -> String {
3442 "opentelemetry".to_string()
3443}
3444
3445fn default_timeout_millis() -> u64 {
3446 3_000
3447}
3448
3449fn default_output_directory() -> PathBuf {
3450 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
3451}
3452
3453#[cfg(not(feature = "object-store"))]
3454struct AtifRemoteStorage;
3455
3456#[cfg(feature = "object-store")]
3464struct AtifRemoteStorage {
3465 sender: std::sync::mpsc::Sender<AtifUploadRequest>,
3466 key_prefix: String,
3467 index: usize,
3468 resource_kind: &'static str,
3469 access_state: AtomicU8,
3470}
3471
3472#[cfg(feature = "object-store")]
3473struct AtifUploadRequest {
3474 key: String,
3475 filename: String,
3476 session_id: String,
3477 payload: Vec<u8>,
3478 reply: std::sync::mpsc::Sender<std::io::Result<()>>,
3479}
3480
3481#[cfg(feature = "object-store")]
3482#[derive(Clone)]
3483struct HttpUploadConfig {
3484 endpoint: String,
3485 headers: HashMap<String, String>,
3486 timeout: Duration,
3487}
3488
3489#[cfg(feature = "object-store")]
3490#[derive(Default)]
3491struct S3BuilderOverrides {
3492 access_key_id: Option<String>,
3493 secret_access_key: Option<String>,
3494 session_token: Option<String>,
3495 region: Option<String>,
3496 endpoint_url: Option<String>,
3497 allow_http: Option<bool>,
3498}
3499
3500#[cfg(feature = "object-store")]
3501impl S3BuilderOverrides {
3502 fn resolve(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
3503 Ok(Self {
3504 access_key_id: s3.access_key_id.clone(),
3505 secret_access_key: resolve_env_var_field(
3506 &format!("storage[{index}].secret_access_key_var"),
3507 s3.secret_access_key_var.as_deref(),
3508 )?,
3509 session_token: resolve_env_var_field(
3510 &format!("storage[{index}].session_token_var"),
3511 s3.session_token_var.as_deref(),
3512 )?,
3513 region: s3.region.clone(),
3514 endpoint_url: s3.endpoint_url.clone(),
3515 allow_http: s3.allow_http,
3516 })
3517 }
3518
3519 fn apply(
3520 self,
3521 mut builder: object_store::aws::AmazonS3Builder,
3522 ) -> object_store::aws::AmazonS3Builder {
3523 if let Some(value) = self.access_key_id {
3524 builder = builder.with_access_key_id(value);
3525 }
3526 if let Some(value) = self.secret_access_key {
3527 builder = builder.with_secret_access_key(value);
3528 }
3529 if let Some(value) = self.session_token {
3530 builder = builder.with_token(value);
3531 }
3532 if let Some(value) = self.region {
3533 builder = builder.with_region(value);
3534 }
3535 if let Some(value) = self.endpoint_url {
3536 builder = builder.with_endpoint(value);
3537 }
3538 if let Some(value) = self.allow_http {
3539 builder = builder.with_allow_http(value);
3540 }
3541 builder
3542 }
3543}
3544
3545#[cfg(feature = "object-store")]
3546fn resolve_env_var_field(field: &str, var_name: Option<&str>) -> std::io::Result<Option<String>> {
3547 let Some(var_name) = var_name else {
3548 return Ok(None);
3549 };
3550 if var_name.trim().is_empty() || var_name.trim() != var_name {
3551 return Err(std::io::Error::other(format!(
3552 "ATIF {field} must be the name of an environment variable, not '{var_name}'"
3553 )));
3554 }
3555 match std::env::var(var_name) {
3556 Ok(value) if !value.is_empty() => Ok(Some(value)),
3557 Ok(_) => Err(std::io::Error::other(format!(
3558 "ATIF {field}='{var_name}' references an environment variable that is set but empty"
3559 ))),
3560 Err(_) => Err(std::io::Error::other(format!(
3561 "ATIF {field}='{var_name}' references an environment variable that is not set"
3562 ))),
3563 }
3564}
3565
3566#[cfg(feature = "object-store")]
3567impl AtifRemoteStorage {
3568 fn from_config(index: usize, config: &AtifStorageConfig) -> std::io::Result<Self> {
3569 match config {
3570 AtifStorageConfig::Http(http) => Self::build_http(index, http),
3571 AtifStorageConfig::S3(s3) => Self::build_s3(index, s3),
3572 }
3573 }
3574
3575 fn build_http(index: usize, http: &HttpStorageConfig) -> std::io::Result<Self> {
3576 let upload_config = HttpUploadConfig::resolve(index, http)?;
3577 let (req_tx, req_rx) = std::sync::mpsc::channel::<AtifUploadRequest>();
3578 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<std::io::Result<()>>();
3579
3580 std::thread::Builder::new()
3581 .name("nemo-relay-atif-storage".to_string())
3582 .spawn(move || {
3583 let runtime = match tokio::runtime::Builder::new_current_thread()
3584 .enable_all()
3585 .build()
3586 {
3587 Ok(rt) => rt,
3588 Err(err) => {
3589 let _ = ready_tx.send(Err(std::io::Error::other(format!(
3590 "failed to build ATIF storage runtime: {err}"
3591 ))));
3592 return;
3593 }
3594 };
3595 let client = match reqwest::Client::builder()
3596 .timeout(upload_config.timeout)
3597 .build()
3598 {
3599 Ok(client) => client,
3600 Err(err) => {
3601 let _ = ready_tx.send(Err(std::io::Error::other(format!(
3602 "failed to build HTTP client for ATIF storage[{}]: {err}",
3603 index
3604 ))));
3605 return;
3606 }
3607 };
3608 if ready_tx.send(Ok(())).is_err() {
3609 return;
3610 }
3611 drop(ready_tx);
3612
3613 while let Ok(request) = req_rx.recv() {
3614 let result = runtime.block_on(post_atif_http(
3615 &client,
3616 &upload_config,
3617 request.filename,
3618 request.session_id,
3619 request.payload,
3620 ));
3621 let _ = request.reply.send(result);
3622 }
3623 })
3624 .map_err(|err| {
3625 std::io::Error::other(format!("failed to spawn ATIF storage thread: {err}"))
3626 })?;
3627
3628 match ready_rx.recv() {
3629 Ok(Ok(())) => Ok(Self {
3630 sender: req_tx,
3631 key_prefix: String::new(),
3632 index,
3633 resource_kind: "http_endpoint",
3634 access_state: AtomicU8::new(0),
3635 }),
3636 Ok(Err(err)) => Err(err),
3637 Err(_) => Err(std::io::Error::other(
3638 "ATIF storage thread exited before signalling readiness",
3639 )),
3640 }
3641 }
3642
3643 fn build_s3(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
3644 let bucket = s3.bucket.clone();
3645 let key_prefix = normalize_storage_key_prefix(s3.key_prefix.as_deref());
3646 let overrides = S3BuilderOverrides::resolve(index, s3)?;
3647
3648 let (req_tx, req_rx) = std::sync::mpsc::channel::<AtifUploadRequest>();
3649 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<std::io::Result<()>>();
3650
3651 std::thread::Builder::new()
3652 .name("nemo-relay-atif-storage".to_string())
3653 .spawn(move || {
3654 let runtime = match tokio::runtime::Builder::new_current_thread()
3655 .enable_all()
3656 .build()
3657 {
3658 Ok(rt) => rt,
3659 Err(err) => {
3660 let _ = ready_tx.send(Err(std::io::Error::other(format!(
3661 "failed to build ATIF storage runtime: {err}"
3662 ))));
3663 return;
3664 }
3665 };
3666 let store = match overrides
3667 .apply(object_store::aws::AmazonS3Builder::from_env())
3668 .with_bucket_name(&bucket)
3669 .build()
3670 {
3671 Ok(store) => Arc::new(store) as Arc<dyn object_store::ObjectStore>,
3672 Err(err) => {
3673 let _ = ready_tx.send(Err(std::io::Error::other(format!(
3674 "failed to build S3 client for bucket '{bucket}': {err}"
3675 ))));
3676 return;
3677 }
3678 };
3679 if ready_tx.send(Ok(())).is_err() {
3680 return;
3681 }
3682 drop(ready_tx);
3683
3684 while let Ok(request) = req_rx.recv() {
3685 let result = runtime.block_on(async {
3686 use object_store::ObjectStoreExt as _;
3687 store
3688 .put(
3689 &object_store::path::Path::from(request.key.clone()),
3690 object_store::PutPayload::from(request.payload),
3691 )
3692 .await
3693 .map(|_| ())
3694 .map_err(|err| {
3695 std::io::Error::other(format!(
3696 "S3 upload to '{}' failed: {err}",
3697 request.key
3698 ))
3699 })
3700 });
3701 let _ = request.reply.send(result);
3702 }
3703 })
3704 .map_err(|err| {
3705 std::io::Error::other(format!("failed to spawn ATIF storage thread: {err}"))
3706 })?;
3707
3708 match ready_rx.recv() {
3709 Ok(Ok(())) => Ok(Self {
3710 sender: req_tx,
3711 key_prefix,
3712 index,
3713 resource_kind: "s3_bucket",
3714 access_state: AtomicU8::new(0),
3715 }),
3716 Ok(Err(err)) => Err(err),
3717 Err(_) => Err(std::io::Error::other(
3718 "ATIF storage thread exited before signalling readiness",
3719 )),
3720 }
3721 }
3722
3723 fn put(&self, filename: &str, session_id: &str, payload: &[u8]) -> std::io::Result<()> {
3724 let key = format!("{}{}", self.key_prefix, filename);
3725 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
3726 self.sender
3727 .send(AtifUploadRequest {
3728 key,
3729 filename: filename.to_string(),
3730 session_id: session_id.to_string(),
3731 payload: payload.to_vec(),
3732 reply: reply_tx,
3733 })
3734 .map_err(|_| std::io::Error::other("ATIF storage thread is not running"))?;
3735 let (result, failure_reason) = match reply_rx.recv() {
3736 Ok(result) => (result, "upload_failed"),
3737 Err(_) => (
3738 Err(std::io::Error::other(
3739 "ATIF storage thread dropped the upload reply",
3740 )),
3741 "reply_channel_closed",
3742 ),
3743 };
3744 match &result {
3745 Ok(()) => {
3746 if self.access_state.swap(2, Ordering::AcqRel) != 2 {
3747 log::info!(
3748 target: "nemo_relay.observability",
3749 event = "storage_access_validated",
3750 plugin_kind = "observability",
3751 exporter = "atif",
3752 resource_index = self.index,
3753 resource_kind = self.resource_kind,
3754 permission = "write";
3755 "ATIF storage access validated"
3756 );
3757 }
3758 }
3759 Err(_) => {
3760 if self.access_state.swap(1, Ordering::AcqRel) != 1 {
3761 log::warn!(
3762 target: "nemo_relay.observability",
3763 event = "storage_access_failed",
3764 plugin_kind = "observability",
3765 exporter = "atif",
3766 resource_index = self.index,
3767 resource_kind = self.resource_kind,
3768 permission = "write",
3769 reason = failure_reason;
3770 "ATIF storage access failed"
3771 );
3772 }
3773 }
3774 }
3775 result
3776 }
3777}
3778
3779#[cfg(feature = "object-store")]
3780impl HttpUploadConfig {
3781 fn resolve(index: usize, http: &HttpStorageConfig) -> std::io::Result<Self> {
3782 let endpoint = http.endpoint.trim();
3783 if endpoint.is_empty() || endpoint != http.endpoint {
3784 return Err(std::io::Error::other(format!(
3785 "ATIF storage[{index}].endpoint must be non-empty and must not have surrounding whitespace"
3786 )));
3787 }
3788 let parsed = reqwest::Url::parse(endpoint).map_err(|err| {
3789 std::io::Error::other(format!(
3790 "ATIF storage[{index}].endpoint must be a valid URL: {err}"
3791 ))
3792 })?;
3793 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none() {
3794 return Err(std::io::Error::other(format!(
3795 "ATIF storage[{index}].endpoint must be a valid http:// or https:// URL"
3796 )));
3797 }
3798 if http.timeout_millis == 0 {
3799 return Err(std::io::Error::other(format!(
3800 "ATIF storage[{index}].timeout_millis must be positive"
3801 )));
3802 }
3803
3804 let mut headers = http.headers.clone();
3805 for (header, var_name) in &http.header_env {
3806 let value = resolve_env_var_field(
3807 &format!("storage[{index}].header_env.{header}"),
3808 Some(var_name.as_str()),
3809 )?
3810 .expect("resolve_env_var_field returns Some when var_name is Some");
3811 headers.insert(header.clone(), value);
3812 }
3813 validate_http_headers(index, &headers)?;
3814
3815 Ok(Self {
3816 endpoint: parsed.to_string(),
3817 headers,
3818 timeout: Duration::from_millis(http.timeout_millis),
3819 })
3820 }
3821}
3822
3823#[cfg(feature = "object-store")]
3824fn validate_http_headers(index: usize, headers: &HashMap<String, String>) -> std::io::Result<()> {
3825 for (header, value) in headers {
3826 reqwest::header::HeaderName::from_bytes(header.as_bytes()).map_err(|err| {
3827 std::io::Error::other(format!(
3828 "ATIF storage[{index}] header name '{header}' is invalid: {err}"
3829 ))
3830 })?;
3831 reqwest::header::HeaderValue::from_str(value).map_err(|err| {
3832 std::io::Error::other(format!(
3833 "ATIF storage[{index}] value for header '{header}' is invalid: {err}"
3834 ))
3835 })?;
3836 }
3837 Ok(())
3838}
3839
3840#[cfg(feature = "object-store")]
3841async fn post_atif_http(
3842 client: &reqwest::Client,
3843 config: &HttpUploadConfig,
3844 filename: String,
3845 session_id: String,
3846 payload: Vec<u8>,
3847) -> std::io::Result<()> {
3848 let mut request = client.post(&config.endpoint);
3849 for (header, value) in &config.headers {
3850 request = request.header(header.as_str(), value.as_str());
3851 }
3852 let response = request
3853 .header(reqwest::header::CONTENT_TYPE, "application/json")
3854 .header("x-nemo-relay-atif-filename", filename.clone())
3855 .header("x-nemo-relay-atif-session-id", session_id)
3856 .body(payload)
3857 .send()
3858 .await
3859 .map_err(|err| {
3860 std::io::Error::other(format!(
3861 "HTTP ATIF upload to '{}' failed: {err}",
3862 config.endpoint
3863 ))
3864 })?;
3865 if response.status().is_success() {
3866 Ok(())
3867 } else {
3868 Err(std::io::Error::other(format!(
3869 "HTTP ATIF upload to '{}' for '{}' failed with status {}",
3870 config.endpoint,
3871 filename,
3872 response.status()
3873 )))
3874 }
3875}
3876
3877#[cfg(feature = "object-store")]
3878fn normalize_storage_key_prefix(raw: Option<&str>) -> String {
3879 let trimmed = raw.unwrap_or("").trim();
3880 if trimmed.is_empty() {
3881 return String::new();
3882 }
3883 if trimmed.ends_with('/') {
3884 trimmed.to_string()
3885 } else {
3886 format!("{trimmed}/")
3887 }
3888}
3889
3890#[cfg(test)]
3891#[path = "../../tests/unit/observability/plugin_component_tests.rs"]
3892mod tests;