Skip to main content
When running LLM applications in production, you quickly accumulate thousands or millions of traces. A high-signal trace is one that points to a new way in which your system fails. The discipline of production observability comes down to finding those traces efficiently and actually reading them. This guide covers:
  • What separates signal from noise in production traces
  • The review practice: a daily smoke check and a weekly error-analysis session
  • Filtering traces by errors, latency outliers, evaluation scores, and session behavior from the CLI and programmatically
  • Turning what you find into a failure taxonomy that drives your next evals

Follow Along with Sample Traces

You can run every query in this guide against any Phoenix project that has traces in it. To follow along with sample traces that cover every technique (hard failures, latency outliers, evaluation scores, and multi-turn sessions), load the prepared sample traces:
  1. Install and launch the Phoenix server.
    pip install arize-phoenix pandas
    phoenix serve   # serves the UI and OTLP collector at http://localhost:6006
    
  2. Install the Phoenix CLI and point px at your server and project.
    npm install -g @arizeai/phoenix-cli       # provides the `px` command
    
    # Scoped to THIS terminal (re-run it in each new shell). Both the `px` CLI
    # and the Python/TS clients read PHOENIX_COLLECTOR_ENDPOINT.
    export PHOENIX_COLLECTOR_ENDPOINT=http://localhost:6006
    export PHOENIX_PROJECT=high-signal-demo
    
    px auth status                            # verify it reaches your server
    
  3. Download the sample traces in the form of two JSONL files: the spans and their evaluations. This is the same shape Phoenix uses when you export your own spans, so nested fields (like span events) and timestamps arrive already typed.
    curl -LO https://gist.githubusercontent.com/nearestnabors/94f006718d5804239e0fa55f10776077/raw/high-signal-traces.jsonl
    curl -LO https://gist.githubusercontent.com/nearestnabors/94f006718d5804239e0fa55f10776077/raw/high-signal-evals.jsonl
    
  4. Load the sample traces into Phoenix. This logs the spans and their evaluation labels into the high-signal-demo project so they appear to have happened in the last 12 hours.
    1. Save this file as data-importer.py:
    import pandas as pd
    from phoenix.client import Client
    
    client = Client()
    PROJECT = "high-signal-demo"
    
    # --- Spans ---  (JSONL keeps nested events + tz-aware timestamps, so no reshaping)
    spans = pd.read_json("high-signal-traces.jsonl", lines=True)
    # shift timestamps so the newest span is ~now (keeps the time-window queries fresh)
    delta = pd.Timestamp.now(tz="UTC") - spans["end_time"].max()
    spans["start_time"] += delta
    spans["end_time"]   += delta
    client.spans.log_spans_dataframe(project_identifier=PROJECT, spans_dataframe=spans)
    
    # --- Evaluations (correctness label + user feedback) ---
    evals = pd.read_json("high-signal-evals.jsonl", lines=True)
    for name, group in evals.groupby("annotation_name"):
        client.spans.log_span_annotations_dataframe(
            dataframe=group.set_index("span_id")[["label", "score"]],
            annotation_name=name,
            annotator_kind="LLM",
        )
    
    print(f"Loaded {len(spans)} spans and {len(evals)} evaluations into {PROJECT}")
    
    1. Run it:
    python data-importer.py
    
  5. Confirm it landed. Open the Phoenix UI at http://localhost:6006, select the high-signal-demo project and select “all spans” to see ~100 traces including several with ERROR status, a few slow outliers, and session groupings.

Finding the signal despite the noise

The most common mistake teams make is treating observability as a dashboard problem when it’s really an analysis problem. Generic aggregate metrics like an overall “helpfulness” score or a global pass rate create a false sense of measurement and control. It doesn’t matter which way the number moves if nobody can say what it means for users. Evals and observablity expert Hamel Husain calls error analysis “the most important activity in evals”, and observes that successful teams spend the majority of their development effort looking at data rather than building automated checks. So before you start building a metrics dashboard, you need to find out which traces matter. While sorts, scores, and queries help you understand traces and filters find them, they never replace reading them. High-signal traces come from five sources:
  1. Hard failures. Exceptions, tool-call errors, timeouts, malformed outputs. It is the cheapest signal to query and the shallowest. An LLM application can be deeply broken without ever throwing an exception.
  2. Soft failures. Traces where evaluation scores flag a quality problem: a hallucinated answer, an irrelevant retrieval, a failed task.
  3. Outliers. Traces at the extremes of any metric: latency, token count, tool-call count, response length. Both extremes are interesting: the slow tail reveals retry loops and runaway agents; the suspiciously fast tail reveals silent failures like empty retrievals.
  4. Human signals. Negative feedback, support escalations, sessions where the user rephrases the same question three times. A user retry is an eval score the user computed for you.
  5. Novelty. Inputs unlike anything you’ve categorized before. New usage patterns are where your next failure mode is hiding.
