Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .github/workflows/run-end-to-end.yml
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,11 @@ jobs:
env:
DD_API_KEY: ${{ secrets.DD_API_KEY }}
DD_APP_KEY: ${{ secrets.DD_APPLICATION_KEY }}
- name: Run APM_TRACING_OTLP scenario
if: always() && steps.build.outcome == 'success' && contains(inputs.scenarios, '"APM_TRACING_OTLP"')
run: ./run.sh APM_TRACING_OTLP
env:
DD_API_KEY: ${{ secrets.DD_API_KEY }}
- name: Run APM_TRACING_EFFICIENT_PAYLOAD scenario
if: always() && steps.build.outcome == 'success' && contains(inputs.scenarios, '"APM_TRACING_EFFICIENT_PAYLOAD"')
run: ./run.sh APM_TRACING_EFFICIENT_PAYLOAD
Expand Down
1 change: 1 addition & 0 deletions manifests/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -755,6 +755,7 @@ manifest:
tests/k8s_lib_injection/test_k8s_lib_injection_appsec.py::TestK8sLibInjectionAppsecClusterEnabled: v3.36.0
tests/k8s_lib_injection/test_k8s_lib_injection_appsec.py::TestK8sLibInjectionAppsecDisabledByDefault: v3.36.0
tests/otel/test_context_propagation.py::Test_Otel_Context_Propagation_Default_Propagator_Api: v3.9.0
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/golang.yml
Original file line number Diff line number Diff line change
Expand Up @@ -981,6 +981,7 @@ manifest:
- weblog_declaration:
"*": incomplete_test_app (endpoint not implemented)
net-http: v1.70.1
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/java.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3465,6 +3465,7 @@ manifest:
spring-boot-openliberty: v1.58.2+06122213c8 # Modified by easy win activation script
uds-spring-boot: v1.58.2+06122213c8 # Modified by easy win activation script
spring-boot-payara: v1.58.2+06122213c8 # Modified by easy win activation script
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1845,6 +1845,7 @@ manifest:
fastify: *ref_5_26_0
tests/otel/test_context_propagation.py::Test_Otel_Context_Propagation_Default_Propagator_Api::test_propagation_extract: incomplete_test_app (Node.js extract endpoint doesn't seem to be working.)
tests/otel/test_context_propagation.py::Test_Otel_Context_Propagation_Default_Propagator_Api::test_propagation_inject: incomplete_test_app (Node.js inject endpoint doesn't seem to be working.)
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -615,6 +615,7 @@ manifest:
tests/k8s_lib_injection/test_k8s_lib_injection_profiling.py::TestK8sLibInjectioProfilingClusterOverride: v1.9.0
tests/k8s_lib_injection/test_k8s_lib_injection_profiling.py::TestK8sLibInjectioProfilingDisabledByDefault: v1.9.0
tests/otel/test_context_propagation.py::Test_Otel_Context_Propagation_Default_Propagator_Api: incomplete_test_app (endpoint not implemented)
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/python.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1503,6 +1503,7 @@ manifest:
flask-poc: v2.19.0
uds-flask: v4.3.1 # Modified by easy win activation script
uwsgi-poc: v4.3.1 # Modified by easy win activation script
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/ruby.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1228,6 +1228,7 @@ manifest:
"*": incomplete_test_app (endpoint not implemented)
rails72: v2.0.0
tests/otel/test_context_propagation.py::Test_Otel_Context_Propagation_Default_Propagator_Api::test_propagation_extract: incomplete_test_app (Ruby extract seems to fail even though it should be supported)
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
1 change: 1 addition & 0 deletions manifests/rust.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ manifest:
tests/integrations/test_mongo.py::Test_Mongo: missing_feature (Endpoint is not implemented on weblog)
tests/integrations/test_service_overrides.py::Test_SqlServiceNameSource: irrelevant (Only implemented for Java)
tests/integrations/test_sql.py::Test_Sql: missing_feature (Endpoint is not implemented on weblog)
tests/otel/test_tracing_otlp.py::Test_Otel_Tracing_OTLP: missing_feature
tests/otel_tracing_e2e/test_e2e.py::Test_OTelLogE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelMetricE2E: irrelevant
tests/otel_tracing_e2e/test_e2e.py::Test_OTelTracingE2E: irrelevant
Expand Down
151 changes: 151 additions & 0 deletions tests/otel/test_tracing_otlp.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# 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 time
import re
from utils import weblog, interfaces, scenarios, features
from utils.dd_constants import SpanKind, StatusCode
from typing import Any
from collections.abc import Iterator


def _snake_to_camel(snake_key: str) -> str:
parts = snake_key.split("_")
return parts[0].lower() + "".join(p.capitalize() for p in parts[1:])


def get_otlp_key(d: dict[str, Any] | None, snake_case_key: str, *, is_json: bool, default: Any = None) -> Any: # noqa: ANN401
"""Look up a field by its snake_case name when is_json is false, or its camelCase equivalent when is_json is true.
Fields must be camelCase for JSON Protobuf encoding. See https://opentelemetry.io/docs/specs/otlp/#json-protobuf-encoding
"""
if d is None:
return default
key = _snake_to_camel(snake_case_key) if is_json else snake_case_key
return d.get(key, default)


def get_keyvalue_generator(attributes: list[dict]) -> Iterator[tuple[str, Any]]:
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel that data should be properly deserialized by the proxy, WDYT ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer this approach where we add workarounds in order to access the request properly, because then we can still store the original contents of the request. And if there are any invalid fields that we try to assert against, it should be easy for the developer to debug this (the original request is saved to disk via the interface). Do you feel strongly that we should change this at the moment?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes I do :) Let me explain :

The philosophy for those files is to have data ready to tests, and easy to read for humans. The result is expected to be something clear and functional, expunged from any shenanigans made for transport purpose.

Those transformations are made by the proxy, as long as :

  • the original expected content format is respected
  • and the transformation is just data-to-data (no particular logic)

For the data you're working one, they are already deserialized, they are far away from the original content. But the job is not fully done. So for instance what is done for v1 traces : https://github.com/DataDog/system-tests/blob/main/utils/proxy/traces/trace_v1.py

And I can see many similarities with your code 😉

Now, the needs for having the real raw request content (maybe with only a very basic deserialization) is definitly something we should have, if ever we need to investigate on the raw data. It's quite easy to do, and I can work on that if you'd like to have it.

But for the json files, I'd like to keep the original philosophy, since it's a cornerstone to have tests easy to write, and easy not too hard debug sessions 😄

for key_value in attributes:
if key_value["value"].get("string_value"):
yield key_value["key"], key_value["value"]["string_value"]
elif key_value["value"].get("stringValue"):
yield key_value["key"], key_value["value"]["stringValue"]
elif key_value["value"].get("bool_value"):
yield key_value["key"], key_value["value"]["bool_value"]
elif key_value["value"].get("boolValue"):
yield key_value["key"], key_value["value"]["boolValue"]
elif key_value["value"].get("int_value"):
yield key_value["key"], key_value["value"]["int_value"]
elif key_value["value"].get("intValue"):
yield key_value["key"], key_value["value"]["intValue"]
elif key_value["value"].get("double_value"):
yield key_value["key"], key_value["value"]["double_value"]
elif key_value["value"].get("doubleValue"):
yield key_value["key"], key_value["value"]["doubleValue"]
elif key_value["value"].get("array_value"):
yield key_value["key"], key_value["value"]["array_value"]
elif key_value["value"].get("arrayValue"):
yield key_value["key"], key_value["value"]["arrayValue"]
elif key_value["value"].get("kvlist_value"):
yield key_value["key"], key_value["value"]["kvlist_value"]
elif key_value["value"].get("kvlistValue"):
yield key_value["key"], key_value["value"]["kvlistValue"]
elif key_value["value"].get("bytes_value"):
yield key_value["key"], key_value["value"]["bytes_value"]
elif key_value["value"].get("bytesValue"):
yield key_value["key"], key_value["value"]["bytesValue"]
else:
raise ValueError(f"Unknown attribute value: {key_value['value']}")


# @scenarios.apm_tracing_e2e_otel
@features.otel_api
@scenarios.apm_tracing_otlp
class Test_Otel_Tracing_OTLP:
def setup_single_server_trace(self):
self.start_time_ns = time.time_ns()
self.req = weblog.get("/")
self.end_time_ns = time.time_ns()

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 = get_otlp_key(content, "resource_spans", is_json=is_json)
expected_key = _snake_to_camel("resource_spans") if is_json else "resource_spans"
assert resource_spans is not None, f"missing '{expected_key}' on content: {content}"
assert len(resource_spans) == 1, f"expected 1 resource span, got {len(resource_spans)}"
resource_span = resource_spans[0]

attributes = {
key_value["key"]: get_otlp_key(key_value["value"], "string_value", is_json=is_json)
for key_value in 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(get_otlp_key(span, "start_time_unix_nano", is_json=is_json))
span_end_time_ns = int(get_otlp_key(span, "end_time_unix_nano", is_json=is_json))
assert span_start_time_ns >= self.start_time_ns
assert span_end_time_ns >= span_start_time_ns
assert span_end_time_ns <= self.end_time_ns

assert get_otlp_key(span, "name", is_json=is_json)
assert get_otlp_key(span, "kind", is_json=is_json) == SpanKind.SERVER.value
assert get_otlp_key(span, "attributes", is_json=is_json) is not None
assert (
get_otlp_key(span, "status", is_json=is_json) is None
or get_otlp_key(span, "status", is_json=is_json).get("code") == StatusCode.STATUS_CODE_UNSET.value
)

# Assert HTTP tags
# Convert attributes list to a dictionary, but for now only handle key_value objects with stringValue
span_attributes = dict(get_keyvalue_generator(get_otlp_key(span, "attributes", is_json=is_json)))
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}"
12 changes: 12 additions & 0 deletions utils/_context/_scenarios/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -578,6 +578,18 @@ class _Scenarios:
require_api_key=True,
doc="",
)
apm_tracing_otlp = EndToEndScenario(
"APM_TRACING_OTLP",
weblog_env={
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_EXPORTER_OTLP_TRACES_ENDPOINT": f"http://proxy:{ProxyPorts.open_telemetry_weblog}/v1/traces",
"OTEL_EXPORTER_OTLP_TRACES_HEADERS": "dd-protocol=otlp,dd-otlp-path=agent",
},
backend_interface_timeout=5,
require_api_key=True,
include_opentelemetry=True,
doc="",
)

