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.
@@ -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
+ )