Prioritize the most frequent failures. Not every failure is mission-critical. Review time and evals aren’t free. The goal is a ranked list of real, frequent problems, not exhaustive coverage.

The Discipline of timely Trace Reviews

Production trace review is all about discipline. If you can handle a daily checkin and a weekly standup, you can handle the regular reviews traces require. This process consists of a daily smoke check and a weekly error analysis session.

Daily smoke check (minutes)

Glance at the metrics dashboard in your Phoenix server. Select your “high-signal-demo” project from the dropdown at the top of the page and look for anything alarming. Pay attention to spikes and climbing numbers in the following tiles:
  • Trace Latency
  • Cost (not included in the sample traces)
  • LLM spans with errors
  • Tool spans with errors
The dashboard is a smoke detector, not a review process. It tells you that something changed but never why it changed. That requires analyzing individual traces, as shown in the next section.

Weekly error-analysis session (an hour or two)

Once a week, pull a deliberately mixed sample of traces and read them with open-ended notes. This is where improvement is made real. You’ll learn how in the last section of this guide.
Teams that only do the daily check end up dashboard-watching. Teams that only do deep dives get surprised by fires. You need both, as they are complementary. Smoke-check anomalies tell you where to oversample in the weekly review.

How to perform the Daily Check: Finding Hard Failures

Hard failures are queryable with zero setup. Phoenix records span status, exception events, and error messages on every trace. Triage them visually in the Phoenix UI or straight from the terminal with the px CLI. (Prefer code, e.g. for monitoring scripts? The same queries run through the Python and TypeScript clients.)
  1. Open the Phoenix UI at http://localhost:6006 and select your project (high-signal-demo).
  2. You will see a spans table. Click the “Traces” tab at the top of the table, next to “Spans”.
  3. Type a condition into the filter bar: status_code == 'ERROR'. The table narrows to errored spans only.
  4. Add a clause to focus on a span kind: status_code == 'ERROR' and span_kind == 'LLM' for model-call failures, or 'TOOL' for tool errors.
  5. Click any row to open the trace, then select the failing span to read its exception (exception.message / exception.stacktrace) under the “Events” tab.

How to read error traces

Find the first failure, not the loudest one. In multi-step agent traces, upstream errors cascade. A malformed tool output at step 2 produces a confused plan at step 4 and a wrong answer at step 9. Note the first point where the trace went wrong; fixing it usually dissolves the downstream symptoms. Collect and group before you fix. A hundred error traces might be three actual problems. Resist debugging trace-by-trace; collect, categorize, count in the weekly session.

Finding Performance Outliers

Latency and cost problems rarely announce themselves in averages. A healthy p50 can hide a p99 where agents loop, contexts balloon, and users give up. Sort by extremes and read both tails:
  • Slow tail: retry storms, agents stuck in tool-call loops, oversized retrieved contexts, sequential calls that should be parallel.
  • Fast tail: short-circuits and silent failures, empty retrieval results, guardrails firing when they shouldn’t, models returning refusals or stubs.
From the terminal, or programmatically for latency-tail analysis:
Go to your project’s dashboard. The “Trace Latency” panel plots the percentile lines (P50 through P99) over time, so you can read both tails at a glance: watch the P99 line for the slow tail, and the P50 dipping toward zero for the suspiciously fast tail. Toggle individual percentiles in the legend to isolate the tail you’re reading. The dashboard tells you when a tail moved; use the CLI or Python tabs to pull the actual outlier traces to read.
For regressions specifically, the unit of comparison is the change: compare the latency or token distribution before and after a deploy, prompt revision, or model swap, not against an absolute threshold. And whatever you use to determine “outliers” (p95, one standard deviation from the median), treat it as a starting point, not a finding.

Finding Quality Issues with Evaluations

