Skip to content

Commit 229dc45

Browse files
authored
Pyramid: Capture custom request/response headers (#1022)
1 parent d760668 commit 229dc45

File tree

5 files changed

+231
-1
lines changed

5 files changed

+231
-1
lines changed

Diff for: CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
3030
([#1001](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1001))
3131
- `opentelemetry-instrumentation-system-metrics` restore `SystemMetrics` instrumentation as `SystemMetricsInstrumentor`
3232
([#1012](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1012))
33+
- `opentelemetry-instrumentation-pyramid` Pyramid: Capture custom request/response headers in span attributes
34+
([#1022])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1022)
35+
3336

3437
## [1.10.0-0.29b0](https://github.com/open-telemetry/opentelemetry-python/releases/tag/v1.10.0-0.29b0) - 2022-03-10
3538

Diff for: instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/__init__.py

+51
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,57 @@
9090
9191
will exclude requests such as ``https://site/client/123/info`` and ``https://site/xyz/healthcheck``.
9292
93+
Capture HTTP request and response headers
94+
*****************************************
95+
You can configure the agent to capture predefined HTTP headers as span attributes, according to the `semantic convention <https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/semantic_conventions/http.md#http-request-and-response-headers>`_.
96+
97+
Request headers
98+
***************
99+
To capture predefined HTTP request headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST``
100+
to a comma-separated list of HTTP header names.
101+
102+
For example,
103+
104+
::
105+
106+
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="content-type,custom_request_header"
107+
108+
will extract ``content-type`` and ``custom_request_header`` from request headers and add them as span attributes.
109+
110+
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
111+
Request header names in pyramid are case insensitive and - characters are replaced by _. So, giving header name as ``CUStom_Header`` in environment variable will be able capture header with name ``custom-header``.
112+
113+
The name of the added span attribute will follow the format ``http.request.header.<header_name>`` where ``<header_name>`` being the normalized HTTP header name (lowercase, with - characters replaced by _ ).
114+
The value of the attribute will be single item list containing all the header values.
115+
116+
Example of the added span attribute,
117+
``http.request.header.custom_request_header = ["<value1>,<value2>"]``
118+
119+
Response headers
120+
****************
121+
To capture predefined HTTP response headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE``
122+
to a comma-separated list of HTTP header names.
123+
124+
For example,
125+
126+
::
127+
128+
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE="content-type,custom_response_header"
129+
130+
will extract ``content-type`` and ``custom_response_header`` from response headers and add them as span attributes.
131+
132+
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
133+
Response header names captured in pyramid are case insensitive. So, giving header name as ``CUStomHeader`` in environment variable will be able capture header with name ``customheader``.
134+
135+
The name of the added span attribute will follow the format ``http.response.header.<header_name>`` where ``<header_name>`` being the normalized HTTP header name (lowercase, with - characters replaced by _ ).
136+
The value of the attribute will be single item list containing all the header values.
137+
138+
Example of the added span attribute,
139+
``http.response.header.custom_response_header = ["<value1>,<value2>"]``
140+
141+
Note:
142+
Environment variable names to caputre http headers are still experimental, and thus are subject to change.
143+
93144
API
94145
---
95146
"""

Diff for: instrumentation/opentelemetry-instrumentation-pyramid/src/opentelemetry/instrumentation/pyramid/callbacks.py

+9-1
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,8 @@ def _before_traversal(event):
104104
] = request.matched_route.pattern
105105
for key, value in attributes.items():
106106
span.set_attribute(key, value)
107+
if span.kind == trace.SpanKind.SERVER:
108+
otel_wsgi.add_custom_request_headers(span, request_environ)
107109

108110
activation = trace.use_span(span, end_on_exit=True)
109111
activation.__enter__() # pylint: disable=E1101
@@ -127,6 +129,7 @@ def disabled_tween(request):
127129
return disabled_tween
128130

129131
# make a request tracing function
132+
# pylint: disable=too-many-branches
130133
def trace_tween(request):
131134
# pylint: disable=E1101
132135
if _excluded_urls.url_disabled(request.url):
@@ -171,7 +174,12 @@ def trace_tween(request):
171174
otel_wsgi.add_response_attributes(
172175
span,
173176
status,
174-
getattr(response, "headerList", None),
177+
getattr(response, "headerlist", None),
178+
)
179+
180+
if span.is_recording() and span.kind == trace.SpanKind.SERVER:
181+
otel_wsgi.add_custom_response_headers(
182+
span, getattr(response, "headerlist", None)
175183
)
176184

177185
propagator = get_global_response_propagator()

Diff for: instrumentation/opentelemetry-instrumentation-pyramid/tests/pyramid_base_test.py

+17
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ def _hello_endpoint(request):
2828
raise NotImplementedError()
2929
return Response("Hello: " + str(helloid))
3030

31+
@staticmethod
32+
def _custom_response_header_endpoint(request):
33+
headers = {
34+
"content-type": "text/plain; charset=utf-8",
35+
"content-length": "7",
36+
"my-custom-header": "my-custom-value-1,my-custom-header-2",
37+
"dont-capture-me": "test-value",
38+
}
39+
return Response("Testing", headers=headers)
40+
3141
def _common_initialization(self, config):
3242
# pylint: disable=unused-argument
3343
def excluded_endpoint(request):
@@ -45,6 +55,13 @@ def excluded2_endpoint(request):
4555
config.add_view(excluded_endpoint, route_name="excluded")
4656
config.add_route("excluded2", "/excluded_noarg2")
4757
config.add_view(excluded2_endpoint, route_name="excluded2")
58+
config.add_route(
59+
"custom_response_headers", "/test_custom_response_headers"
60+
)
61+
config.add_view(
62+
self._custom_response_header_endpoint,
63+
route_name="custom_response_headers",
64+
)
4865

4966
# pylint: disable=attribute-defined-outside-init
5067
self.client = Client(config.make_wsgi_app(), BaseResponse)

Diff for: instrumentation/opentelemetry-instrumentation-pyramid/tests/test_automatic.py

+151
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,20 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15+
from unittest.mock import patch
16+
1517
from pyramid.config import Configurator
1618

19+
from opentelemetry import trace
1720
from opentelemetry.instrumentation.pyramid import PyramidInstrumentor
21+
from opentelemetry.test.globals_test import reset_trace_globals
1822
from opentelemetry.test.test_base import TestBase
1923
from opentelemetry.test.wsgitestutil import WsgiTestBase
2024
from opentelemetry.trace import SpanKind
25+
from opentelemetry.util.http import (
26+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
27+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
28+
)
2129

2230
# pylint: disable=import-error
2331
from .pyramid_base_test import InstrumentationTest
@@ -109,3 +117,146 @@ def test_with_existing_span(self):
109117
parent_span.get_span_context().span_id,
110118
span_list[0].parent.span_id,
111119
)
120+
121+
122+
class TestCustomRequestResponseHeaders(
123+
InstrumentationTest, TestBase, WsgiTestBase
124+
):
125+
def setUp(self):
126+
super().setUp()
127+
PyramidInstrumentor().instrument()
128+
self.config = Configurator()
129+
self._common_initialization(self.config)
130+
self.env_patch = patch.dict(
131+
"os.environ",
132+
{
133+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,invalid-header",
134+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "content-type,content-length,my-custom-header,invalid-header",
135+
},
136+
)
137+
self.env_patch.start()
138+
139+
def tearDown(self) -> None:
140+
super().tearDown()
141+
self.env_patch.stop()
142+
with self.disable_logging():
143+
PyramidInstrumentor().uninstrument()
144+
145+
def test_custom_request_header_added_in_server_span(self):
146+
headers = {
147+
"Custom-Test-Header-1": "Test Value 1",
148+
"Custom-Test-Header-2": "TestValue2,TestValue3",
149+
"Custom-Test-Header-3": "TestValue4",
150+
}
151+
resp = self.client.get("/hello/123", headers=headers)
152+
self.assertEqual(200, resp.status_code)
153+
span = self.memory_exporter.get_finished_spans()[0]
154+
expected = {
155+
"http.request.header.custom_test_header_1": ("Test Value 1",),
156+
"http.request.header.custom_test_header_2": (
157+
"TestValue2,TestValue3",
158+
),
159+
}
160+
not_expected = {
161+
"http.request.header.custom_test_header_3": ("TestValue4",),
162+
}
163+
self.assertEqual(span.kind, SpanKind.SERVER)
164+
self.assertSpanHasAttributes(span, expected)
165+
for key, _ in not_expected.items():
166+
self.assertNotIn(key, span.attributes)
167+
168+
def test_custom_request_header_not_added_in_internal_span(self):
169+
tracer = trace.get_tracer(__name__)
170+
with tracer.start_as_current_span("test", kind=SpanKind.SERVER):
171+
headers = {
172+
"Custom-Test-Header-1": "Test Value 1",
173+
"Custom-Test-Header-2": "TestValue2,TestValue3",
174+
}
175+
resp = self.client.get("/hello/123", headers=headers)
176+
self.assertEqual(200, resp.status_code)
177+
span = self.memory_exporter.get_finished_spans()[0]
178+
not_expected = {
179+
"http.request.header.custom_test_header_1": ("Test Value 1",),
180+
"http.request.header.custom_test_header_2": (
181+
"TestValue2,TestValue3",
182+
),
183+
}
184+
self.assertEqual(span.kind, SpanKind.INTERNAL)
185+
for key, _ in not_expected.items():
186+
self.assertNotIn(key, span.attributes)
187+
188+
def test_custom_response_header_added_in_server_span(self):
189+
resp = self.client.get("/test_custom_response_headers")
190+
self.assertEqual(200, resp.status_code)
191+
span = self.memory_exporter.get_finished_spans()[0]
192+
expected = {
193+
"http.response.header.content_type": (
194+
"text/plain; charset=utf-8",
195+
),
196+
"http.response.header.content_length": ("7",),
197+
"http.response.header.my_custom_header": (
198+
"my-custom-value-1,my-custom-header-2",
199+
),
200+
}
201+
not_expected = {
202+
"http.response.header.dont_capture_me": ("test-value",)
203+
}
204+
self.assertEqual(span.kind, SpanKind.SERVER)
205+
self.assertSpanHasAttributes(span, expected)
206+
for key, _ in not_expected.items():
207+
self.assertNotIn(key, span.attributes)
208+
209+
def test_custom_response_header_not_added_in_internal_span(self):
210+
tracer = trace.get_tracer(__name__)
211+
with tracer.start_as_current_span("test", kind=SpanKind.SERVER):
212+
resp = self.client.get("/test_custom_response_headers")
213+
self.assertEqual(200, resp.status_code)
214+
span = self.memory_exporter.get_finished_spans()[0]
215+
not_expected = {
216+
"http.response.header.content_type": (
217+
"text/plain; charset=utf-8",
218+
),
219+
"http.response.header.content_length": ("7",),
220+
"http.response.header.my_custom_header": (
221+
"my-custom-value-1,my-custom-header-2",
222+
),
223+
}
224+
self.assertEqual(span.kind, SpanKind.INTERNAL)
225+
for key, _ in not_expected.items():
226+
self.assertNotIn(key, span.attributes)
227+
228+
229+
class TestCustomHeadersNonRecordingSpan(
230+
InstrumentationTest, TestBase, WsgiTestBase
231+
):
232+
def setUp(self):
233+
super().setUp()
234+
# This is done because set_tracer_provider cannot override the
235+
# current tracer provider.
236+
reset_trace_globals()
237+
tracer_provider = trace.NoOpTracerProvider()
238+
trace.set_tracer_provider(tracer_provider)
239+
PyramidInstrumentor().instrument()
240+
self.config = Configurator()
241+
self._common_initialization(self.config)
242+
self.env_patch = patch.dict(
243+
"os.environ",
244+
{
245+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,invalid-header",
246+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "content-type,content-length,my-custom-header,invalid-header",
247+
},
248+
)
249+
self.env_patch.start()
250+
251+
def tearDown(self) -> None:
252+
super().tearDown()
253+
self.env_patch.stop()
254+
with self.disable_logging():
255+
PyramidInstrumentor().uninstrument()
256+
257+
def test_custom_header_non_recording_span(self):
258+
try:
259+
resp = self.client.get("/hello/123")
260+
self.assertEqual(200, resp.status_code)
261+
except Exception as exc: # pylint: disable=W0703
262+
self.fail(f"Exception raised with NonRecordingSpan {exc}")

0 commit comments

Comments
 (0)