Skip to content

Commit 2f5bbc4

Browse files
authored
Capture custom request/response headers for wsgi and change in passing response_headers in django, pyramid (#925)
1 parent 2ab6641 commit 2f5bbc4

File tree

7 files changed

+281
-4
lines changed

7 files changed

+281
-4
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased](https://github.com/open-telemetry/opentelemetry-python/compare/v1.9.1-0.28b1...HEAD)
99

10+
- `opentelemetry-instrumentation-wsgi` Capture custom request/response headers in span attributes
11+
([#925])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/925)
12+
1013
### Added
1114

1215
- `opentelemetry-instrumentation-dbapi` add experimental sql commenter capability

instrumentation/opentelemetry-instrumentation-django/src/opentelemetry/instrumentation/django/middleware.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ def process_response(self, request, response):
275275
add_response_attributes(
276276
span,
277277
f"{response.status_code} {response.reason_phrase}",
278-
response,
278+
response.items(),
279279
)
280280

281281
propagator = get_global_response_propagator()

instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ def trace_tween(request):
161161
otel_wsgi.add_response_attributes(
162162
span,
163163
response_or_exception.status,
164-
response_or_exception.headers,
164+
response_or_exception.headerlist,
165165
)
166166

167167
propagator = get_global_response_propagator()

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

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,14 @@ def response_hook(span: Span, environ: WSGIEnvironment, status: str, response_he
117117
from opentelemetry.propagators.textmap import Getter
118118
from opentelemetry.semconv.trace import SpanAttributes
119119
from opentelemetry.trace.status import Status, StatusCode
120-
from opentelemetry.util.http import remove_url_credentials
120+
from opentelemetry.util.http import (
121+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
122+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
123+
get_custom_headers,
124+
normalise_request_header_name,
125+
normalise_response_header_name,
126+
remove_url_credentials,
127+
)
121128

122129
_HTTP_VERSION_PREFIX = "HTTP/"
123130
_CARRIER_KEY_PREFIX = "HTTP_"
@@ -208,6 +215,44 @@ def collect_request_attributes(environ):
208215
return result
209216

210217

218+
def add_custom_request_headers(span, environ):
219+
"""Adds custom HTTP request headers into the span which are configured by the user
220+
from the PEP3333-conforming WSGI environ to be used as span creation attributes as described
221+
in the specification https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers"""
222+
attributes = {}
223+
custom_request_headers_name = get_custom_headers(
224+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
225+
)
226+
for header_name in custom_request_headers_name:
227+
wsgi_env_var = header_name.upper().replace("-", "_")
228+
header_values = environ.get(f"HTTP_{wsgi_env_var}")
229+
if header_values:
230+
key = normalise_request_header_name(header_name)
231+
attributes[key] = [header_values]
232+
span.set_attributes(attributes)
233+
234+
235+
def add_custom_response_headers(span, response_headers):
236+
"""Adds custom HTTP response headers into the sapn which are configured by the user from the
237+
PEP3333-conforming WSGI environ as described in the specification
238+
https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers"""
239+
attributes = {}
240+
custom_response_headers_name = get_custom_headers(
241+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
242+
)
243+
response_headers_dict = {}
244+
if response_headers:
245+
for header_name, header_value in response_headers:
246+
response_headers_dict[header_name.lower()] = header_value
247+
248+
for header_name in custom_response_headers_name:
249+
header_values = response_headers_dict.get(header_name.lower())
250+
if header_values:
251+
key = normalise_response_header_name(header_name)
252+
attributes[key] = [header_values]
253+
span.set_attributes(attributes)
254+
255+
211256
def add_response_attributes(
212257
span, start_response_status, response_headers
213258
): # pylint: disable=unused-argument
@@ -268,6 +313,8 @@ def _create_start_response(span, start_response, response_hook):
268313
@functools.wraps(start_response)
269314
def _start_response(status, response_headers, *args, **kwargs):
270315
add_response_attributes(span, status, response_headers)
316+
if span.kind == trace.SpanKind.SERVER:
317+
add_custom_response_headers(span, response_headers)
271318
if response_hook:
272319
response_hook(status, response_headers)
273320
return start_response(status, response_headers, *args, **kwargs)
@@ -289,6 +336,8 @@ def __call__(self, environ, start_response):
289336
context_getter=wsgi_getter,
290337
attributes=collect_request_attributes(environ),
291338
)
339+
if span.kind == trace.SpanKind.SERVER:
340+
add_custom_request_headers(span, environ)
292341

293342
if self.request_hook:
294343
self.request_hook(span, environ)

instrumentation/opentelemetry-instrumentation-wsgi/tests/test_wsgi_middleware.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@
2525
from opentelemetry.test.test_base import TestBase
2626
from opentelemetry.test.wsgitestutil import WsgiTestBase
2727
from opentelemetry.trace import StatusCode
28+
from opentelemetry.util.http import (
29+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
30+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
31+
)
2832

2933

3034
class Response:
@@ -82,6 +86,19 @@ def error_wsgi_unhandled(environ, start_response):
8286
raise ValueError
8387

8488

89+
def wsgi_with_custom_response_headers(environ, start_response):
90+
assert isinstance(environ, dict)
91+
start_response(
92+
"200 OK",
93+
[
94+
("content-type", "text/plain; charset=utf-8"),
95+
("content-length", "100"),
96+
("my-custom-header", "my-custom-value-1,my-custom-header-2"),
97+
],
98+
)
99+
return [b"*"]
100+
101+
85102
class TestWsgiApplication(WsgiTestBase):
86103
def validate_response(
87104
self,
@@ -444,5 +461,119 @@ def test_mark_span_internal_in_presence_of_span_from_other_framework(self):
444461
)
445462

446463

464+
class TestAdditionOfCustomRequestResponseHeaders(WsgiTestBase, TestBase):
465+
def setUp(self):
466+
super().setUp()
467+
tracer_provider, _ = TestBase.create_tracer_provider()
468+
self.tracer = tracer_provider.get_tracer(__name__)
469+
470+
def iterate_response(self, response):
471+
while True:
472+
try:
473+
value = next(response)
474+
self.assertEqual(value, b"*")
475+
except StopIteration:
476+
break
477+
478+
@mock.patch.dict(
479+
"os.environ",
480+
{
481+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3"
482+
},
483+
)
484+
def test_custom_request_headers_added_in_server_span(self):
485+
self.environ.update(
486+
{
487+
"HTTP_CUSTOM_TEST_HEADER_1": "Test Value 1",
488+
"HTTP_CUSTOM_TEST_HEADER_2": "TestValue2,TestValue3",
489+
}
490+
)
491+
app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)
492+
response = app(self.environ, self.start_response)
493+
self.iterate_response(response)
494+
span = self.memory_exporter.get_finished_spans()[0]
495+
expected = {
496+
"http.request.header.custom_test_header_1": ("Test Value 1",),
497+
"http.request.header.custom_test_header_2": (
498+
"TestValue2,TestValue3",
499+
),
500+
}
501+
self.assertSpanHasAttributes(span, expected)
502+
503+
@mock.patch.dict(
504+
"os.environ",
505+
{
506+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1"
507+
},
508+
)
509+
def test_custom_request_headers_not_added_in_internal_span(self):
510+
self.environ.update(
511+
{
512+
"HTTP_CUSTOM_TEST_HEADER_1": "Test Value 1",
513+
}
514+
)
515+
516+
with self.tracer.start_as_current_span(
517+
"test", kind=trace_api.SpanKind.SERVER
518+
):
519+
app = otel_wsgi.OpenTelemetryMiddleware(simple_wsgi)
520+
response = app(self.environ, self.start_response)
521+
self.iterate_response(response)
522+
span = self.memory_exporter.get_finished_spans()[0]
523+
not_expected = {
524+
"http.request.header.custom_test_header_1": ("Test Value 1",),
525+
}
526+
for key, _ in not_expected.items():
527+
self.assertNotIn(key, span.attributes)
528+
529+
@mock.patch.dict(
530+
"os.environ",
531+
{
532+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "content-type,content-length,my-custom-header,invalid-header"
533+
},
534+
)
535+
def test_custom_response_headers_added_in_server_span(self):
536+
app = otel_wsgi.OpenTelemetryMiddleware(
537+
wsgi_with_custom_response_headers
538+
)
539+
response = app(self.environ, self.start_response)
540+
self.iterate_response(response)
541+
span = self.memory_exporter.get_finished_spans()[0]
542+
expected = {
543+
"http.response.header.content_type": (
544+
"text/plain; charset=utf-8",
545+
),
546+
"http.response.header.content_length": ("100",),
547+
"http.response.header.my_custom_header": (
548+
"my-custom-value-1,my-custom-header-2",
549+
),
550+
}
551+
self.assertSpanHasAttributes(span, expected)
552+
553+
@mock.patch.dict(
554+
"os.environ",
555+
{
556+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "my-custom-header"
557+
},
558+
)
559+
def test_custom_response_headers_not_added_in_internal_span(self):
560+
with self.tracer.start_as_current_span(
561+
"test", kind=trace_api.SpanKind.INTERNAL
562+
):
563+
app = otel_wsgi.OpenTelemetryMiddleware(
564+
wsgi_with_custom_response_headers
565+
)
566+
response = app(self.environ, self.start_response)
567+
self.iterate_response(response)
568+
span = self.memory_exporter.get_finished_spans()[0]
569+
not_expected = {
570+
"http.response.header.my_custom_header": (
571+
"my-custom-value-1,my-custom-header-2",
572+
),
573+
}
574+
for key, _ in not_expected.items():
575+
self.assertNotIn(key, span.attributes)
576+
577+
447578
if __name__ == "__main__":
448579
unittest.main()

util/opentelemetry-util-http/src/opentelemetry/util/http/__init__.py

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,16 @@
1515
from os import environ
1616
from re import compile as re_compile
1717
from re import search
18-
from typing import Iterable
18+
from typing import Iterable, List
1919
from urllib.parse import urlparse, urlunparse
2020

21+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST = (
22+
"OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST"
23+
)
24+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE = (
25+
"OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE"
26+
)
27+
2128

2229
class ExcludeList:
2330
"""Class to exclude certain paths (given as a list of regexes) from tracing requests"""
@@ -98,3 +105,23 @@ def remove_url_credentials(url: str) -> str:
98105
except ValueError: # an unparseable url was passed
99106
pass
100107
return url
108+
109+
110+
def normalise_request_header_name(header: str) -> str:
111+
key = header.lower().replace("-", "_")
112+
return f"http.request.header.{key}"
113+
114+
115+
def normalise_response_header_name(header: str) -> str:
116+
key = header.lower().replace("-", "_")
117+
return f"http.response.header.{key}"
118+
119+
120+
def get_custom_headers(env_var: str) -> List[str]:
121+
custom_headers = environ.get(env_var, [])
122+
if custom_headers:
123+
custom_headers = [
124+
custom_headers.strip()
125+
for custom_headers in custom_headers.split(",")
126+
]
127+
return custom_headers
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
# Copyright The OpenTelemetry Authors
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from unittest.mock import patch
16+
17+
from opentelemetry.test.test_base import TestBase
18+
from opentelemetry.util.http import (
19+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
20+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
21+
get_custom_headers,
22+
normalise_request_header_name,
23+
normalise_response_header_name,
24+
)
25+
26+
27+
class TestCaptureCustomHeaders(TestBase):
28+
@patch.dict(
29+
"os.environ",
30+
{
31+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "User-Agent,Test-Header"
32+
},
33+
)
34+
def test_get_custom_request_header(self):
35+
custom_headers_to_capture = get_custom_headers(
36+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
37+
)
38+
self.assertEqual(
39+
custom_headers_to_capture, ["User-Agent", "Test-Header"]
40+
)
41+
42+
@patch.dict(
43+
"os.environ",
44+
{
45+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "content-type,content-length,test-header"
46+
},
47+
)
48+
def test_get_custom_response_header(self):
49+
custom_headers_to_capture = get_custom_headers(
50+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
51+
)
52+
self.assertEqual(
53+
custom_headers_to_capture,
54+
[
55+
"content-type",
56+
"content-length",
57+
"test-header",
58+
],
59+
)
60+
61+
def test_normalise_request_header_name(self):
62+
key = normalise_request_header_name("Test-Header")
63+
self.assertEqual(key, "http.request.header.test_header")
64+
65+
def test_normalise_response_header_name(self):
66+
key = normalise_response_header_name("Test-Header")
67+
self.assertEqual(key, "http.response.header.test_header")

0 commit comments

Comments
 (0)