
Exploring Llm Traces
- 103 installs
- 70 repo stars
- Updated August 4, 2026
- posthog/ai-plugin
exploring-llm-traces is a Claude Code skill for ai & agent building.
About
exploring-llm-traces is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- exploring-llm-traces
- AI & Agent Building
- AI-coding skill
Exploring Llm Traces by the numbers
- 103 all-time installs (skills.sh)
- +5 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,249 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/posthog/ai-plugin --skill exploring-llm-tracesAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 103 |
|---|---|
| repo stars | ★ 70 |
| Last updated | August 4, 2026 |
| Repository | posthog/ai-plugin ↗ |
How do I helps with ai & agent building tasks during AI-assisted development.?
Helps with ai & agent building tasks during AI-assisted development.
Who is it for?
Best when you're working on ai & agent building and need structured help with exploring llm traces.
Skip if: Teams with no ai & agent building needs, or anyone wanting a generic chat assistant without this specific workflow.
When should I use this skill?
When you need to helps with ai & agent building tasks during AI-assisted development., or when exploring-llm-traces is a claude code skill for ai & agent building.
What you get
Structured output aligned to exploring-llm-traces: exploring-llm-traces, AI & Agent Building.
Files
Exploring LLM traces with MCP tools
PostHog captures LLM/AI agent activity as traces. Each trace is a tree of events representing a single AI interaction — from the top-level agent invocation down to individual LLM API calls.
Available tools
| Tool | Purpose |
|---|---|
posthog:query-llm-traces-list | Search and list traces; can return large multi-trace payloads |
posthog:query-llm-trace | Get a single trace by ID with full event tree |
posthog:read-data-schema | Discover custom event/person properties before filtering |
posthog:execute-sql | Ad-hoc SQL for complex trace analysis |
Event hierarchy
See the event reference for the full schema.
$ai_trace (top-level container)
└── $ai_span (logical groupings, e.g. "RAG retrieval", "tool execution")
├── $ai_generation (individual LLM API call)
└── $ai_embedding (embedding creation)Events are linked via $ai_parent_id → parent's $ai_span_id or $ai_trace_id.
Workflow: debug a trace or session from a URL
Step 1 — Classify the URL
First inspect the path. Do not treat every UUID-looking value as a trace ID.
/ai-observability/traces/<trace_id>or legacy/llm-analytics/traces/<trace_id>//llm-observability/traces/<trace_id>is a single trace. Fetch it withposthog:query-llm-trace./ai-observability/sessions/<session_id>or legacy/llm-analytics/sessions/<session_id>is an AI session, not a trace. Fetch traces withposthog:query-llm-traces-listfiltered by event property$ai_session_id.
Preserve date_from / date_to query parameters from the URL when present. If none are present but the URL has a timestamp query parameter, use that timestamp as the anchor and query an absolute window around it, for example timestamp - 36h to timestamp + 36h. This handles exact session links whose UI timestamp may be offset from the stored event timestamps while keeping the query bounded. If the URL has neither explicit dates nor timestamp, use a safe default like {"date_from": "-7d"}.
For exact trace and session URLs, skip schema discovery for the standard $ai_* fields used below. These are AI observability built-ins, not project-specific custom properties.
Step 2 — Fetch trace data
For a trace URL, call posthog:query-llm-trace with:
{
"traceId": "<trace_id>",
"dateRange": { "date_from": "-7d" }
}For a session URL, call posthog:query-llm-traces-list with:
{
"dateRange": { "date_from": "<timestamp_minus_36h>", "date_to": "<timestamp_plus_36h>" },
"filterTestAccounts": false,
"limit": 20,
"properties": [{ "type": "event", "key": "$ai_session_id", "value": ["<session_id>"], "operator": "exact" }]
}Use the URL's date_from / date_to values in the session query if present. If the URL only has timestamp, calculate the absolute date range from that timestamp instead of using a relative range like -1h. Set filterTestAccounts: false for an exact URL so the requested trace is not hidden by account filters.
The result contains the event tree with all properties. The response may be large — when it exceeds the inline limit, Claude Code auto-persists it to a file.
From the result you get:
- Every event with its type (
$ai_span,$ai_generation, etc.) - Span names (
$ai_span_name) — these are the tool/step names - Latency, error flags, models used
- Parent-child relationships via
$ai_parent_id _posthogUrl— always include this in your response so the user can click through to the UI
Step 3 — Parse large results with scripts
When the result is persisted to a file (large traces with full $ai_input/$ai_output_choices), use the parsing scripts to explore it.
Start with the summary to get the full picture, then drill into specifics:
# 1. Overview: metadata, tool calls, final output, errors
python3 scripts/print_summary.py /path/to/persisted-file.json
# 2. Timeline: chronological event list with truncated I/O
python3 scripts/print_timeline.py /path/to/persisted-file.json
# 3. Drill into a specific span's full input/output
SPAN="tool_name" python3 scripts/extract_span.py /path/to/persisted-file.json
# 4. Full conversation with thinking blocks and tool calls
python3 scripts/extract_conversation.py /path/to/persisted-file.json
# 5. Search for a keyword across all properties
SEARCH="keyword" python3 scripts/search_traces.py /path/to/persisted-file.jsonAll scripts support MAX_LEN=N env var to control truncation (0 = unlimited).
Investigation patterns
"Did the agent use the tool correctly?"
1. Find the $ai_span for the tool call (look at $ai_span_name) 2. Check $ai_input_state — what arguments were passed to the tool? 3. Check $ai_output_state — what did the tool return? 4. Check $ai_is_error — did the tool call fail?
"Was the context correct?" / "Were the right files surfaced?"
1. Find the $ai_generation event where the LLM made the decision 2. Check $ai_input — this is the full message history the LLM saw 3. Look at preceding $ai_span events for retrieval/search steps 4. Check their $ai_output_state — what content was retrieved and fed to the LLM?
"Did the subagent work?"
1. In the structural overview, find spans that are children of other spans (via $ai_parent_id) 2. The parent span is the orchestrator; child spans are subagent steps 3. Check each child's $ai_output_state and $ai_is_error 4. If a child span contains $ai_generation events, those are the subagent's LLM calls
"Why did the LLM say X?"
1. Use search_traces.py to find where the text appears: SEARCH="the text" python3 scripts/search_traces.py FILE 2. This shows which event and property path contains it 3. Check the $ai_input of that generation to see what the LLM was told before it said X
Constructing UI links
The trace tools return _posthogUrl — always surface this to the user.
You can also construct links manually:
- Trace detail:
https://app.posthog.com/ai-observability/traces/<trace_id>?timestamp=<url_encoded_timestamp>&event=<optional_event_id> - Traces list with filters: returned in
_posthogUrlfromquery-llm-traces-list
The timestamp query param is required — use the createdAt of the earliest event in the trace, URL-encoded (e.g. timestamp=2026-04-01T19%3A39%3A20Z).
When presenting findings, always include the relevant PostHog URL so the user can verify.
Finding traces
Use posthog:query-llm-traces-list to search and filter traces.
CRITICAL: Never assume event names, property names, or property values from training data. Every project instruments different custom properties. For open-ended searches and custom filters, call posthog:read-data-schema first to discover what properties and values actually exist in the project's data before constructing filters.
The exception is exact AI observability trace/session URLs: use the built-in $ai_trace_id / $ai_session_id fields directly and skip schema discovery.
Discovering the schema first
Before filtering traces, discover what's available:
1. Confirm AI events exist — call posthog:read-data-schema with kind: "events" and look for $ai_* events 2. Find filterable properties — call posthog:read-data-schema with kind: "event_properties" and event_name: "$ai_generation" (or another AI event) to see what properties are captured 3. Get actual values — call posthog:read-data-schema with kind: "event_property_values", event_name: "$ai_generation", and property_name: "$ai_model" to see real model names in use
Only then construct the query-llm-traces-list call with property filters.
This is especially important for custom properties like project_id, conversation_id, user_tier, etc. — these vary per project and cannot be guessed.
Do not confirm $ai_* properties, but confirm any other like email of a person.
By filters
posthog:query-llm-traces-list
{
"dateRange": {"date_from": "-1h"},
"filterTestAccounts": true,
"limit": 20,
"properties": [
{"type": "event", "key": "$ai_model", "value": "gpt-4o", "operator": "exact"}
]
}Multiple filters are AND-ed together:
posthog:query-llm-traces-list
{
"dateRange": {"date_from": "-1h"},
"filterTestAccounts": true,
"properties": [
{"type": "event", "key": "$ai_provider", "value": "anthropic", "operator": "exact"},
{"type": "event", "key": "$ai_is_error", "value": ["true"], "operator": "exact"}
]
}You can also filter by person properties (discover them via read-data-schema with kind: "entity_properties" and entity: "person"):
posthog:query-llm-traces-list
{
"dateRange": {"date_from": "-1h"},
"filterTestAccounts": true,
"properties": [
{"type": "person", "key": "email", "value": "@company.com", "operator": "icontains"}
]
}By external identifiers
Customers often store their own IDs as event or person properties. Use posthog:read-data-schema to discover what custom properties exist, then filter:
1. Call posthog:read-data-schema with kind: "event_properties" and event_name: "$ai_trace" to find custom properties 2. Review the returned properties and their sample values 3. Construct the filter using the discovered property key and a known value
posthog:query-llm-traces-list
{
"dateRange": {"date_from": "-7d"},
"properties": [
{"type": "event", "key": "project_id", "value": "proj_abc123", "operator": "exact"}
]
}For more complex SQL patterns, read these references:
- Single trace retrieval — fetches a single trace by ID with all events and properties (renders the
TraceQueryHogQL) - Traces list with aggregated metrics — two-phase query: find trace IDs first, then fetch aggregated latency, tokens, costs, and error counts
Parsing large trace results
Trace tool results are JSON. When too large to read inline, Claude Code persists them to a file.
Persisted file format
[{ "type": "text", "text": "{\"results\": [...], \"_posthogUrl\": \"...\"}" }]Trace JSON structure
results (array for list, object for single trace)
├── id, traceName, createdAt, totalLatency, totalCost
├── inputState, outputState (trace-level state)
└── events[]
├── event ($ai_span | $ai_generation | $ai_embedding | $ai_metric | $ai_feedback)
├── id, createdAt
└── properties
├── $ai_span_name, $ai_latency, $ai_is_error
├── $ai_input_state, $ai_output_state (span tool I/O)
├── $ai_input, $ai_output_choices (generation messages)
├── $ai_model, $ai_provider
└── $ai_input_tokens, $ai_output_tokens, $ai_total_cost_usdAvailable scripts
| Script | Purpose | Usage |
|---|---|---|
| `print_summary.py` | Aggregate list/session totals, trace metadata, tool calls, errors, and final LLM output | python3 scripts/print_summary.py FILE |
| `print_timeline.py` | Chronological event timeline with I/O summaries | python3 scripts/print_timeline.py FILE |
| `extract_span.py` | Full input/output of a specific span by name | SPAN="name" python3 scripts/extract_span.py FILE |
| `extract_conversation.py` | LLM messages with thinking blocks and tool calls | python3 scripts/extract_conversation.py FILE |
| `search_traces.py` | Find a keyword across all event properties | SEARCH="keyword" python3 scripts/search_traces.py FILE |
| `show_structure.py` | Show JSON keys and types without values | `cat blob.json \ |
Tips
- Always set
dateRange— queries without a time range are slow. Use narrow windows (-30m,-1h) for broad listing queries; wider windows (-7d,-30d) are fine for narrow queries filtered by trace ID or specific property values - Always include the
_posthogUrlin your response so the user can click through $ai_input_state/$ai_output_stateon spans contain tool call inputs and outputs$ai_input/$ai_output_choiceson generations contain the full LLM conversation — can be megabytes; when the result is persisted to a file, use the parsing scripts- In raw SQL, heavy content (
$ai_input/$ai_output/$ai_output_choices/$ai_input_state/$ai_output_state/$ai_tools) lives only on theposthog.ai_eventstable, notevents.properties— see the event reference for the column mapping and trace-id-anchored query patterns - Use
filterTestAccounts: trueto exclude internal/test traffic when searching $ai_traceevents are NOT in theeventsarray — their data is surfaced via trace-levelinputState,outputState, andtraceName
AI observability event and property reference
Event types
$ai_trace
Top-level container for a trace. Emitted last, after all child events.
| Property | Type | Description |
|---|---|---|
$ai_trace_id | string | Unique trace identifier — shared by all events in this trace |
$ai_trace_name | string | Name of the trace |
$ai_session_id | string | Groups multiple traces into a session |
$ai_input_state | JSON | Application state at trace start (can be very large) |
$ai_output_state | JSON | Application state at trace end (can be very large) |
$ai_latency | float | Total trace duration in seconds |
$ai_span
Logical grouping within a trace (e.g. "RAG retrieval", "tool execution", "routing").
| Property | Type | Description |
|---|---|---|
$ai_trace_id | string | Parent trace ID |
$ai_span_id | string | Unique span identifier |
$ai_span_name | string | Name of this span |
$ai_parent_id | string | ID of parent span or trace |
$ai_latency | float | Span duration in seconds |
$ai_input_state | JSON | State entering this span |
$ai_output_state | JSON | State leaving this span |
$ai_generation
Individual LLM API call (e.g. a chat completion request).
| Property | Type | Description |
|---|---|---|
$ai_trace_id | string | Parent trace ID |
$ai_parent_id | string | ID of parent span or trace |
$ai_model | string | Model identifier (e.g. "gpt-4o", "claude-sonnet-4-20250514") |
$ai_provider | string | Provider name (e.g. "openai", "anthropic") |
$ai_input | JSON array | Input messages — {role, content} objects. Can be very large. |
$ai_output_choices | JSON array | LLM response — {message: {role, content}}. May include tool calls. |
$ai_input_tokens | int | Tokens in the input |
$ai_output_tokens | int | Tokens in the output |
$ai_input_cost_usd | float | Cost of input tokens in USD |
$ai_output_cost_usd | float | Cost of output tokens in USD |
$ai_total_cost_usd | float | Total cost in USD |
$ai_latency | float | Generation duration in seconds |
$ai_http_status | int | HTTP status from the LLM API |
$ai_is_error | boolean | Whether the generation errored |
$ai_error | string | Error message if generation failed |
$ai_base_url | string | LLM API base URL |
$ai_tools_called | string | Comma-separated tool names called by the LLM |
$ai_embedding
Embedding creation event (text to vector).
| Property | Type | Description |
|---|---|---|
$ai_trace_id | string | Parent trace ID |
$ai_parent_id | string | ID of parent span or trace |
$ai_model | string | Embedding model identifier |
$ai_provider | string | Provider name |
$ai_input_tokens | int | Tokens processed |
$ai_total_cost_usd | float | Total cost in USD |
$ai_latency | float | Duration in seconds |
Where heavy content lives: events vs ai_events
The heavy LLM properties are not stored on `events` — they live as native columns on a dedicated ClickHouse table, referenced in HogQL as `posthog.ai_events`. The events table keeps only the lightweight metadata (token counts, costs, model, provider, $ai_trace_id, latency, error flags).
| Heavy content | events property | ai_events column |
|---|---|---|
| Input messages | $ai_input | input |
| Output | $ai_output | output |
| Output choices | $ai_output_choices | output_choices |
| Input state | $ai_input_state | input_state |
| Output state | $ai_output_state | output_state |
| Tools | $ai_tools | tools |
posthog.ai_events is ORDER BY (team_id, trace_id, timestamp), so `trace_id` is the access path, not `timestamp`. Rows are dropped after the retention period (30 days by default), so traces older than that have no content. Nothing restricts which heavy columns an event can carry, but the typical shape is: $ai_generation carries input / output_choices / tools (embeddings carry input); $ai_span and $ai_trace carry input_state / output_state.
For trace inspection, prefer the query-llm-trace / query-llm-traces-list tools — they read posthog.ai_events for you. Drop to the SQL below only for custom analysis (aggregations, joins, batch extraction) or when you're already at the SQL layer.
Single trace — when you already have a trace_id (e.g. from a trace URL or query-llm-traces-list): read it directly.
SELECT timestamp, span_id, event, model, input, output_choices
FROM posthog.ai_events
WHERE trace_id = '<trace_id>'
ORDER BY timestampBatch / analytics (a time window across many traces): filter on the timestamp-indexed events table first to get the trace IDs, then fetch the heavy content from posthog.ai_events anchored on trace_id.
WITH matching_traces AS (
SELECT DISTINCT properties.$ai_trace_id AS trace_id
FROM events
WHERE event = '$ai_generation'
AND timestamp >= now() - INTERVAL 7 DAY
AND properties.$ai_model = 'gpt-4o' -- token/cost/model/ids stay on events
)
SELECT a.trace_id, a.span_id, a.model, a.input, a.output_choices
FROM posthog.ai_events AS a
WHERE a.trace_id IN (SELECT trace_id FROM matching_traces)
ORDER BY a.trace_id, a.timestampCommon patterns
Linking events in a trace
All events share $ai_trace_id. The hierarchy is built via $ai_parent_id:
$ai_trace (id: "trace-1", $ai_trace_id: "trace-1")
└── $ai_span (id: "span-1", $ai_trace_id: "trace-1", $ai_parent_id: "trace-1")
└── $ai_generation (id: "gen-1", $ai_trace_id: "trace-1", $ai_parent_id: "span-1")Cost aggregation
Costs are only on $ai_generation and $ai_embedding events. Sum $ai_total_cost_usd across these for the same $ai_trace_id to get total trace cost.
Large properties warning
These properties can contain megabytes of data:
$ai_input— full conversation history, system prompts$ai_input_state/$ai_output_state— application state snapshots
Use contentDetail: "preview" or "none" when querying via MCP tools. When using contentDetail: "full", dump results to a file.
In raw SQL these live only on posthog.ai_events, not events.properties — see Where heavy content lives for the column mapping and query patterns.
LLM Trace query
This query might return a very large blob of JSON data. You should either only include data you need in case it's minimal or dump the results to a file and use bash commands to explore it. This query must always have time ranges set. You can calculate the time range as -30 to +30 minutes from the source event. The typical order of event capture for a trace is: $ai_span -> $ai_generation/$ai_embedding -> $ai_trace. Explore $ai\_\*-prefixed properties to find data related to traces, generations, embeddings, spans, feedback, and metric. Key properties of the $ai_generation event: $ai_input and $ai_output_choices.
IMPORTANT: The $ai_input, $ai_input_state, and $ai_output_state properties can be extremely large (containing full conversation histories, system prompts, or application state). When your query selects these properties, you MUST dump the results to a file and use bash commands to explore the output. Never output them directly into the conversation.
This content lives only on posthog.ai_events (read it directly by trace_id), not on events.properties — see where heavy content lives.
SELECT
trace_id AS id,
any(session_id) AS ai_session_id,
min(timestamp) AS first_timestamp,
max(timestamp) AS last_timestamp,
ifNull(nullIf(argMinIf(distinct_id, timestamp, equals(event, '$ai_trace')), ''), argMin(distinct_id, timestamp)) AS first_distinct_id,
round(if(and(equals(countIf(and(greater(latency, 0), notEquals(event, '$ai_generation'))), 0), greater(countIf(and(greater(latency, 0), equals(event, '$ai_generation'))), 0)), sumIf(latency, and(equals(event, '$ai_generation'), greater(latency, 0))), sumIf(latency, or(equals(parent_id, NULL), equals(parent_id, trace_id)))), 2) AS total_latency,
nullIf(sumIf(input_tokens, in(event, tuple('$ai_generation', '$ai_embedding'))), 0) AS input_tokens,
nullIf(sumIf(output_tokens, in(event, tuple('$ai_generation', '$ai_embedding'))), 0) AS output_tokens,
nullIf(round(sumIf(input_cost_usd, in(event, tuple('$ai_generation', '$ai_embedding'))), 10), 0) AS input_cost,
nullIf(round(sumIf(output_cost_usd, in(event, tuple('$ai_generation', '$ai_embedding'))), 10), 0) AS output_cost,
nullIf(round(sumIf(total_cost_usd, in(event, tuple('$ai_generation', '$ai_embedding'))), 10), 0) AS total_cost,
arrayDistinct(arraySort(x -> x.3, groupArrayIf(tuple(uuid, event, timestamp, properties, input, output, output_choices, input_state, output_state, tools), notEquals(event, '$ai_trace')))) AS events,
argMinIf(input_state, timestamp, equals(event, '$ai_trace')) AS input_state,
argMinIf(output_state, timestamp, equals(event, '$ai_trace')) AS output_state,
ifNull(argMinIf(ifNull(nullIf(span_name, ''), nullIf(trace_name, '')), timestamp, equals(event, '$ai_trace')), argMin(ifNull(nullIf(span_name, ''), nullIf(trace_name, '')), timestamp)) AS trace_name
FROM
ai_events
WHERE
and(in(event, tuple('$ai_span', '$ai_generation', '$ai_embedding', '$ai_metric', '$ai_feedback', '$ai_trace')), and(greaterOrEquals(ai_events.timestamp, assumeNotNull(toDateTime('2025-12-09 23:35:41'))), lessOrEquals(ai_events.timestamp, assumeNotNull(toDateTime('2025-12-10 00:25:41'))), equals(trace_id, '79955c94-7453-488f-a84a-eabb6f084e4c')))
GROUP BY
trace_id
LIMIT 1LLM Traces list query
List multiple LLM traces with aggregated latency, token usage, costs, and error counts. This is a two-phase query for performance: first find matching trace IDs, then fetch full trace data. Time ranges are always required. Results can be large — dump to a file if needed.
This query intentionally omits large content fields ($ai_input, $ai_output, $ai_output_choices, $ai_input_state, $ai_output_state, $ai_tools). These live only on the dedicated posthog.ai_events table (not events), retained 30 days by default. Use the single trace query (or the query-llm-trace wrapper) to retrieve them for a specific trace, or read posthog.ai_events directly anchored on trace_id — see where heavy content lives for the column mapping.
Phase 1 — Find trace IDs
Use this subquery to find trace IDs matching your criteria. Add property filters here for efficiency.
SELECT
properties.$ai_trace_id AS trace_id,
min(timestamp) AS first_ts,
max(timestamp) AS last_ts
FROM events
WHERE
event IN ('$ai_span', '$ai_generation', '$ai_embedding', '$ai_metric', '$ai_feedback', '$ai_trace')
AND isNotNull(properties.$ai_trace_id)
AND properties.$ai_trace_id != ''
AND timestamp >= now() - INTERVAL 1 HOUR
AND timestamp <= now()
-- Add property filters here, e.g.:
-- AND properties.$ai_model = 'gpt-4o'
-- AND properties.$ai_is_error = 'true'
GROUP BY trace_id
ORDER BY min(timestamp) DESC
LIMIT 20Phase 2 — Fetch trace data
Use the trace IDs from phase 1 to fetch aggregated metrics. Replace the IN (...) clause with the IDs found above.
SELECT
properties.$ai_trace_id AS id,
any(properties.$ai_session_id) AS ai_session_id,
min(timestamp) AS first_timestamp,
ifNull(
nullIf(argMinIf(distinct_id, timestamp, event = '$ai_trace'), ''),
argMin(distinct_id, timestamp)
) AS first_distinct_id,
round(
CASE
WHEN countIf(toFloat(properties.$ai_latency) > 0 AND event != '$ai_generation') = 0
AND countIf(toFloat(properties.$ai_latency) > 0 AND event = '$ai_generation') > 0
THEN sumIf(toFloat(properties.$ai_latency),
event = '$ai_generation' AND toFloat(properties.$ai_latency) > 0)
ELSE sumIf(toFloat(properties.$ai_latency),
properties.$ai_parent_id IS NULL
OR toString(properties.$ai_parent_id) = toString(properties.$ai_trace_id))
END, 2
) AS total_latency,
sumIf(toFloat(properties.$ai_input_tokens),
event IN ('$ai_generation', '$ai_embedding')) AS input_tokens,
sumIf(toFloat(properties.$ai_output_tokens),
event IN ('$ai_generation', '$ai_embedding')) AS output_tokens,
round(sumIf(toFloat(properties.$ai_input_cost_usd),
event IN ('$ai_generation', '$ai_embedding')), 10) AS input_cost,
round(sumIf(toFloat(properties.$ai_output_cost_usd),
event IN ('$ai_generation', '$ai_embedding')), 10) AS output_cost,
round(sumIf(toFloat(properties.$ai_total_cost_usd),
event IN ('$ai_generation', '$ai_embedding')), 10) AS total_cost,
ifNull(
argMinIf(
ifNull(properties.$ai_span_name, properties.$ai_trace_name),
timestamp, event = '$ai_trace'
),
argMin(
ifNull(properties.$ai_span_name, properties.$ai_trace_name),
timestamp
)
) AS trace_name,
countIf(
isNotNull(properties.$ai_error) OR properties.$ai_is_error = 'true'
) AS error_count
FROM events
WHERE
event IN ('$ai_span', '$ai_generation', '$ai_embedding', '$ai_metric', '$ai_feedback', '$ai_trace')
AND timestamp >= now() - INTERVAL 1 HOUR
AND timestamp <= now()
AND properties.$ai_trace_id IN ('trace-id-1', 'trace-id-2')
GROUP BY properties.$ai_trace_id
ORDER BY first_timestamp DESC"""Extract user/assistant messages from LLM generation events in a trace.
Env vars:
MAX_LEN — truncation limit per message (default 500, 0 for unlimited)
"""
import json
import os
import sys
def load_trace_file(path):
with open(path) as f:
raw = json.load(f)
# Claude Code persists large MCP tool results as [{"type": "text", "text": "<json>"}] — unwrap to get the actual trace data.
if isinstance(raw, list) and raw and raw[0].get("type") == "text":
raw = json.loads(raw[0]["text"])
# Both query-llm-trace and query-llm-traces-list return {"results": [...]}, but handle a bare trace object too.
results = raw.get("results", raw)
return [results] if isinstance(results, dict) else results
def truncate(text, max_len):
if max_len <= 0 or len(text) <= max_len:
return text
half = max_len // 2
return text[:half] + f"\n ... [{len(text)} chars] ...\n " + text[-half:]
def format_content(content, max_len):
"""Format message content, preserving thinking/text/tool_use structure."""
if isinstance(content, str):
return truncate(content, max_len)
if not isinstance(content, list):
return str(content)
parts = []
for item in content:
if not isinstance(item, dict):
parts.append(str(item))
continue
item_type = item.get("type", "")
if item_type == "thinking":
thinking = item.get("thinking", "")
parts.append(f" [thinking] {truncate(thinking, max_len)}")
elif item_type == "text":
parts.append(f" {truncate(item.get('text', ''), max_len)}")
elif item_type == "tool_use":
name = item.get("name", "?")
tool_input = json.dumps(item.get("input", {}), default=str)
parts.append(f" [tool_use: {name}] {truncate(tool_input, max_len)}")
elif item_type == "tool_result":
tool_id = item.get("tool_use_id", "?")
result_content = item.get("content", "")
if isinstance(result_content, list):
result_content = " ".join(
p.get("text", "") for p in result_content if isinstance(p, dict)
)
parts.append(f" [tool_result: {tool_id}] {truncate(str(result_content), max_len)}")
else:
parts.append(f" [{item_type}] {truncate(json.dumps(item, default=str), max_len)}")
return "\n".join(parts)
max_len = int(os.environ.get("MAX_LEN", "500"))
traces = load_trace_file(sys.argv[1])
for trace in traces:
for ev in sorted(trace.get("events", []), key=lambda e: e.get("createdAt", "")):
if ev.get("event") != "$ai_generation":
continue
p = ev.get("properties", {})
messages = p.get("$ai_input")
if not isinstance(messages, list):
continue
model = p.get("$ai_model", "?")
print(f"\n{'='*80}")
print(f"Generation: {model} ({ev.get('createdAt', '?')})")
print(f"{'='*80}")
for msg in messages:
role = msg.get("role", "?")
content = msg.get("content", "")
# Show tool_calls on assistant messages
tool_calls = msg.get("tool_calls", [])
print(f"\n[{role.upper()}]")
print(format_content(content, max_len))
if tool_calls:
for tc in tool_calls:
fn = tc.get("function", tc)
name = fn.get("name", "?")
args = fn.get("arguments", "{}")
if isinstance(args, str):
args_str = args
else:
args_str = json.dumps(args, default=str)
print(f" [tool_call: {name}] {truncate(args_str, max_len)}")
# Show output choices
choices = p.get("$ai_output_choices", [])
if choices:
print(f"\n[ASSISTANT (output)]")
for choice in choices:
print(format_content(choice.get("content", ""), max_len))
"""Extract a specific span's full input/output state by name.
Usage:
SPAN="upsert_dashboard" python3 scripts/extract_span.py FILE
SPAN="router" python3 scripts/extract_span.py FILE
Env vars:
SPAN — span name to match (case-insensitive substring match)
MAX_LEN — truncation limit (default 0 = unlimited)
"""
import json
import os
import sys
def load_trace_file(path):
with open(path) as f:
raw = json.load(f)
if isinstance(raw, list) and raw and raw[0].get("type") == "text":
raw = json.loads(raw[0]["text"])
results = raw.get("results", raw)
return [results] if isinstance(results, dict) else results
def truncate(text, max_len):
if max_len <= 0 or len(text) <= max_len:
return text
return text[:max_len] + f"... [{len(text)} chars total]"
span_filter = os.environ.get("SPAN", "").lower()
if not span_filter:
print("Usage: SPAN='span_name' python3 extract_span.py file.json", file=sys.stderr)
sys.exit(1)
max_len = int(os.environ.get("MAX_LEN", "0"))
traces = load_trace_file(sys.argv[1])
found = 0
for trace in traces:
events = sorted(trace.get("events", []), key=lambda e: e.get("createdAt", ""))
for ev in events:
if ev.get("event") != "$ai_span":
continue
p = ev.get("properties", {})
name = p.get("$ai_span_name", "")
if span_filter not in name.lower():
continue
found += 1
error = " [ERROR]" if p.get("$ai_is_error") else ""
print(f"\n{'='*80}")
print(f"SPAN: {name} ({p.get('$ai_latency', '?')}s){error}")
print(f"Created: {ev.get('createdAt', '?')}")
print(f"Parent: {p.get('$ai_parent_id', '(root)')}")
print(f"{'='*80}")
inp = p.get("$ai_input_state")
out = p.get("$ai_output_state")
if inp is not None:
formatted = json.dumps(inp, indent=2, default=str) if not isinstance(inp, str) else inp
print(f"\n--- INPUT STATE ---")
print(truncate(formatted, max_len))
if out is not None:
formatted = json.dumps(out, indent=2, default=str) if not isinstance(out, str) else out
print(f"\n--- OUTPUT STATE ---")
print(truncate(formatted, max_len))
if not inp and not out:
print("\n (no input_state or output_state)")
if found == 0:
print(f"No spans matching '{span_filter}' found.", file=sys.stderr)
sys.exit(1)
"""Print a concise trace summary: metadata, tool calls, and final LLM output."""
import json
import os
import sys
def load_trace_file(path):
with open(path) as f:
raw = json.load(f)
if isinstance(raw, list) and raw and raw[0].get("type") == "text":
raw = json.loads(raw[0]["text"])
metadata = raw if isinstance(raw, dict) else {}
results = raw.get("results", raw) if isinstance(raw, dict) else raw
return ([results] if isinstance(results, dict) else results), metadata
def summarize(val, max_len=500):
if val is None:
return ""
s = json.dumps(val, default=str) if not isinstance(val, str) else val
return s[:max_len] + "..." if len(s) > max_len else s
def extract_final_output(choices):
"""Extract the text and thinking from the last generation's output choices."""
if not isinstance(choices, list):
return None, None
parts_text = []
parts_thinking = []
for choice in choices:
content = choice.get("content", "")
if isinstance(content, str):
parts_text.append(content)
elif isinstance(content, list):
for item in content:
if isinstance(item, dict):
if item.get("type") == "thinking":
parts_thinking.append(item.get("thinking", ""))
elif item.get("type") == "text":
parts_text.append(item.get("text", ""))
return "\n".join(parts_text) or None, "\n".join(parts_thinking) or None
def as_float(value):
if value is None:
return 0.0
try:
return float(value)
except (TypeError, ValueError):
return 0.0
def print_collection_summary(traces, metadata):
if len(traces) <= 1:
return
print(f"{'='*80}")
print("TRACE COLLECTION SUMMARY")
print(f"{'='*80}")
print(f" Total traces: {len(traces)}")
print(f" Total latency: {sum(as_float(t.get('totalLatency')) for t in traces):.2f}s")
print(f" Total cost: ${sum(as_float(t.get('totalCost')) for t in traces):.6f}")
print(f" Tokens in: {int(sum(as_float(t.get('inputTokens')) for t in traces))}")
print(f" Tokens out: {int(sum(as_float(t.get('outputTokens')) for t in traces))}")
print(f" Errors: {int(sum(as_float(t.get('errorCount')) for t in traces))}")
if metadata.get("_posthogUrl"):
print(f" PostHog URL: {metadata['_posthogUrl']}")
print()
max_len = int(os.environ.get("MAX_LEN", "500"))
traces, metadata = load_trace_file(sys.argv[1])
print_collection_summary(traces, metadata)
for trace in traces:
print(f"{'='*80}")
print(f"TRACE SUMMARY")
print(f"{'='*80}")
print(f" ID: {trace.get('id', '?')}")
print(f" Name: {trace.get('traceName', '?')}")
print(f" Created: {trace.get('createdAt', '?')}")
print(f" Person: {trace.get('distinctId', '?')}")
print(f" Latency: {trace.get('totalLatency', '?')}s")
print(f" Cost: ${trace.get('totalCost', '?')}")
print(f" Tokens in: {trace.get('inputTokens', '?')}")
print(f" Tokens out:{trace.get('outputTokens', '?')}")
# Trace-level input/output state
inp = trace.get("inputState")
out = trace.get("outputState")
if inp:
print(f"\n--- Trace input state ---")
print(f" {summarize(inp, max_len)}")
if out:
print(f"\n--- Trace output state (first {max_len} chars) ---")
print(f" {summarize(out, max_len)}")
events = sorted(trace.get("events", []), key=lambda e: e.get("createdAt", ""))
# Collect models used
models = set()
for ev in events:
if ev.get("event") == "$ai_generation":
m = ev["properties"].get("$ai_model")
if m:
models.add(m)
if models:
print(f"\n Models: {', '.join(sorted(models))}")
# Errors
errors = [ev for ev in events if ev.get("properties", {}).get("$ai_is_error")]
if errors:
print(f"\n{'!' * 80}")
print(f" ERRORS: {len(errors)}")
for ev in errors:
p = ev["properties"]
name = p.get("$ai_span_name", p.get("$ai_model", ev.get("event")))
print(f" - {name}: {summarize(p.get('$ai_output_state', p.get('$ai_error', '?')), max_len)}")
print(f"{'!' * 80}")
else:
print("\n Errors: None")
# Tool calls (spans with input/output state)
spans = [ev for ev in events if ev.get("event") == "$ai_span" and ev.get("properties", {}).get("$ai_input_state")]
if spans:
print(f"\n{'=' * 80}")
print(f"TOOL CALLS ({len(spans)} spans with I/O)")
print(f"{'=' * 80}")
for ev in spans:
p = ev["properties"]
name = p.get("$ai_span_name", "?")
latency = p.get("$ai_latency", "?")
error = " [ERROR]" if p.get("$ai_is_error") else ""
print(f"\n [{name}] ({latency}s){error}")
print(f" IN: {summarize(p.get('$ai_input_state'), max_len)}")
print(f" OUT: {summarize(p.get('$ai_output_state'), max_len)}")
# Final LLM output (last generation)
generations = [ev for ev in events if ev.get("event") == "$ai_generation"]
if generations:
last_gen = generations[-1]
p = last_gen["properties"]
text, thinking = extract_final_output(p.get("$ai_output_choices", []))
print(f"\n{'='*80}")
print(f"FINAL LLM OUTPUT ({p.get('$ai_model', '?')})")
print(f"{'='*80}")
if thinking:
print(f"\n [thinking] {thinking[:max_len]}{'...' if len(thinking) > max_len else ''}")
if text:
print(f"\n {text[:max_len * 2]}{'...' if len(text) > max_len * 2 else ''}")
"""Print a chronological timeline of tool calls and generations in a trace."""
import json
import sys
def load_trace_file(path):
with open(path) as f:
raw = json.load(f)
# Claude Code persists large MCP tool results as [{"type": "text", "text": "<json>"}] — unwrap to get the actual trace data.
if isinstance(raw, list) and raw and raw[0].get("type") == "text":
raw = json.loads(raw[0]["text"])
# Both query-llm-trace and query-llm-traces-list return {"results": [...]}, but handle a bare trace object too.
results = raw.get("results", raw)
return [results] if isinstance(results, dict) else results
def summarize(val, max_len=200):
if val is None:
return ""
s = json.dumps(val, default=str) if not isinstance(val, str) else val
return s[:max_len] + "..." if len(s) > max_len else s
traces = load_trace_file(sys.argv[1])
for trace in traces:
print(f"\n{'='*80}")
print(f"Trace: {trace.get('id', '?')} name={trace.get('traceName', '?')} latency={trace.get('totalLatency', '?')}s cost={trace.get('totalCost', '?')}")
print(f"{'='*80}")
# Trace-level input/output state (from $ai_trace event, not in events array)
inp = trace.get("inputState")
out = trace.get("outputState")
if inp:
print(f" Trace input: {summarize(inp)}")
if out:
print(f" Trace output: {summarize(out)}")
events = sorted(trace.get("events", []), key=lambda e: e.get("createdAt", ""))
for i, ev in enumerate(events, 1):
p = ev.get("properties", {})
etype = ev.get("event", "?")
name = p.get("$ai_span_name", p.get("$ai_model", etype))
latency = p.get("$ai_latency", "?")
error = " ERR" if p.get("$ai_is_error") else ""
print(f"\n{i:>3}. [{etype}] {name} ({latency}s){error}")
if "$ai_input_state" in p:
print(f" IN: {summarize(p['$ai_input_state'])}")
if "$ai_output_state" in p:
print(f" OUT: {summarize(p['$ai_output_state'])}")
if "$ai_input_tokens" in p:
print(f" tokens: {p.get('$ai_input_tokens', '?')} in / {p.get('$ai_output_tokens', '?')} out cost=${p.get('$ai_total_cost_usd', '?')}")
"""Search for a keyword across all event properties in a trace."""
import json
import os
import sys
def load_trace_file(path):
with open(path) as f:
raw = json.load(f)
# Claude Code persists large MCP tool results as [{"type": "text", "text": "<json>"}] — unwrap to get the actual trace data.
if isinstance(raw, list) and raw and raw[0].get("type") == "text":
raw = json.loads(raw[0]["text"])
# Both query-llm-trace and query-llm-traces-list return {"results": [...]}, but handle a bare trace object too.
results = raw.get("results", raw)
return [results] if isinstance(results, dict) else results
def search_obj(obj, term, path=""):
if isinstance(obj, str):
if term in obj.lower():
idx = obj.lower().index(term)
start, end = max(0, idx - 80), min(len(obj), idx + len(term) + 80)
yield path, obj[start:end]
elif isinstance(obj, dict):
for k, v in obj.items():
yield from search_obj(v, term, f"{path}.{k}")
elif isinstance(obj, list):
for i, v in enumerate(obj):
yield from search_obj(v, term, f"{path}[{i}]")
term = os.environ.get("SEARCH", "").lower()
if not term:
print("Usage: SEARCH='keyword' python3 search.py file.json", file=sys.stderr)
sys.exit(1)
traces = load_trace_file(sys.argv[1])
for trace in traces:
for ev in trace.get("events", []):
p = ev.get("properties", {})
name = p.get("$ai_span_name", p.get("$ai_model", ev.get("event", "?")))
for path, snippet in search_obj(p, term):
print(f"\n[{ev.get('createdAt', '?')}] {name} -> {path}")
print(f" ...{snippet}...")
"""Show JSON keys and types without values. Reads from stdin or a file argument."""
import json
import sys
def load_trace_data(source):
raw = json.load(source)
# Claude Code persists large MCP tool results as [{"type": "text", "text": "<json>"}] — unwrap to get the actual trace data.
if isinstance(raw, list) and raw and isinstance(raw[0], dict) and raw[0].get("type") == "text":
raw = json.loads(raw[0]["text"])
return raw
def structure(obj, depth=0, max_depth=3):
indent = " " * depth
if depth > max_depth:
print(f"{indent}...")
return
if isinstance(obj, dict):
for k, v in obj.items():
if isinstance(v, dict):
print(f"{indent}{k}: {{...}} ({len(v)} keys)")
structure(v, depth + 1, max_depth)
elif isinstance(v, list):
print(f"{indent}{k}: [...] ({len(v)} items)")
if v:
structure(v[0], depth + 1, max_depth)
elif isinstance(v, str):
print(f"{indent}{k}: str[{len(v)}]")
else:
print(f"{indent}{k}: {v}")
elif isinstance(obj, list):
print(f"{indent}[{len(obj)} items]")
if obj:
structure(obj[0], depth + 1, max_depth)
if len(sys.argv) > 1:
with open(sys.argv[1]) as f:
data = load_trace_data(f)
else:
data = load_trace_data(sys.stdin)
structure(data)
Related skills
FAQ
What does exploring-llm-traces do?
exploring-llm-traces is a Claude Code skill for ai & agent building.
When should I use exploring-llm-traces?
When you need to helps with ai & agent building tasks during AI-assisted development., or when exploring-llm-traces is a claude code skill for ai & agent building.
What are the main capabilities?
exploring-llm-traces; AI & Agent Building; AI-coding skill.