OpenLineage connector

How-to guides

Step-by-step guides for the common OpenLineage scenarios: lineage from a code repository with an AI assistant and a CI pipeline, runtime lineage from Airflow, Spark or dbt, quality results and metrics, connecting events to the crawled catalog, storage shared by several producers, and checking a first run

Each guide below is a short recipe for one scenario: what to configure, in which order, and how to check the result. They assume the endpoint is set up .

I want to Guide
Publish the lineage of a code repository, written with an AI assistant and kept under version control Lineage from a code repository
Collect the lineage of what actually runs in Airflow, Spark or dbt Runtime lineage
See test results and data metrics in Blindata data quality Quality results and metrics
Attach the events to the tables I already crawl Connect to the crawled catalog
Let several producers write the same bucket or database Share a storage
Check what a run did, and why something is missing Check a run

Publish lineage from a code repository

Use this guide when your transformations run as code that no OpenLineage integration observes: Python services, Lambda functions, Glue jobs, notebooks, SQL scripts. Instead of instrumenting every runtime, the repository describes its own lineage: one OpenLineage event per step, kept as a JSON file next to the code.

Lineage that lives with the code

How it works, in short

  1. An AI assistant reads the code and writes the events, following a skill you add to the repository.
  2. The team reviews the events in the same pull request as the code.
  3. A CI pipeline sends all the events of the repository to Blindata when the pull request is merged.
  4. Blindata publishes the catalog and the lineage, and removes what the repository no longer describes.

What you get: the jobs of the repository as catalog entities, table lineage for every step, and column lineage wherever the code makes it traceable. Blindata follows the repository: a step deleted from the code disappears from Blindata at the next merge.

What you need

  • The endpoint set up : an agent that the CI runners can reach, an application user with AGENTS_EDITOR, and a BLINDATA connection on the agent.
  • A CI system. The example uses GitHub Actions; the same steps work in GitLab CI, Azure Pipelines or Jenkins.
  • An AI coding assistant that supports skills or repository instructions (Claude Code, GitHub Copilot, Cursor or the one your team uses). Without one, the same rules work by hand.

Step 1: create the job definitions

Create a live definition for the repository: in the agent, a schedule of type OPENLINEAGE (see Create the job definition ) with this configuration:

{
  "snapshotMode": "BATCH_SNAPSHOT",
  "allowedJobNamespaces": ["github://my-org/ds-quality"],
  "systemPolicies": [
    { "match": "redshift://*", "creation": "NEVER", "entityCreation": false, "cleanupMode": "NONE" },
    { "match": "s3://*", "creation": "IF_MISSING", "technology": "Amazon S3", "entityCreation": true, "cleanupMode": "DAYS", "cleanupDays": 30 }
  ],
  "quality": { "enabled": false },
  "cleanup": { "enabled": true, "maxRemovalsPerRun": 50 },
  "outputOptions": { "dryRun": false, "targetConnectionName": "blindata-prod" }
}
Setting What it does
BATCH_SNAPSHOT Each publication describes the whole repository: what it no longer contains is removed in the same run.
One namespace, no * The repository owns its namespace: no other producer can describe or remove its jobs.
redshift://* with NEVER The warehouse is crawled: the events only add lineage to existing tables. See Connect the events to your crawled catalog .
s3://* with IF_MISSING Each bucket gets a System, created on first use. Use cleanupMode CUTOFF only for buckets that only this repository writes.
quality.enabled false Design-time events carry no test results.
A low maxRemovalsPerRun A broken or truncated batch cannot remove much: over the cap nothing is removed and the run warns.

Add a daily cron expression as a safety net; a batch already starts a run.

To check pull requests before they are merged, create a second definition with the same configuration, "allowedJobNamespaces": ["github://my-org/*"] and no outputOptions. It is a dry run: it writes nothing and reports what a real run would do. One is enough for all repositories.

Copy the uuid of each definition.

Step 2: add the lineage skill to the repository

The skill tells the assistant how to describe the repository: what a step is, how to name jobs and datasets, and, above all, to reach column-level lineage wherever the code allows it, not just to say which tables a process reads and writes.

Save the text below in the repository where your assistant reads it:

Assistant File
Claude Code .claude/skills/openlineage-lineage/SKILL.md
GitHub Copilot .github/instructions/openlineage-lineage.instructions.md, with applyTo: "**"
Cursor .cursor/rules/openlineage-lineage.mdc
Others AGENTS.md, or the instructions file your assistant reads

Replace my-org/ds-quality and the dataset naming table with the values of your repository.

