ibm/ibm-watsonx-orchestrate-adk · Archived

telemetry-analyzer

Use when the user wants to analyze agent telemetry traces to find bugs and get fix recommendations — walks through exporting traces from a local or remote watsonx Orchestrate server, parsing raw OTel JSON or Langfuse-format trace JSON directly, and reasoning over them to identify failures and suggest fixes.

Installation

$ npx skills add ibm/ibm-watsonx-orchestrate-adk --skill telemetry-analyzer

Summary

Use when the user wants to analyze agent telemetry traces to find bugs and get fix recommendations — walks through exporting traces from a local or remote watsonx Orchestrate server, parsing raw OTel JSON or Langfuse-format trace JSON directly, and reasoning over them to identify failures and suggest fixes.

Stronger alternatives

This repository is archived — consider an actively maintained alternative.

Similar popular skills

Related neighbors and high-traction skills in the same topics — useful to compare before installing.

Also in this package

Other skills from ibm/ibm-watsonx-orchestrate-adk.

npx skills add ibm/ibm-watsonx-orchestrate-adk

Browse all from ibm/ibm-watsonx-orchestrate-adk

More details

Agent compatibility

Declared targets from SKILL.md / docs. Unmarked agents are not listed — the skill may still install via the CLI.

Claude Code Not declared
Cursor Not declared
Codex Not declared
GitHub Copilot Not declared
Windsurf Not declared
Gemini CLI Not declared
Cline Not declared
OpenCode Not declared

Repository health

Stars 177
License LICENSE
Default branch main
Status Archived

Package contents

Files included with this skill beyond the listing page.

  • skill md SKILL.md 41,174 B
  • docs SUMMARY.md 336 B

History

  1. First recorded snapshot · 2 installs

SKILL.md

Agent Telemetry Analyzer

This skill exports agent telemetry traces from watsonx Orchestrate and analyzes them directly from raw OTel JSON or Langfuse-format trace JSON to produce structured bug reports with root-cause analysis and fix recommendations.

Two trace formats are supported:
- OTel JSON — produced by the classic TracesController.exporttraceto_json() ADK path and local IBM telemetry servers. Top-level key: traceData.resourceSpans.
- Langfuse JSON — produced by the agentops-v3 REST API (GET /v1/agentops-v3/traces/<id>) and the exporttracesagentops_v3.py script. Top-level key: observations (array of GENERATION / CHAIN / SPAN objects).

Always detect the format first before reading any span data. See the detect_format() helper in Step 3C and the normalization table in Step 4.

Setup: See [README.md](./README.md) for prerequisites (ADK installation, .env configuration, and environment activation) before running any scripts.

What this skill does

  1. Identifies the trace source — remote hosted env (wxO Traces API or agentops-v3 REST API), local env with IBM telemetry, or JSON files already on disk (OTel or Langfuse format)
  2. Exports trace files — discovers traces by agent name or ID, paginates through results, and downloads them as OTel JSON (via ADK TracesController) or Langfuse JSON (via exporttracesagentopsv3.py / searchtracesagentopsv3.py)
  3. Analyzes for bugs — detects the JSON format, normalizes spans to a common structure, and scans for hard errors, LLM failures, tool call failures, agent logic bugs, flow issues, token anomalies, and cache efficiency
  4. Produces a bug report — structured HTML report with an executive summary, trace summary table, critical issues, warnings, observations, and fix recommendations (multi-trace analysis only — a single-trace request goes straight to the conversational flow report)
  5. Produces conversational flow reports — thread-scoped reports that reconstruct multi-turn conversations and surface per-turn failures

When to use this skill

Use this skill when you need to:

  • Debug a failing or misbehaving watsonx Orchestrate agent
  • Understand why an agent returned an empty or incorrect response
  • Trace LLM token usage and identify prompt bloat or context overflow
  • Identify tool call failures, retry loops, or hung async flows
  • Analyze a batch of traces and surface the most impactful issues
  • Reconstruct a full multi-turn conversation from thread-scoped traces

How to invoke

Ask questions like:

  • "Analyze the telemetry for the My Agent traces from the last 20 minutes"
  • "What went wrong in trace f492e71b957ccec2d07096ae99395d19?"
  • "Export and inspect all traces for the Supervisor agent in the last 2 hours"
  • "Why did this agent return an empty response?"
  • "Show me the conversational flow for this trace"

Command-line usage

Standard scripts (_adk.py) — wxO Traces API / local IBM telemetry

# Search traces and save IDs for bulk export
python scripts/search_traces_adk.py --agent-name "My Agent" --last 1h --all \
    --save-ids data/trace_ids.txt

# Export all discovered traces
python scripts/export_traces_adk.py --ids-file data/trace_ids.txt

# Export a single trace by ID
python scripts/export_traces_adk.py --trace-id <32-char-hex-id>

agentops-v3 scripts (agentopsv3.py) — agentops-v3 REST API

