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.
- asap/__init__.py +1 -1
- asap/cli.py +137 -2
- asap/examples/README.md +81 -13
- 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 +0 -2
- 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/base.py +0 -3
- asap/models/constants.py +3 -1
- asap/models/entities.py +21 -6
- asap/models/envelope.py +7 -0
- 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 +28 -0
- asap/transport/cache.py +180 -0
- asap/transport/circuit_breaker.py +9 -8
- asap/transport/client.py +418 -36
- asap/transport/compression.py +389 -0
- asap/transport/handlers.py +106 -53
- asap/transport/middleware.py +58 -34
- asap/transport/server.py +429 -139
- asap/transport/validators.py +0 -4
- asap/utils/sanitization.py +0 -5
- asap_protocol-1.0.0.dist-info/METADATA +264 -0
- asap_protocol-1.0.0.dist-info/RECORD +70 -0
- asap_protocol-0.5.0.dist-info/METADATA +0 -244
- asap_protocol-0.5.0.dist-info/RECORD +0 -41
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/WHEEL +0 -0
- {asap_protocol-0.5.0.dist-info → asap_protocol-1.0.0.dist-info}/entry_points.txt +0 -0
- {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
|
+
}
|
asap/observability/logging.py
CHANGED
|
@@ -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
|
|
asap/observability/metrics.py
CHANGED
|
@@ -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
|