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}