openbox-sdk-python 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.
- openbox_core/__init__.py +59 -0
- openbox_core/adapters/__init__.py +3 -0
- openbox_core/adapters/base.py +123 -0
- openbox_core/approvals.py +106 -0
- openbox_core/client.py +298 -0
- openbox_core/config.py +260 -0
- openbox_core/conformance/__init__.py +3 -0
- openbox_core/conformance/fake_core.py +169 -0
- openbox_core/conformance/hook_preflight.py +87 -0
- openbox_core/conformance/instrumentation.py +91 -0
- openbox_core/context.py +205 -0
- openbox_core/contracts/__init__.py +3 -0
- openbox_core/contracts/context.py +79 -0
- openbox_core/contracts/events.py +401 -0
- openbox_core/contracts/otel_spans.py +325 -0
- openbox_core/contracts/results.py +287 -0
- openbox_core/errors.py +287 -0
- openbox_core/gate.py +185 -0
- openbox_core/hooks/__init__.py +3 -0
- openbox_core/hooks/events.py +64 -0
- openbox_core/hooks/preflight.py +292 -0
- openbox_core/hooks/wrappers.py +105 -0
- openbox_core/identity.py +231 -0
- openbox_core/instrumentation/__init__.py +3 -0
- openbox_core/instrumentation/db.py +689 -0
- openbox_core/instrumentation/file.py +239 -0
- openbox_core/instrumentation/function.py +121 -0
- openbox_core/instrumentation/http.py +840 -0
- openbox_core/instrumentation/llm.py +3 -0
- openbox_core/instrumentation/manager.py +135 -0
- openbox_core/instrumentation/shared.py +27 -0
- openbox_core/otel/__init__.py +3 -0
- openbox_core/otel/propagation.py +45 -0
- openbox_core/otel/provider.py +35 -0
- openbox_core/otel/setup.py +36 -0
- openbox_core/otel/span_processor.py +62 -0
- openbox_core/otel/trace_context.py +71 -0
- openbox_core/py.typed +0 -0
- openbox_core/runtime.py +138 -0
- openbox_core/sdk_version.py +79 -0
- openbox_core/serialization.py +129 -0
- openbox_core/validation/__init__.py +3 -0
- openbox_core/validation/diagnostics.py +60 -0
- openbox_core/validation/event_rules.py +164 -0
- openbox_core/validation/registry.py +31 -0
- openbox_core/validation/span_normalization.py +107 -0
- openbox_core/wire/__init__.py +3 -0
- openbox_core/wire/core_span.py +130 -0
- openbox_core/wire/evaluate_payload.py +56 -0
- openbox_sdk_python-0.2.0.dist-info/METADATA +94 -0
- openbox_sdk_python-0.2.0.dist-info/RECORD +52 -0
- openbox_sdk_python-0.2.0.dist-info/WHEEL +4 -0
|
@@ -0,0 +1,689 @@
|
|
|
1
|
+
"""DB wrappers — SQLAlchemy, DB-API (via OTel dbapi CursorTracer), asyncpg.
|
|
2
|
+
|
|
3
|
+
Blocking semantics: the started preflight runs BEFORE the driver executes the
|
|
4
|
+
statement; a BLOCK/HALT raises out of the listener/patch so the query never
|
|
5
|
+
reaches the database.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import threading
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from ..contracts.otel_spans import HookType
|
|
15
|
+
from ..otel.provider import get_tracer
|
|
16
|
+
from .shared import get_hook_runtime
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"install_sqlalchemy",
|
|
22
|
+
"uninstall_sqlalchemy",
|
|
23
|
+
"install_dbapi",
|
|
24
|
+
"uninstall_dbapi",
|
|
25
|
+
"install_asyncpg",
|
|
26
|
+
"uninstall_asyncpg",
|
|
27
|
+
"install_redis",
|
|
28
|
+
"uninstall_redis",
|
|
29
|
+
"install_pymongo",
|
|
30
|
+
"uninstall_pymongo",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
_SPAN_KEY = "_openbox_db_span"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _db_fields(
|
|
37
|
+
statement: str | None,
|
|
38
|
+
system: str,
|
|
39
|
+
operation: str | None = None,
|
|
40
|
+
*,
|
|
41
|
+
db_name: str | None = None,
|
|
42
|
+
server_address: str | None = None,
|
|
43
|
+
server_port: int | None = None,
|
|
44
|
+
) -> dict:
|
|
45
|
+
"""Assemble DB wire root fields.
|
|
46
|
+
|
|
47
|
+
``db_name``/``server_address``/``server_port`` are populated per driver
|
|
48
|
+
when available; the projection keeps the keys present even when a driver
|
|
49
|
+
exposes no value.
|
|
50
|
+
"""
|
|
51
|
+
op = operation
|
|
52
|
+
if op is None and statement:
|
|
53
|
+
op = statement.strip().split(" ", 1)[0].upper() if statement.strip() else None
|
|
54
|
+
port = server_port
|
|
55
|
+
if port is not None:
|
|
56
|
+
try:
|
|
57
|
+
port = int(port)
|
|
58
|
+
except (TypeError, ValueError):
|
|
59
|
+
port = None
|
|
60
|
+
return {
|
|
61
|
+
"db_system": system,
|
|
62
|
+
"db_name": str(db_name) if db_name else None,
|
|
63
|
+
"db_operation": op,
|
|
64
|
+
"db_statement": statement,
|
|
65
|
+
"server_address": server_address,
|
|
66
|
+
"server_port": port,
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _sqlalchemy_conn_meta(conn: Any) -> tuple[str, str | None, str | None, int | None]:
|
|
71
|
+
"""(dialect, db_name, host, port) from a SQLAlchemy Connection."""
|
|
72
|
+
engine = getattr(conn, "engine", None)
|
|
73
|
+
url = getattr(engine, "url", None)
|
|
74
|
+
dialect = getattr(getattr(conn, "dialect", None), "name", "sql") or "sql"
|
|
75
|
+
return (
|
|
76
|
+
dialect,
|
|
77
|
+
getattr(url, "database", None),
|
|
78
|
+
getattr(url, "host", None),
|
|
79
|
+
getattr(url, "port", None),
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _dbapi_conn_meta(tracer_self: Any) -> tuple[str, str | None, str | None, int | None]:
|
|
84
|
+
"""(db_system, db_name, host, port) from an OTel dbapi CursorTracer."""
|
|
85
|
+
integ = getattr(tracer_self, "_db_api_integration", None)
|
|
86
|
+
system = getattr(integ, "database_system", "sql") if integ else "sql"
|
|
87
|
+
db_name = getattr(integ, "database", None) if integ else None
|
|
88
|
+
props = getattr(integ, "connection_props", None) if integ else None
|
|
89
|
+
host = props.get("host") if isinstance(props, dict) else None
|
|
90
|
+
port = props.get("port") if isinstance(props, dict) else None
|
|
91
|
+
return system or "sql", db_name, host, port
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _asyncpg_conn_meta(conn_self: Any) -> tuple[str | None, str | None, int | None]:
|
|
95
|
+
"""(db_name, host, port) from an asyncpg Connection."""
|
|
96
|
+
addr = getattr(conn_self, "_addr", None)
|
|
97
|
+
host = addr[0] if isinstance(addr, (tuple, list)) and len(addr) >= 1 else None
|
|
98
|
+
port = addr[1] if isinstance(addr, (tuple, list)) and len(addr) >= 2 else None
|
|
99
|
+
params = getattr(conn_self, "_params", None)
|
|
100
|
+
db_name = getattr(params, "database", None) if params is not None else None
|
|
101
|
+
return db_name, host, port
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── SQLAlchemy (event listeners — no monkeypatching) ────────────────────────
|
|
105
|
+
|
|
106
|
+
_sqlalchemy_installed = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _before_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
|
110
|
+
runtime = get_hook_runtime()
|
|
111
|
+
if runtime is None:
|
|
112
|
+
return
|
|
113
|
+
span = get_tracer().start_span(f"db {statement.strip().split(' ', 1)[0].lower()}")
|
|
114
|
+
if context is not None:
|
|
115
|
+
setattr(context, _SPAN_KEY, span)
|
|
116
|
+
dialect, db_name, host, port = _sqlalchemy_conn_meta(conn)
|
|
117
|
+
# Raising here (BLOCK/HALT via adapter) aborts execution — the statement
|
|
118
|
+
# never reaches cursor.execute.
|
|
119
|
+
runtime.preflight(
|
|
120
|
+
span,
|
|
121
|
+
hook_type=HookType.DB_QUERY,
|
|
122
|
+
identifier=statement or "",
|
|
123
|
+
fields=_db_fields(
|
|
124
|
+
statement, dialect, db_name=db_name, server_address=host, server_port=port
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _after_cursor_execute(conn, cursor, statement, parameters, context, executemany):
|
|
130
|
+
runtime = get_hook_runtime()
|
|
131
|
+
if runtime is None:
|
|
132
|
+
return
|
|
133
|
+
span = getattr(context, _SPAN_KEY, None) if context is not None else None
|
|
134
|
+
if span is None:
|
|
135
|
+
return
|
|
136
|
+
try:
|
|
137
|
+
rowcount = getattr(cursor, "rowcount", None)
|
|
138
|
+
dialect, db_name, host, port = _sqlalchemy_conn_meta(conn)
|
|
139
|
+
span.end()
|
|
140
|
+
runtime.completed(
|
|
141
|
+
span,
|
|
142
|
+
hook_type=HookType.DB_QUERY,
|
|
143
|
+
fields={
|
|
144
|
+
**_db_fields(
|
|
145
|
+
statement, dialect, db_name=db_name, server_address=host, server_port=port
|
|
146
|
+
),
|
|
147
|
+
"rowcount": rowcount,
|
|
148
|
+
},
|
|
149
|
+
)
|
|
150
|
+
finally:
|
|
151
|
+
if context is not None and hasattr(context, _SPAN_KEY):
|
|
152
|
+
delattr(context, _SPAN_KEY)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _handle_error(exception_context) -> None:
|
|
156
|
+
runtime = get_hook_runtime()
|
|
157
|
+
if runtime is None:
|
|
158
|
+
return
|
|
159
|
+
context = getattr(exception_context, "execution_context", None)
|
|
160
|
+
span = getattr(context, _SPAN_KEY, None) if context is not None else None
|
|
161
|
+
if span is None:
|
|
162
|
+
return
|
|
163
|
+
try:
|
|
164
|
+
span.end()
|
|
165
|
+
statement = getattr(exception_context, "statement", None)
|
|
166
|
+
engine = getattr(exception_context, "engine", None)
|
|
167
|
+
url = getattr(engine, "url", None)
|
|
168
|
+
dialect = getattr(getattr(engine, "dialect", None), "name", "sql") or "sql"
|
|
169
|
+
runtime.completed(
|
|
170
|
+
span,
|
|
171
|
+
hook_type=HookType.DB_QUERY,
|
|
172
|
+
fields={
|
|
173
|
+
**_db_fields(
|
|
174
|
+
statement,
|
|
175
|
+
dialect,
|
|
176
|
+
db_name=getattr(url, "database", None),
|
|
177
|
+
server_address=getattr(url, "host", None),
|
|
178
|
+
server_port=getattr(url, "port", None),
|
|
179
|
+
),
|
|
180
|
+
"error": str(getattr(exception_context, "original_exception", "error")),
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
finally:
|
|
184
|
+
if context is not None and hasattr(context, _SPAN_KEY):
|
|
185
|
+
delattr(context, _SPAN_KEY)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def install_sqlalchemy() -> bool:
|
|
189
|
+
global _sqlalchemy_installed
|
|
190
|
+
if _sqlalchemy_installed:
|
|
191
|
+
return True
|
|
192
|
+
try:
|
|
193
|
+
from sqlalchemy import event
|
|
194
|
+
from sqlalchemy.engine import Engine
|
|
195
|
+
except ImportError:
|
|
196
|
+
logger.info("sqlalchemy not available (install extra [db]) — deferred")
|
|
197
|
+
return False
|
|
198
|
+
event.listen(Engine, "before_cursor_execute", _before_cursor_execute)
|
|
199
|
+
event.listen(Engine, "after_cursor_execute", _after_cursor_execute)
|
|
200
|
+
event.listen(Engine, "handle_error", _handle_error)
|
|
201
|
+
_sqlalchemy_installed = True
|
|
202
|
+
return True
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def uninstall_sqlalchemy() -> None:
|
|
206
|
+
global _sqlalchemy_installed
|
|
207
|
+
if not _sqlalchemy_installed:
|
|
208
|
+
return
|
|
209
|
+
try:
|
|
210
|
+
from sqlalchemy import event
|
|
211
|
+
from sqlalchemy.engine import Engine
|
|
212
|
+
|
|
213
|
+
event.remove(Engine, "before_cursor_execute", _before_cursor_execute)
|
|
214
|
+
event.remove(Engine, "after_cursor_execute", _after_cursor_execute)
|
|
215
|
+
event.remove(Engine, "handle_error", _handle_error)
|
|
216
|
+
except Exception:
|
|
217
|
+
logger.debug("sqlalchemy listener removal skipped", exc_info=True)
|
|
218
|
+
_sqlalchemy_installed = False
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
# ── DB-API (OTel dbapi CursorTracer — governs psycopg2/mysql/sqlite3) ───────
|
|
222
|
+
|
|
223
|
+
_original_traced_execution: Any = None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def install_dbapi() -> bool:
|
|
227
|
+
"""Patch CursorTracer.traced_execution so OTel-instrumented DB-API drivers
|
|
228
|
+
run through governance. Patch ordering: apply AFTER the OTel dbapi
|
|
229
|
+
instrumentors are set up (the manager guarantees this)."""
|
|
230
|
+
global _original_traced_execution
|
|
231
|
+
if _original_traced_execution is not None:
|
|
232
|
+
return True
|
|
233
|
+
try:
|
|
234
|
+
from opentelemetry.instrumentation import dbapi
|
|
235
|
+
except ImportError:
|
|
236
|
+
logger.info("opentelemetry dbapi instrumentation not available — deferred")
|
|
237
|
+
return False
|
|
238
|
+
|
|
239
|
+
_original_traced_execution = dbapi.CursorTracer.traced_execution
|
|
240
|
+
|
|
241
|
+
def governed_traced_execution(tracer_self, cursor, query_method, *args, **kwargs):
|
|
242
|
+
runtime = get_hook_runtime()
|
|
243
|
+
if runtime is None:
|
|
244
|
+
return _original_traced_execution(tracer_self, cursor, query_method, *args, **kwargs)
|
|
245
|
+
statement = tracer_self.get_statement(cursor, args) if args else ""
|
|
246
|
+
system_name, db_name, host, port = _dbapi_conn_meta(tracer_self)
|
|
247
|
+
span = get_tracer().start_span("db query")
|
|
248
|
+
|
|
249
|
+
def _fields() -> dict:
|
|
250
|
+
return _db_fields(
|
|
251
|
+
str(statement),
|
|
252
|
+
system_name,
|
|
253
|
+
db_name=db_name,
|
|
254
|
+
server_address=host,
|
|
255
|
+
server_port=port,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
runtime.preflight(
|
|
259
|
+
span,
|
|
260
|
+
hook_type=HookType.DB_QUERY,
|
|
261
|
+
identifier=str(statement),
|
|
262
|
+
fields=_fields(),
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
result = _original_traced_execution(tracer_self, cursor, query_method, *args, **kwargs)
|
|
266
|
+
except Exception as exc:
|
|
267
|
+
span.end()
|
|
268
|
+
runtime.completed(
|
|
269
|
+
span,
|
|
270
|
+
hook_type=HookType.DB_QUERY,
|
|
271
|
+
fields={**_fields(), "error": str(exc)},
|
|
272
|
+
)
|
|
273
|
+
raise
|
|
274
|
+
span.end()
|
|
275
|
+
runtime.completed(
|
|
276
|
+
span,
|
|
277
|
+
hook_type=HookType.DB_QUERY,
|
|
278
|
+
fields={**_fields(), "rowcount": getattr(cursor, "rowcount", None)},
|
|
279
|
+
)
|
|
280
|
+
return result
|
|
281
|
+
|
|
282
|
+
dbapi.CursorTracer.traced_execution = governed_traced_execution
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def uninstall_dbapi() -> None:
|
|
287
|
+
global _original_traced_execution
|
|
288
|
+
if _original_traced_execution is None:
|
|
289
|
+
return
|
|
290
|
+
try:
|
|
291
|
+
from opentelemetry.instrumentation import dbapi
|
|
292
|
+
|
|
293
|
+
dbapi.CursorTracer.traced_execution = _original_traced_execution
|
|
294
|
+
except Exception:
|
|
295
|
+
logger.debug("dbapi restore skipped", exc_info=True)
|
|
296
|
+
_original_traced_execution = None
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# ── asyncpg ──────────────────────────────────────────────────────────────────
|
|
300
|
+
|
|
301
|
+
_original_asyncpg_execute: Any = None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def install_asyncpg() -> bool:
|
|
305
|
+
"""Patch asyncpg.Connection._execute — the funnel every fetch/execute
|
|
306
|
+
variant goes through."""
|
|
307
|
+
global _original_asyncpg_execute
|
|
308
|
+
if _original_asyncpg_execute is not None:
|
|
309
|
+
return True
|
|
310
|
+
try:
|
|
311
|
+
from asyncpg import Connection
|
|
312
|
+
except ImportError:
|
|
313
|
+
logger.info("asyncpg not available (install extra [db]) — deferred")
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
_original_asyncpg_execute = Connection._execute
|
|
317
|
+
|
|
318
|
+
async def governed_execute(conn_self, query, *args, **kwargs):
|
|
319
|
+
runtime = get_hook_runtime()
|
|
320
|
+
if runtime is None:
|
|
321
|
+
return await _original_asyncpg_execute(conn_self, query, *args, **kwargs)
|
|
322
|
+
db_name, host, port = _asyncpg_conn_meta(conn_self)
|
|
323
|
+
span = get_tracer().start_span("db query")
|
|
324
|
+
|
|
325
|
+
def _fields() -> dict:
|
|
326
|
+
return _db_fields(
|
|
327
|
+
str(query),
|
|
328
|
+
"postgresql",
|
|
329
|
+
db_name=db_name,
|
|
330
|
+
server_address=host,
|
|
331
|
+
server_port=port,
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
await runtime.apreflight(
|
|
335
|
+
span,
|
|
336
|
+
hook_type=HookType.DB_QUERY,
|
|
337
|
+
identifier=str(query),
|
|
338
|
+
fields=_fields(),
|
|
339
|
+
)
|
|
340
|
+
try:
|
|
341
|
+
result = await _original_asyncpg_execute(conn_self, query, *args, **kwargs)
|
|
342
|
+
except Exception as exc:
|
|
343
|
+
span.end()
|
|
344
|
+
await runtime.acompleted(
|
|
345
|
+
span,
|
|
346
|
+
hook_type=HookType.DB_QUERY,
|
|
347
|
+
fields={**_fields(), "error": str(exc)},
|
|
348
|
+
)
|
|
349
|
+
raise
|
|
350
|
+
span.end()
|
|
351
|
+
await runtime.acompleted(
|
|
352
|
+
span,
|
|
353
|
+
hook_type=HookType.DB_QUERY,
|
|
354
|
+
fields=_fields(),
|
|
355
|
+
)
|
|
356
|
+
return result
|
|
357
|
+
|
|
358
|
+
Connection._execute = governed_execute
|
|
359
|
+
return True
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def uninstall_asyncpg() -> None:
|
|
363
|
+
global _original_asyncpg_execute
|
|
364
|
+
if _original_asyncpg_execute is None:
|
|
365
|
+
return
|
|
366
|
+
try:
|
|
367
|
+
from asyncpg import Connection
|
|
368
|
+
|
|
369
|
+
Connection._execute = _original_asyncpg_execute
|
|
370
|
+
except Exception:
|
|
371
|
+
logger.debug("asyncpg restore skipped", exc_info=True)
|
|
372
|
+
_original_asyncpg_execute = None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ── redis (OTel RedisInstrumentor request/response hooks) ────────────────────
|
|
376
|
+
|
|
377
|
+
_redis_installed = False
|
|
378
|
+
# span_id -> (command, statement) stashed at request so the response hook (which
|
|
379
|
+
# receives only span+response) can carry the same operation into completed.
|
|
380
|
+
_redis_pending: dict[int, tuple[str, str]] = {}
|
|
381
|
+
_REDIS_PENDING_MAX = 4096
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _span_id_of(span: Any) -> int | None:
|
|
385
|
+
try:
|
|
386
|
+
return span.get_span_context().span_id
|
|
387
|
+
except Exception:
|
|
388
|
+
return None
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def _redis_conn_meta(instance: Any) -> tuple[str | None, int | None, str | None]:
|
|
392
|
+
"""(host, port, db_name) from a redis client's connection pool."""
|
|
393
|
+
try:
|
|
394
|
+
kwargs = instance.connection_pool.connection_kwargs
|
|
395
|
+
return kwargs.get("host", "localhost"), kwargs.get("port", 6379), str(kwargs.get("db", 0))
|
|
396
|
+
except AttributeError:
|
|
397
|
+
return "localhost", 6379, "0"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _redis_request_hook(span: Any, instance: Any, args: Any, kwargs: Any) -> None:
|
|
401
|
+
runtime = get_hook_runtime()
|
|
402
|
+
if runtime is None:
|
|
403
|
+
return
|
|
404
|
+
command = str(args[0]) if args else "UNKNOWN"
|
|
405
|
+
statement = " ".join(str(a) for a in args) if args else ""
|
|
406
|
+
host, port, db_name = _redis_conn_meta(instance)
|
|
407
|
+
span_id = _span_id_of(span)
|
|
408
|
+
if span_id is not None:
|
|
409
|
+
if len(_redis_pending) >= _REDIS_PENDING_MAX:
|
|
410
|
+
_redis_pending.clear()
|
|
411
|
+
_redis_pending[span_id] = (command, statement)
|
|
412
|
+
# Raising (BLOCK/HALT via adapter) aborts before the command reaches redis.
|
|
413
|
+
runtime.preflight(
|
|
414
|
+
span,
|
|
415
|
+
hook_type=HookType.DB_QUERY,
|
|
416
|
+
identifier=statement or command,
|
|
417
|
+
fields=_db_fields(
|
|
418
|
+
statement, "redis", operation=command,
|
|
419
|
+
db_name=db_name, server_address=host, server_port=port,
|
|
420
|
+
),
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def _redis_response_hook(span: Any, instance: Any, response: Any) -> None:
|
|
425
|
+
runtime = get_hook_runtime()
|
|
426
|
+
if runtime is None:
|
|
427
|
+
return
|
|
428
|
+
host, port, db_name = _redis_conn_meta(instance)
|
|
429
|
+
span_id = _span_id_of(span)
|
|
430
|
+
command, statement = (
|
|
431
|
+
_redis_pending.pop(span_id, ("UNKNOWN", "")) if span_id is not None else ("UNKNOWN", "")
|
|
432
|
+
)
|
|
433
|
+
runtime.completed(
|
|
434
|
+
span,
|
|
435
|
+
hook_type=HookType.DB_QUERY,
|
|
436
|
+
fields=_db_fields(
|
|
437
|
+
statement, "redis", operation=command,
|
|
438
|
+
db_name=db_name, server_address=host, server_port=port,
|
|
439
|
+
),
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def install_redis() -> bool:
|
|
444
|
+
global _redis_installed
|
|
445
|
+
if _redis_installed:
|
|
446
|
+
return True
|
|
447
|
+
try:
|
|
448
|
+
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
|
449
|
+
except ImportError:
|
|
450
|
+
logger.info("redis instrumentation not available (install extra [db]) — deferred")
|
|
451
|
+
return False
|
|
452
|
+
RedisInstrumentor().instrument(
|
|
453
|
+
request_hook=_redis_request_hook, response_hook=_redis_response_hook
|
|
454
|
+
)
|
|
455
|
+
_redis_installed = True
|
|
456
|
+
return True
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def uninstall_redis() -> None:
|
|
460
|
+
global _redis_installed
|
|
461
|
+
if not _redis_installed:
|
|
462
|
+
return
|
|
463
|
+
try:
|
|
464
|
+
from opentelemetry.instrumentation.redis import RedisInstrumentor
|
|
465
|
+
|
|
466
|
+
RedisInstrumentor().uninstrument()
|
|
467
|
+
except Exception:
|
|
468
|
+
logger.debug("redis uninstrument skipped", exc_info=True)
|
|
469
|
+
_redis_pending.clear()
|
|
470
|
+
_redis_installed = False
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
# ── pymongo (CommandListener telemetry + wrapt Collection blocking) ──────────
|
|
474
|
+
#
|
|
475
|
+
# pymongo monitoring listeners are OBSERVE-ONLY — raising from them cannot stop
|
|
476
|
+
# a command. Blocking therefore rides on a wrapt wrapper around Collection CRUD
|
|
477
|
+
# methods (a Python-level wrapper whose raise propagates to the caller), while
|
|
478
|
+
# the CommandListener supplies telemetry for every OTHER command. A thread-local
|
|
479
|
+
# depth counter suppresses the listener while a wrapt-governed method is on the
|
|
480
|
+
# stack so one logical operation is never evaluated twice. The listener cannot
|
|
481
|
+
# be unregistered (pymongo has no public API); after uninstall it goes dormant
|
|
482
|
+
# because ``get_hook_runtime()`` returns None.
|
|
483
|
+
|
|
484
|
+
_pymongo_listener: Any = None
|
|
485
|
+
_pymongo_wrapt_installed = False
|
|
486
|
+
_pymongo_wrapt_depth = threading.local()
|
|
487
|
+
# request_id -> (span, command string) correlating started with succeeded/failed.
|
|
488
|
+
_pymongo_pending: dict[int, tuple[Any, str]] = {}
|
|
489
|
+
_PYMONGO_PENDING_MAX = 4096
|
|
490
|
+
_pymongo_patched: list[tuple[str, str]] = []
|
|
491
|
+
|
|
492
|
+
_PYMONGO_METHODS = (
|
|
493
|
+
"find", "find_one", "insert_one", "insert_many",
|
|
494
|
+
"update_one", "update_many", "delete_one", "delete_many",
|
|
495
|
+
"aggregate", "count_documents",
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
def _pymongo_address(event: Any) -> tuple[str | None, int | None]:
|
|
500
|
+
try:
|
|
501
|
+
addr = event.connection_id
|
|
502
|
+
if addr and len(addr) >= 2:
|
|
503
|
+
return str(addr[0]), int(addr[1])
|
|
504
|
+
except (AttributeError, TypeError, IndexError):
|
|
505
|
+
pass
|
|
506
|
+
return None, 27017
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _pymongo_collection_address(instance: Any) -> tuple[str | None, int | None]:
|
|
510
|
+
# ``client.address`` triggers pymongo server selection (a live connection).
|
|
511
|
+
# Catch broadly (incl. ServerSelectionTimeoutError) so metadata resolution
|
|
512
|
+
# never blocks or crashes governance — call only AFTER an op has run, when
|
|
513
|
+
# the address is already cached.
|
|
514
|
+
try:
|
|
515
|
+
address = instance.database.client.address
|
|
516
|
+
if address:
|
|
517
|
+
return address[0], int(address[1])
|
|
518
|
+
except Exception:
|
|
519
|
+
pass
|
|
520
|
+
return None, 27017
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def install_pymongo() -> bool:
|
|
524
|
+
global _pymongo_listener
|
|
525
|
+
installed = False
|
|
526
|
+
if _pymongo_listener is None:
|
|
527
|
+
try:
|
|
528
|
+
import pymongo.monitoring
|
|
529
|
+
|
|
530
|
+
class _GovernanceCommandListener(pymongo.monitoring.CommandListener):
|
|
531
|
+
def started(self, event: Any) -> None:
|
|
532
|
+
if getattr(_pymongo_wrapt_depth, "value", 0) > 0:
|
|
533
|
+
return
|
|
534
|
+
runtime = get_hook_runtime()
|
|
535
|
+
if runtime is None:
|
|
536
|
+
return
|
|
537
|
+
try:
|
|
538
|
+
span = get_tracer().start_span(f"mongodb {event.command_name}")
|
|
539
|
+
host, port = _pymongo_address(event)
|
|
540
|
+
cmd_str = str(event.command)[:2000]
|
|
541
|
+
if len(_pymongo_pending) >= _PYMONGO_PENDING_MAX:
|
|
542
|
+
_pymongo_pending.clear()
|
|
543
|
+
_pymongo_pending[event.request_id] = (span, cmd_str)
|
|
544
|
+
# A BLOCK sets the abort flag (future ops) before raising;
|
|
545
|
+
# the listener cannot stop THIS command, so swallow so the
|
|
546
|
+
# pymongo monitoring loop is never crashed.
|
|
547
|
+
runtime.preflight(
|
|
548
|
+
span,
|
|
549
|
+
hook_type=HookType.DB_QUERY,
|
|
550
|
+
identifier=cmd_str,
|
|
551
|
+
fields=_db_fields(
|
|
552
|
+
cmd_str, "mongodb", operation=event.command_name,
|
|
553
|
+
db_name=event.database_name, server_address=host, server_port=port,
|
|
554
|
+
),
|
|
555
|
+
)
|
|
556
|
+
except Exception:
|
|
557
|
+
logger.debug("pymongo started governance error", exc_info=True)
|
|
558
|
+
|
|
559
|
+
def succeeded(self, event: Any) -> None:
|
|
560
|
+
self._completed(event)
|
|
561
|
+
|
|
562
|
+
def failed(self, event: Any) -> None:
|
|
563
|
+
self._completed(event, error=str(getattr(event, "failure", "error")))
|
|
564
|
+
|
|
565
|
+
def _completed(self, event: Any, error: str | None = None) -> None:
|
|
566
|
+
if getattr(_pymongo_wrapt_depth, "value", 0) > 0:
|
|
567
|
+
_pymongo_pending.pop(event.request_id, None)
|
|
568
|
+
return
|
|
569
|
+
runtime = get_hook_runtime()
|
|
570
|
+
span, cmd_str = _pymongo_pending.pop(
|
|
571
|
+
event.request_id, (None, event.command_name)
|
|
572
|
+
)
|
|
573
|
+
if runtime is None:
|
|
574
|
+
return
|
|
575
|
+
if span is None:
|
|
576
|
+
span = get_tracer().start_span(f"mongodb {event.command_name}")
|
|
577
|
+
host, port = _pymongo_address(event)
|
|
578
|
+
try:
|
|
579
|
+
span.end()
|
|
580
|
+
except Exception:
|
|
581
|
+
pass
|
|
582
|
+
fields = _db_fields(
|
|
583
|
+
cmd_str, "mongodb", operation=event.command_name,
|
|
584
|
+
db_name=event.database_name, server_address=host, server_port=port,
|
|
585
|
+
)
|
|
586
|
+
if error:
|
|
587
|
+
fields["error"] = error
|
|
588
|
+
runtime.completed(span, hook_type=HookType.DB_QUERY, fields=fields)
|
|
589
|
+
|
|
590
|
+
_pymongo_listener = _GovernanceCommandListener()
|
|
591
|
+
pymongo.monitoring.register(_pymongo_listener)
|
|
592
|
+
installed = True
|
|
593
|
+
except ImportError:
|
|
594
|
+
logger.info("pymongo not available (install extra [db]) — deferred")
|
|
595
|
+
else:
|
|
596
|
+
installed = True
|
|
597
|
+
|
|
598
|
+
if _install_pymongo_wrapt():
|
|
599
|
+
installed = True
|
|
600
|
+
return installed
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _install_pymongo_wrapt() -> bool:
|
|
604
|
+
global _pymongo_wrapt_installed
|
|
605
|
+
if _pymongo_wrapt_installed:
|
|
606
|
+
return True
|
|
607
|
+
try:
|
|
608
|
+
import wrapt
|
|
609
|
+
except ImportError:
|
|
610
|
+
logger.debug("wrapt not available — pymongo blocking disabled")
|
|
611
|
+
return False
|
|
612
|
+
|
|
613
|
+
def _collection_wrapper(wrapped, instance, args, kwargs):
|
|
614
|
+
depth = getattr(_pymongo_wrapt_depth, "value", 0)
|
|
615
|
+
_pymongo_wrapt_depth.value = depth + 1
|
|
616
|
+
try:
|
|
617
|
+
if depth > 0:
|
|
618
|
+
return wrapped(*args, **kwargs)
|
|
619
|
+
runtime = get_hook_runtime()
|
|
620
|
+
if runtime is None:
|
|
621
|
+
return wrapped(*args, **kwargs)
|
|
622
|
+
db_name = getattr(getattr(instance, "database", None), "name", None)
|
|
623
|
+
operation = getattr(wrapped, "__name__", "query")
|
|
624
|
+
statement = f"{getattr(instance, 'name', '?')}.{operation}"
|
|
625
|
+
span = get_tracer().start_span(f"mongodb {operation}")
|
|
626
|
+
|
|
627
|
+
def _fields(host: Any = None, port: Any = None) -> dict:
|
|
628
|
+
return _db_fields(
|
|
629
|
+
statement, "mongodb", operation=operation,
|
|
630
|
+
db_name=db_name, server_address=host, server_port=port,
|
|
631
|
+
)
|
|
632
|
+
|
|
633
|
+
# Preflight WITHOUT resolving the server address — doing so would
|
|
634
|
+
# trigger a connection and defeat block-before-connect. A BLOCK here
|
|
635
|
+
# raises before the driver runs (and connects).
|
|
636
|
+
runtime.preflight(
|
|
637
|
+
span, hook_type=HookType.DB_QUERY, identifier=statement, fields=_fields()
|
|
638
|
+
)
|
|
639
|
+
try:
|
|
640
|
+
result = wrapped(*args, **kwargs)
|
|
641
|
+
except Exception as exc:
|
|
642
|
+
span.end()
|
|
643
|
+
runtime.completed(
|
|
644
|
+
span, hook_type=HookType.DB_QUERY, fields={**_fields(), "error": str(exc)}
|
|
645
|
+
)
|
|
646
|
+
raise
|
|
647
|
+
span.end()
|
|
648
|
+
# Address is cached now the op has run — safe to resolve for telemetry.
|
|
649
|
+
host, port = _pymongo_collection_address(instance)
|
|
650
|
+
runtime.completed(span, hook_type=HookType.DB_QUERY, fields=_fields(host, port))
|
|
651
|
+
return result
|
|
652
|
+
finally:
|
|
653
|
+
_pymongo_wrapt_depth.value = getattr(_pymongo_wrapt_depth, "value", 1) - 1
|
|
654
|
+
|
|
655
|
+
patched = 0
|
|
656
|
+
for method in _PYMONGO_METHODS:
|
|
657
|
+
try:
|
|
658
|
+
wrapt.wrap_function_wrapper(
|
|
659
|
+
"pymongo.collection", f"Collection.{method}", _collection_wrapper
|
|
660
|
+
)
|
|
661
|
+
_pymongo_patched.append(("pymongo.collection", f"Collection.{method}"))
|
|
662
|
+
patched += 1
|
|
663
|
+
except (AttributeError, TypeError, ImportError):
|
|
664
|
+
pass
|
|
665
|
+
_pymongo_wrapt_installed = patched > 0
|
|
666
|
+
return _pymongo_wrapt_installed
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def uninstall_pymongo() -> None:
|
|
670
|
+
"""Remove wrapt Collection patches and clear pending state. The monitoring
|
|
671
|
+
listener stays registered (no pymongo deregister API) but goes dormant once
|
|
672
|
+
the hook runtime is cleared."""
|
|
673
|
+
global _pymongo_wrapt_installed
|
|
674
|
+
import importlib
|
|
675
|
+
|
|
676
|
+
for module, name in _pymongo_patched:
|
|
677
|
+
try:
|
|
678
|
+
mod = importlib.import_module(module)
|
|
679
|
+
cls_name, meth = name.split(".")
|
|
680
|
+
cls = getattr(mod, cls_name)
|
|
681
|
+
bound = getattr(cls, meth)
|
|
682
|
+
original = getattr(bound, "__wrapped__", None)
|
|
683
|
+
if original is not None:
|
|
684
|
+
setattr(cls, meth, original)
|
|
685
|
+
except Exception:
|
|
686
|
+
logger.debug("pymongo wrapt removal skipped", exc_info=True)
|
|
687
|
+
_pymongo_patched.clear()
|
|
688
|
+
_pymongo_pending.clear()
|
|
689
|
+
_pymongo_wrapt_installed = False
|