Skip to content

Commit d86f164

Browse files
authored
Tornado: Capture custom request/response headers as span attributes (#950)
1 parent 5539d1f commit d86f164

File tree

4 files changed

+178
-3
lines changed

4 files changed

+178
-3
lines changed

CHANGELOG.md

+3
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- `opentelemetry-instrumentation-flask` Flask: Capture custom request/response headers in span attributes
1818
([#952])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/952)
1919

20+
- `opentelemetry-instrumentation-tornado` Tornado: Capture custom request/response headers in span attributes
21+
([#950])(https://github.com/open-telemetry/opentelemetry-python-contrib/pull/950)
22+
2023
### Added
2124

2225
- `opentelemetry-instrumentation-aws-lambda` `SpanKind.SERVER` by default, add more cases for `SpanKind.CONSUMER` services. ([#926](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/926))

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

+39-2
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,15 @@ def client_resposne_hook(span, future):
129129
from opentelemetry.semconv.trace import SpanAttributes
130130
from opentelemetry.trace.status import Status, StatusCode
131131
from opentelemetry.util._time import _time_ns
132-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
132+
from opentelemetry.util.http import (
133+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST,
134+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE,
135+
get_custom_headers,
136+
get_excluded_urls,
137+
get_traced_request_attrs,
138+
normalise_request_header_name,
139+
normalise_response_header_name,
140+
)
133141

134142
from .client import fetch_async # pylint: disable=E0401
135143

@@ -141,7 +149,6 @@ def client_resposne_hook(span, future):
141149

142150
_excluded_urls = get_excluded_urls("TORNADO")
143151
_traced_request_attrs = get_traced_request_attrs("TORNADO")
144-
145152
response_propagation_setter = FuncSetter(tornado.web.RequestHandler.add_header)
146153

147154

@@ -257,6 +264,32 @@ def _log_exception(tracer, func, handler, args, kwargs):
257264
return func(*args, **kwargs)
258265

259266

267+
def _add_custom_request_headers(span, request_headers):
268+
custom_request_headers_name = get_custom_headers(
269+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST
270+
)
271+
attributes = {}
272+
for header_name in custom_request_headers_name:
273+
header_values = request_headers.get(header_name)
274+
if header_values:
275+
key = normalise_request_header_name(header_name.lower())
276+
attributes[key] = [header_values]
277+
span.set_attributes(attributes)
278+
279+
280+
def _add_custom_response_headers(span, response_headers):
281+
custom_response_headers_name = get_custom_headers(
282+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE
283+
)
284+
attributes = {}
285+
for header_name in custom_response_headers_name:
286+
header_values = response_headers.get(header_name)
287+
if header_values:
288+
key = normalise_response_header_name(header_name.lower())
289+
attributes[key] = [header_values]
290+
span.set_attributes(attributes)
291+
292+
260293
def _get_attributes_from_request(request):
261294
attrs = {
262295
SpanAttributes.HTTP_METHOD: request.method,
@@ -307,6 +340,8 @@ def _start_span(tracer, handler, start_time) -> _TraceContext:
307340
for key, value in attributes.items():
308341
span.set_attribute(key, value)
309342
span.set_attribute("tornado.handler", _get_full_handler_name(handler))
343+
if span.kind == trace.SpanKind.SERVER:
344+
_add_custom_request_headers(span, handler.request.headers)
310345

311346
activation = trace.use_span(span, end_on_exit=True)
312347
activation.__enter__() # pylint: disable=E1101
@@ -360,6 +395,8 @@ def _finish_span(tracer, handler, error=None):
360395
description=otel_status_description,
361396
)
362397
)
398+
if ctx.span.kind == trace.SpanKind.SERVER:
399+
_add_custom_response_headers(ctx.span, handler._headers)
363400

364401
ctx.activation.__exit__(*finish_args) # pylint: disable=E1101
365402
if ctx.token:

instrumentation/opentelemetry-instrumentation-tornado/tests/test_instrumentation.py

+125-1
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@
3232
from opentelemetry.test.test_base import TestBase
3333
from opentelemetry.test.wsgitestutil import WsgiTestBase
3434
from opentelemetry.trace import SpanKind
35-
from opentelemetry.util.http import get_excluded_urls, get_traced_request_attrs
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+
get_traced_request_attrs,
40+
)
3641

3742
from .tornado_test_app import (
3843
AsyncHandler,
@@ -604,3 +609,122 @@ def test_mark_span_internal_in_presence_of_another_span(self):
604609
self.assertEqual(
605610
test_span.context.span_id, tornado_handler_span.parent.span_id
606611
)
612+
613+
614+
class TestTornadoCustomRequestResponseHeadersAddedWithServerSpan(TornadoTest):
615+
@patch.dict(
616+
"os.environ",
617+
{
618+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3"
619+
},
620+
)
621+
def test_custom_request_headers_added_in_server_span(self):
622+
headers = {
623+
"Custom-Test-Header-1": "Test Value 1",
624+
"Custom-Test-Header-2": "TestValue2,TestValue3",
625+
}
626+
response = self.fetch("/", headers=headers)
627+
self.assertEqual(response.code, 201)
628+
_, tornado_span, _ = self.sorted_spans(
629+
self.memory_exporter.get_finished_spans()
630+
)
631+
expected = {
632+
"http.request.header.custom_test_header_1": ("Test Value 1",),
633+
"http.request.header.custom_test_header_2": (
634+
"TestValue2,TestValue3",
635+
),
636+
}
637+
self.assertEqual(tornado_span.kind, trace.SpanKind.SERVER)
638+
self.assertSpanHasAttributes(tornado_span, expected)
639+
640+
@patch.dict(
641+
"os.environ",
642+
{
643+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "content-type,content-length,my-custom-header,invalid-header"
644+
},
645+
)
646+
def test_custom_response_headers_added_in_server_span(self):
647+
response = self.fetch("/test_custom_response_headers")
648+
self.assertEqual(response.code, 200)
649+
tornado_span, _ = self.sorted_spans(
650+
self.memory_exporter.get_finished_spans()
651+
)
652+
expected = {
653+
"http.response.header.content_type": (
654+
"text/plain; charset=utf-8",
655+
),
656+
"http.response.header.content_length": ("0",),
657+
"http.response.header.my_custom_header": (
658+
"my-custom-value-1,my-custom-header-2",
659+
),
660+
}
661+
self.assertEqual(tornado_span.kind, trace.SpanKind.SERVER)
662+
self.assertSpanHasAttributes(tornado_span, expected)
663+
664+
665+
class TestTornadoCustomRequestResponseHeadersNotAddedWithInternalSpan(
666+
TornadoTest
667+
):
668+
def get_app(self):
669+
tracer = trace.get_tracer(__name__)
670+
app = make_app(tracer)
671+
672+
def middleware(request):
673+
"""Wraps the request with a server span"""
674+
with tracer.start_as_current_span(
675+
"test", kind=trace.SpanKind.SERVER
676+
):
677+
app(request)
678+
679+
return middleware
680+
681+
@patch.dict(
682+
"os.environ",
683+
{
684+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_REQUEST: "Custom-Test-Header-1,Custom-Test-Header-2,Custom-Test-Header-3"
685+
},
686+
)
687+
def test_custom_request_headers_not_added_in_internal_span(self):
688+
headers = {
689+
"Custom-Test-Header-1": "Test Value 1",
690+
"Custom-Test-Header-2": "TestValue2,TestValue3",
691+
}
692+
response = self.fetch("/", headers=headers)
693+
self.assertEqual(response.code, 201)
694+
_, tornado_span, _, _ = self.sorted_spans(
695+
self.memory_exporter.get_finished_spans()
696+
)
697+
not_expected = {
698+
"http.request.header.custom_test_header_1": ("Test Value 1",),
699+
"http.request.header.custom_test_header_2": (
700+
"TestValue2,TestValue3",
701+
),
702+
}
703+
self.assertEqual(tornado_span.kind, trace.SpanKind.INTERNAL)
704+
for key, _ in not_expected.items():
705+
self.assertNotIn(key, tornado_span.attributes)
706+
707+
@patch.dict(
708+
"os.environ",
709+
{
710+
OTEL_INSTRUMENTATION_HTTP_CAPTURE_HEADERS_SERVER_RESPONSE: "content-type,content-length,my-custom-header,invalid-header"
711+
},
712+
)
713+
def test_custom_response_headers_not_added_in_internal_span(self):
714+
response = self.fetch("/test_custom_response_headers")
715+
self.assertEqual(response.code, 200)
716+
tornado_span, _, _ = self.sorted_spans(
717+
self.memory_exporter.get_finished_spans()
718+
)
719+
not_expected = {
720+
"http.response.header.content_type": (
721+
"text/plain; charset=utf-8",
722+
),
723+
"http.response.header.content_length": ("0",),
724+
"http.response.header.my_custom_header": (
725+
"my-custom-value-1,my-custom-header-2",
726+
),
727+
}
728+
self.assertEqual(tornado_span.kind, trace.SpanKind.INTERNAL)
729+
for key, _ in not_expected.items():
730+
self.assertNotIn(key, tornado_span.attributes)

instrumentation/opentelemetry-instrumentation-tornado/tests/tornado_test_app.py

+11
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,16 @@ def get(self):
9595
self.set_status(200)
9696

9797

98+
class CustomResponseHeaderHandler(tornado.web.RequestHandler):
99+
def get(self):
100+
self.set_header("content-type", "text/plain; charset=utf-8")
101+
self.set_header("content-length", "0")
102+
self.set_header(
103+
"my-custom-header", "my-custom-value-1,my-custom-header-2"
104+
)
105+
self.set_status(200)
106+
107+
98108
def make_app(tracer):
99109
app = tornado.web.Application(
100110
[
@@ -105,6 +115,7 @@ def make_app(tracer):
105115
(r"/on_finish", FinishedHandler),
106116
(r"/healthz", HealthCheckHandler),
107117
(r"/ping", HealthCheckHandler),
118+
(r"/test_custom_response_headers", CustomResponseHeaderHandler),
108119
]
109120
)
110121
app.tracer = tracer

0 commit comments

Comments
 (0)