Skip to content

Commit 228b96b

Browse files
minhtulebasepi
andauthored
Support Psycopg3 synchronous operations (#1841)
* Support Psycopg3 * Remove an unnecessary test for calling stored function * Add `psycopg-newest` to `.matrix_framework.yml` * CHANGELOG --------- Co-authored-by: Colton Myers <[email protected]>
1 parent 5f3df16 commit 228b96b

File tree

8 files changed

+364
-0
lines changed

8 files changed

+364
-0
lines changed

.ci/.matrix_framework.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ FRAMEWORK:
2424
- redis-newest
2525
- aioredis-newest
2626
#- aioredis-2 # not supported yet
27+
- psycopg-newest
2728
- psycopg2-newest
2829
- pymssql-newest
2930
- pyodbc-newest

.ci/.matrix_framework_full.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ FRAMEWORK:
4646
- redis-3
4747
- redis-2
4848
- redis-newest
49+
- psycopg-newest
4950
- psycopg2-newest
5051
- pymssql-newest
5152
- memcached-newest

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ endif::[]
4747
* Add support for urllib3 v2.0.1+ {pull}1822[#1822]
4848
* Add `service.environment` to log correlation {pull}1833[#1833]
4949
* Add `ecs_logging` as a dependency {pull}1840[#1840]
50+
* Add support for synchronous psycopg3 {pull}1841[#1841]
5051
5152
[float]
5253
===== Bug fixes
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
# BSD 3-Clause License
2+
#
3+
# Copyright (c) 2019, Elasticsearch BV
4+
# All rights reserved.
5+
#
6+
# Redistribution and use in source and binary forms, with or without
7+
# modification, are permitted provided that the following conditions are met:
8+
#
9+
# * Redistributions of source code must retain the above copyright notice, this
10+
# list of conditions and the following disclaimer.
11+
#
12+
# * Redistributions in binary form must reproduce the above copyright notice,
13+
# this list of conditions and the following disclaimer in the documentation
14+
# and/or other materials provided with the distribution.
15+
#
16+
# * Neither the name of the copyright holder nor the names of its
17+
# contributors may be used to endorse or promote products derived from
18+
# this software without specific prior written permission.
19+
#
20+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
from __future__ import absolute_import
31+
32+
from elasticapm.instrumentation.packages.dbapi2 import (
33+
ConnectionProxy,
34+
CursorProxy,
35+
DbApi2Instrumentation,
36+
extract_signature,
37+
)
38+
from elasticapm.instrumentation.packages.psycopg2 import get_destination_info
39+
from elasticapm.traces import capture_span
40+
41+
42+
class PGCursorProxy(CursorProxy):
43+
provider_name = "postgresql"
44+
45+
def _bake_sql(self, sql):
46+
# If this is a Composable object, use its `as_string` method.
47+
# See https://www.psycopg.org/psycopg3/docs/api/sql.html
48+
if hasattr(sql, "as_string"):
49+
sql = sql.as_string(self.__wrapped__)
50+
# If the sql string is already a byte string, we need to decode it using the connection encoding
51+
if isinstance(sql, bytes):
52+
sql = sql.decode(self.connection.info.encoding)
53+
return sql
54+
55+
def extract_signature(self, sql):
56+
return extract_signature(sql)
57+
58+
def __enter__(self):
59+
return PGCursorProxy(self.__wrapped__.__enter__(), destination_info=self._self_destination_info)
60+
61+
@property
62+
def _self_database(self):
63+
return self.connection.info.dbname or ""
64+
65+
66+
class PGConnectionProxy(ConnectionProxy):
67+
cursor_proxy = PGCursorProxy
68+
69+
def __enter__(self):
70+
return PGConnectionProxy(self.__wrapped__.__enter__(), destination_info=self._self_destination_info)
71+
72+
73+
class PsycopgInstrumentation(DbApi2Instrumentation):
74+
name = "psycopg"
75+
76+
instrument_list = [("psycopg", "connect")]
77+
78+
def call(self, module, method, wrapped, instance, args, kwargs):
79+
signature = "psycopg.connect"
80+
81+
host, port = get_destination_info(kwargs.get("host"), kwargs.get("port"))
82+
database = kwargs.get("dbname")
83+
signature = f"{signature} {host}:{port}"
84+
destination_info = {
85+
"address": host,
86+
"port": port,
87+
}
88+
with capture_span(
89+
signature,
90+
span_type="db",
91+
span_subtype="postgresql",
92+
span_action="connect",
93+
leaf=True,
94+
extra={"destination": destination_info, "db": {"type": "sql", "instance": database}},
95+
):
96+
return PGConnectionProxy(wrapped(*args, **kwargs), destination_info=destination_info)

elasticapm/instrumentation/register.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"elasticapm.instrumentation.packages.botocore.BotocoreInstrumentation",
3737
"elasticapm.instrumentation.packages.httpx.sync.httpx.HttpxClientInstrumentation",
3838
"elasticapm.instrumentation.packages.jinja2.Jinja2Instrumentation",
39+
"elasticapm.instrumentation.packages.psycopg.PsycopgInstrumentation",
3940
"elasticapm.instrumentation.packages.psycopg2.Psycopg2Instrumentation",
4041
"elasticapm.instrumentation.packages.psycopg2.Psycopg2ExtensionsInstrumentation",
4142
"elasticapm.instrumentation.packages.mysql.MySQLInstrumentation",
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
# -*- coding: utf-8 -*-
2+
3+
# BSD 3-Clause License
4+
#
5+
# Copyright (c) 2019, Elasticsearch BV
6+
# All rights reserved.
7+
#
8+
# Redistribution and use in source and binary forms, with or without
9+
# modification, are permitted provided that the following conditions are met:
10+
#
11+
# * Redistributions of source code must retain the above copyright notice, this
12+
# list of conditions and the following disclaimer.
13+
#
14+
# * Redistributions in binary form must reproduce the above copyright notice,
15+
# this list of conditions and the following disclaimer in the documentation
16+
# and/or other materials provided with the distribution.
17+
#
18+
# * Neither the name of the copyright holder nor the names of its
19+
# contributors may be used to endorse or promote products derived from
20+
# this software without specific prior written permission.
21+
#
22+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
23+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
24+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
25+
# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
26+
# FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
27+
# DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
28+
# SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29+
# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
30+
# OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
31+
# OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
32+
33+
import os
34+
from typing import cast
35+
36+
import pytest
37+
38+
from elasticapm import get_client
39+
from elasticapm.conf.constants import SPAN, TRANSACTION
40+
from elasticapm.instrumentation.packages.psycopg import PGCursorProxy
41+
from elasticapm.utils import default_ports
42+
from tests.fixtures import TempStoreClient
43+
44+
psycopg = pytest.importorskip("psycopg")
45+
46+
pytestmark = pytest.mark.psycopg
47+
48+
has_postgres_configured = "POSTGRES_DB" in os.environ
49+
50+
51+
def connect_kwargs():
52+
return {
53+
"dbname": os.environ.get("POSTGRES_DB", "elasticapm_test"),
54+
"user": os.environ.get("POSTGRES_USER", "postgres"),
55+
"password": os.environ.get("POSTGRES_PASSWORD", "postgres"),
56+
"host": os.environ.get("POSTGRES_HOST", None),
57+
"port": os.environ.get("POSTGRES_PORT", None),
58+
}
59+
60+
61+
@pytest.fixture(scope="function")
62+
def postgres_connection(request):
63+
conn = psycopg.connect(**connect_kwargs())
64+
cursor = conn.cursor()
65+
cursor.execute(
66+
"CREATE TABLE test(id int, name VARCHAR(5) NOT NULL);"
67+
"INSERT INTO test VALUES (1, 'one'), (2, 'two'), (3, 'three');"
68+
)
69+
70+
yield conn
71+
72+
# cleanup
73+
cursor.execute("ROLLBACK")
74+
75+
76+
@pytest.mark.integrationtest
77+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
78+
def test_destination(instrument, postgres_connection, elasticapm_client):
79+
elasticapm_client.begin_transaction("test")
80+
cursor = postgres_connection.cursor()
81+
cursor.execute("SELECT 1")
82+
elasticapm_client.end_transaction("test")
83+
transaction = elasticapm_client.events[TRANSACTION][0]
84+
span = elasticapm_client.spans_for_transaction(transaction)[0]
85+
assert span["context"]["destination"] == {
86+
"address": os.environ.get("POSTGRES_HOST", None),
87+
"port": default_ports["postgresql"],
88+
"service": {"name": "", "resource": "postgresql/elasticapm_test", "type": ""},
89+
}
90+
91+
92+
@pytest.mark.integrationtest
93+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
94+
def test_psycopg_tracing_outside_of_elasticapm_transaction(instrument, postgres_connection, elasticapm_client):
95+
cursor = postgres_connection.cursor()
96+
# check that the cursor is a proxy, even though we're not in an elasticapm
97+
# transaction
98+
assert isinstance(cursor, PGCursorProxy)
99+
cursor.execute("SELECT 1")
100+
transactions = elasticapm_client.events[TRANSACTION]
101+
assert not transactions
102+
103+
104+
@pytest.mark.integrationtest
105+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
106+
def test_psycopg_select_LIKE(instrument, postgres_connection, elasticapm_client):
107+
"""
108+
Check that we pass queries with %-notation but without parameters
109+
properly to the dbapi backend
110+
"""
111+
cursor = postgres_connection.cursor()
112+
query = "SELECT * FROM test WHERE name LIKE 't%'"
113+
114+
try:
115+
elasticapm_client.begin_transaction("web.django")
116+
cursor.execute(query)
117+
cursor.fetchall()
118+
elasticapm_client.end_transaction(None, "test-transaction")
119+
finally:
120+
# make sure we've cleared out the spans for the other tests.
121+
transactions = elasticapm_client.events[TRANSACTION]
122+
spans = elasticapm_client.spans_for_transaction(transactions[0])
123+
span = spans[0]
124+
assert span["name"] == "SELECT FROM test"
125+
assert span["type"] == "db"
126+
assert span["subtype"] == "postgresql"
127+
assert span["action"] == "query"
128+
assert "db" in span["context"]
129+
assert span["context"]["db"]["instance"] == "elasticapm_test"
130+
assert span["context"]["db"]["type"] == "sql"
131+
assert span["context"]["db"]["statement"] == query
132+
assert span["context"]["service"]["target"]["type"] == "postgresql"
133+
assert span["context"]["service"]["target"]["name"] == "elasticapm_test"
134+
135+
136+
@pytest.mark.integrationtest
137+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
138+
def test_psycopg_composable_query_works(instrument, postgres_connection, elasticapm_client):
139+
"""
140+
Check that we parse queries that are psycopg.sql.Composable correctly
141+
"""
142+
from psycopg import sql
143+
144+
cursor = postgres_connection.cursor()
145+
query = sql.SQL("SELECT * FROM {table} WHERE {row} LIKE 't%' ORDER BY {row} DESC").format(
146+
table=sql.Identifier("test"), row=sql.Identifier("name")
147+
)
148+
baked_query = query.as_string(cursor.__wrapped__)
149+
result = None
150+
try:
151+
elasticapm_client.begin_transaction("web.django")
152+
cursor.execute(query)
153+
result = cursor.fetchall()
154+
elasticapm_client.end_transaction(None, "test-transaction")
155+
finally:
156+
# make sure we've cleared out the spans for the other tests.
157+
assert [(2, "two"), (3, "three")] == result
158+
transactions = elasticapm_client.events[TRANSACTION]
159+
spans = elasticapm_client.spans_for_transaction(transactions[0])
160+
span = spans[0]
161+
assert span["name"] == "SELECT FROM test"
162+
assert "db" in span["context"]
163+
assert span["context"]["db"]["instance"] == "elasticapm_test"
164+
assert span["context"]["db"]["type"] == "sql"
165+
assert span["context"]["db"]["statement"] == baked_query
166+
167+
168+
@pytest.mark.integrationtest
169+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
170+
def test_psycopg_binary_query_works(instrument, postgres_connection, elasticapm_client):
171+
"""
172+
Check that we pass queries with %-notation but without parameters
173+
properly to the dbapi backend
174+
"""
175+
cursor = postgres_connection.cursor()
176+
query = b"SELECT * FROM test WHERE name LIKE 't%'"
177+
178+
baked_query = query.decode()
179+
try:
180+
elasticapm_client.begin_transaction("web.django")
181+
cursor.execute(query)
182+
result = cursor.fetchall()
183+
elasticapm_client.end_transaction(None, "test-transaction")
184+
finally:
185+
# make sure we've cleared out the spans for the other tests.
186+
assert [(2, "two"), (3, "three")] == result
187+
transactions = elasticapm_client.events[TRANSACTION]
188+
spans = elasticapm_client.spans_for_transaction(transactions[0])
189+
span = spans[0]
190+
assert span["name"] == "SELECT FROM test"
191+
assert "db" in span["context"]
192+
assert span["context"]["db"]["instance"] == "elasticapm_test"
193+
assert span["context"]["db"]["type"] == "sql"
194+
assert span["context"]["db"]["statement"] == baked_query
195+
196+
197+
@pytest.mark.integrationtest
198+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
199+
def test_psycopg_context_manager(instrument, elasticapm_client):
200+
elasticapm_client.begin_transaction("test")
201+
with psycopg.connect(**connect_kwargs()) as conn:
202+
with conn.cursor() as curs:
203+
curs.execute("SELECT 1;")
204+
curs.fetchall()
205+
elasticapm_client.end_transaction("test", "OK")
206+
transactions = elasticapm_client.events[TRANSACTION]
207+
spans = elasticapm_client.spans_for_transaction(transactions[0])
208+
assert len(spans) == 2
209+
assert spans[0]["subtype"] == "postgresql"
210+
assert spans[0]["action"] == "connect"
211+
assert spans[0]["context"]["service"]["target"]["type"] == "postgresql"
212+
assert spans[0]["context"]["service"]["target"]["name"] == "elasticapm_test"
213+
214+
assert spans[1]["subtype"] == "postgresql"
215+
assert spans[1]["action"] == "query"
216+
217+
218+
@pytest.mark.integrationtest
219+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
220+
def test_psycopg_rows_affected(instrument, postgres_connection, elasticapm_client):
221+
cursor = postgres_connection.cursor()
222+
try:
223+
elasticapm_client.begin_transaction("web.django")
224+
cursor.execute("INSERT INTO test VALUES (4, 'four')")
225+
cursor.execute("SELECT * FROM test")
226+
cursor.execute("UPDATE test SET name = 'five' WHERE id = 4")
227+
cursor.execute("DELETE FROM test WHERE id = 4")
228+
elasticapm_client.end_transaction(None, "test-transaction")
229+
finally:
230+
transactions = elasticapm_client.events[TRANSACTION]
231+
spans = elasticapm_client.spans_for_transaction(transactions[0])
232+
233+
assert spans[0]["name"] == "INSERT INTO test"
234+
assert spans[0]["context"]["db"]["rows_affected"] == 1
235+
236+
assert spans[1]["name"] == "SELECT FROM test"
237+
assert "rows_affected" not in spans[1]["context"]["db"]
238+
239+
assert spans[2]["name"] == "UPDATE test"
240+
assert spans[2]["context"]["db"]["rows_affected"] == 1
241+
242+
assert spans[3]["name"] == "DELETE FROM test"
243+
assert spans[3]["context"]["db"]["rows_affected"] == 1
244+
245+
246+
@pytest.mark.integrationtest
247+
def test_psycopg_connection(instrument, elasticapm_transaction, postgres_connection):
248+
# elastciapm_client.events is only available on `TempStoreClient`, this keeps the type checkers happy
249+
elasticapm_client = cast(TempStoreClient, get_client())
250+
elasticapm_client.end_transaction("test", "success")
251+
span = elasticapm_client.events[SPAN][0]
252+
host = os.environ.get("POSTGRES_HOST", "localhost")
253+
assert span["name"] == f"psycopg.connect {host}:5432"
254+
assert span["action"] == "connect"
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
psycopg
2+
-r reqs-base.txt

tests/scripts/envs/psycopg.sh

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export PYTEST_MARKER="-m psycopg"
2+
export DOCKER_DEPS="postgres"
3+
export POSTGRES_HOST="postgres"
4+
export POSTGRES_USER="postgres"
5+
export POSTGRES_PASSWORD="postgres"
6+
export POSTGRES_DB="elasticapm_test"
7+
export POSTGRES_HOST="postgres"
8+
export POSTGRES_PORT="5432"

0 commit comments

Comments
 (0)