asap-protocol 0.3.0__py3-none-any.whl → 1.0.0__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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/errors.py +167 -0
- asap/examples/README.md +81 -10
- asap/examples/auth_patterns.py +212 -0
- asap/examples/error_recovery.py +248 -0
- asap/examples/long_running.py +287 -0
- asap/examples/mcp_integration.py +240 -0
- asap/examples/multi_step_workflow.py +134 -0
- asap/examples/orchestration.py +293 -0
- asap/examples/rate_limiting.py +137 -0
- asap/examples/run_demo.py +9 -4
- asap/examples/secure_handler.py +84 -0
- asap/examples/state_migration.py +240 -0
- asap/examples/streaming_response.py +108 -0
- asap/examples/websocket_concept.py +129 -0
- asap/mcp/__init__.py +43 -0
- asap/mcp/client.py +224 -0
- asap/mcp/protocol.py +179 -0
- asap/mcp/server.py +333 -0
- asap/mcp/server_runner.py +40 -0
- asap/models/__init__.py +4 -0
- asap/models/base.py +0 -3
- asap/models/constants.py +76 -1
- asap/models/entities.py +58 -7
- asap/models/envelope.py +14 -1
- asap/models/ids.py +8 -4
- asap/models/parts.py +33 -3
- asap/models/validators.py +16 -0
- asap/observability/__init__.py +6 -0
- asap/observability/dashboards/README.md +24 -0
- asap/observability/dashboards/asap-detailed.json +131 -0
- asap/observability/dashboards/asap-red.json +129 -0
- asap/observability/logging.py +81 -1
- asap/observability/metrics.py +15 -1
- asap/observability/trace_parser.py +238 -0
- asap/observability/trace_ui.py +218 -0
- asap/observability/tracing.py +293 -0
- asap/state/machine.py +15 -2
- asap/state/snapshot.py +0 -9
- asap/testing/__init__.py +31 -0
- asap/testing/assertions.py +108 -0
- asap/testing/fixtures.py +113 -0
- asap/testing/mocks.py +152 -0
- asap/transport/__init__.py +31 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +194 -0
- asap/transport/client.py +989 -72
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +64 -39
- asap/transport/server.py +461 -94
- asap/transport/validators.py +320 -0
- asap/utils/__init__.py +7 -0
- asap/utils/sanitization.py +134 -0
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.3.0.dist-info/METADATA +0 -227
- asap_protocol-0.3.0.dist-info/RECORD +0 -37
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Trace parsing and ASCII visualization from ASAP structured logs.
|
|
2
|
+
|
|
3
|
+
Parses JSON log lines containing asap.request.received and asap.request.processed
|
|
4
|
+
events, filters by trace_id, and builds a request flow with timing for CLI output.
|
|
5
|
+
|
|
6
|
+
Expected log events (from transport/server.py):
|
|
7
|
+
- asap.request.received: envelope_id, trace_id, sender, recipient, payload_type
|
|
8
|
+
- asap.request.processed: envelope_id, trace_id, duration_ms, payload_type
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
# Event names emitted by ASAP server
|
|
18
|
+
EVENT_RECEIVED = "asap.request.received"
|
|
19
|
+
EVENT_PROCESSED = "asap.request.processed"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class TraceHop:
|
|
24
|
+
"""A single hop in a trace: sender -> recipient with optional duration."""
|
|
25
|
+
|
|
26
|
+
sender: str
|
|
27
|
+
recipient: str
|
|
28
|
+
duration_ms: float | None
|
|
29
|
+
|
|
30
|
+
def format_hop(self, short_urns: bool = True) -> str:
|
|
31
|
+
"""Format this hop for ASCII diagram (e.g. 'A -> B (15ms)')."""
|
|
32
|
+
s = _shorten_urn(self.sender) if short_urns else self.sender
|
|
33
|
+
r = _shorten_urn(self.recipient) if short_urns else self.recipient
|
|
34
|
+
if self.duration_ms is not None:
|
|
35
|
+
return f"{s} -> {r} ({self.duration_ms:.0f}ms)"
|
|
36
|
+
return f"{s} -> {r} (?)"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _shorten_urn(urn: str) -> str:
|
|
40
|
+
"""Shorten URN to last segment for compact display (e.g. urn:asap:agent:foo -> foo)."""
|
|
41
|
+
if ":" in urn:
|
|
42
|
+
return urn.split(":")[-1]
|
|
43
|
+
return urn
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_log_line(line: str) -> dict[str, object] | None:
|
|
47
|
+
"""Parse a single JSON log line.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
line: One line of log output (e.g. structlog JSON).
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Parsed dict or None if not valid JSON.
|
|
54
|
+
"""
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line:
|
|
57
|
+
return None
|
|
58
|
+
try:
|
|
59
|
+
data = json.loads(line)
|
|
60
|
+
return data if isinstance(data, dict) else None
|
|
61
|
+
except json.JSONDecodeError:
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def filter_records_by_trace_id(lines: Iterable[str], trace_id: str) -> list[dict[str, object]]:
|
|
66
|
+
"""Filter and parse log lines that contain the given trace_id.
|
|
67
|
+
|
|
68
|
+
Only lines that are valid JSON and contain trace_id (string match) are returned.
|
|
69
|
+
This avoids parsing every line as JSON when scanning large files.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
lines: Log lines (e.g. from a file or stdin).
|
|
73
|
+
trace_id: Trace ID to search for.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
List of parsed log records that mention this trace_id.
|
|
77
|
+
"""
|
|
78
|
+
trace_id_str = str(trace_id)
|
|
79
|
+
records: list[dict[str, object]] = []
|
|
80
|
+
for line in lines:
|
|
81
|
+
if trace_id_str not in line:
|
|
82
|
+
continue
|
|
83
|
+
parsed = parse_log_line(line)
|
|
84
|
+
if parsed is None:
|
|
85
|
+
continue
|
|
86
|
+
if parsed.get("trace_id") != trace_id_str:
|
|
87
|
+
continue
|
|
88
|
+
records.append(parsed)
|
|
89
|
+
return records
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def build_hops(records: list[dict[str, object]]) -> list[TraceHop]:
|
|
93
|
+
"""Build ordered list of trace hops from received/processed log records.
|
|
94
|
+
|
|
95
|
+
Pairs asap.request.received with asap.request.processed by envelope_id to
|
|
96
|
+
attach duration_ms to each hop. Sorts by received timestamp when available.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
records: Parsed log records (from filter_records_by_trace_id).
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Ordered list of TraceHop (sender -> recipient, duration_ms or None).
|
|
103
|
+
"""
|
|
104
|
+
received_by_envelope: dict[str, dict[str, object]] = {}
|
|
105
|
+
processed_by_envelope: dict[str, dict[str, object]] = {}
|
|
106
|
+
|
|
107
|
+
for r in records:
|
|
108
|
+
event = r.get("event")
|
|
109
|
+
envelope_id = r.get("envelope_id")
|
|
110
|
+
if not isinstance(envelope_id, str):
|
|
111
|
+
continue
|
|
112
|
+
if event == EVENT_RECEIVED:
|
|
113
|
+
received_by_envelope[envelope_id] = r
|
|
114
|
+
elif event == EVENT_PROCESSED:
|
|
115
|
+
processed_by_envelope[envelope_id] = r
|
|
116
|
+
|
|
117
|
+
hops_with_meta: list[tuple[float, str, str, float | None]] = []
|
|
118
|
+
|
|
119
|
+
for envelope_id, rec in received_by_envelope.items():
|
|
120
|
+
sender = rec.get("sender")
|
|
121
|
+
recipient = rec.get("recipient")
|
|
122
|
+
if not isinstance(sender, str) or not isinstance(recipient, str):
|
|
123
|
+
continue
|
|
124
|
+
proc = processed_by_envelope.get(envelope_id)
|
|
125
|
+
duration_ms: float | None = None
|
|
126
|
+
if isinstance(proc, dict) and "duration_ms" in proc:
|
|
127
|
+
d = proc["duration_ms"]
|
|
128
|
+
if isinstance(d, (int, float)):
|
|
129
|
+
duration_ms = float(d)
|
|
130
|
+
ts = rec.get("timestamp")
|
|
131
|
+
sort_key = _timestamp_to_sort_key(ts) if isinstance(ts, str) else 0.0
|
|
132
|
+
hops_with_meta.append((sort_key, sender, recipient, duration_ms))
|
|
133
|
+
|
|
134
|
+
hops_with_meta.sort(key=lambda x: x[0])
|
|
135
|
+
|
|
136
|
+
return [TraceHop(sender=s, recipient=r, duration_ms=d) for (_, s, r, d) in hops_with_meta]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _timestamp_to_sort_key(ts: str) -> float:
|
|
140
|
+
"""Convert ISO timestamp string to a sortable number (epoch-ish)."""
|
|
141
|
+
try:
|
|
142
|
+
from datetime import datetime
|
|
143
|
+
|
|
144
|
+
dt = datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
|
145
|
+
return dt.timestamp()
|
|
146
|
+
except (ValueError, TypeError):
|
|
147
|
+
return 0.0
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def format_ascii_diagram(hops: list[TraceHop], short_urns: bool = True) -> str:
|
|
151
|
+
"""Format trace hops as a single-line ASCII diagram with timing.
|
|
152
|
+
|
|
153
|
+
Format: Agent A -> Agent B (15ms) -> Agent C (23ms)
|
|
154
|
+
Each hop shows recipient and duration; sender of first hop starts the chain.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
hops: Ordered list of TraceHop from build_hops.
|
|
158
|
+
short_urns: If True, shorten URNs to last segment for display.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
Single line string for CLI output.
|
|
162
|
+
"""
|
|
163
|
+
if not hops:
|
|
164
|
+
return ""
|
|
165
|
+
parts: list[str] = []
|
|
166
|
+
for hop in hops:
|
|
167
|
+
r = _shorten_urn(hop.recipient) if short_urns else hop.recipient
|
|
168
|
+
if hop.duration_ms is not None:
|
|
169
|
+
parts.append(f"{r} ({hop.duration_ms:.0f}ms)")
|
|
170
|
+
else:
|
|
171
|
+
parts.append(f"{r} (?)")
|
|
172
|
+
first_sender = _shorten_urn(hops[0].sender) if short_urns else hops[0].sender
|
|
173
|
+
return first_sender + " -> " + " -> ".join(parts)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def extract_trace_ids(lines: Iterable[str]) -> list[str]:
|
|
177
|
+
"""Extract unique trace IDs from log lines (received/processed events only).
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
lines: Log lines (e.g. from file or stdin).
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
Sorted list of unique trace_id values found in asap.request.received
|
|
184
|
+
or asap.request.processed events.
|
|
185
|
+
"""
|
|
186
|
+
seen: set[str] = set()
|
|
187
|
+
for line in lines:
|
|
188
|
+
if EVENT_RECEIVED not in line and EVENT_PROCESSED not in line:
|
|
189
|
+
continue
|
|
190
|
+
parsed = parse_log_line(line)
|
|
191
|
+
if parsed is None:
|
|
192
|
+
continue
|
|
193
|
+
if parsed.get("event") not in (EVENT_RECEIVED, EVENT_PROCESSED):
|
|
194
|
+
continue
|
|
195
|
+
tid = parsed.get("trace_id")
|
|
196
|
+
if isinstance(tid, str) and tid:
|
|
197
|
+
seen.add(tid)
|
|
198
|
+
return sorted(seen)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def trace_to_json_export(trace_id: str, hops: list[TraceHop]) -> dict[str, object]:
|
|
202
|
+
"""Build a JSON-serializable dict for a trace (for --format json and external tools).
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
trace_id: Trace ID.
|
|
206
|
+
hops: Ordered list of TraceHop from build_hops.
|
|
207
|
+
|
|
208
|
+
Returns:
|
|
209
|
+
Dict with keys: trace_id, hops (list of {sender, recipient, duration_ms}).
|
|
210
|
+
"""
|
|
211
|
+
return {
|
|
212
|
+
"trace_id": trace_id,
|
|
213
|
+
"hops": [
|
|
214
|
+
{
|
|
215
|
+
"sender": h.sender,
|
|
216
|
+
"recipient": h.recipient,
|
|
217
|
+
"duration_ms": h.duration_ms,
|
|
218
|
+
}
|
|
219
|
+
for h in hops
|
|
220
|
+
],
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def parse_trace_from_lines(lines: Iterable[str], trace_id: str) -> tuple[list[TraceHop], str]:
|
|
225
|
+
"""Parse logs and build ASCII diagram for a trace_id.
|
|
226
|
+
|
|
227
|
+
Args:
|
|
228
|
+
lines: Log lines (e.g. from file or stdin).
|
|
229
|
+
trace_id: Trace ID to filter and visualize.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Tuple of (list of TraceHop, ASCII diagram string). Diagram is empty
|
|
233
|
+
if no matching records.
|
|
234
|
+
"""
|
|
235
|
+
records = filter_records_by_trace_id(lines, trace_id)
|
|
236
|
+
hops = build_hops(records)
|
|
237
|
+
diagram = format_ascii_diagram(hops)
|
|
238
|
+
return hops, diagram
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Optional Web UI for ASAP trace visualization.
|
|
2
|
+
|
|
3
|
+
Serves a simple FastAPI app to browse traces, search by trace_id, and visualize
|
|
4
|
+
request flow from pasted JSON log lines (ASAP_LOG_FORMAT=json).
|
|
5
|
+
|
|
6
|
+
Run with: uvicorn asap.observability.trace_ui:app --reload
|
|
7
|
+
Then open http://localhost:8000
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from fastapi import FastAPI, HTTPException
|
|
15
|
+
from fastapi.responses import HTMLResponse
|
|
16
|
+
from pydantic import BaseModel
|
|
17
|
+
|
|
18
|
+
from asap.observability.trace_parser import (
|
|
19
|
+
TraceHop,
|
|
20
|
+
extract_trace_ids,
|
|
21
|
+
parse_trace_from_lines,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
MAX_LOG_LINES_LENGTH = 2 * 1024 * 1024
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _parse_log_lines(raw: str, max_length: int = MAX_LOG_LINES_LENGTH) -> list[str]:
|
|
28
|
+
"""Parse raw log lines into non-empty stripped lines; raise 413 if over max_length."""
|
|
29
|
+
if len(raw) > max_length:
|
|
30
|
+
raise HTTPException(
|
|
31
|
+
status_code=413,
|
|
32
|
+
detail=f"Log lines payload too large (max {max_length} bytes)",
|
|
33
|
+
)
|
|
34
|
+
return [s.strip() for s in raw.strip().splitlines() if s.strip()]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
app = FastAPI(
|
|
38
|
+
title="ASAP Trace UI",
|
|
39
|
+
description="Browse and visualize ASAP request traces from JSON log lines.",
|
|
40
|
+
version="0.1.0",
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LogLinesBody(BaseModel):
|
|
45
|
+
"""Request body with newline-separated log lines."""
|
|
46
|
+
|
|
47
|
+
log_lines: str
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VisualizeBody(BaseModel):
|
|
51
|
+
"""Request body for trace visualization."""
|
|
52
|
+
|
|
53
|
+
log_lines: str
|
|
54
|
+
trace_id: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _hops_to_dict(hops: list[TraceHop]) -> list[dict[str, Any]]:
|
|
58
|
+
"""Convert TraceHop list to JSON-serializable dicts."""
|
|
59
|
+
return [
|
|
60
|
+
{"sender": h.sender, "recipient": h.recipient, "duration_ms": h.duration_ms} for h in hops
|
|
61
|
+
]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@app.get("/", response_class=HTMLResponse)
|
|
65
|
+
def index() -> HTMLResponse:
|
|
66
|
+
"""Serve the trace browser UI (paste logs, list trace IDs, visualize)."""
|
|
67
|
+
html = _INDEX_HTML
|
|
68
|
+
return HTMLResponse(html)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.post("/api/traces/list")
|
|
72
|
+
def list_traces(body: LogLinesBody) -> dict[str, Any]:
|
|
73
|
+
"""Extract unique trace IDs from pasted log lines."""
|
|
74
|
+
lines = _parse_log_lines(body.log_lines)
|
|
75
|
+
trace_ids = extract_trace_ids(lines)
|
|
76
|
+
return {"trace_ids": trace_ids}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@app.post("/api/traces/visualize")
|
|
80
|
+
def visualize_trace(body: VisualizeBody) -> dict[str, Any]:
|
|
81
|
+
"""Parse logs and return hops + ASCII diagram for the given trace_id."""
|
|
82
|
+
lines = _parse_log_lines(body.log_lines)
|
|
83
|
+
hops, diagram = parse_trace_from_lines(lines, body.trace_id.strip())
|
|
84
|
+
if not diagram:
|
|
85
|
+
raise HTTPException(status_code=404, detail=f"No trace found for: {body.trace_id}")
|
|
86
|
+
return {
|
|
87
|
+
"trace_id": body.trace_id,
|
|
88
|
+
"hops": _hops_to_dict(hops),
|
|
89
|
+
"diagram": diagram,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
_INDEX_HTML = """
|
|
94
|
+
<!DOCTYPE html>
|
|
95
|
+
<html lang="en">
|
|
96
|
+
<head>
|
|
97
|
+
<meta charset="utf-8">
|
|
98
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
99
|
+
<title>ASAP Trace UI</title>
|
|
100
|
+
<style>
|
|
101
|
+
* { box-sizing: border-box; }
|
|
102
|
+
body { font-family: system-ui, sans-serif; max-width: 900px; margin: 1rem auto; padding: 0 1rem; }
|
|
103
|
+
h1 { font-size: 1.25rem; }
|
|
104
|
+
label { display: block; margin-top: 0.75rem; font-weight: 600; }
|
|
105
|
+
textarea { width: 100%; min-height: 120px; font-family: monospace; font-size: 0.8rem; padding: 0.5rem; }
|
|
106
|
+
input[type="text"] { width: 100%; max-width: 320px; padding: 0.4rem; }
|
|
107
|
+
button { margin-top: 0.5rem; padding: 0.4rem 0.8rem; cursor: pointer; }
|
|
108
|
+
.result { margin-top: 1rem; padding: 0.75rem; background: #f5f5f5; border-radius: 4px; white-space: pre-wrap; font-family: monospace; font-size: 0.9rem; }
|
|
109
|
+
.error { background: #fee; color: #c00; }
|
|
110
|
+
ul { margin: 0.25rem 0; padding-left: 1.25rem; }
|
|
111
|
+
ul li { cursor: pointer; margin: 0.2rem 0; }
|
|
112
|
+
ul li:hover { text-decoration: underline; }
|
|
113
|
+
</style>
|
|
114
|
+
</head>
|
|
115
|
+
<body>
|
|
116
|
+
<h1>ASAP Trace UI</h1>
|
|
117
|
+
<p>Paste ASAP JSON log lines below (one event per line, <code>ASAP_LOG_FORMAT=json</code>), then list trace IDs or visualize one.</p>
|
|
118
|
+
|
|
119
|
+
<label for="logs">Log lines</label>
|
|
120
|
+
<textarea id="logs" placeholder='{"event":"asap.request.received","trace_id":"t1",...}
|
|
121
|
+
{"event":"asap.request.processed","trace_id":"t1","duration_ms":15,...}'></textarea>
|
|
122
|
+
|
|
123
|
+
<button id="btnList">List trace IDs</button>
|
|
124
|
+
<div id="listResult" class="result" style="display:none;"></div>
|
|
125
|
+
|
|
126
|
+
<label for="traceId">Trace ID (or click one above)</label>
|
|
127
|
+
<input type="text" id="traceId" placeholder="e.g. trace-abc">
|
|
128
|
+
|
|
129
|
+
<button id="btnViz">Visualize</button>
|
|
130
|
+
<div id="vizResult" class="result" style="display:none;"></div>
|
|
131
|
+
|
|
132
|
+
<script>
|
|
133
|
+
function escapeHtml(s) {
|
|
134
|
+
if (typeof s !== 'string') return '';
|
|
135
|
+
const m = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
136
|
+
return s.replace(/[&<>"']/g, function(c) { return m[c] || c; });
|
|
137
|
+
}
|
|
138
|
+
function errorMessage(e) {
|
|
139
|
+
return e instanceof Error ? e.message : String(e);
|
|
140
|
+
}
|
|
141
|
+
function showError(el, msg) {
|
|
142
|
+
el.textContent = 'Error: ' + msg;
|
|
143
|
+
el.classList.add('error');
|
|
144
|
+
el.style.display = 'block';
|
|
145
|
+
}
|
|
146
|
+
function renderTraceList(data, listResult, traceIdEl) {
|
|
147
|
+
if (data.trace_ids && data.trace_ids.length) {
|
|
148
|
+
const safe = data.trace_ids.map(function(t) {
|
|
149
|
+
return '<li data-trace="' + escapeHtml(t) + '">' + escapeHtml(t) + '</li>';
|
|
150
|
+
});
|
|
151
|
+
listResult.innerHTML = 'Trace IDs: <ul>' + safe.join('') + '</ul>';
|
|
152
|
+
listResult.querySelectorAll('li').forEach(function(li) {
|
|
153
|
+
li.onclick = function() { traceIdEl.value = li.dataset.trace || ''; };
|
|
154
|
+
});
|
|
155
|
+
} else {
|
|
156
|
+
listResult.textContent = 'No trace IDs found in log lines.';
|
|
157
|
+
}
|
|
158
|
+
listResult.classList.remove('error');
|
|
159
|
+
listResult.style.display = 'block';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const logsEl = document.getElementById('logs');
|
|
163
|
+
const traceIdEl = document.getElementById('traceId');
|
|
164
|
+
const listResult = document.getElementById('listResult');
|
|
165
|
+
const vizResult = document.getElementById('vizResult');
|
|
166
|
+
|
|
167
|
+
document.getElementById('btnList').onclick = async function() {
|
|
168
|
+
listResult.style.display = 'none';
|
|
169
|
+
const logLines = logsEl.value.trim();
|
|
170
|
+
if (!logLines) {
|
|
171
|
+
listResult.textContent = 'Paste log lines first.';
|
|
172
|
+
listResult.style.display = 'block';
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
try {
|
|
176
|
+
const r = await fetch('/api/traces/list', {
|
|
177
|
+
method: 'POST',
|
|
178
|
+
headers: { 'Content-Type': 'application/json' },
|
|
179
|
+
body: JSON.stringify({ log_lines: logLines }),
|
|
180
|
+
});
|
|
181
|
+
const data = await r.json();
|
|
182
|
+
renderTraceList(data, listResult, traceIdEl);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
showError(listResult, errorMessage(e));
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
document.getElementById('btnViz').onclick = async function() {
|
|
189
|
+
vizResult.style.display = 'none';
|
|
190
|
+
const logLines = logsEl.value.trim();
|
|
191
|
+
const traceId = traceIdEl.value.trim();
|
|
192
|
+
if (!logLines || !traceId) {
|
|
193
|
+
vizResult.textContent = 'Paste log lines and enter a trace ID.';
|
|
194
|
+
vizResult.style.display = 'block';
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
const r = await fetch('/api/traces/visualize', {
|
|
199
|
+
method: 'POST',
|
|
200
|
+
headers: { 'Content-Type': 'application/json' },
|
|
201
|
+
body: JSON.stringify({ log_lines: logLines, trace_id: traceId }),
|
|
202
|
+
});
|
|
203
|
+
if (!r.ok) {
|
|
204
|
+
const err = await r.json();
|
|
205
|
+
throw new Error(err.detail || r.statusText);
|
|
206
|
+
}
|
|
207
|
+
const data = await r.json();
|
|
208
|
+
vizResult.textContent = data.diagram || JSON.stringify(data, null, 2);
|
|
209
|
+
vizResult.classList.remove('error');
|
|
210
|
+
vizResult.style.display = 'block';
|
|
211
|
+
} catch (e) {
|
|
212
|
+
showError(vizResult, errorMessage(e));
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
</script>
|
|
216
|
+
</body>
|
|
217
|
+
</html>
|
|
218
|
+
"""
|