# Search traces for an agent (last 20 minutes by default)
python scripts/search_traces_agentops_v3.py --last 20m

# Narrow or widen the time window
python scripts/search_traces_agentops_v3.py --last 2h
python scripts/search_traces_agentops_v3.py --start 2025-01-01T09:00:00Z --end 2025-01-01T21:00:00Z

# Collect every trace in the window and save IDs for bulk export
python scripts/search_traces_agentops_v3.py --last 1h --all \
    --save-ids data/trace_ids.txt

# Quick count before committing to a full export
python scripts/search_traces_agentops_v3.py --last 2h --count

# Export a single trace by ID
python scripts/export_traces_agentops_v3.py --trace-id <32-char-hex-id>

# Bulk-export from a saved ID list
python scripts/export_traces_agentops_v3.py --ids-file data/trace_ids.txt

Analysis capabilities

Trace export and discovery

  • Cursor-based pagination through the wxO Traces API with automatic 429 retry
  • Supports --limit N, --all, and --count modes for controlling result volume
  • Saves trace files as data/<traceid>.json — OTel format via exporttracesadk.py, Langfuse format via exporttracesagentopsv3.py
  • Works with remote hosted environments and local servers started with --with-ibm-telemetry
  • searchtracesagentopsv3.py / exporttracesagentopsv3.py use the raw agentops-v3 REST API (bearer token from active env, no ADK SDK calls)

Bug detection

Detected from normalized spans, supporting both OTel (traceData.resourceSpans[0].scopeSpans[0].spans) and Langfuse (observations[]) formats:

  • Hard errors — any span with status.code == STATUSCODEERROR
  • LLM failuresfinishreason == "length", empty answer.task output, zero output tokens (via llm.tokencount.completion / genai.usage.outputtokens attributes)
  • Tool call failures — empty ToolMessage content in *.tool spans, retry loops, duration_ms > 30 000 ms
  • Agent logic bugs — missing answer.task span, empty final answer, repeated agent.task cycles (multi-step loop), empty collaborator output
  • Flow issueshaserror on any span, widgetresult.task or collaborator.task with empty output
  • Token anomalies — cumulative token totals exceeding 50 000, high input:output token ratio
  • Cache efficiency — total cache-read tokens, cache hit rate below 20 % on multi-turn threads, zero cache reads across ≥ 3-turn threads, cache-write/read imbalance

Conversational flow analysis

  • Detects trace format and extracts thread_id from the correct location:

- OTel: traceloop.association.properties.threadid or thread.id span attribute - Langfuse: metadata.attributes.threadid or sessionId top-level field

  • Searches the local trace cache and the API for all traces belonging to a thread
  • Reconstructs user message, routing decision, collaborator steps, and final response per turn
  • Flags turns where the agent returned no response or encountered an error

Output format

The analyzer produces:

  1. Bug Report (HTML artifact for 3+ issues, inline for fewer)

- Report header with agent ID, name, environment, date, and UTC time window - Executive Summary with KPI stat cards (for 10+ traces) - Categorical trace summary table grouped by routing pattern or error type - Per-trace detail table with start time, span count, token counts, and status - 🔴 Critical Issues, 🟠 Warnings, and 🟡 Observations tables with affected trace IDs - 🛠️ Fix Recommendations with root cause and actionable steps per issue

  1. Conversational Flow Report (HTML artifact, thread-scoped)

- Thread metadata: thread ID, turn count, agent, environment, time window, overall outcome - Per-turn timeline: user message, routing decision, collaborator steps, response delivered, token counts, error flags - Full span timing breakdown across all turns in the thread - Root cause summary comparing what worked vs. what failed

Supporting files

Scripts in scripts/ (created on first use by Step 1 if not already present):

Script Output format Auth mechanism Notes
searchtracesadk.py OTel ADK TracesController Cursor-based pagination, agent-name filter, rate-limit retry
exporttracesadk.py OTel ADK TracesController Single or bulk export by trace ID
searchtracesagentops_v3.py Langfuse Bearer token from active env Page-based pagination, returns all traces in time window
exporttracesagentops_v3.py Langfuse Bearer token from active env GET /v1/agentops-v3/traces/<id>, no ADK SDK trace call

Use the *agentopsv3.py scripts when the environment uses the agentops-v3 REST API. Use the standard scripts for environments backed by the classic wxO Traces API.


Step-by-step workflow

Step 1: Verify helper scripts are present

File placement rules — apply throughout all steps:
- All Python scripts written to disk must be saved under scripts/ (e.g. scripts/my_helper.py).
- All output files (.json trace dumps, .txt ID lists, etc.) must be saved under data/ (e.g. data/traceids.txt, data/<traceid>.json).
- Inline code snippets shown in this document are for reading and reasoning only — they are not written to disk unless explicitly instructed.

Before doing anything else, confirm the required scripts exist on disk:

