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,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
|