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