obsforge 0.1.1__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.
- obsforge/__init__.py +44 -0
- obsforge/api/__init__.py +5 -0
- obsforge/api/context.py +32 -0
- obsforge/api/decorators.py +42 -0
- obsforge/api/events.py +25 -0
- obsforge/api/logger.py +45 -0
- obsforge/config/__init__.py +4 -0
- obsforge/config/bootstrap.py +190 -0
- obsforge/config/settings.py +176 -0
- obsforge/config/state.py +53 -0
- obsforge/core/__init__.py +3 -0
- obsforge/core/contracts.py +52 -0
- obsforge/core/errors.py +10 -0
- obsforge/core/models.py +572 -0
- obsforge/core/taxonomy.py +29 -0
- obsforge/encoding/__init__.py +3 -0
- obsforge/encoding/json.py +46 -0
- obsforge/encoding/serializers.py +7 -0
- obsforge/instrumentation/__init__.py +1 -0
- obsforge/instrumentation/db/__init__.py +4 -0
- obsforge/instrumentation/db/aiomysql.py +106 -0
- obsforge/instrumentation/db/asyncpg.py +107 -0
- obsforge/instrumentation/db/django.py +70 -0
- obsforge/instrumentation/db/engine.py +324 -0
- obsforge/instrumentation/db/pool.py +123 -0
- obsforge/instrumentation/db/psycopg.py +168 -0
- obsforge/instrumentation/db/sql.py +65 -0
- obsforge/instrumentation/db/sqlalchemy.py +161 -0
- obsforge/instrumentation/db/state.py +28 -0
- obsforge/instrumentation/db/transactions.py +73 -0
- obsforge/instrumentation/exceptions/__init__.py +15 -0
- obsforge/instrumentation/exceptions/classification.py +108 -0
- obsforge/instrumentation/exceptions/dedupe.py +66 -0
- obsforge/instrumentation/exceptions/engine.py +373 -0
- obsforge/instrumentation/exceptions/sanitization.py +64 -0
- obsforge/instrumentation/exceptions/state.py +101 -0
- obsforge/instrumentation/http/__init__.py +13 -0
- obsforge/instrumentation/http/aiohttp.py +57 -0
- obsforge/instrumentation/http/classification.py +98 -0
- obsforge/instrumentation/http/dependency.py +92 -0
- obsforge/instrumentation/http/engine.py +516 -0
- obsforge/instrumentation/http/httpx.py +130 -0
- obsforge/instrumentation/http/requests.py +98 -0
- obsforge/instrumentation/http/sanitization.py +134 -0
- obsforge/instrumentation/http/state.py +24 -0
- obsforge/integrations/__init__.py +1 -0
- obsforge/integrations/asyncio.py +43 -0
- obsforge/integrations/celery/__init__.py +3 -0
- obsforge/integrations/celery/signals.py +76 -0
- obsforge/integrations/django/__init__.py +3 -0
- obsforge/integrations/django/middleware.py +105 -0
- obsforge/integrations/django/settings.py +1 -0
- obsforge/integrations/django/signals.py +5 -0
- obsforge/integrations/drf/__init__.py +3 -0
- obsforge/integrations/drf/exception_handler.py +31 -0
- obsforge/integrations/fastapi/__init__.py +3 -0
- obsforge/integrations/fastapi/dependencies.py +7 -0
- obsforge/integrations/fastapi/exception_handlers.py +29 -0
- obsforge/integrations/fastapi/middleware.py +142 -0
- obsforge/integrations/kafka.py +27 -0
- obsforge/integrations/logging_bridge.py +122 -0
- obsforge/integrations/rabbitmq.py +29 -0
- obsforge/integrations/workers.py +60 -0
- obsforge/plugins/__init__.py +4 -0
- obsforge/plugins/builtin.py +24 -0
- obsforge/plugins/manager.py +23 -0
- obsforge/plugins/registry.py +35 -0
- obsforge/plugins/spec.py +27 -0
- obsforge/propagation/__init__.py +26 -0
- obsforge/propagation/baggage.py +42 -0
- obsforge/propagation/correlation.py +98 -0
- obsforge/propagation/distributed.py +389 -0
- obsforge/propagation/state.py +24 -0
- obsforge/propagation/tracecontext.py +56 -0
- obsforge/py.typed +0 -0
- obsforge/runtime/__init__.py +3 -0
- obsforge/runtime/pii.py +53 -0
- obsforge/runtime/pipeline.py +102 -0
- obsforge/runtime/policies/__init__.py +0 -0
- obsforge/runtime/policies/loki_policy.py +180 -0
- obsforge/runtime/processors.py +78 -0
- obsforge/runtime/redaction.py +101 -0
- obsforge/runtime/sampling.py +21 -0
- obsforge/telemetry/__init__.py +5 -0
- obsforge/telemetry/mapping.py +67 -0
- obsforge/telemetry/otel_logs.py +96 -0
- obsforge/telemetry/otel_traces.py +78 -0
- obsforge/testing/__init__.py +3 -0
- obsforge/testing/capture.py +11 -0
- obsforge/testing/fakes.py +40 -0
- obsforge/transport/__init__.py +3 -0
- obsforge/transport/sink.py +19 -0
- obsforge/transport/stdout.py +20 -0
- obsforge-0.1.1.dist-info/METADATA +383 -0
- obsforge-0.1.1.dist-info/RECORD +98 -0
- obsforge-0.1.1.dist-info/WHEEL +4 -0
- obsforge-0.1.1.dist-info/entry_points.txt +2 -0
- obsforge-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from typing import Any
|
|
6
|
+
from urllib.parse import SplitResult, urlsplit
|
|
7
|
+
|
|
8
|
+
from obsforge.api.events import EventClient
|
|
9
|
+
from obsforge.config.settings import HTTPInstrumentationSettings, ObsforgeSettings
|
|
10
|
+
from obsforge.core.models import (
|
|
11
|
+
BusinessContext,
|
|
12
|
+
CanonicalEvent,
|
|
13
|
+
CorrelationContext,
|
|
14
|
+
DatabaseContext,
|
|
15
|
+
DependencyContext,
|
|
16
|
+
Event,
|
|
17
|
+
EventKind,
|
|
18
|
+
Outcome,
|
|
19
|
+
PerformanceContext,
|
|
20
|
+
RequestContext,
|
|
21
|
+
ResponseContext,
|
|
22
|
+
RetryContext,
|
|
23
|
+
Severity,
|
|
24
|
+
SpanKind,
|
|
25
|
+
TelemetryContext,
|
|
26
|
+
TraceContext,
|
|
27
|
+
)
|
|
28
|
+
from obsforge.instrumentation.exceptions.engine import ExceptionIntelligenceEngine
|
|
29
|
+
from obsforge.instrumentation.http.classification import (
|
|
30
|
+
classify_response,
|
|
31
|
+
merge_anomalies,
|
|
32
|
+
status_class,
|
|
33
|
+
)
|
|
34
|
+
from obsforge.instrumentation.http.dependency import DependencyHealthTracker
|
|
35
|
+
from obsforge.instrumentation.http.sanitization import PayloadSanitizer
|
|
36
|
+
from obsforge.propagation.correlation import CorrelationManager, CorrelationSnapshot
|
|
37
|
+
from obsforge.propagation.distributed import DistributedCorrelationEngine
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class RequestCapture:
|
|
42
|
+
started_at: float
|
|
43
|
+
request: RequestContext
|
|
44
|
+
dependency: DependencyContext | None
|
|
45
|
+
trace: TraceContext
|
|
46
|
+
correlation: CorrelationContext
|
|
47
|
+
baggage: dict[str, str]
|
|
48
|
+
restore_snapshot: CorrelationSnapshot | None = None
|
|
49
|
+
exception_snapshot: Any | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class APIRuntimeEngine:
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
*,
|
|
56
|
+
event_client: EventClient,
|
|
57
|
+
correlation_manager: CorrelationManager,
|
|
58
|
+
settings: ObsforgeSettings,
|
|
59
|
+
exception_engine: ExceptionIntelligenceEngine | None = None,
|
|
60
|
+
distributed_engine: DistributedCorrelationEngine | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
self._event_client = event_client
|
|
63
|
+
self._correlation_manager = correlation_manager
|
|
64
|
+
self._settings = settings
|
|
65
|
+
self._http_settings: HTTPInstrumentationSettings = settings.http
|
|
66
|
+
self._sanitizer = PayloadSanitizer(settings.http)
|
|
67
|
+
self._dependencies = DependencyHealthTracker(settings.http)
|
|
68
|
+
self._exception_engine = exception_engine
|
|
69
|
+
self._distributed = distributed_engine or DistributedCorrelationEngine(
|
|
70
|
+
correlation_manager=correlation_manager,
|
|
71
|
+
settings=settings,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def settings(self) -> ObsforgeSettings:
|
|
76
|
+
return self._settings
|
|
77
|
+
|
|
78
|
+
def inject_distributed_headers(self, headers: dict[str, str]) -> TraceContext:
|
|
79
|
+
envelope = self._distributed.inject_http_headers(headers)
|
|
80
|
+
return envelope.trace
|
|
81
|
+
|
|
82
|
+
def capture_server_request(
|
|
83
|
+
self,
|
|
84
|
+
*,
|
|
85
|
+
method: str,
|
|
86
|
+
url: str,
|
|
87
|
+
headers: dict[str, str],
|
|
88
|
+
route_template: str | None,
|
|
89
|
+
client_address: str | None,
|
|
90
|
+
request_body: bytes | str | dict[str, object] | None,
|
|
91
|
+
) -> RequestCapture:
|
|
92
|
+
parsed = urlsplit(url)
|
|
93
|
+
envelope = self._distributed.extract_http_headers(headers)
|
|
94
|
+
correlation = envelope.correlation
|
|
95
|
+
trace = envelope.trace
|
|
96
|
+
request = RequestContext(
|
|
97
|
+
protocol="http",
|
|
98
|
+
method=method.upper(),
|
|
99
|
+
scheme=parsed.scheme or "http",
|
|
100
|
+
host=parsed.hostname,
|
|
101
|
+
server_address=parsed.hostname,
|
|
102
|
+
server_port=parsed.port,
|
|
103
|
+
route_template=route_template,
|
|
104
|
+
path=parsed.path or "/",
|
|
105
|
+
query_redacted=self._sanitizer.sanitize_query(parsed.query),
|
|
106
|
+
client_address=client_address,
|
|
107
|
+
user_agent=headers.get("user-agent"),
|
|
108
|
+
content_type=headers.get("content-type"),
|
|
109
|
+
body_size_bytes=_payload_size(request_body),
|
|
110
|
+
headers_allowlist=self._sanitizer.sanitize_headers(headers),
|
|
111
|
+
payload_summary=self._sanitizer.summarize_payload(
|
|
112
|
+
request_body,
|
|
113
|
+
content_type=headers.get("content-type"),
|
|
114
|
+
),
|
|
115
|
+
)
|
|
116
|
+
self._bind_correlation_inheritance(correlation)
|
|
117
|
+
if self._exception_engine is not None:
|
|
118
|
+
self._exception_engine.state.bind_request_context(request)
|
|
119
|
+
return RequestCapture(
|
|
120
|
+
started_at=time.perf_counter(),
|
|
121
|
+
request=request,
|
|
122
|
+
dependency=None,
|
|
123
|
+
trace=trace,
|
|
124
|
+
correlation=correlation,
|
|
125
|
+
baggage=envelope.baggage,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
def capture_client_request(
|
|
129
|
+
self,
|
|
130
|
+
*,
|
|
131
|
+
method: str,
|
|
132
|
+
url: str,
|
|
133
|
+
headers: dict[str, str],
|
|
134
|
+
request_body: bytes | str | dict[str, object] | None,
|
|
135
|
+
timeout_ms: float | None = None,
|
|
136
|
+
destination_service: str | None = None,
|
|
137
|
+
) -> RequestCapture:
|
|
138
|
+
parsed = urlsplit(url)
|
|
139
|
+
parent_snapshot = self._distributed.snapshot()
|
|
140
|
+
exception_snapshot = None
|
|
141
|
+
if self._exception_engine is not None:
|
|
142
|
+
exception_snapshot = self._exception_engine.snapshot_context()
|
|
143
|
+
envelope = self._distributed.inject_http_headers(headers)
|
|
144
|
+
trace = envelope.trace
|
|
145
|
+
correlation = envelope.correlation
|
|
146
|
+
request = RequestContext(
|
|
147
|
+
protocol="http",
|
|
148
|
+
method=method.upper(),
|
|
149
|
+
scheme=parsed.scheme or "http",
|
|
150
|
+
host=parsed.hostname,
|
|
151
|
+
server_address=parsed.hostname,
|
|
152
|
+
server_port=parsed.port,
|
|
153
|
+
path=parsed.path or "/",
|
|
154
|
+
query_redacted=self._sanitizer.sanitize_query(parsed.query),
|
|
155
|
+
content_type=headers.get("content-type"),
|
|
156
|
+
body_size_bytes=_payload_size(request_body),
|
|
157
|
+
timeout_ms=timeout_ms,
|
|
158
|
+
headers_allowlist=self._sanitizer.sanitize_headers(headers),
|
|
159
|
+
payload_summary=self._sanitizer.summarize_payload(
|
|
160
|
+
request_body,
|
|
161
|
+
content_type=headers.get("content-type"),
|
|
162
|
+
),
|
|
163
|
+
)
|
|
164
|
+
dependency = self._dependency_from_url(parsed, destination_service=destination_service)
|
|
165
|
+
self._bind_correlation_inheritance(correlation)
|
|
166
|
+
if self._exception_engine is not None:
|
|
167
|
+
self._exception_engine.state.bind_request_context(request)
|
|
168
|
+
self._exception_engine.state.bind_dependency_context(dependency)
|
|
169
|
+
return RequestCapture(
|
|
170
|
+
started_at=time.perf_counter(),
|
|
171
|
+
request=request,
|
|
172
|
+
dependency=dependency,
|
|
173
|
+
trace=trace,
|
|
174
|
+
correlation=correlation,
|
|
175
|
+
baggage=envelope.baggage,
|
|
176
|
+
restore_snapshot=parent_snapshot,
|
|
177
|
+
exception_snapshot=exception_snapshot,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
async def emit_client_event(
|
|
181
|
+
self,
|
|
182
|
+
capture: RequestCapture,
|
|
183
|
+
*,
|
|
184
|
+
response_status_code: int | None = None,
|
|
185
|
+
response_headers: dict[str, str] | None = None,
|
|
186
|
+
response_body: bytes | str | dict[str, object] | None = None,
|
|
187
|
+
exception: BaseException | None = None,
|
|
188
|
+
retry_attempt: int | None = None,
|
|
189
|
+
max_attempts: int | None = None,
|
|
190
|
+
malformed_response: bool = False,
|
|
191
|
+
) -> Event:
|
|
192
|
+
event = self._finalize_event(
|
|
193
|
+
capture,
|
|
194
|
+
response_status_code=response_status_code,
|
|
195
|
+
response_headers=response_headers,
|
|
196
|
+
response_body=response_body,
|
|
197
|
+
exception=exception,
|
|
198
|
+
retry_attempt=retry_attempt,
|
|
199
|
+
max_attempts=max_attempts,
|
|
200
|
+
malformed_response=malformed_response,
|
|
201
|
+
source_kind="client",
|
|
202
|
+
)
|
|
203
|
+
try:
|
|
204
|
+
await self._event_client.emit(event)
|
|
205
|
+
return event
|
|
206
|
+
finally:
|
|
207
|
+
self._restore_client_context(capture)
|
|
208
|
+
|
|
209
|
+
def emit_client_event_sync(
|
|
210
|
+
self,
|
|
211
|
+
capture: RequestCapture,
|
|
212
|
+
*,
|
|
213
|
+
response_status_code: int | None = None,
|
|
214
|
+
response_headers: dict[str, str] | None = None,
|
|
215
|
+
response_body: bytes | str | dict[str, object] | None = None,
|
|
216
|
+
exception: BaseException | None = None,
|
|
217
|
+
retry_attempt: int | None = None,
|
|
218
|
+
max_attempts: int | None = None,
|
|
219
|
+
malformed_response: bool = False,
|
|
220
|
+
) -> Event:
|
|
221
|
+
event = self._finalize_event(
|
|
222
|
+
capture,
|
|
223
|
+
response_status_code=response_status_code,
|
|
224
|
+
response_headers=response_headers,
|
|
225
|
+
response_body=response_body,
|
|
226
|
+
exception=exception,
|
|
227
|
+
retry_attempt=retry_attempt,
|
|
228
|
+
max_attempts=max_attempts,
|
|
229
|
+
malformed_response=malformed_response,
|
|
230
|
+
source_kind="client",
|
|
231
|
+
)
|
|
232
|
+
try:
|
|
233
|
+
self._event_client.emit_sync(event)
|
|
234
|
+
return event
|
|
235
|
+
finally:
|
|
236
|
+
self._restore_client_context(capture)
|
|
237
|
+
|
|
238
|
+
async def emit_server_event(
|
|
239
|
+
self,
|
|
240
|
+
capture: RequestCapture,
|
|
241
|
+
*,
|
|
242
|
+
response_status_code: int | None = None,
|
|
243
|
+
response_headers: dict[str, str] | None = None,
|
|
244
|
+
response_body: bytes | str | dict[str, object] | None = None,
|
|
245
|
+
exception: BaseException | None = None,
|
|
246
|
+
malformed_response: bool = False,
|
|
247
|
+
) -> Event:
|
|
248
|
+
event = self._finalize_event(
|
|
249
|
+
capture,
|
|
250
|
+
response_status_code=response_status_code,
|
|
251
|
+
response_headers=response_headers,
|
|
252
|
+
response_body=response_body,
|
|
253
|
+
exception=exception,
|
|
254
|
+
malformed_response=malformed_response,
|
|
255
|
+
source_kind="server",
|
|
256
|
+
)
|
|
257
|
+
await self._event_client.emit(event)
|
|
258
|
+
return event
|
|
259
|
+
|
|
260
|
+
def emit_server_event_sync(
|
|
261
|
+
self,
|
|
262
|
+
capture: RequestCapture,
|
|
263
|
+
*,
|
|
264
|
+
response_status_code: int | None = None,
|
|
265
|
+
response_headers: dict[str, str] | None = None,
|
|
266
|
+
response_body: bytes | str | dict[str, object] | None = None,
|
|
267
|
+
exception: BaseException | None = None,
|
|
268
|
+
malformed_response: bool = False,
|
|
269
|
+
) -> Event:
|
|
270
|
+
event = self._finalize_event(
|
|
271
|
+
capture,
|
|
272
|
+
response_status_code=response_status_code,
|
|
273
|
+
response_headers=response_headers,
|
|
274
|
+
response_body=response_body,
|
|
275
|
+
exception=exception,
|
|
276
|
+
malformed_response=malformed_response,
|
|
277
|
+
source_kind="server",
|
|
278
|
+
)
|
|
279
|
+
self._event_client.emit_sync(event)
|
|
280
|
+
return event
|
|
281
|
+
|
|
282
|
+
def clear_context(self) -> None:
|
|
283
|
+
self._distributed.clear()
|
|
284
|
+
if self._exception_engine is not None:
|
|
285
|
+
self._exception_engine.clear_context()
|
|
286
|
+
|
|
287
|
+
def response_propagation_headers(self, capture: RequestCapture) -> dict[str, str]:
|
|
288
|
+
return {
|
|
289
|
+
key.lower(): value
|
|
290
|
+
for key, value in self._distributed.response_headers().items()
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
def update_capture_request_body(
|
|
294
|
+
self,
|
|
295
|
+
capture: RequestCapture,
|
|
296
|
+
body: bytes | str | dict[str, object] | None,
|
|
297
|
+
) -> None:
|
|
298
|
+
if body is None:
|
|
299
|
+
return
|
|
300
|
+
capture.request.body_size_bytes = _payload_size(body)
|
|
301
|
+
capture.request.payload_summary = self._sanitizer.summarize_payload(
|
|
302
|
+
body,
|
|
303
|
+
content_type=capture.request.content_type,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
def _finalize_event(
|
|
307
|
+
self,
|
|
308
|
+
capture: RequestCapture,
|
|
309
|
+
*,
|
|
310
|
+
response_status_code: int | None,
|
|
311
|
+
response_headers: dict[str, str] | None,
|
|
312
|
+
response_body: bytes | str | dict[str, object] | None,
|
|
313
|
+
exception: BaseException | None,
|
|
314
|
+
retry_attempt: int | None = None,
|
|
315
|
+
max_attempts: int | None = None,
|
|
316
|
+
malformed_response: bool = False,
|
|
317
|
+
source_kind: str,
|
|
318
|
+
) -> Event:
|
|
319
|
+
duration_ms = (time.perf_counter() - capture.started_at) * 1000
|
|
320
|
+
response = ResponseContext(
|
|
321
|
+
status_code=response_status_code,
|
|
322
|
+
status_class=status_class(response_status_code),
|
|
323
|
+
content_type=(response_headers or {}).get("content-type"),
|
|
324
|
+
body_size_bytes=_payload_size(response_body),
|
|
325
|
+
malformed=malformed_response,
|
|
326
|
+
headers_allowlist=self._sanitizer.sanitize_headers(response_headers),
|
|
327
|
+
payload_summary=self._sanitizer.summarize_payload(
|
|
328
|
+
response_body,
|
|
329
|
+
content_type=(response_headers or {}).get("content-type"),
|
|
330
|
+
),
|
|
331
|
+
)
|
|
332
|
+
if (
|
|
333
|
+
not response.malformed
|
|
334
|
+
and response.content_type is not None
|
|
335
|
+
and "json" in response.content_type.lower()
|
|
336
|
+
and response.payload_summary is not None
|
|
337
|
+
and response.payload_summary.kind != "json"
|
|
338
|
+
):
|
|
339
|
+
response.malformed = True
|
|
340
|
+
outcome, response_anomalies = classify_response(response)
|
|
341
|
+
exception_context = None
|
|
342
|
+
if exception is not None:
|
|
343
|
+
if self._exception_engine is not None:
|
|
344
|
+
exception_context = self._exception_engine.capture_exception_context(
|
|
345
|
+
exception,
|
|
346
|
+
request=capture.request,
|
|
347
|
+
dependency=capture.dependency,
|
|
348
|
+
handled=source_kind == "client",
|
|
349
|
+
escaped=source_kind == "server",
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
from obsforge.instrumentation.http.classification import classify_exception
|
|
353
|
+
|
|
354
|
+
exception_context = classify_exception(exception)
|
|
355
|
+
if exception_context is not None and exception_context.failure_class is not None:
|
|
356
|
+
if exception_context.failure_class.value == "timeout":
|
|
357
|
+
outcome = Outcome.TIMEOUT
|
|
358
|
+
else:
|
|
359
|
+
outcome = Outcome.FAILED
|
|
360
|
+
|
|
361
|
+
retry = RetryContext(
|
|
362
|
+
attempt=retry_attempt,
|
|
363
|
+
max_attempts=max_attempts,
|
|
364
|
+
retriable=(retry_attempt or 1) < (max_attempts or retry_attempt or 1),
|
|
365
|
+
exhausted=(retry_attempt or 1) >= (max_attempts or retry_attempt or 1),
|
|
366
|
+
storm_detected=(retry_attempt or 1) >= self._http_settings.retry_storm_threshold,
|
|
367
|
+
)
|
|
368
|
+
|
|
369
|
+
performance = PerformanceContext(
|
|
370
|
+
duration_ms=duration_ms,
|
|
371
|
+
payload_in_bytes=capture.request.body_size_bytes,
|
|
372
|
+
payload_out_bytes=response.body_size_bytes,
|
|
373
|
+
slow_threshold_ms=self._http_settings.slow_request_threshold_ms,
|
|
374
|
+
sampled=True,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
event_name = "api.request.completed" if source_kind == "server" else "dependency.http.completed"
|
|
378
|
+
severity = Severity.INFO
|
|
379
|
+
if outcome in {Outcome.FAILED, Outcome.TIMEOUT}:
|
|
380
|
+
severity = Severity.ERROR
|
|
381
|
+
elif outcome in {Outcome.REJECTED, Outcome.DEGRADED}:
|
|
382
|
+
severity = Severity.WARNING
|
|
383
|
+
|
|
384
|
+
event = CanonicalEvent(
|
|
385
|
+
severity=severity,
|
|
386
|
+
event_name=event_name,
|
|
387
|
+
event_kind=EventKind.API if source_kind == "server" else EventKind.DEPENDENCY,
|
|
388
|
+
event_domain="http" if source_kind == "server" else "dependency",
|
|
389
|
+
operation=capture.request.method.lower() if capture.request.method else "request",
|
|
390
|
+
outcome=outcome,
|
|
391
|
+
message=self._message_for(source_kind, capture.request, response_status_code, exception_context),
|
|
392
|
+
correlation=capture.correlation,
|
|
393
|
+
telemetry=TelemetryContext(
|
|
394
|
+
trace=capture.trace,
|
|
395
|
+
baggage=dict(capture.baggage),
|
|
396
|
+
span_kind=_span_kind_for(source_kind),
|
|
397
|
+
otel_status_code="ERROR" if outcome in {Outcome.FAILED, Outcome.TIMEOUT} else "OK",
|
|
398
|
+
),
|
|
399
|
+
request=capture.request,
|
|
400
|
+
response=response,
|
|
401
|
+
exception=exception_context,
|
|
402
|
+
database=self._bound_database_context(),
|
|
403
|
+
business=self._bound_business_context(),
|
|
404
|
+
dependency=capture.dependency,
|
|
405
|
+
retry=retry,
|
|
406
|
+
performance=performance,
|
|
407
|
+
tags={"source_kind": source_kind},
|
|
408
|
+
attributes={
|
|
409
|
+
"source.service.name": self._settings.service_name,
|
|
410
|
+
"source.service.namespace": self._settings.namespace,
|
|
411
|
+
"destination.service.name": (
|
|
412
|
+
capture.dependency.remote_service_name
|
|
413
|
+
if capture.dependency is not None
|
|
414
|
+
else self._settings.service_name
|
|
415
|
+
),
|
|
416
|
+
"dependency.graph.edge": (
|
|
417
|
+
f"{self._settings.service_name}->{capture.dependency.remote_service_name}"
|
|
418
|
+
if capture.dependency is not None and capture.dependency.remote_service_name is not None
|
|
419
|
+
else f"{self._settings.service_name}->{self._settings.service_name}"
|
|
420
|
+
),
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
dependency_anomalies = self._dependencies.record(event)
|
|
425
|
+
anomalies = merge_anomalies(response_anomalies, dependency_anomalies)
|
|
426
|
+
if malformed_response:
|
|
427
|
+
anomalies = merge_anomalies(anomalies, ["malformed_response"])
|
|
428
|
+
if anomalies:
|
|
429
|
+
event.ai_hints.extend(anomalies)
|
|
430
|
+
event.investigation_keys.extend(
|
|
431
|
+
[
|
|
432
|
+
event.runtime.service_name,
|
|
433
|
+
event.event_name,
|
|
434
|
+
capture.request.route_template or capture.request.host or "unknown",
|
|
435
|
+
]
|
|
436
|
+
)
|
|
437
|
+
if "upstream_degradation" in anomalies or "dependency_saturation" in anomalies:
|
|
438
|
+
event.outcome = Outcome.DEGRADED
|
|
439
|
+
if event.severity == Severity.INFO:
|
|
440
|
+
event.severity = Severity.WARNING
|
|
441
|
+
if exception_context is not None:
|
|
442
|
+
event.ai_hints.extend(
|
|
443
|
+
hint for hint in exception_context.ai_hints if hint not in event.ai_hints
|
|
444
|
+
)
|
|
445
|
+
return event
|
|
446
|
+
|
|
447
|
+
@staticmethod
|
|
448
|
+
def _dependency_from_url(parsed: SplitResult, *, destination_service: str | None) -> DependencyContext:
|
|
449
|
+
remote_name = destination_service or parsed.hostname or "unknown-remote"
|
|
450
|
+
return DependencyContext(
|
|
451
|
+
dependency_type="http",
|
|
452
|
+
dependency_name=remote_name,
|
|
453
|
+
operation=parsed.path or "/",
|
|
454
|
+
target_namespace=parsed.hostname,
|
|
455
|
+
remote_service_name=remote_name,
|
|
456
|
+
upstream=True,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
@staticmethod
|
|
460
|
+
def _message_for(
|
|
461
|
+
source_kind: str,
|
|
462
|
+
request: RequestContext,
|
|
463
|
+
status_code: int | None,
|
|
464
|
+
exception: object | None,
|
|
465
|
+
) -> str:
|
|
466
|
+
target = request.route_template or request.path or request.host or "unknown"
|
|
467
|
+
if exception is not None:
|
|
468
|
+
return f"{source_kind} {request.method} {target} failed"
|
|
469
|
+
return f"{source_kind} {request.method} {target} -> {status_code}"
|
|
470
|
+
|
|
471
|
+
def _bound_business_context(self) -> BusinessContext | None:
|
|
472
|
+
if self._exception_engine is None:
|
|
473
|
+
return None
|
|
474
|
+
return self._exception_engine.state.get_business_context()
|
|
475
|
+
|
|
476
|
+
def _bound_database_context(self) -> DatabaseContext | None:
|
|
477
|
+
if self._exception_engine is None:
|
|
478
|
+
return None
|
|
479
|
+
return self._exception_engine.state.get_database_context()
|
|
480
|
+
|
|
481
|
+
def _bind_correlation_inheritance(self, correlation: CorrelationContext) -> None:
|
|
482
|
+
if self._exception_engine is None:
|
|
483
|
+
return
|
|
484
|
+
current = self._exception_engine.state.get_business_context()
|
|
485
|
+
if (
|
|
486
|
+
current is not None
|
|
487
|
+
and current.tenant_id == correlation.tenant_id
|
|
488
|
+
and current.transaction_id == correlation.transaction_id
|
|
489
|
+
):
|
|
490
|
+
return
|
|
491
|
+
merged = current.model_copy() if current is not None else BusinessContext()
|
|
492
|
+
if correlation.tenant_id is not None and merged.tenant_id is None:
|
|
493
|
+
merged.tenant_id = correlation.tenant_id
|
|
494
|
+
if correlation.transaction_id is not None and merged.transaction_id is None:
|
|
495
|
+
merged.transaction_id = correlation.transaction_id
|
|
496
|
+
self._exception_engine.state.bind_business_context(merged)
|
|
497
|
+
|
|
498
|
+
def _restore_client_context(self, capture: RequestCapture) -> None:
|
|
499
|
+
if capture.restore_snapshot is not None:
|
|
500
|
+
self._distributed.restore(capture.restore_snapshot)
|
|
501
|
+
if self._exception_engine is not None and capture.exception_snapshot is not None:
|
|
502
|
+
self._exception_engine.restore_context(capture.exception_snapshot)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _payload_size(payload: bytes | str | dict[str, object] | None) -> int | None:
|
|
506
|
+
if payload is None:
|
|
507
|
+
return None
|
|
508
|
+
if isinstance(payload, bytes):
|
|
509
|
+
return len(payload)
|
|
510
|
+
if isinstance(payload, str):
|
|
511
|
+
return len(payload.encode("utf-8", errors="replace"))
|
|
512
|
+
return len(str(payload).encode("utf-8", errors="replace"))
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _span_kind_for(source_kind: str) -> SpanKind:
|
|
516
|
+
return SpanKind.SERVER if source_kind == "server" else SpanKind.CLIENT
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING, Any
|
|
4
|
+
|
|
5
|
+
from obsforge.instrumentation.http.engine import APIRuntimeEngine
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
try: # pragma: no cover - optional dependency
|
|
11
|
+
import httpx as _httpx
|
|
12
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
13
|
+
_httpx = None # type: ignore[assignment]
|
|
14
|
+
|
|
15
|
+
_HAS_HTTPX = _httpx is not None
|
|
16
|
+
|
|
17
|
+
if _HAS_HTTPX:
|
|
18
|
+
_BaseTransport: Any = _httpx.BaseTransport
|
|
19
|
+
_AsyncBaseTransport: Any = _httpx.AsyncBaseTransport
|
|
20
|
+
else: # pragma: no cover - optional dependency
|
|
21
|
+
_BaseTransport = object
|
|
22
|
+
_AsyncBaseTransport = object
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ObsforgeHTTPXTransport(_BaseTransport): # type: ignore[misc]
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
engine: APIRuntimeEngine,
|
|
29
|
+
transport: httpx.BaseTransport | None = None,
|
|
30
|
+
) -> None:
|
|
31
|
+
if not _HAS_HTTPX: # pragma: no cover - optional dependency
|
|
32
|
+
raise RuntimeError("httpx is not installed")
|
|
33
|
+
self._engine = engine
|
|
34
|
+
self._transport = transport or _httpx.HTTPTransport()
|
|
35
|
+
|
|
36
|
+
def handle_request(self, request: httpx.Request) -> httpx.Response:
|
|
37
|
+
headers = dict(request.headers)
|
|
38
|
+
body = request.content if request.content else None
|
|
39
|
+
timeout_ms = _timeout_ext_to_ms(request.extensions.get("timeout"))
|
|
40
|
+
capture = self._engine.capture_client_request(
|
|
41
|
+
method=request.method,
|
|
42
|
+
url=str(request.url),
|
|
43
|
+
headers=headers,
|
|
44
|
+
request_body=body,
|
|
45
|
+
timeout_ms=timeout_ms,
|
|
46
|
+
)
|
|
47
|
+
request.headers.update(headers)
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
response = self._transport.handle_request(request)
|
|
51
|
+
except BaseException as exc:
|
|
52
|
+
self._engine.emit_client_event_sync(capture, exception=exc)
|
|
53
|
+
raise
|
|
54
|
+
|
|
55
|
+
self._engine.emit_client_event_sync(
|
|
56
|
+
capture,
|
|
57
|
+
response_status_code=response.status_code,
|
|
58
|
+
response_headers=dict(response.headers),
|
|
59
|
+
# read() first: the transport returns an unread streaming response, so
|
|
60
|
+
# accessing .content directly raises ResponseNotRead (mirrors the async
|
|
61
|
+
# path's aread()). read() buffers the body so the caller can still use it.
|
|
62
|
+
response_body=response.read(),
|
|
63
|
+
)
|
|
64
|
+
return response
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class ObsforgeHTTPXAsyncTransport(_AsyncBaseTransport): # type: ignore[misc]
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
engine: APIRuntimeEngine,
|
|
71
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
72
|
+
) -> None:
|
|
73
|
+
if not _HAS_HTTPX: # pragma: no cover - optional dependency
|
|
74
|
+
raise RuntimeError("httpx is not installed")
|
|
75
|
+
self._engine = engine
|
|
76
|
+
self._transport = transport or _httpx.AsyncHTTPTransport()
|
|
77
|
+
|
|
78
|
+
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
|
|
79
|
+
headers = dict(request.headers)
|
|
80
|
+
body = request.content if request.content else None
|
|
81
|
+
timeout_ms = _timeout_ext_to_ms(request.extensions.get("timeout"))
|
|
82
|
+
capture = self._engine.capture_client_request(
|
|
83
|
+
method=request.method,
|
|
84
|
+
url=str(request.url),
|
|
85
|
+
headers=headers,
|
|
86
|
+
request_body=body,
|
|
87
|
+
timeout_ms=timeout_ms,
|
|
88
|
+
)
|
|
89
|
+
request.headers.update(headers)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
response = await self._transport.handle_async_request(request)
|
|
93
|
+
except BaseException as exc:
|
|
94
|
+
await self._engine.emit_client_event(capture, exception=exc)
|
|
95
|
+
raise
|
|
96
|
+
|
|
97
|
+
await self._engine.emit_client_event(
|
|
98
|
+
capture,
|
|
99
|
+
response_status_code=response.status_code,
|
|
100
|
+
response_headers=dict(response.headers),
|
|
101
|
+
response_body=await response.aread(),
|
|
102
|
+
)
|
|
103
|
+
return response
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def instrument_httpx_client(
|
|
107
|
+
client: httpx.Client, engine: APIRuntimeEngine
|
|
108
|
+
) -> httpx.Client:
|
|
109
|
+
if not _HAS_HTTPX: # pragma: no cover - optional dependency
|
|
110
|
+
raise RuntimeError("httpx is not installed")
|
|
111
|
+
client._transport = ObsforgeHTTPXTransport(engine, client._transport)
|
|
112
|
+
return client
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def instrument_httpx_async_client(
|
|
116
|
+
client: httpx.AsyncClient, engine: APIRuntimeEngine
|
|
117
|
+
) -> httpx.AsyncClient:
|
|
118
|
+
if not _HAS_HTTPX: # pragma: no cover - optional dependency
|
|
119
|
+
raise RuntimeError("httpx is not installed")
|
|
120
|
+
client._transport = ObsforgeHTTPXAsyncTransport(engine, client._transport)
|
|
121
|
+
return client
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _timeout_ext_to_ms(timeout: Any) -> float | None:
|
|
125
|
+
if timeout is None:
|
|
126
|
+
return None
|
|
127
|
+
if isinstance(timeout, dict):
|
|
128
|
+
values = [value for value in timeout.values() if isinstance(value, (int, float))]
|
|
129
|
+
return max(values) * 1000 if values else None
|
|
130
|
+
return None
|