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.
@@ -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
+ }