Evaluation results attached to traces let you query for soft failures the way you query for exceptions. Treat eval results as exploration clues, not verdicts:
  • Review failures as well as passes. Failing labels show you problems; passing labels show you whether your evaluator is calibrated. If you only ever read failures, you’ll never notice your judge passing garbage.
  • Prefer binary and categorical judgments to numeric scales. “Did the assistant hand off to a human when it should have: yes/no” is actionable. “Helpfulness: 4.2” is not. Arize research testing LLM judges on continuous ranges found numeric score evals unreliable, with small prompt changes producing wildly different results, and a 2025 re-test on newer models confirmed that binary and categorical judgments remain the most stable (original study, follow-up). Phoenix’s built-in evaluators emit categorical labels for exactly this reason.
  • A 100% pass rate is a warning sign. It usually means your evals aren’t stressing the system. A 70% pass rate often indicates an eval that’s actually testing something.
A practical consequence of label-based evals: filter on labels, not score thresholds. Score direction varies by evaluator (for Phoenix’s hallucination evaluator, a higher score means factual), so label filters are both more readable and harder to get backwards. From the terminal, or programmatically for filtering by evaluation results:
# List spans with their evaluation results (label lives at .result.label)
px span list --include-annotations --limit 20 --format raw --no-progress | \
  jq '.[] | select(.annotations != null) | {span_id: .context.span_id, name,
        labels: [.annotations[] | {name, label: .result.label}]}'

# Filter spans by annotation label. These are SPAN annotations, so use `span list`
# (not `trace list`); guard the null with `any(...)` and read the nested .result.label.
px span list --include-annotations --limit 1000 --format raw --no-progress | \
  jq '.[] | select(any((.annotations // [])[]; .name == "correctness" and .result.label == "incorrect"))
        | {span_id: .context.span_id, trace_id: .context.trace_id, input: .attributes["input.value"]}'
Each recurring quality issue you confirm by reading the flagged traces is a candidate for a new, specific evaluator. This is how your eval suite grows out of real world failures instead of generic metrics and arbitrary benchmarks.

Session Signals: Multi-Turn Failures

Single-trace filters miss a whole class of problems that only exist across turns: the user who rephrases three times, the conversation that derails halfway, the agent that loses context it had two turns ago. Phoenix sessions group traces into conversations so you can read the interaction the way the user experienced it. Session-level signals worth querying for:
  • Sessions containing any error trace
  • Unusually long sessions (turn count as an outlier metric often portrays a user fighting the system)
  • Sessions with negative feedback on any turn
When reading a session, the first-failure principle matters even more: find the earliest turn where things went wrong, because every turn after it is contaminated context. From the terminal, or programmatically for session analysis:
# List all sessions
px session list --limit 20

# Get a specific session with all its traces (traces are under .session.traces)
px session get <session-id> --format raw --no-progress | \
  jq '.session.traces[] | {trace_id, start_time, end_time}'

# Find spans for a specific session
px span list --attribute session.id:<session-id> --limit 50

The Weekly Session: Finding Failure Clusters

Individual bad traces can be anecdotal, edge cases. Clusters help you see what to prioritize. This clustering method comes from qualitative research:
  1. Open coding. In qualitative research, “coding” means annotating data with labels, not programing. Pull a sample of traces and write free-form notes on anything wrong with each one. Don’t pre-define categories before looking at the traces, or you’ll miss the failure modes unique to your application.
  2. Axial coding. Group the notes around shared themes (the “axes” the method is named for) producing a failure taxonomy: distinct, named categories. You can do this yourself like grouping socks after doing the laundry, but an LLM is often faster and just as good.
  3. Count. Tally failures per category. This step creates a prioritized roadmap. In one of Hamel Husain’s client engagements, three categories accounted for over 60% of all problems.
  4. Iterate to saturation. Keep sampling until new traces stop revealing new categories. Rule of thumb: review at least 100 traces to start; once ~20 consecutive traces turn up nothing new, you’re saturated.
Random sampling gets inefficient once the obvious failures are fixed. Most random traces are fine. Bias your sample toward signal: Stratified sampling. Group traces by the dimensions you care about, for example: user segment, model, feature, query category, which tools were called, session turn count. Sample from each group, so a high-volume happy path doesn’t drown out a broken niche. For agent applications, “tools-called” is often the most failure-correlated dimension you have, and it’s already an attribute on your spans. In Phoenix, the attributes you attach at instrumentation time (user IDs, session IDs, custom metadata) are exactly what makes this possible: stratification is an instrumentation decision before it’s a query. For a quick assessment, pull a fixed number of spans per group straight from the CLI:
# Sample by model (the sample traces use gpt-4o-mini and gpt-4o)
px span list --attribute llm.model_name:gpt-4o-mini --limit 50 --format raw --no-progress | \
  jq '.[] | {span_id: .context.span_id, input: .attributes["input.value"]}'

