Skip to main content

openlineage_client/
client.rs

1//! The OpenLineage client: a non-blocking emit front-end over a [`Transport`].
2//!
3//! Emission must never break or slow the host query. [`OpenLineageClient::emit`]
4//! is non-blocking: it hands the event to a bounded channel drained by a
5//! background task that delivers events to the transport (coalescing queued
6//! events into batches when the upstream is slow) and swallows + logs any error.
7//! If the channel is full the event is dropped with a warning (back-pressure must
8//! not stall planning). [`OpenLineageClient::shutdown`] drains the queue and
9//! flushes the transport before exit.
10
11use std::sync::Arc;
12use std::sync::Mutex;
13use std::sync::atomic::{AtomicU64, Ordering};
14
15use tokio::runtime::Handle;
16use tokio::sync::mpsc;
17use tokio::task::JoinHandle;
18
19use crate::event::RunEvent;
20use crate::transport::{NoopTransport, Transport};
21
22/// Default bound on the in-flight event queue.
23const DEFAULT_QUEUE_SIZE: usize = 1024;
24
25/// Cap on events coalesced into a single `emit_batch` by the drain task. Bounds
26/// the per-delivery payload size while still amortizing a slow upstream.
27const MAX_BATCH: usize = 256;
28
29/// Error returned when an [`OpenLineageClient`] cannot be constructed.
30#[derive(Debug, thiserror::Error)]
31pub enum ClientError {
32    /// The client could not be built from the given configuration or
33    /// environment — for example, no Tokio runtime was available to host the
34    /// drain task, or an endpoint URL was malformed.
35    #[error("invalid OpenLineage configuration: {0}")]
36    Config(String),
37}
38
39/// Non-blocking front-end for emitting OpenLineage events.
40///
41/// Must be constructed from within a Tokio runtime (it spawns a background
42/// drain task); see [`OpenLineageClient::try_new`] for the non-panicking form.
43#[derive(Debug, Clone)]
44pub struct OpenLineageClient {
45    tx: mpsc::Sender<RunEvent>,
46    /// Events dropped on a full queue (back-pressure) or by transport failure.
47    /// Shared across clones so the count is process-global.
48    dropped: Arc<AtomicU64>,
49    /// The background drain task, shared across clones. [`Self::shutdown`] takes
50    /// it to await a final flush of queued events before process exit.
51    drain: Arc<Mutex<Option<JoinHandle<()>>>>,
52    /// A handle to the transport, kept so [`Self::shutdown`] can flush it after
53    /// the drain queue empties (the drain task owns its own clone).
54    transport: Arc<dyn Transport>,
55}
56
57impl OpenLineageClient {
58    /// Start a client that drains events into `transport` on a background task.
59    ///
60    /// # Panics
61    /// Panics if called outside a Tokio runtime. Use [`Self::try_new`] to get a
62    /// clear error instead.
63    pub fn new(transport: Arc<dyn Transport>) -> Self {
64        Self::with_queue_size(transport, DEFAULT_QUEUE_SIZE)
65    }
66
67    /// Fallible [`Self::new`]: returns [`ClientError::Config`] instead of
68    /// panicking when no Tokio runtime is available to host the drain task.
69    pub fn try_new(transport: Arc<dyn Transport>) -> Result<Self, ClientError> {
70        Self::try_with_queue_size(transport, DEFAULT_QUEUE_SIZE)
71    }
72
73    /// [`Self::new`] with an explicit in-flight queue bound.
74    ///
75    /// # Panics
76    /// Panics if called outside a Tokio runtime; see [`Self::try_with_queue_size`].
77    pub fn with_queue_size(transport: Arc<dyn Transport>, queue_size: usize) -> Self {
78        Self::try_with_queue_size(transport, queue_size)
79            .expect("OpenLineageClient must be constructed within a Tokio runtime")
80    }
81
82    /// Fallible [`Self::with_queue_size`]: returns [`ClientError::Config`]
83    /// instead of panicking when no Tokio runtime is available.
84    ///
85    /// # Errors
86    /// Returns [`ClientError::Config`] if called outside a Tokio runtime.
87    pub fn try_with_queue_size(
88        transport: Arc<dyn Transport>,
89        queue_size: usize,
90    ) -> Result<Self, ClientError> {
91        let handle = Handle::try_current().map_err(|_| {
92            ClientError::Config(
93                "OpenLineageClient must be constructed within a Tokio runtime".to_string(),
94            )
95        })?;
96        let (tx, mut rx) = mpsc::channel::<RunEvent>(queue_size);
97        let dropped = Arc::new(AtomicU64::new(0));
98        let drain_dropped = dropped.clone();
99        // The drain task owns one clone of the transport; `shutdown` keeps another
100        // so it can flush after the queue empties.
101        let drain_transport = transport.clone();
102        let drain = handle.spawn(async move {
103            // Drain-coalescing: block for the first event, then opportunistically
104            // pull whatever else is already queued (up to MAX_BATCH) and deliver
105            // it in one `emit_batch`. Under light load this is one event per call;
106            // when the upstream is slow and the queue backs up, it coalesces into
107            // batch deliveries — the cheapest throughput win exactly when needed.
108            let mut batch: Vec<RunEvent> = Vec::new();
109            while let Some(first) = rx.recv().await {
110                batch.clear();
111                batch.push(first);
112                while batch.len() < MAX_BATCH {
113                    match rx.try_recv() {
114                        Ok(event) => batch.push(event),
115                        Err(_) => break,
116                    }
117                }
118                if let Err(err) = drain_transport.emit_batch(&batch).await {
119                    let n = drain_dropped.fetch_add(batch.len() as u64, Ordering::Relaxed)
120                        + batch.len() as u64;
121                    tracing::warn!(
122                        target: "openlineage",
123                        error = %err,
124                        batch = batch.len(),
125                        dropped_total = n,
126                        "failed to emit lineage events; dropping batch"
127                    );
128                }
129            }
130        });
131        Ok(Self {
132            tx,
133            dropped,
134            drain: Arc::new(Mutex::new(Some(drain))),
135            transport,
136        })
137    }
138
139    /// Returns a builder for configuring a client's transport and queue size.
140    pub fn builder() -> OpenLineageClientBuilder {
141        OpenLineageClientBuilder::default()
142    }
143
144    /// A client whose transport drops everything ([`NoopTransport`]).
145    pub fn noop() -> Self {
146        Self::new(Arc::new(NoopTransport))
147    }
148
149    /// Construct from the standard OpenLineage environment.
150    ///
151    /// If `OPENLINEAGE_URL` is set, builds an HTTP transport (requires the
152    /// `http` feature); otherwise returns a no-op client. `OPENLINEAGE_API_KEY`,
153    /// if present, is sent as a bearer token.
154    pub fn from_env() -> Result<Self, ClientError> {
155        match std::env::var("OPENLINEAGE_URL") {
156            Ok(url) if !url.is_empty() => Self::http_from_env(&url),
157            _ => Ok(Self::noop()),
158        }
159    }
160
161    #[cfg(feature = "http")]
162    fn http_from_env(url: &str) -> Result<Self, ClientError> {
163        use crate::cloud::CloudClientTransport;
164
165        let endpoint =
166            std::env::var("OPENLINEAGE_ENDPOINT").unwrap_or_else(|_| "/api/v1/lineage".to_string());
167        let full = url.trim_end_matches('/').to_string() + &endpoint;
168        let endpoint_url = url::Url::parse(&full)
169            .map_err(|e| ClientError::Config(format!("invalid OPENLINEAGE_URL/ENDPOINT: {e}")))?;
170
171        // Honor OPENLINEAGE_TIMEOUT_MS for the per-request transport timeout.
172        let timeout = crate::config::OpenLineageConfig::from_env().request_timeout;
173        let cloud = match std::env::var("OPENLINEAGE_API_KEY") {
174            Ok(token) if !token.is_empty() => CloudClientTransport::with_token(endpoint_url, token),
175            _ => CloudClientTransport::unauthenticated(endpoint_url),
176        }
177        .with_timeout(timeout);
178        Ok(Self::new(Arc::new(cloud)))
179    }
180
181    #[cfg(not(feature = "http"))]
182    fn http_from_env(_url: &str) -> Result<Self, ClientError> {
183        Err(ClientError::Config(
184            "OPENLINEAGE_URL is set but the `http` feature is disabled".to_string(),
185        ))
186    }
187
188    /// Emit an event without blocking. On a full queue the event is dropped
189    /// with a warning — lineage never applies back-pressure to the query.
190    pub fn emit(&self, event: RunEvent) {
191        if let Err(err) = self.tx.try_send(event) {
192            let n = self.dropped.fetch_add(1, Ordering::Relaxed) + 1;
193            tracing::warn!(
194                target: "openlineage",
195                error = %err,
196                dropped_total = n,
197                "lineage queue full or closed; dropping event"
198            );
199        }
200    }
201
202    /// Total events dropped so far — on a full/closed queue (back-pressure) or
203    /// by transport failure. Process-global (shared across clones).
204    pub fn dropped_count(&self) -> u64 {
205        self.dropped.load(Ordering::Relaxed)
206    }
207
208    /// Flush queued events, flush the transport, and stop the background drain
209    /// task.
210    ///
211    /// Awaits the drain task to completion so events still queued at process exit
212    /// are delivered rather than lost, then calls [`Transport::flush`] so a
213    /// transport that buffers internally (e.g. a Kafka producer) delivers its
214    /// tail before exit. The drain task ends once the event channel closes, which
215    /// requires every sender to be dropped — so call this after (or while)
216    /// dropping all other clones of the client; this consumes the clone it is
217    /// called on. Idempotent across clones: only the clone holding the drain
218    /// handle awaits it, the rest return immediately.
219    pub async fn shutdown(self) {
220        // Drop our sender so this clone no longer keeps the channel open.
221        let Self {
222            tx,
223            drain,
224            transport,
225            ..
226        } = self;
227        drop(tx);
228        let handle = drain.lock().unwrap().take();
229        if let Some(handle) = handle {
230            let _ = handle.await;
231        }
232        if let Err(err) = transport.flush().await {
233            tracing::warn!(
234                target: "openlineage",
235                error = %err,
236                "transport flush failed during shutdown",
237            );
238        }
239    }
240}
241
242/// Builder for [`OpenLineageClient`].
243///
244/// Defaults to a [`NoopTransport`] and the default queue size if left unset.
245#[derive(Default)]
246pub struct OpenLineageClientBuilder {
247    transport: Option<Arc<dyn Transport>>,
248    queue_size: Option<usize>,
249}
250
251impl OpenLineageClientBuilder {
252    /// Sets the transport events are drained into.
253    pub fn transport(mut self, transport: Arc<dyn Transport>) -> Self {
254        self.transport = Some(transport);
255        self
256    }
257
258    /// Sets the bound on the in-flight event queue.
259    pub fn queue_size(mut self, queue_size: usize) -> Self {
260        self.queue_size = Some(queue_size);
261        self
262    }
263
264    /// Builds the client, spawning its background drain task.
265    ///
266    /// # Panics
267    /// Panics if called outside a Tokio runtime.
268    pub fn build(self) -> OpenLineageClient {
269        let transport = self.transport.unwrap_or_else(|| Arc::new(NoopTransport));
270        OpenLineageClient::with_queue_size(transport, self.queue_size.unwrap_or(DEFAULT_QUEUE_SIZE))
271    }
272}