Skip to content

Commit ff43877

Browse files
committed
fix extract_signature to work with sql.Composable objects (#148)
see http://initd.org/psycopg/docs/sql.html closes #148
1 parent 6cdf7ea commit ff43877

File tree

5 files changed

+109
-52
lines changed

5 files changed

+109
-52
lines changed

CHANGELOG.asciidoc

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ https://github.com/elastic/apm-agent-python/compare/v1.0.0\...master[Check the H
2222
* added `transaction_max_spans` setting to limit the amount of spans that are recorded per transaction ({pull}127[#127])
2323
* added configuration options to limit captured local variables to a certain length ({pull}130[#130])
2424
* added options for configuring the amount of context lines that are captured with each frame ({pull}136[#136])
25+
* added support for tracing queries formatted as http://initd.org/psycopg/docs/sql.html[`psycopg2.sql.SQL`] objects ({pull}148[#148])
2526
* switched to `time.perf_counter` as timing function on Python 3 ({pull}138[#138])
2627
* BREAKING: Several settings and APIs have been renamed ({pull}111[#111], {pull}119[#119], {pull}143[#143]):
2728
** The decorator for custom instrumentation, `elasticapm.trace`, is now `elasticapm.capture_span`

elasticapm/instrumentation/packages/dbapi2.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ def scan(tokens):
121121

122122

123123
def extract_signature(sql):
124+
"""
125+
Extracts a minimal signature from a given SQL query
126+
:param sql: the SQL statement
127+
:return: a string representing the signature
128+
"""
124129
sql = sql.strip()
125130
first_space = sql.find(' ')
126131
if first_space < 0:
@@ -170,10 +175,18 @@ def executemany(self, sql, param_list):
170175
return self._trace_sql(self.__wrapped__.executemany, sql,
171176
param_list)
172177

178+
def _bake_sql(self, sql):
179+
"""
180+
Method to turn the "sql" argument into a string. Most database backends simply return
181+
the given object, as it is already a string
182+
"""
183+
return sql
184+
173185
def _trace_sql(self, method, sql, params):
174-
signature = self.extract_signature(sql)
186+
sql_string = self._bake_sql(sql)
187+
signature = self.extract_signature(sql_string)
175188
kind = "db.{0}.sql".format(self.provider_name)
176-
with capture_span(signature, kind, {'db': {"type": "sql", "statement": sql}}):
189+
with capture_span(signature, kind, {'db': {"type": "sql", "statement": sql_string}}):
177190
if params is None:
178191
return method(sql)
179192
else:

elasticapm/instrumentation/packages/psycopg2.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@
99
class PGCursorProxy(CursorProxy):
1010
provider_name = 'postgresql'
1111

12+
def _bake_sql(self, sql):
13+
# if this is a Composable object, use its `as_string` method
14+
# see http://initd.org/psycopg/docs/sql.html
15+
if hasattr(sql, 'as_string'):
16+
return sql.as_string(self.__wrapped__)
17+
return sql
18+
1219
def extract_signature(self, sql):
1320
return extract_signature(sql)
1421

setup.cfg

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ multi_line_output=0
2525
known_standard_library=importlib,types,asyncio
2626
known_django=django
2727
known_first_party=elasticapm,tests
28-
known_third_party=pytest,flask,aiohttp,urllib3_mock,webob,memcache,pymongo,boto3,logbook,twisted,celery,zope,urllib3,redis,jinja2,requests,certifi,mock,jsonschema,werkzeug,pytest_localserver
28+
known_third_party=pytest,flask,aiohttp,urllib3_mock,webob,memcache,pymongo,boto3,logbook,twisted,celery,zope,urllib3,redis,jinja2,requests,certifi,mock,jsonschema,werkzeug,pytest_localserver,psycopg2
2929
default_section=FIRSTPARTY
3030
sections=FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER
3131

tests/instrumentation/psycopg2_tests.py

Lines changed: 85 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
# -*- coding: utf-8 -*-
22
import os
33

4+
import psycopg2
45
import pytest
56

67
from elasticapm.instrumentation import control
78
from elasticapm.instrumentation.packages.psycopg2 import (PGCursorProxy,
89
extract_signature)
910

1011
try:
11-
import psycopg2
12-
has_psycopg2 = True
12+
from psycopg2 import sql
13+
has_sql_module = True
1314
except ImportError:
14-
has_psycopg2 = False
15+
# as of Jan 2018, psycopg2cffi doesn't have this module
16+
has_sql_module = False
17+
1518

1619
has_postgres_configured = 'POSTGRES_DB' in os.environ
1720

@@ -37,100 +40,100 @@ def postgres_connection(request):
3740

3841

3942
def test_insert():
40-
sql = """INSERT INTO mytable (id, name) VALUE ('2323', 'Ron')"""
41-
actual = extract_signature(sql)
43+
sql_statement = """INSERT INTO mytable (id, name) VALUE ('2323', 'Ron')"""
44+
actual = extract_signature(sql_statement)
4245

4346
assert "INSERT INTO mytable" == actual
4447

4548

4649
def test_update_with_quotes():
47-
sql = """UPDATE "my table" set name='Ron' WHERE id = 2323"""
48-
actual = extract_signature(sql)
50+
sql_statement = """UPDATE "my table" set name='Ron' WHERE id = 2323"""
51+
actual = extract_signature(sql_statement)
4952

5053
assert "UPDATE my table" == actual
5154

5255

5356
def test_update():
54-
sql = """update mytable set name = 'Ron where id = 'a'"""
55-
actual = extract_signature(sql)
57+
sql_statement = """update mytable set name = 'Ron where id = 'a'"""
58+
actual = extract_signature(sql_statement)
5659

5760
assert "UPDATE mytable" == actual
5861

5962

6063
def test_delete_simple():
61-
sql = 'DELETE FROM "mytable"'
62-
actual = extract_signature(sql)
64+
sql_statement = 'DELETE FROM "mytable"'
65+
actual = extract_signature(sql_statement)
6366

6467
assert "DELETE FROM mytable" == actual
6568

6669

6770
def test_delete():
68-
sql = """DELETE FROM "my table" WHERE id = 2323"""
69-
actual = extract_signature(sql)
71+
sql_statement = """DELETE FROM "my table" WHERE id = 2323"""
72+
actual = extract_signature(sql_statement)
7073

7174
assert "DELETE FROM my table" == actual
7275

7376

7477
def test_select_simple():
75-
sql = """SELECT id, name FROM my_table WHERE id = 2323"""
76-
actual = extract_signature(sql)
78+
sql_statement = """SELECT id, name FROM my_table WHERE id = 2323"""
79+
actual = extract_signature(sql_statement)
7780

7881
assert "SELECT FROM my_table" == actual
7982

8083

8184
def test_select_with_entity_quotes():
82-
sql = """SELECT id, name FROM "mytable" WHERE id = 2323"""
83-
actual = extract_signature(sql)
85+
sql_statement = """SELECT id, name FROM "mytable" WHERE id = 2323"""
86+
actual = extract_signature(sql_statement)
8487

8588
assert "SELECT FROM mytable" == actual
8689

8790

8891
def test_select_with_difficult_values():
89-
sql = """SELECT id, 'some name' + '" from Denmark' FROM "mytable" WHERE id = 2323"""
90-
actual = extract_signature(sql)
92+
sql_statement = """SELECT id, 'some name' + '" from Denmark' FROM "mytable" WHERE id = 2323"""
93+
actual = extract_signature(sql_statement)
9194

9295
assert "SELECT FROM mytable" == actual
9396

9497

9598
def test_select_with_dollar_quotes():
96-
sql = """SELECT id, $$some single doubles ' $$ + '" from Denmark' FROM "mytable" WHERE id = 2323"""
97-
actual = extract_signature(sql)
99+
sql_statement = """SELECT id, $$some single doubles ' $$ + '" from Denmark' FROM "mytable" WHERE id = 2323"""
100+
actual = extract_signature(sql_statement)
98101

99102
assert "SELECT FROM mytable" == actual
100103

101104

102105
def test_select_with_invalid_dollar_quotes():
103-
sql = """SELECT id, $fish$some single doubles ' $$ + '" from Denmark' FROM "mytable" WHERE id = 2323"""
104-
actual = extract_signature(sql)
106+
sql_statement = """SELECT id, $fish$some single doubles ' $$ + '" from Denmark' FROM "mytable" WHERE id = 2323"""
107+
actual = extract_signature(sql_statement)
105108

106109
assert "SELECT FROM" == actual
107110

108111

109112
def test_select_with_dollar_quotes_custom_token():
110-
sql = """SELECT id, $token $FROM $ FROM $ FROM single doubles ' $token $ + '" from Denmark' FROM "mytable" WHERE id = 2323"""
111-
actual = extract_signature(sql)
113+
sql_statement = """SELECT id, $token $FROM $ FROM $ FROM single doubles ' $token $ + '" from Denmark' FROM "mytable" WHERE id = 2323"""
114+
actual = extract_signature(sql_statement)
112115

113116
assert "SELECT FROM mytable" == actual
114117

115118

116119
def test_select_with_difficult_table_name():
117-
sql = "SELECT id FROM \"myta\n-æøåble\" WHERE id = 2323"""
118-
actual = extract_signature(sql)
120+
sql_statement = "SELECT id FROM \"myta\n-æøåble\" WHERE id = 2323"""
121+
actual = extract_signature(sql_statement)
119122

120123
assert "SELECT FROM myta\n-æøåble" == actual
121124

122125

123126
def test_select_subselect():
124-
sql = """SELECT id, name FROM (
127+
sql_statement = """SELECT id, name FROM (
125128
SELECT id, 'not a FROM ''value' FROM mytable WHERE id = 2323
126129
) LIMIT 20"""
127-
actual = extract_signature(sql)
130+
actual = extract_signature(sql_statement)
128131

129132
assert "SELECT FROM mytable" == actual
130133

131134

132135
def test_select_subselect_with_alias():
133-
sql = """
136+
sql_statement = """
134137
SELECT count(*)
135138
FROM (
136139
SELECT count(id) AS some_alias, some_column
@@ -139,75 +142,76 @@ def test_select_subselect_with_alias():
139142
HAVING count(id) > 1
140143
) AS foo
141144
"""
142-
actual = extract_signature(sql)
145+
actual = extract_signature(sql_statement)
143146

144147
assert "SELECT FROM mytable" == actual
145148

146149

147150
def test_select_with_multiple_tables():
148-
sql = """SELECT count(table2.id)
151+
sql_statement = """SELECT count(table2.id)
149152
FROM table1, table2, table2
150153
WHERE table2.id = table1.table2_id
151154
"""
152-
actual = extract_signature(sql)
155+
actual = extract_signature(sql_statement)
153156
assert "SELECT FROM table1" == actual
154157

155158

156159
def test_select_with_invalid_subselect():
157-
sql = "SELECT id FROM (SELECT * """
158-
actual = extract_signature(sql)
160+
sql_statement = "SELECT id FROM (SELECT * """
161+
actual = extract_signature(sql_statement)
159162

160163
assert "SELECT FROM" == actual
161164

162165

163166
def test_select_with_invalid_literal():
164-
sql = "SELECT 'neverending literal FROM (SELECT * FROM ..."""
165-
actual = extract_signature(sql)
167+
sql_statement = "SELECT 'neverending literal FROM (SELECT * FROM ..."""
168+
actual = extract_signature(sql_statement)
166169

167170
assert "SELECT FROM" == actual
168171

169172

170173
def test_savepoint():
171-
sql = """SAVEPOINT x_asd1234"""
172-
actual = extract_signature(sql)
174+
sql_statement = """SAVEPOINT x_asd1234"""
175+
actual = extract_signature(sql_statement)
173176

174177
assert "SAVEPOINT" == actual
175178

176179

177180
def test_begin():
178-
sql = """BEGIN"""
179-
actual = extract_signature(sql)
181+
sql_statement = """BEGIN"""
182+
actual = extract_signature(sql_statement)
180183

181184
assert "BEGIN" == actual
182185

183186

184187
def test_create_index_with_name():
185-
sql = """CREATE INDEX myindex ON mytable"""
186-
actual = extract_signature(sql)
188+
sql_statement = """CREATE INDEX myindex ON mytable"""
189+
actual = extract_signature(sql_statement)
187190

188191
assert "CREATE INDEX" == actual
189192

190193

191194
def test_create_index_without_name():
192-
sql = """CREATE INDEX ON mytable"""
193-
actual = extract_signature(sql)
195+
sql_statement = """CREATE INDEX ON mytable"""
196+
actual = extract_signature(sql_statement)
194197

195198
assert "CREATE INDEX" == actual
196199

197200

198201
def test_drop_table():
199-
sql = """DROP TABLE mytable"""
200-
actual = extract_signature(sql)
202+
sql_statement = """DROP TABLE mytable"""
203+
actual = extract_signature(sql_statement)
201204

202205
assert "DROP TABLE" == actual
203206

204207

205208
def test_multi_statement_sql():
206-
sql = """CREATE TABLE mytable; SELECT * FROM mytable; DROP TABLE mytable"""
207-
actual = extract_signature(sql)
209+
sql_statement = """CREATE TABLE mytable; SELECT * FROM mytable; DROP TABLE mytable"""
210+
actual = extract_signature(sql_statement)
208211

209212
assert "CREATE TABLE" == actual
210213

214+
211215
@pytest.mark.integrationtest
212216
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
213217
def test_psycopg2_register_type(postgres_connection, elasticapm_client):
@@ -289,3 +293,35 @@ def test_psycopg2_select_LIKE(postgres_connection, elasticapm_client):
289293
assert 'db' in span['context']
290294
assert span['context']['db']['type'] == 'sql'
291295
assert span['context']['db']['statement'] == query
296+
297+
298+
@pytest.mark.integrationtest
299+
@pytest.mark.skipif(not has_postgres_configured, reason="PostgresSQL not configured")
300+
@pytest.mark.skipif(not has_sql_module, reason="psycopg2.sql module missing")
301+
def test_psycopg2_composable_query_works(postgres_connection, elasticapm_client):
302+
"""
303+
Check that we parse queries that are psycopg2.sql.Composable correctly
304+
"""
305+
control.instrument()
306+
cursor = postgres_connection.cursor()
307+
query = sql.SQL("SELECT * FROM {table} WHERE {row} LIKE 't%' ORDER BY {row} DESC").format(
308+
table=sql.Identifier('test'),
309+
row=sql.Identifier('name'),
310+
)
311+
baked_query = query.as_string(cursor.__wrapped__)
312+
result = None
313+
try:
314+
elasticapm_client.begin_transaction("web.django")
315+
cursor.execute(query)
316+
result = cursor.fetchall()
317+
elasticapm_client.end_transaction(None, "test-transaction")
318+
finally:
319+
# make sure we've cleared out the spans for the other tests.
320+
assert [(2, 'two'), (3, 'three')] == result
321+
transactions = elasticapm_client.instrumentation_store.get_all()
322+
spans = transactions[0]['spans']
323+
span = spans[0]
324+
assert span['name'] == 'SELECT FROM test'
325+
assert 'db' in span['context']
326+
assert span['context']['db']['type'] == 'sql'
327+
assert span['context']['db']['statement'] == baked_query

0 commit comments

Comments
 (0)