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.
Files changed (62) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/errors.py +167 -0
  4. asap/examples/README.md +81 -10
  5. asap/examples/auth_patterns.py +212 -0
  6. asap/examples/error_recovery.py +248 -0
  7. asap/examples/long_running.py +287 -0
  8. asap/examples/mcp_integration.py +240 -0
  9. asap/examples/multi_step_workflow.py +134 -0
  10. asap/examples/orchestration.py +293 -0
  11. asap/examples/rate_limiting.py +137 -0
  12. asap/examples/run_demo.py +9 -4
  13. asap/examples/secure_handler.py +84 -0
  14. asap/examples/state_migration.py +240 -0
  15. asap/examples/streaming_response.py +108 -0
  16. asap/examples/websocket_concept.py +129 -0
  17. asap/mcp/__init__.py +43 -0
  18. asap/mcp/client.py +224 -0
  19. asap/mcp/protocol.py +179 -0
  20. asap/mcp/server.py +333 -0
  21. asap/mcp/server_runner.py +40 -0
  22. asap/models/__init__.py +4 -0
  23. asap/models/base.py +0 -3
  24. asap/models/constants.py +76 -1
  25. asap/models/entities.py +58 -7
  26. asap/models/envelope.py +14 -1
  27. asap/models/ids.py +8 -4
  28. asap/models/parts.py +33 -3
  29. asap/models/validators.py +16 -0
  30. asap/observability/__init__.py +6 -0
  31. asap/observability/dashboards/README.md +24 -0
  32. asap/observability/dashboards/asap-detailed.json +131 -0
  33. asap/observability/dashboards/asap-red.json +129 -0
  34. asap/observability/logging.py +81 -1
  35. asap/observability/metrics.py +15 -1
  36. asap/observability/trace_parser.py +238 -0
  37. asap/observability/trace_ui.py +218 -0
  38. asap/observability/tracing.py +293 -0
  39. asap/state/machine.py +15 -2
  40. asap/state/snapshot.py +0 -9
  41. asap/testing/__init__.py +31 -0
  42. asap/testing/assertions.py +108 -0
  43. asap/testing/fixtures.py +113 -0
  44. asap/testing/mocks.py +152 -0
  45. asap/transport/__init__.py +31 -0
  46. asap/transport/cache.py +180 -0
  47. asap/transport/circuit_breaker.py +194 -0
  48. asap/transport/client.py +989 -72
  49. asap/transport/compression.py +389 -0
  50. asap/transport/handlers.py +106 -53
  51. asap/transport/middleware.py +64 -39
  52. asap/transport/server.py +461 -94
  53. asap/transport/validators.py +320 -0
  54. asap/utils/__init__.py +7 -0
  55. asap/utils/sanitization.py +134 -0
  56. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  57. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  58. asap_protocol-0.3.0.dist-info/METADATA +0 -227
  59. asap_protocol-0.3.0.dist-info/RECORD +0 -37
  60. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  61. {asap_protocol-0.3.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  62. {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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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
+ """