scripts/search_traces_adk.py
scripts/export_traces_adk.py
scripts/search_traces_agentops_v3.py
scripts/export_traces_agentops_v3.py

If any are missing, use write_file to create them — the canonical source for each script is in the scripts/ directory of this skill. Write each missing file to its correct path under scripts/ before continuing to Step 2.


Step 2: Determine the trace source

If the user's request does not clearly state where the traces or agent reside, always use askfollowupquestion to clarify before doing anything else. Do not assume a source or proceed to Step 3 without a confirmed answer to all of the following that are not already clear from context:

  • Is the environment remote (a hosted wxO instance) or local (a locally running server)?
  • If remote — what is the environment name (e.g. the name used with orchestrate env activate)?
  • If the user mentioned an agent — what is the agent name or ID?
  • What time window should be searched (or should it default to the last 20 minutes)?

Once the source is confirmed, classify it as one of:

  1. Remote env — a hosted watsonx Orchestrate environment activated with orchestrate env activate. Always try the agentopsv3.py scripts first; fall back to the _adk.py scripts if the agentops-v3 search returns an error or zero traces. See Step 3A for the full fallback procedure.
  2. Local env — IBM telemetry profile — a locally running orchestrate server started with --with-ibm-telemetry. The Traces API is available at http://localhost:4321. Use the _adk.py scripts directly — agentops-v3 is not available on local environments.
  3. Trace files on disk — the user already has one or more {trace_id}.json files on disk ready to analyze. Confirm they are in the data/ directory; if not, ask the user for the path before proceeding.

Step 3: Export the trace files

Option A — Remote env (agentops-v3 first, ADK fallback)

For all remote environments, always try agentopsv3.py first. Fall back to _adk.py only if the agentops-v3 search fails or returns zero traces.

Step A1 — Try agentops-v3:

# 1. Activate the target environment
orchestrate env activate <env-name>

# 2. Search traces via agentops-v3
python scripts/search_traces_agentops_v3.py \
    --start 2025-01-01T09:00:00Z --end 2025-01-01T21:00:00Z \
    --all --save-ids data/trace_ids.txt

If the command exits with a non-zero status or prints Found 0 trace(s), proceed to Step A2. Otherwise, export the discovered traces:

# 3. Export all discovered traces
python scripts/export_traces_agentops_v3.py --ids-file data/trace_ids.txt

Step A2 — Fall back to ADK (only if agentops-v3 failed or returned 0 traces):

# 2. Search traces via ADK — IDs checkpointed to disk after every page
python scripts/search_traces_adk.py --agent-name "My Agent" \
    --start 2025-01-01T09:00:00Z --end 2025-01-01T21:00:00Z \
    --all --save-ids data/trace_ids.txt

# 3. Export all discovered traces
python scripts/export_traces_adk.py --ids-file data/trace_ids.txt

searchtracesadk.py uses cursor-based pagination, retries automatically on 429 rate-limit responses, and handles mid-run 401 token expiry by refreshing the client. Always pass --save-ids so trace IDs are checkpointed to disk page-by-page and are not lost on timeout.

Use --limit N (default 50) for a capped sample, --all to exhaust the full window, or --count for a quick volume figure before committing to a full export.

Option B — Local env (IBM telemetry profile)

# 1. Start the local server with IBM telemetry enabled
orchestrate server start --with-ibm-telemetry --accept-terms-and-conditions

# 2. Activate the local environment
orchestrate env activate local

# 3. Search and export — identical to Option A
python scripts/search_traces_adk.py --agent-name "My Agent" --last 30m --all \
    --save-ids data/trace_ids.txt
python scripts/export_traces_adk.py --ids-file data/trace_ids.txt

The local Traces API endpoint is http://localhost:4321. The scripts detect islocaldev() automatically and set servicenames=["wxo-server"] in the filter, which is required when FORCESINGLE_TENANT=true.

Option C — Trace files already on disk

Confirm the directory or file path with the user. Always detect the format before reading spans. Use this helper:

import json

def detect_format(raw: dict) -> str:
    """Return 'otel' or 'langfuse'."""
    if isinstance(raw.get('traceData'), dict) and 'resourceSpans' in raw['traceData']:
        return 'otel'
    if 'resourceSpans' in raw:  # traceData sometimes omitted at root
        return 'otel'
    if 'observations' in raw:   # Langfuse / agentops-v3 REST response
        return 'langfuse'
    return 'langfuse'           # safe default for agentops-v3 output

def get_spans(raw: dict) -> list:
    """Return a flat list of span/observation dicts regardless of format."""
    fmt = detect_format(raw)
    if fmt == 'otel':
        td = raw.get('traceData') or raw
        return [
            s
            for rs in td.get('resourceSpans', [])
            for ss in rs.get('scopeSpans', [])
            for s in ss.get('spans', [])
        ]
    return raw.get('observations', [])

