| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent de25bce commit 954ad18
9 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -24,6 +24,8 @@ | |||
| 24 | 24 | except ImportError: # pragma: NO COVER | |
| 25 | 25 | flask = None | |
| 26 | 26 | ||
| 27 | + import opentelemetry.trace | ||
| 28 | + | ||
| 27 | 29 | from google.cloud.logging_v2.handlers.middleware.request import _get_django_request | |
| 28 | 30 | ||
| 29 | 31 | _DJANGO_CONTENT_LENGTH = "CONTENT_LENGTH" | |
@@ -191,23 +193,71 @@ def _parse_xcloud_trace(header): | |||
| 191 | 193 | return trace_id, span_id, trace_sampled | |
| 192 | 194 | ||
| 193 | 195 | ||
| 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 | + | ||
| 194 | 218 | def get_request_data(): | |
| 195 | 219 | """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. | ||
| 197 | 226 | ||
| 198 | 227 | Returns: | |
| 199 | 228 | Tuple[Optional[dict], Optional[str], Optional[str], bool]: | |
| 200 | 229 | Data related to the current http request, trace_id, span_id, and trace_sampled | |
| 201 | 230 | for the request. All fields will be None if a http request isn't found. | |
| 202 | 231 | """ | |
| 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 | ||
| 203 | 240 | checkers = ( | |
| 204 | 241 | get_request_data_from_django, | |
| 205 | 242 | get_request_data_from_flask, | |
| 206 | 243 | ) | |
| 207 | 244 | ||
| 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 | + ) | ||
| 212 | 251 | ||
| 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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -63,6 +63,7 @@ | |||
| 63 | 63 | "google-cloud-pubsub", | |
| 64 | 64 | "google-cloud-storage", | |
| 65 | 65 | "google-cloud-testutils", | |
| 66 | + "opentelemetry-sdk", | ||
| 66 | 67 | ] | |
| 67 | 68 | SYSTEM_TEST_LOCAL_DEPENDENCIES: List[str] = [] | |
| 68 | 69 | SYSTEM_TEST_DEPENDENCIES: List[str] = [] | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -93,6 +93,7 @@ def place_before(path, text, *before_text, escape=None): | |||
| 93 | 93 | "google-cloud-pubsub", | |
| 94 | 94 | "google-cloud-storage", | |
| 95 | 95 | "google-cloud-testutils", | |
| 96 | + "opentelemetry-sdk" | ||
| 96 | 97 | ], | |
| 97 | 98 | unit_test_external_dependencies=["flask", "webob", "django"], | |
| 98 | 99 | samples=True, | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -44,6 +44,7 @@ | |||
| 44 | 44 | "google-cloud-audit-log >= 0.1.0, < 1.0.0dev", | |
| 45 | 45 | "google-cloud-core >= 2.0.0, <3.0.0dev", | |
| 46 | 46 | "grpc-google-iam-v1 >=0.12.4, <1.0.0dev", | |
| 47 | + "opentelemetry-api >= 1.0.0", | ||
| 47 | 48 | "proto-plus >= 1.22.0, <2.0.0dev", | |
| 48 | 49 | "proto-plus >= 1.22.2, <2.0.0dev; python_version>='3.11'", | |
| 49 | 50 | "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", | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -19,6 +19,7 @@ | |||
| 19 | 19 | import numbers | |
| 20 | 20 | import os | |
| 21 | 21 | import pytest | |
| 22 | + import sys | ||
| 22 | 23 | import unittest | |
| 23 | 24 | import uuid | |
| 24 | 25 | ||
@@ -117,6 +118,25 @@ def setUpModule(): | |||
| 117 | 118 | ) | |
| 118 | 119 | ||
| 119 | 120 | ||
| 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 | + | ||
| 120 | 140 | class TestLogging(unittest.TestCase): | |
| 121 | 141 | JSON_PAYLOAD = { | |
| 122 | 142 | "message": "System test: test_log_struct", | |
@@ -662,6 +682,43 @@ def test_log_root_handler(self): | |||
| 662 | 682 | self.assertEqual(len(entries), 1) | |
| 663 | 683 | self.assertEqual(entries[0].payload, expected_payload) | |
| 664 | 684 | ||
| 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 | + | ||
| 665 | 722 | def test_create_metric(self): | |
| 666 | 723 | METRIC_NAME = "test-create-metric%s" % (_RESOURCE_ID,) | |
| 667 | 724 | metric = Config.CLIENT.metric( | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -11,3 +11,44 @@ | |||
| 11 | 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | |
| 12 | 12 | # See the License for the specific language governing permissions and | |
| 13 | 13 | # 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) | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -16,6 +16,13 @@ | |||
| 16 | 16 | ||
| 17 | 17 | import mock | |
| 18 | 18 | ||
| 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 | + | ||
| 19 | 26 | _FLASK_TRACE_ID = "flask0id" | |
| 20 | 27 | _FLASK_SPAN_ID = "span0flask" | |
| 21 | 28 | _FLASK_HTTP_REQUEST = {"requestUrl": "https://flask.palletsprojects.com/en/1.1.x/"} | |
@@ -356,6 +363,120 @@ def test_wo_libraries(self): | |||
| 356 | 363 | output = self._call_fut() | |
| 357 | 364 | self.assertEqual(output, (None, None, None, False)) | |
| 358 | 365 | ||
| 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 | + | ||
| 359 | 480 | ||
| 360 | 481 | class Test__parse_xcloud_trace(unittest.TestCase): | |
| 361 | 482 | @staticmethod | |
@@ -477,3 +598,25 @@ def test_invalid_headers(self): | |||
| 477 | 598 | self.assertIsNone(trace_id) | |
| 478 | 599 | self.assertIsNone(span_id) | |
| 479 | 600 | 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) | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments