Skip to content

Commit 78168a3

Browse files
feat: OpenTelemetry trace/spanID integration for Python handlers (#889)
* feat: OpenTelemetry trace/spanID integration for Python handlers * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * Added more tests for OTel Python integration * linting * more linting * renamed _parse_current_open_telemetry_span and fixed otel testcases * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * linting + removed print statements * added opentelemetry sdk module cleanup to system test * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md * Refactored get_request_and_trace_data back into get_request_data * 🦉 Updates from OwlBot post-processor See https://github.com/googleapis/repo-automation-bots/blob/main/packages/owl-bot/README.md --------- Co-authored-by: Owl Bot <gcf-owl-bot[bot]@users.noreply.github.com>
1 parent e46bbf8 commit 78168a3

File tree

9 files changed

+538
-6
lines changed

9 files changed

+538
-6
lines changed

google/cloud/logging_v2/handlers/_helpers.py

Lines changed: 56 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@
2424
except ImportError: # pragma: NO COVER
2525
flask = None
2626

27+
import opentelemetry.trace
28+
2729
from google.cloud.logging_v2.handlers.middleware.request import _get_django_request
2830

2931
_DJANGO_CONTENT_LENGTH = "CONTENT_LENGTH"
@@ -191,23 +193,71 @@ def _parse_xcloud_trace(header):
191193
return trace_id, span_id, trace_sampled
192194

193195

196+
def _retrieve_current_open_telemetry_span():
197+
"""Helper to retrieve trace, span ID, and trace sampled information from the current
198+
OpenTelemetry span.
199+
200+
Returns:
201+
Tuple[Optional[str], Optional[str], bool]:
202+
Data related to the current trace_id, span_id, and trace_sampled for the
203+
current OpenTelemetry span. If a span is not found, return None/False for all
204+
fields.
205+
"""
206+
span = opentelemetry.trace.get_current_span()
207+
if span != opentelemetry.trace.span.INVALID_SPAN:
208+
context = span.get_span_context()
209+
trace_id = opentelemetry.trace.format_trace_id(context.trace_id)
210+
span_id = opentelemetry.trace.format_span_id(context.span_id)
211+
trace_sampled = context.trace_flags.sampled
212+
213+
return trace_id, span_id, trace_sampled
214+
215+
return None, None, False
216+
217+
194218
def get_request_data():
195219
"""Helper to get http_request and trace data from supported web
196-
frameworks (currently supported: Flask and Django).
220+
frameworks (currently supported: Flask and Django), as well as OpenTelemetry. Attempts
221+
to retrieve trace/spanID from OpenTelemetry first, before going to Traceparent then XCTC.
222+
HTTP request data is taken from a supporting web framework (currently Flask or Django).
223+
Because HTTP request data is decoupled from OpenTelemetry, it is possible to get as a
224+
return value the HTTP request from the web framework of choice, and trace/span data from
225+
OpenTelemetry, even if trace data is present in the HTTP request headers.
197226
198227
Returns:
199228
Tuple[Optional[dict], Optional[str], Optional[str], bool]:
200229
Data related to the current http request, trace_id, span_id, and trace_sampled
201230
for the request. All fields will be None if a http request isn't found.
202231
"""
232+
233+
(
234+
otel_trace_id,
235+
otel_span_id,
236+
otel_trace_sampled,
237+
) = _retrieve_current_open_telemetry_span()
238+
239+
# Get HTTP request data
203240
checkers = (
204241
get_request_data_from_django,
205242
get_request_data_from_flask,
206243
)
207244

208-
for checker in checkers:
209-
http_request, trace_id, span_id, trace_sampled = checker()
210-
if http_request is not None:
211-
return http_request, trace_id, span_id, trace_sampled
245+
http_request, http_trace_id, http_span_id, http_trace_sampled = (
246+
None,
247+
None,
248+
None,
249+
False,
250+
)
212251

213-
return None, None, None, False
252+
for checker in checkers:
253+
http_request, http_trace_id, http_span_id, http_trace_sampled = checker()
254+
if http_request is None:
255+
http_trace_id, http_span_id, http_trace_sampled = None, None, False
256+
else:
257+
break
258+
259+
# otel_trace_id existing means the other return values are non-null
260+
if otel_trace_id:
261+
return http_request, otel_trace_id, otel_span_id, otel_trace_sampled
262+
else:
263+
return http_request, http_trace_id, http_span_id, http_trace_sampled

noxfile.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"google-cloud-pubsub",
6464
"google-cloud-storage",
6565
"google-cloud-testutils",
66+
"opentelemetry-sdk",
6667
]
6768
SYSTEM_TEST_LOCAL_DEPENDENCIES: List[str] = []
6869
SYSTEM_TEST_DEPENDENCIES: List[str] = []