Load a file and get its spans:

with open("data/<trace_id>.json") as f:
    raw = json.load(f)
fmt   = detect_format(raw)
spans = get_spans(raw)
print(f"Format: {fmt}, span/observation count: {len(spans)}")

If the user provides a directory, glob for *.json files and skip any files not matching the 32-character hex trace ID pattern.


Step 4: Analyze raw spans for bugs

Single-trace shortcut: If the user has asked to analyze exactly one trace (by ID or as a single file on disk), skip Steps 4 and 5 entirely. Jump straight to Step 7 to produce the conversational flow report for the thread that trace belongs to. Only return to Step 5 (bug report) if the user explicitly asks for one after seeing the flow report.

Load each trace file using detectformat() + getspans() from Step 3C, then normalize every span with the adapter below before running any bug-detection logic.

4.1 Format normalization

from datetime import datetime, timezone

def normalize_span(s: dict, fmt: str) -> dict:
    """
    Return a uniform dict with these keys regardless of source format:
      name, start_utc, duration_ms, status_error (bool),
      input, output, attrs (dict: key -> scalar value)
    """
    if fmt == 'otel':
        start_ns  = int(s.get('startTimeUnixNano', 0))
        end_ns    = int(s.get('endTimeUnixNano', 0))
        start_utc = datetime.fromtimestamp(start_ns / 1e9, tz=timezone.utc)
        duration_ms = (end_ns - start_ns) / 1e6
        status_error = s.get('status', {}).get('code') in ('STATUS_CODE_ERROR', 2)
        attrs = {}
        for a in s.get('attributes', []):
            v = a['value']
            attrs[a['key']] = (
                v.get('stringValue') or v.get('intValue') or
                v.get('doubleValue') or v.get('boolValue')
            )
        input_val  = attrs.get('traceloop.entity.input')
        output_val = attrs.get('traceloop.entity.output')
    else:  # langfuse
        start_utc = datetime.fromisoformat(
            s.get('startTime', '1970-01-01T00:00:00Z').replace('Z', '+00:00'))
        end_str   = s.get('endTime') or s.get('startTime', '1970-01-01T00:00:00Z')
        end_dt    = datetime.fromisoformat(end_str.replace('Z', '+00:00'))
        duration_ms = (end_dt - start_utc).total_seconds() * 1000
        status_error = (s.get('level') == 'ERROR' or bool(s.get('statusMessage')))
        meta  = s.get('metadata') or {}
        attrs = dict(meta.get('attributes', {}))
        usage = s.get('usage') or {}
        if usage.get('input'):
            attrs['llm.usage.prompt_tokens'] = usage['input']
        if usage.get('output'):
            attrs['llm.usage.completion_tokens'] = usage['output']
        input_val  = s.get('input')
        output_val = s.get('output')

    return dict(
        name=s.get('name', ''),
        start_utc=start_utc,
        duration_ms=duration_ms,
        status_error=status_error,
        input=input_val,
        output=output_val,
        attrs=attrs,
    )

def get_attr(attrs: dict, key: str):
    """Read a scalar from the normalized attrs dict (works for both formats)."""
    return attrs.get(key)

Build the normalized span list at the start of every analysis:

fmt    = detect_format(raw)
spans  = get_spans(raw)
nspans = [normalize_span(s, fmt) for s in spans]

llm_spans  = [n for n in nspans if n['name'] == 'WatsonxChatModel.chat']
tool_spans  = [n for n in nspans if 'collaborator' in n['name'].lower()
               or n['attrs'].get('openinference.span.kind') == 'TOOL']

Field mapping reference — use this table when reading span data:

Concept OTel key (in attrs after normalization) Langfuse source (in attrs after normalization)
LLM input tokens llm.tokencount.prompt or llm.usage.prompttokens llm.usage.prompt_tokens (from usage.input)
LLM output tokens llm.tokencount.completion or llm.usage.completiontokens llm.usage.completion_tokens (from usage.output)
LLM model llm.modelname or genai.request.model llm.model_name or model top-level field
Thread ID traceloop.association.properties.thread_id or thread.id thread_id or thread.id
Finish reason llm.finishreason or genai.usage.finish_reasons llm.finish_reason
Cache read tokens genai.usage.cachereadinputtokens or llm.tokencount.cacheread genai.usage.cachereadinputtokens or usage.cacheReadInputTokens (check raw obs)
Cache creation tokens genai.usage.cachecreationinputtokens or llm.tokencount.cachecreation genai.usage.cachecreationinputtokens or usage.cacheCreationInputTokens (check raw obs)
Span/obs input input (normalized field) input (normalized field)
Span/obs output output (normalized field) output (normalized field)
LLM reasoning inside output JSON → additional_kwargs.reasoning inside output dict → additional_kwargs.reasoning
Tool call name span name ending in .tool SPAN observation name (e.g. chatwithcollaborator_*)

