Guides

Data lineage (OpenLineage)

Tracing answers what happened. Lineage answers a different question: what data did this produce, and what did it come from? Hopskip speaks OpenLineage, the standard consumed by Marquez, DataHub, Astronomer, and Airflow’s own lineage backend. A workflow that reads a table your Airflow DAG wrote shows up as one connected graph in tooling neither system owns.

Nothing is exported until you configure a destination.

Turn it on

Point the server at an OpenLineage receiver:

export HOPSKIP_OPENLINEAGE_ENDPOINT=http://marquez:5000
export HOPSKIP_OPENLINEAGE_API_KEY=# optional bearer token
hopskip-server

Events post to {endpoint}/api/v1/lineage, the OpenLineage HTTP transport’s own convention. To try it without standing up a backend, write the events to a file instead:

export HOPSKIP_OPENLINEAGE_FILE=./lineage.jsonl
hop dev
# ... run a workflow, then:
tail -f lineage.jsonl

That alone gets you a run-level graph: every workflow run becomes an OpenLineage job (the workflow type) and run (this execution), with START and COMPLETE/FAIL/ABORT events, and a parent/child edge wherever one workflow started another.

Declare the datasets

Core cannot know which data a run touched. To the engine, an activity that copies a table into Parquet is opaque bytes in and opaque bytes out. Only you know the table and the file, so you say so:

from hopskip import durable, activity, dataset_input, dataset_output

@durable
async def daily_rollup(day):
    dataset_input("orders_source", "postgres://db:5432", "shop.public.orders")
    rows = await activity("read_orders", day)

    await activity("write_parquet", rows)
    dataset_output("rollup", "s3://warehouse", f"orders/{day}.parquet")

Those two calls are what turn a run into a node with edges on both sides. The same API exists in every SDK that has it:

LanguageInputOutput
Pythondataset_input(key, ns, name)dataset_output(key, ns, name)
TypeScriptdeclareDatasetInput(key, ns, name)declareDatasetOutput(key, ns, name)
Rustlineage::input(key, ns, name)lineage::output(key, ns, name)
Gohopskip.DatasetInput(key, ns, name)hopskip.DatasetOutput(key, ns, name)

Each also has a Dataset value for a dataset you declare more than once:

let orders = Dataset::new("orders", "postgres://db:5432", "shop.public.orders");
orders.read();

Declaring the same dataset twice is harmless; the emitter deduplicates.

Naming datasets

