When you have millions of traces, which ones are worth investigating? This guide shows you the techniques for surfacing anomalies, regressions, and failure clusters that deserve your attention.
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
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:
Install and launch the Phoenix server.
pip install arize-phoenix pandasphoenix serve # serves the UI and OTLP collector at http://localhost:6006
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:6006export PHOENIX_PROJECT=high-signal-demopx auth status # verify it reaches your server
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.
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.
Python
TypeScript
Save this file as data-importer.py:
import pandas as pdfrom phoenix.client import Clientclient = 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"] += deltaspans["end_time"] += deltaclient.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}")
Run it:
python data-importer.py
Install the Phoenix client (the TypeScript examples in this guide use
logSpans, listSessions, and server-side span filters, which need
@arizeai/phoenix-client v6 or newer):
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.
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:
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.
Soft failures. Traces where evaluation scores flag a quality problem: a hallucinated answer, an irrelevant retrieval, a failed task.
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.
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.
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.
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.
Glance at the metrics dashboardin 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.
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.)
UI
CLI (px)
Open the Phoenix UI at http://localhost:6006 and select your project (high-signal-demo).
You will see a spans table. Click the “Traces” tab at the top of the table, next to “Spans”.
Type a condition into the filter bar: status_code == 'ERROR'. The table narrows to errored spans only.
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.
Click any row to open the trace, then select the failing span to read its exception (exception.message / exception.stacktrace) under the “Events” tab.
# Find traces with errors in the last hour (--limit defaults to 10, newest-first,# so raise it or you'll only scan the 10 most recent traces once volume climbs)px trace list --last-n-minutes 60 --limit 200 --format raw --no-progress | jq '.[] | select(.status == "ERROR")'# List all errored spanspx span list --status-code ERROR --limit 20# Only LLM-call failures (use --span-kind TOOL for tool errors)px span list --status-code ERROR --span-kind LLM --limit 20# Drill down into a specific tracepx trace get <trace-id> --format raw | jq '.spans[] | select(.status_code != "OK")'
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.
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:
UI
CLI
Python
TypeScript
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.
# Fetch a wide window (--limit defaults to 10), then sort and slice the extremes.# Find the slowest traces (descending by duration)px trace list --limit 200 --format raw --no-progress | jq 'sort_by(-.duration) | .[0:10]'# Find suspiciously fast traces (ascending by duration)px trace list --limit 200 --format raw --no-progress | jq 'sort_by(.duration) | .[0:10]'
from phoenix.client import Clientfrom phoenix.trace.dsl import SpanQueryimport pandas as pdclient = Client()PROJECT = "high-signal-demo"# Pull all LLM spans with timing and token data (the dataframe is indexed by span_id)perf_query = SpanQuery().where("span_kind == 'LLM'").select( trace_id="context.trace_id", input="input.value", model="llm.model_name", start_time="start_time", end_time="end_time", prompt_tokens="llm.token_count.prompt", completion_tokens="llm.token_count.completion", total_tokens="llm.token_count.total",)df = client.spans.get_spans_dataframe(query=perf_query, project_name=PROJECT)# Calculate duration in millisecondsdf['duration'] = (pd.to_datetime(df['end_time']) - pd.to_datetime(df['start_time'])).dt.total_seconds() * 1000df['input'] = df['input'].str.slice(0, 60) # trim for display# Compute percentilesp95_duration = df['duration'].quantile(0.95)p5_duration = df['duration'].quantile(0.05)p95_tokens = df['total_tokens'].quantile(0.95)cols = ['context.trace_id', 'duration', 'total_tokens', 'input']# Slow tail (potential loops, retries): grab the trace_id to go read itslow_tail = df[df['duration'] > p95_duration].sort_values('duration', ascending=False)print(f"Slow tail (>{p95_duration:.0f}ms): {len(slow_tail)} spans")print(slow_tail[cols].to_string(), "\n")# Fast tail (potential silent failures)fast_tail = df[df['duration'] < p5_duration].sort_values('duration')print(f"Fast tail (<{p5_duration:.0f}ms): {len(fast_tail)} spans")print(fast_tail[cols].to_string(), "\n")# High token usage (cost outliers)high_cost = df[df['total_tokens'] > p95_tokens].sort_values('total_tokens', ascending=False)print(f"High token usage (>{p95_tokens:.0f} tokens): {len(high_cost)} spans")print(high_cost[cols].to_string())# Drill into any of these from the terminal: px trace get <trace_id>
import { createClient } from "@arizeai/phoenix-client";import { getSpans } from "@arizeai/phoenix-client/spans";const client = createClient();async function main() { const { spans } = await getSpans({ client, project: { projectName: "high-signal-demo" }, spanKind: "LLM", limit: 1000, // Adjust based on volume }); const rows = spans .map((span) => ({ traceId: span.context.trace_id, spanId: span.context.span_id, durationMs: new Date(span.end_time).getTime() - new Date(span.start_time).getTime(), tokens: span.attributes["llm.token_count.total"] as number, input: String(span.attributes["input.value"] ?? "").slice(0, 60), })) .sort((a, b) => b.durationMs - a.durationMs); // Slow tail (top 5%): print trace ids so you can go read them const p95Index = Math.max(1, Math.floor(rows.length * 0.05)); const slowTail = rows.slice(0, p95Index); console.log(`Slow tail: ${slowTail.length} spans`); console.table(slowTail); // Fast tail (bottom 5%) const fastTail = rows.slice(-p95Index); console.log(`Fast tail: ${fastTail.length} spans`); console.table(fastTail); // Drill into any of these from the terminal: px trace get <traceId>}main().catch((err) => { console.error(err); process.exit(1);});
Note: TypeScript requires client-side aggregation. For large volumes, export to a DataFrame using the CLI or use Python’s SpanQuery as in the corresponding Python example above.
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.
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:
CLI
Python
TypeScript
# 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"]}'
from phoenix.client import Clientfrom phoenix.trace.dsl import SpanQueryclient = Client()PROJECT = "high-signal-demo"# Find incorrect answers (filter on the label, not the score).# Note: filter on labels in .where(); .select() projects span attributes.# Eval labels can't be projected in .select(), but the .where() filter already# guarantees the label, so just pull the trace_id + input to go read them.incorrect = SpanQuery().where( "evals['correctness'].label == 'incorrect'").select( trace_id="context.trace_id", input="input.value",)incorrect_df = client.spans.get_spans_dataframe(query=incorrect, project_name=PROJECT)print(f"Found {len(incorrect_df)} spans labeled incorrect (index is span_id):")print(incorrect_df.assign(input=incorrect_df["input"].str.slice(0, 60)).head(10).to_string(), "\n")# ALSO spot-check the passing labels to validate your evaluatorcorrect = SpanQuery().where( "evals['correctness'].label == 'correct'").select( trace_id="context.trace_id", input="input.value",)correct_df = client.spans.get_spans_dataframe(query=correct, project_name=PROJECT)# Read a handful: are these actually correct? If not, your judge is miscalibrated.print(f"{len(correct_df)} spans labeled correct and spot-check a few for false negatives\n")# Compound signal: incorrect answers that ALSO drew negative user feedbackcompound = SpanQuery().where( "evals['correctness'].label == 'incorrect' " "and evals['user_feedback'].label == '👎'").select( trace_id="context.trace_id", input="input.value",)compound_df = client.spans.get_spans_dataframe(query=compound, project_name=PROJECT)print(f"Incorrect AND thumbs-down: {len(compound_df)} spans")print(compound_df.assign(input=compound_df["input"].str.slice(0, 60)).to_string())# Drill into any of these from the terminal: px trace get <trace_id>
import { createClient } from "@arizeai/phoenix-client";import { getSpans, getSpanAnnotations } from "@arizeai/phoenix-client/spans";const client = createClient();async function main() { // Get spans with annotations const { spans } = await getSpans({ client, project: { projectName: "high-signal-demo" }, limit: 1000, }); const spanIds = spans.map((s) => s.context.span_id); const traceOf = new Map(spans.map((s) => [s.context.span_id, s.context.trace_id])); // getSpanAnnotations returns { annotations } and is cursor-paginated (default // page size 100), so loop until there's no cursor or you'll miss rows. // Each annotation's label is nested under `.result.label` (not `.label`). const annotations = []; let cursor: string | undefined; do { const page = await getSpanAnnotations({ client, project: { projectName: "high-signal-demo" }, spanIds, includeAnnotationNames: ["correctness", "user_feedback"], cursor, limit: 1000, }); annotations.push(...page.annotations); cursor = page.nextCursor || undefined; } while (cursor); // Filter on labels, not score thresholds (score direction varies by evaluator) const incorrect = annotations.filter( (ann) => ann.name === "correctness" && ann.result?.label === "incorrect" ); console.log(`Spans labeled incorrect: ${incorrect.length}`); console.table( incorrect.slice(0, 10).map((a) => ({ spanId: a.span_id, traceId: traceOf.get(a.span_id) })) ); // Spot-check the passing labels too (calibration check) const correct = annotations.filter( (ann) => ann.name === "correctness" && ann.result?.label === "correct" ); console.log( `Spans labeled correct: ${correct.length} (read a few to catch false negatives)` ); // Human signal: turns that drew a thumbs-down const thumbsDown = annotations.filter( (ann) => ann.name === "user_feedback" && ann.result?.label === "👎" ); console.log(`Spans with negative user feedback: ${thumbsDown.length}`); // Drill into any of these from the terminal: px trace get <traceId>}main().catch((err) => { console.error(err); process.exit(1);});
Note: TypeScript requires fetching annotations separately and filtering client-side. For complex eval-based queries, use Python’s SpanQuery or the CLI.
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.
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:
CLI
Python
TypeScript
# List all sessionspx 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 sessionpx span list --attribute session.id:<session-id> --limit 50
from phoenix.client import Clientfrom phoenix.trace.dsl import SpanQueryclient = Client()PROJECT = "high-signal-demo"# Find all spans in sessions with errorserror_sessions = SpanQuery().where( "status_code == 'ERROR' and session.id is not None").select( session_id="session.id", trace_id="context.trace_id", error="exception.message",)error_sessions_df = client.spans.get_spans_dataframe(query=error_sessions, project_name=PROJECT)# Get the session IDs with errors (feed one to `px session get <id>`)problem_sessions = error_sessions_df['session_id'].unique()print(f"Found {len(problem_sessions)} sessions with errors: {list(problem_sessions)}\n")# Find long sessions (high turn count = user struggling)all_sessions = SpanQuery().where( "session.id is not None").select( session_id="session.id", trace_id="context.trace_id",)sessions_df = client.spans.get_spans_dataframe(query=all_sessions, project_name=PROJECT)# Count traces per session (context.* projections keep their dotted column name)# and flag which sessions contained an error, so you can see both signals at once.turn_counts = sessions_df.groupby('session_id')['context.trace_id'].nunique()summary = turn_counts.rename('turn_count').reset_index()summary['has_errors'] = summary['session_id'].isin(problem_sessions)summary = summary.sort_values('turn_count', ascending=False)print("Longest sessions:")print(summary.head(5).to_string(index=False))high_turn_sessions = summary[summary['turn_count'] > turn_counts.quantile(0.90)]print(f"\nLong sessions (>90th percentile): {len(high_turn_sessions)}")
import { createClient } from "@arizeai/phoenix-client";import { getSpans } from "@arizeai/phoenix-client/spans";import { listSessions } from "@arizeai/phoenix-client/sessions";const client = createClient();async function main() { // Sessions containing any error trace: pull the error spans (server-side // status filter) and collect the session.id they belong to. const { spans: errorSpans } = await getSpans({ client, project: { projectName: "high-signal-demo" }, statusCode: "ERROR", limit: 1000, }); const errorSessionIds = new Set( errorSpans.map((s) => s.attributes["session.id"]).filter(Boolean) ); console.log(`Sessions with errors: ${errorSessionIds.size}`, [...errorSessionIds]); // Longest sessions (high turn count = user struggling). listSessions gives the // turn count directly as traces.length. No need to reconstruct it from spans. const sessions = await listSessions({ client, project: "high-signal-demo" }); const longest = sessions .map((s) => ({ sessionId: s.sessionId, turnCount: s.traces.length, hasErrors: errorSessionIds.has(s.sessionId), })) .sort((a, b) => b.turnCount - a.turnCount); console.log("Longest sessions:"); console.table(longest.slice(0, 5)); // Drill into one with: px session get <sessionId>}main().catch((err) => { console.error(err); process.exit(1);});
Individual bad traces can be anecdotal, edge cases. Clusters help you see what to prioritize. This clustering method comes from qualitative research:
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.
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.
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.
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 Clientfrom phoenix.trace.dsl import SpanQueryfrom sklearn.model_selection import train_test_splitclient = 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 filedataset = 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 onEmbedding 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:
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.
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.
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.
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.
Python
TypeScript
This optional pipeline isn’t covered by the base install.
First add the extra libraries (HDBSCAN ships in scikit-learn ≥ 1.3):
pip install umap-learn scikit-learn openai
Then set an embedding provider key:
export OPENAI_API_KEY=sk-... # or swap in your own embedder in the code below
Save the following as pipeline.py:
from phoenix.client import Clientfrom phoenix.trace.dsl import SpanQueryimport numpy as npimport umapfrom sklearn.cluster import HDBSCANfrom openai import OpenAI # or your embedding providerclient = Client()openai_client = OpenAI()PROJECT = "high-signal-demo"# Export spans with inputs and statusquery = 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 inputsdef 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].embeddingdf['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 frontclusterer = 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 examplesfor 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 signalsdf['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 noisehigh_error_clusters = cluster_stats[cluster_stats['error_rate'] > 0.2].indexhigh_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)].indexedge_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
Run the script with
python pipeline.py
// TypeScript: Export spans, then cluster in Python or use an external tool// The pattern: getSpans → write to JSON → cluster offline → rejoin resultsimport { createClient } from "@arizeai/phoenix-client";import { getSpans } from "@arizeai/phoenix-client/spans";import * as fs from "fs";const client = createClient();async function main() { // Export spans for offline clustering const { spans } = await getSpans({ client, project: { projectName: "high-signal-demo" }, limit: 1000, // REST caps limit at 1000; paginate with nextCursor for more }); // Extract inputs for embedding const inputs = spans.map((span) => ({ span_id: span.context.span_id, input: span.attributes["input.value"], status: span.status_code, })); fs.writeFileSync("spans_for_clustering.json", JSON.stringify(inputs, null, 2)); console.log( "Exported spans to spans_for_clustering.json. Next: cluster with Python, then rejoin" );}main().catch((err) => { console.error(err); process.exit(1);});
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.
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.
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.
Update the taxonomy and re-count. New failure modes get new categories; recurring ones get their tallies bumped.
Prioritize by frequency × impact and fix from the top.
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.
import { createClient } from "@arizeai/phoenix-client";import { getSpans } from "@arizeai/phoenix-client/spans";import * as fs from "fs";const client = createClient();async function main() { // Time range: last 7 days const endTime = new Date(); const startTime = new Date(endTime.getTime() - 7 * 24 * 60 * 60 * 1000); console.log("Weekly Trace Review: Mixed Sample"); console.log(`Period: ${startTime.toDateString()} to ${endTime.toDateString()}\n`); // 1. All hard failures (status filtered server-side) const { spans: errorSpans } = await getSpans({ client, project: { projectName: "high-signal-demo" }, statusCode: "ERROR", startTime, endTime, limit: 1000, }); console.log(`Hard failures: ${errorSpans.length}`); // 2. Get all spans for tail analysis const { spans: allSpans } = await getSpans({ client, project: { projectName: "high-signal-demo" }, startTime, endTime, limit: 1000, // REST caps limit at 1000; paginate with nextCursor for more }); // Calculate durations const withDurations = allSpans.map((span) => ({ ...span, duration: new Date(span.end_time).getTime() - new Date(span.start_time).getTime(), })); // Sort by duration const sorted = withDurations.sort((a, b) => b.duration - a.duration); const p95Index = Math.floor(sorted.length * 0.05); const p5Index = Math.floor(sorted.length * 0.95); const slowTail = sorted.slice(0, p95Index); const fastTail = sorted.slice(p5Index); console.log(`Slow tail (p95+): ${slowTail.length}`); console.log(`Fast tail (p5-): ${fastTail.length}`); // 3. Random sample (OK spans only) const okSpans = allSpans.filter((s) => s.status_code === "OK"); const randomSample = okSpans .sort(() => Math.random() - 0.5) .slice(0, Math.min(30, okSpans.length)); console.log(`Random sample: ${randomSample.length}`); // Combine into review queue const reviewQueue = [ ...errorSpans.slice(0, 20), ...slowTail.slice(0, 10), ...fastTail.slice(0, 5), ...randomSample, ]; console.log(`\nTotal review queue: ${reviewQueue.length} spans`); // Export for offline review fs.writeFileSync( "weekly_review_sample.json", JSON.stringify( reviewQueue.map((s) => ({ span_id: s.context.span_id, name: s.name, status: s.status_code, input: s.attributes["input.value"], })), null, 2 ) ); console.log("Exported to weekly_review_sample.json"); console.log("Next: Read traces and add notes with:"); console.log(" px span add-note <span-id> --text 'your observation'");}main().catch((err) => { console.error(err); process.exit(1);});
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.