Skip to content

Commit 2b6f913

Browse files
Add Redis instrumentation query sanitization (#1572)
* Add Redis instrumentation query sanitization Add a query sanitizer to the Redis instrumentation. This can be disabled with the `sanitize_query = False` config option. Given the query `SET key value`, the sanitized query becomes `SET ? ?`. Both the keys and values are sanitized, as both can contain PII data. The Redis queries are sanitized by default. This changes the default behavior of this instrumentation. Previously it reported unsanitized Redis queries. This was previously discussed in the previous implementation of this PR in PR #1571 Closes #1548 * Update Redis sanitize_query option documentation Changes suggested in #1572 (comment) * Remove uninstrument & instrument from test setup The Redis test that performs the tests with the default options, doesn't need to uninstrument and then instrument the instrumentor. This commit removes the unnecessary setup code. The setup code is already present at the top of the file. * Fix code style formatting * Update Redis functional tests - Update the sanitizer to also account for a max `db.statement` attribute value length. No longer than 1000 characters. - Update the functional tests to assume the queries are sanitized by default. - Add new tests that test the behavior with sanitization turned off. Only for the tests in the first test class. I don't think it's needed to duplicate this test for the clustered and async setup combinations. * Test Redis unsanitized queries by default Change the Redis functional tests so that they test the unsanitized query by default, and test the sanitized query results in the separate test functions. This is a partial revert of the previous commit 8d56c2f * Fix formatting issue in Redis utils * Disable Redis query sanitization by default Update the Redis instrumentation library to not change the default behavior for the Redis instrumentation. This can be enabled at a later time when the spec discussion about this topic has concluded. open-telemetry/opentelemetry-specification#3104 * Fix pylint issue Remove else statement. * Update changelog about Redis query sanitization default [ci skip] Co-authored-by: Srikanth Chekuri <[email protected]> * Fix potential error on Redis args being 0 Check the length of the args array and return an empty string if there are no args. That way it won't cause an IndexError if the args array is empty and it tries to fetch the first element, which should be the Redis command. --------- Co-authored-by: Srikanth Chekuri <[email protected]>
1 parent 3770e57 commit 2b6f913

File tree

5 files changed

+144
-6
lines changed

5 files changed

+144
-6
lines changed

CHANGELOG.md

+1
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- `opentelemetry-instrumentation-redis` Add `sanitize_query` config option to allow query sanitization. ([#1572](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1572))
1213
- `opentelemetry-instrumentation-celery` Record exceptions as events on the span.
1314
([#1573](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1573))
1415
- Add metric instrumentation for urllib

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

+9-2
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ async def redis_get():
6464
response_hook (Callable) - a function with extra user-defined logic to be performed after performing the request
6565
this function signature is: def response_hook(span: Span, instance: redis.connection.Connection, response) -> None
6666
67+
sanitize_query (Boolean) - default False, enable the Redis query sanitization
68+
6769
for example:
6870
6971
.. code: python
@@ -139,9 +141,11 @@ def _instrument(
139141
tracer,
140142
request_hook: _RequestHookT = None,
141143
response_hook: _ResponseHookT = None,
144+
sanitize_query: bool = False,
142145
):
143146
def _traced_execute_command(func, instance, args, kwargs):
144-
query = _format_command_args(args)
147+
query = _format_command_args(args, sanitize_query)
148+
145149
if len(args) > 0 and args[0]:
146150
name = args[0]
147151
else:
@@ -169,7 +173,9 @@ def _traced_execute_pipeline(func, instance, args, kwargs):
169173
)
170174

171175
cmds = [
172-
_format_command_args(c.args if hasattr(c, "args") else c[0])
176+
_format_command_args(
177+
c.args if hasattr(c, "args") else c[0], sanitize_query
178+
)
173179
for c in command_stack
174180
]
175181
resource = "\n".join(cmds)
@@ -281,6 +287,7 @@ def _instrument(self, **kwargs):
281287
tracer,
282288
request_hook=kwargs.get("request_hook"),
283289
response_hook=kwargs.get("response_hook"),
290+
sanitize_query=kwargs.get("sanitize_query", False),
284291
)
285292

286293
def _uninstrument(self, **kwargs):

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

+20-4
Original file line numberDiff line numberDiff line change
@@ -48,11 +48,27 @@ def _extract_conn_attributes(conn_kwargs):
4848
return attributes
4949

5050

51-
def _format_command_args(args):
52-
"""Format command arguments and trim them as needed"""
53-
value_max_len = 100
54-
value_too_long_mark = "..."
51+
def _format_command_args(args, sanitize_query):
52+
"""Format and sanitize command arguments, and trim them as needed"""
5553
cmd_max_len = 1000
54+
value_too_long_mark = "..."
55+
if sanitize_query:
56+
# Sanitized query format: "COMMAND ? ?"
57+
args_length = len(args)
58+
if args_length > 0:
59+
out = [str(args[0])] + ["?"] * (args_length - 1)
60+
out_str = " ".join(out)
61+
62+
if len(out_str) > cmd_max_len:
63+
out_str = (
64+
out_str[: cmd_max_len - len(value_too_long_mark)]
65+
+ value_too_long_mark
66+
)
67+
else:
68+
out_str = ""
69+
return out_str
70+
71+
value_max_len = 100
5672
length = 0
5773
out = []
5874
for arg in args:

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

+34
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,40 @@ def request_hook(span, conn, args, kwargs):
148148
span = spans[0]
149149
self.assertEqual(span.attributes.get(custom_attribute_name), "GET")
150150

151+
def test_query_sanitizer_enabled(self):
152+
redis_client = redis.Redis()
153+
connection = redis.connection.Connection()
154+
redis_client.connection = connection
155+
156+
RedisInstrumentor().uninstrument()
157+
RedisInstrumentor().instrument(
158+
tracer_provider=self.tracer_provider,
159+
sanitize_query=True,
160+
)
161+
162+
with mock.patch.object(redis_client, "connection"):
163+
redis_client.set("key", "value")
164+
165+
spans = self.memory_exporter.get_finished_spans()
166+
self.assertEqual(len(spans), 1)
167+
168+
span = spans[0]
169+
self.assertEqual(span.attributes.get("db.statement"), "SET ? ?")
170+
171+
def test_query_sanitizer_disabled(self):
172+
redis_client = redis.Redis()
173+
connection = redis.connection.Connection()
174+
redis_client.connection = connection
175+
176+
with mock.patch.object(redis_client, "connection"):
177+
redis_client.set("key", "value")
178+
179+
spans = self.memory_exporter.get_finished_spans()
180+
self.assertEqual(len(spans), 1)
181+
182+
span = spans[0]
183+
self.assertEqual(span.attributes.get("db.statement"), "SET key value")
184+
151185
def test_no_op_tracer_provider(self):
152186
RedisInstrumentor().uninstrument()
153187
tracer_provider = trace.NoOpTracerProvider()

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

+80
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,27 @@ def _check_span(self, span, name):
4545
)
4646
self.assertEqual(span.attributes[SpanAttributes.NET_PEER_PORT], 6379)
4747

48+
def test_long_command_sanitized(self):
49+
RedisInstrumentor().uninstrument()
50+
RedisInstrumentor().instrument(
51+
tracer_provider=self.tracer_provider, sanitize_query=True
52+
)
53+
54+
self.redis_client.mget(*range(2000))
55+
56+
spans = self.memory_exporter.get_finished_spans()
57+
self.assertEqual(len(spans), 1)
58+
span = spans[0]
59+
self._check_span(span, "MGET")
60+
self.assertTrue(
61+
span.attributes.get(SpanAttributes.DB_STATEMENT).startswith(
62+
"MGET ? ? ? ?"
63+
)
64+
)
65+
self.assertTrue(
66+
span.attributes.get(SpanAttributes.DB_STATEMENT).endswith("...")
67+
)
68+
4869
def test_long_command(self):
4970
self.redis_client.mget(*range(1000))
5071

@@ -61,6 +82,22 @@ def test_long_command(self):
6182
span.attributes.get(SpanAttributes.DB_STATEMENT).endswith("...")
6283
)
6384

85+
def test_basics_sanitized(self):
86+
RedisInstrumentor().uninstrument()
87+
RedisInstrumentor().instrument(
88+
tracer_provider=self.tracer_provider, sanitize_query=True
89+
)
90+
91+
self.assertIsNone(self.redis_client.get("cheese"))
92+
spans = self.memory_exporter.get_finished_spans()
93+
self.assertEqual(len(spans), 1)
94+
span = spans[0]
95+
self._check_span(span, "GET")
96+
self.assertEqual(
97+
span.attributes.get(SpanAttributes.DB_STATEMENT), "GET ?"
98+
)
99+
self.assertEqual(span.attributes.get("db.redis.args_length"), 2)
100+
64101
def test_basics(self):
65102
self.assertIsNone(self.redis_client.get("cheese"))
66103
spans = self.memory_exporter.get_finished_spans()
@@ -72,6 +109,28 @@ def test_basics(self):
72109
)
73110
self.assertEqual(span.attributes.get("db.redis.args_length"), 2)
74111

