Experiments in CI/CD
Use Langfuse experiments in your CI/CD pipeline to catch quality regressions before they ship.
The workflow is:
- Create a Langfuse dataset with your test cases.
- Write an experiment with the Python or JS/TS SDK that tests your application against the dataset.
- Add evaluators to score task outputs.
- Raise
RegressionErrorwhen a score violates your threshold. - Create a GitHub Actions workflow that runs the script with langfuse/experiment-action.
Choose your release policy
Decide what should block a release before writing the gate:
| Policy | Block the release when | Known failures |
|---|---|---|
| Every required case must pass | Any required case fails an absolute quality requirement | Block until fixed, or explicitly remove from the required set after review |
| No approved passing case may regress | A case that passed in the approved baseline now fails | May remain only if that baseline was explicitly accepted; improvements are allowed |
You can combine these policies: require critical cases to pass and protect the remaining approved passing cases against regressions. Missing cases, duplicate case IDs, task failures, and missing or invalid evaluator results must fail the gate rather than count as passes.
A failing run does not become an approved baseline just because it is the first or latest run. Review known failures explicitly. If inputs, expected outputs, the required case set, or evaluator definitions change, review and update the baseline before comparing releases. Keep the baseline artifact and its version identifiers in your repository. See Compare against an approved baseline for an implementation.
GitHub Actions workflow
Create a workflow with the trigger you need, for example pull_request or release.
Pin the action to a release from the langfuse/experiment-action releases.
The action installs the latest SDK version by default; set python_sdk_version or js_sdk_version only if you want a specific SDK version.
The GitHub Action requires Langfuse Python SDK v4.6.0+ or JS SDK v5.3.0+.
name: Langfuse experiment gate
on:
# Run the gate for every pull request. Change this to `push`, `release`, or another
# trigger if you want to run experiments at a different point in your workflow.
pull_request:
permissions:
# Required to check out the repository.
contents: read
# Required to post or update the experiment result comment on pull requests.
pull-requests: write
# Optional: lets the result link to this specific job's logs.
# Without this permission, the action falls back to the workflow-run URL.
actions: read
jobs:
experiment:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
# Add this only if your experiments use the Python SDK
- uses: actions/setup-python@v6
with:
python-version: "3.14"
# Add this only if your experiments use the JS/TS SDK
- uses: actions/setup-node@v6
with:
node-version: "24"
- uses: langfuse/experiment-action@<release tag>
with:
# the credentials for Langfuse
langfuse_public_key: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
langfuse_secret_key: ${{ secrets.LANGFUSE_SECRET_KEY }}
langfuse_base_url: https://cloud.langfuse.com
# the location of your experiment scripts
experiment_path: experiments/support-agent-gate
# the dataset to run the experiment against
dataset_name: support-agent-regression-set
# GitHub token so that the action can comment on PRs
github_token: ${{ github.token }}Experiment definition
The action runs your experiment code from the experiment_path configured in the workflow.
Each script must define an experiment(context) function that accepts a context parameter.
This context is created by the GitHub Action and handles the CI-specific setup for you:
- initializes the Langfuse SDK client from the action inputs
- loads the dataset items from
dataset_nameand appliesdataset_version - adds default metadata under
langfuse.*, such as commit SHA, branch, job URL, and actor. These values are visible in the Langfuse UI.
Use context.runExperiment (JS/TS) or context.run_experiment (Python) to run the experiment with these defaults.
from langfuse import RunnerContext
from langfuse.api import DatasetItem
# Define a task that calls your agent with each dataset item.
def my_task(item: DatasetItem, **kwargs):
...
def experiment(context: RunnerContext):
return context.run_experiment(
name="PR gate",
task=my_task,
)import type { ExperimentTaskParams, RunnerContext } from "@langfuse/client";
// Define a task that calls your agent with each dataset item.
async function myTask(item: ExperimentTaskParams) {
// ...
}
export async function experiment(context: RunnerContext) {
return await context.runExperiment({
name: "PR gate",
task: myTask,
});
}Pass explicit values to context.runExperiment / context.run_experiment when you want to override action-provided defaults such as data or metadata.
Action inputs and outputs
| Input | Required | Description |
|---|---|---|
langfuse_public_key | Yes | Langfuse public key used by the SDK client. Store it as a GitHub secret. |
langfuse_secret_key | Yes | Langfuse secret key used by the SDK client. Store it as a GitHub secret. |
langfuse_base_url | No | Langfuse host. Defaults to https://cloud.langfuse.com; see regions and self-hosted URLs if you use another Langfuse instance. |
experiment_path | Yes | Path to an experiment script file, a directory containing experiment scripts, or a glob pattern. Supports Python, TypeScript, and JavaScript. |
dataset_name | No | Langfuse dataset loaded by the action and provided to the SDK via RunnerContext. If omitted, the script must provide its own data. |
dataset_version | No | Optional timestamp to pin the dataset version for reproducible CI runs. Defaults to the latest dataset version. |
experiment_metadata | No | Additional key=value metadata added to the experiment together with default GitHub metadata. This metadata is visible in the Langfuse UI. |
should_fail_on_regression | No | Fail the CI job when an experiment raises RegressionError. Defaults to true. |
should_fail_on_script_error | No | Fail the CI job when an experiment script crashes or raises a non-regression error. Defaults to true. |
should_comment_on_pr | No | Post or update the experiment report as a pull request comment. Defaults to true. |
python_sdk_version | No | Langfuse Python SDK version installed by the action for .py experiments. Defaults to latest; use v4.6.0 or newer. |
js_sdk_version | No | @langfuse/client version installed by the action for TypeScript or JavaScript experiments. Defaults to latest; use v5.3.0 or newer. |
should_skip_sdk_installation | No | Skip SDK installation when you manage the Python or Node environment yourself before this action. For TypeScript experiments, provide @langfuse/client, @langfuse/tracing, @langfuse/otel, @opentelemetry/sdk-node, and tsx yourself. Defaults to false. |
github_token | No | GitHub token used to post PR comments and resolve the current job URL. Leave blank to skip both. |
See the full input reference in the langfuse/experiment-action README.
| Output | Description |
|---|---|
result_json | Normalized JSON result for downstream workflow steps. |
failed | true if any experiment script errored or raised a regression; otherwise false. |
Failing on regressions
Raise RegressionError when a result should block the workflow. The example below fails when average exact-match accuracy is below the threshold.
from langfuse import Evaluation, RegressionError, RunnerContext
THRESHOLD = 0.95
def experiment(context: RunnerContext):
result = context.run_experiment(
name="PR gate: support agent",
task=answer_support_question,
evaluators=[exact_match],
run_evaluators=[avg_accuracy],
)
accuracy = next(
(
evaluation.value
for evaluation in result.run_evaluations
if evaluation.name == "avg_accuracy"
),
None,
)
if not isinstance(accuracy, (int, float)) or accuracy < THRESHOLD:
raise RegressionError(
# Attach the result so the action can include scores in the PR comment and `result_json` output.
result=result,
metric="avg_accuracy",
value=float(accuracy) if isinstance(accuracy, (int, float)) else 0.0,
threshold=THRESHOLD,
)
return result
def answer_support_question(item, **kwargs):
# Replace this stub with your application logic.
return item.input["question"]
def exact_match(*, output, expected_output, **kwargs):
passed = output.strip() == (expected_output or "").strip()
return Evaluation(
name="exact_match",
value=1.0 if passed else 0.0,
comment="match" if passed else "mismatch",
)
def avg_accuracy(*, item_results, **kwargs):
scores = [
evaluation.value
for item in item_results
for evaluation in item.evaluations
if evaluation.name == "exact_match" and isinstance(evaluation.value, (int, float))
]
return Evaluation(name="avg_accuracy", value=sum(scores) / len(scores) if scores else 0.0)import {
RegressionError,
type Evaluation,
type ExperimentTaskParams,
type RunnerContext,
} from "@langfuse/client";
const THRESHOLD = 0.95;
export async function experiment(context: RunnerContext) {
const result = await context.runExperiment({
name: "PR gate: support agent",
task: answerSupportQuestion,
evaluators: [exactMatch],
runEvaluators: [avgAccuracy],
});
const accuracy = result.runEvaluations.find(
(evaluation) => evaluation.name === "avg_accuracy",
)?.value;
if (typeof accuracy !== "number" || accuracy < THRESHOLD) {
throw new RegressionError({
// Attach the result so the action can include scores in the PR comment and `result_json` output.
result,
metric: "avg_accuracy",
value: typeof accuracy === "number" ? accuracy : 0,
threshold: THRESHOLD,
});
}
return result;
}
async function answerSupportQuestion(item: ExperimentTaskParams) {
const { question } = item.input as { question: string };
// Replace this with your application logic, for example calling your agent.
return await supportAgent(question);
}
async function supportAgent(question: string) {
return question;
}
async function exactMatch({
output,
expectedOutput,
}: {
output: string;
expectedOutput?: string;
}): Promise<Evaluation> {
const passed = output.trim() === expectedOutput?.trim();
return { name: "exact_match", value: passed ? 1 : 0 };
}
async function avgAccuracy({
itemResults,
}: {
itemResults: Array<{ evaluations: Evaluation[] }>;
}): Promise<Evaluation> {
const scores = itemResults
.flatMap((item) => item.evaluations)
.filter((evaluation) => evaluation.name === "exact_match")
.map((evaluation) => Number(evaluation.value))
.filter((score) => Number.isFinite(score));
return {
name: "avg_accuracy",
value: scores.length
? scores.reduce((sum, score) => sum + score, 0) / scores.length
: 0,
};
}Action output
When github_token is provided and the workflow has pull-requests: write, the action posts or updates a pull request comment with:
- pass, regression, or script-error status per experiment script
- run-level scores such as
avg_accuracy - a link to the GitHub Action run
- a link to the Langfuse experiment comparison view for dataset-backed runs
- a compact table of item outputs and item-level scores
The same normalized data is available as the result_json action output. Use this when a later workflow step needs to upload the result as an artifact, send a Slack notification, or feed another reporting system. The output schema is available in the langfuse/experiment-action repository.
- uses: langfuse/experiment-action@<release tag>
id: experiment
with:
# ...
- name: Store experiment result
if: always()
env:
RESULT_JSON: ${{ steps.experiment.outputs.result_json }}
run: printf '%s' "$RESULT_JSON" > experiment-result.jsonAdditional secrets
If your experiment needs provider keys or other secrets, set them as environment variables on the action step. The experiment subprocess inherits the step environment.
- uses: langfuse/experiment-action@<release tag>
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
with:
langfuse_public_key: ${{ secrets.LANGFUSE_PUBLIC_KEY }}
langfuse_secret_key: ${{ secrets.LANGFUSE_SECRET_KEY }}
experiment_path: experiments/support-agent-gate
dataset_name: support-agent-regression-setYour experiment can read these values from os.environ[...] in Python or process.env... in TypeScript and JavaScript. See the langfuse/experiment-action README for details.
Compare against an approved baseline
An aggregate threshold can miss a newly failing case. For example, a candidate that fixes two cases and breaks one has a higher average score, but the broken case may be a release blocker.
Keep a reviewed baseline artifact in your repository. Generate it from a complete run, review its outputs in Compare experiments, and approve changes through your normal code review. Store the dataset version, evaluator version, source run identity, and a pass/fail verdict for every case. Never generate the approved artifact automatically from the candidate being tested.
{
"run": "reviewed-release-run-id",
"dataset_version": "2026-07-01T00:00:00Z",
"evaluator_version": "refund-window-v1",
"cases": { "standard": true, "sale": false }
}The following helpers use the case_id metadata and refund_window evaluator from Evaluate an existing application. Replace the example timestamp and run ID with those from your reviewed run. Use the same timestamp to load the dataset for the candidate. Pass that timestamp to the helper after running the experiment; this checks that the gate is using the approved configuration.
import json
from datetime import datetime
from pathlib import Path
from langfuse import RegressionError
EVALUATOR_VERSION = "refund-window-v1"
def parse_version(value: str | datetime) -> datetime:
parsed = (
datetime.fromisoformat(value.replace("Z", "+00:00"))
if isinstance(value, str)
else value
)
if parsed.utcoffset() is None:
raise ValueError("Dataset version must include a timezone")
return parsed
def check_approved_baseline(result, *, dataset_version: str | datetime):
baseline = json.loads(Path("experiments/approved-baseline.json").read_text())
if (
parse_version(baseline["dataset_version"])
!= parse_version(dataset_version)
or baseline["evaluator_version"] != EVALUATOR_VERSION
):
raise ValueError(
"Dataset or evaluator version differs from the approved baseline"
)
approved = baseline["cases"]
if not approved or any(type(value) is not bool for value in approved.values()):
raise ValueError("Baseline must contain reviewed boolean verdicts")
current = {}
for row in result.item_results:
item = row.item
metadata = item.get("metadata") if isinstance(item, dict) else item.metadata
case_id = (metadata or {}).get("case_id")
scores = [e.value for e in row.evaluations if e.name == "refund_window"]
if (
not isinstance(case_id, str)
or case_id in current
or len(scores) != 1
or scores[0] not in (0, 1)
):
raise ValueError("Incomplete, duplicate, or invalid case result")
current[case_id] = scores[0] == 1
if current.keys() != approved.keys():
raise ValueError("Candidate and baseline contain different cases")
regressions = [
case_id for case_id in approved if approved[case_id] and not current[case_id]
]
if regressions:
print("Newly failing cases:", ", ".join(regressions))
raise RegressionError(
result=result,
metric="newly_failing_cases",
value=float(len(regressions)),
threshold=0.0,
)Call check_approved_baseline(result, dataset_version=version) from your experiment entry point before returning result. Here, version is the timezone-aware datetime or ISO timestamp used to fetch the candidate dataset. Increment EVALUATOR_VERSION whenever the grader changes and review a new baseline before using it.
import { readFileSync } from "node:fs";
import { RegressionError, type ExperimentResult } from "@langfuse/client";
const EVALUATOR_VERSION = "refund-window-v1";
export function checkApprovedBaseline(
result: ExperimentResult,
datasetVersion: string,
) {
const baseline = JSON.parse(
readFileSync("experiments/approved-baseline.json", "utf8"),
);
if (
!Number.isFinite(Date.parse(datasetVersion)) ||
Date.parse(baseline.dataset_version) !== Date.parse(datasetVersion) ||
baseline.evaluator_version !== EVALUATOR_VERSION
) {
throw new Error(
"Dataset or evaluator version differs from the approved baseline",
);
}
const approved = baseline.cases as Record<string, boolean>;
if (
!approved ||
Object.keys(approved).length === 0 ||
Object.values(approved).some((value) => typeof value !== "boolean")
) {
throw new Error("Baseline must contain reviewed boolean verdicts");
}
const current = new Map<string, boolean>();
for (const row of result.itemResults) {
const metadata = row.item.metadata as { case_id?: unknown } | undefined;
const caseId = metadata?.case_id;
const scores = row.evaluations.filter((e) => e.name === "refund_window");
if (
typeof caseId !== "string" ||
current.has(caseId) ||
scores.length !== 1 ||
(scores[0].value !== 0 && scores[0].value !== 1)
) {
throw new Error("Incomplete, duplicate, or invalid case result");
}
current.set(caseId, scores[0].value === 1);
}
if (
current.size !== Object.keys(approved).length ||
Object.keys(approved).some((id) => !current.has(id))
) {
throw new Error("Candidate and baseline contain different cases");
}
const regressions = Object.keys(approved).filter(
(id) => approved[id] && !current.get(id),
);
if (regressions.length) {
console.error("Newly failing cases:", regressions.join(", "));
throw new RegressionError({
result,
metric: "newly_failing_cases",
value: regressions.length,
threshold: 0,
});
}
}Call checkApprovedBaseline(result, version) from your experiment entry point before returning result. Here, version is the ISO timestamp used to fetch the candidate dataset. Increment EVALUATOR_VERSION whenever the grader changes and review a new baseline before using it.
This example treats every previously passing case as protected. If only a subset is release-critical, store that reviewed subset explicitly in the baseline policy. Keep aggregate thresholds as an additional check.
Missing evaluations, duplicate identifiers, and changed case sets raise script errors; newly failing cases raise RegressionError. Keep both should_fail_on_script_error and should_fail_on_regression enabled. A failed evaluator must not silently disappear from the denominator and make a run look better.
Other CI/CD systems
Keep your existing application and grader in Pytest or Vitest. These examples load local JSON cases, call your application, publish its outputs and check results to Langfuse, and require every case to pass. They do not require a hosted dataset or a separate LLM judge.
Configure Langfuse credentials and SDK dependencies. Replace the application and grader imports with your own modules. Here, run_application(input) / runApplication(input) returns a non-null output and grade(output, expected_output) returns a boolean. If your suite uses run_checks(), adapt its named checks into evaluator results and require every required check to be present and passing.
Save your cases in this format, keeping IDs stable between releases:
[
{
"id": "standard-refund",
"input": { "question": "What is the standard refund window?" },
"expected_output": { "refund_days": 30 }
}
]Set APP_VERSION to the application commit or release under test. The examples hash the case file and record a grader version; update that version whenever the grader changes. The application may make model calls according to its own configuration. To publish an already completed run without calling the application or grader again, see Publish saved check results.
import hashlib
import json
import os
from pathlib import Path
from langfuse import Evaluation, get_client
from my_app import run_application # Replace with your application's import.
from my_checks import grade # Reuse your existing boolean grader.
def test_application_checks():
case_file = Path("cases.json").read_bytes()
cases = json.loads(case_file)
ids = [case["id"] for case in cases]
assert ids and all(ids) and len(set(ids)) == len(ids), "Invalid case IDs"
def task(*, item, **kwargs):
return run_application(item["input"])
def evaluator(*, output, expected_output, **kwargs):
passed = grade(output, expected_output)
if type(passed) is not bool:
raise ValueError("grade must return a boolean")
return Evaluation(name="existing_checks", value=int(passed))
langfuse = get_client()
try:
result = langfuse.run_experiment(
name="Application checks",
data=[{
"input": case["input"],
"expected_output": case["expected_output"],
"metadata": {"case_id": case["id"]},
} for case in cases],
task=task,
evaluators=[evaluator],
metadata={
"application_version": os.environ["APP_VERSION"],
"cases_sha256": hashlib.sha256(case_file).hexdigest(),
"evaluator_version": "existing-checks-v1",
},
)
print(result.format()) # Includes the experiment link.
returned_ids = [item.item["metadata"]["case_id"] for item in result.item_results]
assert sorted(returned_ids) == sorted(ids), "Incomplete results"
for item in result.item_results:
assert item.output is not None, "Missing application output"
scores = [e for e in item.evaluations if e.name == "existing_checks"]
assert len(scores) == 1 and scores[0].value == 1, (
f"Failed or missing check: {item.item['metadata']['case_id']}"
)
finally:
langfuse.flush()Run pytest -s test_application_experiment.py with Pytest installed. The -s flag keeps the experiment link visible in CI logs.
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { it, expect } from "vitest";
import { LangfuseClient } from "@langfuse/client";
import { LangfuseSpanProcessor } from "@langfuse/otel";
import { NodeSDK } from "@opentelemetry/sdk-node";
import { runApplication } from "../src/app"; // Replace with your application's import.
import { grade } from "../src/checks"; // Reuse your existing boolean grader.
it("passes every required application check", async () => {
const caseFile = readFileSync("cases.json");
const cases: { id: string; input: unknown; expected_output: unknown }[] =
JSON.parse(caseFile.toString("utf8"));
const ids = cases.map((item) => item.id);
expect(ids.length).toBeGreaterThan(0);
expect(ids.every(Boolean)).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
const applicationVersion = process.env.APP_VERSION;
if (!applicationVersion) throw new Error("Set APP_VERSION");
const otel = new NodeSDK({ spanProcessors: [new LangfuseSpanProcessor()] });
otel.start();
try {
const langfuse = new LangfuseClient();
const result = await langfuse.experiment.run({
name: "Application checks",
data: cases.map((item) => ({
input: item.input,
expectedOutput: item.expected_output,
metadata: { case_id: item.id },
})),
task: async (item) => runApplication(item.input),
evaluators: [async ({ output, expectedOutput }) => {
const passed = await grade(output, expectedOutput);
if (typeof passed !== "boolean") throw new Error("grade must return a boolean");
return { name: "existing_checks", value: Number(passed) };
}],
metadata: {
application_version: applicationVersion,
cases_sha256: createHash("sha256").update(caseFile).digest("hex"),
evaluator_version: "existing-checks-v1",
},
});
console.log(await result.format()); // Includes the experiment link.
expect(result.itemResults.map((item) =>
(item.item.metadata as { case_id: string }).case_id,
).sort())
.toEqual([...ids].sort());
for (const item of result.itemResults) {
expect(item.output).not.toBeNull();
expect(item.output).not.toBeUndefined();
const scores = item.evaluations.filter((score) => score.name === "existing_checks");
expect(scores).toHaveLength(1);
expect(scores[0].value).toBe(1);
}
} finally {
await otel.shutdown();
}
}, 60_000); // Adjust for the runtime of your application and case set.Run npx vitest run test/application-experiment.test.ts with Vitest installed.
These assertions implement the every required case must pass policy. For a suite with explicitly accepted failures, use the approved-baseline gate instead. A test that expects the gate's assertion to throw would turn a real regression into a passing CI job.
Last updated on