namespace and name are OpenLineage’s coordinates, not Hopskip’s. The namespace is the source system (postgres://db:5432, s3://warehouse, bigquery), and the name is the dataset within it (shop.public.orders, orders/2026-07-30.parquet). Following OpenLineage’s naming conventions is what makes your Hopskip run connect to the same table your dbt model wrote. Inventing a local convention gives every tool its own island.

key is the @searchable attribute key the declaration rides on: your label for the call site, not part of the dataset’s identity.

What an event looks like

{
  "eventType": "COMPLETE",
  "eventTime": "2026-07-30T16:00:09.000Z",
  "job": { "namespace": "orders", "name": "hopskip:orders.fulfill@2" },
  "run": {
    "runId": "5c6af70c-96fe-8c67-a701-ca503358540e",
    "facets": {
      "nominalTime": { "nominalStartTime": "…", "nominalEndTime": "…" },
      "hopskip": {
        "taskId": "client-1",
        "workflowInstance": "order-42",
        "hopskipRunId": "019a…",
        "principal": "alice@example.com",
        "terminalStatus": "completed"
      }
    }
  },
  "inputs":  [{ "namespace": "postgres://db:5432", "name": "shop.public.orders" }],
  "outputs": [{ "namespace": "s3://warehouse", "name": "orders/2026-07-30.parquet" }]
}

The job is the workflow type, because that is what repeats across runs. The per-instance name you chose identifies the run and rides on the hopskip facet. Putting the instance name in job.name would give you a million single-run nodes and no edges worth drawing.

How it works

The emitter is a read-side consumer of the event log, the same shape as the visibility projection. It reads the durable records a run already leaves behind (its start pair, its terminal outcome, and any dataset declarations) and maps them into run events. Nothing about lineage sits on the dispatch path, so an unreachable backend can only fall behind; it can never fail a workflow or slow an append.

A tick is three phases, and only the first one touches the log: read the new records, then decode and map them, then deliver. The server holds the dispatcher and log locks for the read alone. Neither the decoding cost nor the network round trip happens on a lock your appends need.

Delivery is at-least-once, by design. The OpenLineage run id is derived (a name-based UUID over the run’s durable identity), so a re-sent event is byte-identical to the first one and lands on the same lineage node. A backend outage costs re-derivation, never lineage: nothing is consumed until it lands, so when the backend recovers the whole pending backlog arrives.

Requests are bounded (5s to connect, 15s total), so a backend that accepts a connection and never answers costs a batch rather than wedging the exporter. Consecutive failed ticks back off exponentially to about five minutes and reset the moment anything is delivered, so a long outage does not mean a tick’s worth of wasted work every few seconds for its duration.

Recovery

The exporter keeps nothing durable (offsets and mapping state are process memory), so recovery is always the same act: re-read the log and re-derive. Three situations, and what each one costs you.

A crash or restart. The restarted process re-reads every partition the log still holds and re-derives everything, including runs that had already finished. Those events are byte-identical to the ones sent before the crash, so your backend collapses them onto the same lineage nodes rather than forking them. Nothing is lost, including a run that finished in the seconds before the crash.

That last part depends on your log backend being able to enumerate its own partitions. The memory and raft backends can; disk and tiered cannot, because they key their segments by hash. On a backend that cannot, the exporter falls back to asking the dispatcher, which only knows about runs this process started, so a run that finished within one tick of a crash never gets its terminal event. The server says so once at startup rather than leaving you to find out:

WARN log backend does not enumerate partitions, so lineage recovery after a
     restart is limited to runs this process started ...

A backend outage. Nothing is consumed until it lands, so an outage is a backlog rather than a gap. When the backend comes back, the whole pending set is delivered.

Multiple nodes or regions. Two nodes reading the same replicated log emit byte-identical events, because every field is derived from the durable record and nothing from the emitting process. Your backend sees duplicates of one graph, not two graphs that disagree.

Where nodes genuinely differ is visibility. If a workflow started by a run on another node lands on this one, this node can see the child’s declared parent but not the parent’s identity, so it emits the child without a parent edge rather than guessing one. That is counted and logged rather than dropped silently:

INFO openlineage: emitted events whose declared parent run is not visible to
     this node; their lineage edges are omitted rather than guessed

A standing non-zero count is the normal shape of a multi-region deployment: each node contributes the edges it can substantiate, and it signals that the graph from any single node is that node’s view rather than the whole one.

Terminal states

Hopskip outcomeOpenLineage
CompletedCOMPLETE
Failed, retries exhausted, poison, DLQ, quarantineFAIL
Superseded by a newer startABORT

ABORT is reserved for a run that was stopped, not one that broke. The exact Hopskip status (skipped_to_dlq, quarantined, …) is preserved on the hopskip facet, so nothing is lost in the narrowing.

Configuration

VariableDefaultMeaning
HOPSKIP_OPENLINEAGE_ENDPOINT(none)Base URL of an OpenLineage receiver. Unset = no export.
HOPSKIP_OPENLINEAGE_API_KEY(none)Bearer token. Unset = no Authorization header.
HOPSKIP_OPENLINEAGE_FILE(none)Append events to this file as newline-delimited JSON. Ignored when an endpoint is set.
HOPSKIP_OPENLINEAGE_TICK_MS5000How often the emitter reads new records.
HOPSKIP_OPENLINEAGE_BATCH_SIZE500Records read per partition per tick.

Limits worth knowing

  • No column-level lineage. The engine sees opaque payload bytes, not columns. A guessed column mapping would be worse than none.
  • Activities are not their own runs. Promoting every step to a lineage node would fill the graph with Hopskip internals rather than your pipeline. The one relationship that is a real durable fact, one workflow starting another, is emitted as a parent/child edge.
  • Haskell has no dataset-declaration API yet. Haskell workflows still produce run-level lineage; they just cannot declare datasets from guest code yet.
  • Non-ASCII dataset names from TypeScript are declined, not corrupted, because that SDK has no UTF-8 encoder behind its scalar encoding yet. Rust, Python, and Go handle them fine.