For each trace collect: startutc, span names, durationms, statuserror, collaborator tool call output, final answer output, threadid (from attrs), and cache token counts.

Look for the following bug categories. For each issue found, record the span name, start_utc, and relevant attrs values.

4.2 Hard errors

  • Any normalized span where status_error == True.

- OTel source: status.code == "STATUSCODEERROR" or 2. - Langfuse source: level == "ERROR" or statusMessage is non-empty.

  • Capture span name, duration_ms, and the output field for the error message.

4.3 LLM failures

  • getattr(n['attrs'], 'llm.finishreason') == "length" — context length exceeded (works for both formats after normalization).
  • WatsonxChatModel.chat span with duration_ms == 0 or absent — silent LLM failure.
  • Token counts: use llm.tokencount.prompt / llm.tokencount.completion (OTel attrs) or llm.usage.prompttokens / llm.usage.completiontokens (Langfuse, auto-added by normalize_span). Zero completion tokens with non-zero prompt tokens = failed generation.
  • Unexpectedly high prompt token count — possible system prompt bloat.
  • Cache token counts: see §4.8. After normalization, try getattr(attrs, 'genai.usage.cachereadinput_tokens') first; fall back to the raw Langfuse usage dict. If absent on all LLM spans, note as an observation (provider may not support caching).

4.4 Tool / collaborator call failures

  • Tool span / SPAN observation whose output field contains an empty or null content (i.e. ToolMessage.content == "").

- Langfuse note: an empty content on the immediate chatwithcollaborator_* SPAN is the normal async dispatch pattern — the real result arrives via the nested collaborator CHAIN observation. Only flag as a failure if the outer collaborator CHAIN output is also empty.

  • Any tool span with duration_ms > 30 000 ms — timeout or hung call.
  • Same collaborator tool called twice in a row — retry loop.

4.5 Agent logic bugs

  • No span/observation named answer (or answer.task in OTel) — agent never produced a final answer.
  • answer span output is empty or whitespace-only.
  • agent (or agent.task) span count > 2 — multi-step LLM loop (agent re-planning instead of delegating immediately).
  • collaborator (or collaborator.task) span present but answer output is empty — collaborator returned nothing and supervisor silently swallowed it.
  • Internal reasoning text present in answer output or output.additional_kwargs.reasoning before the user-facing reply — check both OTel and Langfuse paths.

4.6 Flow / widget issues

  • Span/observation named widgetresult (Langfuse) or widgetresult.task (OTel) with empty output — widget state machine stalled.
  • LangGraph GENERATION (Langfuse) or LangGraph.workflow span (OTel) with duration_ms >> sum of child span durations — unexplained gap in the workflow.

4.7 Token / cost anomalies

  • Cumulative prompt + completion tokens across all WatsonxChatModel.chat spans exceeds 50 000.
  • Prompt tokens >> completion tokens (ratio > 200:1) — system prompt dominates context, little room for conversation history.

4.8 Cache efficiency

Langfuse format caveat: Cache token attributes (cacheReadInputTokens, cacheCreationInputTokens) are not reliably collected in Langfuse-format traces. When the source format is Langfuse (fmt == 'langfuse'), attempt both the metadata.attributes and top-level usage fallback paths below. If both are absent after exhausting all fallbacks, set cache_unsupported = True for the trace and skip all cache-related analysis, stat cards, table columns, and observations for that trace. Do not flag missing cache attributes as a bug or observation for Langfuse-format traces — they are expected to be absent.

Extract per-WatsonxChatModel.chat normalized span, with a Langfuse usage dict fallback for cache fields that may not be hoisted into metadata.attributes:

def get_cache_tokens(nspan: dict, raw_obs: dict = None) -> tuple:
    """
    nspan   — normalized span dict (attrs already flattened from either format).
    raw_obs — original Langfuse observation dict (optional), used as fallback
              for cache fields that are in usage but not in metadata.attributes.
    Returns (cache_read, cache_creation) as ints.
    """
    attrs = nspan['attrs']
    cache_read = (
        get_attr(attrs, 'gen_ai.usage.cache_read_input_tokens') or
        get_attr(attrs, 'llm.token_count.cache_read') or 0
    )
    cache_creation = (
        get_attr(attrs, 'gen_ai.usage.cache_creation_input_tokens') or
        get_attr(attrs, 'llm.token_count.cache_creation') or 0
    )
    # Langfuse-specific fallback: check top-level usage dict
    if raw_obs and not cache_read and not cache_creation:
        usage = raw_obs.get('usage') or {}
        cache_read     = usage.get('cacheReadInputTokens') or 0
        cache_creation = usage.get('cacheCreationInputTokens') or 0
    return int(cache_read), int(cache_creation)

Aggregate across all LLM normalized spans in the trace (pass both the normalized span and the original raw observation when the source is Langfuse):