---
name: openlineage-lineage
description: Writes and updates the OpenLineage JobEvent files in lineage/ that describe how this repository moves data, at column level wherever the code allows it. Use when asked to document, generate, update or check data lineage, or when a change adds, removes or modifies a step that reads or writes a dataset.
---

# OpenLineage lineage of this repository

You describe how the code of this repository moves data, as OpenLineage JobEvents
that Blindata publishes as catalog and lineage. Your goal is the **most precise
lineage the code proves**: column to column wherever you can trace it, table to
table only where you cannot. A description of the overall process is not enough.

## What a step is

A step is a unit of code that writes a dataset: a script, a function, a SQL
statement or file, a notebook, a dbt model, a Glue or Lambda handler. Each step
is one JobEvent in one file: `lineage/<job name>.json`.

- Job namespace: `github://my-org/ds-quality`, the same in every file.
- Job name: `<module>.<function>` or `<pipeline>.<step>`. Keep it stable:
  renaming it removes the old job in Blindata and creates a new one.
- A step that only reads (a report, an API response, a log) is not a step.
  Search indexes, model endpoints and caches are not datasets.

## Datasets

Name every dataset as OpenLineage does:

| Store | Namespace | Name |
| --- | --- | --- |
| Redshift | `redshift://<cluster>.<region>:<port>` | `<database>.<schema>.<table>` |
| PostgreSQL | `postgres://<host>:<port>` | `<database>.<schema>.<table>` |
| S3 | `s3://<bucket>` | the key of the dataset folder, without partitions and file names |
| Glue Data Catalog | `arn:aws:glue:<region>:<account>` | `table/<database>/<table>` |
| Athena | `awsathena://athena.<region>.amazonaws.com` | `<catalog>.<database>.<table>` |

Resolve names to their real values: follow constants, configuration files,
environment variables and infrastructure code (Terraform, CDK, CloudFormation,
serverless.yml). Use the production values. If a name cannot be resolved, do not
guess it: leave the dataset out and report it.

## How to reach column lineage

For each step, use the first approach that applies.

1. **The step writes with SQL** and the statement is known, or can be rebuilt
   because its template parameters are known: put the statement, as executed,
   in the `sql` job facet with its `dialect`, and list `inputs` and `outputs`.
   Do not add field mappings: Blindata parses the SQL and derives the column
   lineage. Expand templates (Jinja, f-strings, format) into the final SQL.
   A step that runs several writing statements becomes one job per statement.

2. **The step transforms data in code** (pandas, PySpark DataFrames, dicts,
   ORM objects): trace every output column back to its input columns and write
   the `lineage` job facet, one entry per output dataset, with `fields`:
   - Follow the data through the whole step: reads, renames, selects, computed
     columns, joins, aggregations, filters, writes.
   - When the code reads with a SQL query and then transforms the result, the
     query does not write the output: do not use the `sql` facet. Trace each
     output column through the code and the query back to the source table
     columns, and write the result in `fields`.
   - Set `transformations` on each input field:
     - `DIRECT` / `IDENTITY`: copied or renamed.
     - `DIRECT` / `TRANSFORMATION`: computed from the input; add a short
       `description` of the formula.
     - `DIRECT` / `AGGREGATION`: sum, count, average and similar.
   - Columns that influence the result without flowing into it (join keys,
     filters, group by, sort, window, conditions) go in the `inputs` of the
     entry, with their `field` and an `INDIRECT` transformation of subtype
     `JOIN`, `FILTER`, `GROUP_BY`, `SORT`, `WINDOW` or `CONDITIONAL`.
   - A pass-through (`select *`, a DataFrame written as read) is expanded
     column by column when the input schema is known from DDL, models, schema
     files or the catalog.

3. **Only when no column can be traced** (opaque libraries, schemas built at
   runtime), declare the table flow alone: the entry with its `inputs` and no
   `fields`. Report why.

Every mapping must point to code you can cite. A guessed mapping is worse than
none: when unsure, leave the field out and report it.

## Schemas and descriptions

- Each output carries the `schema` dataset facet with the columns the code
  writes, their types and order. Nested structures are nested `fields`.
- The job carries a `documentation` facet: what the step does and why, in one
  or two sentences for a business reader.
- Do not describe inputs that this repository does not write: the catalog
  already has them.

## Format

- One JobEvent per file, with `producer` and `schemaURL`
  (`https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/JobEvent`).
- Every facet has `_producer` and `_schemaURL`. Use the facet versions of
  OpenLineage 1.55.0.
- Add a `jobType` facet: `processingType` `BATCH` or `STREAMING`, the
  `integration` (for example `ECS`, `LAMBDA`, `GLUE`), `jobType` `TASK`.
