1use std::collections::HashMap;
12use std::fs::{File, OpenOptions};
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(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
20use futures_util::{SinkExt, stream};
21use serde::{Deserialize, Serialize};
22use serde_json::Value as Json;
23#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
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
118impl AtofEndpointTransport {
119 pub fn parse(value: &str) -> Option<Self> {
121 match value {
122 "http_post" => Some(Self::HttpPost),
123 "websocket" => Some(Self::Websocket),
124 "ndjson" => Some(Self::Ndjson),
125 _ => None,
126 }
127 }
128
129 pub fn as_str(self) -> &'static str {
131 match self {
132 Self::HttpPost => "http_post",
133 Self::Websocket => "websocket",
134 Self::Ndjson => "ndjson",
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141pub struct AtofEndpointConfig {
142 pub url: String,
144 #[serde(default)]
146 pub transport: AtofEndpointTransport,
147 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
149 pub headers: HashMap<String, String>,
150 #[serde(default = "default_endpoint_timeout_millis")]
152 pub timeout_millis: u64,
153}
154
155impl AtofEndpointConfig {
156 pub fn new(url: impl Into<String>, transport: AtofEndpointTransport) -> Self {
158 Self {
159 url: url.into(),
160 transport,
161 headers: HashMap::new(),
162 timeout_millis: default_endpoint_timeout_millis(),
163 }
164 }
165
166 pub fn with_header(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
168 self.headers.insert(key.into(), value.into());
169 self
170 }
171
172 pub fn with_timeout_millis(mut self, timeout_millis: u64) -> Self {
174 self.timeout_millis = timeout_millis;
175 self
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181pub struct AtofExporterConfig {
182 #[serde(default = "default_output_directory")]
184 pub output_directory: PathBuf,
185 #[serde(default)]
187 pub mode: AtofExporterMode,
188 #[serde(default = "default_filename")]
190 pub filename: String,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
193 pub endpoints: Vec<AtofEndpointConfig>,
194}
195
196impl Default for AtofExporterConfig {
197 fn default() -> Self {
198 Self {
199 output_directory: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
200 mode: AtofExporterMode::Append,
201 filename: default_filename(),
202 endpoints: Vec::new(),
203 }
204 }
205}
206
207impl AtofExporterConfig {
208 pub fn new() -> Self {
210 Self::default()
211 }
212
213 pub fn with_output_directory(mut self, output_directory: impl Into<PathBuf>) -> Self {
215 self.output_directory = output_directory.into();
216 self
217 }
218
219 pub fn with_mode(mut self, mode: AtofExporterMode) -> Self {
221 self.mode = mode;
222 self
223 }
224
225 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
227 self.filename = filename.into();
228 self
229 }
230
231 pub fn with_endpoints(mut self, endpoints: Vec<AtofEndpointConfig>) -> Self {
233 self.endpoints = endpoints;
234 self
235 }
236
237 pub fn with_endpoint(mut self, endpoint: AtofEndpointConfig) -> Self {
239 self.endpoints.push(endpoint);
240 self
241 }
242
243 pub fn path(&self) -> PathBuf {
245 self.output_directory.join(&self.filename)
246 }
247}
248
249struct AtofExporterState {
250 writer: BufWriter<File>,
251 last_error: Option<String>,
252 endpoints: Vec<AtofEndpointWorker>,
253 closed: bool,
254}
255
256pub struct AtofExporter {
258 path: PathBuf,
259 state: Arc<Mutex<AtofExporterState>>,
260}
261
262impl AtofExporter {
263 pub fn new(config: AtofExporterConfig) -> Result<Self> {
265 let path = config.path();
266 let file = open_file(&path, config.mode)?;
267 let endpoints = start_endpoint_workers(&config.endpoints)?;
268 Ok(Self {
269 path,
270 state: Arc::new(Mutex::new(AtofExporterState {
271 writer: BufWriter::new(file),
272 last_error: None,
273 endpoints,
274 closed: false,
275 })),
276 })
277 }
278
279 pub fn path(&self) -> &Path {
281 self.path.as_path()
282 }
283
284 pub fn subscriber(&self) -> EventSubscriberFn {
286 let state = Arc::clone(&self.state);
287 Arc::new(move |event: &Event| {
288 let Ok(mut state) = state.lock() else {
289 return;
290 };
291 if state.closed || state.last_error.is_some() {
292 return;
293 }
294 let Ok(value) = event.try_to_json_value() else {
295 state.last_error = Some("failed to serialize ATOF event".to_string());
296 return;
297 };
298 if let Err(error) = write_json_value(&mut state.writer, &value) {
299 state.last_error = Some(error);
300 return;
301 }
302 let Ok(raw_json) = serde_json::to_string(&value) else {
303 state.last_error = Some("failed to serialize ATOF event".to_string());
304 return;
305 };
306 for endpoint in &state.endpoints {
307 endpoint.enqueue(raw_json.clone());
308 }
309 })
310 }
311
312 pub fn register(&self, name: &str) -> Result<()> {
314 register_subscriber(name, self.subscriber()).map_err(Into::into)
315 }
316
317 pub fn deregister(&self, name: &str) -> Result<bool> {
319 deregister_subscriber(name).map_err(Into::into)
320 }
321
322 pub fn force_flush(&self) -> Result<()> {
324 flush_subscribers()?;
325 let mut state = self
326 .state
327 .lock()
328 .map_err(|_| AtofExporterError::LockPoisoned)?;
329 if state.closed {
330 return stored_failure_result(&self.path, &state);
331 }
332 state
333 .writer
334 .flush()
335 .map_err(|source| AtofExporterError::Flush {
336 path: self.path.clone(),
337 source,
338 })?;
339 for endpoint in &state.endpoints {
340 endpoint.flush();
341 }
342 stored_failure_result(&self.path, &state)
343 }
344
345 pub fn shutdown(&self) -> Result<()> {
347 flush_subscribers()?;
348 let mut state = self
349 .state
350 .lock()
351 .map_err(|_| AtofExporterError::LockPoisoned)?;
352 if state.closed {
353 return stored_failure_result(&self.path, &state);
354 }
355 state.closed = true;
356 let flush_result = state
357 .writer
358 .flush()
359 .map_err(|source| AtofExporterError::Flush {
360 path: self.path.clone(),
361 source,
362 });
363 for endpoint in &state.endpoints {
364 endpoint.close();
365 }
366 flush_result?;
367 stored_failure_result(&self.path, &state)
368 }
369}
370
371fn default_filename() -> String {
372 format!(
373 "nemo-relay-events-{}.jsonl",
374 Utc::now().format("%Y-%m-%d-%H.%M.%S")
375 )
376}
377
378fn default_output_directory() -> PathBuf {
379 std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
380}
381
382fn default_endpoint_timeout_millis() -> u64 {
383 3_000
384}
385
386fn open_file(path: &Path, mode: AtofExporterMode) -> Result<File> {
387 let mut options = OpenOptions::new();
388 options.create(true);
389 match mode {
390 AtofExporterMode::Append => {
391 options.append(true);
392 }
393 AtofExporterMode::Overwrite => {
394 options.write(true).truncate(true);
395 }
396 }
397 options
398 .open(path)
399 .map_err(|source| AtofExporterError::OpenFile {
400 path: path.to_path_buf(),
401 source,
402 })
403}
404
405fn write_json_value(writer: &mut BufWriter<File>, value: &Json) -> std::result::Result<(), String> {
406 serde_json::to_writer(&mut *writer, value).map_err(|error| error.to_string())?;
407 writer.write_all(b"\n").map_err(|error| error.to_string())?;
408 writer.flush().map_err(|error| error.to_string())
409}
410
411fn stored_failure_result(path: &Path, state: &AtofExporterState) -> Result<()> {
412 if let Some(message) = &state.last_error {
413 return Err(AtofExporterError::StoredFailure {
414 path: path.to_path_buf(),
415 message: message.clone(),
416 });
417 }
418 Ok(())
419}
420
421#[cfg_attr(
422 any(not(feature = "atof-streaming"), target_arch = "wasm32"),
423 allow(dead_code)
424)]
425enum EndpointMessage {
426 Event(String),
427 Flush(std_mpsc::Sender<()>),
428 Close(std_mpsc::Sender<()>),
429}
430
431#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
432enum NdjsonBodyMessage {
433 Event(Vec<u8>),
434 Flush(std_mpsc::Sender<()>),
435}
436
437#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
438impl NdjsonBodyMessage {
439 fn acknowledge_if_flush(self) {
440 if let Self::Flush(done) = self {
441 let _ = done.send(());
442 }
443 }
444}
445
446struct AtofEndpointWorker {
447 sender: tokio::sync::mpsc::UnboundedSender<EndpointMessage>,
448 timeout: Duration,
449}
450
451impl AtofEndpointWorker {
452 fn enqueue(&self, raw_json: String) {
453 let _ = self.sender.send(EndpointMessage::Event(raw_json));
454 }
455
456 fn flush(&self) {
457 let (tx, rx) = std_mpsc::channel();
458 if self.sender.send(EndpointMessage::Flush(tx)).is_ok()
459 && rx.recv_timeout(self.timeout).is_err()
460 {
461 eprintln!("nemo_relay: timed out flushing ATOF endpoint");
462 }
463 }
464
465 fn close(&self) {
466 let (tx, rx) = std_mpsc::channel();
467 if self.sender.send(EndpointMessage::Close(tx)).is_ok()
468 && rx.recv_timeout(self.timeout).is_err()
469 {
470 eprintln!("nemo_relay: timed out closing ATOF endpoint");
471 }
472 }
473}
474
475#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
476fn start_endpoint_workers(configs: &[AtofEndpointConfig]) -> Result<Vec<AtofEndpointWorker>> {
477 let mut workers = Vec::with_capacity(configs.len());
478 for (index, config) in configs.iter().enumerate() {
479 match start_endpoint_worker(index, config.clone()) {
480 Ok(worker) => workers.push(worker),
481 Err(error) => {
482 eprintln!("nemo_relay: invalid ATOF endpoint[{index}]: {error}");
483 return Err(AtofExporterError::InvalidEndpoint(format!(
484 "endpoints[{index}]: {error}"
485 )));
486 }
487 }
488 }
489 Ok(workers)
490}
491
492#[cfg(any(not(feature = "atof-streaming"), target_arch = "wasm32"))]
493fn start_endpoint_workers(configs: &[AtofEndpointConfig]) -> Result<Vec<AtofEndpointWorker>> {
494 if configs.is_empty() {
495 return Ok(Vec::new());
496 }
497 let message = "ATOF streaming endpoints are not supported in this build".to_string();
498 eprintln!("nemo_relay: {message}");
499 Err(AtofExporterError::InvalidEndpoint(message))
500}
501
502#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
503fn start_endpoint_worker(index: usize, config: AtofEndpointConfig) -> Result<AtofEndpointWorker> {
504 validate_endpoint_config(&config)?;
505 let timeout = Duration::from_millis(config.timeout_millis);
506 let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
507 std::thread::Builder::new()
508 .name(format!("nemo-relay-atof-endpoint-{index}"))
509 .spawn(move || run_endpoint_worker(index, config, rx))
510 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
511 Ok(AtofEndpointWorker {
512 sender: tx,
513 timeout,
514 })
515}
516
517#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
518fn validate_endpoint_config(config: &AtofEndpointConfig) -> Result<()> {
519 if config.url.trim().is_empty() {
520 return Err(AtofExporterError::InvalidEndpoint(
521 "endpoint url must be non-empty".to_string(),
522 ));
523 }
524 if config.timeout_millis == 0 {
525 return Err(AtofExporterError::InvalidEndpoint(
526 "endpoint timeout_millis must be greater than 0".to_string(),
527 ));
528 }
529 let url = reqwest::Url::parse(&config.url)
530 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
531 let valid_scheme = match config.transport {
532 AtofEndpointTransport::HttpPost | AtofEndpointTransport::Ndjson => {
533 matches!(url.scheme(), "http" | "https")
534 }
535 AtofEndpointTransport::Websocket => matches!(url.scheme(), "ws" | "wss"),
536 };
537 if !valid_scheme {
538 return Err(AtofExporterError::InvalidEndpoint(format!(
539 "endpoint {} transport does not support URL scheme {:?}",
540 config.transport.as_str(),
541 url.scheme()
542 )));
543 }
544 build_header_map(&config.headers)?;
545 Ok(())
546}
547
548#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
549fn build_header_map(headers: &HashMap<String, String>) -> Result<reqwest::header::HeaderMap> {
550 let mut out = reqwest::header::HeaderMap::new();
551 for (key, value) in headers {
552 let name = reqwest::header::HeaderName::from_bytes(key.as_bytes())
553 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
554 let value = reqwest::header::HeaderValue::from_str(value)
555 .map_err(|error| AtofExporterError::InvalidEndpoint(error.to_string()))?;
556 out.insert(name, value);
557 }
558 Ok(out)
559}
560
561#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
562fn run_endpoint_worker(
563 index: usize,
564 config: AtofEndpointConfig,
565 rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
566) {
567 let runtime = match tokio::runtime::Builder::new_current_thread()
568 .enable_all()
569 .build()
570 {
571 Ok(runtime) => runtime,
572 Err(error) => {
573 eprintln!("nemo_relay: ATOF endpoint[{index}] runtime failed: {error}");
574 return;
575 }
576 };
577 runtime.block_on(async move {
578 match config.transport {
579 AtofEndpointTransport::HttpPost => run_http_post_endpoint(index, config, rx).await,
580 AtofEndpointTransport::Websocket => run_websocket_endpoint(index, config, rx).await,
581 AtofEndpointTransport::Ndjson => run_ndjson_endpoint(index, config, rx).await,
582 }
583 });
584}
585
586#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
587async fn run_http_post_endpoint(
588 index: usize,
589 config: AtofEndpointConfig,
590 mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
591) {
592 let client = match reqwest::Client::builder()
593 .timeout(Duration::from_millis(config.timeout_millis))
594 .default_headers(match build_header_map(&config.headers) {
595 Ok(headers) => headers,
596 Err(error) => {
597 eprintln!("nemo_relay: ATOF endpoint[{index}] disabled: {error}");
598 drain_closed(rx).await;
599 return;
600 }
601 })
602 .build()
603 {
604 Ok(client) => client,
605 Err(error) => {
606 eprintln!("nemo_relay: ATOF endpoint[{index}] client build failed: {error}");
607 drain_closed(rx).await;
608 return;
609 }
610 };
611 while let Some(message) = rx.recv().await {
612 match message {
613 EndpointMessage::Event(raw_json) => {
614 let body = format!("{raw_json}\n");
615 let result = client
616 .post(&config.url)
617 .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
618 .body(body)
619 .send()
620 .await;
621 match result {
622 Ok(response) if response.status().is_success() => {}
623 Ok(response) => eprintln!(
624 "nemo_relay: ATOF endpoint[{index}] HTTP status {}",
625 response.status()
626 ),
627 Err(error) => {
628 eprintln!("nemo_relay: ATOF endpoint[{index}] send failed: {error}")
629 }
630 }
631 }
632 EndpointMessage::Flush(done) => {
633 let _ = done.send(());
634 }
635 EndpointMessage::Close(done) => {
636 let _ = done.send(());
637 return;
638 }
639 }
640 }
641}
642
643#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
644async fn run_websocket_endpoint(
645 index: usize,
646 config: AtofEndpointConfig,
647 mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
648) {
649 let mut pending = std::collections::VecDeque::new();
650 let mut socket = match connect_websocket(&config).await {
651 Ok(socket) => Some(socket),
652 Err(error) => {
653 eprintln!("nemo_relay: ATOF endpoint[{index}] websocket startup failed: {error}");
654 None
655 }
656 };
657 while let Some(message) = rx.recv().await {
658 match message {
659 EndpointMessage::Event(raw_json) => {
660 pending.push_back(raw_json);
661 let _ = drain_websocket_pending(index, &config, &mut socket, &mut pending).await;
662 }
663 EndpointMessage::Flush(done) => {
664 let _ = drain_websocket_pending(index, &config, &mut socket, &mut pending).await;
665 let _ = done.send(());
666 }
667 EndpointMessage::Close(done) => {
668 let _ = drain_websocket_pending(index, &config, &mut socket, &mut pending).await;
669 if let Some(mut ws) = socket.take() {
670 let _ = ws.close(None).await;
671 }
672 let _ = done.send(());
673 return;
674 }
675 }
676 }
677}
678
679#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
680type AtofWebSocket =
681 tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
682
683#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
684async fn drain_websocket_pending(
685 index: usize,
686 config: &AtofEndpointConfig,
687 socket: &mut Option<AtofWebSocket>,
688 pending: &mut std::collections::VecDeque<String>,
689) -> bool {
690 let timeout = Duration::from_millis(config.timeout_millis);
691 match tokio::time::timeout(
692 timeout,
693 drain_websocket_pending_inner(index, config, socket, pending),
694 )
695 .await
696 {
697 Ok(drained) => drained,
698 Err(_) => {
699 eprintln!("nemo_relay: ATOF endpoint[{index}] websocket drain timed out");
700 false
701 }
702 }
703}
704
705#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
706async fn drain_websocket_pending_inner(
707 index: usize,
708 config: &AtofEndpointConfig,
709 socket: &mut Option<AtofWebSocket>,
710 pending: &mut std::collections::VecDeque<String>,
711) -> bool {
712 while let Some(raw_json) = pending.front().cloned() {
713 if socket.is_none() {
714 match connect_websocket(config).await {
715 Ok(ws) => *socket = Some(ws),
716 Err(error) => {
717 eprintln!(
718 "nemo_relay: ATOF endpoint[{index}] websocket reconnect failed: {error}"
719 );
720 tokio::time::sleep(Duration::from_millis(50)).await;
721 continue;
722 }
723 }
724 }
725
726 let Some(ws) = socket.as_mut() else {
727 continue;
728 };
729 match ws
730 .send(tokio_tungstenite::tungstenite::Message::Text(
731 raw_json.into(),
732 ))
733 .await
734 {
735 Ok(()) => {
736 pending.pop_front();
737 }
738 Err(error) => {
739 eprintln!("nemo_relay: ATOF endpoint[{index}] websocket send failed: {error}");
740 *socket = None;
741 tokio::time::sleep(Duration::from_millis(50)).await;
742 }
743 }
744 }
745 true
746}
747
748#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
749async fn connect_websocket(
750 config: &AtofEndpointConfig,
751) -> std::result::Result<AtofWebSocket, String> {
752 let mut request = config
753 .url
754 .as_str()
755 .into_client_request()
756 .map_err(|error| error.to_string())?;
757 for (key, value) in &config.headers {
758 let name =
759 tokio_tungstenite::tungstenite::http::header::HeaderName::from_bytes(key.as_bytes())
760 .map_err(|error| error.to_string())?;
761 let value = tokio_tungstenite::tungstenite::http::header::HeaderValue::from_str(value)
762 .map_err(|error| error.to_string())?;
763 request.headers_mut().insert(name, value);
764 }
765 tokio::time::timeout(
766 Duration::from_millis(config.timeout_millis),
767 tokio_tungstenite::connect_async(request),
768 )
769 .await
770 .map_err(|error| error.to_string())?
771 .map(|(socket, _)| socket)
772 .map_err(|error| error.to_string())
773}
774
775#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
776async fn run_ndjson_endpoint(
777 index: usize,
778 config: AtofEndpointConfig,
779 mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>,
780) {
781 let client = match build_ndjson_client(&config) {
782 Ok(client) => client,
783 Err(error) => {
784 eprintln!("nemo_relay: ATOF endpoint[{index}] {error}");
785 drain_closed(rx).await;
786 return;
787 }
788 };
789
790 let (body_tx, body) = ndjson_body_channel();
791 let request = tokio::spawn(async move {
792 client
793 .post(config.url)
794 .header(reqwest::header::CONTENT_TYPE, "application/x-ndjson")
795 .body(body)
796 .send()
797 .await
798 });
799 let close_timeout = Duration::from_millis(config.timeout_millis);
800
801 while let Some(message) = rx.recv().await {
802 match message {
803 EndpointMessage::Event(raw_json) => send_ndjson_event(index, &body_tx, raw_json),
804 EndpointMessage::Flush(done) => send_ndjson_flush(index, &body_tx, done),
805 EndpointMessage::Close(done) => {
806 drop(body_tx);
807 finish_ndjson_upload(index, request, close_timeout, done).await;
808 return;
809 }
810 }
811 }
812}
813
814#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
815fn build_ndjson_client(
816 config: &AtofEndpointConfig,
817) -> std::result::Result<reqwest::Client, String> {
818 let headers =
819 build_header_map(&config.headers).map_err(|error| format!("disabled: {error}"))?;
820 reqwest::Client::builder()
821 .connect_timeout(Duration::from_millis(config.timeout_millis))
822 .default_headers(headers)
823 .build()
824 .map_err(|error| format!("client build failed: {error}"))
825}
826
827#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
828fn ndjson_body_channel() -> (
829 tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
830 reqwest::Body,
831) {
832 let (body_tx, body_rx) = tokio::sync::mpsc::unbounded_channel::<NdjsonBodyMessage>();
833 let body_stream = stream::unfold(body_rx, |mut body_rx| async {
834 loop {
835 match body_rx.recv().await? {
836 NdjsonBodyMessage::Event(bytes) => {
837 return Some((Ok::<_, std::io::Error>(bytes), body_rx));
838 }
839 NdjsonBodyMessage::Flush(done) => {
840 let _ = done.send(());
841 }
842 }
843 }
844 });
845 (body_tx, reqwest::Body::wrap_stream(body_stream))
846}
847
848#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
849fn send_ndjson_event(
850 index: usize,
851 body_tx: &tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
852 raw_json: String,
853) {
854 if let Err(error) = body_tx.send(NdjsonBodyMessage::Event(
855 format!("{raw_json}\n").into_bytes(),
856 )) {
857 eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON send failed: {error}");
858 }
859}
860
861#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
862fn send_ndjson_flush(
863 index: usize,
864 body_tx: &tokio::sync::mpsc::UnboundedSender<NdjsonBodyMessage>,
865 done: std_mpsc::Sender<()>,
866) {
867 if let Err(error) = body_tx.send(NdjsonBodyMessage::Flush(done)) {
868 eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON flush failed: {error}");
869 error.0.acknowledge_if_flush();
870 }
871}
872
873#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
874async fn finish_ndjson_upload(
875 index: usize,
876 request: tokio::task::JoinHandle<reqwest::Result<reqwest::Response>>,
877 close_timeout: Duration,
878 done: std_mpsc::Sender<()>,
879) {
880 match tokio::time::timeout(close_timeout, request).await {
881 Ok(Ok(Ok(response))) if response.status().is_success() => {}
882 Ok(Ok(Ok(response))) => eprintln!(
883 "nemo_relay: ATOF endpoint[{index}] NDJSON HTTP status {}",
884 response.status()
885 ),
886 Ok(Ok(Err(error))) => {
887 eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON upload failed: {error}")
888 }
889 Ok(Err(error)) => {
890 eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON task failed: {error}")
891 }
892 Err(_) => eprintln!("nemo_relay: ATOF endpoint[{index}] NDJSON close timed out"),
893 }
894 let _ = done.send(());
895}
896
897#[cfg(all(feature = "atof-streaming", not(target_arch = "wasm32")))]
898async fn drain_closed(mut rx: tokio::sync::mpsc::UnboundedReceiver<EndpointMessage>) {
899 while let Some(message) = rx.recv().await {
900 match message {
901 EndpointMessage::Flush(done) => {
902 let _ = done.send(());
903 }
904 EndpointMessage::Close(done) => {
905 let _ = done.send(());
906 return;
907 }
908 EndpointMessage::Event(_) => {}
909 }
910 }
911}
912
913#[cfg(test)]
918#[path = "../../tests/unit/observability/atof_tests.rs"]
919mod tests;