-
Notifications
You must be signed in to change notification settings - Fork 15
[Tracing] Implement initial OTLP traces weblog tests #6363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
dd04bd9
ebbbb5a
7fee5a2
5f8b2a8
221b031
86b284d
cacbab5
14b1995
57ed6bc
6a1df07
f032d9d
746148b
919b5ef
a63042c
0d6ff15
ca4dc74
bfcd7a9
d0ce4dd
6c632cd
43271c4
3c77f0e
8d1af47
2c037be
5f73ad4
428aded
25d94c3
b1fa3b8
2287e1b
292969c
4d386dc
84d68be
470194c
00b4437
3c52d47
89a0480
9515648
7b89b8f
783437d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| # Unless explicitly stated otherwise all files in this repository are licensed under the the Apache License Version 2.0. | ||
| # This product includes software developed at Datadog (https://www.datadoghq.com/). | ||
| # Copyright 2024 Datadog, Inc. | ||
|
|
||
| import re | ||
|
|
||
| from utils import context, features, interfaces, scenarios, weblog | ||
| from utils._context._scenarios.endtoend import EndToEndScenario | ||
| from utils.dd_constants import SpanKind, StatusCode | ||
|
|
||
|
|
||
| # @scenarios.apm_tracing_e2e_otel | ||
| @features.otel_api | ||
| @scenarios.apm_tracing_otlp | ||
| class Test_Otel_Tracing_OTLP: | ||
| def setup_single_server_trace(self): | ||
| # Get the start time of the weblog container in nanoseconds | ||
| assert isinstance(context.scenario, EndToEndScenario) | ||
| exit_code, output = context.scenario.weblog_container.execute_command("date -u +%s%N") | ||
| assert exit_code == 0, f"self.start_time_ns: date -u +%s%N in weblog container failed: {output!r}" | ||
| stripped = output.strip() | ||
| assert stripped, f"empty output from date -u +%s%N in weblog container: {output!r}" | ||
|
|
||
| self.start_time_ns = int(stripped) | ||
| self.req = weblog.get("/") | ||
|
|
||
| def test_single_server_trace(self): | ||
| """Validates the required elements of the OTLP payload for a single trace""" | ||
| data = list(interfaces.open_telemetry.get_otel_spans(self.req)) | ||
|
|
||
| # Assert that there is only one OTLP request containing the desired server span | ||
| assert len(data) == 1 | ||
| request, content, span = data[0] | ||
|
|
||
| # Determine if JSON Protobuf Encoding was used for the OTLP request (rather than Binary Protobuf) | ||
| # We need to assert that we match the OTLP specification, which has some odd encoding rules when using JSON: https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding | ||
| request_headers = {key.lower(): value for key, value in request.get("headers")} | ||
| is_json = request_headers.get("content-type") == "application/json" | ||
|
|
||
| # Assert that there is only one resource span (i.e. SDK) in the OTLP request | ||
| resource_spans = content["resourceSpans"] | ||
| assert resource_spans is not None, f"missing 'resourceSpans' on content: {content}" | ||
| assert len(resource_spans) == 1, f"expected 1 resource span, got {len(resource_spans)}" | ||
| resource_span = resource_spans[0] | ||
|
|
||
| attributes = resource_span.get("resource", {}).get("attributes", {}) | ||
|
|
||
| # Assert that the resource attributes contain the service-level attributes and tracer-level attributes we expect | ||
| # TODO: Assert the following attributes: runtime-id, git.commit.sha, git.repository_url | ||
| assert attributes.get("service.name") == "weblog" | ||
| assert attributes.get("service.version") == "1.0.0" | ||
| assert ( | ||
| attributes.get("deployment.environment.name") == "system-tests" | ||
| or attributes.get("deployment.environment") == "system-tests" | ||
| ) | ||
| assert attributes.get("telemetry.sdk.name") == "datadog" | ||
| assert "telemetry.sdk.language" in attributes | ||
| # assert "telemetry.sdk.version" in attributes | ||
|
|
||
| # Assert that the `traceId` and `spanId` JSON fields are valid case-insensitive hexadecimal strings, not base64-encoded strings as defined in the standard Protobuf JSON Mapping. | ||
| # See https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding | ||
| # TODO: Assert against trace_id and span_id fields in the protobuf encoding as well | ||
| if is_json: | ||
| assert re.match(r"^[0-9a-fA-F]{32}$", span.get("traceId")), ( | ||
| f"traceId is not a valid case-insensitive hexadecimal string, got {span.get('traceId')}" | ||
| ) | ||
| assert re.match(r"^[0-9a-fA-F]{16}$", span.get("spanId")), ( | ||
| f"spanId is not a valid case-insensitive hexadecimal string, got {span.get('spanId')}" | ||
| ) | ||
|
|
||
| # Assert that the span fields match the expected values | ||
| span_start_time_ns = int(span["startTimeUnixNano"]) | ||
| span_end_time_ns = int(span["endTimeUnixNano"]) | ||
| assert span_start_time_ns >= self.start_time_ns | ||
| assert span_end_time_ns >= span_start_time_ns | ||
|
|
||
| assert span["name"] | ||
| assert span["kind"] == SpanKind.SERVER.value | ||
| status = span.get("status", {}) | ||
| # An absent or empty status dict both mean STATUS_CODE_UNSET (protobuf default = 0). | ||
| assert ( | ||
| not status or status.get("code", StatusCode.STATUS_CODE_UNSET.value) == StatusCode.STATUS_CODE_UNSET.value | ||
| ) | ||
|
|
||
| # Assert core span attributes | ||
| assert span["attributes"] is not None | ||
| span_attributes = span["attributes"] | ||
| assert span_attributes.get("service.name") == "weblog" or span_attributes.get("service.name") is None | ||
| assert span_attributes["resource.name"] == span["name"] | ||
| assert span_attributes["span.type"] == "web" | ||
| assert span_attributes["operation.name"] is not None | ||
|
|
||
| # Assert HTTP tags | ||
| # Convert attributes list to a dictionary, but for now only handle key_value objects with stringValue | ||
| method = span_attributes.get("http.method") or span_attributes.get("http.request.method") | ||
| status_code = span_attributes.get("http.status_code") or span_attributes.get("http.response.status_code") | ||
| assert method == "GET", f"HTTP method is not GET, got {method}" | ||
| assert status_code is not None | ||
| assert int(status_code) == 200, f"HTTP status code is not 200, got {int(status_code)}" | ||
|
|
||
| def setup_unsampled_trace(self): | ||
| self.req = weblog.get("/", headers={"traceparent": "00-11111111111111110000000000000001-0000000000000001-00"}) | ||
|
|
||
| def test_unsampled_trace(self): | ||
| """Validates that the spans from a non-sampled trace are not exported.""" | ||
| data = list(interfaces.open_telemetry.get_otel_spans(self.req)) | ||
|
|
||
| # Assert that the span from this test case was not exported | ||
| assert len(data) == 0, f"Expected no weblog spans in the OTLP trace payload, got {data}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,18 +23,44 @@ def ingest_file(self, src_path: str): | |
| return super().ingest_file(src_path) | ||
|
|
||
| def get_otel_trace_id(self, request: HttpResponse): | ||
| # Paths filter intercepted OTLP export requests (weblog → proxy), not weblog or backend URLs. | ||
| paths = ["/api/v0.2/traces", "/v1/traces"] | ||
| rid = request.get_rid() | ||
|
|
||
| if rid: | ||
| logger.debug(f"Try to find traces related to request {rid}") | ||
|
|
||
| for data in self.get_data(path_filters=paths): | ||
| for resource_span in data.get("request").get("content").get("resourceSpans"): | ||
| for scope_span in resource_span.get("scopeSpans"): | ||
| content = data.get("request").get("content") | ||
| resource_spans = content.get("resourceSpans") or [] | ||
| for resource_span in resource_spans: | ||
| scope_spans = resource_span.get("scopeSpans") or [] | ||
| for scope_span in scope_spans: | ||
| for span in scope_span.get("spans", []): | ||
| attributes = span.get("attributes", {}) | ||
| request_headers_user_agent_value = attributes.get("http.request.headers.user-agent", "") | ||
| user_agent_value = attributes.get("http.useragent", "") | ||
| if rid in request_headers_user_agent_value or rid in user_agent_value: | ||
| yield span.get("trace_id") or span.get("traceId") | ||
|
|
||
| def get_otel_spans(self, request: HttpResponse): | ||
| paths = ["/api/v0.2/traces", "/v1/traces"] | ||
| rid = request.get_rid() | ||
|
|
||
| if rid: | ||
| logger.debug(f"Try to find traces related to request {rid}") | ||
|
|
||
| for data in self.get_data(path_filters=paths): | ||
| content = data.get("request").get("content") | ||
| logger.debug(f"[get_otel_spans] content: {content}") | ||
| resource_spans = content.get("resourceSpans") or [] | ||
| for resource_span in resource_spans: | ||
| scope_spans = resource_span.get("scopeSpans") | ||
| for scope_span in scope_spans: | ||
| for span in scope_span.get("spans"): | ||
| for attribute in span.get("attributes", []): | ||
| attr_key = attribute.get("key") | ||
| attr_val = attribute.get("value").get("stringValue") | ||
| if attr_key == "http.request.headers.user-agent" and rid in attr_val: | ||
| yield span.get("traceId") | ||
| attributes = span.get("attributes", {}) | ||
| request_headers_user_agent_value = attributes.get("http.request.headers.user-agent", "") | ||
| user_agent_value = attributes.get("http.useragent", "") | ||
| if rid in request_headers_user_agent_value or rid in user_agent_value: | ||
| yield data.get("request"), content, span | ||
| break # Skip to next span | ||
|
Comment on lines
+65
to
+66
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Dropping out of the span loop after the first RID match causes Useful? React with 👍 / 👎. |
||
Uh oh!
There was an error while loading. Please reload this page.