Skip to content

Commit a8359fd

Browse files
UTKARSH698tammy-baylis-swixrmxherin049
authored
fix(redis): build ClusterPipeline span metadata from _execution_strategy in redis-py 6+ (#4728)
* fix(redis): build ClusterPipeline span metadata from _execution_strategy in redis-py 6+ redis-py 6 refactored ClusterPipeline so that queued commands are no longer appended to `command_stack`; they now live on `_execution_strategy.command_queue`. As a result the redis instrumentor emitted ClusterPipeline spans with an empty DB_STATEMENT and a `db.redis.pipeline_length` of 0. `_build_span_meta_data_for_pipeline` now reads commands from `_execution_strategy.command_queue` when present, falling back to `command_stack` / `_command_stack` for non-cluster pipelines and older redis-py versions. Fixes #4084 * chore(redis): rename changelog fragment to PR number * test(redis): add docker regression test for ClusterPipeline span metadata Exercises a real RedisCluster pipeline and asserts the redis-py 6+ code path (command_stack empty, _execution_strategy.command_queue populated) is genuinely hit before checking the emitted span statement and db.redis.pipeline_length. Guards against regression #4084. * Bump redis-py to 6.4.0 in docker tests so the ClusterPipeline regression test exercises the redis-py 6+ code path * Fix RediSearch import for redis-py 6+ in docker tests redis-py 6.x renamed redis.commands.search.indexDefinition to index_definition (snake_case). The camelCase module was removed, so bumping redis to 6.4.0 broke collection of the entire redis functional test module. IndexDefinition/IndexType are unchanged in the new module. * fix(redis): read async ClusterPipeline commands from private _command_queue The redis-py 6+ sync cluster strategy exposes queued commands via a public command_queue property, but the async cluster strategy only exposes the private _command_queue attribute. Reading only command_queue left the async ClusterPipeline span with an empty statement and a default "redis" name once the docker tests were bumped to redis-py 6.4.0. Resolve _command_queue as a fallback so both sync and async cluster pipelines populate span metadata. --------- Co-authored-by: Tammy Baylis <96076570+tammy-baylis-swi@users.noreply.github.com> Co-authored-by: Riccardo Magliocchetti <riccardo.magliocchetti@gmail.com> Co-authored-by: Lukas Hering <40302054+herin049@users.noreply.github.com>
1 parent 79f9e57 commit a8359fd

5 files changed

Lines changed: 158 additions & 7 deletions

File tree

.changelog/4728.fixed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
`opentelemetry-instrumentation-redis`: extract `ClusterPipeline` commands from `_execution_strategy` in redis-py 6+ so pipeline span attributes include the queued commands instead of being empty

instrumentation/opentelemetry-instrumentation-redis/src/opentelemetry/instrumentation/redis/util.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -207,11 +207,25 @@ def _build_span_meta_data_for_pipeline(
207207
instance: PipelineInstance | AsyncPipelineInstance,
208208
) -> tuple[list[Any], str, str]:
209209
try:
210-
command_stack = (
211-
instance.command_stack
212-
if hasattr(instance, "command_stack")
213-
else instance._command_stack
214-
)
210+
# redis-py 6+ ClusterPipeline no longer updates ``command_stack``;
211+
# queued commands are tracked on the execution strategy instead. The
212+
# sync cluster strategy exposes them via a public ``command_queue``
213+
# property, while the async cluster strategy only has the private
214+
# ``_command_queue`` attribute. Fall back to ``command_stack`` /
215+
# ``_command_stack`` for non-cluster pipelines and older redis-py.
216+
execution_strategy = getattr(instance, "_execution_strategy", None)
217+
if execution_strategy is not None and hasattr(
218+
execution_strategy, "command_queue"
219+
):
220+
command_stack = execution_strategy.command_queue
221+
elif execution_strategy is not None and hasattr(
222+
execution_strategy, "_command_queue"
223+
):
224+
command_stack = execution_strategy._command_queue
225+
elif hasattr(instance, "command_stack"):
226+
command_stack = instance.command_stack
227+
else:
228+
command_stack = instance._command_stack
215229

216230
cmds = [
217231
_format_command_args(c.args if hasattr(c, "args") else c[0])

instrumentation/opentelemetry-instrumentation-redis/tests/test_redis.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
_OpenTelemetrySemanticConventionStability,
2323
)
2424
from opentelemetry.instrumentation.redis import RedisInstrumentor
25+
from opentelemetry.instrumentation.redis.util import (
26+
_build_span_meta_data_for_pipeline,
27+
)
2528
from opentelemetry.instrumentation.utils import suppress_instrumentation
2629
from opentelemetry.semconv._incubating.attributes.db_attributes import (
2730
DB_REDIS_DATABASE_INDEX,
@@ -1649,3 +1652,100 @@ def test_schema_url_combined_mode(self):
16491652
call_args[1]["schema_url"],
16501653
"https://opentelemetry.io/schemas/1.25.0",
16511654
)
1655+
1656+
1657+
class _FakeCommand:
1658+
def __init__(self, *args):
1659+
self.args = args
1660+
1661+
1662+
class _FakeExecutionStrategy:
1663+
def __init__(self, commands):
1664+
self.command_queue = commands
1665+
1666+
1667+
class _FakeClusterPipeline:
1668+
"""Mimics redis-py 6+ ClusterPipeline: queued commands live on
1669+
``_execution_strategy.command_queue`` while ``command_stack`` stays empty."""
1670+
1671+
def __init__(self, commands):
1672+
self.command_stack = []
1673+
self._execution_strategy = _FakeExecutionStrategy(commands)
1674+
1675+
1676+
class _FakeAsyncExecutionStrategy:
1677+
"""Mimics redis-py 6+ async cluster strategy: unlike the sync strategy
1678+
(public ``command_queue`` property), it only exposes the private
1679+
``_command_queue`` attribute."""
1680+
1681+
def __init__(self, commands):
1682+
self._command_queue = commands
1683+
1684+
1685+
class _FakeAsyncClusterPipeline:
1686+
"""Mimics redis-py 6+ async ClusterPipeline: queued commands live on
1687+
``_execution_strategy._command_queue`` and there is no ``command_stack``."""
1688+
1689+
def __init__(self, commands):
1690+
self._execution_strategy = _FakeAsyncExecutionStrategy(commands)
1691+
1692+
1693+
class _FakeLegacyPipeline:
1694+
def __init__(self, commands):
1695+
self.command_stack = commands
1696+
1697+
1698+
class TestBuildSpanMetaDataForPipeline(TestBase):
1699+
def test_cluster_pipeline_reads_execution_strategy(self):
1700+
# Regression test for issue #4084: redis-py 6+ ClusterPipeline no
1701+
# longer populates command_stack, so commands must be read from
1702+
# _execution_strategy.command_queue.
1703+
commands = [_FakeCommand("SET", "k1", "v1"), _FakeCommand("GET", "k1")]
1704+
instance = _FakeClusterPipeline(commands)
1705+
1706+
command_stack, resource, span_name = (
1707+
_build_span_meta_data_for_pipeline(instance)
1708+
)
1709+
1710+
self.assertEqual(len(command_stack), 2)
1711+
self.assertEqual(resource, "SET ? ?\nGET ?")
1712+
self.assertEqual(span_name, "SET GET")
1713+
1714+
def test_async_cluster_pipeline_reads_private_command_queue(self):
1715+
# Regression test for issue #4084 on the async path: the redis-py 6+
1716+
# async cluster strategy exposes only the private ``_command_queue``
1717+
# (no public ``command_queue`` property), so reading only
1718+
# ``command_queue`` left the async ClusterPipeline span empty.
1719+
commands = [_FakeCommand("SET", "k1", "v1"), _FakeCommand("GET", "k1")]
1720+
instance = _FakeAsyncClusterPipeline(commands)
1721+
1722+
command_stack, resource, span_name = (
1723+
_build_span_meta_data_for_pipeline(instance)
1724+
)
1725+
1726+
self.assertEqual(len(command_stack), 2)
1727+
self.assertEqual(resource, "SET ? ?\nGET ?")
1728+
self.assertEqual(span_name, "SET GET")
1729+
1730+
def test_legacy_pipeline_still_reads_command_stack(self):
1731+
commands = [_FakeCommand("SET", "k1", "v1")]
1732+
instance = _FakeLegacyPipeline(commands)
1733+
1734+
command_stack, resource, span_name = (
1735+
_build_span_meta_data_for_pipeline(instance)
1736+
)
1737+
1738+
self.assertEqual(len(command_stack), 1)
1739+
self.assertEqual(resource, "SET ? ?")
1740+
self.assertEqual(span_name, "SET")
1741+
1742+
def test_empty_cluster_pipeline_falls_back_to_redis_span_name(self):
1743+
instance = _FakeClusterPipeline([])
1744+
1745+
command_stack, resource, span_name = (
1746+
_build_span_meta_data_for_pipeline(instance)
1747+
)
1748+
1749+
self.assertEqual(command_stack, [])
1750+
self.assertEqual(resource, "")
1751+
self.assertEqual(span_name, "redis")

tests/opentelemetry-docker-tests/tests/redis/test_redis_functional.py

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
TextField,
1111
VectorField,
1212
)
13-
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
13+
from redis.commands.search.index_definition import IndexDefinition, IndexType
1414
from redis.commands.search.query import Query
1515
from redis.exceptions import ResponseError
1616