total_cache_read     = sum(get_cache_tokens(n, raw_obs=spans[i] if fmt=='langfuse' else None)[0]
                           for i, n in enumerate(llm_spans))
total_cache_creation = sum(get_cache_tokens(n, raw_obs=spans[i] if fmt=='langfuse' else None)[1]
                           for i, n in enumerate(llm_spans))

# For Langfuse traces: if both totals are still 0 after all fallbacks, cache data
# is not collected by this environment — mark the trace and skip cache analysis.
cache_unsupported = (fmt == 'langfuse') and (total_cache_read == 0) and (total_cache_creation == 0)

# Effective billable input = input_tokens - total_cache_read
# Cache hit rate = total_cache_read / (input_tokens - total_cache_creation)
#                 only meaningful when input_tokens > total_cache_creation > 0
denom = input_tokens - total_cache_creation
cache_hit_rate = (total_cache_read / denom) if (not cache_unsupported and denom > 0) else None

Flag the following — only when cache_unsupported is False:

  • Low cache hit ratecachehitrate < 0.20 (< 20 %) on a trace that belongs to a thread with ≥ 3 turns. The system prompt is likely not structured for prefix caching or varies per turn.
  • Zero cache reads on multi-turn thread — every cachereadinput_tokens == 0 across ≥ 3 turns in the same thread. The cache is never being warmed or the prefix changes each turn.
  • Cache write/read imbalancetotalcachecreation > totalcacheread * 5 across the batch. The cache is being populated but rarely re-used (cache TTL mismatch or non-repeating prefix).
  • No cache attributes present (OTel only) — if the format is OTel and neither key is present on any LLM span, note it as an observation: the provider may not support prompt caching, or instrumentation does not emit these fields. Do not emit this observation for Langfuse-format traces.

Step 5: Produce the bug report (multi-trace analysis only)

Skip this step when the analysis scope is a single trace. Proceed directly to Step 7 instead.

Report title

Telemetry Bug Report — <AgentName>

Report header

Field Value
Agent ID <uuid>
Agent Name <name>
Environment Instance URL or local
Report Date YYYY-MM-DD (UTC)
Time Window (UTC) YYYY-MM-DD HH:MM – YYYY-MM-DD HH:MM UTC

Executive Summary (for 10+ traces)

Two rows of 4 KPI stat cards each:

  • Row 1 — Total traces (note if sampled), failed count (.stat-bad), successful count (.stat-ok), time window duration.
  • Row 2 — Total cache-read tokens, cache hit rate as %, total LLM input tokens, avg/p95 duration. If all traces in the batch are Langfuse format and cache_unsupported is True for every trace, omit the cache-read and cache hit rate stat cards entirely and render only a single row of 4 cards (total traces, failed, successful, avg/p95 duration).

No prose paragraphs — stat cards only.

Required CSS:

.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 20px; }
.stat-card { background: #f7f8fa; border: 1px solid #e5e7eb; border-radius: 6px; padding: 12px 14px; }
.stat-val  { font-size: 22px; font-weight: 700; color: #1f2328; }
.stat-lbl  { font-size: 12px; color: #57606a; margin-top: 2px; }
.stat-bad  .stat-val { color: #b91c1c; }
.stat-ok   .stat-val { color: #15803d; }
.stat-warn .stat-val { color: #b45309; }

Cache-specific stat card values to compute before rendering:

# Exclude traces where cache data is known to be uncollected (Langfuse with no cache attrs)
cache_rows = [r for r in rows if not r.get("cache_unsupported")]

if cache_rows:
    total_cache_read_all     = sum(r.get("cache_read_tokens", 0)     for r in cache_rows)
    total_cache_creation_all = sum(r.get("cache_creation_tokens", 0) for r in cache_rows)
    total_input_cache        = sum(r.get("input_tokens", 0)          for r in cache_rows)

    denom_all = total_input_cache - total_cache_creation_all
    batch_cache_hit_rate = (total_cache_read_all / denom_all * 100) if denom_all > 0 else None

    cache_attrs_present = total_cache_read_all > 0 or total_cache_creation_all > 0
    hit_rate_display = f"{batch_cache_hit_rate:.1f}%" if cache_attrs_present else "N/A"
    hit_rate_class   = "stat-bad" if (cache_attrs_present and batch_cache_hit_rate is not None and batch_cache_hit_rate < 20) else "stat-ok"
    show_cache_cards = True
else:
    # All traces are Langfuse with no cache data — omit both cache stat cards
    show_cache_cards = False

Trace Summary section

Part 1 — Categorical summary table (group by routing pattern, error type, etc.):

Category Traces LLM Calls (avg) Tool Calls (avg) Success Rate Status

Part 2 — Per-trace detail table (ordered by start time ascending):

If showcachecards is True (cache data available for at least some traces), use the full 7-column layout:

Trace ID Start Time (UTC) Spans LLM In / Out / Cached Tokens Cache Hit % Status

In / Out / Cached Tokens — display as three values separated by / , e.g. 12 450 / 320 / 8 100. Show for Cached on a specific row when that trace has cacheunsupported == True. Cache Hit %cachereadtokens / (inputtokens - cachecreationtokens) * 100, formatted as 42 %. Show for rows where cache_unsupported == True.

If showcachecards is False (all traces are Langfuse with no cache data), drop the "Cached Tokens" and "Cache Hit %" columns and use the 5-column layout:

Trace ID Start Time (UTC) Spans LLM In / Out Tokens Status

Rules:

  • For > 10 traces: omit rows with ✅ OK status — only include failed (❌) and warning (⚠) traces to keep the report compact.
  • For ≤ 10 traces: include all traces regardless of status.
  • Trace IDs must never be truncated — display full 32-character hex. Use white-space: nowrap; font-family: monospace on the Trace ID cell.

Issues and Warnings tables

# Span Type Description Evidence Affected Traces

HTML rendering rules — apply these exactly to prevent column overflow:

  1. Table layout — always set table-layout: fixed; width: 100% and declare explicit <colgroup> widths so the browser cannot let any column expand beyond its allocation:

``html <table style="table-layout:fixed; width:100%"> <colgroup> <col style="width:3%"> <!-- # --> <col style="width:12%"> <!-- Span --> <col style="width:7%"> <!-- Type --> <col style="width:28%"> <!-- Description --> <col style="width:28%"> <!-- Evidence --> <col style="width:22%"> <!-- Affected Traces --> </colgroup> … </table> ``

  1. Every <td> and <th> — add overflow: hidden to hard-clip any content that would otherwise bleed into the next column:

``html <td style="overflow:hidden; word-break:break-word">…</td> ``

  1. Evidence column — allow wrapping and use <br> between individual evidence items. Do not set a fixed max-width — let the percentage-based <colgroup> width scale with the container:

``html <th>Evidence</th> <td style="overflow:hidden; word-break:break-word"> <code>field: value</code><br> <code>field: value</code> </td> ``

  1. Affected Traces column — use word-break: break-all (not nowrap) so long hex IDs wrap within the cell, and put each ID on its own line:

``html <th>Affected Traces</th> <td style="overflow:hidden; word-break:break-all; font-family:monospace; font-size:11px"> tid1<br>tid2<br>… </td> ``

For Observations (prose), end each bullet with affected trace IDs in parentheses.

Cache efficiency observations — add the following bullets when applicable. Skip all cache observations entirely when showcachecards is False (all-Langfuse batch with no cache data):

  • No cache attributes found (OTel only) — the format is OTel and neither genai.usage.cachereadinputtokens nor llm.tokencount.cacheread was present on any LLM span. The provider may not support prompt caching, or the traceloop-sdk version predates cache attribute emission. (list affected trace IDs)
  • Zero cache reads on multi-turn threadcachereadinputtokens == 0 across all turns in thread <threadid> (≥ 3 turns). The cache is never warmed; the static system prompt prefix likely changes per turn or is too short to cache. (list affected trace IDs)
  • Cache write/read imbalancetotalcachecreationtokens (N) is more than 5× totalcachereadtokens (M) across the batch. The prompt-cache prefix is being written but not re-used — likely a cache TTL expiry or a prefix that varies each turn. (list affected trace IDs)

Fix Recommendations

For each critical issue and warning:

Issue: <description>
Root Cause: <likely cause>
Recommended Fix:
- <actionable step 1>
- <actionable step 2>

Cache hit rate fix recommendation template — include when cache hit rate < 20 % or zero cache reads observed on multi-turn threads:

Issue: Low prompt-cache hit rate (<X % batch average)
Root Cause: The system prompt either varies per turn, is not positioned at the start of
            the messages array, or the cache TTL expired between turns in long-gap threads.
Recommended Fix:
- Pin the static system prompt as the first message so the LLM provider can cache the
  longest common prefix across turns.
- Avoid injecting dynamic content (timestamps, user IDs, session state) into the system
  prompt — move that to the user turn instead.
- For Anthropic: add `cache_control: {"type": "ephemeral"}` to the last static content
  block to explicitly mark the cacheable prefix boundary.
- For OpenAI: ensure repeated calls use an identical token-level prefix of ≥ 1 024 tokens;
  caching is automatic but the prefix must be byte-for-byte identical.
- Verify the inter-turn gap is within the provider cache TTL: ~5 min for OpenAI and
  Anthropic ephemeral caches. For long-running sessions, refresh the cache before TTL
  expiry by issuing a short warm-up prompt.
- If cache attributes are absent entirely, upgrade the traceloop-sdk to a version that
  emits `gen_ai.usage.cache_read_input_tokens` / `gen_ai.usage.cache_creation_input_tokens`.

Render as createhtmlartifact for 3+ issues; inline for fewer.

HTML report outer wrapper — use width: 80% centred with margin: 0 auto so the report occupies 80 % of the page width:

<div style="width:80%; margin:0 auto; padding:20px 0; font-family:-apple-system,'Segoe UI',system-ui,sans-serif; font-size:14px; line-height:1.6">
  <!-- all report content here -->
</div>

Step 6: Offer conversational flow report (multi-trace analysis only)

After delivering the bug report, ask:

"Would you like a conversational flow report for any specific trace? If so, provide a trace ID and I'll pull all traces under the same thread and produce a full thread-level flow report."

Use askfollowupquestion with suggestions drawn from the failing trace IDs.


Step 7: Produce conversational flow report for a thread

A conversational flow report is thread-scoped — one thread ID may span multiple traces (one per user turn).

7.1 Extract the thread_id

Use detect_format() from Step 3C, then read the thread ID from the correct location:

import json

with open(f"data/{trace_id}.json") as f:
    raw = json.load(f)

fmt = detect_format(raw)
thread_id = None

if fmt == 'otel':
    spans = get_spans(raw)
    for s in spans:
        for a in s.get('attributes', []):
            if a['key'] in ('traceloop.association.properties.thread_id', 'thread.id'):
                thread_id = a['value']['stringValue']
                break
        if thread_id:
            break
else:  # langfuse
    # 1. Check top-level metadata.attributes (most reliable)
    meta_attrs = (raw.get('metadata') or {}).get('attributes', {})
    thread_id = (
        meta_attrs.get('thread.id') or
        meta_attrs.get('thread_id') or
        meta_attrs.get('langfuse.session.id') or
        raw.get('sessionId')   # Langfuse session == wxO thread
    )
    # 2. Fall back to any observation's metadata.attributes
    if not thread_id:
        for obs in raw.get('observations', []):
            obs_attrs = (obs.get('metadata') or {}).get('attributes', {})
            thread_id = obs_attrs.get('thread_id') or obs_attrs.get('thread.id')
            if thread_id:
                break

print(f"format: {fmt}, thread_id: {thread_id}")

7.2 Collect all traces for the thread

First scan the local cache for matching thread_id, then search the API for any traces not yet downloaded. Re-run the local scan after downloading new files. If the wxO token is expired, work with cached traces only and note how many turns were found vs. potentially missing.

7.3 Build the thread timeline

Sort all matched traces by startutc ascending. For each trace call detectformat() + normalize_span() and extract using the unified field names:

What to extract OTel span name Langfuse observation name Field on normalized span
User message POST /orchestrate/runs or LangGraph.workflow POST /chat/completions GENERATION input (top-level content string)
LLM call count count of WatsonxChatModel.chat spans count of WatsonxChatModel.chat GENERATIONs name == 'WatsonxChatModel.chat'
Input / output tokens llm.token_count.prompt / .completion attrs llm.usage.prompttokens / .completiontokens attrs (auto-added) getattr(attrs, 'llm.usage.prompttokens') etc.
Cache tokens genai.usage.cachereadinputtokens attr usage.cacheReadInputTokens (raw obs fallback) getcachetokens(nspan, raw_obs)
Collaborator routing chatwithcollaborator_*.tool span output SPAN obs chatwithcollaborator_*output.content output field
Collaborator steps collaborator.task, tools.task, entrypoint.task spans CHAIN obs collaborator, tools, LangGraph[collaborator:*] name, duration_ms, output
Final answer answer.task span output CHAIN obs answeroutput output field
Error flag status_error == True on any span status_error == True on any obs n['status_error']
LLM reasoning output JSON → additional_kwargs.reasoning output dict → additional_kwargs.reasoning parse n['output']

7.4 Render the conversational flow report

Produce a createhtmlartifact with four sections:

  1. Thread Metadata — thread ID, turn count, agent, environment, start/end time, total duration, overall outcome.
  2. Conversation Turns — vertical timeline per turn: user message, routing decision, collaborator steps, response delivered (or red "NO RESPONSE" flag), duration, token counts (in / out / cached), cache hit % for the turn, error flags.
  3. Span Timing Breakdown — table of every span across all turns: Turn | Seq | Span name | Actor | Duration (ms) | Status.
  4. Root Cause Summary — two-column table comparing what worked vs. what failed.

Step 8: Offer further follow-up actions

After the conversational flow report, offer:

  1. Deep-dive a specific span — re-read the raw data/{tid}.json, detect format, and show full input / output content (traceloop.entity.input / traceloop.entity.output for OTel; input / output top-level fields for Langfuse). Use normalize_span() so the same display code works for both.
  2. Re-run after a fix — guide the user to re-export and re-inspect after applying a fix.
  3. Batch analysis — loop over multiple raw trace files directly using the inline Python pattern from Step 4.
  4. Compare two traces — diff the span sequences (names, durations, collaborator calls) of two raw traces to identify regressions.