# ...or by any custom attribute you attach at instrumentation time: a session,
# a user segment, a feature; whatever dimension you want to stratify on:
px span list --attribute session.id:sess-001 --limit 50
To build a reusable review set that you can annotate in the UI, hand to a teammate, or run experiments against, proportionally sample in Python and load it into a Phoenix dataset rather than a local file. Save the following script as sample.py and run it from the terminal with python sample.py.
from phoenix.client import Client
from phoenix.trace.dsl import SpanQuery
from sklearn.model_selection import train_test_split

client = Client()
PROJECT = "high-signal-demo"

# 1. Pull spans with the column you want to stratify on (here, the model)
df = client.spans.get_spans_dataframe(
    query=SpanQuery().where("span_kind == 'LLM'").select(
        model="llm.model_name",
        input="input.value",
        output="output.value",
    ),
    project_name=PROJECT,
)

# 2. A 30-span sample that preserves each model's share of traffic
#    (test_size must be smaller than len(df); scale it to your volume)
_, sample = train_test_split(
    df, test_size=30, stratify=df["model"], random_state=42
)

# 3. Load the sample into a Phoenix dataset so you can review, annotate, and
#    run experiments on it instead of leaving it in a throwaway local file
dataset = client.datasets.create_dataset(
    name="weekly-review-sample",
    dataframe=sample.reset_index(),   # span_id becomes a column
    input_keys=["input"],
    output_keys=["output"],
    metadata_keys=["model"],
)
print(f"Created dataset '{dataset.name}' with {len(sample)} spans and open it in the UI")
df is a spans dataframe pulled with SpanQuery, and df["model"] is the column you stratify on Embedding clustering (advanced, optional). Embed the message content (usually the user inputs) and cluster it to reveal the natural groupings in your traffic, then sample from every group. Note that this is a different kind of clustering from the failure taxonomy above:
  • Axial coding groups failures, and rarely needs a formal algorithm.
  • Embedding clustering groups inputs, so you can sample across the real shape of your traffic and cross-reference input clusters against error rates and eval labels.
How you weight the samples depends on your goal. Weight proportionally to cluster size when you want representative failure-rate estimates; oversample the small clusters when you’re hunting for failure modes you haven’t seen yet. The first measures, the second discovers. The pipeline below has four stages, each doing one job:
  1. Embed the user inputs. An embedding model converts each input into a long vector of numbers, positioned so that semantically similar inputs land near each other. This turns “which inputs resemble each other?” into a geometry question.
  2. Reduce with UMAP. Raw embeddings have over a thousand dimensions, and at that scale distances stop being informative. Everything is roughly equally far from everything else. UMAP compresses the vectors to 5–25 dimensions while preserving which points are neighbors, which is exactly what the next step needs.
  3. Cluster with HDBSCAN, which finds the dense regions of that space and calls each one a cluster. Unlike k-means, it doesn’t make you guess the number of clusters before you’ve seen the data, and it doesn’t force every point into a group: inputs that fit nowhere get labeled noise (-1). Don’t discard them. An input that resembles nothing else in your traffic is your novelty signal, found for free.
  4. Label each cluster with an LLM using representative examples, because “cluster 3” tells you nothing until you know it means “billing-dispute questions.”
The code below adds two more steps that put the clusters to work: cross-referencing them against failure signals, then building the review sample.
BERTopic packages this exact pipeline if you’d rather not assemble it yourself.
This optional pipeline isn’t covered by the base install.
  1. First add the extra libraries (HDBSCAN ships in scikit-learn ≥ 1.3):
pip install umap-learn scikit-learn openai
  1. Then set an embedding provider key:
export OPENAI_API_KEY=sk-...   # or swap in your own embedder in the code below
  1. Save the following as pipeline.py:
from phoenix.client import Client
from phoenix.trace.dsl import SpanQuery
import numpy as np
import umap
from sklearn.cluster import HDBSCAN
from openai import OpenAI  # or your embedding provider

client = Client()
openai_client = OpenAI()
PROJECT = "high-signal-demo"