@@ -225,6 +225,42 @@ def test_pipeline_traced(self):
225225
)
226226
self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3)
227227

228+
def test_cluster_pipeline_span_metadata_regression_4084(self):
229+
"""Regression test for issue #4084 against a real cluster.
230+
231+
redis-py 6+ refactored ClusterPipeline so queued commands live on
232+
``_execution_strategy.command_queue`` and no longer populate
233+
``command_stack``. This previously produced a ClusterPipeline span
234+
with an empty ``db.statement`` and a ``db.redis.pipeline_length`` of 0.
235+
"""
236+
with self.redis_client.pipeline(transaction=False) as pipeline:
237+
pipeline.set("blah", 32)
238+
pipeline.rpush("foo", "éé")
239+
pipeline.hgetall("xxx")
240+
241+
# On redis-py 6+ the queued commands are tracked on the execution
242+
# strategy rather than command_stack; assert the regression path is
243+
# genuinely exercised before checking the emitted span metadata.
244+
if hasattr(pipeline, "_execution_strategy") and hasattr(
245+
pipeline._execution_strategy, "command_queue"
246+
):
247+
self.assertEqual(pipeline.command_stack, [])
248+
self.assertEqual(
249+
len(pipeline._execution_strategy.command_queue), 3
250+
)
251+
252+
pipeline.execute()
253+
254+
spans = self.memory_exporter.get_finished_spans()
255+
self.assertEqual(len(spans), 1)
256+
span = spans[0]
257+
self._check_span(span, "SET RPUSH HGETALL")
258+
self.assertEqual(
259+
span.attributes.get(DB_STATEMENT),
260+
"SET ? ?\nRPUSH ? ?\nHGETALL ?",
261+
)
262+
self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3)
263+
228264
def test_parent(self):
229265
"""Ensure OpenTelemetry works with redis."""
230266
ot_tracer = trace.get_tracer("redis_svc")

tests/opentelemetry-docker-tests/tests/test-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ pytest==8.0.2
4141
pytest-celery==0.0.0
4242
python-dateutil==2.9.0.post0
4343
pytz==2024.1
44-
redis==5.0.1
44+
redis==6.4.0
4545
requests==2.33.1
4646
six==1.16.0
4747
SQLAlchemy==1.4.52

0 commit comments

Comments
 (0)