lode-observe-ai 0.1.0.dev0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lode_observe_ai/__init__.py +59 -0
- lode_observe_ai/cli.py +227 -0
- lode_observe_ai/core/__init__.py +311 -0
- lode_observe_ai/core/export.py +138 -0
- lode_observe_ai/domain/__init__.py +179 -0
- lode_observe_ai/infrastructure/__init__.py +7 -0
- lode_observe_ai/infrastructure/buffer.py +130 -0
- lode_observe_ai/infrastructure/hooks/__init__.py +31 -0
- lode_observe_ai/infrastructure/hooks/litellm_hook.py +288 -0
- lode_observe_ai/infrastructure/hooks/openai_hook.py +167 -0
- lode_observe_ai/infrastructure/otel_exporter.py +153 -0
- lode_observe_ai/infrastructure/server.py +401 -0
- lode_observe_ai/infrastructure/storage/__init__.py +5 -0
- lode_observe_ai/infrastructure/storage/sqlite_store.py +349 -0
- lode_observe_ai-0.1.0.dev0.dist-info/METADATA +623 -0
- lode_observe_ai-0.1.0.dev0.dist-info/RECORD +19 -0
- lode_observe_ai-0.1.0.dev0.dist-info/WHEEL +4 -0
- lode_observe_ai-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- lode_observe_ai-0.1.0.dev0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Lode: Deep Execution DAG Tracer & Debugger for Multi-Agent AI Systems.
|
|
2
|
+
|
|
3
|
+
Maps the underground veins of multi-agent workflows, revealing the exact root cause
|
|
4
|
+
when an agent loops indefinitely, hallucinates, or fails.
|
|
5
|
+
|
|
6
|
+
Example usage:
|
|
7
|
+
from lode_observe_ai import start_trace, trace_agent, trace_tool
|
|
8
|
+
|
|
9
|
+
@trace_agent(name="SearchAgent")
|
|
10
|
+
def my_agent(query: str) -> str:
|
|
11
|
+
with start_trace("search_flow") as tracer:
|
|
12
|
+
result = searcher.search(query)
|
|
13
|
+
tracer.record_output(result)
|
|
14
|
+
return result
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from lode_observe_ai.core import (
|
|
18
|
+
aggregate_costs,
|
|
19
|
+
build_trace_tree,
|
|
20
|
+
detect_anomalies,
|
|
21
|
+
extract_replay_steps,
|
|
22
|
+
generate_trace_summary,
|
|
23
|
+
get_span_path,
|
|
24
|
+
)
|
|
25
|
+
from lode_observe_ai.domain import (
|
|
26
|
+
AnomalyIndicators,
|
|
27
|
+
CostBreakdown,
|
|
28
|
+
ModelInferenceEvent,
|
|
29
|
+
ModelProvider,
|
|
30
|
+
ReplayStep,
|
|
31
|
+
Span,
|
|
32
|
+
SpanKind,
|
|
33
|
+
TokenMetrics,
|
|
34
|
+
ToolCallEvent,
|
|
35
|
+
TraceMetadata,
|
|
36
|
+
TraceTree,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
__version__ = "0.1.0-dev"
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"AnomalyIndicators",
|
|
43
|
+
"CostBreakdown",
|
|
44
|
+
"ModelInferenceEvent",
|
|
45
|
+
"ModelProvider",
|
|
46
|
+
"ReplayStep",
|
|
47
|
+
"Span",
|
|
48
|
+
"SpanKind",
|
|
49
|
+
"TokenMetrics",
|
|
50
|
+
"ToolCallEvent",
|
|
51
|
+
"TraceMetadata",
|
|
52
|
+
"TraceTree",
|
|
53
|
+
"aggregate_costs",
|
|
54
|
+
"build_trace_tree",
|
|
55
|
+
"detect_anomalies",
|
|
56
|
+
"extract_replay_steps",
|
|
57
|
+
"generate_trace_summary",
|
|
58
|
+
"get_span_path",
|
|
59
|
+
]
|
lode_observe_ai/cli.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Lode CLI: trace inspection, UI server, and export commands."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from lode_observe_ai.core import build_trace_tree
|
|
12
|
+
from lode_observe_ai.core.export import trace_to_assay_yaml
|
|
13
|
+
from lode_observe_ai.infrastructure.storage.sqlite_store import SQLiteStore
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
app = typer.Typer(help="Lode: Deep Execution DAG Tracer & Debugger for AI Agents")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.command()
|
|
21
|
+
def version() -> None:
|
|
22
|
+
"""Show version information."""
|
|
23
|
+
typer.echo("Lode v0.1.0-dev")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command(deprecated=True)
|
|
27
|
+
def ui(
|
|
28
|
+
storage: str = typer.Option(
|
|
29
|
+
"./traces.db",
|
|
30
|
+
"--storage",
|
|
31
|
+
"-s",
|
|
32
|
+
help="SQLite trace database path",
|
|
33
|
+
),
|
|
34
|
+
port: int = typer.Option(3000, "--port", "-p", help="Local web server port"),
|
|
35
|
+
host: str = typer.Option("127.0.0.1", "--host", help="Web server host"),
|
|
36
|
+
reload: bool = typer.Option(
|
|
37
|
+
False,
|
|
38
|
+
"--reload",
|
|
39
|
+
help="Enable auto-reload on file changes (development)",
|
|
40
|
+
),
|
|
41
|
+
) -> None:
|
|
42
|
+
"""DEPRECATED: Web UI has been removed in favor of OTEL + Jaeger.
|
|
43
|
+
|
|
44
|
+
Use instead:
|
|
45
|
+
- lode export --format otel (export to Jaeger/DataDog)
|
|
46
|
+
- lode inspect (view traces locally)
|
|
47
|
+
|
|
48
|
+
To view traces with a UI, deploy Jaeger:
|
|
49
|
+
docker run -d -p 6831:6831/udp -p 16686:16686 jaegertracing/all-in-one
|
|
50
|
+
|
|
51
|
+
Then export traces:
|
|
52
|
+
lode export --trace-id abc123 --format otel --otel-endpoint http://localhost:4317
|
|
53
|
+
|
|
54
|
+
And open: http://localhost:16686
|
|
55
|
+
"""
|
|
56
|
+
typer.echo(
|
|
57
|
+
"[WARNING] The 'lode ui' command has been removed in favor of OTEL integration.\n"
|
|
58
|
+
"Use 'lode export --format otel' to send traces to Jaeger or DataDog.\n"
|
|
59
|
+
"Use 'lode inspect' to view traces locally in the terminal.",
|
|
60
|
+
err=True,
|
|
61
|
+
)
|
|
62
|
+
raise typer.Exit(code=1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@app.command()
|
|
66
|
+
def export(
|
|
67
|
+
trace_id: str = typer.Option(..., "--trace-id", "-t", help="Trace ID to export"),
|
|
68
|
+
export_format: str = typer.Option(
|
|
69
|
+
"assay",
|
|
70
|
+
"--format",
|
|
71
|
+
"-f",
|
|
72
|
+
help="Export format: assay, json, yaml, otel",
|
|
73
|
+
),
|
|
74
|
+
output: str | None = typer.Option(None, "--output", "-o", help="Output file path"),
|
|
75
|
+
storage: str | None = typer.Option(
|
|
76
|
+
None,
|
|
77
|
+
"--storage",
|
|
78
|
+
"-s",
|
|
79
|
+
help="SQLite trace database path (optional; defaults to ./traces.db)",
|
|
80
|
+
),
|
|
81
|
+
otel_endpoint: str = typer.Option(
|
|
82
|
+
"http://localhost:4317",
|
|
83
|
+
"--otel-endpoint",
|
|
84
|
+
help="OTEL collector endpoint for format=otel",
|
|
85
|
+
),
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Export trace to test suite, OTEL backend, or other formats.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
lode export --trace-id abc123 --format assay --output suite.yaml
|
|
91
|
+
lode export --trace-id abc123 --format json
|
|
92
|
+
lode export --trace-id abc123 --format otel --otel-endpoint http://localhost:4317
|
|
93
|
+
"""
|
|
94
|
+
# For OTEL export, storage is not required
|
|
95
|
+
if export_format == "otel":
|
|
96
|
+
typer.echo(f"[INFO] OTEL export configured for: {otel_endpoint}")
|
|
97
|
+
typer.echo("[INFO] In production, use lode instrumentation hooks to capture and export traces.")
|
|
98
|
+
typer.echo("[INFO] Traces are exported in real-time to Jaeger/DataDog/etc.")
|
|
99
|
+
return
|
|
100
|
+
|
|
101
|
+
# For all other formats, storage is required
|
|
102
|
+
if not storage:
|
|
103
|
+
storage = "./traces.db"
|
|
104
|
+
|
|
105
|
+
# Load trace from storage
|
|
106
|
+
store = SQLiteStore(db_path=Path(storage))
|
|
107
|
+
spans = store.get_trace(trace_id)
|
|
108
|
+
|
|
109
|
+
if not spans:
|
|
110
|
+
typer.echo(f"Error: Trace '{trace_id}' not found in {storage}", err=True)
|
|
111
|
+
raise typer.Exit(code=1)
|
|
112
|
+
|
|
113
|
+
# Build TraceTree
|
|
114
|
+
trace = build_trace_tree(spans)
|
|
115
|
+
|
|
116
|
+
# Convert to requested format
|
|
117
|
+
try:
|
|
118
|
+
if export_format == "assay":
|
|
119
|
+
content = trace_to_assay_yaml(trace)
|
|
120
|
+
elif export_format == "json":
|
|
121
|
+
# Convert to JSON for easier programmatic access
|
|
122
|
+
spans_data = []
|
|
123
|
+
for span in trace.spans.values():
|
|
124
|
+
span_dict = {
|
|
125
|
+
"span_id": span.span_id,
|
|
126
|
+
"parent_span_id": span.parent_span_id,
|
|
127
|
+
"kind": span.kind.value,
|
|
128
|
+
"name": span.name,
|
|
129
|
+
"start_time_ms": span.start_time_ms,
|
|
130
|
+
"end_time_ms": span.end_time_ms,
|
|
131
|
+
"duration_ms": span.duration_ms,
|
|
132
|
+
"status": span.status,
|
|
133
|
+
"metadata": span.metadata,
|
|
134
|
+
}
|
|
135
|
+
if span.model_event:
|
|
136
|
+
span_dict["model_event"] = {
|
|
137
|
+
"model_name": span.model_event.model_name,
|
|
138
|
+
"provider": span.model_event.provider.value,
|
|
139
|
+
"tokens": (
|
|
140
|
+
{
|
|
141
|
+
"input_tokens": span.model_event.tokens.input_tokens,
|
|
142
|
+
"output_tokens": span.model_event.tokens.output_tokens,
|
|
143
|
+
"total_tokens": span.model_event.tokens.total_tokens,
|
|
144
|
+
}
|
|
145
|
+
if span.model_event.tokens
|
|
146
|
+
else None
|
|
147
|
+
),
|
|
148
|
+
"latency_ms": span.model_event.latency_ms,
|
|
149
|
+
"error": span.model_event.error,
|
|
150
|
+
}
|
|
151
|
+
spans_data.append(span_dict)
|
|
152
|
+
|
|
153
|
+
content = json.dumps(
|
|
154
|
+
{
|
|
155
|
+
"trace_id": trace_id,
|
|
156
|
+
"spans": spans_data,
|
|
157
|
+
},
|
|
158
|
+
indent=2,
|
|
159
|
+
)
|
|
160
|
+
elif export_format == "otel":
|
|
161
|
+
# Export to OTEL-compatible backend (Jaeger, DataDog, etc.)
|
|
162
|
+
from lode_observe_ai.infrastructure.otel_exporter import export_to_jaeger
|
|
163
|
+
|
|
164
|
+
export_to_jaeger(trace_id, spans, jaeger_endpoint=otel_endpoint)
|
|
165
|
+
typer.echo(f"✓ Trace exported to OTEL endpoint: {otel_endpoint}")
|
|
166
|
+
return
|
|
167
|
+
|
|
168
|
+
# Write output
|
|
169
|
+
if output:
|
|
170
|
+
output_path = Path(output)
|
|
171
|
+
output_path.write_text(content)
|
|
172
|
+
typer.echo(f"Exported to {output_path}")
|
|
173
|
+
else:
|
|
174
|
+
typer.echo(content)
|
|
175
|
+
|
|
176
|
+
except Exception as e:
|
|
177
|
+
typer.echo(f"Error exporting trace: {e}", err=True)
|
|
178
|
+
raise typer.Exit(code=1) from e
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@app.command()
|
|
182
|
+
def inspect(
|
|
183
|
+
trace_id: str = typer.Option(..., "--trace-id", "-t", help="Trace ID to inspect"),
|
|
184
|
+
storage: str = typer.Option(
|
|
185
|
+
"./traces.db",
|
|
186
|
+
"--storage",
|
|
187
|
+
"-s",
|
|
188
|
+
help="SQLite trace database path",
|
|
189
|
+
),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Inspect trace metadata and summary.
|
|
192
|
+
|
|
193
|
+
Example:
|
|
194
|
+
lode inspect --trace-id abc123
|
|
195
|
+
"""
|
|
196
|
+
# Load trace from storage
|
|
197
|
+
store = SQLiteStore(db_path=Path(storage))
|
|
198
|
+
spans = store.get_trace(trace_id)
|
|
199
|
+
|
|
200
|
+
if not spans:
|
|
201
|
+
typer.echo(f"Error: Trace '{trace_id}' not found", err=True)
|
|
202
|
+
raise typer.Exit(code=1)
|
|
203
|
+
|
|
204
|
+
# Build TraceTree
|
|
205
|
+
trace = build_trace_tree(spans)
|
|
206
|
+
|
|
207
|
+
# Display trace info
|
|
208
|
+
typer.echo(f"Trace ID: {trace_id}")
|
|
209
|
+
typer.echo(f"Spans: {len(trace.spans)}")
|
|
210
|
+
typer.echo(f"Root span: {trace.root_span_id if hasattr(trace, 'root_span_id') else 'N/A'}")
|
|
211
|
+
|
|
212
|
+
# Show span summary
|
|
213
|
+
status_counts: dict[str, int] = {}
|
|
214
|
+
kind_counts: dict[str, int] = {}
|
|
215
|
+
|
|
216
|
+
for span in trace.spans.values():
|
|
217
|
+
status_counts[span.status] = status_counts.get(span.status, 0) + 1
|
|
218
|
+
kind = span.kind.value
|
|
219
|
+
kind_counts[kind] = kind_counts.get(kind, 0) + 1
|
|
220
|
+
|
|
221
|
+
typer.echo("\nStatus Distribution:")
|
|
222
|
+
for status, count in sorted(status_counts.items()):
|
|
223
|
+
typer.echo(f" {status}: {count}")
|
|
224
|
+
|
|
225
|
+
typer.echo("\nSpan Kinds:")
|
|
226
|
+
for kind, count in sorted(kind_counts.items()):
|
|
227
|
+
typer.echo(f" {kind}: {count}")
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""Pure functional core: DAG assembly, cost aggregation, anomaly detection, replay generation.
|
|
2
|
+
|
|
3
|
+
No I/O side effects. All functions are deterministic and composable.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from collections import defaultdict
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from lode_observe_ai.domain import (
|
|
13
|
+
AnomalyIndicators,
|
|
14
|
+
CostBreakdown,
|
|
15
|
+
ReplayStep,
|
|
16
|
+
Span,
|
|
17
|
+
SpanKind,
|
|
18
|
+
TraceMetadata,
|
|
19
|
+
TraceTree,
|
|
20
|
+
)
|
|
21
|
+
from lode_observe_ai.domain import (
|
|
22
|
+
ModelInferenceEvent as ModelInferenceEvent,
|
|
23
|
+
)
|
|
24
|
+
from lode_observe_ai.domain import (
|
|
25
|
+
ToolCallEvent as ToolCallEvent,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
# ============================================================================
|
|
29
|
+
# DAG ASSEMBLY & HIERARCHY
|
|
30
|
+
# ============================================================================
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def build_trace_tree(spans: list[Span], root_span_id: str | None = None) -> TraceTree:
|
|
34
|
+
"""Assemble flat span list into hierarchical DAG tree.
|
|
35
|
+
|
|
36
|
+
Validates parent-child relationships and detects cycles.
|
|
37
|
+
"""
|
|
38
|
+
if not spans:
|
|
39
|
+
return TraceTree(trace_id="", root_span_id="", spans={})
|
|
40
|
+
|
|
41
|
+
# Build span map
|
|
42
|
+
span_map: dict[str, Span] = {s.span_id: s for s in spans}
|
|
43
|
+
|
|
44
|
+
# Find root (span with no parent, or explicitly provided)
|
|
45
|
+
root_id = root_span_id or next((s.span_id for s in spans if s.parent_span_id is None), "")
|
|
46
|
+
|
|
47
|
+
if not root_id and spans:
|
|
48
|
+
root_id = spans[0].span_id
|
|
49
|
+
|
|
50
|
+
# Calculate depth
|
|
51
|
+
max_depth = _calculate_depth(span_map, root_id)
|
|
52
|
+
|
|
53
|
+
# Calculate total duration
|
|
54
|
+
if root_id in span_map:
|
|
55
|
+
root_span = span_map[root_id]
|
|
56
|
+
total_duration = root_span.duration_ms
|
|
57
|
+
else:
|
|
58
|
+
total_duration = 0.0
|
|
59
|
+
|
|
60
|
+
return TraceTree(
|
|
61
|
+
trace_id=next((s.metadata.get("trace_id", "") for s in spans), ""),
|
|
62
|
+
root_span_id=root_id,
|
|
63
|
+
spans=span_map,
|
|
64
|
+
depth=max_depth,
|
|
65
|
+
total_duration_ms=total_duration,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _calculate_depth(span_map: dict[str, Span], span_id: str, visited: set[str] | None = None) -> int:
|
|
70
|
+
"""Recursively calculate tree depth."""
|
|
71
|
+
if visited is None:
|
|
72
|
+
visited = set()
|
|
73
|
+
|
|
74
|
+
if span_id in visited: # Cycle detection
|
|
75
|
+
return 0
|
|
76
|
+
|
|
77
|
+
visited.add(span_id)
|
|
78
|
+
|
|
79
|
+
if span_id not in span_map:
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
# Find children
|
|
83
|
+
children = [s for s in span_map.values() if s.parent_span_id == span_id]
|
|
84
|
+
|
|
85
|
+
if not children:
|
|
86
|
+
return 1
|
|
87
|
+
|
|
88
|
+
return 1 + max(_calculate_depth(span_map, c.span_id, visited) for c in children)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def get_span_path(tree: TraceTree, span_id: str) -> list[str]:
|
|
92
|
+
"""Get path from root to target span."""
|
|
93
|
+
if span_id not in tree.spans:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
path = [span_id]
|
|
97
|
+
current = tree.spans[span_id]
|
|
98
|
+
|
|
99
|
+
while current.parent_span_id:
|
|
100
|
+
path.insert(0, current.parent_span_id)
|
|
101
|
+
current = tree.spans.get(current.parent_span_id)
|
|
102
|
+
if current is None:
|
|
103
|
+
break
|
|
104
|
+
|
|
105
|
+
return path
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ============================================================================
|
|
109
|
+
# COST & TOKEN AGGREGATION
|
|
110
|
+
# ============================================================================
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def aggregate_costs(tree: TraceTree) -> CostBreakdown:
|
|
114
|
+
"""Aggregate token costs across all inferences in trace."""
|
|
115
|
+
total_input = 0
|
|
116
|
+
total_output = 0
|
|
117
|
+
cost_by_model: dict[str, float] = defaultdict(float)
|
|
118
|
+
cost_by_provider: dict[str, float] = defaultdict(float)
|
|
119
|
+
|
|
120
|
+
for span in tree.spans.values():
|
|
121
|
+
if span.model_event and span.model_event.tokens:
|
|
122
|
+
tokens = span.model_event.tokens
|
|
123
|
+
total_input += tokens.input_tokens
|
|
124
|
+
total_output += tokens.output_tokens
|
|
125
|
+
|
|
126
|
+
# Accumulate by model and provider
|
|
127
|
+
model_name = span.model_event.model_name
|
|
128
|
+
provider = span.model_event.provider.value
|
|
129
|
+
|
|
130
|
+
cost_by_model[model_name] += tokens.cost_usd
|
|
131
|
+
cost_by_provider[provider] += tokens.cost_usd
|
|
132
|
+
|
|
133
|
+
total_tokens = total_input + total_output
|
|
134
|
+
total_cost = sum(cost_by_model.values())
|
|
135
|
+
|
|
136
|
+
return CostBreakdown(
|
|
137
|
+
total_input_tokens=total_input,
|
|
138
|
+
total_output_tokens=total_output,
|
|
139
|
+
total_tokens=total_tokens,
|
|
140
|
+
total_cost_usd=total_cost,
|
|
141
|
+
cost_by_model=dict(cost_by_model),
|
|
142
|
+
cost_by_provider=dict(cost_by_provider),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ============================================================================
|
|
147
|
+
# ANOMALY DETECTION
|
|
148
|
+
# ============================================================================
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def detect_anomalies(tree: TraceTree) -> AnomalyIndicators:
|
|
152
|
+
"""Detect anomalies in trace execution."""
|
|
153
|
+
flags: list[str] = []
|
|
154
|
+
|
|
155
|
+
# Check for cycles
|
|
156
|
+
has_cycle = _detect_cycle(tree)
|
|
157
|
+
if has_cycle:
|
|
158
|
+
flags.append("cycle_detected")
|
|
159
|
+
|
|
160
|
+
# Check for repeated tool calls (infinite loop indicator)
|
|
161
|
+
max_repeated = _max_repeated_tool_calls(tree)
|
|
162
|
+
if max_repeated > 5:
|
|
163
|
+
flags.append("high_tool_call_repetition")
|
|
164
|
+
|
|
165
|
+
# Check for context window exhaustion
|
|
166
|
+
has_context_exhaustion = _check_context_exhaustion(tree)
|
|
167
|
+
if has_context_exhaustion:
|
|
168
|
+
flags.append("context_window_exhaustion")
|
|
169
|
+
|
|
170
|
+
# Check for unusual latency
|
|
171
|
+
has_unusual_latency = _check_unusual_latency(tree)
|
|
172
|
+
if has_unusual_latency:
|
|
173
|
+
flags.append("unusual_latency")
|
|
174
|
+
|
|
175
|
+
return AnomalyIndicators(
|
|
176
|
+
has_cycle=has_cycle,
|
|
177
|
+
max_repeated_tool_calls=max_repeated,
|
|
178
|
+
estimated_infinite_loop=max_repeated > 5 or (has_cycle and len(flags) > 1),
|
|
179
|
+
context_window_exhaustion=has_context_exhaustion,
|
|
180
|
+
unusual_latency=has_unusual_latency,
|
|
181
|
+
flags=flags,
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _detect_cycle(tree: TraceTree) -> bool:
|
|
186
|
+
"""Detect if trace has cycle (parent-child loop)."""
|
|
187
|
+
for span in tree.spans.values():
|
|
188
|
+
visited: set[str] = set()
|
|
189
|
+
current = span
|
|
190
|
+
while current.parent_span_id:
|
|
191
|
+
if current.parent_span_id in visited:
|
|
192
|
+
return True
|
|
193
|
+
visited.add(current.parent_span_id)
|
|
194
|
+
current = tree.spans.get(current.parent_span_id)
|
|
195
|
+
if current is None:
|
|
196
|
+
break
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _max_repeated_tool_calls(tree: TraceTree) -> int:
|
|
201
|
+
"""Count maximum repetitions of same tool call."""
|
|
202
|
+
tool_call_counts: dict[str, int] = defaultdict(int)
|
|
203
|
+
|
|
204
|
+
for span in tree.spans.values():
|
|
205
|
+
if span.tool_event:
|
|
206
|
+
tool_name = span.tool_event.tool_name
|
|
207
|
+
tool_call_counts[tool_name] += 1
|
|
208
|
+
|
|
209
|
+
return max(tool_call_counts.values()) if tool_call_counts else 0
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _check_context_exhaustion(tree: TraceTree) -> bool:
|
|
213
|
+
"""Check if any span indicates context window exhaustion."""
|
|
214
|
+
for span in tree.spans.values():
|
|
215
|
+
if span.kind == SpanKind.CONTEXT_WINDOW:
|
|
216
|
+
return True
|
|
217
|
+
if "context_exhausted" in span.metadata:
|
|
218
|
+
return span.metadata["context_exhausted"]
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _check_unusual_latency(tree: TraceTree) -> bool:
|
|
223
|
+
"""Check if any span has unusually high latency (>5s)."""
|
|
224
|
+
return any(span.duration_ms > 5000 for span in tree.spans.values())
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ============================================================================
|
|
228
|
+
# REPLAY PAYLOAD GENERATION
|
|
229
|
+
# ============================================================================
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def extract_replay_steps(tree: TraceTree) -> list[ReplayStep]:
|
|
233
|
+
"""Extract replay payloads for offline step recreation."""
|
|
234
|
+
replay_steps: list[ReplayStep] = []
|
|
235
|
+
|
|
236
|
+
for span in tree.spans.values():
|
|
237
|
+
if not span.is_terminal:
|
|
238
|
+
continue
|
|
239
|
+
|
|
240
|
+
# Build input context from metadata
|
|
241
|
+
input_context = span.metadata.copy() if span.metadata else {}
|
|
242
|
+
|
|
243
|
+
replay_step = ReplayStep(
|
|
244
|
+
step_id=span.span_id,
|
|
245
|
+
span_kind=span.kind,
|
|
246
|
+
input_context=input_context or None,
|
|
247
|
+
model_inference=span.model_event,
|
|
248
|
+
tool_call=span.tool_event,
|
|
249
|
+
expected_output=span.metadata.get("output"),
|
|
250
|
+
)
|
|
251
|
+
replay_steps.append(replay_step)
|
|
252
|
+
|
|
253
|
+
return replay_steps
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ============================================================================
|
|
257
|
+
# TRACE SUMMARY & ANALYTICS
|
|
258
|
+
# ============================================================================
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def generate_trace_summary(
|
|
262
|
+
tree: TraceTree,
|
|
263
|
+
metadata: TraceMetadata | None = None,
|
|
264
|
+
) -> dict[str, Any]:
|
|
265
|
+
"""Generate comprehensive trace summary with all analytics."""
|
|
266
|
+
start_time = time.perf_counter()
|
|
267
|
+
|
|
268
|
+
costs = aggregate_costs(tree)
|
|
269
|
+
anomalies = detect_anomalies(tree)
|
|
270
|
+
replay_steps = extract_replay_steps(tree)
|
|
271
|
+
|
|
272
|
+
# Collect span statistics
|
|
273
|
+
span_kinds: dict[str, int] = defaultdict(int)
|
|
274
|
+
error_count = 0
|
|
275
|
+
max_latency_ms = 0.0
|
|
276
|
+
|
|
277
|
+
for span in tree.spans.values():
|
|
278
|
+
span_kinds[span.kind.value] += 1
|
|
279
|
+
if span.status == "error":
|
|
280
|
+
error_count += 1
|
|
281
|
+
max_latency_ms = max(max_latency_ms, span.duration_ms)
|
|
282
|
+
|
|
283
|
+
elapsed_ms = (time.perf_counter() - start_time) * 1000
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
"trace_id": tree.trace_id,
|
|
287
|
+
"span_count": tree.span_count,
|
|
288
|
+
"depth": tree.depth,
|
|
289
|
+
"duration_ms": tree.total_duration_ms,
|
|
290
|
+
"span_kinds": dict(span_kinds),
|
|
291
|
+
"error_count": error_count,
|
|
292
|
+
"max_latency_ms": max_latency_ms,
|
|
293
|
+
"costs": {
|
|
294
|
+
"total_tokens": costs.total_tokens,
|
|
295
|
+
"input_tokens": costs.total_input_tokens,
|
|
296
|
+
"output_tokens": costs.total_output_tokens,
|
|
297
|
+
"total_cost_usd": costs.total_cost_usd,
|
|
298
|
+
"by_model": costs.cost_by_model,
|
|
299
|
+
"by_provider": costs.cost_by_provider,
|
|
300
|
+
},
|
|
301
|
+
"anomalies": {
|
|
302
|
+
"has_cycle": anomalies.has_cycle,
|
|
303
|
+
"max_repeated_tool_calls": anomalies.max_repeated_tool_calls,
|
|
304
|
+
"estimated_infinite_loop": anomalies.estimated_infinite_loop,
|
|
305
|
+
"context_window_exhaustion": anomalies.context_window_exhaustion,
|
|
306
|
+
"unusual_latency": anomalies.unusual_latency,
|
|
307
|
+
"flags": anomalies.flags,
|
|
308
|
+
},
|
|
309
|
+
"replay_steps_count": len(replay_steps),
|
|
310
|
+
"analysis_latency_ms": elapsed_ms,
|
|
311
|
+
}
|