112+
def test_pipeline_traced_sanitized(self):
113+
RedisInstrumentor().uninstrument()
114+
RedisInstrumentor().instrument(
115+
tracer_provider=self.tracer_provider, sanitize_query=True
116+
)
117+
118+
with self.redis_client.pipeline(transaction=False) as pipeline:
119+
pipeline.set("blah", 32)
120+
pipeline.rpush("foo", "éé")
121+
pipeline.hgetall("xxx")
122+
pipeline.execute()
123+
124+
spans = self.memory_exporter.get_finished_spans()
125+
self.assertEqual(len(spans), 1)
126+
span = spans[0]
127+
self._check_span(span, "SET RPUSH HGETALL")
128+
self.assertEqual(
129+
span.attributes.get(SpanAttributes.DB_STATEMENT),
130+
"SET ? ?\nRPUSH ? ?\nHGETALL ?",
131+
)
132+
self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3)
133+
75134
def test_pipeline_traced(self):
76135
with self.redis_client.pipeline(transaction=False) as pipeline:
77136
pipeline.set("blah", 32)
@@ -89,6 +148,27 @@ def test_pipeline_traced(self):
89148
)
90149
self.assertEqual(span.attributes.get("db.redis.pipeline_length"), 3)
91150

151+
def test_pipeline_immediate_sanitized(self):
152+
RedisInstrumentor().uninstrument()
153+
RedisInstrumentor().instrument(
154+
tracer_provider=self.tracer_provider, sanitize_query=True
155+
)
156+
157+
with self.redis_client.pipeline() as pipeline:
158+
pipeline.set("a", 1)
159+
pipeline.immediate_execute_command("SET", "b", 2)
160+
pipeline.execute()
161+
162+
spans = self.memory_exporter.get_finished_spans()
163+
# expecting two separate spans here, rather than a
164+
# single span for the whole pipeline
165+
self.assertEqual(len(spans), 2)
166+
span = spans[0]
167+
self._check_span(span, "SET")
168+
self.assertEqual(
169+
span.attributes.get(SpanAttributes.DB_STATEMENT), "SET ? ?"
170+
)
171+
92172
def test_pipeline_immediate(self):
93173
with self.redis_client.pipeline() as pipeline:
94174
pipeline.set("a", 1)

0 commit comments

Comments
 (0)