1use std::collections::{HashMap, HashSet};
20use std::future::Future;
21use std::path::PathBuf;
22use std::pin::Pin;
23use std::sync::{Arc, Mutex};
24#[cfg(any(feature = "otel", feature = "openinference"))]
25use std::time::Duration;
26
27use serde::{Deserialize, Serialize};
28use serde_json::{Map, Value as Json};
29use uuid::Uuid;
30
31use crate::api::event::{Event, ScopeCategory};
32use crate::api::runtime::{EventSubscriberFn, current_scope_stack};
33use crate::api::scope::ScopeType;
34use crate::api::subscriber::{scope_deregister_subscriber, scope_register_subscriber};
35use crate::observability::atif::{AtifAgentInfo, AtifExporter};
36use crate::observability::atof::{
37 AtofExporter, AtofExporterConfig as CoreAtofExporterConfig, AtofExporterMode,
38};
39#[cfg(feature = "openinference")]
40use crate::observability::openinference::{
41 OpenInferenceConfig as CoreOpenInferenceConfig, OpenInferenceSubscriber,
42 OtlpTransport as OpenInferenceTransport,
43};
44#[cfg(feature = "otel")]
45use crate::observability::otel::{
46 OpenTelemetryConfig as CoreOpenTelemetryConfig, OpenTelemetrySubscriber,
47};
48use crate::plugin::{
49 ConfigDiagnostic, ConfigPolicy, DiagnosticLevel, Plugin, PluginComponentSpec, PluginError,
50 PluginRegistration, PluginRegistrationContext, Result as PluginResult, UnsupportedBehavior,
51 deregister_plugin, register_plugin,
52};
53
54pub const OBSERVABILITY_PLUGIN_KIND: &str = "observability";
56
57#[derive(Debug, Clone)]
63pub struct ComponentSpec {
64 pub enabled: bool,
66 pub config: ObservabilityConfig,
68}
69
70impl ComponentSpec {
71 pub fn new(config: ObservabilityConfig) -> Self {
76 Self {
77 enabled: true,
78 config,
79 }
80 }
81}
82
83impl From<ComponentSpec> for PluginComponentSpec {
84 fn from(value: ComponentSpec) -> Self {
85 let Json::Object(config) = serde_json::to_value(value.config)
86 .expect("observability config should serialize to object")
87 else {
88 unreachable!("observability config must serialize to object");
89 };
90
91 PluginComponentSpec {
92 kind: OBSERVABILITY_PLUGIN_KIND.to_string(),
93 enabled: value.enabled,
94 config,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
105#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
106pub struct ObservabilityConfig {
107 #[serde(default = "default_observability_config_version")]
109 pub version: u32,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub atof: Option<AtofSectionConfig>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub atif: Option<AtifSectionConfig>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub opentelemetry: Option<OtlpSectionConfig>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 pub openinference: Option<OtlpSectionConfig>,
122 #[serde(default)]
124 pub policy: ConfigPolicy,
125}
126
127impl Default for ObservabilityConfig {
128 fn default() -> Self {
129 Self {
130 version: default_observability_config_version(),
131 atof: None,
132 atif: None,
133 opentelemetry: None,
134 openinference: None,
135 policy: ConfigPolicy::default(),
136 }
137 }
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
147#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
148pub struct AtofSectionConfig {
149 #[serde(default)]
151 pub enabled: bool,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 pub output_directory: Option<PathBuf>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
157 pub filename: Option<String>,
158 #[serde(default = "default_atof_mode")]
160 #[cfg_attr(feature = "schema", schemars(schema_with = "atof_mode_schema"))]
161 pub mode: String,
162}
163
164impl Default for AtofSectionConfig {
165 fn default() -> Self {
166 Self {
167 enabled: false,
168 output_directory: None,
169 filename: None,
170 mode: default_atof_mode(),
171 }
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
182#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
183pub struct AtifSectionConfig {
184 #[serde(default)]
186 pub enabled: bool,
187 #[serde(default = "default_agent_name")]
189 pub agent_name: String,
190 #[serde(default = "default_agent_version")]
192 pub agent_version: String,
193 #[serde(default = "default_model_name")]
195 pub model_name: String,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
198 pub tool_definitions: Option<Vec<Json>>,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
201 pub extra: Option<Json>,
202 #[serde(default, skip_serializing_if = "Option::is_none")]
206 pub output_directory: Option<PathBuf>,
207 #[serde(default = "default_atif_filename_template")]
212 pub filename_template: String,
213 #[serde(default, skip_serializing_if = "Vec::is_empty")]
222 pub storage: Vec<AtifStorageConfig>,
223}
224
225impl Default for AtifSectionConfig {
226 fn default() -> Self {
227 Self {
228 enabled: false,
229 agent_name: default_agent_name(),
230 agent_version: default_agent_version(),
231 model_name: default_model_name(),
232 tool_definitions: None,
233 extra: None,
234 output_directory: None,
235 filename_template: default_atif_filename_template(),
236 storage: Vec::new(),
237 }
238 }
239}
240
241#[derive(Debug, Clone, Serialize, Deserialize)]
249#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
250#[serde(tag = "type", rename_all = "snake_case")]
251pub enum AtifStorageConfig {
252 S3(S3StorageConfig),
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
275#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
276pub struct S3StorageConfig {
277 pub bucket: String,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub key_prefix: Option<String>,
283 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub access_key_id: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub secret_access_key_var: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub session_token_var: Option<String>,
296 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub region: Option<String>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub endpoint_url: Option<String>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub allow_http: Option<bool>,
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize)]
314#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
315pub struct OtlpSectionConfig {
316 #[serde(default)]
318 pub enabled: bool,
319 #[serde(default = "default_otlp_transport")]
321 #[cfg_attr(feature = "schema", schemars(schema_with = "otlp_transport_schema"))]
322 pub transport: String,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub endpoint: Option<String>,
326 #[serde(default)]
328 pub headers: HashMap<String, String>,
329 #[serde(default)]
331 pub resource_attributes: HashMap<String, String>,
332 #[serde(default = "default_service_name")]
334 pub service_name: String,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub service_namespace: Option<String>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub service_version: Option<String>,
341 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub instrumentation_scope: Option<String>,
344 #[serde(default = "default_timeout_millis")]
346 pub timeout_millis: u64,
347}
348
349impl Default for OtlpSectionConfig {
350 fn default() -> Self {
351 Self {
352 enabled: false,
353 transport: default_otlp_transport(),
354 endpoint: None,
355 headers: HashMap::new(),
356 resource_attributes: HashMap::new(),
357 service_name: default_service_name(),
358 service_namespace: None,
359 service_version: None,
360 instrumentation_scope: None,
361 timeout_millis: default_timeout_millis(),
362 }
363 }
364}
365
366crate::editor_config! {
367 impl ObservabilityConfig {
368 atof => {
369 label: "ATOF",
370 kind: Section,
371 optional: true,
372 nested: AtofSectionConfig,
373 default: AtofSectionConfig,
374 },
375 atif => {
376 label: "ATIF",
377 kind: Section,
378 optional: true,
379 nested: AtifSectionConfig,
380 default: AtifSectionConfig,
381 },
382 opentelemetry => {
383 label: "OpenTelemetry",
384 kind: Section,
385 optional: true,
386 nested: OtlpSectionConfig,
387 default: OtlpSectionConfig,
388 },
389 openinference => {
390 label: "OpenInference",
391 kind: Section,
392 optional: true,
393 nested: OtlpSectionConfig,
394 default: OtlpSectionConfig,
395 },
396 policy => {
397 label: "policy",
398 kind: Section,
399 nested: ConfigPolicy,
400 default: ConfigPolicy,
401 },
402 }
403}
404
405crate::editor_config! {
406 impl AtofSectionConfig {
407 enabled => { label: "enabled", kind: Boolean },
408 output_directory => { label: "output_directory", kind: String, optional: true },
409 filename => { label: "filename", kind: String, optional: true },
410 mode => { label: "mode", kind: Enum, values: ["append", "overwrite"] },
411 }
412}
413
414crate::editor_config! {
415 impl AtifSectionConfig {
416 enabled => { label: "enabled", kind: Boolean },
417 agent_name => { label: "agent_name", kind: String },
418 agent_version => { label: "agent_version", kind: String },
419 model_name => { label: "model_name", kind: String },
420 tool_definitions => { label: "tool_definitions", kind: Json, optional: true },
421 extra => { label: "extra", kind: Json, optional: true },
422 output_directory => { label: "output_directory", kind: String, optional: true },
423 filename_template => { label: "filename_template", kind: String },
424 storage => { label: "storage", kind: Json, optional: true },
425 }
426}
427
428crate::editor_config! {
429 impl OtlpSectionConfig {
430 enabled => { label: "enabled", kind: Boolean },
431 transport => { label: "transport", kind: Enum, values: ["http_binary", "grpc"] },
432 endpoint => { label: "endpoint", kind: String, optional: true },
433 headers => { label: "headers", kind: StringMap },
434 resource_attributes => { label: "resource_attributes", kind: StringMap },
435 service_name => { label: "service_name", kind: String },
436 service_namespace => { label: "service_namespace", kind: String, optional: true },
437 service_version => { label: "service_version", kind: String, optional: true },
438 instrumentation_scope => { label: "instrumentation_scope", kind: String, optional: true },
439 timeout_millis => { label: "timeout_millis", kind: Integer },
440 }
441}
442
443struct ObservabilityPlugin;
444
445impl Plugin for ObservabilityPlugin {
446 fn plugin_kind(&self) -> &str {
447 OBSERVABILITY_PLUGIN_KIND
448 }
449
450 fn allows_multiple_components(&self) -> bool {
451 false
452 }
453
454 fn validate(&self, plugin_config: &Map<String, Json>) -> Vec<ConfigDiagnostic> {
455 validate_observability_plugin_config(plugin_config)
456 }
457
458 fn register<'a>(
459 &'a self,
460 plugin_config: &Map<String, Json>,
461 ctx: &'a mut PluginRegistrationContext,
462 ) -> Pin<Box<dyn Future<Output = PluginResult<()>> + Send + 'a>> {
463 let plugin_config = plugin_config.clone();
464 Box::pin(async move {
465 let config = parse_observability_config(&plugin_config)?;
466 register_observability(config, ctx)
467 })
468 }
469}
470
471pub fn register_observability_component() -> PluginResult<()> {
477 match register_plugin(Arc::new(ObservabilityPlugin)) {
478 Ok(()) => Ok(()),
479 Err(PluginError::RegistrationFailed(message)) if message.contains("already registered") => {
480 Ok(())
481 }
482 Err(err) => Err(err),
483 }
484}
485
486pub fn deregister_observability_component() -> bool {
492 deregister_plugin(OBSERVABILITY_PLUGIN_KIND)
493}
494
495#[cfg(feature = "schema")]
497pub fn observability_config_schema() -> serde_json::Value {
498 serde_json::to_value(schemars::schema_for!(ObservabilityConfig))
499 .expect("observability config schema should serialize")
500}
501
502#[cfg(feature = "schema")]
503fn atof_mode_schema(generator: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
504 string_enum_schema(generator, &["append", "overwrite"], Some("append"))
505}
506
507#[cfg(feature = "schema")]
508fn otlp_transport_schema(
509 generator: &mut schemars::r#gen::SchemaGenerator,
510) -> schemars::schema::Schema {
511 string_enum_schema(generator, &["http_binary", "grpc"], Some("http_binary"))
512}
513
514#[cfg(feature = "schema")]
515fn string_enum_schema(
516 generator: &mut schemars::r#gen::SchemaGenerator,
517 values: &[&str],
518 default: Option<&str>,
519) -> schemars::schema::Schema {
520 let mut schema: schemars::schema::SchemaObject =
521 <String as schemars::JsonSchema>::json_schema(generator).into();
522 schema.enum_values = Some(
523 values
524 .iter()
525 .map(|value| Json::String((*value).into()))
526 .collect(),
527 );
528 if let Some(default) = default {
529 schema.metadata().default = Some(Json::String(default.into()));
530 }
531 schema.into()
532}
533
534fn register_observability(
535 config: ObservabilityConfig,
536 ctx: &mut PluginRegistrationContext,
537) -> PluginResult<()> {
538 if let Some(atof) = config.atof.filter(|section| section.enabled) {
539 register_atof_exporter(atof, ctx)?;
540 }
541 if let Some(atif) = config.atif.filter(|section| section.enabled) {
542 register_atif_dispatcher(atif, ctx)?;
543 }
544 if let Some(otel) = config.opentelemetry.filter(|section| section.enabled) {
545 register_opentelemetry(otel, ctx)?;
546 }
547 if let Some(openinference) = config.openinference.filter(|section| section.enabled) {
548 register_openinference(openinference, ctx)?;
549 }
550 Ok(())
551}
552
553fn register_atof_exporter(
554 section: AtofSectionConfig,
555 ctx: &mut PluginRegistrationContext,
556) -> PluginResult<()> {
557 let mode = AtofExporterMode::parse(§ion.mode).ok_or_else(|| {
558 PluginError::InvalidConfig("ATOF mode must be 'append' or 'overwrite'".to_string())
559 })?;
560 let mut config = CoreAtofExporterConfig::new().with_mode(mode);
561 if let Some(output_directory) = section.output_directory {
562 config = config.with_output_directory(output_directory);
563 }
564 if let Some(filename) = section.filename {
565 config = config.with_filename(filename);
566 }
567
568 let exporter = Arc::new(AtofExporter::new(config).map_err(observability_registration_error)?);
569 ctx.register_subscriber("atof", exporter.subscriber())?;
570 ctx.add_registration(PluginRegistration::new(
571 "observability",
572 ctx.qualify_name("atof.shutdown"),
573 Box::new(move || {
574 exporter
575 .shutdown()
576 .map_err(observability_registration_error)
577 }),
578 ));
579 Ok(())
580}
581
582type AtifStorageList = Arc<Vec<Arc<AtifRemoteStorage>>>;
583
584fn register_atif_dispatcher(
585 section: AtifSectionConfig,
586 ctx: &mut PluginRegistrationContext,
587) -> PluginResult<()> {
588 if !section.filename_template.contains("{session_id}") {
589 return Err(PluginError::InvalidConfig(
590 "ATIF filename_template must contain '{session_id}'".to_string(),
591 ));
592 }
593
594 let mut storage_vec = Vec::with_capacity(section.storage.len());
595 for (index, entry) in section.storage.iter().enumerate() {
596 storage_vec.push(build_atif_storage(index, entry)?);
597 }
598 let storage: AtifStorageList = Arc::new(storage_vec);
599
600 let manager = Arc::new(Mutex::new(AtifDispatcher::new(section)));
601 let dispatcher = atif_dispatcher_subscriber(
602 Arc::clone(&manager),
603 ctx.qualify_name("atif-"),
604 Arc::clone(&storage),
605 );
606 ctx.register_subscriber("atif", dispatcher)?;
607 let shutdown_storage = Arc::clone(&storage);
608 ctx.add_registration(PluginRegistration::new(
609 "observability",
610 ctx.qualify_name("atif.shutdown"),
611 Box::new(move || {
612 let work = {
613 let mut guard = manager.lock().map_err(|err| {
614 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
615 })?;
616 guard
617 .flush_open_agents()
618 .map_err(observability_registration_error)?
619 };
620 for (scope_uuid, name) in work.scope_subscribers {
621 let _ = scope_deregister_subscriber(&scope_uuid, &name);
622 }
623 for write in work.writes {
624 let agent_uuid = write.agent_uuid;
625 let targets = {
626 let guard = manager.lock().map_err(|err| {
627 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
628 })?;
629 guard.sink_targets()
630 };
631 let results = write_atif(&write, shutdown_storage.as_slice(), &targets);
632 let mut guard = manager.lock().map_err(|err| {
633 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
634 })?;
635 let _ = guard.complete_scope_write(agent_uuid, results);
636 }
637 let guard = manager.lock().map_err(|err| {
638 PluginError::Internal(format!("ATIF dispatcher lock poisoned: {err}"))
639 })?;
640 guard
641 .last_error_result()
642 .map_err(observability_registration_error)
643 }),
644 ));
645 Ok(())
646}
647
648#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
649fn build_atif_storage(
650 index: usize,
651 config: &AtifStorageConfig,
652) -> PluginResult<Arc<AtifRemoteStorage>> {
653 AtifRemoteStorage::from_config(index, config)
654 .map(Arc::new)
655 .map_err(observability_registration_error)
656}
657
658#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
659fn build_atif_storage(
660 _index: usize,
661 _config: &AtifStorageConfig,
662) -> PluginResult<Arc<AtifRemoteStorage>> {
663 Err(PluginError::InvalidConfig(
664 "ATIF storage support is not enabled in this build".to_string(),
665 ))
666}
667
668#[cfg(feature = "otel")]
669fn register_opentelemetry(
670 section: OtlpSectionConfig,
671 ctx: &mut PluginRegistrationContext,
672) -> PluginResult<()> {
673 let subscriber = Arc::new(
674 OpenTelemetrySubscriber::new(build_otel_config(section)?)
675 .map_err(observability_registration_error)?,
676 );
677 ctx.register_subscriber("opentelemetry", subscriber.subscriber())?;
678 ctx.add_registration(PluginRegistration::new(
679 "observability",
680 ctx.qualify_name("opentelemetry.shutdown"),
681 Box::new(move || {
682 subscriber
683 .shutdown()
684 .map_err(observability_registration_error)
685 }),
686 ));
687 Ok(())
688}
689
690#[cfg(not(feature = "otel"))]
691fn register_opentelemetry(
692 _section: OtlpSectionConfig,
693 _ctx: &mut PluginRegistrationContext,
694) -> PluginResult<()> {
695 Err(PluginError::InvalidConfig(
696 "OpenTelemetry support is not enabled in this build".to_string(),
697 ))
698}
699
700#[cfg(feature = "openinference")]
701fn register_openinference(
702 section: OtlpSectionConfig,
703 ctx: &mut PluginRegistrationContext,
704) -> PluginResult<()> {
705 let subscriber = Arc::new(
706 OpenInferenceSubscriber::new(build_openinference_config(section)?)
707 .map_err(observability_registration_error)?,
708 );
709 ctx.register_subscriber("openinference", subscriber.subscriber())?;
710 ctx.add_registration(PluginRegistration::new(
711 "observability",
712 ctx.qualify_name("openinference.shutdown"),
713 Box::new(move || {
714 subscriber
715 .shutdown()
716 .map_err(observability_registration_error)
717 }),
718 ));
719 Ok(())
720}
721
722#[cfg(not(feature = "openinference"))]
723fn register_openinference(
724 _section: OtlpSectionConfig,
725 _ctx: &mut PluginRegistrationContext,
726) -> PluginResult<()> {
727 Err(PluginError::InvalidConfig(
728 "OpenInference support is not enabled in this build".to_string(),
729 ))
730}
731
732struct AtifDispatcher {
733 config: AtifSectionConfig,
734 agents: HashMap<Uuid, ManagedAtifExporter>,
735 scope_owners: HashMap<Uuid, Uuid>,
736 scope_subscribers: HashMap<Uuid, String>,
737 fatal_error: Option<String>,
741 sink_errors: HashMap<SinkLabel, String>,
745}
746
747struct ManagedAtifExporter {
748 exporter: AtifExporter,
749 filename: String,
750 local_path: Option<PathBuf>,
751 observed_events: Vec<Event>,
752 observed_event_keys: HashSet<String>,
753 written: bool,
754}
755
756struct PendingAtifWrite {
757 agent_uuid: Uuid,
758 #[cfg_attr(
761 not(all(feature = "object-store", not(target_arch = "wasm32"))),
762 allow(dead_code)
763 )]
764 filename: String,
765 local_path: Option<PathBuf>,
766 payload: Vec<u8>,
767}
768
769struct AtifFlushWork {
770 writes: Vec<PendingAtifWrite>,
771 scope_subscribers: Vec<(Uuid, String)>,
772}
773
774#[derive(Clone, Debug, PartialEq, Eq, Hash)]
778enum SinkLabel {
779 Local,
780 Remote(usize),
781}
782
783impl SinkLabel {
784 fn display(&self) -> String {
785 match self {
786 SinkLabel::Local => "local".to_string(),
787 SinkLabel::Remote(index) => format!("storage[{index}]"),
788 }
789 }
790
791 fn sort_key(&self) -> isize {
792 match self {
793 SinkLabel::Local => -1,
794 SinkLabel::Remote(index) => *index as isize,
795 }
796 }
797}
798
799impl AtifDispatcher {
800 fn new(config: AtifSectionConfig) -> Self {
801 Self {
802 config,
803 agents: HashMap::new(),
804 scope_owners: HashMap::new(),
805 scope_subscribers: HashMap::new(),
806 fatal_error: None,
807 sink_errors: HashMap::new(),
808 }
809 }
810
811 fn observe_global(
812 &mut self,
813 event: &Event,
814 subscriber_prefix: &str,
815 state: Arc<Mutex<Self>>,
816 storage: AtifStorageList,
817 ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
818 if self.fatal_error.is_some() {
819 return None;
820 }
821
822 if !is_top_level_trajectory_start(event) {
823 return self.observe_descendant_from_global(event);
824 }
825
826 if self.agents.contains_key(&event.uuid()) {
827 return None;
828 }
829
830 let session_id = event.uuid().to_string();
835 let exporter = AtifExporter::new(session_id.clone(), self.agent_info());
836 (exporter.subscriber())(event);
837 let (filename, local_path) = self.prepare_destination(&session_id);
838 self.scope_owners.insert(event.uuid(), event.uuid());
839 self.agents.insert(
840 event.uuid(),
841 ManagedAtifExporter {
842 exporter,
843 filename,
844 local_path,
845 observed_events: vec![event.clone()],
846 observed_event_keys: HashSet::from([event_observation_key(event)]),
847 written: false,
848 },
849 );
850
851 let agent_uuid = event.uuid();
852 let name = format!("{subscriber_prefix}{agent_uuid}");
853 let callback = atif_scope_subscriber(state, agent_uuid, storage);
854 if scope_register_subscriber(&agent_uuid, &name, callback).is_ok() {
860 self.scope_subscribers.insert(agent_uuid, name);
861 }
862 None
863 }
864
865 fn observe_descendant_from_global(
866 &mut self,
867 event: &Event,
868 ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
869 let owner = self.scope_owners.get(&event.uuid()).copied().or_else(|| {
870 event
871 .parent_uuid()
872 .and_then(|uuid| self.scope_owners.get(&uuid).copied())
873 })?;
874
875 if event.scope_category() == Some(ScopeCategory::Start) {
876 self.scope_owners.insert(event.uuid(), owner);
877 }
878
879 let pending_write = self.observe_scope(event, owner);
880
881 if event.scope_category() == Some(ScopeCategory::End) && event.uuid() != owner {
882 self.scope_owners.remove(&event.uuid());
883 }
884
885 pending_write
886 }
887
888 fn observe_scope(
889 &mut self,
890 event: &Event,
891 agent_uuid: Uuid,
892 ) -> Option<(PendingAtifWrite, Vec<SinkLabel>)> {
893 if self.fatal_error.is_some() {
894 return None;
895 }
896 let should_finalize =
897 event.uuid() == agent_uuid && event.scope_category() == Some(ScopeCategory::End);
898 let agent = self.agents.get_mut(&agent_uuid)?;
899 if !agent
900 .observed_event_keys
901 .insert(event_observation_key(event))
902 {
903 return None;
904 }
905 (agent.exporter.subscriber())(event);
906 agent.observed_events.push(event.clone());
907 if !should_finalize || agent.written {
908 return None;
909 }
910 let write = match prepare_atif_file(agent_uuid, agent) {
911 Ok(write) => write,
912 Err(err) => {
913 self.fatal_error = Some(err.to_string());
914 return None;
915 }
916 };
917 let targets = self.sink_targets();
918 Some((write, targets))
919 }
920
921 fn complete_scope_write(
922 &mut self,
923 agent_uuid: Uuid,
924 results: Vec<(SinkLabel, std::io::Result<()>)>,
925 ) -> Option<(Uuid, String)> {
926 for (label, result) in results {
927 if let Err(err) = result {
928 self.sink_errors.insert(label, err.to_string());
929 }
930 }
931 if let Some(agent) = self.agents.get_mut(&agent_uuid) {
932 agent.observed_events.clear();
933 }
934 self.agents.remove(&agent_uuid);
935 self.scope_owners.retain(|_, owner| *owner != agent_uuid);
936 self.scope_subscribers
937 .remove(&agent_uuid)
938 .map(|name| (agent_uuid, name))
939 }
940
941 fn flush_open_agents(&mut self) -> std::io::Result<AtifFlushWork> {
942 let scope_subscribers = std::mem::take(&mut self.scope_subscribers)
946 .into_iter()
947 .collect();
948 let agent_uuids = self
949 .agents
950 .iter()
951 .filter_map(|(agent_uuid, agent)| (!agent.written).then_some(*agent_uuid))
952 .collect::<Vec<_>>();
953 let mut writes = Vec::with_capacity(agent_uuids.len());
954 for agent_uuid in agent_uuids {
955 if let Some(agent) = self.agents.get_mut(&agent_uuid) {
956 writes.push(prepare_atif_file(agent_uuid, agent)?);
957 }
958 }
959 Ok(AtifFlushWork {
960 writes,
961 scope_subscribers,
962 })
963 }
964
965 fn last_error_result(&self) -> std::io::Result<()> {
966 let mut parts: Vec<String> = Vec::new();
967 if let Some(message) = &self.fatal_error {
968 parts.push(message.clone());
969 }
970 let mut sink_entries: Vec<_> = self.sink_errors.iter().collect();
971 sink_entries.sort_by_key(|(label, _)| label.sort_key());
972 for (label, message) in sink_entries {
973 parts.push(format!("{}: {message}", label.display()));
974 }
975 if parts.is_empty() {
976 Ok(())
977 } else {
978 Err(std::io::Error::other(parts.join("; ")))
979 }
980 }
981
982 fn agent_info(&self) -> AtifAgentInfo {
983 AtifAgentInfo {
984 name: self.config.agent_name.clone(),
985 version: self.config.agent_version.clone(),
986 model_name: Some(self.config.model_name.clone()),
987 tool_definitions: self.config.tool_definitions.clone(),
988 extra: self.config.extra.clone(),
989 }
990 }
991
992 fn prepare_destination(&self, session_id: &str) -> (String, Option<PathBuf>) {
993 let filename = self
994 .config
995 .filename_template
996 .replace("{session_id}", session_id);
997 if !self.config.storage.is_empty() {
998 return (filename, None);
999 }
1000 let directory = self
1001 .config
1002 .output_directory
1003 .clone()
1004 .unwrap_or_else(default_output_directory);
1005 let path = directory.join(&filename);
1006 (filename, Some(path))
1007 }
1008
1009 fn sink_targets(&self) -> Vec<SinkLabel> {
1010 if self.config.storage.is_empty() {
1011 if self.sink_errors.contains_key(&SinkLabel::Local) {
1012 Vec::new()
1013 } else {
1014 vec![SinkLabel::Local]
1015 }
1016 } else {
1017 (0..self.config.storage.len())
1018 .map(SinkLabel::Remote)
1019 .filter(|label| !self.sink_errors.contains_key(label))
1020 .collect()
1021 }
1022 }
1023}
1024
1025fn atif_dispatcher_subscriber(
1026 manager: Arc<Mutex<AtifDispatcher>>,
1027 subscriber_prefix: String,
1028 storage: AtifStorageList,
1029) -> EventSubscriberFn {
1030 Arc::new(move |event: &Event| {
1031 let pending = {
1032 let Ok(mut guard) = manager.lock() else {
1033 return;
1034 };
1035 guard.observe_global(
1036 event,
1037 &subscriber_prefix,
1038 Arc::clone(&manager),
1039 Arc::clone(&storage),
1040 )
1041 };
1042 let Some((write, targets)) = pending else {
1043 return;
1044 };
1045 let results = write_atif(&write, storage.as_slice(), &targets);
1046 let scope_subscriber = {
1047 let Ok(mut guard) = manager.lock() else {
1048 return;
1049 };
1050 guard.complete_scope_write(write.agent_uuid, results)
1051 };
1052 if let Some((scope_uuid, name)) = scope_subscriber {
1053 let _ = scope_deregister_subscriber(&scope_uuid, &name);
1054 }
1055 })
1056}
1057
1058fn atif_scope_subscriber(
1059 manager: Arc<Mutex<AtifDispatcher>>,
1060 agent_uuid: Uuid,
1061 storage: AtifStorageList,
1062) -> EventSubscriberFn {
1063 Arc::new(move |event: &Event| {
1064 let pending = {
1065 let Ok(mut guard) = manager.lock() else {
1066 return;
1067 };
1068 guard.observe_scope(event, agent_uuid)
1069 };
1070 let Some((write, targets)) = pending else {
1071 return;
1072 };
1073 let results = write_atif(&write, storage.as_slice(), &targets);
1074 let scope_subscriber = {
1075 let Ok(mut guard) = manager.lock() else {
1076 return;
1077 };
1078 guard.complete_scope_write(write.agent_uuid, results)
1079 };
1080 if let Some((scope_uuid, name)) = scope_subscriber {
1081 let _ = scope_deregister_subscriber(&scope_uuid, &name);
1082 }
1083 })
1084}
1085
1086fn prepare_atif_file(
1087 agent_uuid: Uuid,
1088 agent: &mut ManagedAtifExporter,
1089) -> std::io::Result<PendingAtifWrite> {
1090 let trajectory = agent
1091 .exporter
1092 .try_export()
1093 .map_err(|error| std::io::Error::other(error.to_string()))?;
1094 let mut value = serde_json::to_value(trajectory)?;
1095 if let Some(object) = value.as_object_mut() {
1096 object.insert(
1097 "extra".to_string(),
1098 serde_json::json!({
1099 "observed_events": agent.observed_events,
1100 }),
1101 );
1102 }
1103 let payload = serde_json::to_vec_pretty(&value)?;
1104 agent.written = true;
1105 Ok(PendingAtifWrite {
1106 agent_uuid,
1107 filename: agent.filename.clone(),
1108 local_path: agent.local_path.clone(),
1109 payload,
1110 })
1111}
1112
1113fn write_atif(
1114 write: &PendingAtifWrite,
1115 storage: &[Arc<AtifRemoteStorage>],
1116 targets: &[SinkLabel],
1117) -> Vec<(SinkLabel, std::io::Result<()>)> {
1118 targets
1119 .iter()
1120 .map(|label| {
1121 let result = match label {
1122 SinkLabel::Local => match &write.local_path {
1123 Some(path) => write_atif_local(path, &write.payload),
1124 None => Err(std::io::Error::other(
1125 "ATIF local destination has no output path",
1126 )),
1127 },
1128 SinkLabel::Remote(index) => write_atif_remote(storage, *index, write),
1129 };
1130 (label.clone(), result)
1131 })
1132 .collect()
1133}
1134
1135fn write_atif_local(path: &PathBuf, payload: &[u8]) -> std::io::Result<()> {
1136 if let Some(parent) = path.parent() {
1137 std::fs::create_dir_all(parent)?;
1138 }
1139 std::fs::write(path, payload)
1140}
1141
1142#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1143fn write_atif_remote(
1144 storage: &[Arc<AtifRemoteStorage>],
1145 index: usize,
1146 write: &PendingAtifWrite,
1147) -> std::io::Result<()> {
1148 let sink = storage
1149 .get(index)
1150 .ok_or_else(|| std::io::Error::other(format!("ATIF storage[{index}] is not registered")))?;
1151 sink.put(&write.filename, &write.payload)
1152}
1153
1154#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
1155fn write_atif_remote(
1156 _storage: &[Arc<AtifRemoteStorage>],
1157 _index: usize,
1158 _write: &PendingAtifWrite,
1159) -> std::io::Result<()> {
1160 Err(std::io::Error::other(
1161 "ATIF storage support is not enabled in this build",
1162 ))
1163}
1164
1165fn event_observation_key(event: &Event) -> String {
1166 format!(
1167 "{}:{}:{:?}",
1168 event.kind(),
1169 event.uuid(),
1170 event.scope_category()
1171 )
1172}
1173
1174fn is_top_level_trajectory_start(event: &Event) -> bool {
1175 if event.scope_category() != Some(ScopeCategory::Start) {
1176 return false;
1177 }
1178 if event.scope_type() != Some(ScopeType::Agent) {
1179 return false;
1180 }
1181 let Some(parent_uuid) = event.parent_uuid() else {
1182 return false;
1183 };
1184 current_scope_stack()
1185 .read()
1186 .map(|stack| stack.root_uuid() == parent_uuid)
1187 .unwrap_or(false)
1188}
1189
1190#[cfg(feature = "otel")]
1191fn build_otel_config(section: OtlpSectionConfig) -> PluginResult<CoreOpenTelemetryConfig> {
1192 let mut config = match section.transport.as_str() {
1193 "http_binary" => CoreOpenTelemetryConfig::http_binary(section.service_name),
1194 "grpc" => CoreOpenTelemetryConfig::grpc(section.service_name),
1195 other => {
1196 return Err(PluginError::InvalidConfig(format!(
1197 "OpenTelemetry transport must be 'http_binary' or 'grpc', got {other:?}"
1198 )));
1199 }
1200 }
1201 .with_timeout(Duration::from_millis(section.timeout_millis));
1202
1203 if let Some(endpoint) = section.endpoint {
1204 config = config.with_endpoint(endpoint);
1205 }
1206 if let Some(namespace) = section.service_namespace {
1207 config = config.with_service_namespace(namespace);
1208 }
1209 if let Some(version) = section.service_version {
1210 config = config.with_service_version(version);
1211 }
1212 if let Some(scope) = section.instrumentation_scope {
1213 config = config.with_instrumentation_scope(scope);
1214 }
1215 for (key, value) in section.headers {
1216 config = config.with_header(key, value);
1217 }
1218 for (key, value) in section.resource_attributes {
1219 config = config.with_resource_attribute(key, value);
1220 }
1221 Ok(config)
1222}
1223
1224#[cfg(feature = "openinference")]
1225fn build_openinference_config(section: OtlpSectionConfig) -> PluginResult<CoreOpenInferenceConfig> {
1226 let transport = match section.transport.as_str() {
1227 "http_binary" => OpenInferenceTransport::HttpBinary,
1228 "grpc" => OpenInferenceTransport::Grpc,
1229 other => {
1230 return Err(PluginError::InvalidConfig(format!(
1231 "OpenInference transport must be 'http_binary' or 'grpc', got {other:?}"
1232 )));
1233 }
1234 };
1235 let mut config = CoreOpenInferenceConfig::new()
1236 .with_transport(transport)
1237 .with_service_name(section.service_name)
1238 .with_timeout(Duration::from_millis(section.timeout_millis));
1239
1240 if let Some(endpoint) = section.endpoint {
1241 config = config.with_endpoint(endpoint);
1242 }
1243 if let Some(namespace) = section.service_namespace {
1244 config = config.with_service_namespace(namespace);
1245 }
1246 if let Some(version) = section.service_version {
1247 config = config.with_service_version(version);
1248 }
1249 if let Some(scope) = section.instrumentation_scope {
1250 config = config.with_instrumentation_scope(scope);
1251 }
1252 for (key, value) in section.headers {
1253 config = config.with_header(key, value);
1254 }
1255 for (key, value) in section.resource_attributes {
1256 config = config.with_resource_attribute(key, value);
1257 }
1258 Ok(config)
1259}
1260
1261fn parse_observability_config(
1262 plugin_config: &Map<String, Json>,
1263) -> PluginResult<ObservabilityConfig> {
1264 serde_json::from_value(Json::Object(plugin_config.clone())).map_err(|err| {
1265 PluginError::InvalidConfig(format!("invalid observability plugin config: {err}"))
1266 })
1267}
1268
1269fn validate_observability_plugin_config(
1270 plugin_config: &Map<String, Json>,
1271) -> Vec<ConfigDiagnostic> {
1272 let config = match parse_observability_config(plugin_config) {
1273 Ok(config) => config,
1274 Err(err) => {
1275 return vec![ConfigDiagnostic {
1276 level: DiagnosticLevel::Error,
1277 code: "observability.invalid_plugin_config".to_string(),
1278 component: Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
1279 field: None,
1280 message: err.to_string(),
1281 }];
1282 }
1283 };
1284
1285 let mut diagnostics = vec![];
1286 validate_unknown_fields(
1287 &mut diagnostics,
1288 &config.policy,
1289 Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
1290 plugin_config,
1291 &[
1292 "version",
1293 "atof",
1294 "atif",
1295 "opentelemetry",
1296 "openinference",
1297 "policy",
1298 ],
1299 );
1300
1301 validate_version(&mut diagnostics, &config.policy, config.version);
1302 validate_policy_fields(&mut diagnostics, &config.policy, plugin_config);
1303 validate_section_fields(
1304 &mut diagnostics,
1305 &config.policy,
1306 plugin_config,
1307 "atof",
1308 &["enabled", "output_directory", "filename", "mode"],
1309 );
1310 validate_section_fields(
1311 &mut diagnostics,
1312 &config.policy,
1313 plugin_config,
1314 "atif",
1315 &[
1316 "enabled",
1317 "agent_name",
1318 "agent_version",
1319 "model_name",
1320 "tool_definitions",
1321 "extra",
1322 "output_directory",
1323 "filename_template",
1324 "storage",
1325 ],
1326 );
1327 validate_section_fields(
1328 &mut diagnostics,
1329 &config.policy,
1330 plugin_config,
1331 "opentelemetry",
1332 &[
1333 "enabled",
1334 "transport",
1335 "endpoint",
1336 "headers",
1337 "resource_attributes",
1338 "service_name",
1339 "service_namespace",
1340 "service_version",
1341 "instrumentation_scope",
1342 "timeout_millis",
1343 ],
1344 );
1345 validate_section_fields(
1346 &mut diagnostics,
1347 &config.policy,
1348 plugin_config,
1349 "openinference",
1350 &[
1351 "enabled",
1352 "transport",
1353 "endpoint",
1354 "headers",
1355 "resource_attributes",
1356 "service_name",
1357 "service_namespace",
1358 "service_version",
1359 "instrumentation_scope",
1360 "timeout_millis",
1361 ],
1362 );
1363
1364 if let Some(section) = &config.atof {
1365 validate_atof_values(&mut diagnostics, &config.policy, section);
1366 #[cfg(target_arch = "wasm32")]
1367 if section.enabled {
1368 push_policy_diag(
1369 &mut diagnostics,
1370 config.policy.unsupported_value,
1371 "observability.unsupported_value",
1372 Some("atof".to_string()),
1373 Some("enabled".to_string()),
1374 "ATOF file export is not supported on WebAssembly".to_string(),
1375 );
1376 }
1377 }
1378 if let Some(section) = &config.atif {
1379 validate_atif_values(&mut diagnostics, &config.policy, section);
1380 #[cfg(target_arch = "wasm32")]
1381 if section.enabled {
1382 push_policy_diag(
1383 &mut diagnostics,
1384 config.policy.unsupported_value,
1385 "observability.unsupported_value",
1386 Some("atif".to_string()),
1387 Some("enabled".to_string()),
1388 "ATIF file export is not supported on WebAssembly".to_string(),
1389 );
1390 }
1391 #[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
1392 if !section.storage.is_empty() {
1393 push_policy_diag(
1394 &mut diagnostics,
1395 config.policy.unsupported_value,
1396 "observability.feature_disabled",
1397 Some("atif".to_string()),
1398 Some("storage".to_string()),
1399 "ATIF storage support is not enabled in this build".to_string(),
1400 );
1401 }
1402 }
1403 if let Some(section) = &config.opentelemetry {
1404 validate_otlp_values(&mut diagnostics, &config.policy, "opentelemetry", section);
1405 #[cfg(not(feature = "otel"))]
1406 if section.enabled {
1407 push_policy_diag(
1408 &mut diagnostics,
1409 config.policy.unsupported_value,
1410 "observability.feature_disabled",
1411 Some("opentelemetry".to_string()),
1412 Some("enabled".to_string()),
1413 "OpenTelemetry support is not enabled in this build".to_string(),
1414 );
1415 }
1416 }
1417 if let Some(section) = &config.openinference {
1418 validate_otlp_values(&mut diagnostics, &config.policy, "openinference", section);
1419 #[cfg(not(feature = "openinference"))]
1420 if section.enabled {
1421 push_policy_diag(
1422 &mut diagnostics,
1423 config.policy.unsupported_value,
1424 "observability.feature_disabled",
1425 Some("openinference".to_string()),
1426 Some("enabled".to_string()),
1427 "OpenInference support is not enabled in this build".to_string(),
1428 );
1429 }
1430 }
1431
1432 diagnostics
1433}
1434
1435fn validate_version(diagnostics: &mut Vec<ConfigDiagnostic>, policy: &ConfigPolicy, version: u32) {
1436 if version != 1 {
1437 push_policy_diag(
1438 diagnostics,
1439 policy.unsupported_value,
1440 "observability.unsupported_config_version",
1441 Some(OBSERVABILITY_PLUGIN_KIND.to_string()),
1442 Some("version".to_string()),
1443 format!("observability config version {version} is unsupported"),
1444 );
1445 }
1446}
1447
1448fn validate_policy_fields(
1449 diagnostics: &mut Vec<ConfigDiagnostic>,
1450 policy: &ConfigPolicy,
1451 plugin_config: &Map<String, Json>,
1452) {
1453 if let Some(policy_json) = plugin_config.get("policy").and_then(Json::as_object) {
1454 validate_unknown_fields(
1455 diagnostics,
1456 policy,
1457 Some("policy".to_string()),
1458 policy_json,
1459 &["unknown_component", "unknown_field", "unsupported_value"],
1460 );
1461 }
1462}
1463
1464fn validate_section_fields(
1465 diagnostics: &mut Vec<ConfigDiagnostic>,
1466 policy: &ConfigPolicy,
1467 plugin_config: &Map<String, Json>,
1468 section: &str,
1469 known_fields: &[&str],
1470) {
1471 if let Some(section_json) = plugin_config.get(section).and_then(Json::as_object) {
1472 validate_unknown_fields(
1473 diagnostics,
1474 policy,
1475 Some(section.to_string()),
1476 section_json,
1477 known_fields,
1478 );
1479 }
1480}
1481
1482fn validate_atof_values(
1483 diagnostics: &mut Vec<ConfigDiagnostic>,
1484 policy: &ConfigPolicy,
1485 section: &AtofSectionConfig,
1486) {
1487 if AtofExporterMode::parse(§ion.mode).is_none() {
1488 push_policy_diag(
1489 diagnostics,
1490 policy.unsupported_value,
1491 "observability.unsupported_value",
1492 Some("atof".to_string()),
1493 Some("mode".to_string()),
1494 "ATOF mode must be 'append' or 'overwrite'".to_string(),
1495 );
1496 }
1497}
1498
1499fn validate_atif_values(
1500 diagnostics: &mut Vec<ConfigDiagnostic>,
1501 policy: &ConfigPolicy,
1502 section: &AtifSectionConfig,
1503) {
1504 if !section.filename_template.contains("{session_id}") {
1505 push_policy_diag(
1506 diagnostics,
1507 policy.unsupported_value,
1508 "observability.unsupported_value",
1509 Some("atif".to_string()),
1510 Some("filename_template".to_string()),
1511 "ATIF filename_template must contain '{session_id}'".to_string(),
1512 );
1513 }
1514 for (index, storage) in section.storage.iter().enumerate() {
1515 validate_atif_storage_values(diagnostics, policy, index, storage);
1516 }
1517}
1518
1519fn validate_atif_storage_values(
1520 diagnostics: &mut Vec<ConfigDiagnostic>,
1521 policy: &ConfigPolicy,
1522 index: usize,
1523 storage: &AtifStorageConfig,
1524) {
1525 match storage {
1526 AtifStorageConfig::S3(s3) => {
1527 if s3.bucket.trim().is_empty() {
1528 push_policy_diag(
1529 diagnostics,
1530 policy.unsupported_value,
1531 "observability.unsupported_value",
1532 Some("atif".to_string()),
1533 Some(format!("storage[{index}].bucket")),
1534 format!("ATIF storage[{index}].bucket must be non-empty"),
1535 );
1536 }
1537 validate_atif_storage_env_var(
1538 diagnostics,
1539 policy,
1540 &format!("storage[{index}].secret_access_key_var"),
1541 s3.secret_access_key_var.as_deref(),
1542 );
1543 validate_atif_storage_env_var(
1544 diagnostics,
1545 policy,
1546 &format!("storage[{index}].session_token_var"),
1547 s3.session_token_var.as_deref(),
1548 );
1549 }
1550 }
1551}
1552
1553fn validate_atif_storage_env_var(
1554 diagnostics: &mut Vec<ConfigDiagnostic>,
1555 policy: &ConfigPolicy,
1556 field: &str,
1557 var_name: Option<&str>,
1558) {
1559 let Some(var_name) = var_name else {
1560 return;
1561 };
1562 let trimmed = var_name.trim();
1563 if trimmed.is_empty() {
1564 push_policy_diag(
1565 diagnostics,
1566 policy.unsupported_value,
1567 "observability.unsupported_value",
1568 Some("atif".to_string()),
1569 Some(field.to_string()),
1570 format!("ATIF {field} must be the name of an environment variable, not empty"),
1571 );
1572 return;
1573 }
1574 if trimmed != var_name {
1575 push_policy_diag(
1576 diagnostics,
1577 policy.unsupported_value,
1578 "observability.unsupported_value",
1579 Some("atif".to_string()),
1580 Some(field.to_string()),
1581 format!("ATIF {field} must not have surrounding whitespace; got '{var_name}'"),
1582 );
1583 return;
1584 }
1585 match std::env::var(var_name) {
1586 Ok(value) if !value.is_empty() => {}
1587 Ok(_) => {
1588 push_policy_diag(
1589 diagnostics,
1590 policy.unsupported_value,
1591 "observability.unsupported_value",
1592 Some("atif".to_string()),
1593 Some(field.to_string()),
1594 format!(
1595 "ATIF {field}='{var_name}' references an environment variable that is set but empty"
1596 ),
1597 );
1598 }
1599 Err(_) => {
1600 push_policy_diag(
1601 diagnostics,
1602 policy.unsupported_value,
1603 "observability.unsupported_value",
1604 Some("atif".to_string()),
1605 Some(field.to_string()),
1606 format!(
1607 "ATIF {field}='{var_name}' references an environment variable that is not set"
1608 ),
1609 );
1610 }
1611 }
1612}
1613
1614fn validate_otlp_values(
1615 diagnostics: &mut Vec<ConfigDiagnostic>,
1616 policy: &ConfigPolicy,
1617 section_name: &str,
1618 section: &OtlpSectionConfig,
1619) {
1620 if !matches!(section.transport.as_str(), "http_binary" | "grpc") {
1621 push_policy_diag(
1622 diagnostics,
1623 policy.unsupported_value,
1624 "observability.unsupported_value",
1625 Some(section_name.to_string()),
1626 Some("transport".to_string()),
1627 format!("{section_name} transport must be 'http_binary' or 'grpc'"),
1628 );
1629 }
1630}
1631
1632fn validate_unknown_fields(
1633 diagnostics: &mut Vec<ConfigDiagnostic>,
1634 policy: &ConfigPolicy,
1635 component: Option<String>,
1636 config: &Map<String, Json>,
1637 known_fields: &[&str],
1638) {
1639 for field in config.keys() {
1640 if !known_fields.contains(&field.as_str()) {
1641 push_policy_diag(
1642 diagnostics,
1643 policy.unknown_field,
1644 "observability.unknown_field",
1645 component.clone(),
1646 Some(field.clone()),
1647 format!(
1648 "field '{}' is not recognized for '{}'",
1649 field,
1650 component.as_deref().unwrap_or("unknown")
1651 ),
1652 );
1653 }
1654 }
1655}
1656
1657fn push_policy_diag(
1658 diagnostics: &mut Vec<ConfigDiagnostic>,
1659 behavior: UnsupportedBehavior,
1660 code: &str,
1661 component: Option<String>,
1662 field: Option<String>,
1663 message: String,
1664) {
1665 let level = match behavior {
1666 UnsupportedBehavior::Ignore => return,
1667 UnsupportedBehavior::Warn => DiagnosticLevel::Warning,
1668 UnsupportedBehavior::Error => DiagnosticLevel::Error,
1669 };
1670 diagnostics.push(ConfigDiagnostic {
1671 level,
1672 code: code.to_string(),
1673 component,
1674 field,
1675 message,
1676 });
1677}
1678
1679fn observability_registration_error(error: impl std::fmt::Display) -> PluginError {
1680 PluginError::RegistrationFailed(error.to_string())
1681}
1682
1683fn default_observability_config_version() -> u32 {
1684 1
1685}
1686
1687fn default_atof_mode() -> String {
1688 "append".to_string()
1689}
1690
1691fn default_agent_name() -> String {
1692 "NeMo Relay".to_string()
1693}
1694
1695fn default_agent_version() -> String {
1696 env!("CARGO_PKG_VERSION").to_string()
1697}
1698
1699fn default_model_name() -> String {
1700 "unknown".to_string()
1701}
1702
1703fn default_atif_filename_template() -> String {
1704 "nemo-relay-atif-{session_id}.json".to_string()
1705}
1706
1707fn default_otlp_transport() -> String {
1708 "http_binary".to_string()
1709}
1710
1711fn default_service_name() -> String {
1712 "nemo-relay".to_string()
1713}
1714
1715fn default_timeout_millis() -> u64 {
1716 3_000
1717}
1718
1719fn default_output_directory() -> PathBuf {
1720 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1721}
1722
1723#[cfg(not(all(feature = "object-store", not(target_arch = "wasm32"))))]
1724struct AtifRemoteStorage;
1725
1726#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1734struct AtifRemoteStorage {
1735 sender: std::sync::mpsc::Sender<AtifUploadRequest>,
1736 key_prefix: String,
1737}
1738
1739#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1740struct AtifUploadRequest {
1741 key: String,
1742 payload: Vec<u8>,
1743 reply: std::sync::mpsc::Sender<std::io::Result<()>>,
1744}
1745
1746#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1747#[derive(Default)]
1748struct S3BuilderOverrides {
1749 access_key_id: Option<String>,
1750 secret_access_key: Option<String>,
1751 session_token: Option<String>,
1752 region: Option<String>,
1753 endpoint_url: Option<String>,
1754 allow_http: Option<bool>,
1755}
1756
1757#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1758impl S3BuilderOverrides {
1759 fn resolve(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
1760 Ok(Self {
1761 access_key_id: s3.access_key_id.clone(),
1762 secret_access_key: resolve_env_var_field(
1763 &format!("storage[{index}].secret_access_key_var"),
1764 s3.secret_access_key_var.as_deref(),
1765 )?,
1766 session_token: resolve_env_var_field(
1767 &format!("storage[{index}].session_token_var"),
1768 s3.session_token_var.as_deref(),
1769 )?,
1770 region: s3.region.clone(),
1771 endpoint_url: s3.endpoint_url.clone(),
1772 allow_http: s3.allow_http,
1773 })
1774 }
1775
1776 fn apply(
1777 self,
1778 mut builder: object_store::aws::AmazonS3Builder,
1779 ) -> object_store::aws::AmazonS3Builder {
1780 if let Some(value) = self.access_key_id {
1781 builder = builder.with_access_key_id(value);
1782 }
1783 if let Some(value) = self.secret_access_key {
1784 builder = builder.with_secret_access_key(value);
1785 }
1786 if let Some(value) = self.session_token {
1787 builder = builder.with_token(value);
1788 }
1789 if let Some(value) = self.region {
1790 builder = builder.with_region(value);
1791 }
1792 if let Some(value) = self.endpoint_url {
1793 builder = builder.with_endpoint(value);
1794 }
1795 if let Some(value) = self.allow_http {
1796 builder = builder.with_allow_http(value);
1797 }
1798 builder
1799 }
1800}
1801
1802#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1803fn resolve_env_var_field(field: &str, var_name: Option<&str>) -> std::io::Result<Option<String>> {
1804 let Some(var_name) = var_name else {
1805 return Ok(None);
1806 };
1807 if var_name.trim().is_empty() || var_name.trim() != var_name {
1808 return Err(std::io::Error::other(format!(
1809 "ATIF {field} must be the name of an environment variable, not '{var_name}'"
1810 )));
1811 }
1812 match std::env::var(var_name) {
1813 Ok(value) if !value.is_empty() => Ok(Some(value)),
1814 Ok(_) => Err(std::io::Error::other(format!(
1815 "ATIF {field}='{var_name}' references an environment variable that is set but empty"
1816 ))),
1817 Err(_) => Err(std::io::Error::other(format!(
1818 "ATIF {field}='{var_name}' references an environment variable that is not set"
1819 ))),
1820 }
1821}
1822
1823#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1824impl AtifRemoteStorage {
1825 fn from_config(index: usize, config: &AtifStorageConfig) -> std::io::Result<Self> {
1826 match config {
1827 AtifStorageConfig::S3(s3) => Self::build_s3(index, s3),
1828 }
1829 }
1830
1831 fn build_s3(index: usize, s3: &S3StorageConfig) -> std::io::Result<Self> {
1832 let bucket = s3.bucket.clone();
1833 let key_prefix = normalize_storage_key_prefix(s3.key_prefix.as_deref());
1834 let overrides = S3BuilderOverrides::resolve(index, s3)?;
1835
1836 let (req_tx, req_rx) = std::sync::mpsc::channel::<AtifUploadRequest>();
1837 let (ready_tx, ready_rx) = std::sync::mpsc::channel::<std::io::Result<()>>();
1838
1839 std::thread::Builder::new()
1840 .name("nemo-relay-atif-storage".to_string())
1841 .spawn(move || {
1842 let runtime = match tokio::runtime::Builder::new_current_thread()
1843 .enable_all()
1844 .build()
1845 {
1846 Ok(rt) => rt,
1847 Err(err) => {
1848 let _ = ready_tx.send(Err(std::io::Error::other(format!(
1849 "failed to build ATIF storage runtime: {err}"
1850 ))));
1851 return;
1852 }
1853 };
1854 let store = match overrides
1855 .apply(object_store::aws::AmazonS3Builder::from_env())
1856 .with_bucket_name(&bucket)
1857 .build()
1858 {
1859 Ok(store) => Arc::new(store) as Arc<dyn object_store::ObjectStore>,
1860 Err(err) => {
1861 let _ = ready_tx.send(Err(std::io::Error::other(format!(
1862 "failed to build S3 client for bucket '{bucket}': {err}"
1863 ))));
1864 return;
1865 }
1866 };
1867 if ready_tx.send(Ok(())).is_err() {
1868 return;
1869 }
1870 drop(ready_tx);
1871
1872 while let Ok(request) = req_rx.recv() {
1873 let result = runtime.block_on(async {
1874 use object_store::ObjectStoreExt as _;
1875 store
1876 .put(
1877 &object_store::path::Path::from(request.key.clone()),
1878 object_store::PutPayload::from(request.payload),
1879 )
1880 .await
1881 .map(|_| ())
1882 .map_err(|err| {
1883 std::io::Error::other(format!(
1884 "S3 upload to '{}' failed: {err}",
1885 request.key
1886 ))
1887 })
1888 });
1889 let _ = request.reply.send(result);
1890 }
1891 })
1892 .map_err(|err| {
1893 std::io::Error::other(format!("failed to spawn ATIF storage thread: {err}"))
1894 })?;
1895
1896 match ready_rx.recv() {
1897 Ok(Ok(())) => Ok(Self {
1898 sender: req_tx,
1899 key_prefix,
1900 }),
1901 Ok(Err(err)) => Err(err),
1902 Err(_) => Err(std::io::Error::other(
1903 "ATIF storage thread exited before signalling readiness",
1904 )),
1905 }
1906 }
1907
1908 fn put(&self, filename: &str, payload: &[u8]) -> std::io::Result<()> {
1909 let key = format!("{}{}", self.key_prefix, filename);
1910 let (reply_tx, reply_rx) = std::sync::mpsc::channel();
1911 self.sender
1912 .send(AtifUploadRequest {
1913 key,
1914 payload: payload.to_vec(),
1915 reply: reply_tx,
1916 })
1917 .map_err(|_| std::io::Error::other("ATIF storage thread is not running"))?;
1918 reply_rx
1919 .recv()
1920 .map_err(|_| std::io::Error::other("ATIF storage thread dropped the upload reply"))?
1921 }
1922}
1923
1924#[cfg(all(feature = "object-store", not(target_arch = "wasm32")))]
1925fn normalize_storage_key_prefix(raw: Option<&str>) -> String {
1926 let trimmed = raw.unwrap_or("").trim();
1927 if trimmed.is_empty() {
1928 return String::new();
1929 }
1930 if trimmed.ends_with('/') {
1931 trimmed.to_string()
1932 } else {
1933 format!("{trimmed}/")
1934 }
1935}
1936
1937#[cfg(test)]
1938#[path = "../../tests/unit/observability/plugin_component_tests.rs"]
1939mod tests;