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.
@@ -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)