# Export spans with inputs and status
query = SpanQuery().select(
    input="input.value",
    output="output.value",
    status="status_code",
)
df = client.spans.get_spans_dataframe(query=query, project_name=PROJECT)

# Eval labels can't be projected in .select(); pull them from the annotations
# dataframe and join on span_id so we can cross-reference clusters below.
ann = client.spans.get_span_annotations_dataframe(
    spans_dataframe=df.reset_index(),
    project_identifier=PROJECT,
    include_annotation_names=["correctness"],
)
df["correctness_label"] = ann["result.label"].reindex(df.index)

# 1. Embed the user inputs
def get_embedding(text: str) -> list[float]:
    if not text or not isinstance(text, str):
        return [0.0] * 1536  # Zero vector for missing inputs
    response = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=text[:8000]  # Truncate long inputs
    )
    return response.data[0].embedding

df['embedding'] = df['input'].apply(get_embedding)
embeddings = np.array(df['embedding'].tolist())

# 2. Reduce dimensionality (raw embeddings are too high-dimensional
#    for density-based clustering; 5-25 components works well)
reducer = umap.UMAP(n_components=10, random_state=42)
reduced = reducer.fit_transform(embeddings)

# 3. Cluster with HDBSCAN. No need to pick a cluster count up front
clusterer = HDBSCAN(min_cluster_size=10)
df['cluster'] = clusterer.fit_predict(reduced)

# Cluster -1 is HDBSCAN's "noise": inputs that don't fit any cluster.
# Don't discard these. They're your novelty signal. Review them directly.
noise = df[df['cluster'] == -1]
print(f"Noise points (novel inputs): {len(noise)}")

# 4. Label each cluster with an LLM using representative examples
for cluster_id in sorted(df[df['cluster'] >= 0]['cluster'].unique()):
    examples = df[df['cluster'] == cluster_id]['input'].head(5).tolist()
    # Send to an LLM: "Name the common theme of these user queries in 6 words or fewer"
    # Store the returned label alongside the cluster_id

# 5. Cross-reference input clusters against failure signals
df['is_error'] = df['status'] == 'ERROR'
df['is_incorrect'] = df['correctness_label'] == 'incorrect'
cluster_stats = df[df['cluster'] >= 0].groupby('cluster').agg(
    error_rate=('is_error', 'mean'),
    incorrect_rate=('is_incorrect', 'mean'),
    size=('input', 'count'),
)
print(cluster_stats.sort_values('error_rate', ascending=False))

# 6. Build the review sample: high-error clusters, small clusters, and noise
high_error_clusters = cluster_stats[cluster_stats['error_rate'] > 0.2].index
high_error_sample = df[df['cluster'].isin(high_error_clusters)].sample(
    n=min(50, len(df[df['cluster'].isin(high_error_clusters)]))
)

# The smallest clusters (bottom quartile by size). Use <= because HDBSCAN's
# min_cluster_size puts a floor under every cluster, so a strict < often catches
# nothing when the 25th-percentile size equals that floor.
small_clusters = cluster_stats[cluster_stats['size'] <= cluster_stats['size'].quantile(0.25)].index
edge_case_sample = df[df['cluster'].isin(small_clusters)].sample(
    n=min(20, len(df[df['cluster'].isin(small_clusters)]))
)

print(f"\nHigh-error sample: {len(high_error_sample)} spans")
print(f"Edge-case sample: {len(edge_case_sample)} spans")
print(f"Novelty sample: {min(20, len(noise))} spans")

# Optional: for a visual map, run a second UMAP to 2 components
# and plot (x, y, cluster, status) with your favorite plotting library
  1. Run the script with
python pipeline.py
Phoenix doesn’t include a built-in embedding projector. The workflow above shows the full DIY approach: embed, reduce, cluster, cross-reference against error rates. This portability is a feature. You control the embedding model and clustering algorithm.

Putting It Together: The Triage Loop

The full weekly loop, end to end:
  1. Pull a mixed sample. All hard failures since last review, both latency tails, the lowest (and a few highest) eval scores, sessions with negative feedback or high turn counts, plus a stratified random slice so you don’t develop tunnel vision.
  2. Annotate, open-coding style. Free-form notes, first failure first. Phoenix annotations keep notes attached to the trace where the next reviewer can find them.
  3. Update the taxonomy and re-count. New failure modes get new categories; recurring ones get their tallies bumped.
  4. Prioritize by frequency × impact and fix from the top.
  5. Convert each confirmed failure mode into an evaluator and where possible, a code assertion, an LLM-as-judge with validated binary outputs where not.
