1use std::collections::HashMap;
12use std::fs::{File, OpenOptions, create_dir_all};
13use std::io::{BufWriter, Write};
14use std::path::{Path, PathBuf};
15use std::sync::{Arc, Mutex, mpsc as std_mpsc};
16use std::time::Duration;
17
18use chrono::Utc;
19#[cfg(feature = "atof-streaming")]
20use futures_util::{SinkExt, stream};
21use serde::{Deserialize, Serialize};
22use serde_json::Value as Json;
23#[cfg(feature = "atof-streaming")]
24use tokio_tungstenite::tungstenite::client::IntoClientRequest;
25
26use crate::api::event::Event;
27use crate::api::runtime::EventSubscriberFn;
28use crate::api::subscriber::{deregister_subscriber, flush_subscribers, register_subscriber};
29use crate::error::FlowError;
30
31pub type Result<T> = std::result::Result<T, AtofExporterError>;
33
34#[derive(Debug, thiserror::Error)]
36pub enum AtofExporterError {
37 #[error("failed to resolve current working directory: {0}")]
39 CurrentDirectory(std::io::Error),
40 #[error("failed to open ATOF output file {path:?}: {source}")]
42 OpenFile {
43 path: PathBuf,
45 source: std::io::Error,
47 },
48 #[error("failed to flush ATOF output file {path:?}: {source}")]
50 Flush {
51 path: PathBuf,
53 source: std::io::Error,
55 },
56 #[error("previous ATOF export failed for {path:?}: {message}")]
58 StoredFailure {
59 path: PathBuf,
61 message: String,
63 },
64 #[error("invalid ATOF streaming endpoint: {0}")]
66 InvalidEndpoint(String),
67 #[error("the ATOF exporter state lock was poisoned")]
69 LockPoisoned,
70 #[error(transparent)]
72 Runtime(#[from] FlowError),
73}
74
75#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum AtofExporterMode {
79 #[default]
81 Append,
82 Overwrite,
84}
85
86impl AtofExporterMode {
87 pub fn parse(value: &str) -> Option<Self> {
89 match value {
90 "append" => Some(Self::Append),
91 "overwrite" => Some(Self::Overwrite),
92 _ => None,
93 }
94 }
95
96 pub fn as_str(self) -> &'static str {
98 match self {
99 Self::Append => "append",
100 Self::Overwrite => "overwrite",
101 }
102 }
103}
104
105#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(rename_all = "snake_case")]
108pub enum AtofEndpointTransport {
109 #[default]
111 HttpPost,
112 Websocket,
114 Ndjson,
116}
117
118#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
120#[serde(rename_all = "snake_case")]
121pub enum AtofEndpointFieldNamePolicy {
122 #[default]
124 Preserve,
125 ReplaceDots,
127}
128
129impl AtofEndpointFieldNamePolicy {
130 pub fn parse(value: &str) -> Option<Self> {
132 match value {
133 "preserve" => Some(Self::Preserve),
134 "replace_dots" => Some(Self::ReplaceDots),
135 _ => None,
136 }
137 }
138
139 pub fn as_str(self) -> &'static str {
141 match self {
142 Self::Preserve => "preserve",
143 Self::ReplaceDots => "replace_dots",
144 }
145 }
146}
147
148impl AtofEndpointTransport {
149 pub fn parse(value: &str) -> Option<Self> {
151 match value {
152 "http_post" => Some(Self::HttpPost),
153 "websocket" => Some(Self::Websocket),
154 "ndjson" => Some(Self::Ndjson),
155 _ => None,
156 }
157 }
158
159 pub fn as_str(self) -> &'static str {
161 match self {
162 Self::HttpPost => "http_post",
163 Self::Websocket => "websocket",
164 Self::Ndjson => "ndjson",
165 }
166 }
167}
168
169#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct AtofStreamSinkConfig {
172 pub url: String,
174 #[serde(default)]
176 pub transport: AtofEndpointTransport,
177 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
179 pub headers: HashMap<String, String>,
180 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
182 pub header_env: HashMap<String, String>,
183 #[serde(default = "default_endpoint_timeout_millis")]
185 pub timeout_millis: u64,
186 #[serde(default)]
188 pub field_name_policy: AtofEndpointFieldNamePolicy,
189}
190
191impl AtofStreamSinkConfig {
192 pub fn new(url: impl Into<String>, transport: AtofEndpointTransport) -> Self {
194 Self {
195 url: url.into(),
196 transport,
197 headers: HashMap::new(),
198 header_env: HashMap::new(),
199 timeout_millis: default_endpoint_timeout_millis(),
200 field_name_policy: AtofEndpointFieldNamePolicy::Preserve,
201 }
202 }
203
204 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
206 self.headers.insert(key.into(), value.into());
207 self
208 }
209
210 pub fn with_header_env(
212 mut self,
213 key: impl Into<String>,
214 environment_variable: impl Into<String>,
215 ) -> Self {
216 self.header_env
217 .insert(key.into(), environment_variable.into());
218 self
219 }
220
221 pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self {
223 self.timeout_millis = timeout_millis;
224 self
225 }
226
227 pub fn with_field_name_policy(
229 mut self,
230 field_name_policy: AtofEndpointFieldNamePolicy,
231 ) -> Self {
232 self.field_name_policy = field_name_policy;
233 self
234 }
235}
236
237pub type AtofEndpointConfig = AtofStreamSinkConfig;
239
240#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
242pub struct AtofFileSinkConfig {
243 #[serde(default = "default_output_directory")]
245 pub output_directory: PathBuf,
246 #[serde(default)]
248 pub mode: AtofExporterMode,
249 #[serde(default = "default_filename")]
251 pub filename: String,
252}
253
254impl Default for AtofFileSinkConfig {
255 fn default() -> Self {
256 Self {
257 output_directory: default_output_directory(),
258 mode: AtofExporterMode::Append,
259 filename: default_filename(),
260 }
261 }
262}
263
264impl AtofFileSinkConfig {
265 pub fn new() -> Self {
267 Self::default()
268 }
269
270 pub fn path(&self) -> PathBuf {
272 self.output_directory.join(&self.filename)
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
278#[serde(tag = "type", rename_all = "snake_case")]
279pub enum AtofSinkConfig {
280 File(AtofFileSinkConfig),
282 Stream(AtofStreamSinkConfig),
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
288pub struct AtofExporterConfig {
289 #[serde(flatten)]
291 pub sink: AtofSinkConfig,
292}
293
294impl Default for AtofExporterConfig {
295 fn default() -> Self {
296 Self {
297 sink: AtofSinkConfig::File(AtofFileSinkConfig::default()),
298 }
299 }
300}
301
302impl AtofExporterConfig {
303 pub fn new() -> Self {
305 Self::default()
306 }
307
308 pub fn with_output_directory(mut self, output_directory: impl Into<PathBuf>) -> Self {
313 if let AtofSinkConfig::File(file) = &mut self.sink {
314 file.output_directory = output_directory.into();
315 }
316 self
317 }
318
319 pub fn with_mode(mut self, mode: AtofExporterMode) -> Self {
324 if let AtofSinkConfig::File(file) = &mut self.sink {
325 file.mode = mode;
326 }
327 self
328 }
329
330 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
335 if let AtofSinkConfig::File(file) = &mut self.sink {
336 file.filename = filename.into();
337 }
338 self
339 }
340
341 pub fn with_stream_sink(mut self, sink: AtofStreamSinkConfig) -> Self {
343 self.sink = AtofSinkConfig::Stream(sink);
344 self
345 }
346
347 pub fn with_endpoint(self, endpoint: AtofEndpointConfig) -> Self {
352 self.with_stream_sink(endpoint)
353 }
354
355 pub fn path(&self) -> Option<PathBuf> {
357 match &self.sink {
358 AtofSinkConfig::File(file) => Some(file.path()),
359 AtofSinkConfig::Stream(_) => None,
360 }
361 }
362}
363
364struct AtofExporterState {
365 writer: Option<BufWriter<File>>,
366 last_error: Option<String>,
367 endpoints: Vec<AtofEndpointWorker>,
368 closed: bool,
369}
370
371pub struct AtofExporter {
373 path: Option<PathBuf>,
374 state: Arc<Mutex<AtofExporterState>>,
375}
376
377impl AtofExporter {
378 pub fn new(config: AtofExporterConfig) -> Result<Self> {
380 let (path, writer, endpoints) = match config.sink {
381 AtofSinkConfig::File(file_sink) => {
382 let path = file_sink.path();
383 create_dir_all(&file_sink.output_directory).map_err(|source| {
384 AtofExporterError::OpenFile {
385 path: path.clone(),
386 source,
387 }
388 })?;
389 let file = open_file(&path, file_sink.mode)?;
390 log::info!(
391 target: "nemo_relay.observability",
392 event = "storage_access_validated",
393 plugin_kind = "observability",
394 exporter = "atof",
395 resource_kind = "local_file",
396 permission = "write";
397 "ATOF storage access validated"
398 );
399 (Some(path), Some(BufWriter::new(file)), Vec::new())
400 }
401 AtofSinkConfig::Stream(stream_sink) => {
402 let workers = start_endpoint_workers(&[stream_sink])?;
403 (None, None, workers)
404 }
405 };
406 Ok(Self {
407 path,
408 state: Arc::new(Mutex::new(AtofExporterState {
409 writer,
410 last_error: None,
411 endpoints,
412 closed: false,
413 })),
414 })
415 }
416
417 pub fn path(&self) -> Option<&Path> {
419 self.path.as_deref()
420 }
421
422 pub fn subscriber(&self) -> EventSubscriberFn {
424 let state = Arc::clone(&self.state);
425 Arc::new(move |event: &Event| {
426 let Ok(mut state) = state.lock() else {
427 return;
428 };
429 if state.closed || state.last_error.is_some() {
430 return;
431 }
432 let Ok(value) = event.try_to_json_value() else {
433 state.last_error = Some("failed to serialize ATOF event".to_string());
434 return;
435 };
436 if let Some(writer) = &mut state.writer
437 && let Err(error) = write_json_value(writer, &value)
438 {
439 state.last_error = Some(error);
440 return;
441 }
442 let Ok(raw_json) = serde_json::to_string(&value) else {
443 state.last_error = Some("failed to serialize ATOF event".to_string());
444 return;
445 };
446 for endpoint in &state.endpoints {
447 endpoint.enqueue(raw_json.clone());
448 }
449 })
450 }
451
452 pub fn register(&self, name: &str) -> Result<()> {
454 register_subscriber(name, self.subscriber()).map_err(AtofExporterError::from)?;
455 log::info!(
456 target: "nemo_relay.observability",
457 event = "exporter_registered",
458 exporter = "atof",
459 subscriber = name;
460 "ATOF exporter registered"
461 );
462 Ok(())
463 }
464
465 pub fn deregister(&self, name: &str) -> Result<bool> {
467 let removed = deregister_subscriber(name).map_err(AtofExporterError::from)?;
468 if removed {
469 log::info!(
470 target: "nemo_relay.observability",
471 event = "exporter_deregistered",
472 exporter = "atof",
473 subscriber = name;
474 "ATOF exporter deregistered"
475 );
476 }
477 Ok(removed)
478 }
479
480 pub fn force_flush(&self) -> Result<()> {
486 flush_subscribers()?;
487 let mut state = self
488 .state
489 .lock()
490 .map_err(|_| AtofExporterError::LockPoisoned)?;
491 if state.closed {
492 return stored_failure_result(
493 self.path
494 .as_deref()
495 .unwrap_or_else(|| Path::new("<stream>")),
496 &state,
497 );
498 }
499 state
500 .writer
501 .as_mut()
502 .map(|writer| writer.flush())
503 .transpose()
504 .map_err(|source| AtofExporterError::Flush {
505 path: self
506 .path
507 .clone()
508 .unwrap_or_else(|| PathBuf::from("<stream>")),
509 source,
510 })?;
511 for endpoint in &state.endpoints {
512 endpoint.flush();
513 }
514 stored_failure_result(
515 self.path
516 .as_deref()
517 .unwrap_or_else(|| Path::new("<stream>")),
518 &state,
519 )
520 }
521
522 pub fn shutdown(&self) -> Result<()> {
528 flush_subscribers()?;
529 let mut state = self
530 .state
531 .lock()
532 .map_err(|_| AtofExporterError::LockPoisoned)?;
533 if state.closed {
534 return stored_failure_result(
535 self.path
536 .as_deref()
537 .unwrap_or_else(|| Path::new("<stream>")),
538 &state,
539 );
540 }
541 state.closed = true;
542 let flush_result = state
543 .writer
544 .as_mut()
545 .map(|writer| writer.flush())
546 .transpose()
547 .map_err(|source| AtofExporterError::Flush {
548 path: self
549 .path
550 .clone()
551 .unwrap_or_else(|| PathBuf::from("<stream>")),
552 source,
553 });
554 for endpoint in &state.endpoints {
555 endpoint.close();
556 }
557 flush_result?;
558 let result = stored_failure_result(
559 self.path
560 .as_deref()
561 .unwrap_or_else(|| Path::new("<stream>")),
562 &state,
563 );
564 if result.is_ok() {
565 log::info!(
566 target: "nemo_relay.observability",
567 event = "exporter_shutdown",
568 exporter = "atof";
569 "ATOF exporter shut down"
570 );
571 }
572 result
573 }
574}
575
576fn default_filename() -> String {
577 format!(
578 "nemo-relay-events-{}.jsonl",
579 Utc::now().format("%Y-%m-%d-%H.%M.%S")
580 )
581}
582
583fn default_output_directory() -> PathBuf {
584 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
585}
586
587fn default_endpoint_timeout_millis() -> u64 {
588 3_000
589}
590
591fn open_file(path: &Path, mode: AtofExporterMode) -> Result<File> {
592 let mut options = OpenOptions::new();
593 options.create(true);
594 match mode {
595 AtofExporterMode::Append => {
596 options.append(true);
597 }
598 AtofExporterMode::Overwrite => {
599 options.write(true).truncate(true);
600 }
601 }
602 options
603 .open(path)
604 .map_err(|source| AtofExporterError::OpenFile {
605 path: path.to_path_buf(),
606 source,
607 })
608}
609
610fn write_json_value(writer: &mut BufWriter<File>, value: &Json) -> std::result::Result<(), String> {
611 serde_json::to_writer(&mut *writer, value).map_err(|error| error.to_string())?;
612 writer.write_all(b"\n").map_err(|error| error.to_string())?;
613 writer.flush().map_err(|error| error.to_string())
614}
615
616fn stored_failure_result(path: &Path, state: &AtofExporterState) -> Result<()> {
617 if let Some(message) = &state.last_error {
618 return Err(AtofExporterError::StoredFailure {
619 path: path.to_path_buf(),
620 message: message.clone(),
621 });
622 }
623 Ok(())
624}
625
626#[cfg_attr(not(feature = "atof-streaming"), allow(dead_code))]
627enum EndpointMessage {
628 Event(String),
629 Flush(std_mpsc::Sender<()>),
630 Close(std_mpsc::Sender<()>),
631}
632
633#[cfg(feature = "atof-streaming")]
634enum NdjsonBodyMessage {
635 Event(Vec<u8>),
636 Flush(std_mpsc::Sender<()>),
637}
638
639#[cfg(feature = "atof-streaming")]
640impl NdjsonBodyMessage {
641 fn acknowledge_if_flush(self) {
642 if let Self::Flush(done) = self {
643 let _ = done.send(());
644 }
645 }
646}
647
648struct AtofEndpointWorker {
649 sender: tokio::sync::mpsc::UnboundedSender<EndpointMessage>,
650 timeout: Duration,
651 index: usize,
652 transport: AtofEndpointTransport,
653}
654
655#[cfg(feature = "atof-streaming")]
657#[derive(Debug)]
658struct ActivatedAtofEndpoint {
659 config: AtofEndpointConfig,
660 headers: reqwest::header::HeaderMap,
661}
662
663impl AtofEndpointWorker {
664 fn enqueue(&self, raw_json: String) {
665 let _ = self.sender.send(EndpointMessage::Event(raw_json));
666 }
667
668 fn flush(&self) {
669 let (tx, rx) = std_mpsc::channel();
670 if self.sender.send(EndpointMessage::Flush(tx)).is_ok()
671 && rx.recv_timeout(self.timeout).is_err()
672 {
673 log::warn!(
674 target: "nemo_relay.observability",
675 event = "endpoint_flush_failed",
676 exporter = "atof",
677 endpoint_index = self.index,
678 transport = self.transport.as_str(),
679 reason = "timeout";
680 "ATOF endpoint flush timed out"
681 );
682 }
683 }
684
685 fn close(&self) {
686 let (tx, rx) = std_mpsc::channel();
687 if self.sender.send(EndpointMessage::Close(tx)).is_ok()
688 && rx.recv_timeout(self.timeout).is_err()
689 {
690 log::warn!(
691 target: "nemo_relay.observability",
692 event = "endpoint_close_failed",
693 exporter = "atof",
694 endpoint_index = self.index,
695 transport = self.transport.as_str(),
696 reason = "timeout";
697 "ATOF endpoint close timed out"
698 );
699 }
700 }
701}
702
703#[cfg(feature = "atof-streaming")]
704fn start_endpoint_workers(configs: &[AtofStreamSinkConfig]) -> Result<Vec<AtofEndpointWorker>> {
705 let mut workers = Vec::with_capacity(configs.len());
706 for (index, config) in configs.iter().enumerate() {
707 match start_endpoint_worker(index, config.clone()) {
708 Ok(worker) => workers.push(worker),
709 Err(error) => {
710 return Err(AtofExporterError::InvalidEndpoint(format!(
711 "endpoints[{index}]: {error}"
712 )));
713 }
714 }
715 }
716 Ok(workers)
717}
718
719#[cfg(not(feature = "atof-streaming"))]
720fn start_endpoint_workers(configs: &[AtofEndpointConfig]) -> Result<Vec<AtofEndpointWorker>> {
721 if configs.is_empty() {
722 return Ok(Vec::new());
723 }
724 let message = "ATOF streaming endpoints are not supported in this build".to_string();
725 Err(AtofExporterError::InvalidEndpoint(message))
726}
727
728#[cfg(feature = "atof-streaming")]
729fn start_endpoint_worker(index: usize, config: AtofEndpointConfig) -> Result<AtofEndpointWorker> {
730 let endpoint = validate_endpoint_config(config)?;
731 let timeout = Duration::from_millis(endpoint.config.timeout_millis);
732 let transport = endpoint.config.transport;
733 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
734 std::thread::Builder::new()
735 .name(format!("nemo-relay-atof-endpoint-{index}"))
736 .spawn(move || run_endpoint_worker(index, endpoint, rx))
737 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
738 log::info!(
739 target: "nemo_relay.observability",
740 event = "endpoint_started",
741 exporter = "atof",
742 endpoint_index = index,
743 transport = transport.as_str();
744 "ATOF endpoint worker started"
745 );
746 log::info!(
747 target: "nemo_relay.plugin",
748 event = "plugin_resource_access_pending",
749 plugin_kind = "observability",
750 resource_kind = "stream_endpoint",
751 resource_index = index,
752 permission = "write";
753 "Plugin resource access will be validated on first use"
754 );
755 Ok(AtofEndpointWorker {
756 sender: tx,
757 timeout,
758 index,
759 transport,
760 })
761}
762
763#[cfg(feature = "atof-streaming")]
764fn validate_endpoint_config(config: AtofEndpointConfig) -> Result<ActivatedAtofEndpoint> {
765 if config.url.trim().is_empty() {
766 return Err(AtofExporterError::InvalidEndpoint(
767 "endpoint url must be non-empty".to_string(),
768 ));
769 }
770 if config.timeout_millis == 0 {
771 return Err(AtofExporterError::InvalidEndpoint(
772 "endpoint timeout_millis must be greater than 0".to_string(),
773 ));
774 }
775 let url = reqwest::Url::parse(&config.url)
776 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
777 let valid_scheme = match config.transport {
778 AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
779 matches!(url.scheme(), "http" | "https")
780 }
781 AtofEndpointTransport::Websocket => matches!(url.scheme(), "ws" | "wss"),
782 };
783 if !valid_scheme {
784 return Err(AtofExporterError::InvalidEndpoint(format!(
785 "endpoint {} transport does not support URL scheme {:?}",
786 config.transport.as_str(),
787 url.scheme()
788 )));
789 }
790 let headers = resolved_header_map(&config.headers, &config.header_env)?;
791 Ok(ActivatedAtofEndpoint { config, headers })
792}
793
794#[cfg(feature = "atof-streaming")]
795fn resolved_header_map(
796 headers: &HashMap<String, String>,
797 header_env: &HashMap<String, String>,
798) -> Result<reqwest::header::HeaderMap> {
799 let mut out = reqwest::header::HeaderMap::new();
800 for (key, value) in headers {
801 let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
802 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
803 let value = reqwest::header::HeaderValue::from_str(value)
804 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
805 out.insert(name, value);
806 }
807 for (key, variable) in header_env {
808 let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
809 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
810 if out.contains_key(&name) {
811 return Err(AtofExporterError::InvalidEndpoint(format!(
812 "header {key:?} cannot be configured in both headers and header_env"
813 )));
814 }
815 let value = std::env::var(variable).map_err(|_| {
816 AtofExporterError::InvalidEndpoint(format!(
817 "environment variable {variable:?} for header {key:?} is not set"
818 ))
819 })?;
820 if value.trim().is_empty() {
821 return Err(AtofExporterError::InvalidEndpoint(format!(
822 "environment variable {variable:?} for header {key:?} is blank"
823 )));
824 }
825 let value = reqwest::header::HeaderValue::from_str(&value)
826 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
827 out.insert(name, value);
828 }
829 Ok(out)
830}
831
832#[cfg(feature = "atof-streaming")]
833fn run_endpoint_worker(
834 index: usize,
835 endpoint: ActivatedAtofEndpoint,
836 rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
837) {
838 let runtime = match tokio::runtime::Builder::new_current_thread()
839 .enable_all()
840 .build()
841 {
842 Ok(runtime) => runtime,
843 Err(_) => {
844 log::error!(
845 target: "nemo_relay.observability",
846 event = "endpoint_failed",
847 exporter = "atof",
848 endpoint_index = index,
849 reason = "runtime_initialization";
850 "ATOF endpoint runtime failed"
851 );
852 return;
853 }
854 };
855 runtime.block_on(async move {
856 match endpoint.config.transport {
857 AtofEndpointTransport::HttpPost => run_http_post_endpoint(index, endpoint, rx).await,
858 AtofEndpointTransport::Websocket => run_websocket_endpoint(index, endpoint, rx).await,
859 AtofEndpointTransport::Ndjson => run_ndjson_endpoint(index, endpoint, rx).await,
860 }
861 });
862}
863
864#[cfg(feature = "atof-streaming")]
865async fn run_http_post_endpoint(
866 index: usize,
867 endpoint: ActivatedAtofEndpoint,
868 mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
869) {
870 let client = match reqwest::Client::builder()
871 .timeout(Duration::from_millis(endpoint.config.timeout_millis))
872 .default_headers(endpoint.headers)
873 .build()
874 {
875 Ok(client) => client,
876 Err(_) => {
877 log::error!(
878 target: "nemo_relay.observability",
879 event = "endpoint_disabled",
880 exporter = "atof",
881 endpoint_index = index,
882 transport = "http_post",
883 reason = "client_initialization";
884 "ATOF endpoint disabled"
885 );
886 drain_closed(rx).await;
887 return;
888 }
889 };
890 let mut access_validated = false;
891 while let Some(message) = rx.recv().await {
892 match message {
893 EndpointMessage::Event(raw_json) => {
894 let body = format!("{}\n", endpoint_event_json(&endpoint.config, raw_json));
895 let result = client
896 .post(&endpoint.config.url)
897 .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
898 .body(body)
899 .send()
900 .await;
901 match result {
902 Ok(response) if response.status().is_success() => {
903 if !access_validated {
904 log::info!(
905 target: "nemo_relay.observability",
906 event = "endpoint_access_validated",
907 plugin_kind = "observability",
908 exporter = "atof",
909 endpoint_index = index,
910 transport = "http_post",
911 permission = "write";
912 "ATOF endpoint access validated"
913 );
914 access_validated = true;
915 }
916 }
917 Ok(response) => log_http_error(index, "http_post", response),
918 Err(_) => log::warn!(
919 target: "nemo_relay.observability",
920 event = "endpoint_delivery_failed",
921 exporter = "atof",
922 endpoint_index = index,
923 transport = "http_post",
924 reason = "request_failed";
925 "ATOF endpoint delivery failed"
926 ),
927 }
928 }
929 EndpointMessage::Flush(done) => {
930 let _ = done.send(());
931 }
932 EndpointMessage::Close(done) => {
933 let _ = done.send(());
934 return;
935 }
936 }
937 }
938}
939
940#[cfg(feature = "atof-streaming")]
941async fn run_websocket_endpoint(
942 index: usize,
943 endpoint: ActivatedAtofEndpoint,
944 mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
945) {
946 let mut pending = std::collections::VecDeque::new();
947 let mut retry = WebSocketRetryState::default();
948 let mut socket = match connect_websocket(&endpoint).await {
949 Ok(socket) => {
950 retry.record_recovered(index);
951 Some(socket)
952 }
953 Err(_) => {
954 retry.record_failure(index);
955 None
956 }
957 };
958 while let Some(message) = rx.recv().await {
959 match message {
960 EndpointMessage::Event(raw_json) => {
961 pending.push_back(endpoint_event_json(&endpoint.config, raw_json));
962 let _ = drain_websocket_pending(
963 index,
964 &endpoint,
965 &mut socket,
966 &mut pending,
967 &mut retry,
968 )
969 .await;
970 }
971 EndpointMessage::Flush(done) => {
972 let _ = drain_websocket_pending(
973 index,
974 &endpoint,
975 &mut socket,
976 &mut pending,
977 &mut retry,
978 )
979 .await;
980 let _ = done.send(());
981 }
982 EndpointMessage::Close(done) => {
983 let _ = drain_websocket_pending(
984 index,
985 &endpoint,
986 &mut socket,
987 &mut pending,
988 &mut retry,
989 )
990 .await;
991 if let Some(mut ws) = socket.take() {
992 let _ = ws.close(None).await;
993 }
994 let _ = done.send(());
995 return;
996 }
997 }
998 }
999}
1000
1001#[cfg(feature = "atof-streaming")]
1002type AtofWebSocket =
1003 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
1004
1005#[cfg(feature = "atof-streaming")]
1006#[derive(Default)]
1007struct WebSocketRetryState {
1008 attempts: u64,
1009 warning_emitted: bool,
1010 access_validated: bool,
1011}
1012
1013#[cfg(feature = "atof-streaming")]
1014impl WebSocketRetryState {
1015 fn record_failure(&mut self, index: usize) {
1016 self.attempts = self.attempts.saturating_add(1);
1017 if !self.warning_emitted {
1018 log::warn!(
1019 target: "nemo_relay.observability",
1020 event = "endpoint_reconnecting",
1021 exporter = "atof",
1022 endpoint_index = index,
1023 transport = "websocket";
1024 "ATOF endpoint connection failed; reconnecting"
1025 );
1026 self.warning_emitted = true;
1027 }
1028 }
1029
1030 fn record_recovered(&mut self, index: usize) {
1031 if self.attempts > 0 {
1032 log::info!(
1033 target: "nemo_relay.observability",
1034 event = "endpoint_reconnected",
1035 exporter = "atof",
1036 endpoint_index = index,
1037 transport = "websocket",
1038 attempt_count = self.attempts + 1;
1039 "ATOF endpoint reconnected"
1040 );
1041 }
1042 if !self.access_validated {
1043 log::info!(
1044 target: "nemo_relay.observability",
1045 event = "endpoint_access_validated",
1046 plugin_kind = "observability",
1047 exporter = "atof",
1048 endpoint_index = index,
1049 transport = "websocket",
1050 permission = "connect";
1051 "ATOF endpoint access validated"
1052 );
1053 self.access_validated = true;
1054 }
1055 self.attempts = 0;
1056 self.warning_emitted = false;
1057 }
1058}
1059
1060#[cfg(feature = "atof-streaming")]
1061async fn drain_websocket_pending(
1062 index: usize,
1063 endpoint: &ActivatedAtofEndpoint,
1064 socket: &mut Option<AtofWebSocket>,
1065 pending: &mut std::collections::VecDeque<String>,
1066 retry: &mut WebSocketRetryState,
1067) -> bool {
1068 let timeout = Duration::from_millis(endpoint.config.timeout_millis);
1069 let attempts_before = retry.attempts;
1070 match tokio::time::timeout(
1071 timeout,
1072 drain_websocket_pending_inner(index, endpoint, socket, pending, retry),
1073 )
1074 .await
1075 {
1076 Ok(drained) => drained,
1077 Err(_) => {
1078 if retry.attempts == attempts_before {
1079 retry.record_failure(index);
1080 }
1081 false
1082 }
1083 }
1084}
1085
1086#[cfg(feature = "atof-streaming")]
1087async fn drain_websocket_pending_inner(
1088 index: usize,
1089 endpoint: &ActivatedAtofEndpoint,
1090 socket: &mut Option<AtofWebSocket>,
1091 pending: &mut std::collections::VecDeque<String>,
1092 retry: &mut WebSocketRetryState,
1093) -> bool {
1094 while let Some(raw_json) = pending.front().cloned() {
1095 if socket.is_none() {
1096 match connect_websocket(endpoint).await {
1097 Ok(ws) => {
1098 *socket = Some(ws);
1099 retry.record_recovered(index);
1100 }
1101 Err(_) => {
1102 retry.record_failure(index);
1103 tokio::time::sleep(Duration::from_millis(50)).await;
1104 continue;
1105 }
1106 }
1107 }
1108
1109 let Some(ws) = socket.as_mut() else {
1110 continue;
1111 };
1112 match ws
1113 .send(tokio_tungstenite::tungstenite::Message::Text(
1114 raw_json.into(),
1115 ))
1116 .await
1117 {
1118 Ok(()) => {
1119 pending.pop_front();
1120 }
1121 Err(_) => {
1122 retry.record_failure(index);
1123 *socket = None;
1124 tokio::time::sleep(Duration::from_millis(50)).await;
1125 }
1126 }
1127 }
1128 true
1129}
1130
1131#[cfg(feature = "atof-streaming")]
1132async fn connect_websocket(
1133 endpoint: &ActivatedAtofEndpoint,
1134) -> std::result::Result<AtofWebSocket, String> {
1135 let mut request = endpoint
1136 .config
1137 .url
1138 .as_str()
1139 .into_client_request()
1140 .map_err(|error| error.to_string())?;
1141 for (name, value) in endpoint.headers.clone() {
1142 let Some(name) = name else {
1143 continue;
1144 };
1145 request.headers_mut().insert(name, value);
1146 }
1147 tokio::time::timeout(
1148 Duration::from_millis(endpoint.config.timeout_millis),
1149 tokio_tungstenite::connect_async(request),
1150 )
1151 .await
1152 .map_err(|error| error.to_string())?
1153 .map(|(socket, _)| socket)
1154 .map_err(|error| error.to_string())
1155}
1156
1157#[cfg(feature = "atof-streaming")]
1158async fn run_ndjson_endpoint(
1159 index: usize,
1160 endpoint: ActivatedAtofEndpoint,
1161 mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
1162) {
1163 let client = match build_ndjson_client(&endpoint) {
1164 Ok(client) => client,
1165 Err(_) => {
1166 log::error!(
1167 target: "nemo_relay.observability",
1168 event = "endpoint_disabled",
1169 exporter = "atof",
1170 endpoint_index = index,
1171 transport = "ndjson",
1172 reason = "client_initialization";
1173 "ATOF endpoint disabled"
1174 );
1175 drain_closed(rx).await;
1176 return;
1177 }
1178 };
1179
1180 let (body_tx, body) = ndjson_body_channel();
1181 let url = endpoint.config.url.clone();
1182 let request = tokio::spawn(async move {
1183 client
1184 .post(url)
1185 .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
1186 .body(body)
1187 .send()
1188 .await
1189 });
1190 let close_timeout = Duration::from_millis(endpoint.config.timeout_millis);
1191
1192 while let Some(message) = rx.recv().await {
1193 match message {
1194 EndpointMessage::Event(raw_json) => send_ndjson_event(
1195 index,
1196 &body_tx,
1197 endpoint_event_json(&endpoint.config, raw_json),
1198 ),
1199 EndpointMessage::Flush(done) => send_ndjson_flush(index, &body_tx, done),
1200 EndpointMessage::Close(done) => {
1201 drop(body_tx);
1202 finish_ndjson_upload(index, request, close_timeout, done).await;
1203 return;
1204 }
1205 }
1206 }
1207}
1208
1209#[cfg(feature = "atof-streaming")]
1210fn build_ndjson_client(
1211 endpoint: &ActivatedAtofEndpoint,
1212) -> std::result::Result<reqwest::Client, String> {
1213 reqwest::Client::builder()
1214 .connect_timeout(Duration::from_millis(endpoint.config.timeout_millis))
1215 .default_headers(endpoint.headers.clone())
1216 .build()
1217 .map_err(|error| format!("client build failed: {error}"))
1218}
1219
1220#[cfg(feature = "atof-streaming")]
1221fn ndjson_body_channel() -> (
1222 tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
1223 reqwest::Body,
1224) {
1225 let (body_tx, body_rx) = tokio::sync::mpsc::unbounded_channel::<NdjsonBodyMessage>();
1226 let body_stream = stream::unfold(body_rx, |mut body_rx| async {
1227 loop {
1228 match body_rx.recv().await? {
1229 NdjsonBodyMessage::Event(bytes) => {
1230 return Some((Ok::<_, std::io::Error>(bytes), body_rx));
1231 }
1232 NdjsonBodyMessage::Flush(done) => {
1233 let _ = done.send(());
1234 }
1235 }
1236 }
1237 });
1238 (body_tx, reqwest::Body::wrap_stream(body_stream))
1239}
1240
1241#[cfg(feature = "atof-streaming")]
1242fn send_ndjson_event(
1243 index: usize,
1244 body_tx: &tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
1245 raw_json: String,
1246) {
1247 if body_tx
1248 .send(NdjsonBodyMessage::Event(
1249 format!("{raw_json}\n").into_bytes(),
1250 ))
1251 .is_err()
1252 {
1253 log::warn!(
1254 target: "nemo_relay.observability",
1255 event = "endpoint_delivery_failed",
1256 exporter = "atof",
1257 endpoint_index = index,
1258 transport = "ndjson",
1259 reason = "body_channel_closed";
1260 "ATOF endpoint delivery failed"
1261 );
1262 }
1263}
1264
1265#[cfg(feature = "atof-streaming")]
1266fn send_ndjson_flush(
1267 index: usize,
1268 body_tx: &tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
1269 done: std_mpsc::Sender<()>,
1270) {
1271 if let Err(error) = body_tx.send(NdjsonBodyMessage::Flush(done)) {
1272 log::warn!(
1273 target: "nemo_relay.observability",
1274 event = "endpoint_flush_failed",
1275 exporter = "atof",
1276 endpoint_index = index,
1277 transport = "ndjson",
1278 reason = "body_channel_closed";
1279 "ATOF endpoint flush failed"
1280 );
1281 error.0.acknowledge_if_flush();
1282 }
1283}
1284
1285#[cfg(feature = "atof-streaming")]
1286async fn finish_ndjson_upload(
1287 index: usize,
1288 request: tokio::task::JoinHandle<reqwest::Result<reqwest::Response>>,
1289 close_timeout: Duration,
1290 done: std_mpsc::Sender<()>,
1291) {
1292 match tokio::time::timeout(close_timeout, request).await {
1293 Ok(Ok(Ok(response))) if response.status().is_success() => log::info!(
1294 target: "nemo_relay.observability",
1295 event = "endpoint_access_validated",
1296 plugin_kind = "observability",
1297 exporter = "atof",
1298 endpoint_index = index,
1299 transport = "ndjson",
1300 permission = "write";
1301 "ATOF endpoint access validated"
1302 ),
1303 Ok(Ok(Ok(response))) => log_http_error(index, "ndjson", response),
1304 Ok(Ok(Err(_))) => log::warn!(
1305 target: "nemo_relay.observability",
1306 event = "endpoint_delivery_failed",
1307 exporter = "atof",
1308 endpoint_index = index,
1309 transport = "ndjson",
1310 reason = "upload_failed";
1311 "ATOF endpoint upload failed"
1312 ),
1313 Ok(Err(_)) => log::error!(
1314 target: "nemo_relay.observability",
1315 event = "endpoint_failed",
1316 exporter = "atof",
1317 endpoint_index = index,
1318 transport = "ndjson",
1319 reason = "task_failed";
1320 "ATOF endpoint task failed"
1321 ),
1322 Err(_) => log::warn!(
1323 target: "nemo_relay.observability",
1324 event = "endpoint_close_failed",
1325 exporter = "atof",
1326 endpoint_index = index,
1327 transport = "ndjson",
1328 reason = "timeout";
1329 "ATOF endpoint close timed out"
1330 ),
1331 }
1332 let _ = done.send(());
1333}
1334
1335#[cfg(feature = "atof-streaming")]
1336async fn drain_closed(mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>) {
1337 while let Some(message) = rx.recv().await {
1338 match message {
1339 EndpointMessage::Flush(done) => {
1340 let _ = done.send(());
1341 }
1342 EndpointMessage::Close(done) => {
1343 let _ = done.send(());
1344 return;
1345 }
1346 EndpointMessage::Event(_) => {}
1347 }
1348 }
1349}
1350
1351#[cfg(feature = "atof-streaming")]
1352fn endpoint_event_json(config: &AtofEndpointConfig, raw_json: String) -> String {
1353 match config.field_name_policy {
1354 AtofEndpointFieldNamePolicy::Preserve => raw_json,
1355 AtofEndpointFieldNamePolicy::ReplaceDots => replace_dotted_field_names(&raw_json),
1356 }
1357}
1358
1359#[cfg(feature = "atof-streaming")]
1360fn replace_dotted_field_names(raw_json: &str) -> String {
1361 let Ok(mut value) = serde_json::from_str::<Json>(raw_json) else {
1362 return raw_json.to_string();
1363 };
1364 replace_dotted_value_keys(&mut value);
1365 serde_json::to_string(&value).unwrap_or_else(|_| raw_json.to_string())
1366}
1367
1368#[cfg(feature = "atof-streaming")]
1369fn replace_dotted_value_keys(value: &mut Json) {
1370 match value {
1371 Json::Object(object) => replace_dotted_object_keys(object),
1372 Json::Array(items) => {
1373 for item in items {
1374 replace_dotted_value_keys(item);
1375 }
1376 }
1377 _ => {}
1378 }
1379}
1380
1381#[cfg(feature = "atof-streaming")]
1382fn replace_dotted_object_keys(object: &mut serde_json::Map<String, Json>) {
1383 let mut old = std::mem::take(object)
1384 .into_iter()
1385 .map(|(key, mut value)| {
1386 replace_dotted_value_keys(&mut value);
1387 (key, value)
1388 })
1389 .collect::<Vec<_>>();
1390 old.sort_by_key(|(key, _)| !key.contains('.'));
1391
1392 for (key, value) in old {
1393 let sanitized_key = key.replace('.', "_");
1394 let final_key = collision_free_key(object, sanitized_key);
1395 object.insert(final_key, value);
1396 }
1397}
1398
1399#[cfg(feature = "atof-streaming")]
1400fn collision_free_key(object: &serde_json::Map<String, Json>, key: String) -> String {
1401 if !object.contains_key(&key) {
1402 return key;
1403 }
1404 for suffix in 2.. {
1405 let candidate = format!("{key}_{suffix}");
1406 if !object.contains_key(&candidate) {
1407 return candidate;
1408 }
1409 }
1410 unreachable!("unbounded suffix search must find a key")
1411}
1412
1413#[cfg(feature = "atof-streaming")]
1414fn log_http_error(index: usize, transport: &str, response: reqwest::Response) {
1415 let status = response.status();
1416 log::warn!(
1417 target: "nemo_relay.observability",
1418 event = "endpoint_delivery_failed",
1419 exporter = "atof",
1420 endpoint_index = index,
1421 transport = transport,
1422 status = status.as_u16();
1423 "ATOF endpoint returned an unsuccessful status"
1424 );
1425}
1426
1427#[cfg(test)]
1432#[path = "../../tests/unit/observability/atof_tests.rs"]
1433mod tests;