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,215 @@
1
+ """Flask auto-instrumentation (WSGI middleware).
2
+
3
+ Provides a WSGI middleware that wraps Flask (or any WSGI) applications
4
+ to create structured Span objects for each HTTP request, with full trace
5
+ hierarchy support via context propagation. Captures full request/response
6
+ data (FR6/FR7).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import Any, Callable, Iterable
13
+
14
+ from tracely.capture import build_url, capture_request_data, capture_response_data
15
+ from tracely.context import _span_context
16
+ from tracely.instrumentation.base import BaseInstrumentor
17
+ from tracely.span import Span
18
+ from tracely.span_processor import on_span_end, on_span_start
19
+
20
+ logger = logging.getLogger("tracely")
21
+
22
+
23
+ def _extract_wsgi_headers(environ: dict[str, Any]) -> dict[str, str]:
24
+ """Extract HTTP headers from WSGI environ dict.
25
+
26
+ WSGI stores headers as HTTP_<NAME> with underscores replacing hyphens.
27
+ CONTENT_TYPE and CONTENT_LENGTH are special cases without HTTP_ prefix.
28
+ """
29
+ headers: dict[str, str] = {}
30
+ for key, value in environ.items():
31
+ if key.startswith("HTTP_"):
32
+ header_name = key[5:].lower().replace("_", "-")
33
+ headers[header_name] = str(value)
34
+ elif key == "CONTENT_TYPE":
35
+ headers["content-type"] = str(value)
36
+ elif key == "CONTENT_LENGTH":
37
+ headers["content-length"] = str(value)
38
+ return headers
39
+
40
+
41
+ def _read_wsgi_body(environ: dict[str, Any]) -> bytes:
42
+ """Read request body from WSGI environ's wsgi.input stream."""
43
+ try:
44
+ content_length = int(environ.get("CONTENT_LENGTH", 0) or 0)
45
+ except (ValueError, TypeError):
46
+ content_length = 0
47
+
48
+ if content_length <= 0:
49
+ return b""
50
+
51
+ wsgi_input = environ.get("wsgi.input")
52
+ if wsgi_input is None:
53
+ return b""
54
+
55
+ try:
56
+ return wsgi_input.read(content_length)
57
+ except Exception:
58
+ logger.debug("Error reading WSGI body", exc_info=True)
59
+ return b""
60
+
61
+
62
+ class TracelyWSGIMiddleware:
63
+ """WSGI middleware that creates root spans for HTTP requests.
64
+
65
+ Creates a Span object with trace_id and span_id, sets it as the
66
+ active span via context propagation, and captures HTTP attributes
67
+ including full request/response data (headers, body, URL).
68
+ """
69
+
70
+ def __init__(
71
+ self,
72
+ app: Callable[..., Iterable[bytes]],
73
+ on_span: Callable[[dict[str, Any]], None] | None = None,
74
+ service_name: str | None = None,
75
+ on_end: Callable[[Span], None] | None = None,
76
+ ) -> None:
77
+ self.app = app
78
+ self._on_span = on_span
79
+ self._service_name = service_name
80
+ self._on_end = on_end
81
+
82
+ def __call__(
83
+ self,
84
+ environ: dict[str, Any],
85
+ start_response: Callable[..., Any],
86
+ ) -> Iterable[bytes]:
87
+ method = environ.get("REQUEST_METHOD", "UNKNOWN")
88
+ path = environ.get("PATH_INFO", "/")
89
+ query = environ.get("QUERY_STRING", "")
90
+
91
+ # Build full URL
92
+ scheme = environ.get("wsgi.url_scheme", "http")
93
+ host = environ.get("HTTP_HOST", "")
94
+ if not host:
95
+ server_name = environ.get("SERVER_NAME", "localhost")
96
+ server_port = environ.get("SERVER_PORT", "80")
97
+ if (scheme == "https" and server_port != "443") or (scheme == "http" and server_port != "80"):
98
+ host = f"{server_name}:{server_port}"
99
+ else:
100
+ host = server_name
101
+ full_url = build_url(scheme, host, path, query)
102
+
103
+ # Read request headers and body
104
+ req_headers = _extract_wsgi_headers(environ)
105
+ req_content_type = req_headers.get("content-type", "")
106
+ req_body = _read_wsgi_body(environ)
107
+
108
+ span = Span(
109
+ name=f"{method} {path}",
110
+ kind="SERVER",
111
+ service_name=self._service_name,
112
+ on_end=self._on_end or on_span_end,
113
+ )
114
+ span.set_attribute("http.route", path)
115
+ span.set_attribute("http.query", query)
116
+
117
+ # AR3: Export pending_span immediately for real-time dashboard
118
+ on_span_start(span)
119
+
120
+ status_code = 0
121
+ resp_headers_dict: dict[str, str] = {}
122
+ resp_content_type = ""
123
+
124
+ def wrapped_start_response(
125
+ status: str, headers: list[Any], exc_info: Any = None
126
+ ) -> Any:
127
+ nonlocal status_code, resp_headers_dict, resp_content_type
128
+ try:
129
+ status_code = int(status.split(" ", 1)[0])
130
+ except (ValueError, IndexError):
131
+ status_code = 0
132
+ # Convert response headers to dict
133
+ for name, value in headers:
134
+ resp_headers_dict[str(name).lower()] = str(value)
135
+ resp_content_type = resp_headers_dict.get("content-type", "")
136
+ return start_response(status, headers, exc_info)
137
+
138
+ with _span_context(span):
139
+ try:
140
+ result = self.app(environ, wrapped_start_response)
141
+ # Collect response body
142
+ response_body_chunks: list[bytes] = []
143
+ collected: list[bytes] = []
144
+ for chunk in result:
145
+ collected.append(chunk)
146
+ response_body_chunks.append(chunk)
147
+ response_body = b"".join(response_body_chunks)
148
+
149
+ # Capture request data (FR6)
150
+ capture_request_data(
151
+ span,
152
+ method=method,
153
+ url=full_url,
154
+ headers=req_headers,
155
+ body=req_body,
156
+ content_type=req_content_type,
157
+ query_params=query,
158
+ )
159
+
160
+ # Capture response data (FR7)
161
+ capture_response_data(
162
+ span,
163
+ status_code=status_code,
164
+ headers=resp_headers_dict,
165
+ body=response_body,
166
+ content_type=resp_content_type,
167
+ )
168
+
169
+ return collected
170
+ except Exception as exc:
171
+ span.set_status("ERROR", str(exc))
172
+ span.set_attribute("error", "true")
173
+ span.set_attribute("error.type", type(exc).__name__)
174
+ span.set_attribute("error.message", str(exc))
175
+ raise
176
+ finally:
177
+ span.set_attribute("http.status_code", str(status_code))
178
+ span.end()
179
+ if self._on_span is not None:
180
+ try:
181
+ self._on_span(span.to_dict())
182
+ except Exception:
183
+ logger.debug("Error in on_span callback", exc_info=True)
184
+
185
+
186
+ class FlaskInstrumentor(BaseInstrumentor):
187
+ """Instruments Flask applications with WSGI middleware wrapping."""
188
+
189
+ def __init__(self, framework_info: Any) -> None:
190
+ super().__init__(framework_info)
191
+ self._active = False
192
+
193
+ def activate(self) -> None:
194
+ self._active = True
195
+ logger.info("TRACELY: Flask instrumentation activated")
196
+
197
+ def deactivate(self) -> None:
198
+ self._active = False
199
+ logger.debug("TRACELY: Flask instrumentation deactivated")
200
+
201
+ @property
202
+ def is_active(self) -> bool:
203
+ return self._active
204
+
205
+ @staticmethod
206
+ def wrap_app(
207
+ app: Callable[..., Iterable[bytes]],
208
+ on_span: Callable[[dict[str, Any]], None] | None = None,
209
+ service_name: str | None = None,
210
+ on_end: Callable[[Span], None] | None = None,
211
+ ) -> TracelyWSGIMiddleware:
212
+ """Wrap a WSGI app with TRACELY middleware."""
213
+ return TracelyWSGIMiddleware(
214
+ app=app, on_span=on_span, service_name=service_name, on_end=on_end,
215
+ )
@@ -0,0 +1,38 @@
1
+ """Generic fallback instrumentation when no framework is detected.
2
+
3
+ Provides basic Python process instrumentation and logs guidance
4
+ about manual instrumentation options.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+
11
+ logger = logging.getLogger("tracely")
12
+
13
+
14
+ class GenericInstrumentor:
15
+ """Fallback instrumentor for non-framework Python applications.
16
+
17
+ Logs an info message about manual instrumentation when activated.
18
+ Does not require BaseInstrumentor inheritance since it is used
19
+ outside the framework detection flow.
20
+ """
21
+
22
+ def __init__(self) -> None:
23
+ self._active = False
24
+
25
+ def activate(self) -> None:
26
+ self._active = True
27
+ logger.info(
28
+ "TRACELY: No supported framework detected. "
29
+ "For manual instrumentation, use tracely.create_span() "
30
+ "to instrument your application code."
31
+ )
32
+
33
+ def deactivate(self) -> None:
34
+ self._active = False
35
+
36
+ @property
37
+ def is_active(self) -> bool:
38
+ return self._active
@@ -0,0 +1,130 @@
1
+ """External HTTP call instrumentation via httpx event hooks.
2
+
3
+ Creates child spans for outbound HTTP calls made through httpx,
4
+ capturing method, URL, status code, and duration (AC3).
5
+
6
+ All instrumentation is fail-silent — never crashes the host application.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ import time
13
+ from typing import Any
14
+
15
+ import httpx
16
+
17
+ from tracely.context import get_current_span
18
+ from tracely.span import Span
19
+
20
+ logger = logging.getLogger("tracely")
21
+
22
+
23
+ class HttpxInstrumentor:
24
+ """Instruments httpx clients to create child spans for outbound HTTP calls.
25
+
26
+ Usage:
27
+ instrumentor = HttpxInstrumentor()
28
+ instrumentor.activate() # Patches httpx.Client and httpx.AsyncClient
29
+ instrumentor.deactivate() # Restores originals
30
+ """
31
+
32
+ def __init__(self) -> None:
33
+ self._active = False
34
+ self._original_sync_send: Any = None
35
+ self._original_async_send: Any = None
36
+
37
+ def activate(self) -> None:
38
+ """Patch httpx Client/AsyncClient to create child spans."""
39
+ if self._active:
40
+ return
41
+
42
+ self._original_sync_send = httpx.Client.send
43
+ self._original_async_send = httpx.AsyncClient.send
44
+
45
+ original_sync = self._original_sync_send
46
+ original_async = self._original_async_send
47
+
48
+ def instrumented_sync_send(client_self: httpx.Client, request: httpx.Request, **kwargs: Any) -> httpx.Response:
49
+ parent = get_current_span()
50
+ if parent is None:
51
+ return original_sync(client_self, request, **kwargs)
52
+
53
+ span = Span(
54
+ name=f"HTTP {request.method} {request.url.host}",
55
+ parent=parent,
56
+ kind="CLIENT",
57
+ )
58
+ span.set_attribute("http.method", str(request.method))
59
+ span.set_attribute("http.url", str(request.url))
60
+
61
+ try:
62
+ response = original_sync(client_self, request, **kwargs)
63
+ span.set_attribute("http.status_code", str(response.status_code))
64
+ if response.status_code >= 400:
65
+ span.set_status("ERROR", f"HTTP {response.status_code}")
66
+ else:
67
+ span.set_status("OK")
68
+ return response
69
+ except Exception as exc:
70
+ span.set_status("ERROR", str(exc))
71
+ span.set_attribute("error", "true")
72
+ span.set_attribute("error.type", type(exc).__name__)
73
+ span.set_attribute("error.message", str(exc))
74
+ raise
75
+ finally:
76
+ span.end()
77
+
78
+ async def instrumented_async_send(client_self: httpx.AsyncClient, request: httpx.Request, **kwargs: Any) -> httpx.Response:
79
+ parent = get_current_span()
80
+ if parent is None:
81
+ return await original_async(client_self, request, **kwargs)
82
+
83
+ span = Span(
84
+ name=f"HTTP {request.method} {request.url.host}",
85
+ parent=parent,
86
+ kind="CLIENT",
87
+ )
88
+ span.set_attribute("http.method", str(request.method))
89
+ span.set_attribute("http.url", str(request.url))
90
+
91
+ try:
92
+ response = await original_async(client_self, request, **kwargs)
93
+ span.set_attribute("http.status_code", str(response.status_code))
94
+ if response.status_code >= 400:
95
+ span.set_status("ERROR", f"HTTP {response.status_code}")
96
+ else:
97
+ span.set_status("OK")
98
+ return response
99
+ except Exception as exc:
100
+ span.set_status("ERROR", str(exc))
101
+ span.set_attribute("error", "true")
102
+ span.set_attribute("error.type", type(exc).__name__)
103
+ span.set_attribute("error.message", str(exc))
104
+ raise
105
+ finally:
106
+ span.end()
107
+
108
+ httpx.Client.send = instrumented_sync_send # type: ignore[assignment]
109
+ httpx.AsyncClient.send = instrumented_async_send # type: ignore[assignment]
110
+ self._active = True
111
+ logger.info("TRACELY: httpx instrumentation activated")
112
+
113
+ def deactivate(self) -> None:
114
+ """Restore original httpx send methods."""
115
+ if not self._active:
116
+ return
117
+
118
+ if self._original_sync_send is not None:
119
+ httpx.Client.send = self._original_sync_send # type: ignore[assignment]
120
+ if self._original_async_send is not None:
121
+ httpx.AsyncClient.send = self._original_async_send # type: ignore[assignment]
122
+
123
+ self._active = False
124
+ self._original_sync_send = None
125
+ self._original_async_send = None
126
+ logger.debug("TRACELY: httpx instrumentation deactivated")
127
+
128
+ @property
129
+ def is_active(self) -> bool:
130
+ return self._active
tracely/log_handler.py ADDED
@@ -0,0 +1,55 @@
1
+ """Log event handler for span association (FR60).
2
+
3
+ Captures log events and associates them with the currently active
4
+ span's span_id and trace_id. Only captures events when a span is
5
+ active — otherwise silently discards them.
6
+
7
+ All operations are fail-silent to avoid crashing the host application.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import logging
13
+ from typing import Any, Callable
14
+
15
+ from tracely.context import get_current_span
16
+
17
+ _logger = logging.getLogger("tracely")
18
+
19
+
20
+ class TracelyLogHandler(logging.Handler):
21
+ """Logging handler that associates log events with active spans.
22
+
23
+ Args:
24
+ on_event: Callback invoked with a dict for each captured log event.
25
+ If None, events are silently discarded.
26
+ """
27
+
28
+ def __init__(self, on_event: Callable[[dict[str, Any]], None] | None = None) -> None:
29
+ super().__init__()
30
+ self._on_event = on_event
31
+
32
+ def emit(self, record: logging.LogRecord) -> None:
33
+ try:
34
+ span = get_current_span()
35
+ if span is None:
36
+ return
37
+
38
+ event: dict[str, Any] = {
39
+ "trace_id": span.trace_id,
40
+ "span_id": span.span_id,
41
+ "level": record.levelname,
42
+ "message": record.getMessage(),
43
+ "timestamp": record.created,
44
+ "logger_name": record.name,
45
+ }
46
+
47
+ if record.exc_info and record.exc_info[1] is not None:
48
+ exc = record.exc_info[1]
49
+ event["exception_type"] = type(exc).__name__
50
+ event["exception_message"] = str(exc)
51
+
52
+ if self._on_event is not None:
53
+ self._on_event(event)
54
+ except Exception:
55
+ _logger.debug("Error in TracelyLogHandler.emit", exc_info=True)
tracely/logging_api.py ADDED
@@ -0,0 +1,38 @@
1
+ """Public logging API for adding events to the active span.
2
+
3
+ Provides debug(), info(), warning(), error() functions that attach
4
+ log events to the currently active span. All functions are fail-silent
5
+ and no-op when no span is active.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from tracely.context import get_current_span
11
+
12
+
13
+ def debug(message: str, **attributes: str) -> None:
14
+ """Add a DEBUG event to the active span."""
15
+ span = get_current_span()
16
+ if span is not None:
17
+ span.add_event(message, level="DEBUG", attributes=attributes or None)
18
+
19
+
20
+ def info(message: str, **attributes: str) -> None:
21
+ """Add an INFO event to the active span."""
22
+ span = get_current_span()
23
+ if span is not None:
24
+ span.add_event(message, level="INFO", attributes=attributes or None)
25
+
26
+
27
+ def warning(message: str, **attributes: str) -> None:
28
+ """Add a WARNING event to the active span."""
29
+ span = get_current_span()
30
+ if span is not None:
31
+ span.add_event(message, level="WARNING", attributes=attributes or None)
32
+
33
+
34
+ def error(message: str, **attributes: str) -> None:
35
+ """Add an ERROR event to the active span."""
36
+ span = get_current_span()
37
+ if span is not None:
38
+ span.add_event(message, level="ERROR", attributes=attributes or None)
tracely/otlp.py ADDED
@@ -0,0 +1,128 @@
1
+ """OTLP protobuf serialization for span data (AR2).
2
+
3
+ Converts internal span dicts to OTLP ExportTraceServiceRequest protobuf bytes
4
+ for transmission via OTLP/HTTP.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from collections import defaultdict
11
+ from typing import Any
12
+
13
+ from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
14
+ ExportTraceServiceRequest,
15
+ )
16
+ from opentelemetry.proto.common.v1.common_pb2 import AnyValue, KeyValue
17
+ from opentelemetry.proto.resource.v1.resource_pb2 import Resource
18
+ from opentelemetry.proto.trace.v1.trace_pb2 import (
19
+ ResourceSpans,
20
+ ScopeSpans,
21
+ Span as OtlpSpan,
22
+ Status,
23
+ )
24
+
25
+ logger = logging.getLogger("tracely")
26
+
27
+ # OTLP SpanKind mapping
28
+ _KIND_MAP: dict[str, int] = {
29
+ "INTERNAL": 1, # SPAN_KIND_INTERNAL
30
+ "SERVER": 2, # SPAN_KIND_SERVER
31
+ "CLIENT": 3, # SPAN_KIND_CLIENT
32
+ "PRODUCER": 4, # SPAN_KIND_PRODUCER
33
+ "CONSUMER": 5, # SPAN_KIND_CONSUMER
34
+ }
35
+
36
+ # OTLP StatusCode mapping
37
+ _STATUS_MAP: dict[str, int] = {
38
+ "UNSET": 0, # STATUS_CODE_UNSET
39
+ "OK": 1, # STATUS_CODE_OK
40
+ "ERROR": 2, # STATUS_CODE_ERROR
41
+ }
42
+
43
+
44
+ def _seconds_to_nanos(ts: float | None) -> int:
45
+ """Convert a Unix timestamp in seconds to nanoseconds."""
46
+ if ts is None:
47
+ return 0
48
+ return int(ts * 1_000_000_000)
49
+
50
+
51
+ def _make_kv(key: str, value: str) -> KeyValue:
52
+ """Create an OTLP KeyValue with a string value."""
53
+ return KeyValue(key=key, value=AnyValue(string_value=str(value)))
54
+
55
+
56
+ def _span_to_otlp(span_dict: dict[str, Any]) -> OtlpSpan:
57
+ """Convert an internal span dict to an OTLP Span message."""
58
+ trace_id = bytes.fromhex(span_dict["trace_id"])
59
+ span_id = bytes.fromhex(span_dict["span_id"])
60
+
61
+ parent = span_dict.get("parent_span_id")
62
+ parent_span_id = bytes.fromhex(parent) if parent else b""
63
+
64
+ # Build attributes from span dict + tracely.span_type
65
+ attributes = []
66
+ for k, v in span_dict.get("attributes", {}).items():
67
+ attributes.append(_make_kv(k, v))
68
+ attributes.append(_make_kv("tracely.span_type", span_dict.get("span_type", "span")))
69
+
70
+ # Build events
71
+ events = []
72
+ for evt in span_dict.get("events", []):
73
+ evt_attrs = [_make_kv(k, v) for k, v in evt.get("attributes", {}).items()]
74
+ if evt.get("level"):
75
+ evt_attrs.append(_make_kv("level", evt["level"]))
76
+ events.append(OtlpSpan.Event(
77
+ name=evt.get("message", ""),
78
+ time_unix_nano=_seconds_to_nanos(evt.get("timestamp")),
79
+ attributes=evt_attrs,
80
+ ))
81
+
82
+ # Build status
83
+ status_code = _STATUS_MAP.get(span_dict.get("status_code", "UNSET"), 0)
84
+ status = Status(
85
+ code=status_code,
86
+ message=span_dict.get("status_message", ""),
87
+ )
88
+
89
+ return OtlpSpan(
90
+ trace_id=trace_id,
91
+ span_id=span_id,
92
+ parent_span_id=parent_span_id,
93
+ name=span_dict.get("span_name", ""),
94
+ kind=_KIND_MAP.get(span_dict.get("kind", "INTERNAL"), 1),
95
+ start_time_unix_nano=_seconds_to_nanos(span_dict.get("start_time")),
96
+ end_time_unix_nano=_seconds_to_nanos(span_dict.get("end_time")),
97
+ attributes=attributes,
98
+ events=events,
99
+ status=status,
100
+ )
101
+
102
+
103
+ def serialize_spans(spans: list[dict[str, Any]]) -> bytes:
104
+ """Serialize a list of span dicts to OTLP ExportTraceServiceRequest bytes.
105
+
106
+ Spans are grouped by service_name into separate ResourceSpans.
107
+ Returns protobuf-serialized bytes ready for OTLP/HTTP transport.
108
+ """
109
+ if not spans:
110
+ return ExportTraceServiceRequest().SerializeToString()
111
+
112
+ # Group spans by service_name
113
+ grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
114
+ for span_dict in spans:
115
+ svc = span_dict.get("service_name") or "unknown"
116
+ grouped[svc].append(span_dict)
117
+
118
+ resource_spans_list = []
119
+ for svc_name, svc_spans in grouped.items():
120
+ resource = Resource(attributes=[_make_kv("service.name", svc_name)])
121
+ otlp_spans = [_span_to_otlp(s) for s in svc_spans]
122
+ scope_spans = ScopeSpans(spans=otlp_spans)
123
+ resource_spans_list.append(
124
+ ResourceSpans(resource=resource, scope_spans=[scope_spans])
125
+ )
126
+
127
+ request = ExportTraceServiceRequest(resource_spans=resource_spans_list)
128
+ return request.SerializeToString()
tracely/py.typed ADDED
File without changes