Every review cycle makes the next cycle’s automated filtering smarter. Where agents fit. The filtering and sampling steps above are mechanical enough to delegate to a coding agent once your sampling logic is settled. More interestingly, agent-as-judge review over full trajectories adds a recall layer that scalar filters miss: an agent can assess whether a complex tool call or generated shell command was actually correct, something no human reviewer can verify at scale. It can contextualize the smoke signals your filters raise. Agents triage and judge what humans can’t check at volume; humans keep what agents can’t be trusted with, validating the judge’s calibration, naming new failure categories, and deciding what matters. Agent review is one more filter whose escalations a human reads. The scripts below are starting-point templates. Adapt the sampling logic, thresholds, and time windows to match your application’s failure modes and review capacity.
from phoenix.client import Client
from phoenix.trace.dsl import SpanQuery
from datetime import datetime, timedelta
import pandas as pd

client = Client()
PROJECT = "high-signal-demo"

# Time range: last 7 days
end_time = datetime.now()
start_time = end_time - timedelta(days=7)

print("Weekly Trace Review: Mixed Sample")
print(f"Period: {start_time.date()} to {end_time.date()}\n")

# 1. All hard failures
errors = SpanQuery().where("status_code == 'ERROR'").select(
    input="input.value",
    error="exception.message",
)
errors_df = client.spans.get_spans_dataframe(
    query=errors, start_time=start_time, end_time=end_time, project_name=PROJECT
)
print(f"Hard failures: {len(errors_df)}")

# 2. Slow tail (p95+)
all_spans = SpanQuery().select(
    start_time="start_time",
    end_time="end_time",
)
spans_df = client.spans.get_spans_dataframe(
    query=all_spans, start_time=start_time, end_time=end_time, project_name=PROJECT
)
# Calculate duration in milliseconds
spans_df['duration'] = (pd.to_datetime(spans_df['end_time']) - pd.to_datetime(spans_df['start_time'])).dt.total_seconds() * 1000

p95_duration = spans_df['duration'].quantile(0.95)
slow_tail = spans_df[spans_df['duration'] > p95_duration]
print(f"Slow tail (p95+): {len(slow_tail)}")

# 3. Fast tail (p5-)
p5_duration = spans_df['duration'].quantile(0.05)
fast_tail = spans_df[spans_df['duration'] < p5_duration]
print(f"Fast tail (p5-): {len(fast_tail)}")

# 4. Failing eval labels
incorrect = SpanQuery().where(
    "evals['correctness'].label == 'incorrect'"
).select(
    input="input.value",
)
incorrect_df = client.spans.get_spans_dataframe(
    query=incorrect, start_time=start_time, end_time=end_time, project_name=PROJECT
)
print(f"Failing eval labels: {len(incorrect_df)}")

# 5. Stratified random sample (all non-error spans)
random_sample = SpanQuery().where("status_code == 'OK'").select(
    input="input.value",
)
random_df = client.spans.get_spans_dataframe(
    query=random_sample, start_time=start_time, end_time=end_time, project_name=PROJECT
)
random_df = random_df.sample(n=min(30, len(random_df)))  # 30 random spans
print(f"Random sample: {len(random_df)}")

# Combine into review queue
review_queue = pd.concat([
    errors_df.head(20),     # Top 20 errors
    slow_tail.head(10),     # Top 10 slow
    fast_tail.head(5),      # 5 suspiciously fast
    incorrect_df.head(15),  # 15 failing evals
    random_df,              # 30 random
])

print(f"\nTotal review queue: {len(review_queue)} spans")
print("Next: Read these traces and add notes with:")
print("  px span add-note <span-id> --text 'your observation'")

# Export for offline review
review_queue.to_csv('weekly_review_sample.csv', index=True)
print("Exported to weekly_review_sample.csv")
Re-run the loop whenever you ship a significant change. Thirty minutes reading 20–50 traces after a prompt revision catches regressions that no pre-existing eval was written to see.

Next Steps

  • Convert your top failure categories into automated evaluators and run them as online evals
  • Set up annotation queues so domain experts as well as engineers can review edge cases
  • Track failure-category counts over time: a category trending up is a regression even if no individual eval fires
  • Revisit the full loop after every significant prompt, model, or retrieval change

Further Reading

The methodology in this guide draws on practitioner research in error analysis and evaluation: