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,123 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from threading import Lock
|
|
6
|
+
|
|
7
|
+
from obsforge.config.settings import DatabaseInstrumentationSettings
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class PoolSnapshot:
|
|
12
|
+
pool_name: str
|
|
13
|
+
pool_size: int | None = None
|
|
14
|
+
checked_out: int | None = None
|
|
15
|
+
available: int | None = None
|
|
16
|
+
waiters: int | None = None
|
|
17
|
+
saturated: bool = False
|
|
18
|
+
leak_suspected: bool = False
|
|
19
|
+
checkout_wait_ms: float | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class CheckoutState:
|
|
24
|
+
checked_out_at: float
|
|
25
|
+
connection_id: str
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PoolTracker:
|
|
29
|
+
def __init__(self, settings: DatabaseInstrumentationSettings) -> None:
|
|
30
|
+
self._settings = settings
|
|
31
|
+
self._states: dict[str, dict[str, CheckoutState]] = {}
|
|
32
|
+
self._lock = Lock()
|
|
33
|
+
|
|
34
|
+
def checkout(
|
|
35
|
+
self,
|
|
36
|
+
*,
|
|
37
|
+
pool_name: str,
|
|
38
|
+
connection_id: str,
|
|
39
|
+
pool_size: int | None = None,
|
|
40
|
+
checked_out: int | None = None,
|
|
41
|
+
available: int | None = None,
|
|
42
|
+
waiters: int | None = None,
|
|
43
|
+
checkout_wait_ms: float | None = None,
|
|
44
|
+
) -> PoolSnapshot:
|
|
45
|
+
with self._lock:
|
|
46
|
+
pool = self._states.setdefault(pool_name, {})
|
|
47
|
+
pool[connection_id] = CheckoutState(checked_out_at=time.perf_counter(), connection_id=connection_id)
|
|
48
|
+
checked_out_now = checked_out if checked_out is not None else len(pool)
|
|
49
|
+
available_now = available if available is not None else (
|
|
50
|
+
max(pool_size - checked_out_now, 0) if pool_size is not None else None
|
|
51
|
+
)
|
|
52
|
+
waiters_now = waiters
|
|
53
|
+
return PoolSnapshot(
|
|
54
|
+
pool_name=pool_name,
|
|
55
|
+
pool_size=pool_size,
|
|
56
|
+
checked_out=checked_out_now,
|
|
57
|
+
available=available_now,
|
|
58
|
+
waiters=waiters_now,
|
|
59
|
+
saturated=(waiters_now or 0) >= self._settings.pool_exhaustion_waiters_threshold
|
|
60
|
+
or (available_now == 0 and checked_out_now > 0 if available_now is not None else False),
|
|
61
|
+
leak_suspected=self._leak_suspected(pool),
|
|
62
|
+
checkout_wait_ms=checkout_wait_ms,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
def checkin(
|
|
66
|
+
self,
|
|
67
|
+
*,
|
|
68
|
+
pool_name: str,
|
|
69
|
+
connection_id: str,
|
|
70
|
+
pool_size: int | None = None,
|
|
71
|
+
checked_out: int | None = None,
|
|
72
|
+
available: int | None = None,
|
|
73
|
+
waiters: int | None = None,
|
|
74
|
+
) -> PoolSnapshot:
|
|
75
|
+
with self._lock:
|
|
76
|
+
pool = self._states.setdefault(pool_name, {})
|
|
77
|
+
pool.pop(connection_id, None)
|
|
78
|
+
checked_out_now = checked_out if checked_out is not None else len(pool)
|
|
79
|
+
available_now = available if available is not None else (
|
|
80
|
+
max(pool_size - checked_out_now, 0) if pool_size is not None else None
|
|
81
|
+
)
|
|
82
|
+
return PoolSnapshot(
|
|
83
|
+
pool_name=pool_name,
|
|
84
|
+
pool_size=pool_size,
|
|
85
|
+
checked_out=checked_out_now,
|
|
86
|
+
available=available_now,
|
|
87
|
+
waiters=waiters,
|
|
88
|
+
saturated=(waiters or 0) >= self._settings.pool_exhaustion_waiters_threshold,
|
|
89
|
+
leak_suspected=self._leak_suspected(pool),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
def snapshot(
|
|
93
|
+
self,
|
|
94
|
+
*,
|
|
95
|
+
pool_name: str,
|
|
96
|
+
pool_size: int | None = None,
|
|
97
|
+
checked_out: int | None = None,
|
|
98
|
+
available: int | None = None,
|
|
99
|
+
waiters: int | None = None,
|
|
100
|
+
) -> PoolSnapshot:
|
|
101
|
+
with self._lock:
|
|
102
|
+
pool = self._states.setdefault(pool_name, {})
|
|
103
|
+
checked_out_now = checked_out if checked_out is not None else len(pool)
|
|
104
|
+
available_now = available if available is not None else (
|
|
105
|
+
max(pool_size - checked_out_now, 0) if pool_size is not None else None
|
|
106
|
+
)
|
|
107
|
+
return PoolSnapshot(
|
|
108
|
+
pool_name=pool_name,
|
|
109
|
+
pool_size=pool_size,
|
|
110
|
+
checked_out=checked_out_now,
|
|
111
|
+
available=available_now,
|
|
112
|
+
waiters=waiters,
|
|
113
|
+
saturated=(waiters or 0) >= self._settings.pool_exhaustion_waiters_threshold
|
|
114
|
+
or (available_now == 0 and checked_out_now > 0 if available_now is not None else False),
|
|
115
|
+
leak_suspected=self._leak_suspected(pool),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _leak_suspected(self, pool: dict[str, CheckoutState]) -> bool:
|
|
119
|
+
now = time.perf_counter()
|
|
120
|
+
for state in pool.values():
|
|
121
|
+
if (now - state.checked_out_at) * 1000 >= self._settings.leak_checkout_threshold_ms:
|
|
122
|
+
return True
|
|
123
|
+
return False
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from types import ModuleType
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from obsforge.instrumentation.db.engine import DBObservabilityEngine
|
|
7
|
+
|
|
8
|
+
psycopg: ModuleType | None
|
|
9
|
+
try: # pragma: no cover - optional dependency
|
|
10
|
+
import psycopg
|
|
11
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
12
|
+
psycopg = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class PsycopgCursorProxy:
|
|
16
|
+
def __init__(self, cursor: Any, connection_proxy: PsycopgConnectionProxy) -> None:
|
|
17
|
+
self._cursor = cursor
|
|
18
|
+
self._connection_proxy = connection_proxy
|
|
19
|
+
|
|
20
|
+
def execute(self, query: str, params: Any = None) -> Any:
|
|
21
|
+
capture = self._connection_proxy._db_engine.start_query(
|
|
22
|
+
system="postgresql",
|
|
23
|
+
sql=query,
|
|
24
|
+
database_name=getattr(self._connection_proxy._connection.info, "dbname", None),
|
|
25
|
+
host=getattr(self._connection_proxy._connection.info, "host", None),
|
|
26
|
+
port=getattr(self._connection_proxy._connection.info, "port", None),
|
|
27
|
+
role="primary",
|
|
28
|
+
connection_id=str(id(self._connection_proxy._connection)),
|
|
29
|
+
many=False,
|
|
30
|
+
namespace=getattr(self._connection_proxy._connection.info, "dbname", None),
|
|
31
|
+
)
|
|
32
|
+
try:
|
|
33
|
+
result = self._cursor.execute(query, params)
|
|
34
|
+
except BaseException as exc:
|
|
35
|
+
self._connection_proxy._db_engine.emit_query_sync(capture, exception=exc)
|
|
36
|
+
raise
|
|
37
|
+
self._connection_proxy._db_engine.emit_query_sync(
|
|
38
|
+
capture,
|
|
39
|
+
rows_affected=getattr(self._cursor, "rowcount", None),
|
|
40
|
+
transaction_state=self._connection_proxy._db_engine.transaction_state(
|
|
41
|
+
str(id(self._connection_proxy._connection))
|
|
42
|
+
),
|
|
43
|
+
)
|
|
44
|
+
return result
|
|
45
|
+
|
|
46
|
+
def executemany(self, query: str, params_seq: Any) -> Any:
|
|
47
|
+
capture = self._connection_proxy._db_engine.start_query(
|
|
48
|
+
system="postgresql",
|
|
49
|
+
sql=query,
|
|
50
|
+
database_name=getattr(self._connection_proxy._connection.info, "dbname", None),
|
|
51
|
+
host=getattr(self._connection_proxy._connection.info, "host", None),
|
|
52
|
+
port=getattr(self._connection_proxy._connection.info, "port", None),
|
|
53
|
+
role="primary",
|
|
54
|
+
connection_id=str(id(self._connection_proxy._connection)),
|
|
55
|
+
many=True,
|
|
56
|
+
namespace=getattr(self._connection_proxy._connection.info, "dbname", None),
|
|
57
|
+
)
|
|
58
|
+
try:
|
|
59
|
+
result = self._cursor.executemany(query, params_seq)
|
|
60
|
+
except BaseException as exc:
|
|
61
|
+
self._connection_proxy._db_engine.emit_query_sync(capture, exception=exc)
|
|
62
|
+
raise
|
|
63
|
+
self._connection_proxy._db_engine.emit_query_sync(
|
|
64
|
+
capture,
|
|
65
|
+
rows_affected=getattr(self._cursor, "rowcount", None),
|
|
66
|
+
transaction_state=self._connection_proxy._db_engine.transaction_state(
|
|
67
|
+
str(id(self._connection_proxy._connection))
|
|
68
|
+
),
|
|
69
|
+
)
|
|
70
|
+
return result
|
|
71
|
+
|
|
72
|
+
def __getattr__(self, name: str) -> Any:
|
|
73
|
+
return getattr(self._cursor, name)
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> PsycopgCursorProxy:
|
|
76
|
+
self._cursor.__enter__()
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
|
|
80
|
+
return self._cursor.__exit__(exc_type, exc, tb)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class PsycopgConnectionProxy:
|
|
84
|
+
def __init__(self, connection: Any, db_engine: DBObservabilityEngine) -> None:
|
|
85
|
+
self._connection = connection
|
|
86
|
+
self._db_engine = db_engine
|
|
87
|
+
|
|
88
|
+
def cursor(self, *args: Any, **kwargs: Any) -> PsycopgCursorProxy:
|
|
89
|
+
return PsycopgCursorProxy(self._connection.cursor(*args, **kwargs), self)
|
|
90
|
+
|
|
91
|
+
def execute(self, query: str, params: Any = None) -> Any:
|
|
92
|
+
with self.cursor() as cursor:
|
|
93
|
+
return cursor.execute(query, params)
|
|
94
|
+
|
|
95
|
+
def commit(self) -> Any:
|
|
96
|
+
self._db_engine.transaction_commit(str(id(self._connection)))
|
|
97
|
+
return self._connection.commit()
|
|
98
|
+
|
|
99
|
+
def rollback(self) -> Any:
|
|
100
|
+
self._db_engine.transaction_rollback(str(id(self._connection)))
|
|
101
|
+
return self._connection.rollback()
|
|
102
|
+
|
|
103
|
+
def transaction_begin(self) -> None:
|
|
104
|
+
self._db_engine.transaction_begin(
|
|
105
|
+
str(id(self._connection)),
|
|
106
|
+
isolation_level=getattr(self._connection, "isolation_level", None),
|
|
107
|
+
autocommit=getattr(self._connection, "autocommit", None),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def __getattr__(self, name: str) -> Any:
|
|
111
|
+
return getattr(self._connection, name)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def instrument_psycopg_connection(connection: Any, db_engine: DBObservabilityEngine) -> PsycopgConnectionProxy:
|
|
115
|
+
return PsycopgConnectionProxy(connection, db_engine)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def instrument_psycopg_pool(pool: Any, db_engine: DBObservabilityEngine) -> Any:
|
|
119
|
+
original_connection = pool.connection
|
|
120
|
+
|
|
121
|
+
def connection(*args: Any, **kwargs: Any) -> Any:
|
|
122
|
+
context_manager = original_connection(*args, **kwargs)
|
|
123
|
+
|
|
124
|
+
class ConnectionContextProxy:
|
|
125
|
+
def __enter__(self) -> PsycopgConnectionProxy:
|
|
126
|
+
raw = context_manager.__enter__()
|
|
127
|
+
stats = _pool_stats(pool)
|
|
128
|
+
db_engine.pool_checkout(
|
|
129
|
+
pool_name=type(pool).__name__,
|
|
130
|
+
connection_id=str(id(raw)),
|
|
131
|
+
pool_size=stats.get("pool_size"),
|
|
132
|
+
available=stats.get("pool_available"),
|
|
133
|
+
waiters=stats.get("requests_waiting"),
|
|
134
|
+
)
|
|
135
|
+
return instrument_psycopg_connection(raw, db_engine)
|
|
136
|
+
|
|
137
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> Any:
|
|
138
|
+
result = context_manager.__exit__(exc_type, exc, tb)
|
|
139
|
+
stats = _pool_stats(pool)
|
|
140
|
+
raw_id = None
|
|
141
|
+
if hasattr(context_manager, "__dict__"):
|
|
142
|
+
raw = context_manager.__dict__.get("_conn")
|
|
143
|
+
raw_id = str(id(raw)) if raw is not None else None
|
|
144
|
+
if raw_id is not None:
|
|
145
|
+
db_engine.pool_checkin(
|
|
146
|
+
pool_name=type(pool).__name__,
|
|
147
|
+
connection_id=raw_id,
|
|
148
|
+
pool_size=stats.get("pool_size"),
|
|
149
|
+
available=stats.get("pool_available"),
|
|
150
|
+
waiters=stats.get("requests_waiting"),
|
|
151
|
+
)
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
return ConnectionContextProxy()
|
|
155
|
+
|
|
156
|
+
pool.connection = connection
|
|
157
|
+
return pool
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _pool_stats(pool: Any) -> dict[str, Any]:
|
|
161
|
+
getter = getattr(pool, "get_stats", None)
|
|
162
|
+
if callable(getter):
|
|
163
|
+
try:
|
|
164
|
+
stats: dict[str, Any] = getter()
|
|
165
|
+
return stats
|
|
166
|
+
except Exception:
|
|
167
|
+
return {}
|
|
168
|
+
return {}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from hashlib import sha1
|
|
5
|
+
|
|
6
|
+
from obsforge.config.settings import DatabaseInstrumentationSettings
|
|
7
|
+
|
|
8
|
+
STRING_RE = re.compile(r"'(?:''|[^'])*'")
|
|
9
|
+
NUMBER_RE = re.compile(r"\b\d+(?:\.\d+)?\b")
|
|
10
|
+
UUID_RE = re.compile(
|
|
11
|
+
r"\b[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b",
|
|
12
|
+
re.IGNORECASE,
|
|
13
|
+
)
|
|
14
|
+
WS_RE = re.compile(r"\s+")
|
|
15
|
+
IN_RE = re.compile(r"\bin\s*\(([^)]+)\)", re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SQLNormalizer:
|
|
19
|
+
def __init__(self, settings: DatabaseInstrumentationSettings) -> None:
|
|
20
|
+
self._settings = settings
|
|
21
|
+
|
|
22
|
+
def normalize(self, sql: str) -> str:
|
|
23
|
+
normalized = UUID_RE.sub(self._settings.redacted_literals_placeholder, sql)
|
|
24
|
+
normalized = STRING_RE.sub(self._settings.redacted_literals_placeholder, normalized)
|
|
25
|
+
normalized = NUMBER_RE.sub(self._settings.redacted_literals_placeholder, normalized)
|
|
26
|
+
normalized = IN_RE.sub("IN (...)", normalized)
|
|
27
|
+
normalized = WS_RE.sub(" ", normalized).strip()
|
|
28
|
+
if len(normalized) > self._settings.max_normalized_query_chars:
|
|
29
|
+
normalized = normalized[: self._settings.max_normalized_query_chars - 3] + "..."
|
|
30
|
+
return normalized
|
|
31
|
+
|
|
32
|
+
def fingerprint(self, sql: str) -> str:
|
|
33
|
+
return sha1(self.normalize(sql).lower().encode("utf-8")).hexdigest()
|
|
34
|
+
|
|
35
|
+
def summary(self, sql: str) -> str:
|
|
36
|
+
normalized = self.normalize(sql)
|
|
37
|
+
parts = normalized.split(" ", 3)
|
|
38
|
+
if not parts:
|
|
39
|
+
return "unknown query"
|
|
40
|
+
operation = parts[0].upper()
|
|
41
|
+
if operation == "SELECT":
|
|
42
|
+
return self._select_summary(normalized)
|
|
43
|
+
if operation in {"UPDATE", "INSERT", "DELETE"}:
|
|
44
|
+
target = parts[1] if len(parts) > 1 else "unknown"
|
|
45
|
+
return f"{operation} {target}"
|
|
46
|
+
if operation in {"BEGIN", "COMMIT", "ROLLBACK", "SAVEPOINT", "RELEASE"}:
|
|
47
|
+
return operation
|
|
48
|
+
return " ".join(parts[:2])
|
|
49
|
+
|
|
50
|
+
@staticmethod
|
|
51
|
+
def operation(sql: str) -> str:
|
|
52
|
+
stripped = sql.lstrip()
|
|
53
|
+
if not stripped:
|
|
54
|
+
return "UNKNOWN"
|
|
55
|
+
return stripped.split(" ", 1)[0].upper()
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _select_summary(sql: str) -> str:
|
|
59
|
+
lower = sql.lower()
|
|
60
|
+
from_index = lower.find(" from ")
|
|
61
|
+
if from_index == -1:
|
|
62
|
+
return "SELECT"
|
|
63
|
+
after_from = sql[from_index + 6 :]
|
|
64
|
+
table = after_from.split(" ", 1)[0]
|
|
65
|
+
return f"SELECT {table}"
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable
|
|
4
|
+
from typing import Any, TypeVar, cast
|
|
5
|
+
|
|
6
|
+
from obsforge.instrumentation.db.engine import DBObservabilityEngine
|
|
7
|
+
|
|
8
|
+
event: Any
|
|
9
|
+
try: # pragma: no cover - optional dependency
|
|
10
|
+
from sqlalchemy import event
|
|
11
|
+
except ImportError: # pragma: no cover - optional dependency
|
|
12
|
+
event = None
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_F = TypeVar("_F", bound=Callable[..., Any])
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _listens_for(target: Any, identifier: str) -> Callable[[_F], _F]:
|
|
19
|
+
"""Typed wrapper around ``sqlalchemy.event.listens_for``.
|
|
20
|
+
|
|
21
|
+
SQLAlchemy returns the decorated function unchanged, so this preserves
|
|
22
|
+
runtime behavior while giving mypy a typed decorator.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
return cast("Callable[[_F], _F]", event.listens_for(target, identifier))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if event is not None:
|
|
29
|
+
def instrument_sqlalchemy_engine(engine: Any, db_engine: DBObservabilityEngine) -> Any:
|
|
30
|
+
target = getattr(engine, "sync_engine", engine)
|
|
31
|
+
sa_pool = getattr(target, "pool", None)
|
|
32
|
+
|
|
33
|
+
@_listens_for(target, "before_cursor_execute")
|
|
34
|
+
def before_cursor_execute(
|
|
35
|
+
conn: Any,
|
|
36
|
+
cursor: Any,
|
|
37
|
+
statement: Any,
|
|
38
|
+
parameters: Any,
|
|
39
|
+
context: Any,
|
|
40
|
+
executemany: Any,
|
|
41
|
+
) -> None:
|
|
42
|
+
conn.info["_obsforge_db_capture"] = db_engine.start_query(
|
|
43
|
+
system=target.dialect.name,
|
|
44
|
+
sql=statement,
|
|
45
|
+
alias=getattr(target.url, "database", None),
|
|
46
|
+
database_name=getattr(target.url, "database", None),
|
|
47
|
+
host=getattr(target.url, "host", None),
|
|
48
|
+
port=getattr(target.url, "port", None),
|
|
49
|
+
role="primary",
|
|
50
|
+
connection_id=str(id(conn.connection)),
|
|
51
|
+
pool_name=type(sa_pool).__name__ if sa_pool is not None else None,
|
|
52
|
+
many=executemany,
|
|
53
|
+
read_only=getattr(context, "isinsert", False) is False and getattr(context, "isupdate", False) is False,
|
|
54
|
+
namespace=getattr(target.url, "database", None),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
@_listens_for(target, "after_cursor_execute")
|
|
58
|
+
def after_cursor_execute(
|
|
59
|
+
conn: Any,
|
|
60
|
+
cursor: Any,
|
|
61
|
+
statement: Any,
|
|
62
|
+
parameters: Any,
|
|
63
|
+
context: Any,
|
|
64
|
+
executemany: Any,
|
|
65
|
+
) -> None:
|
|
66
|
+
capture = conn.info.pop("_obsforge_db_capture", None)
|
|
67
|
+
if capture is None:
|
|
68
|
+
return
|
|
69
|
+
db_engine.emit_query_sync(
|
|
70
|
+
capture,
|
|
71
|
+
rows_affected=getattr(cursor, "rowcount", None),
|
|
72
|
+
pool_snapshot=_pool_snapshot(db_engine, sa_pool),
|
|
73
|
+
transaction_state=db_engine.transaction_state(str(id(conn.connection))),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
@_listens_for(target, "handle_error")
|
|
77
|
+
def handle_error(exception_context: Any) -> None:
|
|
78
|
+
conn = exception_context.connection
|
|
79
|
+
capture = conn.info.pop("_obsforge_db_capture", None) if conn is not None else None
|
|
80
|
+
if capture is None:
|
|
81
|
+
return
|
|
82
|
+
db_engine.emit_query_sync(
|
|
83
|
+
capture,
|
|
84
|
+
exception=exception_context.original_exception,
|
|
85
|
+
pool_snapshot=_pool_snapshot(db_engine, sa_pool),
|
|
86
|
+
transaction_state=(
|
|
87
|
+
db_engine.transaction_state(str(id(conn.connection))) if conn is not None else None
|
|
88
|
+
),
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
@_listens_for(target, "begin")
|
|
92
|
+
def begin(conn: Any) -> None:
|
|
93
|
+
db_engine.transaction_begin(
|
|
94
|
+
str(id(conn.connection)),
|
|
95
|
+
isolation_level=_safe_call(conn, "get_isolation_level"),
|
|
96
|
+
autocommit=_safe_getattr(conn.connection, "autocommit"),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
@_listens_for(target, "commit")
|
|
100
|
+
def commit(conn: Any) -> None:
|
|
101
|
+
db_engine.transaction_commit(str(id(conn.connection)))
|
|
102
|
+
|
|
103
|
+
@_listens_for(target, "rollback")
|
|
104
|
+
def rollback(conn: Any) -> None:
|
|
105
|
+
db_engine.transaction_rollback(str(id(conn.connection)))
|
|
106
|
+
|
|
107
|
+
if sa_pool is not None:
|
|
108
|
+
@_listens_for(sa_pool, "checkout")
|
|
109
|
+
def checkout(
|
|
110
|
+
dbapi_connection: Any,
|
|
111
|
+
connection_record: Any,
|
|
112
|
+
connection_proxy: Any,
|
|
113
|
+
) -> None:
|
|
114
|
+
db_engine.pool_checkout(
|
|
115
|
+
pool_name=type(sa_pool).__name__,
|
|
116
|
+
connection_id=str(id(dbapi_connection)),
|
|
117
|
+
pool_size=_safe_call(sa_pool, "size"),
|
|
118
|
+
checked_out=_safe_call(sa_pool, "checkedout"),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
@_listens_for(sa_pool, "checkin")
|
|
122
|
+
def checkin(dbapi_connection: Any, connection_record: Any) -> None:
|
|
123
|
+
db_engine.pool_checkin(
|
|
124
|
+
pool_name=type(sa_pool).__name__,
|
|
125
|
+
connection_id=str(id(dbapi_connection)),
|
|
126
|
+
pool_size=_safe_call(sa_pool, "size"),
|
|
127
|
+
checked_out=_safe_call(sa_pool, "checkedout"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
return engine
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _pool_snapshot(db_engine: DBObservabilityEngine, pool: Any) -> Any:
|
|
134
|
+
if pool is None:
|
|
135
|
+
return None
|
|
136
|
+
return db_engine.pool_snapshot(
|
|
137
|
+
pool_name=type(pool).__name__,
|
|
138
|
+
pool_size=_safe_call(pool, "size"),
|
|
139
|
+
checked_out=_safe_call(pool, "checkedout"),
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _safe_call(obj: Any, name: str) -> Any:
|
|
144
|
+
method = getattr(obj, name, None)
|
|
145
|
+
if callable(method):
|
|
146
|
+
try:
|
|
147
|
+
return method()
|
|
148
|
+
except Exception:
|
|
149
|
+
return None
|
|
150
|
+
return None
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _safe_getattr(obj: Any, name: str) -> Any:
|
|
154
|
+
try:
|
|
155
|
+
return getattr(obj, name)
|
|
156
|
+
except Exception:
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
else:
|
|
160
|
+
def instrument_sqlalchemy_engine(engine: Any, db_engine: DBObservabilityEngine) -> Any: # pragma: no cover
|
|
161
|
+
raise RuntimeError("sqlalchemy is not installed")
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import TYPE_CHECKING
|
|
4
|
+
|
|
5
|
+
from obsforge.core.errors import ConfigurationError
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from obsforge.instrumentation.db.engine import DBObservabilityEngine
|
|
9
|
+
|
|
10
|
+
_default_db_engine: DBObservabilityEngine | None = None
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def set_default_db_engine(engine: DBObservabilityEngine) -> None:
|
|
14
|
+
global _default_db_engine
|
|
15
|
+
_default_db_engine = engine
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_default_db_engine() -> DBObservabilityEngine | None:
|
|
19
|
+
return _default_db_engine
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_default_db_engine() -> DBObservabilityEngine:
|
|
23
|
+
if _default_db_engine is None:
|
|
24
|
+
raise ConfigurationError(
|
|
25
|
+
"obsforge is not initialized: call obsforge.bootstrap() before using the "
|
|
26
|
+
"database instrumentation, or pass db_engine= explicitly."
|
|
27
|
+
)
|
|
28
|
+
return _default_db_engine
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from threading import Lock
|
|
5
|
+
from uuid import uuid4
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(slots=True)
|
|
9
|
+
class TransactionState:
|
|
10
|
+
transaction_id: str
|
|
11
|
+
depth: int
|
|
12
|
+
isolation_level: str | None
|
|
13
|
+
autocommit: bool | None
|
|
14
|
+
status: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class TransactionTracker:
|
|
18
|
+
def __init__(self) -> None:
|
|
19
|
+
self._states: dict[str, TransactionState] = {}
|
|
20
|
+
self._lock = Lock()
|
|
21
|
+
|
|
22
|
+
def begin(
|
|
23
|
+
self,
|
|
24
|
+
connection_id: str,
|
|
25
|
+
*,
|
|
26
|
+
isolation_level: str | None = None,
|
|
27
|
+
autocommit: bool | None = None,
|
|
28
|
+
) -> TransactionState:
|
|
29
|
+
with self._lock:
|
|
30
|
+
current = self._states.get(connection_id)
|
|
31
|
+
if current is None:
|
|
32
|
+
current = TransactionState(
|
|
33
|
+
transaction_id=str(uuid4()),
|
|
34
|
+
depth=1,
|
|
35
|
+
isolation_level=isolation_level,
|
|
36
|
+
autocommit=autocommit,
|
|
37
|
+
status="active",
|
|
38
|
+
)
|
|
39
|
+
else:
|
|
40
|
+
current.depth += 1
|
|
41
|
+
current.status = "active"
|
|
42
|
+
if isolation_level is not None:
|
|
43
|
+
current.isolation_level = isolation_level
|
|
44
|
+
if autocommit is not None:
|
|
45
|
+
current.autocommit = autocommit
|
|
46
|
+
self._states[connection_id] = current
|
|
47
|
+
return current
|
|
48
|
+
|
|
49
|
+
def commit(self, connection_id: str) -> TransactionState | None:
|
|
50
|
+
with self._lock:
|
|
51
|
+
current = self._states.get(connection_id)
|
|
52
|
+
if current is None:
|
|
53
|
+
return None
|
|
54
|
+
current.depth = max(current.depth - 1, 0)
|
|
55
|
+
current.status = "committed"
|
|
56
|
+
if current.depth == 0:
|
|
57
|
+
self._states.pop(connection_id, None)
|
|
58
|
+
return current
|
|
59
|
+
|
|
60
|
+
def rollback(self, connection_id: str) -> TransactionState | None:
|
|
61
|
+
with self._lock:
|
|
62
|
+
current = self._states.get(connection_id)
|
|
63
|
+
if current is None:
|
|
64
|
+
return None
|
|
65
|
+
current.depth = max(current.depth - 1, 0)
|
|
66
|
+
current.status = "rolled_back"
|
|
67
|
+
if current.depth == 0:
|
|
68
|
+
self._states.pop(connection_id, None)
|
|
69
|
+
return current
|
|
70
|
+
|
|
71
|
+
def state(self, connection_id: str) -> TransactionState | None:
|
|
72
|
+
with self._lock:
|
|
73
|
+
return self._states.get(connection_id)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from obsforge.instrumentation.exceptions.engine import ExceptionIntelligenceEngine
|
|
2
|
+
from obsforge.instrumentation.exceptions.state import (
|
|
3
|
+
ExceptionContextSnapshot,
|
|
4
|
+
ExceptionStateManager,
|
|
5
|
+
get_default_exception_engine,
|
|
6
|
+
set_default_exception_engine,
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ExceptionContextSnapshot",
|
|
11
|
+
"ExceptionIntelligenceEngine",
|
|
12
|
+
"ExceptionStateManager",
|
|
13
|
+
"get_default_exception_engine",
|
|
14
|
+
"set_default_exception_engine",
|
|
15
|
+
]
|