|
| 1 | +# Score Production Traces |
| 2 | + |
| 3 | +Score **existing production traces** in an Opik project from your own pipeline — using |
| 4 | +the same judge/metric classes an [online evaluation rule](https://www.comet.com/docs/opik/production/rules) |
| 5 | +would use — and log the scores back onto those traces. For teams whose GenAI gateway |
| 6 | +is reachable **from their code but not yet from the Opik platform**: run evals on a cron |
| 7 | +now, then switch to platform online rules later with **no re-authoring** (same score |
| 8 | +names, same variable mapping). |
| 9 | + |
| 10 | +> **This is not `opik.evaluate()`.** `evaluate()` runs a *task* over a *dataset* to |
| 11 | +> generate new outputs and create an experiment (pre-production). This scores traces that |
| 12 | +> **already exist** and attaches feedback in place (post-production) — the job online |
| 13 | +> rules do. There is no single turnkey SDK call for that, so this example composes three |
| 14 | +> primitives: `search_traces` → `metric.score()` → `log_traces_feedback_scores`. |
| 15 | +
|
| 16 | +## Prerequisites |
| 17 | + |
| 18 | +- Python 3.12+ |
| 19 | +- [uv](https://docs.astral.sh/uv/) — this folder is a `uv` project; run `uv sync` |
| 20 | +- A GenAI gateway reachable from where you run this (for the LLM judges) |
| 21 | + |
| 22 | +## Environment Setup |
| 23 | + |
| 24 | +| Variable | Required | Default | Description | |
| 25 | +|---|---|---|---| |
| 26 | +| `OPIK_API_KEY` | for a live run | — | Opik API key (unset → DRY_RUN, no network) | |
| 27 | +| `OPIK_WORKSPACE` | for a live run | — | Opik workspace name | |
| 28 | +| `OPIK_URL_OVERRIDE` | self-hosted | `https://www.comet.com/opik/api` | Opik API base URL | |
| 29 | +| `OPIK_PROJECT_NAME` | no | `score-traces-example` | Project whose traces are scored | |
| 30 | +| `EVAL_WINDOW_HOURS` | no | `1` | How far back each run looks | |
| 31 | +| `EVAL_MAX_RESULTS` | no | `1000` | Max traces per run; warns on truncation | |
| 32 | +| `GATEWAY_BASE_URL` | for LLM judges | — | OpenAI-compatible gateway URL (Path A) | |
| 33 | +| `GATEWAY_API_KEY` | for LLM judges | — | Gateway API key | |
| 34 | +| `GATEWAY_MODEL` | no | `gpt-4o` | Judge model name (local knob) | |
| 35 | +| `OPIK_EXAMPLES_MODEL` | no | — | CI routes judges to a cheap model via this (used when `GATEWAY_MODEL` is unset) | |
| 36 | +| `OPENAI_API_KEY` | often | — | OpenAI provider key LiteLLM uses for the `openai/` judge route — set alongside `GATEWAY_*` if the judge errors on auth (see note below) | |
| 37 | + |
| 38 | +> **Judge auth (LiteLLM).** The judges run through LiteLLM, which routes the model as |
| 39 | +> `openai/$GATEWAY_MODEL`. Set your gateway via `GATEWAY_BASE_URL` + `GATEWAY_API_KEY`, and |
| 40 | +> **also set `OPENAI_API_KEY`** (to your gateway/OpenAI key) — the `openai/` route reads the |
| 41 | +> provider key from it and will error on auth without it, even when the `GATEWAY_*` vars are set. |
| 42 | +
|
| 43 | +### Credentials & security |
| 44 | + |
| 45 | +- **Never commit or hard-code `OPIK_API_KEY`** or gateway keys — keep them out of `run.sh`, |
| 46 | + source, and the committed `.env.example`. Inject them at runtime from your CI secret store |
| 47 | + or a secret manager; your real `.env` should stay git-ignored. |
| 48 | +- Prefer a **dedicated Opik service account** for scheduled/CI runs over a personal API key, |
| 49 | + so the job's access is scoped and can be revoked independently of any individual. |
| 50 | + |
| 51 | +### Running in a pipeline / on a schedule |
| 52 | + |
| 53 | +`OPIK_WORKSPACE` and `OPIK_PROJECT_NAME` are plain env vars — set them wherever the job runs: |
| 54 | +export them in your CI/cron pipeline, or edit `run.sh` (the CI entry point). With |
| 55 | +`OPIK_API_KEY`/`OPIK_WORKSPACE` unset the run falls back to DRY_RUN and exits 0 without |
| 56 | +touching the network, so CI stays green before credentials are wired in. |
| 57 | + |
| 58 | +## Workflow |
| 59 | + |
| 60 | +```mermaid |
| 61 | +flowchart LR |
| 62 | + A["search_traces<br/>(last N hours)"] --> B["for each trace × eval:<br/>resolve variables → metric.score()"] |
| 63 | + B --> C["log_traces_feedback_scores<br/>(one batched call, same names + reason)"] |
| 64 | + C --> D["scores visible on the<br/>same traces in the Opik UI"] |
| 65 | + subgraph later["when the gateway reaches Opik (Stage 1)"] |
| 66 | + E["same metrics.py"] --> F["create online rules<br/>(same names + variable mapping)"] |
| 67 | + end |
| 68 | + C -. same names & mapping .-> F |
| 69 | +``` |
| 70 | + |
| 71 | +### Step 0 — seed a test project (optional, no LLM) |
| 72 | + |
| 73 | +```bash |
| 74 | +export OPIK_API_KEY=... OPIK_WORKSPACE=... |
| 75 | +export OPIK_PROJECT_NAME=score-traces-example |
| 76 | +uv run python utils/seed_traces.py # ~10 hardcoded Q&A traces, good/bad mix |
| 77 | +``` |
| 78 | + |
| 79 | +### Step 1 — configure the judge model |
| 80 | + |
| 81 | +Edit `model.py`. Path A (default) points LiteLLM at an |
| 82 | +OpenAI-compatible gateway via `GATEWAY_BASE_URL` / `GATEWAY_API_KEY`. For a non-standard |
| 83 | +gateway, use Path B (a custom `OpikBaseModel` subclass). Both paths, with full worked |
| 84 | +examples, are documented here: |
| 85 | +<https://www.comet.com/docs/opik/evaluation/metrics/custom_model> |
| 86 | +([`LiteLLMChatModel`](https://www.comet.com/docs/opik/python-sdk-reference/Objects/LiteLLMChatModel.html) · |
| 87 | +[`OpikBaseModel`](https://www.comet.com/docs/opik/python-sdk-reference/Objects/OpikBaseModel.html)). |
| 88 | +Set `GATEWAY_BASE_URL` + `GATEWAY_API_KEY` + `OPENAI_API_KEY` (see the **Judge auth** note above). |
| 89 | + |
| 90 | +### Step 2 — run the evals |
| 91 | + |
| 92 | +```bash |
| 93 | +export EVAL_WINDOW_HOURS=1 |
| 94 | +uv run python score_traces.py # scores the last hour, logs feedback back |
| 95 | +``` |
| 96 | + |
| 97 | +Open the project in Opik — each trace now carries `hallucination`, `relevance`, and |
| 98 | +`exact_match` feedback scores, each with the judge's **reason**. |
| 99 | + |
| 100 | +## How it works (the primitives) |
| 101 | + |
| 102 | +The runner is nothing more than three public SDK primitives composed in a loop: |
| 103 | + |
| 104 | +**1. Pull existing traces** |
| 105 | + |
| 106 | +```python |
| 107 | +import opik |
| 108 | + |
| 109 | +client = opik.Opik(project_name="score-traces-example") |
| 110 | +traces = client.search_traces( |
| 111 | + project_name="score-traces-example", |
| 112 | + filter_string='start_time >= "2026-07-29T00:00:00Z"', |
| 113 | + max_results=1000, |
| 114 | +) |
| 115 | +trace = traces[0] |
| 116 | +print(trace.id, trace.input, trace.output) # input/output are dicts |
| 117 | +``` |
| 118 | + |
| 119 | +**2. Score one trace with a metric + variable mapping** |
| 120 | + |
| 121 | +```python |
| 122 | +from opik.evaluation.metrics import Hallucination |
| 123 | + |
| 124 | +judge = Hallucination(model="gpt-4o", name="hallucination") |
| 125 | + |
| 126 | +# variable mapping: metric score() param -> trace field path |
| 127 | +variables = {"input": "input.question", "output": "output.answer", "context": "output.context"} |
| 128 | +kwargs = { |
| 129 | + "input": trace.input["question"], |
| 130 | + "output": trace.output["answer"], |
| 131 | + "context": trace.output["context"], |
| 132 | +} |
| 133 | +result = judge.score(**kwargs) |
| 134 | +print(result.value, result.reason) |
| 135 | +``` |
| 136 | + |
| 137 | +**3. Write the score back onto the trace** |
| 138 | + |
| 139 | +```python |
| 140 | +client.log_traces_feedback_scores( |
| 141 | + [ |
| 142 | + {"id": trace.id, "name": "hallucination", "value": result.value, "reason": result.reason}, |
| 143 | + ] |
| 144 | +) |
| 145 | +``` |
| 146 | + |
| 147 | +**Put it together:** `score_traces.py` is exactly these three steps — for every eval in |
| 148 | +`EVALS`, over every trace in the window, with one batched write at the end. Schedule |
| 149 | +it on a cron (e.g. hourly with `EVAL_WINDOW_HOURS=1`). |
| 150 | + |
| 151 | +## Adding or changing evals |
| 152 | + |
| 153 | +Edit the `EVALS` list in `metrics.py`. Each `Eval` has a `name` (the feedback-score name), |
| 154 | +a live `metric` object, and a `variables` mapping (metric `score()` param → trace field |
| 155 | +path). Copy a block to add one. Three styles ship: |
| 156 | + |
| 157 | +| Eval | Type | Migrates to an online rule as | |
| 158 | +|---|---|---| |
| 159 | +| `hallucination` | built-in preset judge | the platform preset — same engine, **same score name** (strong, not a literal string copy) | |
| 160 | +| `relevance` | G-Eval custom judge | an `llm_as_judge` rule — criteria text + name map directly (**clean**) | |
| 161 | +| `exact_match` | Python metric (`metrics.py`) | a `user_defined_metric_python` rule — the exact source round-trips (**perfect**) | |
| 162 | + |
| 163 | +The `variables` mapping is the same **"variable mapping"** you set on a rule in the Opik UI — |
| 164 | +so when you migrate, the mapping and score names carry over verbatim and your dashboards |
| 165 | +don't change. G-Eval's `score()` evaluates a single labeled `output` string, not separate |
| 166 | +kwargs, so for `relevance` the runner composes that string from `variables` (e.g. |
| 167 | +`INPUT: ...\nOUTPUT: ...`) — the mapping still names the same trace fields an online G-Eval |
| 168 | +rule would map. |
| 169 | + |
| 170 | +## Roadmap (not in this example) |
| 171 | + |
| 172 | +- **Stage 1 — promote to online rules.** A small script reading the same `metrics.py` |
| 173 | + and creating the online rules (reuses |
| 174 | + [`scripts/online_eval_rules`](../online_eval_rules)). For multi-output custom judges it |
| 175 | + adds an output-schema hint. |
| 176 | +- **Stage 2 — productionize.** Config-driven CLI; **watermarking** (persist a last-processed |
| 177 | + timestamp instead of a fixed window); **span- and thread-scope** evaluation. A large |
| 178 | + custom Path B model can also move into its own `model_provider.py`. |
0 commit comments