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,169 @@
|
|
|
1
|
+
"""What every PostgreSQL driver's events have in common: the database
|
|
2
|
+
category's contract keys, the operation name derived from the SQL,
|
|
3
|
+
and the capture policy that keeps queries and their data out of the
|
|
4
|
+
record.
|
|
5
|
+
|
|
6
|
+
This module imports only wrapture, so a target subpackage can import
|
|
7
|
+
it at load time without dragging any driver in.
|
|
8
|
+
|
|
9
|
+
Every event carries `system` ("postgresql") and `operation` (the SQL's
|
|
10
|
+
leading keyword, or CONNECT, COMMIT, ROLLBACK and their kin), plus
|
|
11
|
+
`database`, `host` and `port` read from the driver's connection info,
|
|
12
|
+
so each event says which server it went to. The SQL text is recorded
|
|
13
|
+
as `statement` only when the target's `statement` setting is on, as
|
|
14
|
+
the application handed it to the driver and never with its bound
|
|
15
|
+
parameters, which no setting captures.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
SYSTEM = "postgresql"
|
|
23
|
+
|
|
24
|
+
# The argument names under which the drivers take SQL text, and those
|
|
25
|
+
# under which they take its parameters; the capture policy reduces
|
|
26
|
+
# the first to a length and the second to a count.
|
|
27
|
+
|
|
28
|
+
_QUERY_NAMES = frozenset({"query", "sql", "statement", "command"})
|
|
29
|
+
_PARAMETER_NAMES = frozenset(
|
|
30
|
+
{"params", "params_seq", "vars", "vars_list", "parameters", "args", "records"}
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
# File-like arguments (a COPY's source or sink) reduce to their type:
|
|
34
|
+
# their repr can name a path, and their contents are the data.
|
|
35
|
+
|
|
36
|
+
_FILE_NAMES = frozenset({"file", "source", "output"})
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def operation_of(sql: str) -> str:
|
|
40
|
+
"""The SQL's leading keyword, uppercased: the low-cardinality
|
|
41
|
+
operation name the database contract carries."""
|
|
42
|
+
|
|
43
|
+
head = sql.split(None, 1)
|
|
44
|
+
|
|
45
|
+
# A statement may end in its keyword ("COMMIT;"): the terminator is
|
|
46
|
+
# not part of the operation.
|
|
47
|
+
|
|
48
|
+
return head[0].upper().rstrip(";") if head else "?"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def statement_of(query: Any, context: Any = None) -> str | None:
|
|
52
|
+
"""The SQL text of a query as the driver was handed it: a string
|
|
53
|
+
as is, bytes decoded, a composed query (psycopg's `sql.SQL` and
|
|
54
|
+
kin) rendered against the cursor or connection it will run on,
|
|
55
|
+
anything else None."""
|
|
56
|
+
|
|
57
|
+
if isinstance(query, str):
|
|
58
|
+
return query
|
|
59
|
+
|
|
60
|
+
if isinstance(query, (bytes, bytearray, memoryview)):
|
|
61
|
+
try:
|
|
62
|
+
return bytes(query).decode()
|
|
63
|
+
except UnicodeDecodeError:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
render = getattr(query, "as_string", None)
|
|
67
|
+
if callable(render):
|
|
68
|
+
try:
|
|
69
|
+
rendered = render(context)
|
|
70
|
+
except Exception:
|
|
71
|
+
return None
|
|
72
|
+
return rendered if isinstance(rendered, str) else None
|
|
73
|
+
|
|
74
|
+
return None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def server_of(info: Any) -> dict[str, Any]:
|
|
78
|
+
"""The `database`, `host` and `port` keys from a driver's
|
|
79
|
+
connection info object (psycopg's and psycopg2's both expose
|
|
80
|
+
`dbname`, `host` and `port`), whichever of them it can supply."""
|
|
81
|
+
|
|
82
|
+
data: dict[str, Any] = {}
|
|
83
|
+
|
|
84
|
+
for key, attribute in (("database", "dbname"), ("host", "host"), ("port", "port")):
|
|
85
|
+
try:
|
|
86
|
+
value = getattr(info, attribute)
|
|
87
|
+
except Exception:
|
|
88
|
+
continue
|
|
89
|
+
|
|
90
|
+
if value not in (None, ""):
|
|
91
|
+
data[key] = value
|
|
92
|
+
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def statement_data(
|
|
97
|
+
query: Any,
|
|
98
|
+
context: Any,
|
|
99
|
+
server: dict[str, Any],
|
|
100
|
+
record_statement: bool,
|
|
101
|
+
operation: str | None = None,
|
|
102
|
+
) -> dict[str, Any]:
|
|
103
|
+
"""The data for one query event: the contract keys, the server
|
|
104
|
+
keys given, and the statement text when the setting asks for it.
|
|
105
|
+
The operation is the one given, else the query's leading keyword."""
|
|
106
|
+
|
|
107
|
+
text = statement_of(query, context)
|
|
108
|
+
|
|
109
|
+
data: dict[str, Any] = {"system": SYSTEM}
|
|
110
|
+
|
|
111
|
+
if operation is not None:
|
|
112
|
+
data["operation"] = operation
|
|
113
|
+
elif text is not None:
|
|
114
|
+
data["operation"] = operation_of(text)
|
|
115
|
+
|
|
116
|
+
data.update(server)
|
|
117
|
+
|
|
118
|
+
if record_statement and text is not None:
|
|
119
|
+
data["statement"] = text
|
|
120
|
+
|
|
121
|
+
return data
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def query_data(
|
|
125
|
+
query: Any,
|
|
126
|
+
context: Any,
|
|
127
|
+
info: Any,
|
|
128
|
+
record_statement: bool,
|
|
129
|
+
operation: str | None = None,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
"""statement_data() with the server keys read from a driver's
|
|
132
|
+
connection info object (psycopg's and psycopg2's)."""
|
|
133
|
+
|
|
134
|
+
return statement_data(query, context, server_of(info), record_statement, operation)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def captured(name: str | None, value: Any) -> Any:
|
|
138
|
+
"""SQL text reduces to its length, parameters to a count or their
|
|
139
|
+
type (a parameter sequence may be a generator the driver has yet
|
|
140
|
+
to consume, and is never iterated), a COPY's file to its type, a
|
|
141
|
+
context manager exit's exception value and traceback to their
|
|
142
|
+
types, and every unnamed value to its type: the query and its
|
|
143
|
+
data never reach the record through argument capture."""
|
|
144
|
+
|
|
145
|
+
if name in _QUERY_NAMES:
|
|
146
|
+
text = statement_of(value)
|
|
147
|
+
if text is not None:
|
|
148
|
+
return f"<{len(text)} chars>"
|
|
149
|
+
return f"<{type(value).__name__}>"
|
|
150
|
+
|
|
151
|
+
if name in _PARAMETER_NAMES:
|
|
152
|
+
if isinstance(value, (list, tuple, dict)):
|
|
153
|
+
return f"<{len(value)} values>"
|
|
154
|
+
return f"<{type(value).__name__}>"
|
|
155
|
+
|
|
156
|
+
if name in _FILE_NAMES:
|
|
157
|
+
return f"<{type(value).__name__}>"
|
|
158
|
+
|
|
159
|
+
# An exception's message is application data like any other; the
|
|
160
|
+
# exit event's exception, when one escapes, is recorded properly
|
|
161
|
+
# on the event itself.
|
|
162
|
+
|
|
163
|
+
if name in ("exc_value", "exc_val", "traceback", "exc_tb") and value is not None:
|
|
164
|
+
return f"<{type(value).__name__}>"
|
|
165
|
+
|
|
166
|
+
if name is None:
|
|
167
|
+
return f"<{type(value).__name__}>"
|
|
168
|
+
|
|
169
|
+
return value
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# psycopg instrumentation
|
|
2
|
+
|
|
3
|
+
Query and transaction tracing for
|
|
4
|
+
[psycopg](https://www.psycopg.org/psycopg3/) (version 3), the current
|
|
5
|
+
PostgreSQL adapter. Entry point name `psycopg`, the package it
|
|
6
|
+
patches; supports psycopg 3.1 and later, below 4; fully removable.
|
|
7
|
+
Sync and async classes alike, whichever of the pure Python, C or
|
|
8
|
+
binary implementations is installed.
|
|
9
|
+
|
|
10
|
+
## Enabling it
|
|
11
|
+
|
|
12
|
+
An `[[instrument]]` entry in `wrapture.toml` (with at least one sink
|
|
13
|
+
to hear the events):
|
|
14
|
+
|
|
15
|
+
```toml
|
|
16
|
+
[[instrument]]
|
|
17
|
+
name = "psycopg"
|
|
18
|
+
|
|
19
|
+
[[sink]]
|
|
20
|
+
type = "printer"
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
run under wrapture's runner (`python -m wrapture -m myapp`), or in a
|
|
24
|
+
test through the context manager:
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
with wrapture.instrumentation("psycopg"):
|
|
28
|
+
...
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## What you see
|
|
32
|
+
|
|
33
|
+
One `database` leaf per operation: the connection being opened, each
|
|
34
|
+
query however it was issued (a cursor's `execute` or `executemany`,
|
|
35
|
+
or the connection's `execute` shortcut), each streamed query, each
|
|
36
|
+
COPY, and each transaction boundary (`commit`, `rollback`, the
|
|
37
|
+
connection's commit-or-rollback context manager, and a
|
|
38
|
+
`transaction()` block's begin and end):
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
psycopg:connect() -> '<Connection>'
|
|
42
|
+
psycopg:Cursor.execute(query='<29 chars>', params='<1 values>', prepare=None, binary=None) -> '<Cursor>'
|
|
43
|
+
psycopg:Transaction.__enter__() -> '<Transaction>'
|
|
44
|
+
psycopg:Transaction.__exit__(exc_type=None, exc_val=None, exc_tb=None) -> '<bool>'
|
|
45
|
+
psycopg:Connection.commit()
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
- psycopg's classes are pure Python, so the instrumentation binds
|
|
49
|
+
their methods in place: `Cursor.execute` and `executemany` (which
|
|
50
|
+
every cursor class inherits, `ClientCursor` and `RawCursor`
|
|
51
|
+
included, and which the connection's `execute` shortcut calls, so
|
|
52
|
+
it records once), `Cursor.stream`, `Cursor.copy`, the server-side
|
|
53
|
+
cursors' own `execute` (a DECLARE), `Connection.connect` (and the
|
|
54
|
+
`psycopg.connect` spelling of it), `commit`, `rollback`, the
|
|
55
|
+
connection's context manager exit, and the `transaction()` block's
|
|
56
|
+
enter and exit. The async classes are bound the same way and
|
|
57
|
+
record around the await. Connections from a `psycopg_pool` pool
|
|
58
|
+
record like any other, since the bindings sit on the classes.
|
|
59
|
+
|
|
60
|
+
- Every event carries the database contract keys `system`
|
|
61
|
+
(`postgresql`) and `operation` (the SQL's leading keyword, or
|
|
62
|
+
`CONNECT`, `COMMIT`, `ROLLBACK`, `BEGIN`, `SAVEPOINT`, `RELEASE`,
|
|
63
|
+
`DECLARE`, `COPY`), which wrapture's OpenTelemetry export maps to
|
|
64
|
+
`db.system.name` and `db.operation.name`, plus the `database`,
|
|
65
|
+
`host` and `port` the connection reached, from the connection's
|
|
66
|
+
own info, so every span says which server it went to.
|
|
67
|
+
|
|
68
|
+
- A `transaction()` block records what it did: `BEGIN` on entering
|
|
69
|
+
when nothing was open on the connection, `SAVEPOINT` (with the
|
|
70
|
+
savepoint name) for a nested block, and on leaving `COMMIT` or
|
|
71
|
+
`RELEASE`, or `ROLLBACK` when an exception passed through,
|
|
72
|
+
`force_rollback` was set or `psycopg.Rollback` was raised inside.
|
|
73
|
+
The connection's own context manager records its `COMMIT` or
|
|
74
|
+
`ROLLBACK` the same way. The `BEGIN` psycopg sends implicitly
|
|
75
|
+
before the first statement of a transaction goes with that
|
|
76
|
+
statement and folds into its event.
|
|
77
|
+
|
|
78
|
+
- A streamed query (`cursor.stream()`) records one event around the
|
|
79
|
+
whole iteration: its duration is the time spent inside the
|
|
80
|
+
generator and `items` the rows streamed; the event closes when the
|
|
81
|
+
iteration ends or the generator is abandoned.
|
|
82
|
+
|
|
83
|
+
- A COPY (`with cursor.copy(...) as copy:`) records one block event
|
|
84
|
+
spanning the transfer, from entering the block to leaving it,
|
|
85
|
+
labelled `psycopg:Cursor.copy` (or `AsyncCursor.copy`), with the
|
|
86
|
+
operation `COPY` and the rows copied as `rows`.
|
|
87
|
+
|
|
88
|
+
- The capture policy is deliberate about sensitive data: bound
|
|
89
|
+
parameters are never recorded, under any setting (they reduce to a
|
|
90
|
+
count, or to a type name for a parameter sequence the driver has
|
|
91
|
+
yet to consume, which is never iterated); the SQL text reduces to
|
|
92
|
+
its length unless the `statement` setting is on; and the connect
|
|
93
|
+
event captures none of its arguments, which carry the password.
|
|
94
|
+
There is no obfuscation at this layer, because rewriting SQL to
|
|
95
|
+
strip literals is a losing game outside a real lexer: record the
|
|
96
|
+
text where queries are parameterized, leave it off where they are
|
|
97
|
+
not.
|
|
98
|
+
|
|
99
|
+
- A failing statement records the driver's exception
|
|
100
|
+
(`psycopg.errors.UndefinedTable`, say) on the event as it escapes.
|
|
101
|
+
Fetching is not recorded: a query event closes when its execute
|
|
102
|
+
returns, so time spent iterating rows afterwards is not attributed
|
|
103
|
+
to the database, and a server-side cursor's FETCHes from its portal
|
|
104
|
+
go unrecorded for the same reason, its DECLARE being the event.
|
|
105
|
+
|
|
106
|
+
- In pipeline mode (`with conn.pipeline():`) an execute returns as
|
|
107
|
+
soon as the query is queued and the results arrive at the sync
|
|
108
|
+
point, so the statement events are short and the wait sits in the
|
|
109
|
+
pipeline's exit; every statement still records.
|
|
110
|
+
|
|
111
|
+
## Settings
|
|
112
|
+
|
|
113
|
+
| Setting | Default | Controls |
|
|
114
|
+
| ------- | ------- | -------- |
|
|
115
|
+
| `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. |
|
|
116
|
+
|
|
117
|
+
```toml
|
|
118
|
+
[[instrument]]
|
|
119
|
+
name = "psycopg"
|
|
120
|
+
statement = true
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## With the sqlalchemy instrumentation
|
|
124
|
+
|
|
125
|
+
An instrumented psycopg beneath the core package's `sqlalchemy`
|
|
126
|
+
target composes through that target's `leaf` setting. With the
|
|
127
|
+
default `leaf = true` each statement is one event and the driver's
|
|
128
|
+
own events stay out of the tree; with `leaf = false` the driver's
|
|
129
|
+
events nest beneath each statement, `psycopg:Cursor.execute` under
|
|
130
|
+
`do_execute`, `psycopg:connect` under the dialect's `connect`, the
|
|
131
|
+
async engine's `AsyncCursor.execute` likewise. Raw psycopg use beside
|
|
132
|
+
the engine records at the top level either way. A little of the
|
|
133
|
+
dialect's own housekeeping also shows up regardless, because it runs
|
|
134
|
+
straight against the driver outside the recorded seams: the type
|
|
135
|
+
lookups the psycopg dialect makes when it opens a connection, and
|
|
136
|
+
the pool's reset-on-return rollback.
|
|
137
|
+
|
|
138
|
+
## How it patches
|
|
139
|
+
|
|
140
|
+
For the implementation detail see the module docstrings of
|
|
141
|
+
[cursor.py](cursor.py) (the execute family, stream and COPY),
|
|
142
|
+
[connection.py](connection.py) (connect and the connection's own
|
|
143
|
+
boundaries) and [transaction.py](transaction.py) (the `transaction()`
|
|
144
|
+
block).
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Instrumentation for psycopg (version 3): every query, the
|
|
2
|
+
connections opened and the transaction boundaries recorded as
|
|
3
|
+
database events, by bindings on the pure Python classes the driver
|
|
4
|
+
is made of.
|
|
5
|
+
|
|
6
|
+
This module imports only wrapture. Everything that touches psycopg
|
|
7
|
+
lives in the sibling modules, one per kind of seam (cursor.py for the
|
|
8
|
+
execute family, stream and COPY; connection.py for connect, commit,
|
|
9
|
+
rollback and the connection's context manager; transaction.py for
|
|
10
|
+
`transaction()` blocks), each importing only wrapture at top level
|
|
11
|
+
and reaching psycopg through the package the hook is handed, so
|
|
12
|
+
loading this class when a config loads never imports psycopg ahead of
|
|
13
|
+
the hook meant to fire on its import.
|
|
14
|
+
|
|
15
|
+
One trigger suffices: importing psycopg initialises every submodule
|
|
16
|
+
the seams live in, so by the time the hook fires all the classes
|
|
17
|
+
exist under the package.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import wrapture
|
|
25
|
+
from wrapture import Setting
|
|
26
|
+
|
|
27
|
+
from . import connection, cursor, transaction
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class PsycopgInstrumentation(wrapture.Instrumentation):
|
|
31
|
+
"""Query and transaction tracing for psycopg (version 3)."""
|
|
32
|
+
|
|
33
|
+
description = "Query and transaction tracing for psycopg (version 3)."
|
|
34
|
+
|
|
35
|
+
target = "psycopg"
|
|
36
|
+
supports = ">=3.1,<4"
|
|
37
|
+
removable = True
|
|
38
|
+
|
|
39
|
+
settings = {
|
|
40
|
+
"statement": Setting(
|
|
41
|
+
False,
|
|
42
|
+
"record the SQL text as handed to the driver on each query"
|
|
43
|
+
" event; off by default because the driver cannot tell a"
|
|
44
|
+
" literal an application interpolated from a placeholder,"
|
|
45
|
+
" and the text is only safe to record when queries are"
|
|
46
|
+
" parameterized",
|
|
47
|
+
),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
@wrapture.instrumentation_hook("psycopg")
|
|
51
|
+
def psycopg(self, name: str, module: Any) -> None:
|
|
52
|
+
"""Bind the cursor, connection and transaction seams once
|
|
53
|
+
psycopg exists."""
|
|
54
|
+
|
|
55
|
+
cursor.instrument(module, self)
|
|
56
|
+
connection.instrument(module, self)
|
|
57
|
+
transaction.instrument(module, self)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""The connection seams: connections being opened, and the
|
|
2
|
+
transaction boundaries the connection itself performs, recorded as
|
|
3
|
+
database events.
|
|
4
|
+
|
|
5
|
+
`Connection.connect` and `AsyncConnection.connect` are the
|
|
6
|
+
classmethods every connection comes from: the module-level
|
|
7
|
+
`psycopg.connect` is the same classmethod under another name, bound
|
|
8
|
+
at import, so it is bound as a module attribute too, as sqlite3's two
|
|
9
|
+
spellings of `connect` are; a pool (psycopg_pool) calls the class's
|
|
10
|
+
method, so pooled connections record through it. The connect event's
|
|
11
|
+
arguments are never captured (the conninfo carries a password); the
|
|
12
|
+
server it reached is annotated from the connection's info afterwards.
|
|
13
|
+
|
|
14
|
+
`commit()` and `rollback()` go to libpq directly rather than through
|
|
15
|
+
a cursor, so they are bound in their own right. The connection's
|
|
16
|
+
context manager exit commits, or rolls back when an exception is on
|
|
17
|
+
its way through, then closes the connection unless it belongs to a
|
|
18
|
+
pool; the exit is bound and records which of the two it performed.
|
|
19
|
+
The BEGIN psycopg issues implicitly before the first statement of a
|
|
20
|
+
transaction is sent inside that statement's execute, so it folds into
|
|
21
|
+
that event.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import wrapture
|
|
29
|
+
|
|
30
|
+
from ..common import SYSTEM, captured, server_of
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def instrument(module: Any, instrumentation: wrapture.Instrumentation) -> None:
|
|
34
|
+
"""Bind connect, commit, rollback and the context manager exit on
|
|
35
|
+
the sync and async connection classes; register their removal as
|
|
36
|
+
this trigger's cleanup."""
|
|
37
|
+
|
|
38
|
+
def opens(
|
|
39
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
40
|
+
) -> Any:
|
|
41
|
+
wrapture.annotate(system=SYSTEM, operation="CONNECT")
|
|
42
|
+
|
|
43
|
+
connection = wrapped(*args, **kwargs)
|
|
44
|
+
wrapture.annotate(**server_of(connection.info))
|
|
45
|
+
|
|
46
|
+
return connection
|
|
47
|
+
|
|
48
|
+
async def opens_async(
|
|
49
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
50
|
+
) -> Any:
|
|
51
|
+
wrapture.annotate(system=SYSTEM, operation="CONNECT")
|
|
52
|
+
|
|
53
|
+
connection = await wrapped(*args, **kwargs)
|
|
54
|
+
wrapture.annotate(**server_of(connection.info))
|
|
55
|
+
|
|
56
|
+
return connection
|
|
57
|
+
|
|
58
|
+
def performs(operation: str) -> Any:
|
|
59
|
+
def record(
|
|
60
|
+
wrapped: Any,
|
|
61
|
+
instance: Any,
|
|
62
|
+
args: tuple[Any, ...],
|
|
63
|
+
kwargs: dict[str, Any],
|
|
64
|
+
) -> Any:
|
|
65
|
+
wrapture.annotate(
|
|
66
|
+
system=SYSTEM, operation=operation, **server_of(instance.info)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return wrapped(*args, **kwargs)
|
|
70
|
+
|
|
71
|
+
return record
|
|
72
|
+
|
|
73
|
+
def performs_async(operation: str) -> Any:
|
|
74
|
+
async def record(
|
|
75
|
+
wrapped: Any,
|
|
76
|
+
instance: Any,
|
|
77
|
+
args: tuple[Any, ...],
|
|
78
|
+
kwargs: dict[str, Any],
|
|
79
|
+
) -> Any:
|
|
80
|
+
wrapture.annotate(
|
|
81
|
+
system=SYSTEM, operation=operation, **server_of(instance.info)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return await wrapped(*args, **kwargs)
|
|
85
|
+
|
|
86
|
+
return record
|
|
87
|
+
|
|
88
|
+
def exit_operation(args: tuple[Any, ...], kwargs: dict[str, Any]) -> str:
|
|
89
|
+
# The exit commits unless an exception is on its way through.
|
|
90
|
+
|
|
91
|
+
exc_type = args[0] if args else kwargs.get("exc_type")
|
|
92
|
+
|
|
93
|
+
return "COMMIT" if exc_type is None else "ROLLBACK"
|
|
94
|
+
|
|
95
|
+
def leaves(
|
|
96
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
97
|
+
) -> Any:
|
|
98
|
+
wrapture.annotate(
|
|
99
|
+
system=SYSTEM,
|
|
100
|
+
operation=exit_operation(args, kwargs),
|
|
101
|
+
**server_of(instance.info),
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
return wrapped(*args, **kwargs)
|
|
105
|
+
|
|
106
|
+
async def leaves_async(
|
|
107
|
+
wrapped: Any, instance: Any, args: tuple[Any, ...], kwargs: dict[str, Any]
|
|
108
|
+
) -> Any:
|
|
109
|
+
wrapture.annotate(
|
|
110
|
+
system=SYSTEM,
|
|
111
|
+
operation=exit_operation(args, kwargs),
|
|
112
|
+
**server_of(instance.info),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
return await wrapped(*args, **kwargs)
|
|
116
|
+
|
|
117
|
+
def database_binding(
|
|
118
|
+
owner: Any, name: str, capture_args: Any = captured
|
|
119
|
+
) -> wrapture.Binding:
|
|
120
|
+
return wrapture.binding(
|
|
121
|
+
owner,
|
|
122
|
+
name,
|
|
123
|
+
category="database",
|
|
124
|
+
leaf=True,
|
|
125
|
+
capture_args=capture_args,
|
|
126
|
+
capture_result=captured,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
named: dict[str, wrapture.Binding] = {}
|
|
130
|
+
|
|
131
|
+
# The connect classmethods, and the module-level spelling of the
|
|
132
|
+
# sync one; none captures its arguments.
|
|
133
|
+
|
|
134
|
+
connect = database_binding(module.Connection, "connect", "none")
|
|
135
|
+
connect.on_call.decorates(opens)
|
|
136
|
+
named["connect"] = connect
|
|
137
|
+
|
|
138
|
+
async_connect = database_binding(module.AsyncConnection, "connect", "none")
|
|
139
|
+
async_connect.on_call.decorates(opens_async)
|
|
140
|
+
named["async_connect"] = async_connect
|
|
141
|
+
|
|
142
|
+
module_connect = database_binding(module, "connect", "none")
|
|
143
|
+
module_connect.on_call.decorates(opens)
|
|
144
|
+
named["module_connect"] = module_connect
|
|
145
|
+
|
|
146
|
+
# The transaction boundaries the connection performs itself.
|
|
147
|
+
|
|
148
|
+
for method, operation in (("commit", "COMMIT"), ("rollback", "ROLLBACK")):
|
|
149
|
+
bound = database_binding(module.Connection, method)
|
|
150
|
+
bound.on_call.decorates(performs(operation))
|
|
151
|
+
named[method] = bound
|
|
152
|
+
|
|
153
|
+
bound = database_binding(module.AsyncConnection, method)
|
|
154
|
+
bound.on_call.decorates(performs_async(operation))
|
|
155
|
+
named[f"async_{method}"] = bound
|
|
156
|
+
|
|
157
|
+
closes = database_binding(module.Connection, "__exit__")
|
|
158
|
+
closes.on_call.decorates(leaves)
|
|
159
|
+
named["exit"] = closes
|
|
160
|
+
|
|
161
|
+
async_closes = database_binding(module.AsyncConnection, "__aexit__")
|
|
162
|
+
async_closes.on_call.decorates(leaves_async)
|
|
163
|
+
named["async_exit"] = async_closes
|
|
164
|
+
|
|
165
|
+
group = wrapture.bindings(**named)
|
|
166
|
+
group.apply()
|
|
167
|
+
|
|
168
|
+
instrumentation.on_cleanup(group.remove)
|