apm_tracing_efficient_payload = EndToEndScenario(
"APM_TRACING_EFFICIENT_PAYLOAD",
Expand Down
21 changes: 20 additions & 1 deletion utils/_context/_scenarios/endtoend.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,7 @@ def __init__(
runtime_metrics_enabled: bool = False,
backend_interface_timeout: int = 0,
include_buddies: bool = False,
include_opentelemetry: bool = False,
require_api_key: bool = False,
other_weblog_containers: tuple[type[TestedContainer], ...] = (),
) -> None:
Expand Down Expand Up @@ -283,13 +284,15 @@ def __init__(
self.agent_interface_timeout = agent_interface_timeout
self.backend_interface_timeout = backend_interface_timeout
self._library_interface_timeout = library_interface_timeout
self.include_opentelemetry = include_opentelemetry

def configure(self, config: pytest.Config):
if self._require_api_key and "DD_API_KEY" not in os.environ and not self.replay:
pytest.exit("DD_API_KEY is required for this scenario", 1)

self.weblog_infra.configure(config)
self._set_containers_dependancies()
self.weblog_container.environment["DD_API_KEY"] = os.environ.get("DD_API_KEY")

super().configure(config)
interfaces.agent.configure(self.host_log_folder, replay=self.replay)
Expand All @@ -299,6 +302,9 @@ def configure(self, config: pytest.Config):
interfaces.library_stdout.configure(self.host_log_folder, replay=self.replay)
interfaces.agent_stdout.configure(self.host_log_folder, replay=self.replay)

if self.include_opentelemetry:
interfaces.open_telemetry.configure(self.host_log_folder, replay=self.replay)

for container in self.buddies:
container.interface.configure(self.host_log_folder, replay=self.replay)

Expand Down Expand Up @@ -360,7 +366,11 @@ def _get_weblog_system_info(self):

def _start_interfaces_watchdog(self):
super().start_interfaces_watchdog(
[interfaces.library, interfaces.agent] + [container.interface for container in self.buddies]
[interfaces.library, interfaces.agent]
+ [container.interface for container in self.buddies]
+ [interfaces.open_telemetry]
if self.include_opentelemetry
else []
)

def _set_weblog_domain(self):
Expand Down Expand Up @@ -420,6 +430,10 @@ def _wait_and_stop_containers(self, *, force_interface_timout_to_zero: bool):

interfaces.backend.load_data_from_logs()

if self.include_opentelemetry:
interfaces.open_telemetry.load_data_from_logs()
interfaces.open_telemetry.check_deserialization_errors()

else:
self._wait_interface(
interfaces.library, 0 if force_interface_timout_to_zero else self.library_interface_timeout
Expand All @@ -444,6 +458,11 @@ def _wait_and_stop_containers(self, *, force_interface_timout_to_zero: bool):
interfaces.backend, 0 if force_interface_timout_to_zero else self.backend_interface_timeout
)

if self.include_opentelemetry:
self._wait_interface(
interfaces.open_telemetry, 0 if force_interface_timout_to_zero else self.backend_interface_timeout
)

def _wait_interface(self, interface: ProxyBasedInterfaceValidator, timeout: int):
logger.terminal.write_sep("-", f"Wait for {interface} ({timeout}s)")
logger.terminal.flush()
Expand Down
8 changes: 8 additions & 0 deletions utils/dd_constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,18 @@ class SamplingMechanism(IntEnum):
AI_GUARD = 13


# See https://github.com/open-telemetry/opentelemetry-proto/blob/v1.9.0/opentelemetry/proto/trace/v1/trace.proto#L153
class SpanKind(IntEnum):
UNSPECIFIED = 0
INTERNAL = 1
SERVER = 2
CLIENT = 3
PRODUCER = 4
CONSUMER = 5


# See https://github.com/open-telemetry/opentelemetry-proto/blob/v1.9.0/opentelemetry/proto/trace/v1/trace.proto#L316
class StatusCode(IntEnum):
STATUS_CODE_UNSET = 0
STATUS_CODE_OK = 1
STATUS_CODE_ERROR = 2
Loading
Loading