Skip to main content

faucet_lineage/
config.rs

1//! User-facing `lineage:` configuration types.
2
3use schemars::JsonSchema;
4use serde::{Deserialize, Serialize};
5use std::path::PathBuf;
6use std::time::Duration;
7
8/// Top-level `lineage:` block.
9#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10#[serde(deny_unknown_fields)]
11pub struct LineageConfig {
12    /// Lineage format. Only `openlineage` in v1.
13    #[serde(rename = "type", default)]
14    pub kind: LineageKind,
15    /// OpenLineage namespace for the emitted job/datasets.
16    pub namespace: String,
17    /// Where events are sent.
18    pub transport: Transport,
19    /// Job-name template. `${name}` / `${row_id}` / `${now.*}` are resolved
20    /// per matrix row at run time.
21    #[serde(default = "default_job_name")]
22    pub job_name: String,
23    /// Optional parent job (orchestrator linkage).
24    #[serde(default)]
25    pub parent_job: Option<ParentJob>,
26    /// Emit column-level lineage facets where the transform chain is mappable.
27    #[serde(default)]
28    pub include_column_lineage: bool,
29    /// Emit dataset schema facets (inferred from a record sample).
30    #[serde(default)]
31    pub include_schema_facet: bool,
32    /// Emit the resolved config body as a SourceCode facet. Off by default —
33    /// the resolved config may contain secrets; enabling warns.
34    #[serde(default)]
35    pub include_source_code_facet: bool,
36    /// Which lifecycle events to emit.
37    #[serde(default)]
38    pub emit_on: EmitOn,
39    /// Max records sampled for schema/column facets. Default 100.
40    #[serde(default = "default_sample")]
41    pub sample_records: usize,
42    /// RUNNING heartbeat interval. Default 30s; only used when `emit_on.running`.
43    #[serde(
44        with = "faucet_core::config::duration_secs",
45        default = "default_heartbeat"
46    )]
47    #[schemars(with = "u64")]
48    pub heartbeat_interval: Duration,
49}
50
51#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, JsonSchema)]
52#[serde(rename_all = "snake_case")]
53pub enum LineageKind {
54    #[default]
55    Openlineage,
56}
57
58/// Lineage event transport.
59///
60/// Uses the project-wide `{ type, config: { … } }` shape (adjacently tagged),
61/// matching connectors and the shared auth catalog. `deny_unknown_fields` is
62/// intentionally omitted — serde does not honor it on tagged enums (same as
63/// `faucet-core` auth, websocket, grpc).
64#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
65#[serde(tag = "type", content = "config", rename_all = "snake_case")]
66pub enum Transport {
67    /// POST each event to an OpenLineage HTTP endpoint (e.g. Marquez).
68    Http {
69        url: String,
70        #[serde(
71            with = "faucet_core::config::duration_secs",
72            default = "default_http_timeout"
73        )]
74        #[schemars(with = "u64")]
75        timeout_secs: Duration,
76        #[serde(default)]
77        auth: Option<HttpAuth>,
78    },
79    /// Append each event as one JSON line to a local file.
80    File { path: PathBuf },
81    /// Produce each event as a JSON message to a Kafka topic.
82    #[cfg(feature = "transport-kafka")]
83    Kafka { brokers: String, topic: String },
84}
85
86/// HTTP transport auth. Same `{ type, config: { … } }` shape as connector auth
87/// (e.g. `{ type: bearer, config: { token: … } }`).
88#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
89#[serde(tag = "type", content = "config", rename_all = "snake_case")]
90pub enum HttpAuth {
91    Bearer { token: String },
92}
93
94/// Parent-job linkage (Airflow, Dagster, …).
95#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
96#[serde(deny_unknown_fields)]
97pub struct ParentJob {
98    pub namespace: String,
99    pub name: String,
100    #[serde(default)]
101    pub run_id: Option<String>,
102}
103
104/// Per-event emit toggles.
105#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
106#[serde(deny_unknown_fields)]
107pub struct EmitOn {
108    #[serde(default = "default_true")]
109    pub start: bool,
110    #[serde(default)]
111    pub running: bool,
112    #[serde(default = "default_true")]
113    pub complete: bool,
114    #[serde(default = "default_true")]
115    pub fail: bool,
116    #[serde(default = "default_true")]
117    pub abort: bool,
118}
119
120impl Default for EmitOn {
121    fn default() -> Self {
122        Self {
123            start: true,
124            running: false,
125            complete: true,
126            fail: true,
127            abort: true,
128        }
129    }
130}
131
132fn default_true() -> bool {
133    true
134}
135fn default_job_name() -> String {
136    "${name}::${row_id}".to_string()
137}
138fn default_sample() -> usize {
139    100
140}
141fn default_heartbeat() -> Duration {
142    Duration::from_secs(30)
143}
144fn default_http_timeout() -> Duration {
145    Duration::from_secs(10)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151
152    #[test]
153    fn deserializes_http_transport_with_defaults() {
154        let json = serde_json::json!({
155            "type": "openlineage",
156            "namespace": "prod.warehouse",
157            "transport": { "type": "http", "config": { "url": "https://marquez/api/v1/lineage" } }
158        });
159        let cfg: LineageConfig = serde_json::from_value(json).unwrap();
160        assert_eq!(cfg.namespace, "prod.warehouse");
161        assert_eq!(cfg.job_name, "${name}::${row_id}");
162        assert!(cfg.emit_on.start && cfg.emit_on.complete && !cfg.emit_on.running);
163        assert_eq!(cfg.sample_records, 100);
164        assert!(matches!(cfg.transport, Transport::Http { .. }));
165    }
166
167    #[test]
168    fn deserializes_file_transport() {
169        let json = serde_json::json!({
170            "namespace": "n",
171            "transport": { "type": "file", "config": { "path": "/tmp/ol.jsonl" } }
172        });
173        let cfg: LineageConfig = serde_json::from_value(json).unwrap();
174        assert!(matches!(cfg.transport, Transport::File { .. }));
175    }
176
177    #[test]
178    fn deserializes_http_transport_with_bearer_auth() {
179        // The nested auth uses the same { type, config } shape as connector auth.
180        let json = serde_json::json!({
181            "namespace": "n",
182            "transport": {
183                "type": "http",
184                "config": {
185                    "url": "https://marquez/api/v1/lineage",
186                    "auth": { "type": "bearer", "config": { "token": "secret" } }
187                }
188            }
189        });
190        let cfg: LineageConfig = serde_json::from_value(json).unwrap();
191        match cfg.transport {
192            Transport::Http {
193                auth: Some(HttpAuth::Bearer { token }),
194                ..
195            } => {
196                assert_eq!(token, "secret");
197            }
198            _ => panic!("expected http transport with bearer auth"),
199        }
200    }
201
202    #[test]
203    fn rejects_unknown_field() {
204        let json = serde_json::json!({
205            "namespace": "n",
206            "transport": { "type": "file", "config": { "path": "/tmp/ol.jsonl" } },
207            "bogus": true
208        });
209        assert!(serde_json::from_value::<LineageConfig>(json).is_err());
210    }
211
212    #[test]
213    fn schema_generates() {
214        let _ = schemars::schema_for!(LineageConfig);
215    }
216}