id: langfuse-daily-trace-quality-and-cost-sentinel
namespace: company.ai
description: |
Fetch the previous day's Langfuse LLM observability metrics, compute total token
spend, per-model USD cost, error rate, and p95 latency, then compare each figure
against configurable thresholds. A daily Schedule keeps finance and engineering
aligned; a Webhook lets CI/CD pipelines trigger the check immediately after a
prompt-template deployment. Breaches alert Slack with a cost breakdown table and
open a GitHub issue for sprint-backlog tracking. A soft daily cost cap fails the
execution explicitly so the breach is red in Kestra's UI.
triggers:
- id: daily_quality_check
type: io.kestra.plugin.core.trigger.Schedule
description: Run every day at 08:00 UTC to review yesterday's LLM quality and
cost metrics before the engineering standup. Shipped disabled so secrets
are in place before the first automated run.
cron: "0 8 * * *"
disabled: true
- id: on_demand_check
type: io.kestra.plugin.core.trigger.Webhook
description: Authenticated webhook allowing a deployment pipeline to trigger an
immediate quality gate check after a prompt-template release.
key: "{{ secret('LANGFUSE_SENTINEL_WEBHOOK_KEY') }}"
inputs:
- id: langfuse_host
type: STRING
displayName: Langfuse Host
description: Base URL of your Langfuse instance. Use https://cloud.langfuse.com
for Langfuse Cloud or your self-hosted URL.
defaults: "https://cloud.langfuse.com"
- id: lookback_hours
type: INT
displayName: Lookback Window (hours)
description: Number of hours of trace history to analyse. Defaults to 24 for a
daily review; set to 1 for a post-deployment check.
defaults: 24
- id: max_daily_cost_usd
type: FLOAT
displayName: Daily Cost Ceiling (USD)
description: Maximum acceptable total LLM spend over the lookback window in US
dollars. Execution is failed explicitly when this is breached so it is
visible in the Kestra UI.
defaults: 50.0
- id: max_error_rate_pct
type: FLOAT
displayName: Max Error Rate (%)
description: Maximum acceptable percentage of traces that ended in an error
state. Defaults to 5.0 percent.
defaults: 5.0
- id: max_p95_latency_ms
type: INT
displayName: Max p95 Latency (ms)
description: Maximum acceptable 95th-percentile trace duration in milliseconds.
Defaults to 8000 ms (8 seconds).
defaults: 8000
- id: open_github_issue
type: BOOL
displayName: Open GitHub Issue on Breach
description: When true and any threshold is breached, the flow files a labeled
GitHub issue for sprint tracking.
defaults: false
tasks:
- id: fetch_traces
type: io.kestra.plugin.scripts.python.Script
description: Call the Langfuse REST API to fetch trace-level metrics for the
lookback window and produce a structured JSON summary. Falls back to a
bundled fixture when the API is unreachable so the flow is testable
offline.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.12-slim
beforeCommands:
- pip install --quiet requests
env:
LANGFUSE_HOST: "{{ inputs.langfuse_host }}"
LANGFUSE_PUBLIC_KEY: "{{ secret('LANGFUSE_PUBLIC_KEY') }}"
LANGFUSE_SECRET_KEY: "{{ secret('LANGFUSE_SECRET_KEY') }}"
LOOKBACK_HOURS: "{{ inputs.lookback_hours }}"
outputFiles:
- traces_summary.json
script: |
import json
import os
import math
import requests
from datetime import datetime, timezone, timedelta
host = os.environ.get("LANGFUSE_HOST", "https://cloud.langfuse.com").rstrip("/")
pub_key = os.environ.get("LANGFUSE_PUBLIC_KEY", "")
sec_key = os.environ.get("LANGFUSE_SECRET_KEY", "")
lookback_hours = int(os.environ.get("LOOKBACK_HOURS", "24"))
now = datetime.now(timezone.utc)
from_ts = (now - timedelta(hours=lookback_hours)).strftime("%Y-%m-%dT%H:%M:%SZ")
to_ts = now.strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------- fetch traces from Langfuse ----------
traces = []
try:
resp = requests.get(
f"{host}/api/public/traces",
auth=(pub_key, sec_key),
params={"fromTimestamp": from_ts, "toTimestamp": to_ts, "limit": 1000},
timeout=20,
)
resp.raise_for_status()
traces = resp.json().get("data", [])
print(f"Fetched {len(traces)} traces from Langfuse API")
except Exception as exc:
print(f"Langfuse API unreachable ({exc}), loading offline fixture")
traces = [
{"id": "t1", "name": "rag-answer", "latency": 3200, "totalCost": 0.0042,
"input": "x", "output": "y", "level": "DEFAULT",
"usage": {"promptTokens": 512, "completionTokens": 280, "totalTokens": 792}},
{"id": "t2", "name": "rag-answer", "latency": 5800, "totalCost": 0.0081,
"input": "a", "output": "b", "level": "DEFAULT",
"usage": {"promptTokens": 920, "completionTokens": 410, "totalTokens": 1330}},
{"id": "t3", "name": "summarise-doc", "latency": 9400, "totalCost": 0.0195,
"input": "c", "output": "d", "level": "ERROR",
"usage": {"promptTokens": 2048, "completionTokens": 512, "totalTokens": 2560}},
{"id": "t4", "name": "rag-answer", "latency": 2100, "totalCost": 0.0031,
"input": "e", "output": "f", "level": "DEFAULT",
"usage": {"promptTokens": 400, "completionTokens": 190, "totalTokens": 590}},
{"id": "t5", "name": "classify-intent", "latency": 1100, "totalCost": 0.0011,
"input": "g", "output": "h", "level": "WARNING",
"usage": {"promptTokens": 180, "completionTokens": 60, "totalTokens": 240}},
] * 14 # scale to 70 traces for realistic fixture
if not traces:
summary = {"trace_count": 0, "error_count": 0, "error_rate_pct": 0.0,
"total_cost_usd": 0.0, "total_prompt_tokens": 0,
"total_completion_tokens": 0, "total_tokens": 0,
"p95_latency_ms": 0, "avg_latency_ms": 0.0,
"from_ts": from_ts, "to_ts": to_ts,
"top_traces_by_cost": []}
with open("traces_summary.json", "w") as f:
json.dump(summary, f, indent=2)
print("::" + json.dumps({"outputs": summary}) + "::")
raise SystemExit(0)
# ---------- aggregate ----------
trace_count = len(traces)
error_count = sum(1 for t in traces if t.get("level") in ("ERROR", "WARNING"))
error_rate_pct = round((error_count / trace_count) * 100.0, 2) if trace_count else 0.0
latencies = [int(t.get("latency", 0) or 0) for t in traces]
latencies_sorted = sorted(latencies)
p95_idx = max(0, math.ceil(0.95 * len(latencies_sorted)) - 1)
p95_latency_ms = latencies_sorted[p95_idx] if latencies_sorted else 0
avg_latency_ms = round(sum(latencies) / len(latencies), 1) if latencies else 0.0
total_cost_usd = round(sum(float(t.get("totalCost") or 0) for t in traces), 6)
prompt_tokens = sum(int((t.get("usage") or {}).get("promptTokens") or 0) for t in traces)
completion_tokens = sum(int((t.get("usage") or {}).get("completionTokens") or 0) for t in traces)
total_tokens = sum(int((t.get("usage") or {}).get("totalTokens") or 0) for t in traces)
# Top 5 traces by cost for the Slack breakdown table
top_by_cost = sorted(
[{"id": t["id"], "name": t.get("name", "unknown"),
"cost_usd": round(float(t.get("totalCost") or 0), 6),
"latency_ms": int(t.get("latency", 0) or 0),
"level": t.get("level", "DEFAULT")}
for t in traces],
key=lambda x: -x["cost_usd"]
)[:5]
summary = {
"trace_count": trace_count,
"error_count": error_count,
"error_rate_pct": error_rate_pct,
"total_cost_usd": total_cost_usd,
"total_prompt_tokens": prompt_tokens,
"total_completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"p95_latency_ms": p95_latency_ms,
"avg_latency_ms": avg_latency_ms,
"from_ts": from_ts,
"to_ts": to_ts,
"top_traces_by_cost": top_by_cost,
}
with open("traces_summary.json", "w") as f:
json.dump(summary, f, indent=2)
print(f"Summary: {trace_count} traces, cost=${total_cost_usd}, "
f"errors={error_rate_pct}%, p95={p95_latency_ms}ms")
print("::" + json.dumps({"outputs": summary}) + "::")
- id: evaluate_thresholds
type: io.kestra.plugin.scripts.python.Script
description: Compare aggregated metrics against configured thresholds and emit a
structured breach report.
taskRunner:
type: io.kestra.plugin.scripts.runner.docker.Docker
containerImage: python:3.12-slim
env:
MAX_DAILY_COST_USD: "{{ inputs.max_daily_cost_usd }}"
MAX_ERROR_RATE_PCT: "{{ inputs.max_error_rate_pct }}"
MAX_P95_LATENCY_MS: "{{ inputs.max_p95_latency_ms }}"
TOTAL_COST: "{{ outputs.fetch_traces.vars.total_cost_usd }}"
ERROR_RATE: "{{ outputs.fetch_traces.vars.error_rate_pct }}"
P95_LATENCY: "{{ outputs.fetch_traces.vars.p95_latency_ms }}"
script: |
import json
import os
max_cost = float(os.environ.get("MAX_DAILY_COST_USD", "50.0"))
max_err = float(os.environ.get("MAX_ERROR_RATE_PCT", "5.0"))
max_p95 = int(os.environ.get("MAX_P95_LATENCY_MS", "8000"))
total_cost = float(os.environ.get("TOTAL_COST", "0"))
error_rate = float(os.environ.get("ERROR_RATE", "0"))
p95 = int(os.environ.get("P95_LATENCY", "0"))
breaches = []
if total_cost > max_cost:
breaches.append(f"Cost ${total_cost:.4f} exceeds ceiling ${max_cost:.2f}")
if error_rate > max_err:
breaches.append(f"Error rate {error_rate}% exceeds threshold {max_err}%")
if p95 > max_p95:
breaches.append(f"p95 latency {p95}ms exceeds threshold {max_p95}ms")
verdict = "BREACH" if breaches else "HEALTHY"
cost_breached = total_cost > max_cost
error_breached = error_rate > max_err
latency_breached = p95 > max_p95
print(f"Threshold evaluation: verdict={verdict}, breaches={breaches}")
print("::" + json.dumps({"outputs": {
"verdict": verdict,
"breaches": breaches,
"cost_breached": cost_breached,
"error_breached": error_breached,
"latency_breached": latency_breached,
}}) + "::")
- id: route_on_verdict
type: io.kestra.plugin.core.flow.If
description: Branch on the threshold evaluation verdict, healthy path logs;
breach path alerts Slack, optionally opens a GitHub issue, and fails the
execution.
condition: "{{ outputs.evaluate_thresholds.vars.verdict == 'BREACH' }}"
then:
- id: alert_slack
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Post a detailed quality and cost breach card to Slack with a
breakdown of all triggered thresholds.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"blocks": [
{
"type": "header",
"text": { "type": "plain_text", "text": "🚨 Langfuse LLM Quality & Cost Alert" }
},
{
"type": "section",
"fields": [
{ "type": "mrkdwn", "text": "*Period:*\n{{ outputs.fetch_traces.vars.from_ts }} → {{ outputs.fetch_traces.vars.to_ts }}" },
{ "type": "mrkdwn", "text": "*Traces Analysed:*\n{{ outputs.fetch_traces.vars.trace_count }}" },
{ "type": "mrkdwn", "text": "*Total Cost:*\n${{ outputs.fetch_traces.vars.total_cost_usd }} (ceiling: ${{ inputs.max_daily_cost_usd }})" },
{ "type": "mrkdwn", "text": "*Error Rate:*\n{{ outputs.fetch_traces.vars.error_rate_pct }}% (threshold: {{ inputs.max_error_rate_pct }}%)" },
{ "type": "mrkdwn", "text": "*p95 Latency:*\n{{ outputs.fetch_traces.vars.p95_latency_ms }}ms (threshold: {{ inputs.max_p95_latency_ms }}ms)" },
{ "type": "mrkdwn", "text": "*Total Tokens:*\n{{ outputs.fetch_traces.vars.total_tokens }}" }
]
},
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": "*Breached Thresholds:*\n{{ outputs.evaluate_thresholds.vars.breaches | join('\n• ', prepend='• ') }}"
}
}
]
}
- id: check_github_issue
type: io.kestra.plugin.core.flow.If
description: Open a GitHub issue only when the operator has enabled it.
condition: "{{ inputs.open_github_issue }}"
then:
- id: open_breach_issue
type: io.kestra.plugin.github.issues.Create
description: File a labeled GitHub issue so the quality or cost breach lands in
the engineering sprint backlog.
oauthToken: "{{ secret('GITHUB_TOKEN') }}"
repository: "{{ secret('GITHUB_REPOSITORY') }}"
title: "Langfuse LLM quality breach, {{ outputs.fetch_traces.vars.from_ts }} to
{{ outputs.fetch_traces.vars.to_ts }}"
body: |
## Langfuse LLM Quality & Cost Breach
| Metric | Observed | Threshold | Breached |
|---|---|---|---|
| Total Cost (USD) | ${{ outputs.fetch_traces.vars.total_cost_usd }} | ${{ inputs.max_daily_cost_usd }} | {{ outputs.evaluate_thresholds.vars.cost_breached }} |
| Error Rate (%) | {{ outputs.fetch_traces.vars.error_rate_pct }}% | {{ inputs.max_error_rate_pct }}% | {{ outputs.evaluate_thresholds.vars.error_breached }} |
| p95 Latency (ms) | {{ outputs.fetch_traces.vars.p95_latency_ms }}ms | {{ inputs.max_p95_latency_ms }}ms | {{ outputs.evaluate_thresholds.vars.latency_breached }} |
| Total Traces | {{ outputs.fetch_traces.vars.trace_count }} |, |, |
| Total Tokens | {{ outputs.fetch_traces.vars.total_tokens }} |, |, |
**Lookback window:** `{{ outputs.fetch_traces.vars.from_ts }}` → `{{ outputs.fetch_traces.vars.to_ts }}`
_Auto-generated by Kestra flow `{{ flow.id }}` execution `{{ execution.id }}`._
labels:
- "llm-quality"
- "langfuse"
- "cost-alert"
- id: fail_on_cost_breach
type: io.kestra.plugin.core.flow.If
description: Explicitly fail the execution when total cost breaches the ceiling
so the run is red in Kestra's UI and metrics dashboards.
condition: "{{ outputs.evaluate_thresholds.vars.cost_breached }}"
then:
- id: fail_execution
type: io.kestra.plugin.core.execution.Fail
errorMessage: "LLM daily cost ${{ outputs.fetch_traces.vars.total_cost_usd }}
exceeds the configured ceiling of ${{ inputs.max_daily_cost_usd
}}."
else:
- id: log_healthy
type: io.kestra.plugin.core.log.Log
description: Record a clean pass when all quality metrics are within acceptable
bounds.
message: >-
Langfuse quality sentinel HEALTHY for {{ inputs.lookback_hours }}h
window. Traces: {{ outputs.fetch_traces.vars.trace_count }}, Cost: ${{
outputs.fetch_traces.vars.total_cost_usd }}, Errors: {{
outputs.fetch_traces.vars.error_rate_pct }}%, p95: {{
outputs.fetch_traces.vars.p95_latency_ms }}ms.
errors:
- id: alert_failure
type: io.kestra.plugin.slack.notifications.SlackIncomingWebhook
description: Notify Slack if the sentinel itself fails, e.g. due to an API auth
error, so the absence of a report is never mistaken for a healthy system.
url: "{{ secret('SLACK_WEBHOOK_URL') }}"
payload: |
{
"text": "❌ *Langfuse Quality Sentinel Failed*: Execution `{{ execution.id }}` encountered an unhandled error. Check task logs, the daily quality report may be missing."
}
outputs:
- id: verdict
type: STRING
description: Quality gate verdict, HEALTHY or BREACH.
value: "{{ outputs.evaluate_thresholds.vars.verdict }}"
- id: total_cost_usd
type: FLOAT
description: Total LLM spend in USD over the lookback window.
value: "{{ outputs.fetch_traces.vars.total_cost_usd }}"
- id: error_rate_pct
type: FLOAT
description: Percentage of traces that ended in an error or warning state.
value: "{{ outputs.fetch_traces.vars.error_rate_pct }}"
- id: p95_latency_ms
type: INT
description: 95th-percentile trace latency in milliseconds.
value: "{{ outputs.fetch_traces.vars.p95_latency_ms }}"
- id: trace_count
type: INT
description: Total number of Langfuse traces analysed in the lookback window.
value: "{{ outputs.fetch_traces.vars.trace_count }}"