Skip to content

Commit 0eb9009

Browse files
committed
feat: introduce rust_tracing integration
1 parent 02d0934 commit 0eb9009

File tree

3 files changed

+668
-0
lines changed

3 files changed

+668
-0
lines changed
Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
"""
2+
This integration ingests tracing data from native extensions written in Rust.
3+
4+
Using it requires additional setup on the Rust side to accept a
5+
`RustTracingLayer` Python object and register it with the `tracing-subscriber`
6+
using an adapter from the `pyo3-python-tracing-subscriber` crate. For example:
7+
```rust
8+
#[pyfunction]
9+
pub fn initialize_tracing(py_impl: Bound<'_, PyAny>) {
10+
tracing_subscriber::registry()
11+
.with(pyo3_python_tracing_subscriber::PythonCallbackLayerBridge::new(py_impl))
12+
.init();
13+
}
14+
```
15+
16+
Usage in Python would then look like:
17+
```
18+
sentry_sdk.init(
19+
dsn=sentry_dsn,
20+
integrations=[
21+
SentryIntegrationFactory.create(
22+
"demo_rust_extension",
23+
demo_rust_extension.initialize_tracing,
24+
event_type_mapping=event_type_mapping,
25+
)
26+
],
27+
)
28+
```
29+
30+
Each native extension requires its own integration.
31+
"""
32+
33+
import json
34+
from enum import Enum, auto
35+
from typing import Any, Callable, Final, Tuple, Optional
36+
37+
import sentry_sdk
38+
from sentry_sdk.integrations import Integration
39+
from sentry_sdk.tracing import Span as SentrySpan
40+
41+
TraceState = Optional[Tuple[SentrySpan | None, SentrySpan]]
42+
43+
44+
class RustTracingLevel(Enum):
45+
Trace: Final[str] = "TRACE"
46+
Debug: Final[str] = "DEBUG"
47+
Info: Final[str] = "INFO"
48+
Warn: Final[str] = "WARN"
49+
Error: Final[str] = "ERROR"
50+
51+
52+
class EventTypeMapping(Enum):
53+
Ignore = auto()
54+
Exc = auto()
55+
Breadcrumb = auto()
56+
Event = auto()
57+
58+
59+
def tracing_level_to_sentry_level(level):
60+
# type: (str) -> sentry_sdk._types.LogLevelStr
61+
match RustTracingLevel(level):
62+
case RustTracingLevel.Trace | RustTracingLevel.Debug:
63+
return "debug"
64+
case RustTracingLevel.Info:
65+
return "info"
66+
case RustTracingLevel.Warn:
67+
return "warning"
68+
case RustTracingLevel.Error:
69+
return "error"
70+
71+
72+
def extract_contexts(event: dict[str, Any]) -> dict[str, Any]:
73+
metadata = event.get("metadata", {})
74+
contexts = {}
75+
76+
location = {}
77+
for field in ["module_path", "file", "line"]:
78+
if field in metadata:
79+
location[field] = metadata[field]
80+
if len(location) > 0:
81+
contexts["Rust Tracing Location"] = location
82+
83+
fields = {}
84+
for field in metadata.get("fields", []):
85+
fields[field] = event.get(field)
86+
if len(fields) > 0:
87+
contexts["Rust Tracing Fields"] = fields
88+
89+
return contexts
90+
91+
92+
def process_event(event: dict[str, Any]) -> None:
93+
metadata = event.get("metadata", {})
94+
95+
logger = metadata.get("target")
96+
level = tracing_level_to_sentry_level(metadata.get("level"))
97+
message = event.get("message") # type: sentry_sdk._types.Any
98+
contexts = extract_contexts(event)
99+
100+
sentry_event = {
101+
"logger": logger,
102+
"level": level,
103+
"message": message,
104+
"contexts": contexts,
105+
} # type: sentry_sdk._types.Event
106+
107+
sentry_sdk.capture_event(sentry_event)
108+
109+
110+
def process_exception(event: dict[str, Any]) -> None:
111+
process_event(event)
112+
113+
114+
def process_breadcrumb(event: dict[str, Any]) -> None:
115+
level = tracing_level_to_sentry_level(event.get("metadata", {}).get("level"))
116+
message = event.get("message")
117+
118+
sentry_sdk.add_breadcrumb(level=level, message=message)
119+
120+
121+
def default_span_filter(metadata: dict[str, Any]) -> bool:
122+
return RustTracingLevel(metadata.get("level")) in (
123+
RustTracingLevel.Error,
124+
RustTracingLevel.Warn,
125+
RustTracingLevel.Info,
126+
)
127+
128+
129+
def default_event_type_mapping(metadata: dict[str, Any]) -> EventTypeMapping:
130+
match RustTracingLevel(metadata.get("level")):
131+
case RustTracingLevel.Error:
132+
return EventTypeMapping.Exc
133+
case RustTracingLevel.Warn | RustTracingLevel.Info:
134+
return EventTypeMapping.Breadcrumb
135+
case RustTracingLevel.Debug | RustTracingLevel.Trace:
136+
return EventTypeMapping.Ignore
137+
138+
139+
class RustTracingLayer:
140+
def __init__(
141+
self,
142+
origin: str,
143+
event_type_mapping: Callable[
144+
[dict[str, Any]], EventTypeMapping
145+
] = default_event_type_mapping,
146+
span_filter: Callable[[dict[str, Any]], bool] = default_span_filter,
147+
):
148+
self.origin = origin
149+
self.event_type_mapping = event_type_mapping
150+
self.span_filter = span_filter
151+
152+
def on_event(self, event: str, _span_state: TraceState) -> None:
153+
deserialized_event = json.loads(event)
154+
metadata = deserialized_event.get("metadata", {})
155+
156+
event_type = self.event_type_mapping(metadata)
157+
match event_type:
158+
case EventTypeMapping.Ignore:
159+
return
160+
case EventTypeMapping.Exc:
161+
process_exception(deserialized_event)
162+
case EventTypeMapping.Breadcrumb:
163+
process_breadcrumb(deserialized_event)
164+
case EventTypeMapping.Event:
165+
process_event(deserialized_event)
166+
167+
def on_new_span(self, attrs: str, span_id: str) -> TraceState:
168+
attrs = json.loads(attrs)
169+
metadata = attrs.get("metadata", {})
170+
171+
if not self.span_filter(metadata):
172+
return None
173+
174+
module_path = metadata.get("module_path")
175+
name = metadata.get("name")
176+
message = attrs.get("message")
177+
178+
if message is not None:
179+
sentry_span_name = message
180+
elif module_path is not None and name is not None:
181+
sentry_span_name = f"{module_path}::{name}" # noqa: E231
182+
elif name is not None:
183+
sentry_span_name = name
184+
else:
185+
sentry_span_name = "<unknown>"
186+
187+
kwargs = {
188+
"op": "native_extension",
189+
"name": sentry_span_name,
190+
"origin": self.origin,
191+
}
192+
193+
scope = sentry_sdk.get_current_scope()
194+
parent_sentry_span = scope.span
195+
if parent_sentry_span:
196+
sentry_span = parent_sentry_span.start_child(**kwargs)
197+
else:
198+
sentry_span = scope.start_span(**kwargs)
199+
200+
fields = metadata.get("fields", [])
201+
for field in fields:
202+
sentry_span.set_data(field, attrs.get(field))
203+
204+
scope.span = sentry_span
205+
return (parent_sentry_span, sentry_span)
206+
207+
def on_close(self, span_id: str, span_state: TraceState) -> None:
208+
if span_state is None:
209+
return
210+
211+
parent_sentry_span, sentry_span = span_state
212+
sentry_span.finish()
213+
sentry_sdk.get_current_scope().span = parent_sentry_span
214+
215+
def on_record(self, span_id: str, values: str, span_state: TraceState) -> None:
216+
if span_state is None:
217+
return
218+
_parent_sentry_span, sentry_span = span_state
219+
220+
deserialized_values = json.loads(values)
221+
for key, value in deserialized_values.items():
222+
sentry_span.set_data(key, value)
223+
224+
225+
def _create_integration(
226+
identifier: str,
227+
initializer: Callable[[RustTracingLayer], None],
228+
event_type_mapping: Callable[
229+
[dict[str, Any]], EventTypeMapping
230+
] = default_event_type_mapping,
231+
span_filter: Callable[[dict[str, Any]], bool] = default_span_filter,
232+
) -> object:
233+
"""
234+
Each native extension used by a project requires its own integration, but
235+
`sentry_sdk` does not expect multiple instances of the same integration. To
236+
work around that, invoking `RustTracingIntegration()` actually calls this
237+
factory function which creates a unique anonymous class and returns an
238+
instance of it.
239+
"""
240+
origin = f"auto.native_extension.{identifier}"
241+
tracing_layer = RustTracingLayer(origin, event_type_mapping, span_filter)
242+
243+
def setup_once() -> None:
244+
initializer(tracing_layer)
245+
246+
anonymous_class = type(
247+
"",
248+
(Integration,),
249+
{
250+
"identifier": identifier,
251+
"setup_once": setup_once,
252+
"tracing_layer": tracing_layer,
253+
},
254+
)
255+
anonymous_class_instance = anonymous_class()
256+
return anonymous_class_instance
257+
258+
259+
RustTracingIntegration = _create_integration

tests/integrations/rust_tracing/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)