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,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)