Skip to content

Commit d54b61e

Browse files
srikanthccvocelotl
andauthored
feat(instrumentation-dbapi): add experimental sql commenter capability (#908)
* feat(instrumentation-dbapi): add experimental sql commenter capability * Update instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py Co-authored-by: Diego Hurtado <ocelotl@users.noreply.github.com> * Fix lint * Add CHANGELOG entry * Fix lint * Fix lint again Co-authored-by: Diego Hurtado <ocelotl@users.noreply.github.com>
1 parent 454f4b1 commit d54b61e

File tree

4 files changed

+109
-10
lines changed

4 files changed

+109
-10
lines changed

CHANGELOG.md

+7-2
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased](https://github.com/open-telemetry/opentelemetry-python/compare/v1.9.1-0.28b1...HEAD)
99

10-
- `opentelemetry-instrumentation-wsgi` WSGI: Conditionally create SERVER spans
11-
([#903](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/903))
10+
### Added
11+
12+
- `opentelemetry-instrumentation-dbapi` add experimental sql commenter capability
13+
([#908](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/908))
1214

1315
### Fixed
1416

1517
- `opentelemetry-instrumentation-logging` retrieves service name defensively.
1618
([#890](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/890))
1719

20+
- `opentelemetry-instrumentation-wsgi` WSGI: Conditionally create SERVER spans
21+
([#903](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/903))
22+
1823
## [1.9.1-0.28b1](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.9.1-0.28b1) - 2022-01-29
1924

2025

instrumentation/opentelemetry-instrumentation-dbapi/src/opentelemetry/instrumentation/dbapi/__init__.py

+45-8
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,11 @@
4545

4646
from opentelemetry import trace as trace_api
4747
from opentelemetry.instrumentation.dbapi.version import __version__
48-
from opentelemetry.instrumentation.utils import unwrap
48+
from opentelemetry.instrumentation.utils import _generate_sql_comment, unwrap
4949
from opentelemetry.semconv.trace import SpanAttributes
50-
from opentelemetry.trace import SpanKind, TracerProvider, get_tracer
50+
from opentelemetry.trace import Span, SpanKind, TracerProvider, get_tracer
5151

52-
logger = logging.getLogger(__name__)
52+
_logger = logging.getLogger(__name__)
5353

5454

5555
def trace_integration(
@@ -59,6 +59,7 @@ def trace_integration(
5959
connection_attributes: typing.Dict = None,
6060
tracer_provider: typing.Optional[TracerProvider] = None,
6161
capture_parameters: bool = False,
62+
enable_commenter: bool = False,
6263
db_api_integration_factory=None,
6364
):
6465
"""Integrate with DB API library.
@@ -84,6 +85,7 @@ def trace_integration(
8485
version=__version__,
8586
tracer_provider=tracer_provider,
8687
capture_parameters=capture_parameters,
88+
enable_commenter=enable_commenter,
8789
db_api_integration_factory=db_api_integration_factory,
8890
)
8991

@@ -97,6 +99,7 @@ def wrap_connect(
9799
version: str = "",
98100
tracer_provider: typing.Optional[TracerProvider] = None,
99101
capture_parameters: bool = False,
102+
enable_commenter: bool = False,
100103
db_api_integration_factory=None,
101104
):
102105
"""Integrate with DB API library.
@@ -132,6 +135,7 @@ def wrap_connect_(
132135
version=version,
133136
tracer_provider=tracer_provider,
134137
capture_parameters=capture_parameters,
138+
enable_commenter=enable_commenter,
135139
)
136140
return db_integration.wrapped_connection(wrapped, args, kwargs)
137141

@@ -140,7 +144,7 @@ def wrap_connect_(
140144
connect_module, connect_method_name, wrap_connect_
141145
)
142146
except Exception as ex: # pylint: disable=broad-except
143-
logger.warning("Failed to integrate with DB API. %s", str(ex))
147+
_logger.warning("Failed to integrate with DB API. %s", str(ex))
144148

145149

146150
def unwrap_connect(
@@ -163,7 +167,8 @@ def instrument_connection(
163167
connection_attributes: typing.Dict = None,
164168
version: str = "",
165169
tracer_provider: typing.Optional[TracerProvider] = None,
166-
capture_parameters=False,
170+
capture_parameters: bool = False,
171+
enable_commenter: bool = False,
167172
):
168173
"""Enable instrumentation in a database connection.
169174
@@ -180,7 +185,7 @@ def instrument_connection(
180185
An instrumented connection.
181186
"""
182187
if isinstance(connection, wrapt.ObjectProxy):
183-
logger.warning("Connection already instrumented")
188+
_logger.warning("Connection already instrumented")
184189
return connection
185190

186191
db_integration = DatabaseApiIntegration(
@@ -190,6 +195,7 @@ def instrument_connection(
190195
version=version,
191196
tracer_provider=tracer_provider,
192197
capture_parameters=capture_parameters,
198+
enable_commenter=enable_commenter,
193199
)
194200
db_integration.get_connection_attributes(connection)
195201
return get_traced_connection_proxy(connection, db_integration)
@@ -207,7 +213,7 @@ def uninstrument_connection(connection):
207213
if isinstance(connection, wrapt.ObjectProxy):
208214
return connection.__wrapped__
209215

210-
logger.warning("Connection is not instrumented")
216+
_logger.warning("Connection is not instrumented")
211217
return connection
212218

213219

@@ -220,6 +226,7 @@ def __init__(
220226
version: str = "",
221227
tracer_provider: typing.Optional[TracerProvider] = None,
222228
capture_parameters: bool = False,
229+
enable_commenter: bool = False,
223230
):
224231
self.connection_attributes = connection_attributes
225232
if self.connection_attributes is None:
@@ -237,6 +244,7 @@ def __init__(
237244
tracer_provider=tracer_provider,
238245
)
239246
self.capture_parameters = capture_parameters
247+
self.enable_commenter = enable_commenter
240248
self.database_system = database_system
241249
self.connection_props = {}
242250
self.span_attributes = {}
@@ -313,8 +321,9 @@ def __exit__(self, *args, **kwargs):
313321

314322

315323
class CursorTracer:
316-
def __init__(self, db_api_integration: DatabaseApiIntegration):
324+
def __init__(self, db_api_integration: DatabaseApiIntegration) -> None:
317325
self._db_api_integration = db_api_integration
326+
self._commenter_enabled = self._db_api_integration.enable_commenter
318327

319328
def _populate_span(
320329
self,
@@ -355,6 +364,22 @@ def get_statement(self, cursor, args): # pylint: disable=no-self-use
355364
return statement.decode("utf8", "replace")
356365
return statement
357366

367+
@staticmethod
368+
def _generate_comment(span: Span) -> str:
369+
span_context = span.get_span_context()
370+
meta = {}
371+
if span_context.is_valid:
372+
meta.update(
373+
{
374+
"trace_id": span_context.trace_id,
375+
"span_id": span_context.span_id,
376+
"trace_flags": span_context.trace_flags,
377+
"trace_state": span_context.trace_state.to_header(),
378+
}
379+
)
380+
# TODO(schekuri): revisit to enrich with info such as route, db_driver etc...
381+
return _generate_sql_comment(**meta)
382+
358383
def traced_execution(
359384
self,
360385
cursor,
@@ -374,6 +399,18 @@ def traced_execution(
374399
name, kind=SpanKind.CLIENT
375400
) as span:
376401
self._populate_span(span, cursor, *args)
402+
if args and self._commenter_enabled:
403+
try:
404+
comment = self._generate_comment(span)
405+
if isinstance(args[0], bytes):
406+
comment = comment.encode("utf8")
407+
args_list = list(args)
408+
args_list[0] += comment
409+
args = tuple(args_list)
410+
except Exception as exc: # pylint: disable=broad-except
411+
_logger.exception(
412+
"Exception while generating sql comment: %s", exc
413+
)
377414
return query_method(*args, **kwargs)
378415

379416

instrumentation/opentelemetry-instrumentation-dbapi/tests/test_dbapi_integration.py

+21
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,21 @@ def test_executemany(self):
228228
span.attributes[SpanAttributes.DB_STATEMENT], "Test query"
229229
)
230230

231+
def test_executemany_comment(self):
232+
db_integration = dbapi.DatabaseApiIntegration(
233+
"testname", "testcomponent", enable_commenter=True
234+
)
235+
mock_connection = db_integration.wrapped_connection(
236+
mock_connect, {}, {}
237+
)
238+
cursor = mock_connection.cursor()
239+
cursor.executemany("Test query")
240+
spans_list = self.memory_exporter.get_finished_spans()
241+
self.assertEqual(len(spans_list), 1)
242+
span = spans_list[0]
243+
comment = dbapi.CursorTracer._generate_comment(span)
244+
self.assertIn(comment, cursor.query)
245+
231246
def test_callproc(self):
232247
db_integration = dbapi.DatabaseApiIntegration(
233248
"testname", "testcomponent"
@@ -308,6 +323,10 @@ def cursor(self):
308323

309324

310325
class MockCursor:
326+
def __init__(self) -> None:
327+
self.query = ""
328+
self.params = None
329+
311330
# pylint: disable=unused-argument, no-self-use
312331
def execute(self, query, params=None, throw_exception=False):
313332
if throw_exception:
@@ -317,6 +336,8 @@ def execute(self, query, params=None, throw_exception=False):
317336
def executemany(self, query, params=None, throw_exception=False):
318337
if throw_exception:
319338
raise Exception("Test Exception")
339+
self.query = query
340+
self.params = params
320341

321342
# pylint: disable=unused-argument, no-self-use
322343
def callproc(self, query, params=None, throw_exception=False):

opentelemetry-instrumentation/src/opentelemetry/instrumentation/utils.py

+36
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
import urllib.parse
1516
from typing import Dict, Sequence
1617

1718
from wrapt import ObjectProxy
@@ -115,3 +116,38 @@ def _start_internal_or_server_span(
115116
attributes=attributes,
116117
)
117118
return span, token
119+
120+
121+
_KEY_VALUE_DELIMITER = ","
122+
123+
124+
def _generate_sql_comment(**meta):
125+
"""
126+
Return a SQL comment with comma delimited key=value pairs created from
127+
**meta kwargs.
128+
"""
129+
if not meta: # No entries added.
130+
return ""
131+
132+
# Sort the keywords to ensure that caching works and that testing is
133+
# deterministic. It eases visual inspection as well.
134+
return (
135+
" /*"
136+
+ _KEY_VALUE_DELIMITER.join(
137+
"{}={!r}".format(_url_quote(key), _url_quote(value))
138+
for key, value in sorted(meta.items())
139+
if value is not None
140+
)
141+
+ "*/"
142+
)
143+
144+
145+
def _url_quote(s): # pylint: disable=invalid-name
146+
if not isinstance(s, (str, bytes)):
147+
return s
148+
quoted = urllib.parse.quote(s)
149+
# Since SQL uses '%' as a keyword, '%' is a by-product of url quoting
150+
# e.g. foo,bar --> foo%2Cbar
151+
# thus in our quoting, we need to escape it too to finally give
152+
# foo,bar --> foo%%2Cbar
153+
return quoted.replace("%", "%%")

0 commit comments

Comments
 (0)