Skip to content

Commit e20cea6

Browse files
committed
feat(redis): add unit tests for semconv opt-in stable Redis attributes (#3826)
1 parent 2b2670d commit e20cea6

File tree

2 files changed

+175
-0
lines changed

2 files changed

+175
-0
lines changed
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import os
2+
from unittest.mock import patch
3+
4+
import fakeredis
5+
from redis.exceptions import ResponseError, WatchError
6+
7+
from opentelemetry import trace
8+
from opentelemetry.instrumentation.redis import RedisInstrumentor
9+
from opentelemetry.semconv._incubating.attributes.db_attributes import (
10+
DB_STATEMENT,
11+
DB_SYSTEM,
12+
)
13+
from opentelemetry.test.test_base import TestBase
14+
15+
16+
class TestRedisSemConv(TestBase):
17+
def setUp(self):
18+
super().setUp()
19+
self.conn_patcher = patch(
20+
"opentelemetry.instrumentation.redis._get_redis_conn_info",
21+
return_value=("localhost", 6379, 0, None),
22+
)
23+
self.conn_patcher.start()
24+
RedisInstrumentor().instrument(tracer_provider=self.tracer_provider)
25+
self.redis_client = fakeredis.FakeStrictRedis()
26+
self.env_mode = os.getenv("OTEL_SEMCONV_STABILITY_OPT_IN")
27+
28+
def tearDown(self):
29+
super().tearDown()
30+
self.conn_patcher.stop()
31+
RedisInstrumentor().uninstrument()
32+
33+
def test_single_command(self):
34+
"""Tests attributes for a regular single command."""
35+
self.redis_client.set("key", "value")
36+
spans = self.memory_exporter.get_finished_spans()
37+
self.assertEqual(len(spans), 1)
38+
span = spans[0]
39+
40+
if self.env_mode == "database":
41+
self.assertEqual(span.attributes.get("db.system.name"), "redis")
42+
self.assertEqual(span.attributes.get("db.operation.name"), "SET")
43+
self.assertEqual(span.attributes.get("db.namespace"), "0")
44+
self.assertEqual(span.attributes.get("db.query.text"), "SET ? ?")
45+
self.assertNotIn(DB_SYSTEM, span.attributes)
46+
self.assertNotIn(DB_STATEMENT, span.attributes)
47+
elif self.env_mode == "database/dup":
48+
self.assertEqual(span.attributes.get("db.system.name"), "redis")
49+
self.assertEqual(span.attributes.get(DB_SYSTEM), "redis")
50+
self.assertEqual(span.attributes.get(DB_STATEMENT), "SET ? ?")
51+
else: # Default (old) behavior
52+
self.assertEqual(span.attributes.get(DB_SYSTEM), "redis")
53+
self.assertEqual(span.attributes.get(DB_STATEMENT), "SET ? ?")
54+
self.assertNotIn("db.system.name", span.attributes)
55+
56+
def test_pipeline_command(self):
57+
"""Tests attributes for a pipeline command."""
58+
with self.redis_client.pipeline(transaction=False) as pipe:
59+
pipe.set("a", 1)
60+
pipe.get("a")
61+
pipe.execute()
62+
63+
spans = self.memory_exporter.get_finished_spans()
64+
self.assertEqual(len(spans), 1)
65+
span = spans[0]
66+
67+
if self.env_mode in ("database", "database/dup"):
68+
self.assertEqual(
69+
span.attributes.get("db.operation.name"), "PIPELINE"
70+
)
71+
self.assertEqual(span.attributes.get("db.operation.batch.size"), 2)
72+
73+
def test_stored_procedure_command(self):
74+
"""Tests attributes for a stored procedure command."""
75+
with patch.object(
76+
self.redis_client, "parse_response", return_value=b"ok"
77+
):
78+
self.redis_client.evalsha("some-sha", 0, "key", "value")
79+
80+
spans = self.memory_exporter.get_finished_spans()
81+
self.assertEqual(len(spans), 1)
82+
span = spans[0]
83+
84+
if self.env_mode in ("database", "database/dup"):
85+
self.assertEqual(
86+
span.attributes.get("db.stored_procedure.name"), "some-sha"
87+
)
88+
else:
89+
self.assertNotIn("db.stored_procedure.name", span.attributes)
90+
91+
def test_generic_error(self):
92+
"""Tests attributes for a generic error."""
93+
with patch.object(
94+
self.redis_client,
95+
"parse_response",
96+
side_effect=ResponseError("ERR unknown command"),
97+
):
98+
with self.assertRaises(ResponseError):
99+
self.redis_client.execute_command("INVALID_COMMAND")
100+
101+
spans = self.memory_exporter.get_finished_spans()
102+
self.assertEqual(len(spans), 1)
103+
span = spans[0]
104+
self.assertEqual(span.status.status_code, trace.StatusCode.ERROR)
105+
106+
if self.env_mode in ("database", "database/dup"):
107+
self.assertEqual("ERR", span.attributes.get("error.type"))
108+
self.assertEqual(
109+
"ERR", span.attributes.get("db.response.status_code")
110+
)
111+
else:
112+
self.assertNotIn("error.type", span.attributes)
113+
self.assertNotIn("db.response.status_code", span.attributes)
114+
115+
def test_watch_error(self):
116+
"""Tests attributes for a WatchError."""
117+
with self.assertRaises(WatchError):
118+
with self.redis_client.pipeline(transaction=True) as pipe:
119+
pipe.watch("watched_key")
120+
self.redis_client.set("watched_key", "modified-externally")
121+
pipe.multi()
122+
pipe.set("watched_key", "new-value")
123+
pipe.get("watched_key")
124+
with patch.object(pipe, "execute", side_effect=WatchError):
125+
pipe.execute()
126+
127+
spans = self.memory_exporter.get_finished_spans()
128+
self.assertEqual(len(spans), 2)
129+
130+
failed_pipeline_span = spans[-1]
131+
self.assertEqual(
132+
failed_pipeline_span.status.status_code, trace.StatusCode.UNSET
133+
)
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import os
2+
import subprocess
3+
import sys
4+
5+
import pytest
6+
7+
# The full relative path from the project root
8+
HELPER_PATH = "instrumentation/opentelemetry-instrumentation-redis/tests/_test_semconv_helper.py"
9+
10+
11+
@pytest.mark.parametrize(
12+
"semconv_opt_in, test_name",
13+
[
14+
(None, "default"),
15+
("database", "new"),
16+
("database/dup", "dup"),
17+
],
18+
)
19+
def test_run_in_subprocess(semconv_opt_in, test_name):
20+
"""
21+
Runs the semantic convention test in a clean subprocess.
22+
The `semconv_opt_in` value is used to set the env var.
23+
"""
24+
env = os.environ.copy()
25+
26+
if semconv_opt_in is None:
27+
if "OTEL_SEMCONV_STABILITY_OPT_IN" in env:
28+
del env["OTEL_SEMCONV_STABILITY_OPT_IN"]
29+
else:
30+
env["OTEL_SEMCONV_STABILITY_OPT_IN"] = semconv_opt_in
31+
32+
result = subprocess.run(
33+
[sys.executable, "-m", "pytest", HELPER_PATH],
34+
capture_output=True,
35+
text=True,
36+
env=env,
37+
check=False, # Explicitly set check=False to satisfy pylint
38+
)
39+
# Use a standard assert
40+
assert (
41+
result.returncode == 0
42+
), f"Subprocess for '{test_name}' mode failed with stdout:\n{result.stdout}\nstderr:\n{result.stderr}"

0 commit comments

Comments
 (0)