Skip to content

Commit 26a8aa1

Browse files
Starlette: capture custom request response headers in span attributes (#1046)
1 parent 3ca7e7a commit 26a8aa1

File tree

4 files changed

+338
-5
lines changed

4 files changed

+338
-5
lines changed

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2222
([#999])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/999)
2323

2424
### Added
25+
26+
- `opentelemetry-instrumentation-starlette` Capture custom request/response headers in span attributes
27+
([#1046])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1046)
2528
- `opentelemetry-instrumentation-fastapi` Capture custom request/response headers in span attributes
2629
([#1032])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/1032)
2730
- `opentelemetry-instrumentation-django` Capture custom request/response headers in span attributes

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,6 @@ def client_response_hook(span: Span, message: dict):
7474
7575
FastAPIInstrumentor().instrument(server_request_hook=server_request_hook, client_request_hook=client_request_hook, client_response_hook=client_response_hook)
7676
77-
7877
Capture HTTP request and response headers
7978
*****************************************
8079
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>`_.
@@ -93,7 +92,7 @@ def client_response_hook(span: Span, message: dict):
9392
will extract ``content-type`` and ``custom_request_header`` from request headers and add them as span attributes.
9493
9594
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
96-
Request header names in FastAPI are case insensitive. So, giving header name as ``CUStom-Header`` in environment variable will be able capture header with name ``custom-header``.
95+
Request header names in fastapi are case insensitive. So, giving header name as ``CUStom-Header`` in environment variable will be able capture header with name ``custom-header``.
9796
9897
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 _ ).
9998
The value of the attribute will be single item list containing all the header values.
@@ -115,7 +114,7 @@ def client_response_hook(span: Span, message: dict):
115114
will extract ``content-type`` and ``custom_response_header`` from response headers and add them as span attributes.
116115
117116
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
118-
Response header names captured in FastAPI are case insensitive. So, giving header name as ``CUStomHeader`` in environment variable will be able capture header with name ``customheader``.
117+
Response header names captured in fastapi are case insensitive. So, giving header name as ``CUStomHeader`` in environment variable will be able capture header with name ``customheader``.
119118
120119
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 _ ).
121120
The value of the attribute will be single item list containing all the header values.

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

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,57 @@ def client_response_hook(span: Span, message: dict):
6868
6969
StarletteInstrumentor().instrument(server_request_hook=server_request_hook, client_request_hook=client_request_hook, client_response_hook=client_response_hook)
7070
71+
Capture HTTP request and response headers
72+
*****************************************
73+
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>`_.
74+
75+
Request headers
76+
***************
77+
To capture predefined HTTP request headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST``
78+
to a comma-separated list of HTTP header names.
79+
80+
For example,
81+
82+
::
83+
84+
export OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST="content-type,custom_request_header"
85+
86+
will extract ``content-type`` and ``custom_request_header`` from request headers and add them as span attributes.
87+
88+
It is recommended that you should give the correct names of the headers to be captured in the environment variable.
89+
Request header names in starlette are case insensitive. So, giving header name as ``CUStom-Header`` in environment variable will be able capture header with name ``custom-header``.
90+
91+
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 _ ).
92+
The value of the attribute will be single item list containing all the header values.
93+
94+
Example of the added span attribute,
95+
``http.request.header.custom_request_header = ["<value1>,<value2>"]``
96+
97+
Response headers
98+
****************
99+
To capture predefined HTTP response headers as span attributes, set the environment variable ``OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE``
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_RESPONSE="content-type,custom_response_header"
107+
108+
will extract ``content-type`` and ``custom_response_header`` from response 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+
Response header names captured in starlette are case insensitive. So, giving header name as ``CUStomHeader`` in environment variable will be able capture header with name ``customheader``.
112+
113+
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 _ ).
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.response.header.custom_response_header = ["<value1>,<value2>"]``
118+
119+
Note:
120+
Environment variable names to caputre http headers are still experimental, and thus are subject to change.
121+
71122
API
72123
---
73124
"""

instrumentation/opentelemetry-instrumentation-starlette/tests/test_starlette_instrumentation.py

Lines changed: 282 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,24 @@
1919
from starlette.responses import PlainTextResponse
2020
from starlette.routing import Route
2121
from starlette.testclient import TestClient
22+
from starlette.websockets import WebSocket
2223

2324
import opentelemetry.instrumentation.starlette as otel_starlette
2425
from opentelemetry.sdk.resources import Resource
2526
from opentelemetry.semconv.trace import SpanAttributes
27+
from opentelemetry.test.globals_test import reset_trace_globals
2628
from opentelemetry.test.test_base import TestBase
27-
from opentelemetry.trace import SpanKind, get_tracer
28-
from opentelemetry.util.http import get_excluded_urls
29+
from opentelemetry.trace import (
30+
NoOpTracerProvider,
31+
SpanKind,
32+
get_tracer,
33+
set_tracer_provider,
34+
)
35+
from opentelemetry.util.http import (
36+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
37+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
38+
get_excluded_urls,
39+
)
2940

3041

3142
class TestStarletteManualInstrumentation(TestBase):
@@ -244,3 +255,272 @@ def test_mark_span_internal_in_presence_of_another_span(self):
244255
self.assertEqual(
245256
parent_span.context.span_id, starlette_span.parent.span_id
246257
)
258+
259+
260+
class TestBaseWithCustomHeaders(TestBase):
261+
def create_app(self):
262+
app = self.create_starlette_app()
263+
self._instrumentor.instrument_app(app=app)
264+
return app
265+
266+
def setUp(self):
267+
super().setUp()
268+
self.env_patch = patch.dict(
269+
"os.environ",
270+
{
271+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
272+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3",
273+
},
274+
)
275+
self.env_patch.start()
276+
self._instrumentor = otel_starlette.StarletteInstrumentor()
277+
self._app = self.create_app()
278+
self._client = TestClient(self._app)
279+
280+
def tearDown(self) -> None:
281+
super().tearDown()
282+
self.env_patch.stop()
283+
with self.disable_logging():
284+
self._instrumentor.uninstrument()
285+
286+
@staticmethod
287+
def create_starlette_app():
288+
app = applications.Starlette()
289+
290+
@app.route("/foobar")
291+
def _(request):
292+
return PlainTextResponse(
293+
content="hi",
294+
headers={
295+
"custom-test-header-1": "test-header-value-1",
296+
"custom-test-header-2": "test-header-value-2",
297+
},
298+
)
299+
300+
@app.websocket_route("/foobar_web")
301+
async def _(websocket: WebSocket) -> None:
302+
message = await websocket.receive()
303+
if message.get("type") == "websocket.connect":
304+
await websocket.send(
305+
{
306+
"type": "websocket.accept",
307+
"headers": [
308+
(b"custom-test-header-1", b"test-header-value-1"),
309+
(b"custom-test-header-2", b"test-header-value-2"),
310+
],
311+
}
312+
)
313+
await websocket.send_json({"message": "hello world"})
314+
await websocket.close()
315+
if message.get("type") == "websocket.disconnect":
316+
pass
317+
318+
return app
319+
320+
321+
class TestHTTPAppWithCustomHeaders(TestBaseWithCustomHeaders):
322+
def test_custom_request_headers_in_span_attributes(self):
323+
expected = {
324+
"http.request.header.custom_test_header_1": (
325+
"test-header-value-1",
326+
),
327+
"http.request.header.custom_test_header_2": (
328+
"test-header-value-2",
329+
),
330+
}
331+
resp = self._client.get(
332+
"/foobar",
333+
headers={
334+
"custom-test-header-1": "test-header-value-1",
335+
"custom-test-header-2": "test-header-value-2",
336+
},
337+
)
338+
self.assertEqual(200, resp.status_code)
339+
span_list = self.memory_exporter.get_finished_spans()
340+
self.assertEqual(len(span_list), 3)
341+
342+
server_span = [
343+
span for span in span_list if span.kind == SpanKind.SERVER
344+
][0]
345+
346+
self.assertSpanHasAttributes(server_span, expected)
347+
348+
def test_custom_request_headers_not_in_span_attributes(self):
349+
not_expected = {
350+
"http.request.header.custom_test_header_3": (
351+
"test-header-value-3",
352+
),
353+
}
354+
resp = self._client.get(
355+
"/foobar",
356+
headers={
357+
"custom-test-header-1": "test-header-value-1",
358+
"custom-test-header-2": "test-header-value-2",
359+
},
360+
)
361+
self.assertEqual(200, resp.status_code)
362+
span_list = self.memory_exporter.get_finished_spans()
363+
self.assertEqual(len(span_list), 3)
364+
365+
server_span = [
366+
span for span in span_list if span.kind == SpanKind.SERVER
367+
][0]
368+
369+
for key in not_expected:
370+
self.assertNotIn(key, server_span.attributes)
371+
372+
def test_custom_response_headers_in_span_attributes(self):
373+
expected = {
374+
"http.response.header.custom_test_header_1": (
375+
"test-header-value-1",
376+
),
377+
"http.response.header.custom_test_header_2": (
378+
"test-header-value-2",
379+
),
380+
}
381+
resp = self._client.get("/foobar")
382+
self.assertEqual(200, resp.status_code)
383+
span_list = self.memory_exporter.get_finished_spans()
384+
self.assertEqual(len(span_list), 3)
385+
386+
server_span = [
387+
span for span in span_list if span.kind == SpanKind.SERVER
388+
][0]
389+
390+
self.assertSpanHasAttributes(server_span, expected)
391+
392+
def test_custom_response_headers_not_in_span_attributes(self):
393+
not_expected = {
394+
"http.response.header.custom_test_header_3": (
395+
"test-header-value-3",
396+
),
397+
}
398+
resp = self._client.get("/foobar")
399+
self.assertEqual(200, resp.status_code)
400+
span_list = self.memory_exporter.get_finished_spans()
401+
self.assertEqual(len(span_list), 3)
402+
403+
server_span = [
404+
span for span in span_list if span.kind == SpanKind.SERVER
405+
][0]
406+
407+
for key in not_expected:
408+
self.assertNotIn(key, server_span.attributes)
409+
410+
411+
class TestWebSocketAppWithCustomHeaders(TestBaseWithCustomHeaders):
412+
def test_custom_request_headers_in_span_attributes(self):
413+
expected = {
414+
"http.request.header.custom_test_header_1": (
415+
"test-header-value-1",
416+
),
417+
"http.request.header.custom_test_header_2": (
418+
"test-header-value-2",
419+
),
420+
}
421+
with self._client.websocket_connect(
422+
"/foobar_web",
423+
headers={
424+
"custom-test-header-1": "test-header-value-1",
425+
"custom-test-header-2": "test-header-value-2",
426+
},
427+
) as websocket:
428+
data = websocket.receive_json()
429+
self.assertEqual(data, {"message": "hello world"})
430+
431+
span_list = self.memory_exporter.get_finished_spans()
432+
self.assertEqual(len(span_list), 5)
433+
434+
server_span = [
435+
span for span in span_list if span.kind == SpanKind.SERVER
436+
][0]
437+
self.assertSpanHasAttributes(server_span, expected)
438+
439+
def test_custom_request_headers_not_in_span_attributes(self):
440+
not_expected = {
441+
"http.request.header.custom_test_header_3": (
442+
"test-header-value-3",
443+
),
444+
}
445+
with self._client.websocket_connect(
446+
"/foobar_web",
447+
headers={
448+
"custom-test-header-1": "test-header-value-1",
449+
"custom-test-header-2": "test-header-value-2",
450+
},
451+
) as websocket:
452+
data = websocket.receive_json()
453+
self.assertEqual(data, {"message": "hello world"})
454+
455+
span_list = self.memory_exporter.get_finished_spans()
456+
self.assertEqual(len(span_list), 5)
457+
458+
server_span = [
459+
span for span in span_list if span.kind == SpanKind.SERVER
460+
][0]
461+
462+
for key, _ in not_expected.items():
463+
self.assertNotIn(key, server_span.attributes)
464+
465+
def test_custom_response_headers_in_span_attributes(self):
466+
expected = {
467+
"http.response.header.custom_test_header_1": (
468+
"test-header-value-1",
469+
),
470+
"http.response.header.custom_test_header_2": (
471+
"test-header-value-2",
472+
),
473+
}
474+
with self._client.websocket_connect("/foobar_web") as websocket:
475+
data = websocket.receive_json()
476+
self.assertEqual(data, {"message": "hello world"})
477+
478+
span_list = self.memory_exporter.get_finished_spans()
479+
self.assertEqual(len(span_list), 5)
480+
481+
server_span = [
482+
span for span in span_list if span.kind == SpanKind.SERVER
483+
][0]
484+
485+
self.assertSpanHasAttributes(server_span, expected)
486+
487+
def test_custom_response_headers_not_in_span_attributes(self):
488+
not_expected = {
489+
"http.response.header.custom_test_header_3": (
490+
"test-header-value-3",
491+
),
492+
}
493+
with self._client.websocket_connect("/foobar_web") as websocket:
494+
data = websocket.receive_json()
495+
self.assertEqual(data, {"message": "hello world"})
496+
497+
span_list = self.memory_exporter.get_finished_spans()
498+
self.assertEqual(len(span_list), 5)
499+
500+
server_span = [
501+
span for span in span_list if span.kind == SpanKind.SERVER
502+
][0]
503+
504+
for key, _ in not_expected.items():
505+
self.assertNotIn(key, server_span.attributes)
506+
507+
508+
class TestNonRecordingSpanWithCustomHeaders(TestBaseWithCustomHeaders):
509+
def setUp(self):
510+
super().setUp()
511+
reset_trace_globals()
512+
set_tracer_provider(tracer_provider=NoOpTracerProvider())
513+
514+
self._app = self.create_app()
515+
self._client = TestClient(self._app)
516+
517+
def test_custom_header_not_present_in_non_recording_span(self):
518+
resp = self._client.get(
519+
"/foobar",
520+
headers={
521+
"custom-test-header-1": "test-header-value-1",
522+
},
523+
)
524+
self.assertEqual(200, resp.status_code)
525+
span_list = self.memory_exporter.get_finished_spans()
526+
self.assertEqual(len(span_list), 0)

0 commit comments

Comments
 (0)