owlbot.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ def place_before(path, text, *before_text, escape=None):
9393
"google-cloud-pubsub",
9494
"google-cloud-storage",
9595
"google-cloud-testutils",
96+
"opentelemetry-sdk"
9697
],
9798
unit_test_external_dependencies=["flask", "webob", "django"],
9899
samples=True,

setup.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"google-cloud-audit-log >= 0.1.0, < 1.0.0dev",
4545
"google-cloud-core >= 2.0.0, <3.0.0dev",
4646
"grpc-google-iam-v1 >=0.12.4, <1.0.0dev",
47+
"opentelemetry-api >= 1.0.0",
4748
"proto-plus >= 1.22.0, <2.0.0dev",
4849
"proto-plus >= 1.22.2, <2.0.0dev; python_version>='3.11'",
4950
"protobuf>=3.19.5,<5.0.0dev,!=3.20.0,!=3.20.1,!=4.21.0,!=4.21.1,!=4.21.2,!=4.21.3,!=4.21.4,!=4.21.5",

tests/system/test_system.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
import numbers
2020
import os
2121
import pytest
22+
import sys
2223
import unittest
2324
import uuid
2425

@@ -117,6 +118,25 @@ def setUpModule():
117118
)
118119

119120

121+
def _cleanup_otel_sdk_modules(f):
122+
"""
123+
Decorator to delete all references to opentelemetry SDK modules after a
124+
testcase is run. Test case should import opentelemetry SDK modules inside
125+
the function. This is to test situations where the opentelemetry SDK
126+
is not imported at all.
127+
"""
128+
129+
def wrapped(*args, **kwargs):
130+
f(*args, **kwargs)
131+
132+
# Deleting from sys.modules should be good enough in this use case
133+
for module_name in list(sys.modules.keys()):
134+
if module_name.startswith("opentelemetry.sdk"):
135+
sys.modules.pop(module_name)
136+
137+
return wrapped
138+
139+
120140
class TestLogging(unittest.TestCase):
121141
JSON_PAYLOAD = {
122142
"message": "System test: test_log_struct",
@@ -662,6 +682,43 @@ def test_log_root_handler(self):
662682
self.assertEqual(len(entries), 1)
663683
self.assertEqual(entries[0].payload, expected_payload)
664684

685+
@_cleanup_otel_sdk_modules
686+
def test_log_handler_otel_integration(self):
687+
# Doing OTel imports here to not taint the other tests with OTel SDK imports
688+
from opentelemetry import trace
689+
from opentelemetry.sdk.trace import TracerProvider
690+
691+
LOG_MESSAGE = "This is a test of OpenTelemetry"
692+
LOGGER_NAME = "otel-integration"
693+
handler_name = self._logger_name(LOGGER_NAME)
694+
695+
handler = CloudLoggingHandler(
696+
Config.CLIENT, name=handler_name, transport=SyncTransport
697+
)
698+
# only create the logger to delete, hidden otherwise
699+
logger = Config.CLIENT.logger(handler.name)
700+
self.to_delete.append(logger)
701+
702+
# Set up OTel SDK
703+
provider = TracerProvider()
704+
705+
tracer = provider.get_tracer("test_system")
706+
with tracer.start_as_current_span("test-span") as span:
707+
context = span.get_span_context()
708+
expected_trace_id = f"projects/{Config.CLIENT.project}/traces/{trace.format_trace_id(context.trace_id)}"
709+
expected_span_id = trace.format_span_id(context.span_id)
710+
expected_tracesampled = context.trace_flags.sampled
711+
712+
cloud_logger = logging.getLogger(LOGGER_NAME)
713+
cloud_logger.addHandler(handler)
714+
cloud_logger.warning(LOG_MESSAGE)
715+
716+
entries = _list_entries(logger)
717+
self.assertEqual(len(entries), 1)
718+
self.assertEqual(entries[0].trace, expected_trace_id)
719+
self.assertEqual(entries[0].span_id, expected_span_id)
720+
self.assertTrue(entries[0].trace_sampled, expected_tracesampled)
721+
665722
def test_create_metric(self):
666723
METRIC_NAME = "test-create-metric%s" % (_RESOURCE_ID,)
667724
metric = Config.CLIENT.metric(

tests/unit/handlers/__init__.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,44 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14+
15+
16+
# Utility functions to setup mock OpenTelemetry spans, needed by multiple test
17+
# suites.
18+
19+
import contextlib
20+
21+
import opentelemetry.context
22+
import opentelemetry.trace
23+
24+
from opentelemetry.trace import NonRecordingSpan
25+
from opentelemetry.trace.span import TraceFlags
26+
27+
_OTEL_SPAN_CONTEXT_TRACE_ID = 0x123456789123456789
28+
_OTEL_SPAN_CONTEXT_SPAN_ID = 0x123456789
29+
_OTEL_SPAN_CONTEXT_TRACEFLAGS = TraceFlags(TraceFlags.SAMPLED)
30+
31+
_EXPECTED_OTEL_TRACE_ID = "00000000000000123456789123456789"
32+
_EXPECTED_OTEL_SPAN_ID = "0000000123456789"
33+
_EXPECTED_OTEL_TRACESAMPLED = True
34+
35+
36+
@contextlib.contextmanager
37+
def _setup_otel_span_context():
38+
"""Sets up a nonrecording OpenTelemetry span with a mock span context that gets returned
39+
by opentelemetry.trace.get_current_span, and returns it as a contextmanager
40+
"""
41+
span_context = opentelemetry.trace.SpanContext(
42+
_OTEL_SPAN_CONTEXT_TRACE_ID,
43+
_OTEL_SPAN_CONTEXT_SPAN_ID,
44+
False,
45+
trace_flags=_OTEL_SPAN_CONTEXT_TRACEFLAGS,
46+
)
47+
ctx = opentelemetry.trace.set_span_in_context(NonRecordingSpan(span_context))
48+
tracer = opentelemetry.trace.NoOpTracer()
49+
token = opentelemetry.context.attach(ctx)
50+
try:
51+
with tracer.start_as_current_span("test-span", context=ctx):
52+
yield
53+
finally:
54+
opentelemetry.context.detach(token)

tests/unit/handlers/test__helpers.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@
1616

1717
import mock
1818

19+
from tests.unit.handlers import (
20+
_setup_otel_span_context,
21+
_EXPECTED_OTEL_TRACE_ID,
22+
_EXPECTED_OTEL_SPAN_ID,
23+
_EXPECTED_OTEL_TRACESAMPLED,
24+
)
25+
1926
_FLASK_TRACE_ID = "flask0id"
2027
_FLASK_SPAN_ID = "span0flask"
2128
_FLASK_HTTP_REQUEST = {"requestUrl": "https://flask.palletsprojects.com/en/1.1.x/"}
@@ -356,6 +363,120 @@ def test_wo_libraries(self):
356363
output = self._call_fut()
357364
self.assertEqual(output, (None, None, None, False))
358365

366+
def test_otel_span_exists_no_request(self):
367+
flask_expected = (None, None, None, False)
368+
django_expected = (None, None, None, False)
369+
370+
with _setup_otel_span_context():
371+
_, _, output = self._helper(django_expected, flask_expected)
372+
self.assertEqual(
373+
output,
374+
(
375+
None,
376+
_EXPECTED_OTEL_TRACE_ID,
377+
_EXPECTED_OTEL_SPAN_ID,
378+
_EXPECTED_OTEL_TRACESAMPLED,
379+
),
380+
)
381+
382+
def test_otel_span_exists_django_request(self):
383+
django_expected = (
384+
_DJANGO_HTTP_REQUEST,
385+
_DJANGO_TRACE_ID,
386+
_DJANGO_SPAN_ID,
387+
False,
388+
)
389+
flask_expected = (None, None, None, False)
390+
391+
with _setup_otel_span_context():
392+
_, _, output = self._helper(django_expected, flask_expected)
393+
self.assertEqual(
394+
output,
395+
(
396+
_DJANGO_HTTP_REQUEST,
397+
_EXPECTED_OTEL_TRACE_ID,
398+
_EXPECTED_OTEL_SPAN_ID,
399+
_EXPECTED_OTEL_TRACESAMPLED,
400+
),
401+
)
402+
403+
def test_otel_span_exists_flask_request(self):
404+
django_expected = (None, None, None, False)
405+
flask_expected = (_FLASK_HTTP_REQUEST, _FLASK_TRACE_ID, _FLASK_SPAN_ID, False)
406+
407+
with _setup_otel_span_context():
408+
_, _, output = self._helper(django_expected, flask_expected)
409+
self.assertEqual(
410+
output,
411+
(
412+
_FLASK_HTTP_REQUEST,
413+
_EXPECTED_OTEL_TRACE_ID,
414+
_EXPECTED_OTEL_SPAN_ID,
415+
_EXPECTED_OTEL_TRACESAMPLED,
416+
),
417+
)
418+
419+
def test_otel_span_exists_both_django_and_flask(self):
420+
django_expected = (
421+
_DJANGO_HTTP_REQUEST,
422+
_DJANGO_TRACE_ID,
423+
_DJANGO_SPAN_ID,
424+
False,
425+
)
426+
flask_expected = (_FLASK_HTTP_REQUEST, _FLASK_TRACE_ID, _FLASK_SPAN_ID, False)
427+
428+
with _setup_otel_span_context():
429+
_, _, output = self._helper(django_expected, flask_expected)
430+
431+
# Django wins
432+
self.assertEqual(
433+
output,
434+
(
435+
_DJANGO_HTTP_REQUEST,
436+
_EXPECTED_OTEL_TRACE_ID,
437+
_EXPECTED_OTEL_SPAN_ID,
438+
_EXPECTED_OTEL_TRACESAMPLED,
439+
),
440+
)
441+
442+
def test_no_otel_span_no_requests(self):
443+
flask_expected = (None, None, None, False)
444+
django_expected = (None, None, None, False)
445+
_, _, output = self._helper(django_expected, flask_expected)
446+
self.assertEqual(output, (None, None, None, False))
447+
448+
def test_no_otel_span_django_request(self):
449+
django_expected = (
450+
_DJANGO_HTTP_REQUEST,
451+
_DJANGO_TRACE_ID,
452+
_DJANGO_SPAN_ID,
453+
False,
454+
)
455+
flask_expected = (None, None, None, False)
456+
_, _, output = self._helper(django_expected, flask_expected)
457+
self.assertEqual(output, django_expected)
458+
459+
def test_no_otel_span_flask_request(self):
460+
django_expected = (None, None, None, False)
461+
flask_expected = (_FLASK_HTTP_REQUEST, _FLASK_TRACE_ID, _FLASK_SPAN_ID, False)
462+
_, _, output = self._helper(django_expected, flask_expected)
463+
464+
# Django wins
465+
self.assertEqual(output, flask_expected)
466+
467+
def test_no_otel_span_both_django_and_flask(self):
468+
django_expected = (
469+
_DJANGO_HTTP_REQUEST,
470+
_DJANGO_TRACE_ID,
471+
_DJANGO_SPAN_ID,
472+
False,
473+
)
474+
flask_expected = (_FLASK_HTTP_REQUEST, _FLASK_TRACE_ID, _FLASK_SPAN_ID, False)
475+
_, _, output = self._helper(django_expected, flask_expected)
476+
477+
# Django wins
478+
self.assertEqual(output, django_expected)
479+
359480

360481
class Test__parse_xcloud_trace(unittest.TestCase):
361482
@staticmethod
@@ -477,3 +598,25 @@ def test_invalid_headers(self):
477598
self.assertIsNone(trace_id)
478599
self.assertIsNone(span_id)
479600
self.assertEqual(sampled, False)
601+
602+
603+
class Test__parse_open_telemetry_data(unittest.TestCase):
604+
@staticmethod
605+
def _call_fut():
606+
from google.cloud.logging_v2.handlers import _helpers
607+
608+
trace, span, sampled = _helpers._retrieve_current_open_telemetry_span()
609+
return trace, span, sampled
610+
611+
def test_no_op(self):
612+
trace_id, span_id, sampled = self._call_fut()
613+
self.assertIsNone(trace_id)
614+
self.assertIsNone(span_id)
615+
self.assertEqual(sampled, False)
616+
617+
def test_span_exists(self):
618+
with _setup_otel_span_context():
619+
trace_id, span_id, sampled = self._call_fut()
620+
self.assertEqual(trace_id, _EXPECTED_OTEL_TRACE_ID)
621+
self.assertEqual(span_id, _EXPECTED_OTEL_SPAN_ID)
622+
self.assertEqual(sampled, _EXPECTED_OTEL_TRACESAMPLED)

0 commit comments

Comments
 (0)