EverOS/examples/langfuse/replay.py

267 lines
9.5 KiB
Python

"""Replay a recorded EverOS trace into your own Langfuse project.
No EverOS install and no model API keys: this pushes a trace that a real
EverOS server actually produced (``recorded_trace.json``, captured with
``record_trace.py``) into your Langfuse project, so you can see what the
integration looks like in your own UI before deciding to deploy anything.
It is a recording, not a live server. Span names, attributes, token usage,
parent/child structure and durations are EverOS's own output, replayed
verbatim. Three things are necessarily rewritten: trace/span ids are minted
fresh (so repeated runs do not collide), timestamps are shifted so the trace
lands at the current time, and the root spans get a ``replay`` tag so nobody
mistakes it for live traffic.
Usage::
pip install opentelemetry-sdk opentelemetry-exporter-otlp-proto-http
export LANGFUSE_PUBLIC_KEY="pk-lf-..."
export LANGFUSE_SECRET_KEY="sk-lf-..."
export LANGFUSE_HOST="https://cloud.langfuse.com" # US: https://us.cloud.langfuse.com
python replay.py
To trace your own EverOS server instead, see ``README.md`` — that needs no
replay at all, just ``[observability]`` in ``everos.toml``.
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import time
import urllib.error
import urllib.request
from collections import defaultdict
from typing import Any
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import BatchSpanProcessor
from opentelemetry.trace import (
Span,
SpanKind,
Status,
StatusCode,
set_span_in_context,
)
DEFAULT_HOST = "https://cloud.langfuse.com"
REPLAY_TAG = "replay"
SCORE_MAX_ATTEMPTS = 5
# Small gap between scores; cheaper than discovering the limiter one 429 at a time.
SCORE_PACE_SECONDS = 0.15
def _credentials() -> tuple[str, str]:
"""Langfuse OTLP endpoint + Basic auth header, from the standard env vars."""
public_key = os.environ.get("LANGFUSE_PUBLIC_KEY")
secret_key = os.environ.get("LANGFUSE_SECRET_KEY")
host = os.environ.get("LANGFUSE_HOST", DEFAULT_HOST).rstrip("/")
if not (public_key and secret_key):
sys.exit(
"LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY must be set "
"(project settings in Langfuse)."
)
token = base64.b64encode(f"{public_key}:{secret_key}".encode()).decode()
return host, f"Basic {token}"
def _check_credentials(host: str, auth: str) -> None:
"""Fail fast, and say why, before pushing a few hundred spans.
Langfuse keys are region-scoped, and the OTLP exporter only reports a
rejected export through the SDK's own logging, so a wrong host otherwise
looks like a successful run into an empty project.
"""
request = urllib.request.Request(
f"{host}/api/public/projects", headers={"Authorization": auth}
)
try:
with urllib.request.urlopen(request, timeout=20) as response:
response.read()
except urllib.error.HTTPError as exc:
if exc.code not in {401, 403}:
# Only auth is under test; any other response is the replay's problem.
return
other = (
"https://cloud.langfuse.com"
if "us." in host
else "https://us.cloud.langfuse.com"
)
sys.exit(
f"{host} rejected these keys ({exc.code}). Langfuse projects are "
f"region-scoped, so if the project lives in the other region set "
f"LANGFUSE_HOST={other} and try again."
)
except OSError:
return # unreachable host surfaces on the real export a moment later
def _span_kind(name: str) -> SpanKind:
bare = name.removeprefix("SPAN_KIND_")
if bare in {"", "UNSPECIFIED"}:
return SpanKind.INTERNAL
return SpanKind[bare]
def _status(record: dict[str, Any]) -> Status | None:
code = record.get("code", "STATUS_CODE_UNSET").removeprefix("STATUS_CODE_")
if code in {"", "UNSET"}:
return None
return Status(StatusCode[code], record.get("message") or None)
def _post_score(host: str, auth: str, payload: dict[str, Any]) -> None:
"""POST one score, backing off when Langfuse rate-limits the endpoint.
Scores go one per request, so replaying a whole recording sends dozens in a
row and reliably trips the limiter without this.
"""
request = urllib.request.Request(
f"{host}/api/public/scores",
data=json.dumps(payload).encode(),
headers={"content-type": "application/json", "Authorization": auth},
method="POST",
)
for attempt in range(SCORE_MAX_ATTEMPTS):
try:
with urllib.request.urlopen(request, timeout=20) as response:
response.read()
return
except urllib.error.HTTPError as exc:
retryable = exc.code == 429 or 500 <= exc.code < 600
if not retryable or attempt == SCORE_MAX_ATTEMPTS - 1:
raise
after = exc.headers.get("retry-after") if exc.headers else None
delay = float(after) if after and after.isdigit() else 2.0**attempt
time.sleep(delay)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--fixture", default="recorded_trace.json")
args = parser.parse_args()
host, auth = _credentials()
_check_credentials(host, auth)
try:
with open(args.fixture, encoding="utf-8") as handle:
fixture = json.load(handle)
except FileNotFoundError:
sys.exit(
f"{args.fixture} not found. Fetch it next to this script from "
"https://github.com/EverMind-AI/EverOS/tree/main/examples/langfuse"
)
spans: list[dict[str, Any]] = fixture["spans"]
if not spans:
sys.exit(f"{args.fixture} contains no spans.")
provider = TracerProvider(resource=Resource.create(fixture.get("resource") or {}))
provider.add_span_processor(
BatchSpanProcessor(
OTLPSpanExporter(
endpoint=f"{host}/api/public/otel/v1/traces",
headers={"Authorization": auth},
)
)
)
tracer = provider.get_tracer("everos.replay")
# Land the recording at "now", preserving every relative duration.
offset = time.time_ns() - min(span["start_unix_nano"] for span in spans)
by_id = {span["span_id"]: span for span in spans}
children: dict[str, list[dict[str, Any]]] = defaultdict(list)
roots: list[dict[str, Any]] = []
for span in spans:
parent = span["parent_span_id"]
if parent and parent in by_id:
children[parent].append(span)
else:
roots.append(span)
# old span id -> (new trace id hex, new span id hex), for remapping scores.
remapped: dict[str, tuple[str, str]] = {}
trace_remap: dict[str, str] = {}
def emit(record: dict[str, Any], parent: Span | None) -> None:
attributes = dict(record["attributes"])
if parent is None:
tags = attributes.get("langfuse.trace.tags")
tags = list(tags) if isinstance(tags, list) else []
if REPLAY_TAG not in tags:
tags.append(REPLAY_TAG)
attributes["langfuse.trace.tags"] = tags
recorded_at = fixture.get("recorded_at")
if recorded_at:
attributes["langfuse.trace.metadata.replay_of"] = recorded_at
span = tracer.start_span(
record["name"],
context=set_span_in_context(parent) if parent is not None else None,
kind=_span_kind(record["kind"]),
start_time=record["start_unix_nano"] + offset,
attributes=attributes,
)
context = span.get_span_context()
remapped[record["span_id"]] = (
format(context.trace_id, "032x"),
format(context.span_id, "016x"),
)
trace_remap.setdefault(record["trace_id"], format(context.trace_id, "032x"))
for child in children[record["span_id"]]:
emit(child, span)
status = _status(record["status"])
if status is not None:
span.set_status(status)
span.end(end_time=record["end_unix_nano"] + offset)
for root in roots:
emit(root, None)
provider.force_flush()
provider.shutdown()
print(f"Replayed {len(spans)} span(s) in {len(roots)} trace(s) to {host}")
sent = 0
skipped = 0
for score in fixture.get("scores", []):
payload = dict(score)
observation = score.get("observationId")
if observation and observation in remapped:
trace_id, span_id = remapped[observation]
payload["traceId"] = trace_id
payload["observationId"] = span_id
elif score.get("traceId") in trace_remap:
payload["traceId"] = trace_remap[score["traceId"]]
payload.pop("observationId", None)
else:
skipped += 1
continue
try:
_post_score(host, auth, payload)
sent += 1
except urllib.error.HTTPError as exc:
print(f" ! score {score.get('name')} rejected: {exc}", file=sys.stderr)
time.sleep(SCORE_PACE_SECONDS)
if sent or skipped:
note = f", {skipped} unmapped" if skipped else ""
print(f"Pushed {sent} recall score(s){note}")
print(
"\nOpen Langfuse -> Tracing and filter on the 'replay' tag. "
"This is a recorded EverOS run, not a live server: "
"see README.md to trace your own."
)
if __name__ == "__main__":
main()