- Do not write `eventTime` or `sourceCodeLocation`: the pipeline adds them.
- Never write secrets, credentials, connection strings with passwords, or
  personal data.

## When the code changes

- Update the file of each step the change touches, in the same pull request.
- A step deleted from the code: delete its file.
- Keep job names as they are unless the step really became another one.

## Your report

End with a table: step, file, lineage level reached (`column`, `partial
column`, `table`), and what you could not resolve or left out, with the file
and line that caused it. These are the points the reviewer checks first.

Step 3: generate and review the events

Ask the assistant to describe one area at a time, for example: “Describe the lineage of features/”. It writes the files under lineage/ and ends with its report.

Review the files like code, in the same pull request as the code they describe:

  • Every dataset name matches the real table or folder: compare it with the catalog in Blindata.
  • Every field mapping can be found in the code.
  • SQL steps carry the SQL as executed, not a simplified version.
  • What the report lists as unresolved is either fixed or accepted.
  • No secret, connection string or personal data in any file.

Two complete events, a SQL step and a code step with field mappings and nested fields, are in Send a batch with curl . The facets and their versions are in the Facet support reference .

Step 4: add the CI pipeline

The pipeline sends all the files of lineage/ as one batch: only a complete batch, sent in one request, is a snapshot.

When Sends to Result
A pull request changes lineage/ The dry-run definition A report in the agent; nothing written.
The default branch changes lineage/ The live definition Catalog and lineage published; steps no longer described removed.

Store these values as CI secrets and variables:

Name Kind Value
BLINDATA_TENANT secret The tenant uuid.
BLINDATA_USER secret The user name of the application user.
BLINDATA_API_KEY secret Its API key.
OPENLINEAGE_AGENT_URL variable https://<agent-host>
OPENLINEAGE_DRY_RUN_UUID variable The uuid of the dry-run definition.
OPENLINEAGE_LIVE_UUID variable The uuid of the live definition.

.github/workflows/lineage.yml:

name: Lineage
on:
  pull_request:
    paths: ["lineage/**"]
  push:
    branches: [main]
    paths: ["lineage/**"]

