datafusion_openlineage/session.rs
1//! The customer-facing entry point: [`OpenLineage::builder`].
2//!
3//! One builder assembles the three pieces an integration needs — a
4//! [`Transport`]/[`OpenLineageClient`], a [`LineageContextProvider`], and an
5//! [`OpenLineageConfig`] — and installs them on a `SessionState`. The common
6//! case is [`OpenLineageBuilder::from_env`] followed by
7//! [`OpenLineageBuilder::instrument`].
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use datafusion::common::Result as DataFusionResult;
13use datafusion::dataframe::DataFrame;
14use datafusion::execution::SessionStateBuilder;
15use datafusion::execution::context::{SessionContext, SessionState};
16use datafusion::logical_expr::{DdlStatement, LogicalPlan};
17
18use openlineage_client::{ClientError, LineageContext, OpenLineageClient, Transport};
19
20use crate::config::{DataFusionConfig, OpenLineageConfig};
21use crate::context::{LineageContextProvider, StaticContextProvider};
22use crate::rule::OpenLineageQueryPlanner;
23
24/// Entry point for instrumenting a DataFusion session with OpenLineage.
25///
26/// Construct a [`OpenLineageBuilder`] with [`OpenLineage::builder`].
27#[derive(Debug)]
28pub struct OpenLineage;
29
30impl OpenLineage {
31 /// Start configuring OpenLineage instrumentation.
32 pub fn builder() -> OpenLineageBuilder {
33 OpenLineageBuilder::default()
34 }
35}
36
37/// Builds and installs OpenLineage instrumentation on a [`SessionState`].
38///
39/// Set a transport (or client) and, optionally, a context provider and config;
40/// then call [`Self::instrument`]. [`Self::from_env`] is the one-call path that
41/// derives everything from the standard OpenLineage environment.
42#[derive(Default)]
43pub struct OpenLineageBuilder {
44 client: Option<OpenLineageClient>,
45 transport: Option<Arc<dyn Transport>>,
46 context: Option<Arc<dyn LineageContextProvider>>,
47 config: Option<OpenLineageConfig>,
48}
49
50impl std::fmt::Debug for OpenLineageBuilder {
51 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52 f.debug_struct("OpenLineageBuilder")
53 .field("has_client", &self.client.is_some())
54 .field("has_transport", &self.transport.is_some())
55 .field("has_context", &self.context.is_some())
56 .field("config", &self.config)
57 .finish()
58 }
59}
60
61impl OpenLineageBuilder {
62 /// Derive client, context, and config from the standard OpenLineage
63 /// environment in one call.
64 ///
65 /// Builds the client via [`OpenLineageClient::from_env`] (reads
66 /// `OPENLINEAGE_URL` / `OPENLINEAGE_ENDPOINT` / `OPENLINEAGE_API_KEY`), the
67 /// config via [`OpenLineageConfig::for_datafusion_from_env`] (reads
68 /// `OPENLINEAGE_NAMESPACE` / `OPENLINEAGE_TIMEOUT_MS`), and the context via
69 /// [`LineageContext::from_env`] (reads the parent-run variables). Anything
70 /// set explicitly on the builder beforehand is preserved.
71 ///
72 /// # Errors
73 /// Returns [`ClientError`] if the client cannot be built — e.g. called
74 /// outside a Tokio runtime, or `OPENLINEAGE_URL` is malformed.
75 pub fn from_env(mut self) -> Result<Self, ClientError> {
76 let config = self
77 .config
78 .take()
79 .unwrap_or_else(OpenLineageConfig::for_datafusion_from_env);
80 if self.client.is_none() && self.transport.is_none() {
81 self.client = Some(OpenLineageClient::from_env()?);
82 }
83 if self.context.is_none() {
84 let ctx = LineageContext::from_env(&config);
85 self.context = Some(Arc::new(StaticContextProvider(ctx)));
86 }
87 self.config = Some(config);
88 Ok(self)
89 }
90
91 /// Use a pre-built [`OpenLineageClient`]. Takes precedence over
92 /// [`Self::transport`].
93 pub fn client(mut self, client: OpenLineageClient) -> Self {
94 self.client = Some(client);
95 self
96 }
97
98 /// Drain events into `transport`. A client is constructed around it at
99 /// [`Self::instrument`] time. Ignored if [`Self::client`] is also set.
100 pub fn transport(mut self, transport: Arc<dyn Transport>) -> Self {
101 self.transport = Some(transport);
102 self
103 }
104
105 /// Set the per-query context provider. Defaults to an empty
106 /// [`StaticContextProvider`].
107 pub fn context(mut self, context: Arc<dyn LineageContextProvider>) -> Self {
108 self.context = Some(context);
109 self
110 }
111
112 /// Set the static config. Defaults to [`OpenLineageConfig::for_datafusion`].
113 pub fn config(mut self, config: OpenLineageConfig) -> Self {
114 self.config = Some(config);
115 self
116 }
117
118 /// Install the instrumentation on `state`, returning the wired
119 /// `SessionState`.
120 ///
121 /// Sets the session's query planner to an [`OpenLineageQueryPlanner`]. A
122 /// pre-existing custom `QueryPlanner` is replaced; standard physical planning
123 /// (including extension nodes) is preserved because the new planner delegates
124 /// to a `DefaultPhysicalPlanner`.
125 ///
126 /// # Panics
127 /// Panics if no client or transport was set and a default client must be
128 /// built outside a Tokio runtime. Prefer [`Self::from_env`] (which surfaces a
129 /// [`ClientError`]) or set a client built with
130 /// [`OpenLineageClient::try_new`].
131 pub fn instrument(self, state: SessionState) -> SessionState {
132 let client = self.client.unwrap_or_else(|| match self.transport {
133 Some(transport) => OpenLineageClient::new(transport),
134 None => OpenLineageClient::noop(),
135 });
136 let context = self
137 .context
138 .unwrap_or_else(|| Arc::new(StaticContextProvider::default()));
139 let config = self
140 .config
141 .unwrap_or_else(OpenLineageConfig::for_datafusion);
142
143 let planner = Arc::new(OpenLineageQueryPlanner::new(
144 client,
145 context,
146 config,
147 Vec::new(),
148 ));
149 // Also stash the planner as a typed `SessionConfig` extension so the
150 // `SessionContext`-level DDL path ([`OpenLineageSqlExt::sql_with_lineage`])
151 // can recover it: the `QueryPlanner` trait isn't `Any`, so `query_planner()`
152 // can't be downcast, but a config extension is keyed by type and retrieved
153 // with `get_extension::<OpenLineageQueryPlanner>()`. One `Arc`, two roles —
154 // the registered query planner and the extension are the same instance.
155 let mut session_config = state.config().clone();
156 session_config.set_extension(planner.clone());
157 SessionStateBuilder::from(state)
158 .with_config(session_config)
159 .with_query_planner(planner)
160 .build()
161 }
162}
163
164/// Instrument `state` with an explicit client, context provider, and config.
165///
166/// A thin wrapper over [`OpenLineage::builder`] for callers that already hold
167/// the three pieces. Prefer the builder ([`OpenLineage::builder`]) for new code.
168pub fn instrument_session_state(
169 state: SessionState,
170 client: OpenLineageClient,
171 context: Arc<dyn LineageContextProvider>,
172 config: OpenLineageConfig,
173) -> SessionState {
174 OpenLineage::builder()
175 .client(client)
176 .context(context)
177 .config(config)
178 .instrument(state)
179}
180
181/// Instrument `state` with an empty [`StaticContextProvider`].
182///
183/// A thin wrapper over [`OpenLineage::builder`]. Prefer the builder for new code.
184pub fn instrument_session_state_simple(
185 state: SessionState,
186 client: OpenLineageClient,
187 config: OpenLineageConfig,
188) -> SessionState {
189 OpenLineage::builder()
190 .client(client)
191 .config(config)
192 .instrument(state)
193}
194
195/// `SessionContext` ergonomics for OpenLineage instrumentation.
196///
197/// Two conveniences over [`OpenLineageBuilder`]:
198///
199/// - [`with_lineage`](Self::with_lineage) installs the instrumentation on a
200/// context in one consume-and-return call, mirroring DataFusion's own
201/// `SessionContext::enable_url_table(self) -> Self`. It saves the host the
202/// `SessionContext::new_with_state(builder.instrument(ctx.state()))` dance. It
203/// takes `impl Into<Option<OpenLineageBuilder>>`, so `ctx.with_lineage(None)`
204/// uses defaults (a no-op client, default config) and
205/// `ctx.with_lineage(OpenLineage::builder()…)` supplies a configured one.
206/// - [`sql_with_lineage`](Self::sql_with_lineage) runs SQL *with* lineage for
207/// statements DataFusion executes outside the query-planner hook — `CREATE TABLE
208/// … AS SELECT` and `CREATE VIEW … AS SELECT`. `SessionContext::sql` dispatches
209/// these DDL statements straight to the catalog before the instrumented
210/// `QueryPlanner` ever sees the full plan, so the created dataset is silently
211/// dropped from lineage (and a `CREATE VIEW` emits nothing at all). For every
212/// other statement this is exactly `SessionContext::sql(...).await` and the
213/// normal planner path produces the lineage.
214#[async_trait]
215pub trait OpenLineageSqlExt: Sized {
216 /// Instrument this context with OpenLineage and return it (consume-and-return,
217 /// like `SessionContext::enable_url_table`).
218 ///
219 /// Pass a configured [`OpenLineageBuilder`] to control the transport/client,
220 /// context provider, and config — or `None` for defaults (a no-op client and
221 /// [`OpenLineageConfig::for_datafusion`]). Because the argument is
222 /// `impl Into<Option<OpenLineageBuilder>>`, both `ctx.with_lineage(None)` and
223 /// `ctx.with_lineage(OpenLineage::builder()…)` are valid call sites. For the
224 /// standard-environment path use [`try_with_lineage_from_env`](Self::try_with_lineage_from_env)
225 /// (it is fallible, so it can't fold into this signature).
226 fn with_lineage(self, builder: impl Into<Option<OpenLineageBuilder>>) -> Self;
227
228 /// Instrument this context with OpenLineage, deriving everything from the
229 /// environment (see [`OpenLineageBuilder::from_env`]).
230 ///
231 /// # Errors
232 /// Returns [`ClientError`] if the client cannot be built (e.g. a malformed
233 /// `OPENLINEAGE_URL`, or called outside a Tokio runtime).
234 fn try_with_lineage_from_env(self) -> Result<Self, ClientError>;
235
236 /// Run `sql`, emitting OpenLineage events even for the CTAS / `CREATE VIEW`
237 /// statements DataFusion executes outside the query-planner hook. Equivalent to
238 /// [`SessionContext::sql`] for every other statement.
239 async fn sql_with_lineage(&self, sql: &str) -> DataFusionResult<DataFrame>;
240}
241
242#[async_trait]
243impl OpenLineageSqlExt for SessionContext {
244 fn with_lineage(self, builder: impl Into<Option<OpenLineageBuilder>>) -> Self {
245 // Mirror `enable_url_table`: consume the context, instrument its state, and
246 // rebuild. `instrument` both registers the query planner and stashes the
247 // planner as a config extension, so `sql_with_lineage` can recover it. A
248 // `None` builder is the empty default builder (no-op client, default cfg).
249 let builder = builder.into().unwrap_or_default();
250 SessionContext::new_with_state(builder.instrument(self.state()))
251 }
252
253 fn try_with_lineage_from_env(self) -> Result<Self, ClientError> {
254 Ok(self.with_lineage(OpenLineage::builder().from_env()?))
255 }
256
257 async fn sql_with_lineage(&self, sql: &str) -> DataFusionResult<DataFrame> {
258 let plan = self.state().create_logical_plan(sql).await?;
259
260 // Only DDL that carries a SELECT body (CTAS / CREATE VIEW) is stolen from
261 // the planner; for everything else the planner path already produces
262 // lineage, so delegate to the normal execution path verbatim.
263 let is_ddl_with_input = matches!(
264 plan,
265 LogicalPlan::Ddl(DdlStatement::CreateMemoryTable(_) | DdlStatement::CreateView(_))
266 );
267 if !is_ddl_with_input {
268 return self.execute_logical_plan(plan).await;
269 }
270
271 // An instrumented session carries the planner as a config extension; an
272 // uninstrumented one does not, so this transparently degrades to plain
273 // execution (no events) there.
274 match self
275 .state()
276 .config()
277 .get_extension::<OpenLineageQueryPlanner>()
278 {
279 Some(planner) => planner.execute_ddl_with_lineage(self, plan, sql).await,
280 None => self.execute_logical_plan(plan).await,
281 }
282 }
283}