wrapture-instrumentation-postgresql 1.0.0.dev1__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.
- wrapture_instrumentation_postgresql/__init__.py +26 -0
- wrapture_instrumentation_postgresql/asyncpg/README.md +136 -0
- wrapture_instrumentation_postgresql/asyncpg/__init__.py +75 -0
- wrapture_instrumentation_postgresql/asyncpg/connection.py +220 -0
- wrapture_instrumentation_postgresql/asyncpg/cursor.py +88 -0
- wrapture_instrumentation_postgresql/asyncpg/prepared.py +74 -0
- wrapture_instrumentation_postgresql/common.py +169 -0
- wrapture_instrumentation_postgresql/psycopg/README.md +144 -0
- wrapture_instrumentation_postgresql/psycopg/__init__.py +57 -0
- wrapture_instrumentation_postgresql/psycopg/connection.py +168 -0
- wrapture_instrumentation_postgresql/psycopg/cursor.py +258 -0
- wrapture_instrumentation_postgresql/psycopg/transaction.py +126 -0
- wrapture_instrumentation_postgresql/psycopg2/README.md +131 -0
- wrapture_instrumentation_postgresql/psycopg2/__init__.py +49 -0
- wrapture_instrumentation_postgresql/psycopg2/factories.py +402 -0
- wrapture_instrumentation_postgresql/py.typed +0 -0
- wrapture_instrumentation_postgresql-1.0.0.dev1.dist-info/METADATA +155 -0
- wrapture_instrumentation_postgresql-1.0.0.dev1.dist-info/RECORD +22 -0
- wrapture_instrumentation_postgresql-1.0.0.dev1.dist-info/WHEEL +5 -0
- wrapture_instrumentation_postgresql-1.0.0.dev1.dist-info/entry_points.txt +4 -0
- wrapture_instrumentation_postgresql-1.0.0.dev1.dist-info/licenses/LICENSE +24 -0
- wrapture_instrumentation_postgresql-1.0.0.dev1.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""The cursor seams: the execute family, streamed queries and COPY,
|
|
2
|
+
on the sync and async cursors and on the server-side cursors,
|
|
3
|
+
recorded as database events.
|
|
4
|
+
|
|
5
|
+
psycopg's cursors are pure Python, so each method is bound in place
|
|
6
|
+
on its class. `Cursor.execute` and `Cursor.executemany` are the doors
|
|
7
|
+
every ordinary query passes through, whichever cursor class the
|
|
8
|
+
application chose: `ClientCursor` and `RawCursor` inherit them, and
|
|
9
|
+
`Connection.execute`, the shortcut, builds a cursor and calls its
|
|
10
|
+
`execute` in Python, so the one binding records it too with no
|
|
11
|
+
double. The server-side cursors override `execute` to DECLARE a
|
|
12
|
+
cursor rather than run the query, so those overrides are bound in
|
|
13
|
+
their own right and record a DECLARE operation; their fetches, which
|
|
14
|
+
FETCH from the portal, are not recorded, the model every database
|
|
15
|
+
target here follows (a query event closes when its execute returns,
|
|
16
|
+
and time spent iterating rows is the application's).
|
|
17
|
+
|
|
18
|
+
`Cursor.stream()` is a generator: the query is sent on the first
|
|
19
|
+
iteration and rows arrive one at a time. The binding's decorator is
|
|
20
|
+
itself a generator over the driver's, so wrapture records the event
|
|
21
|
+
around the iteration, its duration the time spent inside the
|
|
22
|
+
generator and its item count the rows streamed.
|
|
23
|
+
|
|
24
|
+
`Cursor.copy()` is a context manager factory: the COPY statement is
|
|
25
|
+
sent on entering and the data flows through the yielded Copy object
|
|
26
|
+
until exit. A plain binding would record the factory call, an
|
|
27
|
+
instant, so the binding records nothing itself (`when=False`) and its
|
|
28
|
+
decorator hands back a wrapping context manager that opens a block
|
|
29
|
+
event on entry and closes it on exit, the block spanning the
|
|
30
|
+
transfer, with the row count annotated at the end.
|
|
31
|
+
|
|
32
|
+
Every event carries the database contract keys from the common
|
|
33
|
+
module; the SQL text rides as `statement` only when the setting is
|
|
34
|
+
on. Bound parameters are never recorded.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
from collections.abc import AsyncIterator, Iterator
|
|
40
|
+
from types import TracebackType
|
|
41
|
+
from typing import Any
|
|
42
|
+
|
|
43
|
+
import wrapture
|
|
44
|
+
|
|
45
|
+
from ..common import SYSTEM, captured, query_data, server_of
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RecordedCopy:
|
|
49
|
+
"""The context manager handed back in place of psycopg's copy
|
|
50
|
+
factory result: a block event around the real one."""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self, label: str, cursor: Any, inner: Any, data: dict[str, Any]
|
|
54
|
+
) -> None:
|
|
55
|
+
self._cursor = cursor
|
|
56
|
+
self._inner = inner
|
|
57
|
+
self._block = wrapture.block(label, category="database", data=data, leaf=True)
|
|
58
|
+
|
|
59
|
+
def _finish(
|
|
60
|
+
self,
|
|
61
|
+
exc_type: type[BaseException] | None,
|
|
62
|
+
exc_value: BaseException | None,
|
|
63
|
+
traceback: TracebackType | None,
|
|
64
|
+
) -> None:
|
|
65
|
+
# The cursor's rowcount is fresh once the inner exit has run
|
|
66
|
+
# the rest of the factory's body, which reads the COPY result.
|
|
67
|
+
|
|
68
|
+
rows = getattr(self._cursor, "rowcount", -1)
|
|
69
|
+
if isinstance(rows, int) and rows >= 0:
|
|
70
|
+
wrapture.annotate(rows=rows)
|
|
71
|
+
|
|
72
|
+
self._block.__exit__(exc_type, exc_value, traceback)
|
|
73
|
+
|
|
74
|
+
def __enter__(self) -> Any:
|
|
75
|
+
self._block.__enter__()
|
|
76
|
+
try:
|
|
77
|
+
return self._inner.__enter__()
|
|
78
|
+
except BaseException as error:
|
|
79
|
+
self._block.__exit__(type(error), error, error.__traceback__)
|
|
80
|
+
raise
|
|
81
|
+
|
|
82
|
+
def __exit__(
|
|
83
|
+
self,
|
|
84
|
+
exc_type: type[BaseException] | None,
|
|
85
|
+
exc_value: BaseException | None,
|
|
86
|
+
traceback: TracebackType | None,
|
|
87
|
+
) -> Any:
|
|
88
|
+
try:
|
|
89
|
+
outcome = self._inner.__exit__(exc_type, exc_value, traceback)
|
|
90
|
+
except BaseException as error:
|
|
91
|
+
self._finish(type(error), error, error.__traceback__)
|
|
92
|
+
raise
|
|
93
|
+
|
|
94
|
+
self._finish(exc_type, exc_value, traceback)
|
|
95
|
+
|
|
96
|
+
return outcome
|
|
97
|
+
|
|
98
|
+
async def __aenter__(self) -> Any:
|
|
99
|
+
self._block.__enter__()
|
|
100
|
+
try:
|
|
101
|
+
return await self._inner.__aenter__()
|
|
102
|
+
except BaseException as error:
|
|
103
|
+
self._block.__exit__(type(error), error, error.__traceback__)
|
|
104
|
+
raise
|
|
105
|
+
|
|
106
|
+
async def __aexit__(
|
|
107
|
+
self,
|
|
108
|
+
exc_type: type[BaseException] | None,
|
|
109
|
+
exc_value: BaseException | None,
|
|
110
|
+
traceback: TracebackType | None,
|
|
111
|
+
) -> Any:
|
|
112
|
+
try:
|
|
113
|
+
outcome = await self._inner.__aexit__(exc_type, exc_value, traceback)
|
|
114
|
+
except BaseException as error:
|
|
115
|
+
self._finish(type(error), error, error.__traceback__)
|
|
116
|
+
raise
|
|
117
|
+
|
|
118
|
+
self._finish(exc_type, exc_value, traceback)
|
|
119
|
+
|
|
120
|
+
return outcome
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def instrument(module: Any, instrumentation: wrapture.Instrumentation) -> None:
|
|
124
|
+
"""Bind the execute family, stream and copy on the cursor classes;
|
|
125
|
+
register their removal as this trigger's cleanup."""
|
|
126
|
+
|
|
127
|
+
settings = instrumentation.settings
|
|
128
|
+
record_statement = bool(settings["statement"])
|
|
129
|
+
|
|
130
|
+
def query_of(args: tuple[Any, ...], kwargs: dict[str, Any]) -> Any:
|
|
131
|
+
return args[0] if args else kwargs.get("query", kwargs.get("statement"))
|
|
132
|
+
|
|
133
|
+
def data_for(
|
|
134
|
+
instance: Any, query: Any, operation: str | None = None
|
|
135
|
+
) -> dict[str, Any]:
|
|
136
|
+
return query_data(
|
|
137
|
+
query, instance, instance.connection.info, record_statement, operation
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
def executes(operation: str | None = None) -> Any:
|
|
141
|
+
def record(
|
|
142
|
+
wrapped: Any,
|
|
143
|
+
instance: Any,
|
|
144
|
+
args: tuple[Any, ...],
|
|
145
|
+
kwargs: dict[str, Any],
|
|
146
|
+
) -> Any:
|
|
147
|
+
wrapture.annotate(**data_for(instance, query_of(args, kwargs), operation))
|
|
148
|
+
|
|
149
|
+
return wrapped(*args, **kwargs)
|
|
150
|
+
|
|
151
|
+
return record
|
|
152
|
+
|
|
153
|
+
def executes_async(operation: str | None = None) -> Any:
|
|
154
|
+
async def record(
|
|
155
|
+
wrapped: Any,
|
|
156
|
+
instance: Any,
|
|
157
|
+
args: tuple[Any, ...],
|
|
158
|
+
kwargs: dict[str, Any],
|
|
159
|
+
) -> Any:
|
|
160
|
+
wrapture.annotate(**data_for(instance, query_of(args, kwargs), operation))
|
|
161
|
+
|
|
162
|
+
return await wrapped(*args, **kwargs)
|
|
163
|
+
|
|
164
|
+
return record
|
|
165
|
+
|
|
166
|
+
# The stream decorators are generators over the driver's, so the
|
|
167
|
+
# annotation lands inside the event wrapture records around the
|
|
168
|
+
# iteration, not before it exists.
|
|
169
|
+
|
|
170
|
+
def streams(
|
|
171
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
172
|
+
) -> Iterator[Any]:
|
|
173
|
+
wrapture.annotate(**data_for(instance, query_of(args, kwargs)))
|
|
174
|
+
|
|
175
|
+
yield from wrapped(*args, **kwargs)
|
|
176
|
+
|
|
177
|
+
async def streams_async(
|
|
178
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
179
|
+
) -> AsyncIterator[Any]:
|
|
180
|
+
wrapture.annotate(**data_for(instance, query_of(args, kwargs)))
|
|
181
|
+
|
|
182
|
+
async for row in wrapped(*args, **kwargs):
|
|
183
|
+
yield row
|
|
184
|
+
|
|
185
|
+
def copies(label: str) -> Any:
|
|
186
|
+
def record(
|
|
187
|
+
wrapped: Any,
|
|
188
|
+
instance: Any,
|
|
189
|
+
args: tuple[Any, ...],
|
|
190
|
+
kwargs: dict[str, Any],
|
|
191
|
+
) -> RecordedCopy:
|
|
192
|
+
data = data_for(instance, query_of(args, kwargs), "COPY")
|
|
193
|
+
|
|
194
|
+
return RecordedCopy(label, instance, wrapped(*args, **kwargs), data)
|
|
195
|
+
|
|
196
|
+
return record
|
|
197
|
+
|
|
198
|
+
def query_binding(owner: Any, name: str) -> wrapture.Binding:
|
|
199
|
+
return wrapture.binding(
|
|
200
|
+
owner,
|
|
201
|
+
name,
|
|
202
|
+
category="database",
|
|
203
|
+
leaf=True,
|
|
204
|
+
capture_args=captured,
|
|
205
|
+
capture_result=captured,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
named: dict[str, wrapture.Binding] = {}
|
|
209
|
+
|
|
210
|
+
# The execute family on the ordinary cursors, sync and async.
|
|
211
|
+
|
|
212
|
+
for prefix, owner, decorator in (
|
|
213
|
+
("cursor", module.Cursor, executes),
|
|
214
|
+
("async_cursor", module.AsyncCursor, executes_async),
|
|
215
|
+
):
|
|
216
|
+
for method in ("execute", "executemany"):
|
|
217
|
+
bound = query_binding(owner, method)
|
|
218
|
+
bound.on_call.decorates(decorator())
|
|
219
|
+
named[f"{prefix}_{method}"] = bound
|
|
220
|
+
|
|
221
|
+
# The server-side cursors' own execute, a DECLARE.
|
|
222
|
+
|
|
223
|
+
for prefix, owner, decorator in (
|
|
224
|
+
("server_cursor", module.ServerCursor, executes),
|
|
225
|
+
("async_server_cursor", module.AsyncServerCursor, executes_async),
|
|
226
|
+
):
|
|
227
|
+
bound = query_binding(owner, "execute")
|
|
228
|
+
bound.on_call.decorates(decorator("DECLARE"))
|
|
229
|
+
named[f"{prefix}_execute"] = bound
|
|
230
|
+
|
|
231
|
+
# Streamed queries, recorded around the iteration.
|
|
232
|
+
|
|
233
|
+
stream = query_binding(module.Cursor, "stream")
|
|
234
|
+
stream.on_call.decorates(streams)
|
|
235
|
+
named["cursor_stream"] = stream
|
|
236
|
+
|
|
237
|
+
async_stream = query_binding(module.AsyncCursor, "stream")
|
|
238
|
+
async_stream.on_call.decorates(streams_async)
|
|
239
|
+
named["async_cursor_stream"] = async_stream
|
|
240
|
+
|
|
241
|
+
# COPY: the factory records nothing itself, the wrapping context
|
|
242
|
+
# manager its decorator returns records the block.
|
|
243
|
+
|
|
244
|
+
for prefix, owner in (
|
|
245
|
+
("cursor", module.Cursor),
|
|
246
|
+
("async_cursor", module.AsyncCursor),
|
|
247
|
+
):
|
|
248
|
+
bound = wrapture.binding(owner, "copy", when=False)
|
|
249
|
+
bound.on_call.decorates(copies(f"psycopg:{owner.__name__}.copy"))
|
|
250
|
+
named[f"{prefix}_copy"] = bound
|
|
251
|
+
|
|
252
|
+
group = wrapture.bindings(**named)
|
|
253
|
+
group.apply()
|
|
254
|
+
|
|
255
|
+
instrumentation.on_cleanup(group.remove)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = ["RecordedCopy", "SYSTEM", "instrument", "server_of"]
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""The `transaction()` block seams: entering and leaving a
|
|
2
|
+
Transaction or AsyncTransaction, recorded as database events.
|
|
3
|
+
|
|
4
|
+
`with conn.transaction():` is psycopg's transaction block. Entering
|
|
5
|
+
issues BEGIN when no transaction is open on the connection, and a
|
|
6
|
+
SAVEPOINT for a nested block (or when a savepoint name was asked
|
|
7
|
+
for); leaving issues COMMIT or RELEASE SAVEPOINT, or ROLLBACK (to the
|
|
8
|
+
savepoint, for a nested block) when an exception is passing through,
|
|
9
|
+
`force_rollback` was set, or `psycopg.Rollback` was raised inside.
|
|
10
|
+
The block object knows which it did: whether it opened the outermost
|
|
11
|
+
transaction, and its savepoint name, both read after the enter has
|
|
12
|
+
run and before the exit does. These statements go to libpq directly,
|
|
13
|
+
never through a cursor, so the enter and exit are bound in their own
|
|
14
|
+
right, each recording the operation it performed and the savepoint
|
|
15
|
+
name when one was involved.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import wrapture
|
|
23
|
+
|
|
24
|
+
from ..common import SYSTEM, captured, server_of
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def instrument(module: Any, instrumentation: wrapture.Instrumentation) -> None:
|
|
28
|
+
"""Bind the enter and exit of the sync and async transaction
|
|
29
|
+
blocks; register their removal as this trigger's cleanup."""
|
|
30
|
+
|
|
31
|
+
def entered(instance: Any) -> dict[str, Any]:
|
|
32
|
+
# Known only once the enter has run: whether this block began
|
|
33
|
+
# the transaction or nested inside one.
|
|
34
|
+
|
|
35
|
+
outer = bool(getattr(instance, "_outer_transaction", False))
|
|
36
|
+
savepoint = getattr(instance, "_savepoint_name", "") or None
|
|
37
|
+
|
|
38
|
+
data: dict[str, Any] = {"operation": "BEGIN" if outer else "SAVEPOINT"}
|
|
39
|
+
if savepoint:
|
|
40
|
+
data["savepoint"] = savepoint
|
|
41
|
+
|
|
42
|
+
return data
|
|
43
|
+
|
|
44
|
+
def leaving(
|
|
45
|
+
instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
exc_value = args[1] if len(args) > 1 else kwargs.get("exc_val")
|
|
48
|
+
outer = bool(getattr(instance, "_outer_transaction", False))
|
|
49
|
+
savepoint = getattr(instance, "_savepoint_name", "") or None
|
|
50
|
+
commits = exc_value is None and not getattr(instance, "force_rollback", False)
|
|
51
|
+
|
|
52
|
+
if commits:
|
|
53
|
+
operation = "COMMIT" if outer else "RELEASE"
|
|
54
|
+
else:
|
|
55
|
+
operation = "ROLLBACK"
|
|
56
|
+
|
|
57
|
+
data: dict[str, Any] = {"operation": operation}
|
|
58
|
+
if savepoint:
|
|
59
|
+
data["savepoint"] = savepoint
|
|
60
|
+
|
|
61
|
+
return data
|
|
62
|
+
|
|
63
|
+
def enters(
|
|
64
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
65
|
+
) -> Any:
|
|
66
|
+
wrapture.annotate(system=SYSTEM, **server_of(instance.connection.info))
|
|
67
|
+
|
|
68
|
+
outcome = wrapped(*args, **kwargs)
|
|
69
|
+
wrapture.annotate(**entered(instance))
|
|
70
|
+
|
|
71
|
+
return outcome
|
|
72
|
+
|
|
73
|
+
async def enters_async(
|
|
74
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
75
|
+
) -> Any:
|
|
76
|
+
wrapture.annotate(system=SYSTEM, **server_of(instance.connection.info))
|
|
77
|
+
|
|
78
|
+
outcome = await wrapped(*args, **kwargs)
|
|
79
|
+
wrapture.annotate(**entered(instance))
|
|
80
|
+
|
|
81
|
+
return outcome
|
|
82
|
+
|
|
83
|
+
def exits(
|
|
84
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
85
|
+
) -> Any:
|
|
86
|
+
wrapture.annotate(
|
|
87
|
+
system=SYSTEM,
|
|
88
|
+
**server_of(instance.connection.info),
|
|
89
|
+
**leaving(instance, args, kwargs),
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return wrapped(*args, **kwargs)
|
|
93
|
+
|
|
94
|
+
async def exits_async(
|
|
95
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
96
|
+
) -> Any:
|
|
97
|
+
wrapture.annotate(
|
|
98
|
+
system=SYSTEM,
|
|
99
|
+
**server_of(instance.connection.info),
|
|
100
|
+
**leaving(instance, args, kwargs),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return await wrapped(*args, **kwargs)
|
|
104
|
+
|
|
105
|
+
def boundary(owner: Any, name: str, decorator: Any) -> wrapture.Binding:
|
|
106
|
+
binding = wrapture.binding(
|
|
107
|
+
owner,
|
|
108
|
+
name,
|
|
109
|
+
category="database",
|
|
110
|
+
leaf=True,
|
|
111
|
+
capture_args=captured,
|
|
112
|
+
capture_result=captured,
|
|
113
|
+
)
|
|
114
|
+
binding.on_call.decorates(decorator)
|
|
115
|
+
|
|
116
|
+
return binding
|
|
117
|
+
|
|
118
|
+
group = wrapture.bindings(
|
|
119
|
+
enter=boundary(module.Transaction, "__enter__", enters),
|
|
120
|
+
exit=boundary(module.Transaction, "__exit__", exits),
|
|
121
|
+
async_enter=boundary(module.AsyncTransaction, "__aenter__", enters_async),
|
|
122
|
+
async_exit=boundary(module.AsyncTransaction, "__aexit__", exits_async),
|
|
123
|
+
)
|
|
124
|
+
group.apply()
|
|
125
|
+
|
|
126
|
+
instrumentation.on_cleanup(group.remove)
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# psycopg2 instrumentation
|
|
2
|
+
|
|
3
|
+
Query and transaction tracing for
|
|
4
|
+
[psycopg2](https://www.psycopg.org/docs/), the long-standing
|
|
5
|
+
PostgreSQL adapter. Entry point name `psycopg2`, the package it
|
|
6
|
+
patches (psycopg2-binary installs the same package); supports
|
|
7
|
+
psycopg2 2.9 and later, below 3; fully removable.
|
|
8
|
+
|
|
9
|
+
## Enabling it
|
|
10
|
+
|
|
11
|
+
An `[[instrument]]` entry in `wrapture.toml` (with at least one sink
|
|
12
|
+
to hear the events):
|
|
13
|
+
|
|
14
|
+
```toml
|
|
15
|
+
[[instrument]]
|
|
16
|
+
name = "psycopg2"
|
|
17
|
+
|
|
18
|
+
[[sink]]
|
|
19
|
+
type = "printer"
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
run under wrapture's runner (`python -m wrapture -m myapp`), or in a
|
|
23
|
+
test through the context manager:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
with wrapture.instrumentation("psycopg2"):
|
|
27
|
+
...
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## What you see
|
|
31
|
+
|
|
32
|
+
One `database` leaf per operation: the connection being opened, each
|
|
33
|
+
query however it was issued (`execute`, `executemany`, `callproc`,
|
|
34
|
+
and the extras' batch helpers above them), each COPY (`copy_from`,
|
|
35
|
+
`copy_to`, `copy_expert`), and each transaction boundary (`commit`,
|
|
36
|
+
`rollback`, and the connection's commit-or-rollback context manager,
|
|
37
|
+
whose exit records which of the two it performed):
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
psycopg2:connect() -> '<connection>'
|
|
41
|
+
psycopg2.extensions:cursor.execute(query='<31 chars>', vars='<1 values>')
|
|
42
|
+
psycopg2.extensions:connection.commit()
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
- `psycopg2.extensions.connection` and `.cursor` are C types no
|
|
46
|
+
patch can touch, and psycopg2's own C entry points (`register_type`
|
|
47
|
+
and everything built on it, `quote_ident`, `Json.prepare`,
|
|
48
|
+
`lobject`) type-check the objects handed to them, so a proxy is
|
|
49
|
+
ruled out too. The instrumentation instead uses the mechanism
|
|
50
|
+
psycopg2 provides for its own extras: it binds `psycopg2.connect`
|
|
51
|
+
to substitute the requested connection class with a recording
|
|
52
|
+
subclass of it, and that subclass hands out cursors that are
|
|
53
|
+
recording subclasses of whatever cursor class was asked for. Your
|
|
54
|
+
own factories keep working and are simply recorded: a
|
|
55
|
+
`cursor_factory` named at connect or per cursor (`DictCursor`,
|
|
56
|
+
`RealDictCursor`, `NamedTupleCursor`), a `connection_factory` with
|
|
57
|
+
a cursor default of its own (`RealDictConnection`,
|
|
58
|
+
`LoggingConnection`), all of them real subclasses, so isinstance
|
|
59
|
+
checks and the C type checks pass and reprs read as before.
|
|
60
|
+
|
|
61
|
+
- Every event carries the database contract keys `system`
|
|
62
|
+
(`postgresql`) and `operation` (the SQL's leading keyword, or
|
|
63
|
+
`CONNECT`, `COMMIT`, `ROLLBACK`, `CALL`, `COPY`), which wrapture's
|
|
64
|
+
OpenTelemetry export maps to `db.system.name` and
|
|
65
|
+
`db.operation.name`, plus the `database`, `host` and `port` the
|
|
66
|
+
connection reached, from the connection's own info. A `callproc`
|
|
67
|
+
names the procedure as `procedure`; a `copy_from` or `copy_to`
|
|
68
|
+
names the table as `collection`.
|
|
69
|
+
|
|
70
|
+
- The connection's context manager (`with conn:`) commits, or rolls
|
|
71
|
+
back when an exception is on its way through, and does not close
|
|
72
|
+
the connection; its exit records which it did. A cursor's context
|
|
73
|
+
manager only closes the cursor and records nothing.
|
|
74
|
+
|
|
75
|
+
- The capture policy is deliberate about sensitive data: bound
|
|
76
|
+
parameters are never recorded, under any setting (they reduce to a
|
|
77
|
+
count, or to a type name for a sequence the driver has yet to
|
|
78
|
+
consume, which is never iterated); the SQL text reduces to its
|
|
79
|
+
length unless the `statement` setting is on; a COPY's file reduces
|
|
80
|
+
to its type; and the connect event captures none of its arguments,
|
|
81
|
+
which carry the password. psycopg2 interpolates parameters
|
|
82
|
+
client-side, below the seam, so the recorded text is always the
|
|
83
|
+
template with its `%s` placeholders, never the query as sent.
|
|
84
|
+
|
|
85
|
+
- The extras' batch helpers (`execute_values`, `execute_batch`) call
|
|
86
|
+
`execute` once per page or batch, and record one event each, which
|
|
87
|
+
is what happens on the wire. A failing statement records the
|
|
88
|
+
driver's exception (`psycopg2.errors.UndefinedTable`, say) on the
|
|
89
|
+
event as it escapes. Fetching is not recorded: a query event closes
|
|
90
|
+
when its execute returns, and a named (server-side) cursor's
|
|
91
|
+
FETCHes go unrecorded for the same reason, its execute being the
|
|
92
|
+
DECLARE.
|
|
93
|
+
|
|
94
|
+
- An asynchronous connection (`async_=True`) returns from `execute`
|
|
95
|
+
before the query runs and the application polls; the event then
|
|
96
|
+
measures the send only.
|
|
97
|
+
|
|
98
|
+
## Settings
|
|
99
|
+
|
|
100
|
+
| Setting | Default | Controls |
|
|
101
|
+
| ------- | ------- | -------- |
|
|
102
|
+
| `statement` | `false` | Whether each query event records the SQL text as handed to the driver, as `statement` (a composed `sql.SQL(...)` query rendered as it will be sent). Off by default because the driver cannot tell a literal an application interpolated from a placeholder; turn it on when your queries are parameterized, the text then carrying placeholders rather than data. A `sql.Literal` composed into a query is recorded as written, so prefer placeholders there too. Bound parameters are never recorded either way. |
|
|
103
|
+
|
|
104
|
+
```toml
|
|
105
|
+
[[instrument]]
|
|
106
|
+
name = "psycopg2"
|
|
107
|
+
statement = true
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## With the sqlalchemy instrumentation
|
|
111
|
+
|
|
112
|
+
An instrumented psycopg2 beneath the core package's `sqlalchemy`
|
|
113
|
+
target composes through that target's `leaf` setting. With the
|
|
114
|
+
default `leaf = true` each statement is one event and the driver's
|
|
115
|
+
own events stay out of the tree; with `leaf = false` the driver's
|
|
116
|
+
events nest beneath each statement: `cursor.execute` under
|
|
117
|
+
`do_execute`, `psycopg2:connect` under the dialect's `connect`, and
|
|
118
|
+
the driver's `executemany` (or the `execute` calls of the dialect's
|
|
119
|
+
`execute_values` batching, for compiled inserts) under the psycopg2
|
|
120
|
+
dialect's own `do_executemany` override, which the sqlalchemy target
|
|
121
|
+
binds in its own right.
|
|
122
|
+
Raw psycopg2 use beside the engine records at the top level either
|
|
123
|
+
way. A little of the dialect's own housekeeping also shows up
|
|
124
|
+
regardless, because it runs straight against the driver outside the
|
|
125
|
+
recorded seams: the settings the dialect reads when it opens a
|
|
126
|
+
connection, and the pool's reset-on-return rollback.
|
|
127
|
+
|
|
128
|
+
## How it patches
|
|
129
|
+
|
|
130
|
+
For the implementation detail see the module docstring of
|
|
131
|
+
[factories.py](factories.py).
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Instrumentation for psycopg2: every query, the connections opened
|
|
2
|
+
and the transaction boundaries recorded as database events, through
|
|
3
|
+
recording subclasses of the driver's connection and cursor types
|
|
4
|
+
injected by way of the factory hooks psycopg2 itself provides.
|
|
5
|
+
|
|
6
|
+
This module imports only wrapture. Everything that touches psycopg2
|
|
7
|
+
lives in the sibling factories module, importing only wrapture at
|
|
8
|
+
top level and reaching psycopg2 through the package the hook is
|
|
9
|
+
handed or a lazy import inside a method, so loading this class when a
|
|
10
|
+
config loads never imports psycopg2 ahead of the hook meant to fire
|
|
11
|
+
on its import.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import wrapture
|
|
19
|
+
from wrapture import Setting
|
|
20
|
+
|
|
21
|
+
from . import factories
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Psycopg2Instrumentation(wrapture.Instrumentation):
|
|
25
|
+
"""Query and transaction tracing for psycopg2."""
|
|
26
|
+
|
|
27
|
+
description = "Query and transaction tracing for psycopg2."
|
|
28
|
+
|
|
29
|
+
target = "psycopg2"
|
|
30
|
+
supports = ">=2.9,<3"
|
|
31
|
+
removable = True
|
|
32
|
+
|
|
33
|
+
settings = {
|
|
34
|
+
"statement": Setting(
|
|
35
|
+
False,
|
|
36
|
+
"record the SQL text as handed to the driver on each query"
|
|
37
|
+
" event; off by default because the driver cannot tell a"
|
|
38
|
+
" literal an application interpolated from a placeholder,"
|
|
39
|
+
" and the text is only safe to record when queries are"
|
|
40
|
+
" parameterized",
|
|
41
|
+
),
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@wrapture.instrumentation_hook("psycopg2")
|
|
45
|
+
def psycopg2(self, name: str, module: Any) -> None:
|
|
46
|
+
"""Bind the connect factory and the recording classes' methods
|
|
47
|
+
once psycopg2 exists."""
|
|
48
|
+
|
|
49
|
+
factories.instrument(module, self)
|