From 02adc40ccaf0af8bd3c54636180ec1f712a0f62d Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 27 Mar 2026 16:44:05 -0700 Subject: [PATCH 1/6] Duplicate: add _semconv schem_url helper --- .../opentelemetry/instrumentation/_semconv.py | 78 +++++++- .../tests/test_semconv.py | 176 ++++++++++++++++++ 2 files changed, 252 insertions(+), 2 deletions(-) diff --git a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py index ccf1f39202..1edd18d038 100644 --- a/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py +++ b/opentelemetry-instrumentation/src/opentelemetry/instrumentation/_semconv.py @@ -18,10 +18,14 @@ import threading from enum import Enum from typing import Container, Mapping, MutableMapping +from urllib.parse import urlparse + +from packaging import version as package_version from opentelemetry.instrumentation.utils import http_status_to_status_code from opentelemetry.semconv._incubating.attributes.db_attributes import ( DB_NAME, + DB_OPERATION, DB_STATEMENT, DB_SYSTEM, DB_USER, @@ -50,6 +54,7 @@ ) from opentelemetry.semconv.attributes.db_attributes import ( DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_TEXT, DB_SYSTEM_NAME, ) @@ -177,6 +182,9 @@ OTEL_SEMCONV_STABILITY_OPT_IN = "OTEL_SEMCONV_STABILITY_OPT_IN" +# Legacy/default schema version when schema_url was first introduced +_LEGACY_SCHEMA_VERSION = "1.11.0" + class _OpenTelemetryStabilitySignalType(Enum): HTTP = "http" @@ -590,6 +598,17 @@ def _set_db_user( # No new attribute - db.user was removed with no replacement +def _set_db_operation( + result: MutableMapping[str, AttributeValue], + operation: str, + sem_conv_opt_in_mode: _StabilityMode, +) -> None: + if _report_old(sem_conv_opt_in_mode): + set_string_attribute(result, DB_OPERATION, operation) + if _report_new(sem_conv_opt_in_mode): + set_string_attribute(result, DB_OPERATION_NAME, operation) + + # General @@ -634,8 +653,63 @@ def _set_status( span.set_status(Status(status)) -# Get schema version based off of opt-in mode def _get_schema_url(mode: _StabilityMode) -> str: + """Get schema version URL for a single signal type's opt-in mode (backwards compatible). + + For new instrumentations using multiple signal types, use + _get_schema_url_for_signal_types() + """ if mode is _StabilityMode.DEFAULT: - return "https://opentelemetry.io/schemas/1.11.0" + return f"https://opentelemetry.io/schemas/{_LEGACY_SCHEMA_VERSION}" return Schemas.V1_21_0.value + + +def _get_schema_version_for_opt_in_mode( + signal_type: _OpenTelemetryStabilitySignalType, + mode: _StabilityMode, +) -> str: + """Get the schema version for a specific signal type and opt-in mode.""" + if mode == _StabilityMode.DEFAULT: + return _LEGACY_SCHEMA_VERSION + + signal_versions = { + _OpenTelemetryStabilitySignalType.HTTP: Schemas.V1_21_0.value, + _OpenTelemetryStabilitySignalType.DATABASE: Schemas.V1_25_0.value, + _OpenTelemetryStabilitySignalType.GEN_AI: Schemas.V1_26_0.value, + } + schema_url = signal_versions.get(signal_type) + if not schema_url: + return _LEGACY_SCHEMA_VERSION + + path = urlparse(schema_url).path + schema_version = path.rstrip("/").split("/")[-1] + return schema_version or _LEGACY_SCHEMA_VERSION + + +def _get_schema_url_for_signal_types( + signal_types: list[_OpenTelemetryStabilitySignalType], +) -> str: + """Get the highest applicable schema URL for multiple signal types. + + Note: + Instrumentors should call _OpenTelemetrySemanticConventionStability._initialize() + before using this function to ensure proper initialization of stability modes. + + Args: + signal_types: List of signal types used by the instrumentation + + Returns: + Schema URL string representing the highest applicable semconv version + """ + highest_schema_version = _LEGACY_SCHEMA_VERSION + for signal_type in signal_types: + mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + signal_type + ) + schema_version = _get_schema_version_for_opt_in_mode(signal_type, mode) + # Keep the highest for all signals + if package_version.Version(schema_version) > package_version.Version( + highest_schema_version + ): + highest_schema_version = schema_version + return f"https://opentelemetry.io/schemas/{highest_schema_version}" diff --git a/opentelemetry-instrumentation/tests/test_semconv.py b/opentelemetry-instrumentation/tests/test_semconv.py index 4560e28101..f160c51286 100644 --- a/opentelemetry-instrumentation/tests/test_semconv.py +++ b/opentelemetry-instrumentation/tests/test_semconv.py @@ -17,10 +17,14 @@ from unittest.mock import Mock, patch from opentelemetry.instrumentation._semconv import ( + _LEGACY_SCHEMA_VERSION, OTEL_SEMCONV_STABILITY_OPT_IN, + _get_schema_url_for_signal_types, + _get_schema_version_for_opt_in_mode, _OpenTelemetrySemanticConventionStability, _OpenTelemetryStabilitySignalType, _set_db_name, + _set_db_operation, _set_db_statement, _set_db_system, _set_db_user, @@ -29,12 +33,14 @@ ) from opentelemetry.semconv._incubating.attributes.db_attributes import ( DB_NAME, + DB_OPERATION, DB_STATEMENT, DB_SYSTEM, DB_USER, ) from opentelemetry.semconv.attributes.db_attributes import ( DB_NAMESPACE, + DB_OPERATION_NAME, DB_QUERY_TEXT, DB_SYSTEM_NAME, ) @@ -188,6 +194,134 @@ def test_stability_mode_dup_precedence(self): ) +class TestOpenTelemetrySemConvSchemaUrl(TestCase): + @stability_mode("") + def test_get_schema_version_for_opt_in_mode_default(self): + version = _get_schema_version_for_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, _StabilityMode.DEFAULT + ) + self.assertEqual(version, _LEGACY_SCHEMA_VERSION) + + version = _get_schema_version_for_opt_in_mode( + _OpenTelemetryStabilitySignalType.DATABASE, _StabilityMode.DEFAULT + ) + self.assertEqual(version, _LEGACY_SCHEMA_VERSION) + + version = _get_schema_version_for_opt_in_mode( + _OpenTelemetryStabilitySignalType.GEN_AI, _StabilityMode.DEFAULT + ) + self.assertEqual(version, _LEGACY_SCHEMA_VERSION) + + @stability_mode("") + def test_get_schema_version_for_opt_in_mode_http_stable(self): + version = _get_schema_version_for_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP, _StabilityMode.HTTP + ) + self.assertEqual(version, "1.21.0") + + @stability_mode("") + def test_get_schema_version_for_opt_in_mode_database_stable(self): + version = _get_schema_version_for_opt_in_mode( + _OpenTelemetryStabilitySignalType.DATABASE, _StabilityMode.DATABASE + ) + self.assertEqual(version, "1.25.0") + + @stability_mode("") + def test_get_schema_version_for_opt_in_mode_gen_ai_stable(self): + version = _get_schema_version_for_opt_in_mode( + _OpenTelemetryStabilitySignalType.GEN_AI, + _StabilityMode.GEN_AI_LATEST_EXPERIMENTAL, + ) + self.assertEqual(version, "1.26.0") + + @stability_mode("") + def test_get_schema_url_for_signal_types_single_http_default(self): + url = _get_schema_url_for_signal_types( + [_OpenTelemetryStabilitySignalType.HTTP] + ) + self.assertEqual( + url, f"https://opentelemetry.io/schemas/{_LEGACY_SCHEMA_VERSION}" + ) + + @stability_mode("http") + def test_get_schema_url_for_signal_types_single_http_stable(self): + url = _get_schema_url_for_signal_types( + [_OpenTelemetryStabilitySignalType.HTTP] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.21.0") + + @stability_mode("database") + def test_get_schema_url_for_signal_types_single_database_stable(self): + url = _get_schema_url_for_signal_types( + [_OpenTelemetryStabilitySignalType.DATABASE] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.25.0") + + @stability_mode("http,database") + def test_get_schema_url_for_signal_types_multiple_both_stable(self): + # DATABASE has higher version (1.25.0) than HTTP (1.21.0) + url = _get_schema_url_for_signal_types( + [ + _OpenTelemetryStabilitySignalType.HTTP, + _OpenTelemetryStabilitySignalType.DATABASE, + ] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.25.0") + + @stability_mode("http") + def test_get_schema_url_for_signal_types_mixed_modes(self): + # HTTP is stable (1.21.0), DATABASE is default (1.11.0) + # Should return HTTP version as it's higher + url = _get_schema_url_for_signal_types( + [ + _OpenTelemetryStabilitySignalType.HTTP, + _OpenTelemetryStabilitySignalType.DATABASE, + ] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.21.0") + + @stability_mode("database") + def test_get_schema_url_for_signal_types_database_only_stable(self): + # DATABASE is stable (1.25.0), HTTP is default (1.11.0) + # Should return DATABASE version as it's highest + url = _get_schema_url_for_signal_types( + [ + _OpenTelemetryStabilitySignalType.HTTP, + _OpenTelemetryStabilitySignalType.DATABASE, + ] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.25.0") + + @stability_mode("") + def test_get_schema_url_for_signal_types_empty_list(self): + url = _get_schema_url_for_signal_types([]) + self.assertEqual( + url, f"https://opentelemetry.io/schemas/{_LEGACY_SCHEMA_VERSION}" + ) + + @stability_mode("http/dup,database/dup") + def test_get_schema_url_for_signal_types_dup_modes(self): + url = _get_schema_url_for_signal_types( + [ + _OpenTelemetryStabilitySignalType.HTTP, + _OpenTelemetryStabilitySignalType.DATABASE, + ] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.25.0") + + @stability_mode("http,database,gen_ai_latest_experimental") + def test_get_schema_url_for_signal_types_with_gen_ai(self): + # GEN_AI should be highest at 1.26.0 + url = _get_schema_url_for_signal_types( + [ + _OpenTelemetryStabilitySignalType.HTTP, + _OpenTelemetryStabilitySignalType.DATABASE, + _OpenTelemetryStabilitySignalType.GEN_AI, + ] + ) + self.assertEqual(url, "https://opentelemetry.io/schemas/1.26.0") + + class TestOpenTelemetrySemConvStabilityHTTP(TestCase): def test_set_status_for_non_http_code_with_recording_span(self): span = Mock() @@ -443,3 +577,45 @@ def test_db_user_none_value(self): result = {} _set_db_user(result, None, sem_conv_opt_in_mode=_StabilityMode.DEFAULT) self.assertNotIn(DB_USER, result) + + def test_db_operation_default(self): + result = {} + _set_db_operation( + result, + "SELECT", + sem_conv_opt_in_mode=_StabilityMode.DEFAULT, + ) + self.assertIn(DB_OPERATION, result) + self.assertEqual(result[DB_OPERATION], "SELECT") + self.assertNotIn(DB_OPERATION_NAME, result) + + def test_db_operation_database_stable(self): + result = {} + _set_db_operation( + result, + "SELECT", + sem_conv_opt_in_mode=_StabilityMode.DATABASE, + ) + self.assertNotIn(DB_OPERATION, result) + self.assertIn(DB_OPERATION_NAME, result) + self.assertEqual(result[DB_OPERATION_NAME], "SELECT") + + def test_db_operation_database_dup(self): + result = {} + _set_db_operation( + result, + "SELECT", + sem_conv_opt_in_mode=_StabilityMode.DATABASE_DUP, + ) + self.assertIn(DB_OPERATION, result) + self.assertEqual(result[DB_OPERATION], "SELECT") + self.assertIn(DB_OPERATION_NAME, result) + self.assertEqual(result[DB_OPERATION_NAME], "SELECT") + + def test_db_operation_none_value(self): + result = {} + _set_db_operation( + result, None, sem_conv_opt_in_mode=_StabilityMode.DEFAULT + ) + self.assertNotIn(DB_OPERATION, result) + self.assertNotIn(DB_OPERATION_NAME, result) From 6cc6600dd99ca59498e7c71f5ac77da76e5e2988 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 27 Mar 2026 16:44:40 -0700 Subject: [PATCH 2/6] Redis Http,Database semconv opt in --- .../instrumentation/redis/__init__.py | 127 +++++- .../instrumentation/redis/package.py | 2 + .../instrumentation/redis/util.py | 45 ++- .../tests/test_redis.py | 382 +++++++++++++++++- 4 files changed, 525 insertions(+), 31 deletions(-) diff --git a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py index 77c3ac31c3..8bbb506ea1 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py +++ b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/__init__.py @@ -152,6 +152,12 @@ def response_hook(span, instance, response): from wrapt import wrap_function_wrapper from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + _get_schema_url_for_signal_types, + _OpenTelemetrySemanticConventionStability, + _OpenTelemetryStabilitySignalType, + _set_db_statement, +) from opentelemetry.instrumentation.instrumentor import BaseInstrumentor from opentelemetry.instrumentation.redis.package import _instruments from opentelemetry.instrumentation.redis.util import ( @@ -167,9 +173,6 @@ def response_hook(span, instance, response): is_instrumentation_enabled, unwrap, ) -from opentelemetry.semconv._incubating.attributes.db_attributes import ( - DB_STATEMENT, -) from opentelemetry.trace import ( StatusCode, Tracer, @@ -222,6 +225,15 @@ def _traced_execute_factory( request_hook: RequestHook | None = None, response_hook: ResponseHook | None = None, ): + # Get semconv opt-in modes for database and HTTP signal types + _OpenTelemetrySemanticConventionStability._initialize() + db_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.DATABASE + ) + http_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP + ) + def _traced_execute_command( func: Callable[..., R], instance: RedisInstance, @@ -237,9 +249,20 @@ def _traced_execute_command( name, kind=trace.SpanKind.CLIENT ) as span: if span.is_recording(): - span.set_attribute(DB_STATEMENT, query) - _set_connection_attributes(span, instance) - span.set_attribute("db.redis.args_length", len(args)) + span_attrs = {} + _set_db_statement(span_attrs, query, db_sem_conv_opt_in_mode) + span_attrs["db.redis.args_length"] = len(args) + + # Set all DB attributes + for key, value in span_attrs.items(): + span.set_attribute(key, value) + + _set_connection_attributes( + span, + instance, + db_sem_conv_opt_in_mode, + http_sem_conv_opt_in_mode, + ) if span.name == "redis.create_index": _add_create_attributes(span, args) if callable(request_hook): @@ -260,6 +283,15 @@ def _traced_execute_pipeline_factory( request_hook: RequestHook | None = None, response_hook: ResponseHook | None = None, ): + # Get semconv opt-in modes for database and HTTP signal types + _OpenTelemetrySemanticConventionStability._initialize() + db_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.DATABASE + ) + http_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP + ) + def _traced_execute_pipeline( func: Callable[..., R], instance: PipelineInstance, @@ -279,10 +311,21 @@ def _traced_execute_pipeline( span_name, kind=trace.SpanKind.CLIENT ) as span: if span.is_recording(): - span.set_attribute(DB_STATEMENT, resource) - _set_connection_attributes(span, instance) - span.set_attribute( - "db.redis.pipeline_length", len(command_stack) + span_attrs = {} + _set_db_statement( + span_attrs, resource, db_sem_conv_opt_in_mode + ) + span_attrs["db.redis.pipeline_length"] = len(command_stack) + + # Set all DB attributes + for key, value in span_attrs.items(): + span.set_attribute(key, value) + + _set_connection_attributes( + span, + instance, + db_sem_conv_opt_in_mode, + http_sem_conv_opt_in_mode, ) response = None @@ -308,6 +351,15 @@ def _async_traced_execute_factory( request_hook: RequestHook | None = None, response_hook: ResponseHook | None = None, ): + # Get semconv opt-in modes for database and HTTP signal types + _OpenTelemetrySemanticConventionStability._initialize() + db_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.DATABASE + ) + http_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP + ) + async def _async_traced_execute_command( func: Callable[..., Awaitable[R]], instance: AsyncRedisInstance, @@ -324,9 +376,20 @@ async def _async_traced_execute_command( name, kind=trace.SpanKind.CLIENT ) as span: if span.is_recording(): - span.set_attribute(DB_STATEMENT, query) - _set_connection_attributes(span, instance) - span.set_attribute("db.redis.args_length", len(args)) + span_attrs = {} + _set_db_statement(span_attrs, query, db_sem_conv_opt_in_mode) + span_attrs["db.redis.args_length"] = len(args) + + # Set all DB attributes + for key, value in span_attrs.items(): + span.set_attribute(key, value) + + _set_connection_attributes( + span, + instance, + db_sem_conv_opt_in_mode, + http_sem_conv_opt_in_mode, + ) if callable(request_hook): request_hook(span, instance, args, kwargs) response = await func(*args, **kwargs) @@ -342,6 +405,15 @@ def _async_traced_execute_pipeline_factory( request_hook: RequestHook | None = None, response_hook: ResponseHook | None = None, ): + # Get semconv opt-in modes for database and HTTP signal types + _OpenTelemetrySemanticConventionStability._initialize() + db_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.DATABASE + ) + http_sem_conv_opt_in_mode = _OpenTelemetrySemanticConventionStability._get_opentelemetry_stability_opt_in_mode( + _OpenTelemetryStabilitySignalType.HTTP + ) + async def _async_traced_execute_pipeline( func: Callable[..., Awaitable[R]], instance: AsyncPipelineInstance, @@ -363,10 +435,21 @@ async def _async_traced_execute_pipeline( span_name, kind=trace.SpanKind.CLIENT ) as span: if span.is_recording(): - span.set_attribute(DB_STATEMENT, resource) - _set_connection_attributes(span, instance) - span.set_attribute( - "db.redis.pipeline_length", len(command_stack) + span_attrs = {} + _set_db_statement( + span_attrs, resource, db_sem_conv_opt_in_mode + ) + span_attrs["db.redis.pipeline_length"] = len(command_stack) + + # Set all DB attributes + for key, value in span_attrs.items(): + span.set_attribute(key, value) + + _set_connection_attributes( + span, + instance, + db_sem_conv_opt_in_mode, + http_sem_conv_opt_in_mode, ) response = None @@ -540,12 +623,20 @@ def _pipeline_wrapper(func, instance, args, kwargs): class RedisInstrumentor(BaseInstrumentor): @staticmethod def _get_tracer(**kwargs): + # Initialize semantic conventions opt-in if needed + _OpenTelemetrySemanticConventionStability._initialize() + # Redis instrumentation supports both DATABASE and HTTP signal types + signal_types = [ + _OpenTelemetryStabilitySignalType.DATABASE, + _OpenTelemetryStabilitySignalType.HTTP, + ] + tracer_provider = kwargs.get("tracer_provider") return get_tracer( __name__, __version__, tracer_provider=tracer_provider, - schema_url="https://opentelemetry.io/schemas/1.11.0", + schema_url=_get_schema_url_for_signal_types(signal_types), ) def instrument( diff --git a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/package.py b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/package.py index dd2efb37b0..e31b4a45db 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/package.py +++ b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/package.py @@ -14,3 +14,5 @@ _instruments = ("redis >= 2.6",) + +_semconv_status = "migration" diff --git a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py index 320758f842..b79759f184 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py +++ b/instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py @@ -20,13 +20,15 @@ from typing import TYPE_CHECKING, Any +from opentelemetry.instrumentation._semconv import ( + _set_db_system, + _set_http_net_peer_name_client, + _set_http_peer_port_client, +) from opentelemetry.semconv._incubating.attributes.db_attributes import ( DB_REDIS_DATABASE_INDEX, - DB_SYSTEM, ) from opentelemetry.semconv._incubating.attributes.net_attributes import ( - NET_PEER_NAME, - NET_PEER_PORT, NET_TRANSPORT, ) from opentelemetry.semconv.trace import ( @@ -46,19 +48,33 @@ _FIELD_TYPES = ["NUMERIC", "TEXT", "GEO", "TAG", "VECTOR"] -def _extract_conn_attributes(conn_kwargs): +def _extract_conn_attributes( + conn_kwargs, db_sem_conv_opt_in_mode, http_sem_conv_opt_in_mode +): """Transform redis conn info into dict""" - attributes = { - DB_SYSTEM: DbSystemValues.REDIS.value, - } + attributes = {} + _set_db_system( + attributes, DbSystemValues.REDIS.value, db_sem_conv_opt_in_mode + ) + db = conn_kwargs.get("db", 0) attributes[DB_REDIS_DATABASE_INDEX] = db if "path" in conn_kwargs: - attributes[NET_PEER_NAME] = conn_kwargs.get("path", "") + _set_http_net_peer_name_client( + attributes, conn_kwargs.get("path", ""), http_sem_conv_opt_in_mode + ) attributes[NET_TRANSPORT] = NetTransportValues.OTHER.value else: - attributes[NET_PEER_NAME] = conn_kwargs.get("host", "localhost") - attributes[NET_PEER_PORT] = conn_kwargs.get("port", 6379) + _set_http_net_peer_name_client( + attributes, + conn_kwargs.get("host", "localhost"), + http_sem_conv_opt_in_mode, + ) + _set_http_peer_port_client( + attributes, + conn_kwargs.get("port", 6379), + http_sem_conv_opt_in_mode, + ) attributes[NET_TRANSPORT] = NetTransportValues.IP_TCP.value return attributes @@ -99,12 +115,17 @@ def _value_or_none(values, n): def _set_connection_attributes( - span: Span, conn: RedisInstance | AsyncRedisInstance + span: Span, + conn: RedisInstance | AsyncRedisInstance, + db_sem_conv_opt_in_mode, + http_sem_conv_opt_in_mode, ) -> None: if not span.is_recording() or not hasattr(conn, "connection_pool"): return for key, value in _extract_conn_attributes( - conn.connection_pool.connection_kwargs + conn.connection_pool.connection_kwargs, + db_sem_conv_opt_in_mode, + http_sem_conv_opt_in_mode, ).items(): span.set_attribute(key, value) diff --git a/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py b/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py index 3e649fcef7..695f1006ca 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py +++ b/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py @@ -12,8 +12,9 @@ # See the License for the specific language governing permissions and # limitations under the License. import asyncio +import os from unittest import IsolatedAsyncioTestCase, mock -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, patch import fakeredis import pytest @@ -24,10 +25,15 @@ from redis.exceptions import WatchError from opentelemetry import trace +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) from opentelemetry.instrumentation.redis import RedisInstrumentor from opentelemetry.instrumentation.utils import suppress_instrumentation from opentelemetry.semconv._incubating.attributes.db_attributes import ( DB_REDIS_DATABASE_INDEX, + DB_STATEMENT, DB_SYSTEM, DbSystemValues, ) @@ -37,10 +43,28 @@ NET_TRANSPORT, NetTransportValues, ) +from opentelemetry.semconv.attributes.db_attributes import DB_QUERY_TEXT +from opentelemetry.semconv.attributes.server_attributes import ( + SERVER_ADDRESS, + SERVER_PORT, +) from opentelemetry.test.test_base import TestBase from opentelemetry.trace import SpanKind +def stability_mode(mode): + def decorator(test_case): + @patch.dict(os.environ, {OTEL_SEMCONV_STABILITY_OPT_IN: mode}) + def wrapper(*args, **kwargs): + _OpenTelemetrySemanticConventionStability._initialized = False + _OpenTelemetrySemanticConventionStability._initialize() + return test_case(*args, **kwargs) + + return wrapper + + return decorator + + # pylint: disable=too-many-public-methods class TestRedis(TestBase): def assert_span_count(self, count: int): @@ -773,3 +797,359 @@ def test_watch_error_sync_only_client(self): self.assertEqual(span.attributes.get("db.statement"), "SET ? ?") self.assertEqual(span.kind, SpanKind.CLIENT) self.assertEqual(span.status.status_code, trace.StatusCode.UNSET) + + +class TestRedisSemconvConfiguration(TestRedis): + """Tests semconv migration for both Redis pipeline and db_statement""" + + def re_instrument_and_clear_exporter(self): + # Re-instrument to pick up the environment variable change + RedisInstrumentor().uninstrument() + self.memory_exporter.clear() # Clear previous spans + RedisInstrumentor().instrument(tracer_provider=self.tracer_provider) + + @stability_mode("") + def test_pipeline_default_mode(self): + self.re_instrument_and_clear_exporter() + redis_client = fakeredis.FakeStrictRedis() + pipe = redis_client.pipeline() + pipe.get("key1") + pipe.set("key2", "value2") + pipe.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertIn(DB_STATEMENT, span.attributes) + self.assertIn("GET ?", span.attributes[DB_STATEMENT]) + self.assertIn("SET ? ?", span.attributes[DB_STATEMENT]) + self.assertNotIn(DB_QUERY_TEXT, span.attributes) + + @stability_mode("database") + def test_pipeline_database_stable_mode(self): + self.re_instrument_and_clear_exporter() + redis_client = fakeredis.FakeStrictRedis() + pipe = redis_client.pipeline() + pipe.get("key1") + pipe.set("key2", "value2") + pipe.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertNotIn(DB_STATEMENT, span.attributes) + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertIn("GET ?", span.attributes[DB_QUERY_TEXT]) + self.assertIn("SET ? ?", span.attributes[DB_QUERY_TEXT]) + + @stability_mode("database/dup") + def test_pipeline_database_dup_mode(self): + self.re_instrument_and_clear_exporter() + redis_client = fakeredis.FakeStrictRedis() + pipe = redis_client.pipeline() + pipe.get("key1") + pipe.set("key2", "value2") + pipe.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertIn(DB_STATEMENT, span.attributes) + self.assertIn("GET ?", span.attributes[DB_STATEMENT]) + self.assertIn("SET ? ?", span.attributes[DB_STATEMENT]) + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertIn("GET ?", span.attributes[DB_QUERY_TEXT]) + self.assertIn("SET ? ?", span.attributes[DB_QUERY_TEXT]) + + @stability_mode("") + def test_db_statement_default_mode(self): + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertIn(DB_STATEMENT, span.attributes) + self.assertEqual(span.attributes[DB_STATEMENT], "GET ?") + self.assertNotIn(DB_QUERY_TEXT, span.attributes) + + @stability_mode("database") + def test_db_statement_database_stable_mode(self): + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertNotIn(DB_STATEMENT, span.attributes) + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertEqual(span.attributes[DB_QUERY_TEXT], "GET ?") + + @stability_mode("database/dup") + def test_db_statement_database_dup_mode(self): + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + self.assertIn(DB_STATEMENT, span.attributes) + self.assertEqual(span.attributes[DB_STATEMENT], "GET ?") + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertEqual(span.attributes[DB_QUERY_TEXT], "GET ?") + + @stability_mode("http") + def test_db_statement_http_stable_mode(self): + # HTTP signal type should not affect database attributes; they stay in default behavior + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # HTTP signal type doesn't affect database attributes - they remain in default mode + self.assertIn(DB_STATEMENT, span.attributes) + self.assertEqual(span.attributes[DB_STATEMENT], "GET ?") + self.assertNotIn(DB_QUERY_TEXT, span.attributes) + # Network attributes should still be present (HTTP signal type for network attributes) + self.assertIn(SERVER_ADDRESS, span.attributes) + self.assertIn(SERVER_PORT, span.attributes) + + @stability_mode("http/dup") + def test_db_statement_http_dup_mode(self): + # HTTP signal type should not affect database attributes; they stay in default behavior + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # HTTP signal type doesn't affect database attributes - they remain in default mode + self.assertIn(DB_STATEMENT, span.attributes) + self.assertEqual(span.attributes[DB_STATEMENT], "GET ?") + self.assertNotIn(DB_QUERY_TEXT, span.attributes) + # Network attributes should still be present (HTTP signal type for network attributes) + self.assertIn(SERVER_ADDRESS, span.attributes) + self.assertIn(SERVER_PORT, span.attributes) + + @stability_mode("http,database") + def test_db_statement_combined_http_database_mode(self): + # Both HTTP and DATABASE signal types should be active + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # DATABASE signal type should use stable attributes + self.assertNotIn(DB_STATEMENT, span.attributes) + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertEqual(span.attributes[DB_QUERY_TEXT], "GET ?") + # Network attributes should still be present (HTTP signal type) + self.assertIn(SERVER_ADDRESS, span.attributes) + self.assertIn(SERVER_PORT, span.attributes) + + @stability_mode("database,http") + def test_db_statement_combined_database_http_mode(self): + # Both DATABASE and HTTP signal types should be active (order shouldn't matter) + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # DATABASE signal type should use stable attributes + self.assertNotIn(DB_STATEMENT, span.attributes) + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertEqual(span.attributes[DB_QUERY_TEXT], "GET ?") + # Network attributes should still be present (HTTP signal type) + self.assertIn(SERVER_ADDRESS, span.attributes) + self.assertIn(SERVER_PORT, span.attributes) + + @stability_mode("database/dup,http") + def test_db_statement_combined_database_dup_http_mode(self): + # Both DATABASE (dup) and HTTP signal types should be active + self.re_instrument_and_clear_exporter() + redis_client = redis.Redis() + + with mock.patch.object(redis_client, "connection"): + redis_client.get("key") + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # DATABASE signal type in dup mode should have both attributes + self.assertIn(DB_STATEMENT, span.attributes) + self.assertEqual(span.attributes[DB_STATEMENT], "GET ?") + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertEqual(span.attributes[DB_QUERY_TEXT], "GET ?") + # Network attributes should still be present (HTTP signal type) + self.assertIn(SERVER_ADDRESS, span.attributes) + self.assertIn(SERVER_PORT, span.attributes) + + @stability_mode("http") + def test_pipeline_http_stable_mode(self): + # HTTP signal type should not affect database attributes in pipeline + self.re_instrument_and_clear_exporter() + redis_client = fakeredis.FakeStrictRedis() + pipe = redis_client.pipeline() + pipe.get("key1") + pipe.set("key2", "value2") + pipe.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # HTTP signal type doesn't affect database attributes - they remain in default mode + self.assertIn(DB_STATEMENT, span.attributes) + self.assertIn("GET ?", span.attributes[DB_STATEMENT]) + self.assertIn("SET ? ?", span.attributes[DB_STATEMENT]) + self.assertNotIn(DB_QUERY_TEXT, span.attributes) + + @stability_mode("http,database") + def test_pipeline_combined_http_database_mode(self): + # Both HTTP and DATABASE signal types should be active in pipeline + self.re_instrument_and_clear_exporter() + redis_client = fakeredis.FakeStrictRedis() + pipe = redis_client.pipeline() + pipe.get("key1") + pipe.set("key2", "value2") + pipe.execute() + + spans = self.memory_exporter.get_finished_spans() + self.assertEqual(len(spans), 1) + + span = spans[0] + # DATABASE signal type should use stable attributes + self.assertNotIn(DB_STATEMENT, span.attributes) + self.assertIn(DB_QUERY_TEXT, span.attributes) + self.assertIn("GET ?", span.attributes[DB_QUERY_TEXT]) + self.assertIn("SET ? ?", span.attributes[DB_QUERY_TEXT]) + + @stability_mode("") + def test_schema_url_default_mode(self): + """Test schema URL assignment in default stability mode.""" + self.re_instrument_and_clear_exporter() + with mock.patch( + "opentelemetry.instrumentation.redis.get_tracer" + ) as mock_get_tracer: + mock_tracer = mock.Mock() + mock_get_tracer.return_value = mock_tracer + RedisInstrumentor._get_tracer(tracer_provider=self.tracer_provider) + + # Verify get_tracer was called with legacy schema URL in default mode + mock_get_tracer.assert_called_once() + call_args = mock_get_tracer.call_args + self.assertEqual( + call_args[1]["schema_url"], + "https://opentelemetry.io/schemas/1.11.0", + ) + + @stability_mode("database") + def test_schema_url_database_stable_mode(self): + """Test schema URL assignment in database stable mode.""" + self.re_instrument_and_clear_exporter() + with mock.patch( + "opentelemetry.instrumentation.redis.get_tracer" + ) as mock_get_tracer: + mock_tracer = mock.Mock() + mock_get_tracer.return_value = mock_tracer + RedisInstrumentor._get_tracer(tracer_provider=self.tracer_provider) + + # Verify get_tracer was called with stable schema URL + mock_get_tracer.assert_called_once() + call_args = mock_get_tracer.call_args + self.assertEqual( + call_args[1]["schema_url"], + "https://opentelemetry.io/schemas/1.25.0", + ) + + @stability_mode("database/dup") + def test_schema_url_database_dup_mode(self): + """Test schema URL assignment in database duplicate mode.""" + self.re_instrument_and_clear_exporter() + with mock.patch( + "opentelemetry.instrumentation.redis.get_tracer" + ) as mock_get_tracer: + mock_tracer = mock.Mock() + mock_get_tracer.return_value = mock_tracer + RedisInstrumentor._get_tracer(tracer_provider=self.tracer_provider) + + # Verify get_tracer was called with stable schema URL + mock_get_tracer.assert_called_once() + call_args = mock_get_tracer.call_args + self.assertEqual( + call_args[1]["schema_url"], + "https://opentelemetry.io/schemas/1.25.0", + ) + + @stability_mode("http") + def test_schema_url_http_mode(self): + """Test schema URL assignment in HTTP stability mode.""" + self.re_instrument_and_clear_exporter() + with mock.patch( + "opentelemetry.instrumentation.redis.get_tracer" + ) as mock_get_tracer: + mock_tracer = mock.Mock() + mock_get_tracer.return_value = mock_tracer + RedisInstrumentor._get_tracer(tracer_provider=self.tracer_provider) + + # Verify get_tracer was called with stable schema URL + mock_get_tracer.assert_called_once() + call_args = mock_get_tracer.call_args + self.assertEqual( + call_args[1]["schema_url"], + "https://opentelemetry.io/schemas/1.21.0", + ) + + @stability_mode("http,database") + def test_schema_url_combined_mode(self): + """Test schema URL assignment in combined HTTP and database mode.""" + self.re_instrument_and_clear_exporter() + with mock.patch( + "opentelemetry.instrumentation.redis.get_tracer" + ) as mock_get_tracer: + mock_tracer = mock.Mock() + mock_get_tracer.return_value = mock_tracer + RedisInstrumentor._get_tracer(tracer_provider=self.tracer_provider) + + # Verify get_tracer was called with stable schema URL + mock_get_tracer.assert_called_once() + call_args = mock_get_tracer.call_args + self.assertEqual( + call_args[1]["schema_url"], + "https://opentelemetry.io/schemas/1.25.0", + ) From 269949b2805ce6682216d83df2f43ce3cf00be54 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 27 Mar 2026 16:51:36 -0700 Subject: [PATCH 3/6] Changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c9c67be4b..91461d0d2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ([#4212](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4212)) - `opentelemetry-instrumentation-botocore`: Add support for instrumenting `aiobotocore` ([#4049](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4049)) +- `opentelemetry-instrumentation-redis`: implement new semantic convention opt-in migration + ([#4370](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4370)) ### Fixed From aadf1d23927157e1613d18a6cc1f86ab0647b079 Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 27 Mar 2026 16:54:06 -0700 Subject: [PATCH 4/6] semconv status generated --- instrumentation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation/README.md b/instrumentation/README.md index 2802d8a872..cdb6a8d5b1 100644 --- a/instrumentation/README.md +++ b/instrumentation/README.md @@ -38,7 +38,7 @@ | [opentelemetry-instrumentation-pymssql](./opentelemetry-instrumentation-pymssql) | pymssql >= 2.1.5, < 3 | No | development | [opentelemetry-instrumentation-pymysql](./opentelemetry-instrumentation-pymysql) | PyMySQL < 2 | No | development | [opentelemetry-instrumentation-pyramid](./opentelemetry-instrumentation-pyramid) | pyramid >= 1.7 | Yes | migration -| [opentelemetry-instrumentation-redis](./opentelemetry-instrumentation-redis) | redis >= 2.6 | No | development +| [opentelemetry-instrumentation-redis](./opentelemetry-instrumentation-redis) | redis >= 2.6 | No | migration | [opentelemetry-instrumentation-remoulade](./opentelemetry-instrumentation-remoulade) | remoulade >= 0.50 | No | development | [opentelemetry-instrumentation-requests](./opentelemetry-instrumentation-requests) | requests ~= 2.0 | Yes | migration | [opentelemetry-instrumentation-sqlalchemy](./opentelemetry-instrumentation-sqlalchemy) | sqlalchemy >= 1.0.0, < 2.1.0 | Yes | development From 1d4d6d59a3c1052b891cfe13f4e16ec7c792e4ef Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Fri, 27 Mar 2026 16:58:51 -0700 Subject: [PATCH 5/6] lint --- .../opentelemetry-instrumentation-redis/tests/test_redis.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py b/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py index 695f1006ca..e621992c14 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py +++ b/instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py @@ -11,6 +11,9 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. + +# pylint: disable=too-many-lines + import asyncio import os from unittest import IsolatedAsyncioTestCase, mock From 1c7e3cc7638dc33ccb0b1dcb98252c254987ed3e Mon Sep 17 00:00:00 2001 From: tammy-baylis-swi Date: Mon, 30 Mar 2026 12:03:05 -0700 Subject: [PATCH 6/6] Add dep for py39 redis test --- .../opentelemetry-instrumentation-redis/test-requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt b/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt index 627b13573c..4892d9ab78 100644 --- a/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt +++ b/instrumentation/opentelemetry-instrumentation-redis/test-requirements.txt @@ -7,6 +7,7 @@ packaging==24.0 pluggy==1.6.0 py-cpuinfo==9.0.0 pytest==7.4.4 +pytest-asyncio==0.23.5 redis==5.0.1 tomli==2.0.1 typing_extensions==4.12.2