Skip to content

Commit b03b89e

Browse files
authored
Claude Code Evaluation Example (#6)
Closes: elastic/obs-ai-team#642 ### Summary - Added Claude Code evaluation suite - Asking Claude questions about this codebase - Sample evaluators for tool use, criteria, latency - Instrumenting Claude Code with OpenTelemetry and exporting via EDOT collector - Capturing task traces and visualizing using Evals plugin in Kibana <img width="1477" height="833" alt="image" src="https://github.com/user-attachments/assets/dc696ea6-b663-4c8b-ba32-fda7f1cfaa07" /> ### Testing ```bash CONNECTOR_ID="azure-gpt4_1" \ KIBANA_URL="http://elastic:changeme@host.docker.internal:5601/dev" \ EDOT_ENDPOINT="http://kibana-edot-collector:4318" \ ELASTIC_EVALS_TRACING_ENDPOINT="http://kibana-edot-collector:4318/v1/traces" \ TRACE_ES_URL="http://elastic:changeme@host.docker.internal:9200" \ uv run elastic-evals run --suite claude-code-eval ```
1 parent f56a44d commit b03b89e

17 files changed

Lines changed: 659 additions & 10 deletions

File tree

.devcontainer/devcontainer.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"image": "mcr.microsoft.com/devcontainers/base:ubuntu",
3+
"features": {
4+
"ghcr.io/devcontainers/features/node:1": {},
5+
"ghcr.io/devcontainers/features/python:1": {
6+
"version": "3.13"
7+
},
8+
"ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {},
9+
"ghcr.io/jsburckhardt/devcontainer-features/uv:1": {},
10+
"ghcr.io/jsburckhardt/devcontainer-features/ruff:1": {}
11+
}
12+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
# Claude Code Eval
2+
3+
Evaluates the Claude Code CLI as a task target. Each dataset example is a prompt sent
4+
to `claude --print --output-format stream-json`. The eval harness captures:
5+
6+
- **Latency** — wall-clock time for the subprocess to complete
7+
- **Tool use** — which tools Claude Code actually called vs. expected
8+
- **Response quality** — optional LLM criteria scoring (requires a Kibana connector)
9+
10+
Claude Code's own telemetry (metrics + logs) is forwarded to a local EDOT collector
11+
so you can observe both sides — the eval harness and Claude Code itself — in Kibana APM.
12+
13+
## Prerequisites
14+
15+
1. Running Elasticsearch and Kibana (local stack is fine).
16+
2. EDOT collector listening at `http://localhost:4318` (HTTP/protobuf).
17+
3. `claude` CLI installed and authenticated (`claude --version`).
18+
4. A Kibana LLM connector for dataset storage and optional criteria scoring.
19+
20+
## Run the eval
21+
22+
```bash
23+
CONNECTOR_ID="your-connector-id" \
24+
KIBANA_URL="http://elastic:changeme@localhost:5601" \
25+
EDOT_ENDPOINT="http://localhost:4318" \
26+
uv run elastic-evals run --suite claude-code-eval
27+
```
28+
29+
Or run directly:
30+
31+
```bash
32+
CONNECTOR_ID="azure-gpt4_1" \
33+
KIBANA_URL="http://elastic:changeme@host.docker.internal:5601/dev" \
34+
EDOT_ENDPOINT="http://kibana-edot-collector:4318" \
35+
ELASTIC_EVALS_TRACING_ENDPOINT="http://kibana-edot-collector:4318/v1/traces" \
36+
TRACE_ES_URL="http://elastic:changeme@host.docker.internal:9200" \
37+
uv run elastic-evals run --suite claude-code-eval
38+
```
39+
40+
## Telemetry flow
41+
42+
```
43+
elastic-evals harness
44+
│ spans → OTLP/HTTP → localhost:4318 (EDOT)
45+
46+
└─ spawns: claude --print --output-format stream-json
47+
│ metrics → OTLP/HTTP → localhost:4318 (EDOT)
48+
│ logs → OTLP/HTTP → localhost:4318 (EDOT)
49+
└─ (spans if trace propagation works — see below)
50+
```
51+
52+
Both services share the same `elastic.evals.run_id` resource attribute, so you can
53+
filter by run ID in APM / Discover to correlate all signals from a single eval run.
54+
55+
## Trace propagation
56+
57+
The harness injects the current OTel span context as `TRACEPARENT` and `TRACESTATE`
58+
environment variables before spawning each Claude Code subprocess.
59+
60+
**Current status**: Claude Code's Node.js OTel SDK reads trace context from HTTP headers,
61+
not from environment variables, so strict parent-child linking does not work out of the box.
62+
The `TRACEPARENT` env var is set anyway — if a future Claude Code release adds an env-var
63+
propagator, spans will automatically appear as children in the eval trace.
64+
65+
**What works today**: correlation via `elastic.evals.run_id`. In APM, filter for:
66+
67+
```
68+
resource.attributes.elastic.evals.run_id: "<your-run-id>"
69+
```
70+
71+
This surfaces spans from both `elastic-evals` and `claude-code` services for the same run.
72+
73+
## Evaluators
74+
75+
| Name | Kind | What it scores |
76+
|------|------|----------------|
77+
| `Latency` | CODE | Wall-clock ms → FAST / GOOD / OK / SLOW / VERY_SLOW |
78+
| `ToolUse` | CODE | Overlap between expected and actual tool names |
79+
| `criteria` | LLM | Per-example criteria from dataset metadata (optional) |
80+
81+
## Tuning
82+
83+
| Env var | Default | Purpose |
84+
|---------|---------|---------|
85+
| `EDOT_ENDPOINT` | `http://localhost:4318` | OTLP endpoint for both harness and Claude Code |
86+
| `ELASTIC_EVALS_REPETITIONS` | `1` | Number of times to repeat each example |
87+
| `ELASTIC_EVALS_CONCURRENCY` | `5` | Parallel task slots |
88+
| `EVALUATION_CONNECTOR_ID` || Separate connector for LLM scoring |

examples/claude_code_eval/__init__.py

Whitespace-only changes.

examples/claude_code_eval/datasets/__init__.py

Whitespace-only changes.
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Coding tasks dataset for Claude Code eval."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from elastic_evals.types import EvaluationDataset, Example
8+
9+
CodingTaskExample = Example[dict[str, str], None, dict[str, Any]]
10+
11+
coding_tasks_dataset: EvaluationDataset[CodingTaskExample] = EvaluationDataset(
12+
name="claude-code-eval: coding-tasks",
13+
description="Tasks that exercise Claude Code's code generation and tool use capabilities.",
14+
examples=[
15+
Example(
16+
input={
17+
"prompt": (
18+
"Write a Python function called `validate_email` that checks whether a "
19+
"string is a valid email address using only the standard library. "
20+
"Use type hints and return True if valid, False otherwise."
21+
)
22+
},
23+
output=None,
24+
metadata={
25+
"expected_tools": [],
26+
"task_type": "code_generation",
27+
"criteria": [
28+
"The response includes a Python function named validate_email",
29+
"The function uses type hints (str -> bool or similar)",
30+
"The function uses only standard library modules",
31+
"The function handles basic valid and invalid email formats",
32+
],
33+
},
34+
),
35+
Example(
36+
input={
37+
"prompt": (
38+
"List the top-level Python files in the current working directory "
39+
"and briefly describe the purpose of each one based on its content."
40+
)
41+
},
42+
output=None,
43+
metadata={
44+
"expected_tools": ["bash", "read_file"],
45+
"task_type": "file_exploration",
46+
"criteria": [
47+
"The response lists Python files found in the current directory",
48+
"The response describes the purpose of at least one file",
49+
"The response reads or inspects file contents rather than guessing",
50+
],
51+
},
52+
),
53+
Example(
54+
input={
55+
"prompt": (
56+
"Read the pyproject.toml in the current directory and output a "
57+
"Markdown table with two columns — Dependency and Min Version — "
58+
"for all entries under [project.dependencies]."
59+
)
60+
},
61+
output=None,
62+
metadata={
63+
"expected_tools": ["read_file"],
64+
"task_type": "file_reading",
65+
"criteria": [
66+
"The response contains a Markdown table",
67+
"The table has columns for Dependency and Min Version",
68+
"The table includes pydantic, opentelemetry entries",
69+
"The data is read from the actual file, not fabricated",
70+
],
71+
},
72+
),
73+
],
74+
)

examples/claude_code_eval/evaluators/__init__.py

Whitespace-only changes.
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Latency evaluator for Claude Code eval."""
2+
3+
from __future__ import annotations
4+
5+
from elastic_evals.evaluators.base import SimpleEvaluator
6+
from elastic_evals.types import EvaluationResult, Evaluator, EvaluatorParams
7+
8+
# (upper bound ms, score, label)
9+
_THRESHOLDS: list[tuple[int, float, str]] = [
10+
(5_000, 1.0, "FAST"),
11+
(15_000, 0.8, "GOOD"),
12+
(30_000, 0.6, "OK"),
13+
(60_000, 0.4, "SLOW"),
14+
]
15+
16+
17+
def create_latency_evaluator() -> Evaluator:
18+
async def evaluate(params: EvaluatorParams) -> EvaluationResult:
19+
output = params.output or {}
20+
latency_ms: float = output.get("latency_ms", float("inf"))
21+
22+
score = 0.2
23+
label = "VERY_SLOW"
24+
for threshold, s, lbl in _THRESHOLDS:
25+
if latency_ms <= threshold:
26+
score = s
27+
label = lbl
28+
break
29+
30+
return EvaluationResult(
31+
score=score,
32+
label=label,
33+
metadata={
34+
"latency_ms": latency_ms,
35+
"claude_duration_ms": output.get("claude_duration_ms"),
36+
"num_turns": output.get("num_turns"),
37+
},
38+
)
39+
40+
return SimpleEvaluator(name="Latency", kind="CODE", evaluate=evaluate)
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""Tool use evaluator for Claude Code eval."""
2+
3+
from __future__ import annotations
4+
5+
from typing import Any
6+
7+
from elastic_evals.evaluators.base import SimpleEvaluator
8+
from elastic_evals.types import EvaluationResult, Evaluator, EvaluatorParams
9+
10+
11+
def _to_string_list(value: Any) -> list[str]:
12+
if not isinstance(value, list):
13+
return []
14+
return [item for item in value if isinstance(item, str)]
15+
16+
17+
def create_tool_use_evaluator() -> Evaluator:
18+
"""Score whether Claude Code used the expected tools.
19+
20+
- expected_tools empty → PASS (1.0) when no tools were used; FAIL otherwise
21+
- expected_tools set → partial credit: overlap / len(expected)
22+
"""
23+
24+
async def evaluate(params: EvaluatorParams) -> EvaluationResult:
25+
output = params.output or {}
26+
metadata = params.metadata or {}
27+
28+
expected_tools = _to_string_list(metadata.get("expected_tools", []))
29+
actual_tools = _to_string_list(output.get("tool_calls", []))
30+
actual_set = set(actual_tools)
31+
32+
if not expected_tools:
33+
score = 1.0 if not actual_set else 0.0
34+
label = "PASS" if score == 1.0 else "FAIL"
35+
return EvaluationResult(
36+
score=score,
37+
label=label,
38+
metadata={
39+
"expected_tools": expected_tools,
40+
"actual_tools": actual_tools,
41+
"tool_call_count": len(actual_tools),
42+
},
43+
)
44+
45+
expected_set = set(expected_tools)
46+
overlap = len(expected_set & actual_set)
47+
score = overlap / len(expected_set)
48+
if score >= 1.0:
49+
label = "PASS"
50+
elif score > 0:
51+
label = "PARTIAL"
52+
else:
53+
label = "FAIL"
54+
55+
return EvaluationResult(
56+
score=score,
57+
label=label,
58+
metadata={
59+
"expected_tools": expected_tools,
60+
"actual_tools": actual_tools,
61+
"tool_call_count": len(actual_tools),
62+
"matched_tools": sorted(expected_set & actual_set),
63+
"missing_tools": sorted(expected_set - actual_set),
64+
},
65+
)
66+
67+
return SimpleEvaluator(name="ToolUse", kind="CODE", evaluate=evaluate)

examples/claude_code_eval/run.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Run Claude Code eval example."""
2+
3+
from __future__ import annotations
4+
5+
import asyncio
6+
import os
7+
from typing import Any
8+
9+
from elastic_evals.config import ElasticEvalsConfig
10+
from elastic_evals.evaluators.base import SimpleEvaluator
11+
from elastic_evals.evaluators.criteria import (
12+
EvaluationCriterion,
13+
create_criteria_evaluator,
14+
)
15+
from elastic_evals.executor import ElasticEvalsClient
16+
from elastic_evals.tracing import init_tracing
17+
from elastic_evals.types import EvaluationResult, Evaluator, EvaluatorParams
18+
19+
from examples.claude_code_eval.datasets.coding_tasks import coding_tasks_dataset
20+
from examples.claude_code_eval.evaluators.latency import create_latency_evaluator
21+
from examples.claude_code_eval.evaluators.tool_use import create_tool_use_evaluator
22+
from examples.claude_code_eval.tasks.claude_code import claude_code_task
23+
24+
25+
def _read_criteria(metadata: dict[str, Any] | None) -> list[EvaluationCriterion]:
26+
if not metadata:
27+
return []
28+
raw = metadata.get("criteria")
29+
if not isinstance(raw, list):
30+
return []
31+
return [c for c in raw if isinstance(c, str)]
32+
33+
34+
def create_criteria_evaluator_from_metadata(
35+
*, inference_client: Any, log: Any
36+
) -> Evaluator:
37+
async def evaluate(params: EvaluatorParams) -> EvaluationResult:
38+
criteria = _read_criteria(params.metadata)
39+
evaluator = create_criteria_evaluator(
40+
inference_client=inference_client,
41+
criteria=criteria,
42+
log=log,
43+
)
44+
return await evaluator.evaluate(params)
45+
46+
return SimpleEvaluator(name="criteria", kind="LLM", evaluate=evaluate)
47+
48+
49+
async def main() -> None:
50+
config = ElasticEvalsConfig.from_env()
51+
init_tracing(config.tracing)
52+
53+
client = ElasticEvalsClient(config)
54+
55+
edot_endpoint = os.environ.get("EDOT_ENDPOINT", "http://localhost:4318")
56+
57+
print(f"Running evaluation with run_id: {config.run_id}")
58+
print(f"Dataset: {coding_tasks_dataset.name}")
59+
print(f"Examples: {len(coding_tasks_dataset.examples)}")
60+
print(f"Repetitions: {config.repetitions}")
61+
print(f"EDOT collector: {edot_endpoint}")
62+
print()
63+
64+
evaluators: list[Evaluator] = [
65+
create_latency_evaluator(),
66+
create_tool_use_evaluator(),
67+
]
68+
69+
# LLM criteria evaluator is optional — only added when an evaluator connector is configured.
70+
if config.evaluator_connector_id or config.connector_id:
71+
inference_client = client.get_inference_client()
72+
evaluators.append(
73+
create_criteria_evaluator_from_metadata(
74+
inference_client=inference_client,
75+
log=config.logger,
76+
)
77+
)
78+
79+
async def task(example):
80+
return await claude_code_task(example, config)
81+
82+
await client.run_experiment(
83+
dataset=coding_tasks_dataset,
84+
task=task,
85+
evaluators=evaluators,
86+
)
87+
88+
89+
if __name__ == "__main__":
90+
asyncio.run(main())

0 commit comments

Comments
 (0)