tracely-sdk 0.1.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.
- tracely/__init__.py +11 -0
- tracely/capture.py +210 -0
- tracely/config.py +39 -0
- tracely/context.py +55 -0
- tracely/detection.py +49 -0
- tracely/exporter.py +120 -0
- tracely/instrumentation/__init__.py +47 -0
- tracely/instrumentation/base.py +30 -0
- tracely/instrumentation/dbapi.py +264 -0
- tracely/instrumentation/django_inst.py +155 -0
- tracely/instrumentation/fastapi_inst.py +203 -0
- tracely/instrumentation/flask_inst.py +215 -0
- tracely/instrumentation/generic.py +38 -0
- tracely/instrumentation/httpx_inst.py +130 -0
- tracely/log_handler.py +55 -0
- tracely/logging_api.py +38 -0
- tracely/otlp.py +128 -0
- tracely/py.typed +0 -0
- tracely/redaction.py +196 -0
- tracely/sdk.py +192 -0
- tracely/span.py +168 -0
- tracely/span_processor.py +110 -0
- tracely/tracing.py +59 -0
- tracely/transport.py +134 -0
- tracely_sdk-0.1.0.dist-info/METADATA +205 -0
- tracely_sdk-0.1.0.dist-info/RECORD +28 -0
- tracely_sdk-0.1.0.dist-info/WHEEL +4 -0
- tracely_sdk-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Database query instrumentation for SQL and MongoDB.
|
|
2
|
+
|
|
3
|
+
Creates child Span objects linked to the active root span via context
|
|
4
|
+
propagation, enabling hierarchical trace trees (FR56, FR57).
|
|
5
|
+
|
|
6
|
+
Provides instrumentors for:
|
|
7
|
+
- SQLAlchemy (via before/after_cursor_execute events)
|
|
8
|
+
- Django ORM (via query callback)
|
|
9
|
+
- MongoDB (via pymongo command monitoring)
|
|
10
|
+
|
|
11
|
+
All instrumentors are fail-silent and never crash the host application.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import logging
|
|
17
|
+
import time
|
|
18
|
+
from typing import Any, Callable
|
|
19
|
+
|
|
20
|
+
from tracely.context import get_current_span
|
|
21
|
+
from tracely.span import Span
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger("tracely")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _extract_operation(statement: str) -> str:
|
|
27
|
+
"""Extract the SQL operation (SELECT, INSERT, UPDATE, DELETE) from a statement."""
|
|
28
|
+
normalized = statement.strip().upper()
|
|
29
|
+
for op in ("SELECT", "INSERT", "UPDATE", "DELETE"):
|
|
30
|
+
if normalized.startswith(op):
|
|
31
|
+
return op
|
|
32
|
+
return "UNKNOWN"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class SQLAlchemyInstrumentor:
|
|
36
|
+
"""Tracks SQLAlchemy query execution as child spans.
|
|
37
|
+
|
|
38
|
+
Creates a child Span linked to the active root/parent span when a
|
|
39
|
+
query executes. If no active span exists, records a standalone dict.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
on_span: Callable[[dict[str, Any]], None] | None = None,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._on_span = on_span
|
|
47
|
+
self._timings: dict[int, tuple[float, Span | None]] = {}
|
|
48
|
+
|
|
49
|
+
def before_cursor_execute(
|
|
50
|
+
self,
|
|
51
|
+
conn: Any,
|
|
52
|
+
cursor: Any,
|
|
53
|
+
statement: str,
|
|
54
|
+
parameters: Any,
|
|
55
|
+
context: Any,
|
|
56
|
+
executemany: bool,
|
|
57
|
+
) -> None:
|
|
58
|
+
"""Record start time and create child span before query execution."""
|
|
59
|
+
try:
|
|
60
|
+
parent = get_current_span()
|
|
61
|
+
span: Span | None = None
|
|
62
|
+
if parent is not None:
|
|
63
|
+
operation = _extract_operation(statement)
|
|
64
|
+
span = Span(
|
|
65
|
+
name=f"SQL {operation}",
|
|
66
|
+
parent=parent,
|
|
67
|
+
kind="CLIENT",
|
|
68
|
+
)
|
|
69
|
+
span.set_attribute("db.system", "sql")
|
|
70
|
+
span.set_attribute("db.statement", statement)
|
|
71
|
+
span.set_attribute("db.operation", operation)
|
|
72
|
+
self._timings[id(context)] = (time.perf_counter(), span)
|
|
73
|
+
except Exception:
|
|
74
|
+
logger.debug("Error in before_cursor_execute", exc_info=True)
|
|
75
|
+
|
|
76
|
+
def after_cursor_execute(
|
|
77
|
+
self,
|
|
78
|
+
conn: Any,
|
|
79
|
+
cursor: Any,
|
|
80
|
+
statement: str,
|
|
81
|
+
parameters: Any,
|
|
82
|
+
context: Any,
|
|
83
|
+
executemany: bool,
|
|
84
|
+
) -> None:
|
|
85
|
+
"""Record query span after execution completes."""
|
|
86
|
+
try:
|
|
87
|
+
entry = self._timings.pop(id(context), None)
|
|
88
|
+
if entry is None:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
start, span = entry
|
|
92
|
+
elapsed_ms = (time.perf_counter() - start) * 1000
|
|
93
|
+
|
|
94
|
+
if span is not None:
|
|
95
|
+
span.set_attribute("duration_ms", str(round(elapsed_ms, 3)))
|
|
96
|
+
span.set_status("OK")
|
|
97
|
+
span.end()
|
|
98
|
+
|
|
99
|
+
if self._on_span is not None:
|
|
100
|
+
self._on_span(span.to_dict())
|
|
101
|
+
elif self._on_span is not None:
|
|
102
|
+
self._on_span({
|
|
103
|
+
"span_type": "db",
|
|
104
|
+
"db.system": "sql",
|
|
105
|
+
"db.statement": statement,
|
|
106
|
+
"db.operation": _extract_operation(statement),
|
|
107
|
+
"duration_ms": round(elapsed_ms, 3),
|
|
108
|
+
"error": False,
|
|
109
|
+
})
|
|
110
|
+
except Exception:
|
|
111
|
+
logger.debug("Error in after_cursor_execute", exc_info=True)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class DjangoORMInstrumentor:
|
|
115
|
+
"""Tracks Django ORM query execution as child spans."""
|
|
116
|
+
|
|
117
|
+
def __init__(
|
|
118
|
+
self,
|
|
119
|
+
on_span: Callable[[dict[str, Any]], None] | None = None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._on_span = on_span
|
|
122
|
+
|
|
123
|
+
def on_query(
|
|
124
|
+
self,
|
|
125
|
+
sql: str,
|
|
126
|
+
duration_ms: float,
|
|
127
|
+
vendor: str = "sql",
|
|
128
|
+
) -> None:
|
|
129
|
+
"""Record a completed Django ORM query as a child span."""
|
|
130
|
+
try:
|
|
131
|
+
parent = get_current_span()
|
|
132
|
+
operation = _extract_operation(sql)
|
|
133
|
+
|
|
134
|
+
if parent is not None:
|
|
135
|
+
span = Span(
|
|
136
|
+
name=f"SQL {operation}",
|
|
137
|
+
parent=parent,
|
|
138
|
+
kind="CLIENT",
|
|
139
|
+
)
|
|
140
|
+
span.set_attribute("db.system", vendor)
|
|
141
|
+
span.set_attribute("db.statement", sql)
|
|
142
|
+
span.set_attribute("db.operation", operation)
|
|
143
|
+
span.set_attribute("duration_ms", str(round(duration_ms, 3)))
|
|
144
|
+
span.set_status("OK")
|
|
145
|
+
span.end()
|
|
146
|
+
|
|
147
|
+
if self._on_span is not None:
|
|
148
|
+
self._on_span(span.to_dict())
|
|
149
|
+
elif self._on_span is not None:
|
|
150
|
+
self._on_span({
|
|
151
|
+
"span_type": "db",
|
|
152
|
+
"db.system": vendor,
|
|
153
|
+
"db.statement": sql,
|
|
154
|
+
"db.operation": operation,
|
|
155
|
+
"duration_ms": round(duration_ms, 3),
|
|
156
|
+
"error": False,
|
|
157
|
+
})
|
|
158
|
+
except Exception:
|
|
159
|
+
logger.debug("Error in DjangoORMInstrumentor.on_query", exc_info=True)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class MongoInstrumentor:
|
|
163
|
+
"""Tracks MongoDB command execution as child spans."""
|
|
164
|
+
|
|
165
|
+
def __init__(
|
|
166
|
+
self,
|
|
167
|
+
on_span: Callable[[dict[str, Any]], None] | None = None,
|
|
168
|
+
) -> None:
|
|
169
|
+
self._on_span = on_span
|
|
170
|
+
self._inflight: dict[int, dict[str, Any]] = {}
|
|
171
|
+
|
|
172
|
+
def on_command_start(
|
|
173
|
+
self,
|
|
174
|
+
command_name: str,
|
|
175
|
+
database_name: str,
|
|
176
|
+
request_id: int,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Record the start of a MongoDB command and create a child span."""
|
|
179
|
+
try:
|
|
180
|
+
parent = get_current_span()
|
|
181
|
+
span: Span | None = None
|
|
182
|
+
if parent is not None:
|
|
183
|
+
span = Span(
|
|
184
|
+
name=f"MongoDB {command_name}",
|
|
185
|
+
parent=parent,
|
|
186
|
+
kind="CLIENT",
|
|
187
|
+
)
|
|
188
|
+
span.set_attribute("db.system", "mongodb")
|
|
189
|
+
span.set_attribute("db.operation", command_name)
|
|
190
|
+
span.set_attribute("db.name", database_name)
|
|
191
|
+
|
|
192
|
+
self._inflight[request_id] = {
|
|
193
|
+
"command_name": command_name,
|
|
194
|
+
"database_name": database_name,
|
|
195
|
+
"start": time.perf_counter(),
|
|
196
|
+
"span": span,
|
|
197
|
+
}
|
|
198
|
+
except Exception:
|
|
199
|
+
logger.debug("Error in MongoInstrumentor.on_command_start", exc_info=True)
|
|
200
|
+
|
|
201
|
+
def on_command_success(
|
|
202
|
+
self,
|
|
203
|
+
request_id: int,
|
|
204
|
+
duration_ms: float,
|
|
205
|
+
) -> None:
|
|
206
|
+
"""Record a successful MongoDB command completion."""
|
|
207
|
+
try:
|
|
208
|
+
info = self._inflight.pop(request_id, None)
|
|
209
|
+
if info is None:
|
|
210
|
+
return
|
|
211
|
+
|
|
212
|
+
span: Span | None = info.get("span")
|
|
213
|
+
if span is not None:
|
|
214
|
+
span.set_attribute("duration_ms", str(round(duration_ms, 3)))
|
|
215
|
+
span.set_status("OK")
|
|
216
|
+
span.end()
|
|
217
|
+
|
|
218
|
+
if self._on_span is not None:
|
|
219
|
+
self._on_span(span.to_dict())
|
|
220
|
+
elif self._on_span is not None:
|
|
221
|
+
self._on_span({
|
|
222
|
+
"span_type": "db",
|
|
223
|
+
"db.system": "mongodb",
|
|
224
|
+
"db.operation": info["command_name"],
|
|
225
|
+
"db.name": info["database_name"],
|
|
226
|
+
"duration_ms": round(duration_ms, 3),
|
|
227
|
+
"error": False,
|
|
228
|
+
})
|
|
229
|
+
except Exception:
|
|
230
|
+
logger.debug("Error in MongoInstrumentor.on_command_success", exc_info=True)
|
|
231
|
+
|
|
232
|
+
def on_command_failure(
|
|
233
|
+
self,
|
|
234
|
+
request_id: int,
|
|
235
|
+
duration_ms: float,
|
|
236
|
+
failure: str,
|
|
237
|
+
) -> None:
|
|
238
|
+
"""Record a failed MongoDB command."""
|
|
239
|
+
try:
|
|
240
|
+
info = self._inflight.pop(request_id, None)
|
|
241
|
+
if info is None:
|
|
242
|
+
return
|
|
243
|
+
|
|
244
|
+
span: Span | None = info.get("span")
|
|
245
|
+
if span is not None:
|
|
246
|
+
span.set_attribute("duration_ms", str(round(duration_ms, 3)))
|
|
247
|
+
span.set_attribute("error.message", failure)
|
|
248
|
+
span.set_status("ERROR", failure)
|
|
249
|
+
span.end()
|
|
250
|
+
|
|
251
|
+
if self._on_span is not None:
|
|
252
|
+
self._on_span(span.to_dict())
|
|
253
|
+
elif self._on_span is not None:
|
|
254
|
+
self._on_span({
|
|
255
|
+
"span_type": "db",
|
|
256
|
+
"db.system": "mongodb",
|
|
257
|
+
"db.operation": info["command_name"],
|
|
258
|
+
"db.name": info["database_name"],
|
|
259
|
+
"duration_ms": round(duration_ms, 3),
|
|
260
|
+
"error": True,
|
|
261
|
+
"error.message": failure,
|
|
262
|
+
})
|
|
263
|
+
except Exception:
|
|
264
|
+
logger.debug("Error in MongoInstrumentor.on_command_failure", exc_info=True)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Django auto-instrumentation (Django middleware).
|
|
2
|
+
|
|
3
|
+
Provides a Django-style middleware that creates structured Span objects
|
|
4
|
+
for each HTTP request, with full trace hierarchy support via context
|
|
5
|
+
propagation. Captures full request/response data (FR6/FR7).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from tracely.capture import build_url, capture_request_data, capture_response_data
|
|
14
|
+
from tracely.context import _span_context
|
|
15
|
+
from tracely.instrumentation.base import BaseInstrumentor
|
|
16
|
+
from tracely.span import Span
|
|
17
|
+
from tracely.span_processor import on_span_end, on_span_start
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("tracely")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _extract_django_headers(meta: dict[str, Any]) -> dict[str, str]:
|
|
23
|
+
"""Extract HTTP headers from Django request.META dict.
|
|
24
|
+
|
|
25
|
+
Django stores headers as HTTP_<NAME> with underscores replacing hyphens.
|
|
26
|
+
CONTENT_TYPE and CONTENT_LENGTH are special cases without HTTP_ prefix.
|
|
27
|
+
"""
|
|
28
|
+
headers: dict[str, str] = {}
|
|
29
|
+
for key, value in meta.items():
|
|
30
|
+
if key.startswith("HTTP_"):
|
|
31
|
+
header_name = key[5:].lower().replace("_", "-")
|
|
32
|
+
headers[header_name] = str(value)
|
|
33
|
+
elif key == "CONTENT_TYPE":
|
|
34
|
+
headers["content-type"] = str(value)
|
|
35
|
+
elif key == "CONTENT_LENGTH":
|
|
36
|
+
headers["content-length"] = str(value)
|
|
37
|
+
return headers
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TracelyDjangoMiddleware:
|
|
41
|
+
"""Django middleware that creates root spans for HTTP requests.
|
|
42
|
+
|
|
43
|
+
Follows Django's middleware protocol: __init__(get_response) + __call__(request).
|
|
44
|
+
Creates a Span object with trace_id and span_id, sets it as the active span,
|
|
45
|
+
and captures HTTP attributes including full request/response data.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
get_response: Callable[..., Any],
|
|
51
|
+
on_span: Callable[[dict[str, Any]], None] | None = None,
|
|
52
|
+
service_name: str | None = None,
|
|
53
|
+
on_end: Callable[[Span], None] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.get_response = get_response
|
|
56
|
+
self._on_span = on_span
|
|
57
|
+
self._service_name = service_name
|
|
58
|
+
self._on_end = on_end
|
|
59
|
+
|
|
60
|
+
def __call__(self, request: Any) -> Any:
|
|
61
|
+
method = getattr(request, "method", "UNKNOWN")
|
|
62
|
+
path = getattr(request, "path", "/")
|
|
63
|
+
meta = getattr(request, "META", {})
|
|
64
|
+
query = meta.get("QUERY_STRING", "")
|
|
65
|
+
|
|
66
|
+
# Build full URL from Django request
|
|
67
|
+
scheme = getattr(request, "scheme", "http")
|
|
68
|
+
host = meta.get("HTTP_HOST", "localhost")
|
|
69
|
+
full_url = build_url(scheme, host, path, query)
|
|
70
|
+
|
|
71
|
+
# Extract request headers and body
|
|
72
|
+
req_headers = _extract_django_headers(meta)
|
|
73
|
+
req_content_type = req_headers.get("content-type", "")
|
|
74
|
+
req_body = getattr(request, "body", b"")
|
|
75
|
+
|
|
76
|
+
span = Span(
|
|
77
|
+
name=f"{method} {path}",
|
|
78
|
+
kind="SERVER",
|
|
79
|
+
service_name=self._service_name,
|
|
80
|
+
on_end=self._on_end or on_span_end,
|
|
81
|
+
)
|
|
82
|
+
span.set_attribute("http.route", path)
|
|
83
|
+
span.set_attribute("http.query", query)
|
|
84
|
+
|
|
85
|
+
# AR3: Export pending_span immediately for real-time dashboard
|
|
86
|
+
on_span_start(span)
|
|
87
|
+
|
|
88
|
+
with _span_context(span):
|
|
89
|
+
try:
|
|
90
|
+
response = self.get_response(request)
|
|
91
|
+
|
|
92
|
+
# Extract response data
|
|
93
|
+
resp_headers_dict: dict[str, str] = {}
|
|
94
|
+
try:
|
|
95
|
+
for name, value in response.items():
|
|
96
|
+
resp_headers_dict[str(name).lower()] = str(value)
|
|
97
|
+
except Exception:
|
|
98
|
+
pass
|
|
99
|
+
resp_content_type = resp_headers_dict.get("content-type", "")
|
|
100
|
+
resp_body = getattr(response, "content", b"")
|
|
101
|
+
|
|
102
|
+
# Capture request data (FR6)
|
|
103
|
+
capture_request_data(
|
|
104
|
+
span,
|
|
105
|
+
method=method,
|
|
106
|
+
url=full_url,
|
|
107
|
+
headers=req_headers,
|
|
108
|
+
body=req_body,
|
|
109
|
+
content_type=req_content_type,
|
|
110
|
+
query_params=query,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
# Capture response data (FR7)
|
|
114
|
+
capture_response_data(
|
|
115
|
+
span,
|
|
116
|
+
status_code=getattr(response, "status_code", 0),
|
|
117
|
+
headers=resp_headers_dict,
|
|
118
|
+
body=resp_body,
|
|
119
|
+
content_type=resp_content_type,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
return response
|
|
123
|
+
except Exception as exc:
|
|
124
|
+
span.set_status("ERROR", str(exc))
|
|
125
|
+
span.set_attribute("error", "true")
|
|
126
|
+
span.set_attribute("error.type", type(exc).__name__)
|
|
127
|
+
span.set_attribute("error.message", str(exc))
|
|
128
|
+
raise
|
|
129
|
+
finally:
|
|
130
|
+
span.end()
|
|
131
|
+
if self._on_span is not None:
|
|
132
|
+
try:
|
|
133
|
+
self._on_span(span.to_dict())
|
|
134
|
+
except Exception:
|
|
135
|
+
logger.debug("Error in on_span callback", exc_info=True)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class DjangoInstrumentor(BaseInstrumentor):
|
|
139
|
+
"""Instruments Django applications with middleware."""
|
|
140
|
+
|
|
141
|
+
def __init__(self, framework_info: Any) -> None:
|
|
142
|
+
super().__init__(framework_info)
|
|
143
|
+
self._active = False
|
|
144
|
+
|
|
145
|
+
def activate(self) -> None:
|
|
146
|
+
self._active = True
|
|
147
|
+
logger.info("TRACELY: Django instrumentation activated")
|
|
148
|
+
|
|
149
|
+
def deactivate(self) -> None:
|
|
150
|
+
self._active = False
|
|
151
|
+
logger.debug("TRACELY: Django instrumentation deactivated")
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_active(self) -> bool:
|
|
155
|
+
return self._active
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""FastAPI auto-instrumentation (ASGI middleware).
|
|
2
|
+
|
|
3
|
+
Wraps FastAPI (or any ASGI) applications with middleware that creates
|
|
4
|
+
structured Span objects for each HTTP request, with full trace hierarchy
|
|
5
|
+
support via context propagation. Captures full request/response data (FR6/FR7).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import Any, Callable
|
|
12
|
+
|
|
13
|
+
from tracely.capture import build_url, capture_request_data, capture_response_data
|
|
14
|
+
from tracely.context import _span_context
|
|
15
|
+
from tracely.instrumentation.base import BaseInstrumentor
|
|
16
|
+
from tracely.span import Span
|
|
17
|
+
from tracely.span_processor import on_span_end, on_span_start
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger("tracely")
|
|
20
|
+
|
|
21
|
+
# Type aliases for ASGI protocol
|
|
22
|
+
Scope = dict[str, Any]
|
|
23
|
+
Receive = Callable[..., Any]
|
|
24
|
+
Send = Callable[..., Any]
|
|
25
|
+
ASGIApp = Callable[..., Any]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _extract_header_value(
|
|
29
|
+
headers: list[tuple[bytes, bytes]], name: bytes,
|
|
30
|
+
) -> str:
|
|
31
|
+
"""Extract a single header value from ASGI header list (case-insensitive)."""
|
|
32
|
+
name_lower = name.lower()
|
|
33
|
+
for hdr_name, hdr_value in headers:
|
|
34
|
+
if hdr_name.lower() == name_lower:
|
|
35
|
+
return hdr_value.decode("utf-8", errors="replace")
|
|
36
|
+
return ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class TracelyASGIMiddleware:
|
|
40
|
+
"""ASGI middleware that creates root spans for HTTP requests.
|
|
41
|
+
|
|
42
|
+
Creates a Span object with trace_id and span_id, sets it as the
|
|
43
|
+
active span via context propagation, and captures HTTP attributes
|
|
44
|
+
including full request/response data (headers, body, URL).
|
|
45
|
+
Non-HTTP scopes (lifespan, websocket) pass through untouched.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
app: ASGIApp,
|
|
51
|
+
on_span: Callable[[dict[str, Any]], None] | None = None,
|
|
52
|
+
service_name: str | None = None,
|
|
53
|
+
on_end: Callable[[Span], None] | None = None,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.app = app
|
|
56
|
+
self._on_span = on_span
|
|
57
|
+
self._service_name = service_name
|
|
58
|
+
self._on_end = on_end
|
|
59
|
+
|
|
60
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
61
|
+
if scope["type"] != "http":
|
|
62
|
+
await self.app(scope, receive, send)
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
method = scope.get("method", "UNKNOWN")
|
|
66
|
+
path = scope.get("path", "/")
|
|
67
|
+
query = scope.get("query_string", b"").decode("utf-8", errors="replace")
|
|
68
|
+
raw_headers: list[tuple[bytes, bytes]] = scope.get("headers", [])
|
|
69
|
+
|
|
70
|
+
# Build full URL from ASGI scope
|
|
71
|
+
scheme = scope.get("scheme", "http")
|
|
72
|
+
server = scope.get("server")
|
|
73
|
+
if server:
|
|
74
|
+
host = server[0]
|
|
75
|
+
port = server[1]
|
|
76
|
+
# Include port only if non-standard
|
|
77
|
+
if (scheme == "https" and port != 443) or (scheme == "http" and port != 80):
|
|
78
|
+
host = f"{host}:{port}"
|
|
79
|
+
else:
|
|
80
|
+
host = "localhost"
|
|
81
|
+
full_url = build_url(scheme, host, path, query)
|
|
82
|
+
|
|
83
|
+
# Extract content type from request headers
|
|
84
|
+
req_content_type = _extract_header_value(raw_headers, b"content-type")
|
|
85
|
+
|
|
86
|
+
span = Span(
|
|
87
|
+
name=f"{method} {path}",
|
|
88
|
+
kind="SERVER",
|
|
89
|
+
service_name=self._service_name,
|
|
90
|
+
on_end=self._on_end or on_span_end,
|
|
91
|
+
)
|
|
92
|
+
span.set_attribute("http.route", path)
|
|
93
|
+
span.set_attribute("http.query", query)
|
|
94
|
+
|
|
95
|
+
# AR3: Export pending_span immediately for real-time dashboard
|
|
96
|
+
on_span_start(span)
|
|
97
|
+
|
|
98
|
+
# Buffer request body so the app can still read it
|
|
99
|
+
request_body = b""
|
|
100
|
+
body_consumed = False
|
|
101
|
+
|
|
102
|
+
async def receive_wrapper() -> dict[str, Any]:
|
|
103
|
+
nonlocal request_body, body_consumed
|
|
104
|
+
message = await receive()
|
|
105
|
+
if message.get("type") == "http.request" and not body_consumed:
|
|
106
|
+
chunk = message.get("body", b"")
|
|
107
|
+
request_body += chunk
|
|
108
|
+
if not message.get("more_body", False):
|
|
109
|
+
body_consumed = True
|
|
110
|
+
return message
|
|
111
|
+
|
|
112
|
+
# Capture response data via send wrapper
|
|
113
|
+
status_code = 0
|
|
114
|
+
response_headers: list[tuple[bytes, bytes]] = []
|
|
115
|
+
response_body_chunks: list[bytes] = []
|
|
116
|
+
resp_content_type = ""
|
|
117
|
+
|
|
118
|
+
async def send_wrapper(message: dict[str, Any]) -> None:
|
|
119
|
+
nonlocal status_code, response_headers, resp_content_type
|
|
120
|
+
if message.get("type") == "http.response.start":
|
|
121
|
+
status_code = message.get("status", 0)
|
|
122
|
+
response_headers = message.get("headers", [])
|
|
123
|
+
resp_content_type = _extract_header_value(response_headers, b"content-type")
|
|
124
|
+
elif message.get("type") == "http.response.body":
|
|
125
|
+
chunk = message.get("body", b"")
|
|
126
|
+
if chunk:
|
|
127
|
+
response_body_chunks.append(chunk)
|
|
128
|
+
await send(message)
|
|
129
|
+
|
|
130
|
+
with _span_context(span):
|
|
131
|
+
try:
|
|
132
|
+
await self.app(scope, receive_wrapper, send_wrapper)
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
span.set_status("ERROR", str(exc))
|
|
135
|
+
span.set_attribute("error", "true")
|
|
136
|
+
span.set_attribute("error.type", type(exc).__name__)
|
|
137
|
+
span.set_attribute("error.message", str(exc))
|
|
138
|
+
raise
|
|
139
|
+
finally:
|
|
140
|
+
# Capture request data (FR6)
|
|
141
|
+
capture_request_data(
|
|
142
|
+
span,
|
|
143
|
+
method=method,
|
|
144
|
+
url=full_url,
|
|
145
|
+
headers=raw_headers,
|
|
146
|
+
body=request_body,
|
|
147
|
+
content_type=req_content_type,
|
|
148
|
+
query_params=query,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
# Capture response data (FR7)
|
|
152
|
+
response_body = b"".join(response_body_chunks)
|
|
153
|
+
capture_response_data(
|
|
154
|
+
span,
|
|
155
|
+
status_code=status_code,
|
|
156
|
+
headers=response_headers,
|
|
157
|
+
body=response_body,
|
|
158
|
+
content_type=resp_content_type,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
span.end()
|
|
162
|
+
if self._on_span is not None:
|
|
163
|
+
try:
|
|
164
|
+
self._on_span(span.to_dict())
|
|
165
|
+
except Exception:
|
|
166
|
+
logger.debug("Error in on_span callback", exc_info=True)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class FastAPIInstrumentor(BaseInstrumentor):
|
|
170
|
+
"""Instruments FastAPI applications with ASGI middleware.
|
|
171
|
+
|
|
172
|
+
On activate(), registers the middleware factory so it can be applied
|
|
173
|
+
to FastAPI apps. The actual wrapping happens when the user's app
|
|
174
|
+
is detected or when middleware is explicitly applied.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, framework_info: Any) -> None:
|
|
178
|
+
super().__init__(framework_info)
|
|
179
|
+
self._active = False
|
|
180
|
+
|
|
181
|
+
def activate(self) -> None:
|
|
182
|
+
self._active = True
|
|
183
|
+
logger.info("TRACELY: FastAPI instrumentation activated")
|
|
184
|
+
|
|
185
|
+
def deactivate(self) -> None:
|
|
186
|
+
self._active = False
|
|
187
|
+
logger.debug("TRACELY: FastAPI instrumentation deactivated")
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def is_active(self) -> bool:
|
|
191
|
+
return self._active
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def wrap_app(
|
|
195
|
+
app: ASGIApp,
|
|
196
|
+
on_span: Callable[[dict[str, Any]], None] | None = None,
|
|
197
|
+
service_name: str | None = None,
|
|
198
|
+
on_end: Callable[[Span], None] | None = None,
|
|
199
|
+
) -> TracelyASGIMiddleware:
|
|
200
|
+
"""Wrap an ASGI app with TRACELY middleware."""
|
|
201
|
+
return TracelyASGIMiddleware(
|
|
202
|
+
app=app, on_span=on_span, service_name=service_name, on_end=on_end,
|
|
203
|
+
)
|