jobs:
  publish:
    # A runner that reaches the agent, for example a self-hosted runner in its network.
    runs-on: self-hosted
    steps:
      - uses: actions/checkout@v4

      - name: Build the batch
        run: |
          jq -s \
            --arg now "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
            --arg repo "${{ github.server_url }}/${{ github.repository }}" \
            --arg sha "${{ github.sha }}" \
            'map(
               .eventTime = $now
               | .job.facets.sourceCodeLocation = ({
                   "_producer": "https://github.com/my-org/lineage-pipeline/v1",
                   "_schemaURL": "https://openlineage.io/spec/facets/1-1-0/SourceCodeLocationJobFacet.json#/$defs/SourceCodeLocationJobFacet",
                   "type": "git", "url": $repo, "repoUrl": $repo, "version": $sha
                 } + (.job.facets.sourceCodeLocation // {}))
             )' lineage/*.json > batch.json
          echo "Events in the batch: $(jq length batch.json)"          

      - name: Send the batch
        env:
          TENANT: ${{ secrets.BLINDATA_TENANT }}
          USER_NAME: ${{ secrets.BLINDATA_USER }}
          API_KEY: ${{ secrets.BLINDATA_API_KEY }}
          AGENT: ${{ vars.OPENLINEAGE_AGENT_URL }}
          UUID: ${{ github.event_name == 'pull_request' && vars.OPENLINEAGE_DRY_RUN_UUID || vars.OPENLINEAGE_LIVE_UUID }}
        run: |
          status=$(gzip -c batch.json | curl -sS -o response.json -w '%{http_code}' -X POST \
            "$AGENT/agent-api/v1/openlineage/$UUID/api/v1/lineage/batch" \
            -H "Content-Type: application/json" -H "Content-Encoding: gzip" \
            -H "X-BD-Tenant: $TENANT" -H "X-BD-User: $USER_NAME" -H "X-BD-ApiKey: $API_KEY" \
            --data-binary @-)
          echo "HTTP $status"
          if [ "$status" != "204" ]; then cat response.json; exit 1; fi          
  • The job fails on any answer but 204. A 200 lists the rejected events in failed_events, and a batch with a rejected event is not a snapshot: nothing would be removed by it.
  • jq -s turns the files into the array of the batch, and adds the time and the commit. A path written in a file is kept.
  • Pull requests from forks get no secrets in GitHub Actions: run the check only for branches of the repository.
  • A batch is limited to 50 MB once decompressed, thousands of events. Split a large repository into several namespaces, each with its own definition, long before that.

Step 5: check the result

  1. Open a pull request with the first files. In the agent, open the Runs of the dry-run definition and read the report: Systems resolved and created, flows planned, and gaps. UNMAPPED_NAMESPACE means a dataset namespace matches no System: stamp the System or fix the name. See Why is my lineage missing? .
  2. Merge. The live definition publishes, and its report says what was written.
  3. In Blindata, open an output table and its lineage: the flows name the job (@github://my-org/ds-quality/<job name>), and the job entity on the OpenLineage System links to the repository.
  4. Later, delete a step and its file. After the merge, its job, its flows and its routine are removed in the same run. See Removal of deprecated metadata .

Variants

Situation Change
A step also runs with the OpenLineage Spark integration Keep the runtime events in their own ADDITIVE definition with another job namespace, see Runtime lineage .
Several repositories write the same bucket cleanupMode DAYS for that bucket in every repository, see Share a storage between producers .
The repository is large One namespace and one definition per area, each sending its own complete batch.
No AI assistant Follow the skill by hand, starting from the two events of Send a batch with curl .

Collect runtime lineage from Airflow, Spark or dbt

Runtime integrations emit events while jobs run. Each run describes only what it did, so the definition adds what it receives and removes, after a number of days, what is no longer seen.

1. Choose the job namespace of the producer, for example airflow-prod. It is set in the producer:

Producer Where the namespace is set
Airflow the namespace option of the OpenLineage provider
Spark spark.openlineage.namespace
dbt (dbt-ol) the OPENLINEAGE_NAMESPACE environment variable
Trino openlineage-event-listener.namespace

Use one namespace per environment (airflow-prod, airflow-test) and one definition per namespace, so test runs never reach production metadata.

2. Create the definition in the agent, with a cron expression that runs it often enough for your users, for example every 15 minutes:

{
  "snapshotMode": "ADDITIVE",
  "allowedJobNamespaces": ["airflow-prod"],
  "cleanup": { "enabled": true, "staleJobDays": 30 },
  "outputOptions": { "dryRun": false, "targetConnectionName": "blindata-prod" }
}
  • Nothing is created on crawled Systems by default: the events add lineage to the tables that exist.
  • staleJobDays must be longer than the interval of your slowest job: a monthly job needs more than 31 days, or it is removed between two runs.
  • Start with the dry run (leave outputOptions out), read a report, then go live.

3. Connect the namespaces of the datasets to the Systems you crawl: see Connect the events to your crawled catalog .

4. Configure the producer with the address and headers of the definition: Airflow , Spark , dbt , Flink , Trino .

5. Run one job, wait for the next run of the definition, and check it .

What to expect from each producer:

Producer Lineage Good to know
Airflow Table lineage of the operators that support OpenLineage, and the DAG task graph as orchestration flows Operators without lineage extraction are reported as PRODUCER_NO_LINEAGE.
Spark Table and column lineage from the query plans A plan the integration could not fully handle is reported as PRODUCER_PARTIAL_LINEAGE.
dbt Table lineage between models and sources Each model is a job. When the event carries the SQL of the model, it is analyzed for column lineage.
Trino Table lineage of write queries Each query is a job by default: group them with the job name format, see Trino .

When a completed run always describes the whole job (a job that reads and writes the same datasets every time), set lineageReplacement to JOB: lineage the job no longer has is removed at its next run instead of after staleJobDays.

Bring quality results and metrics into Blindata

Quality results come from completed runs whose events carry the quality facets: assertions (dataQualityAssertions), tests (test) and metrics (dataQualityMetrics, inputStatistics, outputStatistics). Integrations such as Great Expectations emit them; your own checks can too.

1. Use a definition with quality on (the default) and the namespace of the producer:

{
  "snapshotMode": "ADDITIVE",
  "allowedJobNamespaces": ["quality-checks"],
  "quality": { "enabled": true, "metricsEnabled": true },
  "outputOptions": { "dryRun": false, "targetConnectionName": "blindata-prod" }
}

2. Make sure the checked datasets are in the catalog. A check is linked to the entity of its dataset: an assertion on a dataset that is not catalogued makes no check (TARGET_NOT_CATALOGUED). See Connect to the crawled catalog .

3. Send the results. From your own code, one COMPLETE run event per check run is enough:

{
  "eventType": "COMPLETE",
  "eventTime": "2026-11-04T06:00:00Z",
  "producer": "https://github.com/my-org/quality-checks/v1",
  "schemaURL": "https://openlineage.io/spec/2-0-2/OpenLineage.json#/$defs/RunEvent",
  "run": { "runId": "0193a8e2-6c1f-7d2e-9b55-3f1d2a4c8e10" },
  "job": { "namespace": "quality-checks", "name": "daily_kpi_checks" },
  "inputs": [ {
    "namespace": "redshift://my-dwh.eu-west-1:5439",
    "name": "dwh.analytics.daily_kpi",
    "inputFacets": {
      "dataQualityAssertions": {
        "_producer": "https://github.com/my-org/quality-checks/v1",
        "_schemaURL": "https://openlineage.io/spec/facets/1-1-0/DataQualityAssertionsDatasetFacet.json#/$defs/DataQualityAssertionsDatasetFacet",
        "assertions": [
          { "assertion": "not_null", "success": true, "column": "plant_id" },
          { "assertion": "avg_value_in_range", "success": false, "column": "avg_value" }
        ]
      },
      "dataQualityMetrics": {
        "_producer": "https://github.com/my-org/quality-checks/v1",
        "_schemaURL": "https://openlineage.io/spec/facets/1-0-3/DataQualityMetricsInputDatasetFacet.json#/$defs/DataQualityMetricsInputDatasetFacet",
        "rowCount": 18250,
        "columnMetrics": { "plant_id": { "nullCount": 0, "distinctCount": 50 } }
      }
    }
  } ],
  "outputs": []
}

Post it to /lineage of the definition, with the headers of Authentication . Use a new runId for every run: results are published once per check and run, so a retried request does not duplicate them.

4. Look at the result in Blindata: the checks are in the quality suite openlineage:<job definition uuid>, one per dataset, assertion and column, with one result per run; the metrics are profiling records of the entity. See OpenLineage to Blindata quality .

Connect the events to your crawled catalog

OpenLineage names a dataset by a namespace (where it is: redshift://my-dwh.eu-west-1:5439) and a name. Blindata needs to know which System a namespace is. Do it once per System, for every producer:

  1. Find the namespace. Run the definition once: datasets of an unknown namespace are reported as UNMAPPED_NAMESPACE, and the run log names it: Namespace <namespace> maps to no System; <n> datasets skipped.
  2. Stamp the System. In Blindata, open the System you crawl and add the additional property openLineageNamespace with the namespace as value.
  3. Run again. The datasets resolve to the System, and the name gives the schema and the table, as the crawler names them. See Dataset name to schema and entity .

Prefer the stamp to the other options:

Option Use it when
openLineageNamespace on the System Always, for crawled Systems: it holds for every producer and definition.
namespaceMappings in a definition One producer uses a namespace that others do not, or a pattern covers many namespaces.
creation: IF_MISSING in a System policy Stores that nobody crawls, such as S3 buckets: the job creates the System.

Keep creation at NEVER for stores you crawl: a System created by the job next to the crawled one would split the catalog in two.

Share a storage between producers

Several producers often write the same bucket or database: two repositories writing one S3 bucket, or Airflow and Spark writing one warehouse. Each producer keeps its own definition and its own job namespace; the storage needs a cleanup rule that does not let one producer remove what another still describes.

The storage is cleanupMode in the System policy of every definition that writes it
Crawled (a warehouse, a database) NONE, the default: the crawler owns its tables.
Written by one producer only CUTOFF: a snapshot removes what it no longer describes, in the same run.
Written by several producers DAYS, with cleanupDays longer than the slowest producer’s interval: an entity is removed only when nobody has described it for that long.

The job protects you if you forget: a System created by another definition, or by none, is never cleaned with CUTOFF, and the run reports SHARED_SYSTEM_DOWNGRADED. See Ownership of dataset Systems .

Check a run

Every run of a definition leaves a report in the agent: open the definition in Schedules and its Runs.

  1. Events: read and processed. Fewer processed than read means runs still waiting for their end event, or uploads that failed: the events stay for the next run.
  2. Catalogue: Systems resolved and created, entities, fields, job entities. A created System you did not expect means a missing stamp (see above).
  3. Lineage: table, column and orchestration flows. No column flows for a SQL job in the first run is normal: its SQL is analyzed and the column flows follow (QLP_PENDING).
  4. Gaps: each code says what is missing and what to do. See Why is my lineage missing? .
  5. Cleanup: the mode, and the removals per object type. Over maxRemovalsPerRun nothing is removed and the run warns.
  6. Senders: who sent the events the run read.

A run in WARNING completed but logged something that changed the outcome (a gap, a skipped cleanup). A run in ERROR wrote nothing more after the failure, and its events stay for the next run. See Read the run report .