Skip to content

feat: Pipeline run recording & deterministic replay - turn every run into a shareable, diffable, testable artifact #11836

Description

@Aarkin7

Is your feature request related to a problem? Please describe.
Three problems that I keep hitting, and that I think most teams shipping Haystack to production run into too:

  1. I can't reproduce LLM bugs. When a pipeline misbehaves in production (wrong route, hallucinated answer, a tool call that shouldn't have fired), the run is gone the moment it ends. The LLM is stochastic, retrievers depend on a live store, and web search returns different results every minute.
  2. I can't write deterministic tests for my RAG and Agent pipelines. Every team I've talked to rebuilds the same hand-rolled mocking layer for CI: monkey-patch OpenAIChatGenerator, fake out SerperDevWebSearch, stub the retriever. The mocks drift from production behaviour within weeks, the tests rot, and we lose confidence in the suite. A quick grep for replay, recorder, cassette, or vcr across haystack/ and test/ returns nothing, there is no first-class primitive for this today.
  3. I can't see what a run cost me. OpenAIChatGenerator writes usage into each ChatMessage._meta["usage"], but Pipeline.run() never aggregates it. If my pipeline has three generators and an Agent loop, I'm summing dictionaries by hand to get total tokens, and there's no pipeline.last_run_cost, no budget cap, no per-component breakdown. (This pain shows up in older discussions like Tracking LLM usage #9642 with no PR.)

These three look unrelated, but they're all the same shape: an executed pipeline run is ephemeral, there's no artifact you can save, share, replay, or measure.

Describe the solution you'd like
A new haystack.recording module that adds three things, built as one cohesive primitive:

from haystack.recording import load_run, diff_runs, ReplayMode

# 1. RECORD: opt-in flag, default behaviour unchanged
result, run = pipeline.run(data, record=True)

run.save("debug/run.json")
print(run.usage)          # {"prompt_tokens": 2104, "completion_tokens": 318, "total_tokens": 2422}
print(run.cost_estimate)  # {"openai/gpt-4o-mini": 0.0013, "total_usd": 0.0013}
print(run.timeline)       # per-component duration + exception (if any)

# 2. REPLAY: short-circuit side-effecting components (LLMs, web search, fetchers,
# remote retrievers) so the same pipeline runs deterministically with no network.
recorded = load_run("debug/run.json")
result = pipeline.run(data, replay=recorded, mode=ReplayMode.STRICT)
# Non-side-effecting components (builders, routers, joiners, validators) re-execute live
# so I can edit my prompt template and see how the run would have changed.

# 3. DIFF: A/B prompts, model swaps, retriever changes, with one call.
baseline = load_run("v1.json"); candidate = load_run("v2.json")
print(diff_runs(baseline, candidate))
# Structured diff: per-component IO deltas, decision-point changes, cost delta.

Implementation outline (designed to be additive, not invasive):

  • New package haystack/recording/ modelled on haystack/tracing/, PipelineRun dataclass, PipelineRecorder context manager, Replayer, diff_runs.
  • Hook the recorder into the same call site the breakpoint dispatch already lives in (haystack/core/pipeline/pipeline.py::_run_component), pre-call captures inputs, post-call captures outputs, duration, and any usage metadata. Agents extend the recording inside the existing loop in haystack/components/agents/agent.py.
  • Pipeline.run() gains two keyword-only args: record=False and replay=None. Default behaviour is byte-identical to today.
  • Components opt into "side-effecting" with a one-line class attribute (haystack_side_effecting = True), following the same dunder-attribute pattern Haystack already uses for component-level state markers like haystack_added_to_pipeline (see haystack/core/component/component.py:328). Built-in generators, web search, link fetchers, and audio transcribers ship with the flag in this PR; community components opt in when they want to.
  • ReplayMode enum: STRICT (raise on drift), DRIFT_OK (re-run drifted components live), RECORD_NEW (extend the artifact in place).
  • Serialization reuses existing Haystack utilities: PipelineSnapshot / PipelineState from haystack/dataclasses/breakpoints.py, _serialize_with_field_fallback from haystack/core/pipeline/breakpoint.py, and _serialize_value_with_schema from haystack/utils/base_serialization.py. The on-disk JSON is designed to embed the existing PipelineSnapshot schema so snapshot tooling can read records as a degenerate single-step snapshot.
  • A SnapshotCallback-style hook (mirroring the existing one at haystack/core/pipeline/breakpoint.py:40) so users can stream records into S3, Datadog, or a database without touching disk.

Describe alternatives you've considered

  • Stand-alone cost/token tracker: solves only problem no. 3 and leaves bug-repro + testing untouched. Half a feature.
  • A standalone semantic LLM response cache: saves money but doesn't help reproduce a run or test it deterministically.
  • Lean harder on PipelineSnapshot + breakpoints: snapshots exist, but they're a one-shot pause-and-save mechanism keyed to a manually-set Breakpoint. They don't capture per-component IO across an entire run, can't replay end-to-end without manual reconstruction, and don't help with cost or diffs.
  • LangSmith / Phoenix / external diagnostics tools: these exist (some are paid SaaS, some are separate processes), but they sit outside the framework. Users have to pick, configure, and pay for them, and bug reports end up depending on third-party infra. A native primitive is portable, free, and shows up in every Haystack install by default.
  • Pure mocking conventions in user test suites: what most teams do today. It works, until it doesn't: the mocks rot, drift from production behaviour, and offer zero help reproducing a real failed run.

None of the alternatives cover all three problems with one well-scoped abstraction.

Additional context
Why I think this belongs in Haystack core rather than as a third-party package:

  • Haystack's README markets the framework as "a transparent architecture that lets you experiment, customize deeply, and deploy with confidence". Run recording is what makes that promise real, it turns the abstract "transparent and traceable" claim into a concrete JSON artifact a user can hold in their hand, share in an issue, and replay locally.
  • Once this lands, bug reports against Haystack become "here's run.json, please replay." That's a categorically better debugging loop for the maintainers themselves, not just downstream users.
  • Every Haystack-based app gets a deterministic test story for free, without API keys. That lowers the barrier to writing tests for community components and recipes, which is good for the ecosystem.
  • No mainstream OSS LLM framework ships a built-in record/replay primitive today. LangChain users reach for LangSmith (paid SaaS); LlamaIndex users reach for Phoenix (separate process). A native, open primitive is a genuine OSS-first differentiator for Haystack.
  • The breakpoint machinery, snapshot serialization, and per-component dispatch sites already exist, the change is mostly composition of existing primitives, not new infrastructure. Default behaviour stays byte-identical; this is purely additive.

I've done a fairly deep read of core/pipeline/pipeline.py, async_pipeline.py, breakpoint.py, components/agents/agent.py, and the generator components, and I'm confident this can be landed without breaking changes.

Metadata

Metadata

Assignees

No one assigned

    Labels

    P2Medium priority, add to the next sprint if no P1 available

    Fields

    No fields configured for Feature.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions