diff --git a/CHANGELOG.md b/CHANGELOG.md index 973e5085d6..9b8e8890aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3100](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3100)) - Add support to database stability opt-in in `_semconv` utilities and add tests ([#3111](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3111)) +- `opentelemetry-opentelemetry-pymongo` Add `py.typed` file to enable PEP 561 + ([#3136](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3136)) +- `opentelemetry-opentelemetry-requests` Add `py.typed` file to enable PEP 561 + ([#3135](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3135)) - `opentelemetry-instrumentation-system-metrics` Add `py.typed` file to enable PEP 561 ([#3132](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3132)) - `opentelemetry-opentelemetry-sqlite3` Add `py.typed` file to enable PEP 561 @@ -27,6 +31,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#3148](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3148)) - add support to Python 3.13 ([#3134](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3134)) +- `opentelemetry-util-http` Add `py.typed` file to enable PEP 561 + ([#3127](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3127)) ### Fixed @@ -37,6 +43,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `opentelemetry-instrumentation` Fix `get_dist_dependency_conflicts` if no distribution requires ([#3168](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3168)) +### Breaking changes + +- `opentelemetry-instrumentation-sqlalchemy` including sqlcomment in `db.statement` span attribute value is now opt-in + ([#3112](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3112)) + +### Breaking changes + +- `opentelemetry-instrumentation-dbapi` including sqlcomment in `db.statement` span attribute value is now opt-in + ([#3115](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3115)) + ### Breaking changes diff --git a/README.md b/README.md index 8582c472ba..2400e5d47a 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ The Python auto-instrumentation libraries for [OpenTelemetry](https://openteleme * [Installation](#installation) * [Releasing](#releasing) * [Releasing a package as `1.0` stable](#releasing-a-package-as-10-stable) +* [Semantic Convention status of instrumentations](#semantic-convention-status-of-instrumentations) * [Contributing](#contributing) * [Thanks to all the people who already contributed](#thanks-to-all-the-people-who-already-contributed) @@ -100,7 +101,7 @@ To release a package as `1.0` stable, the package: ## Semantic Convention status of instrumentations -In our efforts to maintain optimal user experience and prevent breaking changes for transitioning into stable semantic conventions, OpenTelemetry Python is adopting the [semantic convention migration plan](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/http/migration-guide.md) for several instrumentations. Currently this plan is only being adopted for HTTP-related instrumentations, but will eventually cover all types. Please refer to the `semconv status` column of the [instrumentation README](instrumentation/README.md) of the current status of instrumentations' semantic conventions. The possible values are `experimental`, `stable` and `migration` referring to [status](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.31.0/specification/document-status.md#lifecycle-status) of that particular semantic convention. `Migration` refers to an instrumentation that currently supports the migration plan. +In our efforts to maintain optimal user experience and prevent breaking changes for transitioning into stable semantic conventions, OpenTelemetry Python is adopting the semantic convention migration plan for several instrumentations. Currently this plan is only being adopted for [HTTP-related instrumentations](https://github.com/open-telemetry/semantic-conventions/blob/main/docs/non-normative/http-migration.md), but will eventually cover all types. Please refer to the `semconv status` column of the [instrumentation README](instrumentation/README.md) of the current status of instrumentations' semantic conventions. The possible values are `experimental`, `stable` and `migration` referring to [status](https://github.com/open-telemetry/opentelemetry-specification/blob/v1.31.0/specification/document-status.md#lifecycle-status) of that particular semantic convention. `Migration` refers to an instrumentation that currently supports the migration plan. ## Contributing diff --git a/instrumentation/opentelemetry-instrumentation-asyncpg/tests/test_asyncpg_wrapper.py b/instrumentation/opentelemetry-instrumentation-asyncpg/tests/test_asyncpg_wrapper.py index 7c88b9c005..0fc44d6a23 100644 --- a/instrumentation/opentelemetry-instrumentation-asyncpg/tests/test_asyncpg_wrapper.py +++ b/instrumentation/opentelemetry-instrumentation-asyncpg/tests/test_asyncpg_wrapper.py @@ -5,6 +5,7 @@ from asyncpg import Connection, Record, cursor from wrapt import ObjectProxy +from opentelemetry import trace as trace_api from opentelemetry.instrumentation.asyncpg import AsyncPGInstrumentor from opentelemetry.test.test_base import TestBase @@ -105,3 +106,36 @@ async def exec_mock(*args, **kwargs): spans = self.memory_exporter.get_finished_spans() self.assertEqual(len(spans), 2) self.assertEqual([span.status.is_ok for span in spans], [True, True]) + + def test_no_op_tracer_provider(self): + AsyncPGInstrumentor().uninstrument() + AsyncPGInstrumentor().instrument( + tracer_provider=trace_api.NoOpTracerProvider() + ) + + # Mock out all interaction with postgres + async def bind_mock(*args, **kwargs): + return [] + + async def exec_mock(*args, **kwargs): + return [], None, True + + conn = mock.Mock() + conn.is_closed = lambda: False + + conn._protocol = mock.Mock() + conn._protocol.bind = bind_mock + conn._protocol.execute = exec_mock + conn._protocol.bind_execute = exec_mock + conn._protocol.close_portal = bind_mock + + state = mock.Mock() + state.closed = False + + # init the cursor and fetch a single record + crs = cursor.Cursor(conn, "SELECT * FROM test", state, [], Record) + asyncio.run(crs._init(1)) + asyncio.run(crs.fetch(1)) + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 0) diff --git a/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/__init__.py b/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/__init__.py index 0b199cbe64..9867992d49 100644 --- a/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/__init__.py @@ -39,17 +39,21 @@ """ # pylint: disable=no-value-for-parameter +from __future__ import annotations + import logging -from typing import Collection +from types import CodeType +from typing import Any, Callable, Collection, TypeVar import jinja2 +from jinja2.environment import Template from wrapt import wrap_function_wrapper as _wrap from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.jinja2.package import _instruments from opentelemetry.instrumentation.jinja2.version import __version__ from opentelemetry.instrumentation.utils import unwrap -from opentelemetry.trace import SpanKind, get_tracer +from opentelemetry.trace import SpanKind, Tracer, get_tracer logger = logging.getLogger(__name__) @@ -57,12 +61,27 @@ ATTRIBUTE_JINJA2_TEMPLATE_PATH = "jinja2.template_path" DEFAULT_TEMPLATE_NAME = "" +R = TypeVar("R") + -def _with_tracer_wrapper(func): +def _with_tracer_wrapper( + func: Callable[ + [Tracer, Callable[..., R], Any, list[Any], dict[str, Any]], R + ], +) -> Callable[ + [Tracer], Callable[[Callable[..., R], Any, list[Any], dict[str, Any]], R] +]: """Helper for providing tracer for wrapper functions.""" - def _with_tracer(tracer): - def wrapper(wrapped, instance, args, kwargs): + def _with_tracer( + tracer: Tracer, + ) -> Callable[[Callable[..., R], Any, list[Any], dict[str, Any]], R]: + def wrapper( + wrapped: Callable[..., R], + instance: Any, + args: list[Any], + kwargs: dict[str, Any], + ) -> R: return func(tracer, wrapped, instance, args, kwargs) return wrapper @@ -71,7 +90,13 @@ def wrapper(wrapped, instance, args, kwargs): @_with_tracer_wrapper -def _wrap_render(tracer, wrapped, instance, args, kwargs): +def _wrap_render( + tracer: Tracer, + wrapped: Callable[..., Any], + instance: Template, + args: list[Any], + kwargs: dict[str, Any], +): """Wrap `Template.render()` or `Template.generate()`""" with tracer.start_as_current_span( "jinja2.render", @@ -84,7 +109,13 @@ def _wrap_render(tracer, wrapped, instance, args, kwargs): @_with_tracer_wrapper -def _wrap_compile(tracer, wrapped, _, args, kwargs): +def _wrap_compile( + tracer: Tracer, + wrapped: Callable[..., CodeType], + _, + args: list[Any], + kwargs: dict[str, Any], +) -> CodeType: with tracer.start_as_current_span( "jinja2.compile", kind=SpanKind.INTERNAL, @@ -100,7 +131,13 @@ def _wrap_compile(tracer, wrapped, _, args, kwargs): @_with_tracer_wrapper -def _wrap_load_template(tracer, wrapped, _, args, kwargs): +def _wrap_load_template( + tracer: Tracer, + wrapped: Callable[..., Template], + _, + args: list[Any], + kwargs: dict[str, Any], +) -> Template: with tracer.start_as_current_span( "jinja2.load", kind=SpanKind.INTERNAL, @@ -128,7 +165,7 @@ class Jinja2Instrumentor(BaseInstrumentor): def instrumentation_dependencies(self) -> Collection[str]: return _instruments - def _instrument(self, **kwargs): + def _instrument(self, **kwargs: Any): tracer_provider = kwargs.get("tracer_provider") tracer = get_tracer( __name__, @@ -146,7 +183,7 @@ def _instrument(self, **kwargs): _wrap_load_template(tracer), ) - def _uninstrument(self, **kwargs): + def _uninstrument(self, **kwargs: Any): unwrap(jinja2.Template, "render") unwrap(jinja2.Template, "generate") unwrap(jinja2.Environment, "compile") diff --git a/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/py.typed b/instrumentation/opentelemetry-instrumentation-jinja2/src/opentelemetry/instrumentation/jinja2/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/__init__.py b/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/__init__.py index e0721f2f2d..bb609a08c6 100644 --- a/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/__init__.py @@ -75,8 +75,10 @@ def failed_hook(span, event): """ +from __future__ import annotations + from logging import getLogger -from typing import Callable, Collection +from typing import Any, Callable, Collection, TypeVar from pymongo import monitoring @@ -88,7 +90,7 @@ def failed_hook(span, event): from opentelemetry.instrumentation.pymongo.version import __version__ from opentelemetry.instrumentation.utils import is_instrumentation_enabled from opentelemetry.semconv.trace import DbSystemValues, SpanAttributes -from opentelemetry.trace import SpanKind, get_tracer +from opentelemetry.trace import SpanKind, Tracer, get_tracer from opentelemetry.trace.span import Span from opentelemetry.trace.status import Status, StatusCode @@ -98,14 +100,21 @@ def failed_hook(span, event): ResponseHookT = Callable[[Span, monitoring.CommandSucceededEvent], None] FailedHookT = Callable[[Span, monitoring.CommandFailedEvent], None] +CommandEvent = TypeVar( + "CommandEvent", + monitoring.CommandStartedEvent, + monitoring.CommandSucceededEvent, + monitoring.CommandFailedEvent, +) + -def dummy_callback(span, event): ... +def dummy_callback(span: Span, event: CommandEvent): ... class CommandTracer(monitoring.CommandListener): def __init__( self, - tracer, + tracer: Tracer, request_hook: RequestHookT = dummy_callback, response_hook: ResponseHookT = dummy_callback, failed_hook: FailedHookT = dummy_callback, @@ -195,10 +204,12 @@ def failed(self, event: monitoring.CommandFailedEvent): _LOG.exception(hook_exception) span.end() - def _pop_span(self, event): + def _pop_span(self, event: CommandEvent) -> Span | None: return self._span_dict.pop(_get_span_dict_key(event), None) - def _get_statement_by_command_name(self, command_name, event): + def _get_statement_by_command_name( + self, command_name: str, event: CommandEvent + ) -> str: statement = command_name command_attribute = COMMAND_TO_ATTRIBUTE_MAPPING.get(command_name) command = event.command.get(command_attribute) @@ -207,14 +218,16 @@ def _get_statement_by_command_name(self, command_name, event): return statement -def _get_span_dict_key(event): +def _get_span_dict_key( + event: CommandEvent, +) -> int | tuple[int, tuple[str, int | None]]: if event.connection_id is not None: return event.request_id, event.connection_id return event.request_id class PymongoInstrumentor(BaseInstrumentor): - _commandtracer_instance = None # type CommandTracer + _commandtracer_instance: CommandTracer | None = None # The instrumentation for PyMongo is based on the event listener interface # https://api.mongodb.com/python/current/api/pymongo/monitoring.html. # This interface only allows to register listeners and does not provide @@ -225,7 +238,7 @@ class PymongoInstrumentor(BaseInstrumentor): def instrumentation_dependencies(self) -> Collection[str]: return _instruments - def _instrument(self, **kwargs): + def _instrument(self, **kwargs: Any): """Integrate with pymongo to trace it using event listener. https://api.mongodb.com/python/current/api/pymongo/monitoring.html @@ -259,6 +272,6 @@ def _instrument(self, **kwargs): # If already created, just enable it self._commandtracer_instance.is_enabled = True - def _uninstrument(self, **kwargs): + def _uninstrument(self, **kwargs: Any): if self._commandtracer_instance is not None: self._commandtracer_instance.is_enabled = False diff --git a/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/py.typed b/instrumentation/opentelemetry-instrumentation-pymongo/src/opentelemetry/instrumentation/pymongo/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py index d1afa834d6..6c197d530e 100644 --- a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/__init__.py @@ -72,10 +72,12 @@ def response_hook(span, request_obj, response) --- """ +from __future__ import annotations + import functools import types from timeit import default_timer -from typing import Callable, Collection, Optional +from typing import Any, Callable, Collection, Optional from urllib.parse import urlparse from requests.models import PreparedRequest, Response @@ -146,7 +148,7 @@ def _instrument( duration_histogram_new: Histogram, request_hook: _RequestHookT = None, response_hook: _ResponseHookT = None, - excluded_urls: ExcludeList = None, + excluded_urls: ExcludeList | None = None, sem_conv_opt_in_mode: _StabilityMode = _StabilityMode.DEFAULT, ): """Enables tracing of all requests calls that go through @@ -164,7 +166,9 @@ def _instrument( # pylint: disable-msg=too-many-locals,too-many-branches @functools.wraps(wrapped_send) - def instrumented_send(self, request, **kwargs): + def instrumented_send( + self: Session, request: PreparedRequest, **kwargs: Any + ): if excluded_urls and excluded_urls.url_disabled(request.url): return wrapped_send(self, request, **kwargs) @@ -345,7 +349,7 @@ def _uninstrument(): _uninstrument_from(Session) -def _uninstrument_from(instr_root, restore_as_bound_func=False): +def _uninstrument_from(instr_root, restore_as_bound_func: bool = False): for instr_func_name in ("request", "send"): instr_func = getattr(instr_root, instr_func_name) if not getattr( @@ -361,7 +365,7 @@ def _uninstrument_from(instr_root, restore_as_bound_func=False): setattr(instr_root, instr_func_name, original) -def get_default_span_name(method): +def get_default_span_name(method: str) -> str: """ Default implementation for name_callback, returns HTTP {method_name}. https://opentelemetry.io/docs/reference/specification/trace/semantic_conventions/http/#name @@ -385,7 +389,7 @@ class RequestsInstrumentor(BaseInstrumentor): def instrumentation_dependencies(self) -> Collection[str]: return _instruments - def _instrument(self, **kwargs): + def _instrument(self, **kwargs: Any): """Instruments requests module Args: @@ -443,10 +447,10 @@ def _instrument(self, **kwargs): sem_conv_opt_in_mode=semconv_opt_in_mode, ) - def _uninstrument(self, **kwargs): + def _uninstrument(self, **kwargs: Any): _uninstrument() @staticmethod - def uninstrument_session(session): + def uninstrument_session(session: Session): """Disables instrumentation on the session object.""" _uninstrument_from(session, restore_as_bound_func=True) diff --git a/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/py.typed b/instrumentation/opentelemetry-instrumentation-requests/src/opentelemetry/instrumentation/requests/py.typed new file mode 100644 index 0000000000..e69de29bb2 diff --git a/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/__init__.py b/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/__init__.py index 4182c0034e..23b68a6c52 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/__init__.py @@ -65,6 +65,30 @@ :: Enabling this flag will add traceparent values /*traceparent='00-03afa25236b8cd948fa853d67038ac79-405ff022e8247c46-01'*/ +SQLComment in span attribute +**************************** +If sqlcommenter is enabled, you can further configure SQLAlchemy instrumentation to append sqlcomment to the `db.statement` span attribute for convenience of your platform. + +.. code:: python + + from opentelemetry.instrumentation.sqlalchemy import SQLAlchemyInstrumentor + + SQLAlchemyInstrumentor().instrument( + enable_commenter=True, + commenter_options={}, + enable_attribute_commenter=True, + ) + + +For example, +:: + + Invoking `engine.execute("select * from auth_users")` will lead to sql query "select * from auth_users" but when SQLCommenter and `attribute_commenter` is enabled + the query will get appended with some configurable tags like "select * from auth_users /*tag=value*/;" for both server query and `db.statement` span attribute. + +Warning: capture of sqlcomment in ``db.statement`` may have high cardinality without platform normalization. See `Semantic Conventions for database spans `_ for more information. + + Usage ----- .. code:: python @@ -138,6 +162,7 @@ def _instrument(self, **kwargs): ``meter_provider``: a MeterProvider, defaults to global ``enable_commenter``: bool to enable sqlcommenter, defaults to False ``commenter_options``: dict of sqlcommenter config, defaults to {} + ``enable_attribute_commenter``: bool to enable sqlcomment addition to span attribute, defaults to False. Must also set `enable_commenter`. Returns: An instrumented engine if passed in as an argument or list of instrumented engines, None otherwise. @@ -166,19 +191,30 @@ def _instrument(self, **kwargs): enable_commenter = kwargs.get("enable_commenter", False) commenter_options = kwargs.get("commenter_options", {}) + enable_attribute_commenter = kwargs.get( + "enable_attribute_commenter", False + ) _w( "sqlalchemy", "create_engine", _wrap_create_engine( - tracer, connections_usage, enable_commenter, commenter_options + tracer, + connections_usage, + enable_commenter, + commenter_options, + enable_attribute_commenter, ), ) _w( "sqlalchemy.engine", "create_engine", _wrap_create_engine( - tracer, connections_usage, enable_commenter, commenter_options + tracer, + connections_usage, + enable_commenter, + commenter_options, + enable_attribute_commenter, ), ) # sqlalchemy.engine.create is not present in earlier versions of sqlalchemy (which we support) @@ -191,6 +227,7 @@ def _instrument(self, **kwargs): connections_usage, enable_commenter, commenter_options, + enable_attribute_commenter, ), ) _w( @@ -207,6 +244,7 @@ def _instrument(self, **kwargs): connections_usage, enable_commenter, commenter_options, + enable_attribute_commenter, ), ) if kwargs.get("engine") is not None: @@ -216,6 +254,7 @@ def _instrument(self, **kwargs): connections_usage, kwargs.get("enable_commenter", False), kwargs.get("commenter_options", {}), + kwargs.get("enable_attribute_commenter", False), ) if kwargs.get("engines") is not None and isinstance( kwargs.get("engines"), Sequence @@ -227,6 +266,7 @@ def _instrument(self, **kwargs): connections_usage, kwargs.get("enable_commenter", False), kwargs.get("commenter_options", {}), + kwargs.get("enable_attribute_commenter", False), ) for engine in kwargs.get("engines") ] diff --git a/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/engine.py b/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/engine.py index a20e481819..a3312bd77c 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/engine.py +++ b/instrumentation/opentelemetry-instrumentation-sqlalchemy/src/opentelemetry/instrumentation/sqlalchemy/engine.py @@ -43,7 +43,11 @@ def _normalize_vendor(vendor): def _wrap_create_async_engine( - tracer, connections_usage, enable_commenter=False, commenter_options=None + tracer, + connections_usage, + enable_commenter=False, + commenter_options=None, + enable_attribute_commenter=False, ): # pylint: disable=unused-argument def _wrap_create_async_engine_internal(func, module, args, kwargs): @@ -57,6 +61,7 @@ def _wrap_create_async_engine_internal(func, module, args, kwargs): connections_usage, enable_commenter, commenter_options, + enable_attribute_commenter, ) return engine @@ -64,7 +69,11 @@ def _wrap_create_async_engine_internal(func, module, args, kwargs): def _wrap_create_engine( - tracer, connections_usage, enable_commenter=False, commenter_options=None + tracer, + connections_usage, + enable_commenter=False, + commenter_options=None, + enable_attribute_commenter=False, ): def _wrap_create_engine_internal(func, _module, args, kwargs): """Trace the SQLAlchemy engine, creating an `EngineTracer` @@ -77,6 +86,7 @@ def _wrap_create_engine_internal(func, _module, args, kwargs): connections_usage, enable_commenter, commenter_options, + enable_attribute_commenter, ) return engine @@ -110,12 +120,14 @@ def __init__( connections_usage, enable_commenter=False, commenter_options=None, + enable_attribute_commenter=False, ): self.tracer = tracer self.connections_usage = connections_usage self.vendor = _normalize_vendor(engine.name) self.enable_commenter = enable_commenter self.commenter_options = commenter_options if commenter_options else {} + self.enable_attribute_commenter = enable_attribute_commenter self._engine_attrs = _get_attributes_from_engine(engine) self._leading_comment_remover = re.compile(r"^/\*.*?\*/") @@ -218,6 +230,32 @@ def _operation_name(self, db_name, statement): return self.vendor return " ".join(parts) + def _get_commenter_data(self, conn) -> dict: + """Calculate sqlcomment contents from conn and configured options""" + commenter_data = { + "db_driver": conn.engine.driver, + # Driver/framework centric information. + "db_framework": f"sqlalchemy:{sqlalchemy.__version__}", + } + + if self.commenter_options.get("opentelemetry_values", True): + commenter_data.update(**_get_opentelemetry_values()) + + # Filter down to just the requested attributes. + commenter_data = { + k: v + for k, v in commenter_data.items() + if self.commenter_options.get(k, True) + } + return commenter_data + + def _set_db_client_span_attributes(self, span, statement, attrs) -> None: + """Uses statement and attrs to set attributes of provided Otel span""" + span.set_attribute(SpanAttributes.DB_STATEMENT, statement) + span.set_attribute(SpanAttributes.DB_SYSTEM, self.vendor) + for key, value in attrs.items(): + span.set_attribute(key, value) + def _before_cur_exec( self, conn, cursor, statement, params, context, _executemany ): @@ -233,30 +271,30 @@ def _before_cur_exec( with trace.use_span(span, end_on_exit=False): if span.is_recording(): if self.enable_commenter: - commenter_data = { - "db_driver": conn.engine.driver, - # Driver/framework centric information. - "db_framework": f"sqlalchemy:{sqlalchemy.__version__}", - } - - if self.commenter_options.get( - "opentelemetry_values", True - ): - commenter_data.update(**_get_opentelemetry_values()) - - # Filter down to just the requested attributes. - commenter_data = { - k: v - for k, v in commenter_data.items() - if self.commenter_options.get(k, True) - } - - statement = _add_sql_comment(statement, **commenter_data) - - span.set_attribute(SpanAttributes.DB_STATEMENT, statement) - span.set_attribute(SpanAttributes.DB_SYSTEM, self.vendor) - for key, value in attrs.items(): - span.set_attribute(key, value) + commenter_data = self._get_commenter_data(conn) + + if self.enable_attribute_commenter: + # sqlcomment is added to executed query and db.statement span attribute + statement = _add_sql_comment( + statement, **commenter_data + ) + self._set_db_client_span_attributes( + span, statement, attrs + ) + + else: + # sqlcomment is only added to executed query + # so db.statement is set before add_sql_comment + self._set_db_client_span_attributes( + span, statement, attrs + ) + statement = _add_sql_comment( + statement, **commenter_data + ) + + else: + # no sqlcomment anywhere + self._set_db_client_span_attributes(span, statement, attrs) context._otel_span = span diff --git a/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlalchemy.py b/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlalchemy.py index 27a253decb..8f5d0f2a94 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlalchemy.py +++ b/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlalchemy.py @@ -209,6 +209,44 @@ def test_create_engine_wrapper_enable_commenter(self): self.caplog.records[-2].getMessage(), r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_create_engine_wrapper_enable_commenter_stmt_enabled(self): + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + SQLAlchemyInstrumentor().instrument( + enable_commenter=True, + commenter_options={"db_framework": False}, + enable_attribute_commenter=True, + ) + from sqlalchemy import create_engine # pylint: disable-all + + engine = create_engine("sqlite:///:memory:") + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + # sqlcommenter + self.assertRegex( + self.caplog.records[-2].getMessage(), + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) def test_create_engine_wrapper_enable_commenter_otel_values_false(self): logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) @@ -229,6 +267,49 @@ def test_create_engine_wrapper_enable_commenter_otel_values_false(self): self.caplog.records[-2].getMessage(), r"SELECT 1 /\*db_driver='(.*)'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_create_engine_wrapper_enable_commenter_stmt_enabled_otel_values_false( + self, + ): + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + SQLAlchemyInstrumentor().instrument( + enable_commenter=True, + commenter_options={ + "db_framework": False, + "opentelemetry_values": False, + }, + enable_attribute_commenter=True, + ) + from sqlalchemy import create_engine # pylint: disable-all + + engine = create_engine("sqlite:///:memory:") + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + # sqlcommenter + self.assertRegex( + self.caplog.records[-2].getMessage(), + r"SELECT 1 /\*db_driver='(.*)'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)'\*/;", + ) def test_custom_tracer_provider(self): provider = TracerProvider( @@ -321,6 +402,55 @@ async def run(): self.caplog.records[1].getMessage(), r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + asyncio.get_event_loop().run_until_complete(run()) + + @pytest.mark.skipif( + not sqlalchemy.__version__.startswith("1.4"), + reason="only run async tests for 1.4", + ) + def test_create_async_engine_wrapper_enable_commenter_stmt_enabled(self): + async def run(): + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + SQLAlchemyInstrumentor().instrument( + enable_commenter=True, + commenter_options={ + "db_framework": False, + }, + enable_attribute_commenter=True, + ) + from sqlalchemy.ext.asyncio import ( # pylint: disable-all + create_async_engine, + ) + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as cnx: + await cnx.execute(text("SELECT 1;")) + # sqlcommenter + self.assertRegex( + self.caplog.records[1].getMessage(), + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) asyncio.get_event_loop().run_until_complete(run()) @@ -352,6 +482,58 @@ async def run(): self.caplog.records[1].getMessage(), r"SELECT 1 /\*db_driver='(.*)'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + asyncio.get_event_loop().run_until_complete(run()) + + @pytest.mark.skipif( + not sqlalchemy.__version__.startswith("1.4"), + reason="only run async tests for 1.4", + ) + def test_create_async_engine_wrapper_enable_commenter_stmt_enabled_otel_values_false( + self, + ): + async def run(): + logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) + SQLAlchemyInstrumentor().instrument( + enable_commenter=True, + commenter_options={ + "db_framework": False, + "opentelemetry_values": False, + }, + enable_attribute_commenter=True, + ) + from sqlalchemy.ext.asyncio import ( # pylint: disable-all + create_async_engine, + ) + + engine = create_async_engine("sqlite+aiosqlite:///:memory:") + async with engine.connect() as cnx: + await cnx.execute(text("SELECT 1;")) + # sqlcommenter + self.assertRegex( + self.caplog.records[1].getMessage(), + r"SELECT 1 /\*db_driver='(.*)'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)'*/;", + ) asyncio.get_event_loop().run_until_complete(run()) diff --git a/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlcommenter.py b/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlcommenter.py index 8490721e3e..d8144dadc1 100644 --- a/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlcommenter.py +++ b/instrumentation/opentelemetry-instrumentation-sqlalchemy/tests/test_sqlcommenter.py @@ -61,13 +61,97 @@ def test_sqlcommenter_enabled(self): r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", ) - def test_sqlcommenter_enabled_matches_db_statement_attribute(self): + def test_sqlcommenter_default_stmt_enabled_no_comments_anywhere(self): + engine = create_engine("sqlite:///:memory:") + SQLAlchemyInstrumentor().instrument( + engine=engine, + tracer_provider=self.tracer_provider, + # enable_commenter not set + enable_attribute_commenter=True, + ) + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + query_log = self.caplog.records[-2].getMessage() + self.assertEqual( + query_log, + "SELECT 1;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_sqlcommenter_disabled_stmt_enabled_no_comments_anywhere(self): + engine = create_engine("sqlite:///:memory:") + SQLAlchemyInstrumentor().instrument( + engine=engine, + tracer_provider=self.tracer_provider, + enable_commenter=False, + enable_attribute_commenter=True, + ) + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + query_log = self.caplog.records[-2].getMessage() + self.assertEqual( + query_log, + "SELECT 1;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_sqlcommenter_enabled_stmt_disabled_default( + self, + ): + engine = create_engine("sqlite:///:memory:") + SQLAlchemyInstrumentor().instrument( + engine=engine, + tracer_provider=self.tracer_provider, + enable_commenter=True, + commenter_options={"db_framework": False}, + # enable_attribute_commenter not set + ) + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + query_log = self.caplog.records[-2].getMessage() + self.assertRegex( + query_log, + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_sqlcommenter_enabled_stmt_enabled_matches_db_statement_attribute( + self, + ): engine = create_engine("sqlite:///:memory:") SQLAlchemyInstrumentor().instrument( engine=engine, tracer_provider=self.tracer_provider, enable_commenter=True, commenter_options={"db_framework": False}, + enable_attribute_commenter=True, ) cnx = engine.connect() cnx.execute(text("SELECT 1;")).fetchall() @@ -110,6 +194,45 @@ def test_sqlcommenter_enabled_otel_values_false(self): self.caplog.records[-2].getMessage(), r"SELECT 1 /\*db_driver='(.*)'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1;", + ) + + def test_sqlcommenter_enabled_stmt_enabled_otel_values_false(self): + engine = create_engine("sqlite:///:memory:") + SQLAlchemyInstrumentor().instrument( + engine=engine, + tracer_provider=self.tracer_provider, + enable_commenter=True, + commenter_options={ + "db_framework": False, + "opentelemetry_values": False, + }, + enable_attribute_commenter=True, + ) + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + self.assertRegex( + self.caplog.records[-2].getMessage(), + r"SELECT 1 /\*db_driver='(.*)'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)'*/;", + ) def test_sqlcommenter_flask_integration(self): engine = create_engine("sqlite:///:memory:") @@ -132,6 +255,49 @@ def test_sqlcommenter_flask_integration(self): self.caplog.records[-2].getMessage(), r"SELECT 1 /\*db_driver='(.*)',flask=1,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_sqlcommenter_stmt_enabled_flask_integration(self): + engine = create_engine("sqlite:///:memory:") + SQLAlchemyInstrumentor().instrument( + engine=engine, + tracer_provider=self.tracer_provider, + enable_commenter=True, + commenter_options={"db_framework": False}, + enable_attribute_commenter=True, + ) + cnx = engine.connect() + + current_context = context.get_current() + sqlcommenter_context = context.set_value( + "SQLCOMMENTER_ORM_TAGS_AND_VALUES", {"flask": 1}, current_context + ) + context.attach(sqlcommenter_context) + + cnx.execute(text("SELECT 1;")).fetchall() + self.assertRegex( + self.caplog.records[-2].getMessage(), + r"SELECT 1 /\*db_driver='(.*)',flask=1,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)',flask=1,traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) def test_sqlcommenter_enabled_create_engine_after_instrumentation(self): SQLAlchemyInstrumentor().instrument( @@ -147,6 +313,44 @@ def test_sqlcommenter_enabled_create_engine_after_instrumentation(self): self.caplog.records[-2].getMessage(), r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertEqual( + query_span.attributes[SpanAttributes.DB_STATEMENT], + "SELECT 1;", + ) + + def test_sqlcommenter_enabled_stmt_enabled_create_engine_after_instrumentation( + self, + ): + SQLAlchemyInstrumentor().instrument( + tracer_provider=self.tracer_provider, + enable_commenter=True, + enable_attribute_commenter=True, + ) + from sqlalchemy import create_engine # pylint: disable-all + + engine = create_engine("sqlite:///:memory:") + cnx = engine.connect() + cnx.execute(text("SELECT 1;")).fetchall() + self.assertRegex( + self.caplog.records[-2].getMessage(), + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 2) + # first span is connection to db + self.assertEqual(spans[0].name, "connect") + # second span is query itself + query_span = spans[1] + self.assertRegex( + query_span.attributes[SpanAttributes.DB_STATEMENT], + r"SELECT 1 /\*db_driver='(.*)',traceparent='\d{1,2}-[a-zA-Z0-9_]{32}-[a-zA-Z0-9_]{16}-\d{1,2}'\*/;", + ) def test_sqlcommenter_disabled_create_engine_after_instrumentation(self): SQLAlchemyInstrumentor().instrument( diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py index f5dacf0fff..c7dd9f7b06 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py @@ -19,7 +19,7 @@ from re import IGNORECASE as RE_IGNORECASE from re import compile as re_compile from re import search -from typing import Callable, Iterable, Optional +from typing import Callable, Iterable from urllib.parse import urlparse, urlunparse from opentelemetry.semconv.trace import SpanAttributes @@ -121,18 +121,16 @@ def sanitize_header_values( _root = r"OTEL_PYTHON_{}" -def get_traced_request_attrs(instrumentation): +def get_traced_request_attrs(instrumentation: str) -> list[str]: traced_request_attrs = environ.get( - _root.format(f"{instrumentation}_TRACED_REQUEST_ATTRS"), [] + _root.format(f"{instrumentation}_TRACED_REQUEST_ATTRS") ) - if traced_request_attrs: - traced_request_attrs = [ + return [ traced_request_attr.strip() for traced_request_attr in traced_request_attrs.split(",") ] - - return traced_request_attrs + return [] def get_excluded_urls(instrumentation: str) -> ExcludeList: @@ -193,7 +191,7 @@ def normalise_response_header_name(header: str) -> str: return f"http.response.header.{key}" -def sanitize_method(method: Optional[str]) -> Optional[str]: +def sanitize_method(method: str | None) -> str | None: if method is None: return None method = method.upper() diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/httplib.py b/util/opentelemetry-util-http/src/opentelemetry/util/http/httplib.py index 3d6b875752..f375e2f7c8 100644 --- a/util/opentelemetry-util-http/src/opentelemetry/util/http/httplib.py +++ b/util/opentelemetry-util-http/src/opentelemetry/util/http/httplib.py @@ -17,12 +17,14 @@ not create spans on its own. """ +from __future__ import annotations + import contextlib import http.client import logging import socket # pylint:disable=unused-import # Used for typing import typing -from typing import Collection +from typing import Any, Callable, Collection, TypedDict, cast import wrapt @@ -36,20 +38,22 @@ logger = logging.getLogger(__name__) +R = typing.TypeVar("R") + class HttpClientInstrumentor(BaseInstrumentor): def instrumentation_dependencies(self) -> Collection[str]: return () # This instruments http.client from stdlib; no extra deps. - def _instrument(self, **kwargs): + def _instrument(self, **kwargs: Any): """Instruments the http.client module (not creating spans on its own)""" _instrument() - def _uninstrument(self, **kwargs): + def _uninstrument(self, **kwargs: Any): _uninstrument() -def _remove_nonrecording(spanlist: typing.List[Span]): +def _remove_nonrecording(spanlist: list[Span]) -> bool: idx = len(spanlist) - 1 while idx >= 0: if not spanlist[idx].is_recording(): @@ -67,7 +71,9 @@ def _remove_nonrecording(spanlist: typing.List[Span]): return True -def trysetip(conn: http.client.HTTPConnection, loglevel=logging.DEBUG) -> bool: +def trysetip( + conn: http.client.HTTPConnection, loglevel: int = logging.DEBUG +) -> bool: """Tries to set the net.peer.ip semantic attribute on the current span from the given HttpConnection. @@ -110,14 +116,17 @@ def trysetip(conn: http.client.HTTPConnection, loglevel=logging.DEBUG) -> bool: def _instrumented_connect( - wrapped, instance: http.client.HTTPConnection, args, kwargs -): + wrapped: Callable[..., R], + instance: http.client.HTTPConnection, + args: tuple[Any, ...], + kwargs: dict[str, Any], +) -> R: result = wrapped(*args, **kwargs) trysetip(instance, loglevel=logging.WARNING) return result -def instrument_connect(module, name="connect"): +def instrument_connect(module: type[Any], name: str = "connect"): """Instrument additional connect() methods, e.g. for derived classes.""" wrapt.wrap_function_wrapper( @@ -129,8 +138,11 @@ def instrument_connect(module, name="connect"): def _instrument(): def instrumented_send( - wrapped, instance: http.client.HTTPConnection, args, kwargs - ): + wrapped: Callable[..., R], + instance: http.client.HTTPConnection, + args: tuple[Any, ...], + kwargs: dict[str, Any], + ) -> R: done = trysetip(instance) result = wrapped(*args, **kwargs) if not done: @@ -147,8 +159,12 @@ def instrumented_send( # No need to instrument HTTPSConnection, as it calls super().connect() -def _getstate() -> typing.Optional[dict]: - return context.get_value(_STATE_KEY) +class _ConnectionState(TypedDict): + need_ip: list[Span] + + +def _getstate() -> _ConnectionState | None: + return cast(_ConnectionState, context.get_value(_STATE_KEY)) @contextlib.contextmanager @@ -163,7 +179,7 @@ def set_ip_on_next_http_connection(span: Span): finally: context.detach(token) else: - spans: typing.List[Span] = state["need_ip"] + spans = state["need_ip"] spans.append(span) try: yield diff --git a/util/opentelemetry-util-http/src/opentelemetry/util/http/py.typed b/util/opentelemetry-util-http/src/opentelemetry/util/http/py.typed new file mode 100644 index 0000000000..e69de29bb2