Skip to main content

openlineage_client/
cloud.rs

1//! Default HTTP transport backed by [`olai_http::CloudClient`].
2//!
3//! `CloudClient` handles auth (bearer token, Databricks, AWS/GCP credentials,
4//! or unauthenticated) and retry/backoff, so this transport works against
5//! deployed, authenticated OpenLineage endpoints out of the box. It does not add
6//! its own retry loop; it does bound each request with a timeout so one hung
7//! upstream request cannot stall the shared background drain.
8
9use std::time::Duration;
10
11use async_trait::async_trait;
12use olai_http::CloudClient;
13use url::Url;
14
15use crate::config::DEFAULT_REQUEST_TIMEOUT;
16use crate::event::RunEvent;
17use crate::transport::{Transport, TransportError};
18
19/// Posts OpenLineage events to an HTTP endpoint via [`CloudClient`].
20///
21/// Single events go to `endpoint`; batches go to `batch_endpoint` (by default the
22/// single endpoint with `/batch` appended, matching the OpenLineage HTTP API).
23#[derive(Clone)]
24pub struct CloudClientTransport {
25    client: CloudClient,
26    endpoint: Url,
27    batch_endpoint: Url,
28    timeout: Duration,
29}
30
31impl std::fmt::Debug for CloudClientTransport {
32    // `CloudClient` is not `Debug`, so don't try to print it.
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("CloudClientTransport")
35            .field("endpoint", &self.endpoint)
36            .field("batch_endpoint", &self.batch_endpoint)
37            .field("timeout", &self.timeout)
38            .finish_non_exhaustive()
39    }
40}
41
42impl CloudClientTransport {
43    /// Use a pre-built [`CloudClient`] (e.g. one constructed with cloud
44    /// credentials via [`CloudClient::new_aws`] / [`CloudClient::new_databricks`]).
45    ///
46    /// The batch endpoint defaults to `endpoint` with `/batch` appended; the
47    /// request timeout defaults to [`DEFAULT_REQUEST_TIMEOUT`]. Override either
48    /// with [`Self::with_batch_endpoint`] / [`Self::with_timeout`].
49    pub fn new(client: CloudClient, endpoint: Url) -> Self {
50        let batch_endpoint = default_batch_endpoint(&endpoint);
51        Self {
52            client,
53            endpoint,
54            batch_endpoint,
55            timeout: DEFAULT_REQUEST_TIMEOUT,
56        }
57    }
58
59    /// Authenticate with a static bearer token (e.g. `OPENLINEAGE_API_KEY`).
60    pub fn with_token(endpoint: Url, token: impl ToString) -> Self {
61        Self::new(CloudClient::new_with_token(token), endpoint)
62    }
63
64    /// No authentication.
65    pub fn unauthenticated(endpoint: Url) -> Self {
66        Self::new(CloudClient::new_unauthenticated(), endpoint)
67    }
68
69    /// Override the per-request timeout (default [`DEFAULT_REQUEST_TIMEOUT`]).
70    pub fn with_timeout(mut self, timeout: Duration) -> Self {
71        self.timeout = timeout;
72        self
73    }
74
75    /// Override the batch endpoint (default: `endpoint` with `/batch` appended).
76    pub fn with_batch_endpoint(mut self, batch_endpoint: Url) -> Self {
77        self.batch_endpoint = batch_endpoint;
78        self
79    }
80}
81
82/// Append `/batch` to the endpoint's path, falling back to the endpoint itself
83/// if the URL cannot be a base (so a degenerate URL still has a usable target).
84fn default_batch_endpoint(endpoint: &Url) -> Url {
85    let mut batch = endpoint.clone();
86    if let Ok(mut segments) = batch.path_segments_mut() {
87        segments.pop_if_empty().push("batch");
88    }
89    batch
90}
91
92#[async_trait]
93impl Transport for CloudClientTransport {
94    async fn emit(&self, event: &RunEvent) -> Result<(), TransportError> {
95        self.client
96            .post(self.endpoint.clone())
97            .timeout(self.timeout)
98            .json(event)
99            .send()
100            .await
101            .map_err(|e| TransportError::Other(e.to_string()))?;
102        Ok(())
103    }
104
105    async fn emit_batch(&self, events: &[RunEvent]) -> Result<(), TransportError> {
106        // A single event is cheaper to deliver on the single-event endpoint.
107        match events {
108            [] => Ok(()),
109            [event] => self.emit(event).await,
110            events => {
111                self.client
112                    .post(self.batch_endpoint.clone())
113                    .timeout(self.timeout)
114                    .json(events)
115                    .send()
116                    .await
117                    .map_err(|e| TransportError::Other(e.to_string()))?;
118                Ok(())
119            }
120        }
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn batch_endpoint_appends_batch_segment() {
130        let endpoint = Url::parse("http://localhost:8091/api/v1/lineage").unwrap();
131        let batch = default_batch_endpoint(&endpoint);
132        assert_eq!(batch.as_str(), "http://localhost:8091/api/v1/lineage/batch");
133    }
134
135    #[test]
136    fn batch_endpoint_handles_trailing_slash() {
137        let endpoint = Url::parse("http://localhost:8091/api/v1/lineage/").unwrap();
138        let batch = default_batch_endpoint(&endpoint);
139        assert_eq!(batch.as_str(), "http://localhost:8091/api/v1/lineage/batch");
140    }
141
142    fn endpoint() -> Url {
143        Url::parse("http://localhost:8091/api/v1/lineage").unwrap()
144    }
145
146    #[test]
147    fn new_defaults_batch_endpoint_and_timeout() {
148        let t = CloudClientTransport::unauthenticated(endpoint());
149        assert_eq!(t.endpoint.as_str(), "http://localhost:8091/api/v1/lineage");
150        assert_eq!(
151            t.batch_endpoint.as_str(),
152            "http://localhost:8091/api/v1/lineage/batch",
153            "batch endpoint defaults to endpoint + /batch"
154        );
155        assert_eq!(t.timeout, DEFAULT_REQUEST_TIMEOUT);
156    }
157
158    #[test]
159    fn with_token_carries_the_same_endpoint_defaults() {
160        let t = CloudClientTransport::with_token(endpoint(), "secret");
161        assert_eq!(
162            t.batch_endpoint.as_str(),
163            "http://localhost:8091/api/v1/lineage/batch"
164        );
165        assert_eq!(t.timeout, DEFAULT_REQUEST_TIMEOUT);
166    }
167
168    #[test]
169    fn with_timeout_and_with_batch_endpoint_override_defaults() {
170        let custom_batch = Url::parse("http://localhost:8091/bulk").unwrap();
171        let t = CloudClientTransport::unauthenticated(endpoint())
172            .with_timeout(Duration::from_secs(5))
173            .with_batch_endpoint(custom_batch.clone());
174        assert_eq!(t.timeout, Duration::from_secs(5));
175        assert_eq!(t.batch_endpoint, custom_batch);
176        // The single-event endpoint is untouched by the batch override.
177        assert_eq!(t.endpoint, endpoint());
178    }
179
180    #[tokio::test]
181    async fn emit_batch_of_nothing_is_a_no_op() {
182        // The empty slice must short-circuit to Ok without touching the network —
183        // so this passes even though the endpoint isn't listening.
184        let t = CloudClientTransport::unauthenticated(endpoint());
185        assert!(t.emit_batch(&[]).await.is_ok());
186    }
187}