policyengine-observability 0.2.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,166 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from .config import ObservabilityConfig
6
+ from .context import OperationObservabilityContext, RequestObservabilityContext
7
+ from .runtime import (
8
+ OBSERVABILITY_INTERNAL_DISPATCH_HEADER,
9
+ REQUEST_ID_HEADER,
10
+ TRACEPARENT_HEADER,
11
+ ObservabilityRuntime,
12
+ observability_runtime,
13
+ set_observability_runtime,
14
+ )
15
+ from .segments import UNKNOWN_SEGMENT, coerce_segment_name
16
+
17
+
18
+ def current_context() -> RequestObservabilityContext | None:
19
+ return observability_runtime().current_context()
20
+
21
+
22
+ def current_operation() -> OperationObservabilityContext | None:
23
+ return observability_runtime().current_operation()
24
+
25
+
26
+ def set_attribute(key: str, value: Any) -> None:
27
+ observability_runtime().set_attribute(key, value)
28
+
29
+
30
+ def record_error(
31
+ exc: BaseException,
32
+ *,
33
+ handled: bool,
34
+ status_code: int | None = None,
35
+ include_stack: bool = True,
36
+ ) -> None:
37
+ observability_runtime().record_error(
38
+ exc,
39
+ handled=handled,
40
+ status_code=status_code,
41
+ include_stack=include_stack,
42
+ )
43
+
44
+
45
+ def record_event(event: str, **fields: Any) -> None:
46
+ observability_runtime().record_event(event, **fields)
47
+
48
+
49
+ def traceparent_header() -> str | None:
50
+ return observability_runtime().traceparent_header()
51
+
52
+
53
+ def capture_context():
54
+ return observability_runtime().capture_context()
55
+
56
+
57
+ def mark(key: str, ms: float) -> None:
58
+ observability_runtime().mark(key, ms)
59
+
60
+
61
+ def mark_ttft(key: str = "ttft_ms") -> None:
62
+ observability_runtime().mark_ttft(key)
63
+
64
+
65
+ def start_scope(
66
+ timings: dict[str, float],
67
+ *,
68
+ name: str = "operation",
69
+ parent_context: Any = None,
70
+ **attrs: Any,
71
+ ):
72
+ return observability_runtime().start_scope(
73
+ timings,
74
+ name=name,
75
+ parent_context=parent_context,
76
+ **attrs,
77
+ )
78
+
79
+
80
+ def annotate(handle=None, **attrs: Any) -> None:
81
+ observability_runtime().annotate(handle, **attrs)
82
+
83
+
84
+ def end_scope(handle, error: BaseException | None = None) -> None:
85
+ observability_runtime().end_scope(handle, error)
86
+
87
+
88
+ def instrument_fastapi(app: Any) -> None:
89
+ observability_runtime().instrument_fastapi(app)
90
+
91
+
92
+ def instrument_httpx() -> None:
93
+ observability_runtime().instrument_httpx()
94
+
95
+
96
+ def shutdown_observability() -> None:
97
+ observability_runtime().shutdown()
98
+
99
+
100
+ def shutdown_tracing() -> None:
101
+ shutdown_observability()
102
+
103
+
104
+ def operation(name: str, *, flavor: str | None = None, **attrs: Any):
105
+ return observability_runtime().operation(name, flavor=flavor, **attrs)
106
+
107
+
108
+ def entrypoint(
109
+ name: str | None = None,
110
+ *,
111
+ flavor: str | None = None,
112
+ **attrs: Any,
113
+ ):
114
+ return observability_runtime().entrypoint(
115
+ name,
116
+ flavor=flavor,
117
+ **attrs,
118
+ )
119
+
120
+
121
+ def segment(name: Any, **attrs: Any):
122
+ return observability_runtime().segment(name, **attrs)
123
+
124
+
125
+ def asegment(name: Any, **attrs: Any):
126
+ return observability_runtime().asegment(name, **attrs)
127
+
128
+
129
+ def collect_timings(name: str = "operation", **attrs: Any):
130
+ return observability_runtime().collect_timings(name, **attrs)
131
+
132
+
133
+ __all__ = [
134
+ "OBSERVABILITY_INTERNAL_DISPATCH_HEADER",
135
+ "REQUEST_ID_HEADER",
136
+ "TRACEPARENT_HEADER",
137
+ "UNKNOWN_SEGMENT",
138
+ "OperationObservabilityContext",
139
+ "ObservabilityConfig",
140
+ "ObservabilityRuntime",
141
+ "RequestObservabilityContext",
142
+ "annotate",
143
+ "asegment",
144
+ "capture_context",
145
+ "coerce_segment_name",
146
+ "collect_timings",
147
+ "current_context",
148
+ "current_operation",
149
+ "end_scope",
150
+ "entrypoint",
151
+ "instrument_fastapi",
152
+ "instrument_httpx",
153
+ "mark",
154
+ "mark_ttft",
155
+ "observability_runtime",
156
+ "operation",
157
+ "record_error",
158
+ "record_event",
159
+ "segment",
160
+ "set_attribute",
161
+ "set_observability_runtime",
162
+ "shutdown_observability",
163
+ "shutdown_tracing",
164
+ "start_scope",
165
+ "traceparent_header",
166
+ ]
@@ -0,0 +1 @@
1
+ """Framework adapters for policyengine-observability."""
@@ -0,0 +1,286 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any
5
+ from urllib.parse import parse_qs
6
+
7
+ from ..config import ObservabilityConfig
8
+ from ..context import RequestObservabilityContext
9
+ from ..runtime import (
10
+ REQUEST_ID_HEADER,
11
+ TRACEPARENT_HEADER,
12
+ ObservabilityRuntime,
13
+ set_observability_runtime,
14
+ )
15
+
16
+ UNMATCHED_ROUTE = "<unmatched>"
17
+
18
+
19
+ class FastAPIObservabilityAdapter:
20
+ def __init__(self, runtime: ObservabilityRuntime) -> None:
21
+ self.runtime = runtime
22
+
23
+ def instrument_app(self, app: Any) -> None:
24
+ if not self.runtime.enabled:
25
+ return
26
+ if getattr(app.state, "policyengine_observability_adapter", None):
27
+ return
28
+ app.state.policyengine_observability_adapter = self
29
+ if self.runtime.config.instrument_fastapi:
30
+ self.runtime.instrument_fastapi(app)
31
+ try:
32
+ app.add_middleware(
33
+ FastAPIObservabilityMiddleware,
34
+ adapter=self,
35
+ )
36
+ except BaseException as exc:
37
+ self.runtime.log_observability_failure(
38
+ "fastapi.middleware_install",
39
+ exc,
40
+ )
41
+
42
+ def start_request(self, scope: dict[str, Any]) -> None:
43
+ try:
44
+ headers = _headers_from_scope(scope)
45
+ path = scope.get("path") or ""
46
+ route = _route_from_scope(scope) or UNMATCHED_ROUTE
47
+ endpoint = _endpoint_from_scope(scope)
48
+ request_id = headers.get(REQUEST_ID_HEADER.lower()) or str(
49
+ uuid.uuid4()
50
+ )
51
+ context = RequestObservabilityContext(
52
+ config=self.runtime.config,
53
+ request_id=request_id,
54
+ method=scope.get("method") or "",
55
+ route=route,
56
+ path=path,
57
+ endpoint=endpoint,
58
+ query_keys=_query_keys(scope),
59
+ content_length_bytes=_int_header(
60
+ headers.get("content-length")
61
+ ),
62
+ inbound=self._inbound_metadata(scope, headers),
63
+ )
64
+ self.runtime.begin_request(context, carrier=headers)
65
+ except BaseException as exc:
66
+ self.runtime.log_observability_failure(
67
+ "fastapi.before_request",
68
+ exc,
69
+ )
70
+
71
+ def update_resolved_route(self, scope: dict[str, Any]) -> None:
72
+ route = _route_from_scope(scope)
73
+ endpoint = _endpoint_from_scope(scope)
74
+ if route or endpoint:
75
+ self.runtime.update_request_route(route=route, endpoint=endpoint)
76
+
77
+ def _inbound_metadata(
78
+ self,
79
+ scope: dict[str, Any],
80
+ headers: dict[str, str],
81
+ ) -> dict[str, Any]:
82
+ forwarded_for = _split_forwarded_for(headers.get("x-forwarded-for"))
83
+ x_real_ip = headers.get("x-real-ip")
84
+ client = scope.get("client") or ()
85
+ remote_addr = client[0] if client else None
86
+ client_ip = None
87
+ ip_source = None
88
+ if forwarded_for:
89
+ client_ip = forwarded_for[0]
90
+ ip_source = "x_forwarded_for"
91
+ elif x_real_ip:
92
+ client_ip = x_real_ip
93
+ ip_source = "x_real_ip"
94
+ elif remote_addr:
95
+ client_ip = remote_addr
96
+ ip_source = "remote_addr"
97
+ metadata = {
98
+ "ip_source": ip_source,
99
+ "user_agent": headers.get("user-agent"),
100
+ "origin": headers.get("origin"),
101
+ "referer": headers.get("referer"),
102
+ "host": headers.get("host"),
103
+ "content_length_bytes": _int_header(headers.get("content-length")),
104
+ }
105
+ if self.runtime.config.log_raw_ip:
106
+ metadata["client_ip"] = client_ip
107
+ metadata["forwarded_for"] = forwarded_for
108
+ metadata["x_real_ip"] = x_real_ip
109
+ return metadata
110
+
111
+
112
+ class FastAPIObservabilityMiddleware:
113
+ def __init__(
114
+ self,
115
+ app: Any,
116
+ *,
117
+ adapter: FastAPIObservabilityAdapter,
118
+ ) -> None:
119
+ self.app = app
120
+ self.adapter = adapter
121
+
122
+ async def __call__(self, scope, receive, send) -> None:
123
+ if scope.get("type") != "http":
124
+ await self.app(scope, receive, send)
125
+ return
126
+
127
+ self.adapter.start_request(scope)
128
+ status_code: int | None = None
129
+ completed = False
130
+
131
+ async def send_wrapper(message) -> None:
132
+ nonlocal completed
133
+ nonlocal status_code
134
+
135
+ if message["type"] == "http.response.start":
136
+ status_code = int(message.get("status") or 0)
137
+ self.adapter.update_resolved_route(scope)
138
+ response_headers = self.adapter.runtime.prepare_response(
139
+ status_code
140
+ )
141
+ if response_headers:
142
+ message = {
143
+ **message,
144
+ "headers": _merge_response_headers(
145
+ message.get("headers") or [],
146
+ response_headers,
147
+ ),
148
+ }
149
+ await send(message)
150
+ return
151
+
152
+ if message["type"] == "http.response.body" and not message.get(
153
+ "more_body", False
154
+ ):
155
+ try:
156
+ await send(message)
157
+ finally:
158
+ completed = True
159
+ self.adapter.update_resolved_route(scope)
160
+ self.adapter.runtime.complete_request(status_code)
161
+ self.adapter.runtime.teardown_request(None)
162
+ return
163
+
164
+ await send(message)
165
+
166
+ try:
167
+ await self.app(scope, receive, send_wrapper)
168
+ except BaseException as exc:
169
+ if not completed:
170
+ error_status = status_code or 500
171
+ self.adapter.update_resolved_route(scope)
172
+ self.adapter.runtime.prepare_response(error_status)
173
+ self.adapter.runtime.complete_request(error_status)
174
+ self.adapter.runtime.teardown_request(exc)
175
+ completed = True
176
+ raise
177
+ finally:
178
+ if not completed:
179
+ self.adapter.update_resolved_route(scope)
180
+ self.adapter.runtime.complete_request(status_code)
181
+ self.adapter.runtime.teardown_request(None)
182
+
183
+
184
+ def init_fastapi_observability(
185
+ app: Any,
186
+ *,
187
+ config: ObservabilityConfig | None = None,
188
+ runtime: ObservabilityRuntime | None = None,
189
+ service_name: str,
190
+ service_role: str = "api",
191
+ span_prefix: str | None = None,
192
+ segment_registry=None,
193
+ ) -> ObservabilityRuntime:
194
+ existing = getattr(app.state, "policyengine_observability", None)
195
+ if existing:
196
+ return existing
197
+ runtime = runtime or ObservabilityRuntime(
198
+ config
199
+ or ObservabilityConfig.from_env(
200
+ service_name=service_name,
201
+ service_role=service_role,
202
+ span_prefix=span_prefix,
203
+ ),
204
+ segment_registry=segment_registry,
205
+ )
206
+ runtime.configure()
207
+ app.state.policyengine_observability = runtime
208
+ set_observability_runtime(runtime)
209
+ FastAPIObservabilityAdapter(runtime).instrument_app(app)
210
+ return runtime
211
+
212
+
213
+ def _headers_from_scope(scope: dict[str, Any]) -> dict[str, str]:
214
+ headers: dict[str, str] = {}
215
+ for key, value in scope.get("headers") or []:
216
+ try:
217
+ header_key = key.decode("latin-1").lower()
218
+ header_value = value.decode("latin-1")
219
+ except BaseException:
220
+ continue
221
+ if header_key in headers:
222
+ headers[header_key] = f"{headers[header_key]},{header_value}"
223
+ else:
224
+ headers[header_key] = header_value
225
+ return headers
226
+
227
+
228
+ def _route_from_scope(scope: dict[str, Any]) -> str | None:
229
+ route = scope.get("route")
230
+ route_path = getattr(route, "path", None)
231
+ if route_path:
232
+ return str(route_path)
233
+ return None
234
+
235
+
236
+ def _endpoint_from_scope(scope: dict[str, Any]) -> str | None:
237
+ endpoint = scope.get("endpoint")
238
+ if endpoint is None:
239
+ return None
240
+ endpoint_name = getattr(endpoint, "__name__", None)
241
+ return endpoint_name or str(endpoint)
242
+
243
+
244
+ def _query_keys(scope: dict[str, Any]) -> list[str]:
245
+ query_string = scope.get("query_string") or b""
246
+ try:
247
+ decoded = query_string.decode("latin-1")
248
+ except AttributeError:
249
+ decoded = str(query_string)
250
+ return sorted(parse_qs(decoded, keep_blank_values=True).keys())
251
+
252
+
253
+ def _merge_response_headers(
254
+ existing_headers: list[tuple[bytes, bytes]],
255
+ headers: dict[str, str],
256
+ ) -> list[tuple[bytes, bytes]]:
257
+ response_header_names = {key.lower().encode("latin-1") for key in headers}
258
+ merged = [
259
+ (key, value)
260
+ for key, value in existing_headers
261
+ if key.lower() not in response_header_names
262
+ ]
263
+ merged.extend(
264
+ (
265
+ key.encode("latin-1"),
266
+ value.encode("latin-1"),
267
+ )
268
+ for key, value in headers.items()
269
+ if key in {REQUEST_ID_HEADER, TRACEPARENT_HEADER}
270
+ )
271
+ return merged
272
+
273
+
274
+ def _split_forwarded_for(value: str | None) -> list[str]:
275
+ if not value:
276
+ return []
277
+ return [part.strip() for part in value.split(",") if part.strip()]
278
+
279
+
280
+ def _int_header(value: str | None) -> int | None:
281
+ if value is None:
282
+ return None
283
+ try:
284
+ return int(value)
285
+ except ValueError:
286
+ return None
@@ -0,0 +1,132 @@
1
+ from __future__ import annotations
2
+
3
+ import uuid
4
+ from typing import Any
5
+
6
+ from ..config import ObservabilityConfig
7
+ from ..context import RequestObservabilityContext
8
+ from ..runtime import (
9
+ OBSERVABILITY_INTERNAL_DISPATCH_HEADER,
10
+ REQUEST_ID_HEADER,
11
+ ObservabilityRuntime,
12
+ set_observability_runtime,
13
+ )
14
+
15
+
16
+ class FlaskObservabilityAdapter:
17
+ def __init__(self, runtime: ObservabilityRuntime) -> None:
18
+ self.runtime = runtime
19
+
20
+ def instrument_app(self, app: Any) -> None:
21
+ if not self.runtime.enabled:
22
+ return
23
+ if app.extensions.get("policyengine_observability_adapter"):
24
+ return
25
+ app.extensions["policyengine_observability_adapter"] = self
26
+
27
+ @app.before_request
28
+ def _start_observed_request() -> None:
29
+ self.start_request()
30
+
31
+ @app.after_request
32
+ def _finish_observed_request(response):
33
+ headers = self.runtime.finish_request(response.status_code)
34
+ for key, value in headers.items():
35
+ response.headers[key] = value
36
+ return response
37
+
38
+ @app.teardown_request
39
+ def _emit_observed_request(exc) -> None:
40
+ self.runtime.teardown_request(exc)
41
+
42
+ def start_request(self) -> None:
43
+ try:
44
+ from flask import request
45
+
46
+ route = request.url_rule.rule if request.url_rule else request.path
47
+ request_id = request.headers.get(REQUEST_ID_HEADER) or str(
48
+ uuid.uuid4()
49
+ )
50
+ context = RequestObservabilityContext(
51
+ config=self.runtime.config,
52
+ request_id=request_id,
53
+ method=request.method,
54
+ route=route,
55
+ path=request.path,
56
+ endpoint=request.endpoint,
57
+ query_keys=sorted(request.args.keys()),
58
+ content_length_bytes=request.content_length,
59
+ inbound=self._inbound_metadata(request),
60
+ internal_dispatch=(
61
+ request.headers.get(OBSERVABILITY_INTERNAL_DISPATCH_HEADER)
62
+ == "1"
63
+ ),
64
+ )
65
+ self.runtime.begin_request(context, carrier=request.headers)
66
+ except BaseException as exc:
67
+ self.runtime.log_observability_failure("flask.before_request", exc)
68
+
69
+ def _inbound_metadata(self, request) -> dict:
70
+ forwarded_for = _split_forwarded_for(
71
+ request.headers.get("X-Forwarded-For")
72
+ )
73
+ x_real_ip = request.headers.get("X-Real-IP")
74
+ remote_addr = request.remote_addr
75
+ client_ip = None
76
+ ip_source = None
77
+ if forwarded_for:
78
+ client_ip = forwarded_for[0]
79
+ ip_source = "x_forwarded_for"
80
+ elif x_real_ip:
81
+ client_ip = x_real_ip
82
+ ip_source = "x_real_ip"
83
+ elif remote_addr:
84
+ client_ip = remote_addr
85
+ ip_source = "remote_addr"
86
+ metadata = {
87
+ "ip_source": ip_source,
88
+ "user_agent": request.headers.get("User-Agent"),
89
+ "origin": request.headers.get("Origin"),
90
+ "referer": request.headers.get("Referer"),
91
+ "host": request.host,
92
+ "content_length_bytes": request.content_length,
93
+ }
94
+ if self.runtime.config.log_raw_ip:
95
+ metadata["client_ip"] = client_ip
96
+ metadata["forwarded_for"] = forwarded_for
97
+ metadata["x_real_ip"] = x_real_ip
98
+ return metadata
99
+
100
+
101
+ def init_flask_observability(
102
+ app: Any,
103
+ *,
104
+ config: ObservabilityConfig | None = None,
105
+ runtime: ObservabilityRuntime | None = None,
106
+ service_name: str,
107
+ service_role: str = "api",
108
+ span_prefix: str | None = None,
109
+ segment_registry=None,
110
+ ) -> ObservabilityRuntime:
111
+ if app.extensions.get("policyengine_observability"):
112
+ return app.extensions["policyengine_observability"]
113
+ runtime = runtime or ObservabilityRuntime(
114
+ config
115
+ or ObservabilityConfig.from_env(
116
+ service_name=service_name,
117
+ service_role=service_role,
118
+ span_prefix=span_prefix,
119
+ ),
120
+ segment_registry=segment_registry,
121
+ )
122
+ runtime.configure()
123
+ app.extensions["policyengine_observability"] = runtime
124
+ set_observability_runtime(runtime)
125
+ FlaskObservabilityAdapter(runtime).instrument_app(app)
126
+ return runtime
127
+
128
+
129
+ def _split_forwarded_for(value: str | None) -> list[str]:
130
+ if not value:
131
+ return []
132
+ return [part.strip() for part in value.split(",") if part.strip()]