asap-protocol 0.5.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 (59) hide show
  1. asap/__init__.py +1 -1
  2. asap/cli.py +137 -2
  3. asap/examples/README.md +81 -13
  4. asap/examples/auth_patterns.py +212 -0
  5. asap/examples/error_recovery.py +248 -0
  6. asap/examples/long_running.py +287 -0
  7. asap/examples/mcp_integration.py +240 -0
  8. asap/examples/multi_step_workflow.py +134 -0
  9. asap/examples/orchestration.py +293 -0
  10. asap/examples/rate_limiting.py +137 -0
  11. asap/examples/run_demo.py +0 -2
  12. asap/examples/secure_handler.py +84 -0
  13. asap/examples/state_migration.py +240 -0
  14. asap/examples/streaming_response.py +108 -0
  15. asap/examples/websocket_concept.py +129 -0
  16. asap/mcp/__init__.py +43 -0
  17. asap/mcp/client.py +224 -0
  18. asap/mcp/protocol.py +179 -0
  19. asap/mcp/server.py +333 -0
  20. asap/mcp/server_runner.py +40 -0
  21. asap/models/base.py +0 -3
  22. asap/models/constants.py +3 -1
  23. asap/models/entities.py +21 -6
  24. asap/models/envelope.py +7 -0
  25. asap/models/ids.py +8 -4
  26. asap/models/parts.py +33 -3
  27. asap/models/validators.py +16 -0
  28. asap/observability/__init__.py +6 -0
  29. asap/observability/dashboards/README.md +24 -0
  30. asap/observability/dashboards/asap-detailed.json +131 -0
  31. asap/observability/dashboards/asap-red.json +129 -0
  32. asap/observability/logging.py +81 -1
  33. asap/observability/metrics.py +15 -1
  34. asap/observability/trace_parser.py +238 -0
  35. asap/observability/trace_ui.py +218 -0
  36. asap/observability/tracing.py +293 -0
  37. asap/state/machine.py +15 -2
  38. asap/state/snapshot.py +0 -9
  39. asap/testing/__init__.py +31 -0
  40. asap/testing/assertions.py +108 -0
  41. asap/testing/fixtures.py +113 -0
  42. asap/testing/mocks.py +152 -0
  43. asap/transport/__init__.py +28 -0
  44. asap/transport/cache.py +180 -0
  45. asap/transport/circuit_breaker.py +9 -8
  46. asap/transport/client.py +418 -36
  47. asap/transport/compression.py +389 -0
  48. asap/transport/handlers.py +106 -53
  49. asap/transport/middleware.py +58 -34
  50. asap/transport/server.py +429 -139
  51. asap/transport/validators.py +0 -4
  52. asap/utils/sanitization.py +0 -5
  53. asap_protocol-1.0.0.dist-info/METADATA +264 -0
  54. asap_protocol-1.0.0.dist-info/RECORD +70 -0
  55. asap_protocol-0.5.0.dist-info/METADATA +0 -244
  56. asap_protocol-0.5.0.dist-info/RECORD +0 -41
  57. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
  58. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
  59. {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,131 @@
1
+ {
2
+ "annotations": { "list": [] },
3
+ "editable": true,
4
+ "fiscalYearStartMonth": 0,
5
+ "graphTooltip": 1,
6
+ "id": null,
7
+ "links": [],
8
+ "liveNow": false,
9
+ "panels": [
10
+ {
11
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
12
+ "description": "Handler executions per second by payload type (activity view)",
13
+ "fieldConfig": {
14
+ "defaults": {
15
+ "color": { "mode": "palette-classic" },
16
+ "custom": { "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "normal" }, "thresholdsStyle": { "mode": "off" } },
17
+ "unit": "reqps"
18
+ },
19
+ "overrides": []
20
+ },
21
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
22
+ "id": 10,
23
+ "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } },
24
+ "targets": [
25
+ {
26
+ "expr": "sum(rate(asap_handler_executions_total[1m])) by (payload_type)",
27
+ "legendFormat": "{{payload_type}}",
28
+ "refId": "A"
29
+ }
30
+ ],
31
+ "title": "Handler executions (by payload type)",
32
+ "type": "timeseries"
33
+ },
34
+ {
35
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
36
+ "description": "Task state machine transition rate by from_state and to_state",
37
+ "fieldConfig": {
38
+ "defaults": {
39
+ "color": { "mode": "palette-classic" },
40
+ "custom": { "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
41
+ "unit": "reqps"
42
+ },
43
+ "overrides": []
44
+ },
45
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
46
+ "id": 11,
47
+ "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } },
48
+ "targets": [
49
+ {
50
+ "expr": "sum(rate(asap_state_transitions_total[1m])) by (from_status, to_status)",
51
+ "legendFormat": "{{from_status}} → {{to_status}}",
52
+ "refId": "A"
53
+ }
54
+ ],
55
+ "title": "State transitions",
56
+ "type": "timeseries"
57
+ },
58
+ {
59
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
60
+ "description": "Circuit breaker open state (1 = open). Requires asap_circuit_breaker_open metric from client.",
61
+ "fieldConfig": {
62
+ "defaults": {
63
+ "color": { "mode": "thresholds" },
64
+ "mappings": [
65
+ { "options": { "0": { "color": "green", "index": 0, "text": "closed" } }, "type": "value" },
66
+ { "options": { "1": { "color": "red", "index": 0, "text": "open" } }, "type": "value" }
67
+ ],
68
+ "thresholds": {
69
+ "mode": "absolute",
70
+ "steps": [
71
+ { "color": "green", "value": null },
72
+ { "color": "red", "value": 1 }
73
+ ]
74
+ },
75
+ "unit": "short"
76
+ },
77
+ "overrides": []
78
+ },
79
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
80
+ "id": 12,
81
+ "options": { "colorMode": "value", "graphMode": "none", "justifyMode": "auto", "orientation": "auto", "reduceOptions": { "calcs": ["lastNotNull"], "fields": "", "values": false }, "textMode": "auto" },
82
+ "targets": [
83
+ {
84
+ "expr": "asap_circuit_breaker_open",
85
+ "legendFormat": "{{instance}}",
86
+ "refId": "A"
87
+ }
88
+ ],
89
+ "title": "Circuit breaker status",
90
+ "type": "stat"
91
+ },
92
+ {
93
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
94
+ "description": "State transition rate as table (from_state, to_state, rate)",
95
+ "fieldConfig": {
96
+ "defaults": {
97
+ "color": { "mode": "palette-classic" },
98
+ "custom": { "align": "auto", "cellOptions": { "type": "auto" }, "inspect": false },
99
+ "mappings": [],
100
+ "unit": "reqps"
101
+ },
102
+ "overrides": []
103
+ },
104
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
105
+ "id": 13,
106
+ "options": { "cellHeight": "sm", "footer": { "countRows": false, "fields": "", "reducer": ["sum"], "show": false }, "showHeader": true, "sortBy": [] },
107
+ "targets": [
108
+ {
109
+ "expr": "sum(rate(asap_state_transitions_total[1m])) by (from_status, to_status)",
110
+ "format": "table",
111
+ "instant": true,
112
+ "refId": "A"
113
+ }
114
+ ],
115
+ "title": "State transitions (table)",
116
+ "type": "table"
117
+ }
118
+ ],
119
+ "refresh": "10s",
120
+ "schemaVersion": 38,
121
+ "style": "dark",
122
+ "tags": ["asap", "detailed", "state-machine"],
123
+ "templating": { "list": [] },
124
+ "time": { "from": "now-1h", "to": "now" },
125
+ "timepicker": {},
126
+ "timezone": "utc",
127
+ "title": "ASAP Detailed (topology & state)",
128
+ "uid": "asap-detailed",
129
+ "version": 1,
130
+ "weekStart": ""
131
+ }
@@ -0,0 +1,129 @@
1
+ {
2
+ "annotations": { "list": [] },
3
+ "editable": true,
4
+ "fiscalYearStartMonth": 0,
5
+ "graphTooltip": 1,
6
+ "id": null,
7
+ "links": [],
8
+ "liveNow": false,
9
+ "panels": [
10
+ {
11
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
12
+ "description": "Requests per second by payload type",
13
+ "fieldConfig": {
14
+ "defaults": {
15
+ "color": { "mode": "palette-classic" },
16
+ "custom": { "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
17
+ "unit": "reqps"
18
+ },
19
+ "overrides": []
20
+ },
21
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 0 },
22
+ "id": 1,
23
+ "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } },
24
+ "targets": [
25
+ {
26
+ "expr": "sum(rate(asap_requests_total[1m])) by (payload_type)",
27
+ "legendFormat": "{{payload_type}}",
28
+ "refId": "A"
29
+ }
30
+ ],
31
+ "title": "Request Rate",
32
+ "type": "timeseries"
33
+ },
34
+ {
35
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
36
+ "description": "Error rate: errors / total requests",
37
+ "fieldConfig": {
38
+ "defaults": {
39
+ "color": { "mode": "thresholds" },
40
+ "mappings": [],
41
+ "max": 1,
42
+ "min": 0,
43
+ "thresholds": {
44
+ "mode": "absolute",
45
+ "steps": [
46
+ { "color": "green", "value": null },
47
+ { "color": "yellow", "value": 0.01 },
48
+ { "color": "red", "value": 0.05 }
49
+ ]
50
+ },
51
+ "unit": "percentunit"
52
+ },
53
+ "overrides": []
54
+ },
55
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 0 },
56
+ "id": 2,
57
+ "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } },
58
+ "targets": [
59
+ {
60
+ "expr": "sum(rate(asap_requests_error_total[1m])) / (sum(rate(asap_requests_total[1m])) + 1e-9)",
61
+ "legendFormat": "Error rate",
62
+ "refId": "A"
63
+ }
64
+ ],
65
+ "title": "Error Rate",
66
+ "type": "timeseries"
67
+ },
68
+ {
69
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
70
+ "description": "95th percentile request duration",
71
+ "fieldConfig": {
72
+ "defaults": {
73
+ "color": { "mode": "palette-classic" },
74
+ "custom": { "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
75
+ "unit": "s"
76
+ },
77
+ "overrides": []
78
+ },
79
+ "gridPos": { "h": 8, "w": 12, "x": 0, "y": 8 },
80
+ "id": 3,
81
+ "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } },
82
+ "targets": [
83
+ {
84
+ "expr": "histogram_quantile(0.95, sum(rate(asap_request_duration_seconds_bucket[1m])) by (le))",
85
+ "legendFormat": "p95",
86
+ "refId": "A"
87
+ }
88
+ ],
89
+ "title": "Latency (p95)",
90
+ "type": "timeseries"
91
+ },
92
+ {
93
+ "datasource": { "type": "prometheus", "uid": "prometheus" },
94
+ "description": "99th percentile request duration",
95
+ "fieldConfig": {
96
+ "defaults": {
97
+ "color": { "mode": "palette-classic" },
98
+ "custom": { "axisCenteredZero": false, "axisColorMode": "text", "axisLabel": "", "axisPlacement": "auto", "barAlignment": 0, "drawStyle": "line", "fillOpacity": 10, "gradientMode": "none", "hideFrom": { "legend": false, "tooltip": false, "viz": false }, "lineInterpolation": "linear", "lineWidth": 1, "pointSize": 5, "scaleDistribution": { "type": "linear" }, "showPoints": "auto", "spanNulls": false, "stacking": { "group": "A", "mode": "none" }, "thresholdsStyle": { "mode": "off" } },
99
+ "unit": "s"
100
+ },
101
+ "overrides": []
102
+ },
103
+ "gridPos": { "h": 8, "w": 12, "x": 12, "y": 8 },
104
+ "id": 4,
105
+ "options": { "legend": { "displayMode": "list", "placement": "bottom", "showLegend": true } },
106
+ "targets": [
107
+ {
108
+ "expr": "histogram_quantile(0.99, sum(rate(asap_request_duration_seconds_bucket[1m])) by (le))",
109
+ "legendFormat": "p99",
110
+ "refId": "A"
111
+ }
112
+ ],
113
+ "title": "Latency (p99)",
114
+ "type": "timeseries"
115
+ }
116
+ ],
117
+ "refresh": "10s",
118
+ "schemaVersion": 38,
119
+ "style": "dark",
120
+ "tags": ["asap", "red"],
121
+ "templating": { "list": [] },
122
+ "time": { "from": "now-1h", "to": "now" },
123
+ "timepicker": {},
124
+ "timezone": "utc",
125
+ "title": "ASAP RED",
126
+ "uid": "asap-red",
127
+ "version": 1,
128
+ "weekStart": ""
129
+ }
@@ -13,6 +13,8 @@ Environment Variables:
13
13
  ASAP_LOG_FORMAT: Set to "json" for JSON output, "console" for colored output
14
14
  ASAP_LOG_LEVEL: Set log level (DEBUG, INFO, WARNING, ERROR)
15
15
  ASAP_SERVICE_NAME: Service name to include in logs
16
+ ASAP_DEBUG: Set to "true" or "1" to log full data and stack traces; otherwise sensitive fields are redacted
17
+ ASAP_DEBUG_LOG: Set to "true" or "1" to log full request/response bodies (structured JSON); for development
16
18
 
17
19
  Example:
18
20
  >>> from asap.observability.logging import get_logger, configure_logging
@@ -42,11 +44,90 @@ DEFAULT_SERVICE_NAME = "asap-protocol"
42
44
  ENV_LOG_FORMAT = "ASAP_LOG_FORMAT"
43
45
  ENV_LOG_LEVEL = "ASAP_LOG_LEVEL"
44
46
  ENV_SERVICE_NAME = "ASAP_SERVICE_NAME"
47
+ ENV_DEBUG = "ASAP_DEBUG"
48
+ ENV_DEBUG_LOG = "ASAP_DEBUG_LOG"
49
+
50
+ # Placeholder for redacted sensitive values in logs
51
+ REDACTED_PLACEHOLDER = "***REDACTED***"
52
+
53
+ # Key substrings (case-insensitive) that indicate sensitive data to redact
54
+ _SENSITIVE_KEY_PATTERNS = frozenset(
55
+ {
56
+ "password",
57
+ "token",
58
+ "secret",
59
+ "key",
60
+ "authorization",
61
+ "auth",
62
+ "credential",
63
+ "api_key",
64
+ "apikey",
65
+ "access_token",
66
+ "refresh_token",
67
+ }
68
+ )
45
69
 
46
70
  # Module-level flag to track if logging has been configured
47
71
  _logging_configured = False
48
72
 
49
73
 
74
+ def _is_sensitive_key(key: str) -> bool:
75
+ """Return True if the key name indicates sensitive data that should be redacted."""
76
+ lower = key.lower()
77
+ return any(pattern in lower for pattern in _SENSITIVE_KEY_PATTERNS)
78
+
79
+
80
+ def sanitize_for_logging(data: dict[str, Any]) -> dict[str, Any]:
81
+ """Sanitize a dict for safe logging by redacting sensitive field values.
82
+
83
+ Keys matching (case-insensitive) password, token, secret, key, authorization,
84
+ auth, credential, api_key, apikey, access_token, refresh_token have their
85
+ values replaced with REDACTED_PLACEHOLDER. Handles nested
86
+ dicts and lists of dicts recursively. Non-sensitive keys and correlation_id
87
+ are preserved for debugging.
88
+
89
+ Args:
90
+ data: The dict to sanitize (e.g. envelope payload or request params).
91
+
92
+ Returns:
93
+ A new dict with sensitive values replaced; nested structures are deep-copied
94
+ and sanitized. Non-dict/non-list values for sensitive keys are redacted.
95
+
96
+ Example:
97
+ >>> sanitize_for_logging({"user": "alice", "password": "secret123"})
98
+ {'user': 'alice', 'password': '***REDACTED***'}
99
+ >>> sanitize_for_logging({"nested": {"token": "sk_live_abc"}})
100
+ {'nested': {'token': '***REDACTED***'}}
101
+ """
102
+ if not data:
103
+ return {}
104
+ result: dict[str, Any] = {}
105
+ for k, v in data.items():
106
+ if _is_sensitive_key(k):
107
+ result[k] = REDACTED_PLACEHOLDER
108
+ elif isinstance(v, dict):
109
+ result[k] = sanitize_for_logging(v)
110
+ elif isinstance(v, list):
111
+ result[k] = [
112
+ sanitize_for_logging(item) if isinstance(item, dict) else item for item in v
113
+ ]
114
+ else:
115
+ result[k] = v
116
+ return result
117
+
118
+
119
+ def is_debug_mode() -> bool:
120
+ """Return True if ASAP_DEBUG is set to a truthy value (e.g. true, 1)."""
121
+ value = os.environ.get(ENV_DEBUG, "").strip().lower()
122
+ return value in ("true", "1", "yes", "on")
123
+
124
+
125
+ def is_debug_log_mode() -> bool:
126
+ """Return True if ASAP_DEBUG_LOG is set to a truthy value (log full request/response)."""
127
+ value = os.environ.get(ENV_DEBUG_LOG, "").strip().lower()
128
+ return value in ("true", "1", "yes", "on")
129
+
130
+
50
131
  def _get_log_level() -> str:
51
132
  """Get log level from environment or use default."""
52
133
  return os.environ.get(ENV_LOG_LEVEL, DEFAULT_LOG_LEVEL).upper()
@@ -185,7 +266,6 @@ def get_logger(name: str) -> structlog.stdlib.BoundLogger:
185
266
  >>> logger = logger.bind(trace_id="trace_123")
186
267
  >>> logger.info("request.processed") # trace_id automatically included
187
268
  """
188
- # Ensure logging is configured
189
269
  if not _logging_configured:
190
270
  configure_logging()
191
271
 
@@ -150,16 +150,30 @@ class MetricsCollector:
150
150
  >>> print(collector.export_prometheus())
151
151
  """
152
152
 
153
- # Default metric definitions
153
+ # Default metric definitions (20+ for observability)
154
154
  DEFAULT_COUNTERS: ClassVar[dict[str, str]] = {
155
155
  "asap_requests_total": "Total number of ASAP requests received",
156
156
  "asap_requests_success_total": "Total number of successful ASAP requests",
157
157
  "asap_requests_error_total": "Total number of failed ASAP requests",
158
158
  "asap_thread_pool_exhausted_total": "Total number of thread pool exhaustion events",
159
+ "asap_handler_executions_total": "Total number of handler executions",
160
+ "asap_handler_errors_total": "Total number of handler execution failures",
161
+ "asap_state_transitions_total": "Total number of state machine transitions",
162
+ "asap_transport_send_total": "Total number of transport send attempts",
163
+ "asap_transport_send_errors_total": "Total number of transport send errors",
164
+ "asap_transport_retries_total": "Total number of transport retries",
165
+ "asap_parse_errors_total": "Total number of JSON-RPC parse errors",
166
+ "asap_auth_failures_total": "Total number of authentication failures",
167
+ "asap_validation_errors_total": "Total number of envelope validation errors",
168
+ "asap_invalid_timestamp_total": "Total number of invalid timestamp rejections",
169
+ "asap_invalid_nonce_total": "Total number of invalid nonce rejections",
170
+ "asap_sender_mismatch_total": "Total number of sender identity mismatches",
159
171
  }
160
172
 
161
173
  DEFAULT_HISTOGRAMS: ClassVar[dict[str, str]] = {
162
174
  "asap_request_duration_seconds": "Request processing duration in seconds",
175
+ "asap_handler_duration_seconds": "Handler execution duration in seconds",
176
+ "asap_transport_send_duration_seconds": "Transport send duration in seconds",
163
177
  }
164
178
 
165
179
  def __init__(self) -> None:
@@ -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