Skip to main content

nemo_relay/observability/
plugin_component.rs

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