Find out what is going wrong in LLM or agent traffic by reading sampled Phoenix traces, spans, or sessions, writing free-form notes (open coding), then grouping the notes into a few narrow annotations, one per failure dimension with a small label set and counts, that pick eval targets and fix priorities (axial coding). Use for "what's going wrong with this agent", "I just instrumented my app, where do I start", "review these traces", "the chatbot keeps losing context", "what kinds of mistakes is the model making", "what categories of failures do we have", "what should I build evals for", "how do I prioritize fixes", "group these notes", "MECE breakdown" — or any framing that needs observations or categories grounded in real traces rather than invented top-down, even without naming the technique.
69
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Low
Low-risk findings worth noting
Two phases against sampled traces, spans, or sessions. Open coding reads each sampled entity and writes a short, specific note on what went wrong. Axial coding groups those notes into a few narrow annotations, each judging one dimension of the entity with a small label set, and counts the labels to feed eval design and fix prioritization. Open coding always comes first: dimensions and labels that are not grounded in notes are invented top-down, which is the failure this workflow exists to avoid. Axial coding can also start from any existing set of open-ended observations.
Recorded notes are the deliverable. Open coding ends with one note per problematic entity written to the server and mirrored in the local sidecar, not with a summary in chat. Reading traces and reporting what you noticed without recording anything is a read-only diagnosis, the top-down shortcut this workflow exists to replace. A project-wide question such as "find any issues", "what's going wrong", or "any errors here" is a request for phase 1: pick the identifier, pick the unit, record a note for each problem as you find it, then summarize. A project with no annotation names yet is the normal starting state, not a reason to hold off writing.
Pick trace, span, or session deliberately before recording — the choice determines what every note and annotation below targets, and a wrong default is expensive to undo mid-run. The unit is where the failure modes you're investigating actually live:
session.id. Multi-turn agents where the failure is a trajectory — context loss across turns, goal drift, forgotten preferences — that exists only across traces.Three signals to read:
User framing. "Conversation", "agent forgot", "drift", "across turns" → session. "This trace", "wrong output" → trace. "Exception", "malformed", "the retrieval failed" → span.
Session wiring. The session id lives at the root span's session.id attribute (not a top-level trace field); "" means absent. List ~200 recent traces and compute how many carry a non-empty session id, how many distinct session ids appear, and the median traces per session. No session ids or median 1 → trace or span level; median 2+ → session level is plausibly right.
Turn structure. Open one recent trace's root-span input.value (it may be large — filter rather than reading wholesale). A single user message → one shot; a message array ([{role: user}, {role: assistant}, ...]) → a turn of a dialogue that lives at the session level.
State the chosen unit explicitly before recording any note. It can shift if the data demands it — trace-level notes that keep surfacing "the agent never remembers earlier turns" should pivot the next batch to session. The unit is a starting hypothesis, not a contract.
Every artifact this workflow produces — open-coding notes, axial-coding labels, and the local sidecar files — is tagged with one coding annotation identifier so the run is queryable and revertible as a unit. Pick a descriptive, unique value before recording, e.g. coding-run:chatbot-context-loss-2026-05-06. (The coding-run: prefix is a naming convention; the value is not a Phoenix session id.)
Pass the identifier explicitly on every server write. A shell variable is fine for readability, but do not rely on shell inheritance — many agent harnesses spawn each command in a fresh subshell.
The local sidecars live at .px/coding/<sanitized-identifier>.jsonl (open-coding notes) and .px/coding/<sanitized-identifier>-axial.jsonl (axial labels), CWD-relative; sanitization replaces every character outside [a-zA-Z0-9_-] with -. Uniqueness is a local file check, not a server query:
CODING_ANNOTATION_IDENTIFIER="coding-run:chatbot-context-loss-2026-05-06"
SLUG=$(echo -n "$CODING_ANNOTATION_IDENTIFIER" | sed 's/[^a-zA-Z0-9_-]/-/g')
NOTES_SIDECAR=".px/coding/${SLUG}.jsonl"
AXIAL_SIDECAR=".px/coding/${SLUG}-axial.jsonl"
test ! -f "$NOTES_SIDECAR" || { echo "Sidecar already exists at $NOTES_SIDECAR — pick a new identifier or delete the file"; exit 1; }
mkdir -p .px/codingIf $NOTES_SIDECAR already exists, append a disambiguator (-v2, -dustin, etc.) and re-check.
Resuming a run — the two phases may run in independent invocations. When axial coding starts in a fresh shell, set the identifier to the same value chosen during open coding (recoverable from the wrap-up UI URL or by listing .px/coding/*.jsonl), skip the uniqueness check, and re-derive the sidecar paths from it. Never mint a new identifier mid-run.
The steps below name operations, not commands. Two surfaces implement them; use whichever the harness gives you. The phoenix-cli skill documents the px flags, and the phoenix-graphql skill's references (project-spans-traces.md, sessions.md, annotations.md) document the GraphQL fields and mutation inputs. This skill ships no reference files of its own.
| Operation | Phoenix CLI (px) | GraphQL (phoenix-gql or any client) |
|---|---|---|
| Sample, expand, drill | px trace list, px trace get <trace-id>, px span get <span-id>, px session get <session-id> | Project.spans(rootSpansOnly: true), getTraceByOtelId, getSpanByOtelId, node(id:) on a session |
| Check existing notes | --include-notes on get/list | Span.spanNotes; Trace.traceAnnotations / ProjectSession.sessionAnnotations where name == "note" |
| Write a note | px trace add-note <trace-id> --text "..." --identifier "$CODING_ANNOTATION_IDENTIFIER" (also px span add-note, px session add-note) | createTraceNotes, createSpanNotes, createProjectSessionNotes — each takes { note, annotatorKind: LLM, source: API, identifier } plus an entity reference by OTel id ({ otelId }) or node id ({ id }) |
| Write an axial annotation | px trace annotate <trace-id> --name <annotation-name> --label <label> --identifier "$CODING_ANNOTATION_IDENTIFIER" (also span, session) | createTraceAnnotations, createSpanAnnotations, createProjectSessionAnnotations — Phoenix node ids only, plus name, label, explanation, annotatorKind: LLM, source: API, metadata: {}, identifier |
| Annotation config | px annotation-config list, create, update <identifier> | createAnnotationConfig, updateAnnotationConfig, addAnnotationConfigToProject |
| Discard the run | px trace-annotations delete --identifier "$CODING_ANNOTATION_IDENTIFIER" --all -y (also span-annotations, session-annotations) | No identifier-filtered sweep: collect the run's annotation node ids, then deleteTraceAnnotations, deleteSpanAnnotations, deleteProjectSessionAnnotations |
Note and annotation writes are synchronous and upsert on (entity, name, identifier). Where the harness gates mutations behind an approval step, describe the write each command makes and keep every mutation in its own call.
Free-form note-writing. Write what you saw, not the category you think it belongs to — categorization is phase 2.
Don't sample by span status
ERROR. OTel'sstatus_codeonly flips toERRORwhen an instrumentor catches a raised exception. Hallucinations, wrong tone, retrieval misses, and bad tool selection all complete cleanly asOKorUNSET— filtering to error status excludes the population this workflow exists to surface.
A checklist, not a taxonomy — categories come later. Note the first thing that goes wrong; a downstream symptom gets its own note only if it has an independent cause.
Treat existing evals and annotations as one input among many. Read content, not status: a success status can hide an error in the attributes, and an exception can be expected behavior.
Whatever the tooling, the fetches are: sample recent traces (trace id, root span name, status, root-span input.value / output.value); expand one trace into its spans ordered by start time; drill into a single span by id when the unit is the span; and check existing notes on entities you are about to review — notes are stored server-side as annotations with the reserved name note. As always, be aware that the data may be verbose, so take care not to blow up the context.
For each session, trace, or span you inspect, submit a note to the server (see Where the writes go) and also save a local copy. Every write should carry the note text and an explicit identifier set to the coding annotation identifier. Record as you go, entity by entity — do not batch the notes into a closing summary.
After every successful note write to the server, append one JSONL line to $NOTES_SIDECAR — the sidecar is what axial coding reads, with no server round-trip:
{"entity_kind":"trace","entity_id":"<trace-id>","note":"<text>","identifier":"<original identifier value, unsanitized>","ts":"<ISO-8601 UTC>"}| Weak note | Good note |
|---|---|
| "Wrong answer" | "Said the store closes at 6pm but policy is 9pm" |
| "Retrieval issue" | "Retrieved docs about shipping when the question was about returns" |
Stop when observations stop being new: the last 10–15 entities repeat failures you've already seen, you catch yourself paraphrasing earlier notes, or skips outnumber notes. Resist grouping into categories while still collecting. You do not need to annotate every trace — annotating correct ones dilutes signal.
At saturation, decide whether to continue into axial coding:
Turn the open-coding notes into a small set of annotations, each judging one dimension of the entity: one question about its behavior, answered with a few labels. An entity carries one annotation per dimension its note touches, so a trace can end up with two or three. The output is several narrow annotations with two to four labels each — not one wide <app>_failure_mode annotation whose labels span unrelated concerns. A wide annotation cannot be aggregated per dimension, cannot be reused by an eval that judges one thing, and forces one label onto entities that failed in two ways.
$NOTES_SIDECAR. An absent file means open coding hasn't run for this identifier in this CWD — stop and run phase 1 first. The newest ts per entity wins; fix or drop a malformed line without touching its neighbors. To include notes from other reviewers or earlier runs, fetch them from the server (annotations with the reserved name note).tool_selection, answer_faithfulness) and choose its labels: the failure outcomes the notes describe, plus one passing value so an eval can later apply the same annotation to entities that pass. Labels within a dimension are mutually exclusive; if two labels could both be true of one entity, they belong to different dimensions.$AXIAL_SIDECAR: group the current rows by (annotation_name, axial_label), newest ts per (entity, annotation_name) wins. No server query — the sidecar holds exactly the labels this run wrote.tool_selection), the label is the answer (hallucinated_tool). Judging tool choice and harness stability at once means two annotations.retrieval or tool_use is a dimension name, not a label. If a label reads as a noun for a part of the system, promote it to its own annotation and describe its outcomes as the labels.Axial coding inherits open coding's unit by default, but an annotation can live at a different level than the note that informed it, in any direction: trace-level "answered shipping when asked about returns" notes can produce a span-level retrieval_relevance label on the retrieval span once retrieval emerges as the consistent culprit; trace-level single-turn-confusion notes can produce a session-level context_tracking label once the pattern is "doesn't track context across turns"; a session-level drift note can attribute to one specific turn and produce a trace-level label. Write each annotation at the level its dimension actually implicates — different dimensions in one run may live at different levels.
Categorize the entities you took notes on: $NOTES_SIDECAR is the source of candidates, and labels are written only after reading the note text and surrounding context. Do not select entities by error status — that captures only raised exceptions and excludes most failure modes (hallucination, wrong tone, retrieval miss).
Once the dimensions have stabilized — after Group and Define, not per-write — register one categorical annotation config per dimension before recording:
tool_selection, answer_faithfulness); that is how Phoenix links them. Never a generic category, and never one config that bundles every dimension.Annotations write fine without a config, but the config is what makes a dimension first-class in the Phoenix UI: human annotators get its labels as a dropdown instead of free text, and later runs inherit a shared vocabulary instead of drifting. If a new label emerges mid-recording, add it to that dimension's config before writing with it.
An annotation is useful later only if it is filterable, aggregatable, and auditable. So:
tool_selection = hallucinated_tool, not hallucinated_tool = true, and not failure_mode = hallucinated_tool._v2 suffixes, and no borrowing another project's config because the name fits.For each entity and each dimension its note touches, write one annotation with:
retrieval_relevanceoff_topicLLM for your own judgment, HUMAN only for one the user gave youThe server also accepts an optional score. Writes upsert on (entity_id, name, identifier), so the same entity can carry several annotations under one identifier as long as their names differ. The server's default write mode enqueues asynchronously — prefer a synchronous mode where the tooling offers one, so the row is applied before continuing.
After each write, append one JSONL row to $AXIAL_SIDECAR — one row per annotation, so an entity labelled on two dimensions gets two rows:
{"entity_kind":"trace","entity_id":"<trace-id>","annotation_name":"<annotation-name>","axial_label":"<label>","explanation":"<optional explanation>","identifier":"<original identifier value, unsanitized>","ts":"<ISO-8601 UTC>"}entity_kind ("trace", "span", or "session") matches the level the annotation was written at; identifier is the original unsanitized value — the sanitized form lives only in the filename. To revise a label, replace the row or append a newer one: the newest ts per (entity_kind, entity_id, annotation_name) is current, matching the server upsert.
Each key is an annotation name; its values are that annotation's labels. A run produces a few of these, not a tree.
tool_selection: [correct, wrong_tool, hallucinated_tool, unnecessary_call]
retrieval_relevance: [relevant, off_topic, nothing_retrieved]
answer_faithfulness: [grounded, invented_fact, invented_citation]
context_tracking: [kept, ignored_preference, goal_drift]
task_completion: [completed, partial, abandoned]Applies whether the run ends after phase 1 or phase 2.
If axial coding ran, list the dimensions you wrote and say for each whether you reused, extended, or created its annotation config — the rubric is the user's to weigh in on. Link the dimensions to the project's configuration page, <endpoint>/projects/<project-node-id>/config, which lists that project's annotation configs next to its annotations — not the instance-wide annotation settings page, which mixes in every other project's configs.
Share Phoenix UI links with the user: one per level — span, trace, session — that actually carries this run's notes or annotations, filtered to the run's work. Skip levels with none. Each tab reads its filter from its own search param; an unrecognized or misspelled param is silently dropped, leaving an unfiltered table.
Filter on the coding annotation identifier — annotation accessors expose .identifier alongside .label, .score, and .explanation — so each link shows exactly this run's notes and axial annotations. Add one <annotation-name> clause per dimension written at that level, joined with or; drop them if axial coding did not run:
| Level annotated | Tab and search param | Filter expression |
|---|---|---|
| span | /spans?spanFilterCondition= | annotations['note'].identifier == '<id>' or annotations['<annotation-name>'].identifier == '<id>' |
| trace | /traces?traceFilterCondition= | trace_annotations['note'].identifier == '<id>' or trace_annotations['<annotation-name>'].identifier == '<id>' |
| session | /sessions?sessionFilterCondition= | session_annotations['note'].identifier == '<id>' or session_annotations['<annotation-name>'].identifier == '<id>' |
URL-encode each expression into its tab's param:
<endpoint>/projects/<project-node-id>/<tab>?<param>=<encoded-expression>When citing an individual finding, link directly to the annotated entity as well as sharing the filtered tables. For a trace, use <endpoint>/projects/<project-node-id>/traces/<otel-trace-id>. For a span, use that trace URL with selectedSpanNodeId=<span-node-id> so the annotated span opens selected. Resolve the span's Relay node ID and containing OpenTelemetry trace ID from the fetched data; the selection parameter does not accept an OpenTelemetry span ID. Preserve existing search parameters such as timeRangeKey=30d and URL-encode the added value.
Discarding the run — only with the user's explicit confirmation, since it is destructive. For each of trace, span, and session, delete the project's annotations filtered to the coding annotation identifier; this removes every dimension's labels at once, since they share the identifier. The server requires an explicit delete-all flag (or a time bound) to authorize the sweep; the identifier filter narrows but never authorizes on its own. Then remove $NOTES_SIDECAR and $AXIAL_SIDECAR. Each per-kind delete removes notes and axial annotations together because they share the underlying annotation table.
e127482
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.