diff --git a/tests/parametric/conftest.py b/tests/parametric/conftest.py index 8ce0af0c4c7..f94de4a29ea 100644 --- a/tests/parametric/conftest.py +++ b/tests/parametric/conftest.py @@ -8,11 +8,9 @@ import pytest import yaml -from utils.parametric._library_client import APMLibrary - from utils import scenarios, logger +from utils.docker_fixtures import TestAgentAPI, ParametricTestClientApi as APMLibrary -from utils.docker_fixtures import TestAgentAPI as _TestAgentAPI # Max timeout in seconds to keep a container running default_subprocess_run_timeout = 300 @@ -77,7 +75,7 @@ def test_agent( request: pytest.FixtureRequest, test_agent_otlp_http_port: int, test_agent_otlp_grpc_port: int, -) -> Generator[_TestAgentAPI, None, None]: +) -> Generator[TestAgentAPI, None, None]: with scenarios.parametric.get_test_agent_api( request=request, worker_id=worker_id, @@ -93,7 +91,7 @@ def test_library( worker_id: str, request: pytest.FixtureRequest, test_id: str, - test_agent: _TestAgentAPI, + test_agent: TestAgentAPI, library_env: dict[str, str], library_extra_command_arguments: list[str], ) -> Generator[APMLibrary, None, None]: diff --git a/tests/parametric/test_crashtracking.py b/tests/parametric/test_crashtracking.py index e1cc592fafd..d53697bb5cb 100644 --- a/tests/parametric/test_crashtracking.py +++ b/tests/parametric/test_crashtracking.py @@ -5,8 +5,7 @@ import pytest from utils import bug, features, scenarios, logger -from utils.parametric._library_client import APMLibrary -from utils.docker_fixtures import TestAgentAPI +from utils.docker_fixtures import TestAgentAPI, ParametricTestClientApi as APMLibrary @scenarios.parametric @@ -44,7 +43,7 @@ def test_telemetry_timeout(self, test_agent: TestAgentAPI, test_library: APMLibr try: # container.wait will throw if the application doesn't exit in time - test_library._client.container.wait(timeout=10) # noqa: SLF001 + test_library.container.wait(timeout=10) finally: test_agent.set_trace_delay(0) diff --git a/tests/parametric/test_feature_flag_exposure/test_feature_flag_exposure.py b/tests/parametric/test_feature_flag_exposure/test_feature_flag_exposure.py index 550a014d087..70e6597130d 100644 --- a/tests/parametric/test_feature_flag_exposure/test_feature_flag_exposure.py +++ b/tests/parametric/test_feature_flag_exposure/test_feature_flag_exposure.py @@ -10,7 +10,8 @@ scenarios, ) from utils.dd_constants import RemoteConfigApplyState -from tests.parametric.conftest import _TestAgentAPI, APMLibrary +from utils.docker_fixtures import TestAgentAPI +from tests.parametric.conftest import APMLibrary RC_PRODUCT = "FFE_FLAGS" RC_PATH = f"datadog/2/{RC_PRODUCT}" @@ -51,7 +52,7 @@ def _get_test_case_files() -> list[str]: def _set_and_wait_ffe_rc( - test_agent: _TestAgentAPI, ufc_data: dict[str, Any], config_id: str | None = None + test_agent: TestAgentAPI, ufc_data: dict[str, Any], config_id: str | None = None ) -> dict[str, Any]: """Set FFE Remote Config and wait for it to be acknowledged. @@ -89,7 +90,7 @@ class Test_Feature_Flag_Exposure: @parametrize("library_env", [{**DEFAULT_ENVVARS}]) def test_ffe_remote_config( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """Test to verify FFE can receive and acknowledge UFC configurations via Remote Config.""" @@ -100,7 +101,7 @@ def test_ffe_remote_config( @parametrize("library_env", [{**DEFAULT_ENVVARS}]) @parametrize("test_case_file", ALL_TEST_CASE_FILES) def test_ffe_flag_evaluation( - self, library_env: dict[str, str], test_case_file: str, test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_case_file: str, test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """Test FFE flag evaluation logic with various targeting scenarios. diff --git a/tests/parametric/test_headers_b3.py b/tests/parametric/test_headers_b3.py index db54decdc53..c29b2613d6e 100644 --- a/tests/parametric/test_headers_b3.py +++ b/tests/parametric/test_headers_b3.py @@ -3,8 +3,7 @@ from utils.parametric.spec.trace import SAMPLING_PRIORITY_KEY, ORIGIN from utils.parametric.spec.trace import span_has_no_parent from utils.parametric.spec.trace import find_only_span -from utils.docker_fixtures import TestAgentAPI -from utils.parametric._library_client import APMLibrary +from utils.docker_fixtures import TestAgentAPI, ParametricTestClientApi as APMLibrary from utils import missing_feature, context, scenarios, features, irrelevant, logger parametrize = pytest.mark.parametrize diff --git a/tests/parametric/test_otel_api_interoperability.py b/tests/parametric/test_otel_api_interoperability.py index eb0f4fc8c21..97132825ea6 100644 --- a/tests/parametric/test_otel_api_interoperability.py +++ b/tests/parametric/test_otel_api_interoperability.py @@ -3,8 +3,7 @@ from utils import scenarios, features from opentelemetry.trace import SpanKind from utils.parametric.spec.trace import find_trace, find_span, retrieve_span_links, find_only_span, find_root_span -from utils.parametric._library_client import APMLibrary -from utils.docker_fixtures import TestAgentAPI +from utils.docker_fixtures import TestAgentAPI, ParametricTestClientApi as APMLibrary # this global mark applies to all tests in this file. diff --git a/tests/parametric/test_parametric_endpoints.py b/tests/parametric/test_parametric_endpoints.py index 9c3ea97f559..f9f9854c810 100644 --- a/tests/parametric/test_parametric_endpoints.py +++ b/tests/parametric/test_parametric_endpoints.py @@ -18,8 +18,8 @@ from utils import irrelevant, bug, incomplete_test_app, scenarios, features, context from opentelemetry.trace import SpanKind from opentelemetry.trace import StatusCode -from utils.parametric._library_client import APMLibrary, Link, LogLevel -from utils.docker_fixtures import TestAgentAPI +from utils.parametric._library_client import Link, LogLevel +from utils.docker_fixtures import TestAgentAPI, ParametricTestClientApi as APMLibrary # this global mark applies to all tests in this file. # DD_TRACE_OTEL_ENABLED=true is required in the tracers to enable OTel diff --git a/tests/parametric/test_telemetry.py b/tests/parametric/test_telemetry.py index 92340ce40f5..c309ba70027 100644 --- a/tests/parametric/test_telemetry.py +++ b/tests/parametric/test_telemetry.py @@ -7,7 +7,7 @@ import pytest -from .conftest import StableConfigWriter, _TestAgentAPI +from .conftest import StableConfigWriter from utils.telemetry_utils import TelemetryUtils from utils import context, scenarios, rfc, features, missing_feature, irrelevant, logger, bug @@ -1166,7 +1166,7 @@ class Test_TelemetrySCAEnvVar: ) @missing_feature(context.library <= "python@2.16.0", reason="Converts boolean values to strings") def test_telemetry_sca_enabled_propagated( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary, *, outcome_value: bool + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary, *, outcome_value: bool ): self._assert_telemetry_sca_enabled_propagated( library_env, @@ -1187,7 +1187,7 @@ def test_telemetry_sca_enabled_propagated( @missing_feature(context.library <= "python@2.16.0", reason="Converts boolean values to strings") @irrelevant(context.library not in ("python", "golang")) def test_telemetry_sca_enabled_propagated_specifics( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary, *, outcome_value: bool + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary, *, outcome_value: bool ): self._assert_telemetry_sca_enabled_propagated( library_env, @@ -1197,7 +1197,7 @@ def test_telemetry_sca_enabled_propagated_specifics( ) def _assert_telemetry_sca_enabled_propagated( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary, *, outcome_value: bool + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary, *, outcome_value: bool ): configuration_by_name = test_agent.wait_for_telemetry_configurations() dd_appsec_sca_enabled = TelemetryUtils.get_dd_appsec_sca_enabled_str(context.library) diff --git a/tests/parametric/test_tracer.py b/tests/parametric/test_tracer.py index 4f2e9d78e8d..7dfcb5fb8d0 100644 --- a/tests/parametric/test_tracer.py +++ b/tests/parametric/test_tracer.py @@ -5,8 +5,8 @@ from utils.parametric.spec.trace import find_first_span_in_trace_payload from utils.parametric.spec.trace import find_root_span from utils import missing_feature, context, rfc, scenarios, features +from utils.docker_fixtures import TestAgentAPI -from .conftest import _TestAgentAPI from .conftest import APMLibrary @@ -18,7 +18,7 @@ class Test_Tracer: @missing_feature(context.library == "cpp", reason="metrics cannot be set manually") @missing_feature(context.library == "nodejs", reason="nodejs overrides the manually set service name") - def test_tracer_span_top_level_attributes(self, test_agent: _TestAgentAPI, test_library: APMLibrary) -> None: + def test_tracer_span_top_level_attributes(self, test_agent: TestAgentAPI, test_library: APMLibrary) -> None: """Do a simple trace to ensure that the test client is working properly.""" with ( test_library, @@ -51,7 +51,7 @@ def test_tracer_span_top_level_attributes(self, test_agent: _TestAgentAPI, test_ class Test_TracerSCITagging: @parametrize("library_env", [{"DD_GIT_REPOSITORY_URL": "https://github.com/DataDog/dd-trace-go"}]) def test_tracer_repository_url_environment_variable( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """When DD_GIT_REPOSITORY_URL is specified When a trace chunk is emitted @@ -78,7 +78,7 @@ def test_tracer_repository_url_environment_variable( @parametrize("library_env", [{"DD_GIT_COMMIT_SHA": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]) def test_tracer_commit_sha_environment_variable( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """When DD_GIT_COMMIT_SHA is specified When a trace chunk is emitted @@ -139,7 +139,7 @@ def test_tracer_commit_sha_environment_variable( ) @missing_feature(context.library == "nodejs", reason="nodejs does not strip credentials yet") def test_tracer_repository_url_strip_credentials( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """When DD_GIT_REPOSITORY_URL is specified When a trace chunk is emitted @@ -166,7 +166,7 @@ class Test_TracerUniversalServiceTagging: @missing_feature(reason="FIXME: library test client sets empty string as the service name") @parametrize("library_env", [{"DD_SERVICE": "service1"}]) def test_tracer_service_name_environment_variable( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """When DD_SERVICE is specified When a span is created @@ -184,7 +184,7 @@ def test_tracer_service_name_environment_variable( @parametrize("library_env", [{"DD_ENV": "prod"}]) def test_tracer_env_environment_variable( - self, library_env: dict[str, str], test_agent: _TestAgentAPI, test_library: APMLibrary + self, library_env: dict[str, str], test_agent: TestAgentAPI, test_library: APMLibrary ) -> None: """When DD_ENV is specified When a span is created diff --git a/utils/_context/_scenarios/parametric.py b/utils/_context/_scenarios/parametric.py index b8407c09275..530fb984afd 100644 --- a/utils/_context/_scenarios/parametric.py +++ b/utils/_context/_scenarios/parametric.py @@ -15,8 +15,8 @@ TestAgentAPI, compute_volumes, ParametricTestClientFactory, + ParametricTestClientApi, ) -from utils.parametric._library_client import APMLibrary from .core import scenario_groups from ._docker_fixtures import DockerFixturesScenario @@ -159,7 +159,7 @@ def get_apm_library( test_agent: TestAgentAPI, library_env: dict, library_extra_command_arguments: list[str], - ) -> Generator[APMLibrary, None, None]: + ) -> Generator[ParametricTestClientApi, None, None]: log_path = f"{self.host_log_folder}/outputs/{request.cls.__name__}/{request.node.name}/server_log.log" Path(log_path).parent.mkdir(parents=True, exist_ok=True) diff --git a/utils/docker_fixtures/__init__.py b/utils/docker_fixtures/__init__.py index 41df9fc6115..98b21ced927 100644 --- a/utils/docker_fixtures/__init__.py +++ b/utils/docker_fixtures/__init__.py @@ -1,11 +1,12 @@ from ._core import get_host_port, docker_run, compute_volumes from ._test_agent import TestAgentAPI, TestAgentFactory -from ._test_client_parametric import ParametricTestClientFactory +from ._test_client_parametric import ParametricTestClientFactory, ParametricTestClientApi from ._test_client_framework_integrations import FrameworkTestClientApi, FrameworkTestClientFactory __all__ = [ "FrameworkTestClientApi", "FrameworkTestClientFactory", + "ParametricTestClientApi", "ParametricTestClientFactory", "TestAgentAPI", "TestAgentFactory", diff --git a/utils/docker_fixtures/_test_agent.py b/utils/docker_fixtures/_test_agent.py index 4853c125cee..755b023461b 100644 --- a/utils/docker_fixtures/_test_agent.py +++ b/utils/docker_fixtures/_test_agent.py @@ -45,7 +45,11 @@ class AgentRequestV06Stats(AgentRequest): class TestAgentFactory: - """Handle everything to create the TestAgentApi""" + """Handle everything to create the TestAgentApi + This class is responsible to: + * build the image + * expose a ready to call function that runs the container and returns the client that will be used in tests + """ def __init__(self, image: str): self.image = image @@ -156,6 +160,8 @@ def get_test_agent_api( class TestAgentAPI: + """API to interact with the test agent server running in a docker container.""" + __test__ = False # pytest must not collect it def __init__( diff --git a/utils/docker_fixtures/_test_client_framework_integrations.py b/utils/docker_fixtures/_test_client_framework_integrations.py index d8c1129c9de..ae8f073972d 100644 --- a/utils/docker_fixtures/_test_client_framework_integrations.py +++ b/utils/docker_fixtures/_test_client_framework_integrations.py @@ -93,6 +93,10 @@ def get_client( class FrameworkTestClientApi: + """API to interact with the tracer+framework server running in a docker container for + INTEGRATIONS_FRAMEWORK scenarios. + """ + def __init__(self, url: str, timeout: int, container: Container): self._base_url = url self._session = requests.Session() diff --git a/utils/docker_fixtures/_test_client_parametric.py b/utils/docker_fixtures/_test_client_parametric.py index e3151b3485e..56d4cc0a470 100644 --- a/utils/docker_fixtures/_test_client_parametric.py +++ b/utils/docker_fixtures/_test_client_parametric.py @@ -2,7 +2,7 @@ import contextlib from typing import TextIO -from utils.parametric._library_client import APMLibrary, APMLibraryClient +from utils.parametric._library_client import ParametricTestClientApi from ._core import get_host_port, docker_run @@ -11,6 +11,12 @@ class ParametricTestClientFactory(TestClientFactory): + """Abstracts the docker image/container that ship the tested tracer for PARAMETRIC scenario. + # This class is responsible to: + # * build the image + # * expose a ready to call function that runs the container and returns the client that will be used in tests + """ + @contextlib.contextmanager def get_apm_library( self, @@ -20,7 +26,7 @@ def get_apm_library( library_env: dict, library_extra_command_arguments: list[str], test_server_log_file: TextIO, - ) -> Generator["APMLibrary", None, None]: + ) -> Generator["ParametricTestClientApi", None, None]: host_port = get_host_port(worker_id, 4500) container_port = 8080 @@ -62,12 +68,11 @@ def get_apm_library( ) as container: test_server_timeout = 60 - client = APMLibraryClient( + client = ParametricTestClientApi( self.library, f"http://localhost:{host_port}", test_server_timeout, container, ) - tracer = APMLibrary(client, self.library) - yield tracer + yield client diff --git a/utils/parametric/_library_client.py b/utils/parametric/_library_client.py index b57060a54df..db3e5483aca 100644 --- a/utils/parametric/_library_client.py +++ b/utils/parametric/_library_client.py @@ -53,9 +53,96 @@ class Event(TypedDict): attributes: dict -class APMLibraryClient: +class _TestSpan: + def __init__(self, client: "ParametricTestClientApi", span_id: int, trace_id: int): + self._client = client + self.span_id = span_id + self.trace_id = trace_id + + def set_resource(self, resource: str): + self._client.span_set_resource(self.span_id, resource) + + def set_meta(self, key: str, val: str | bool | list[str | list[str]] | None): # noqa: FBT001 + self._client.span_set_meta(self.span_id, key, value=val) + + def set_metric(self, key: str, val: float | list[int] | None): + self._client.span_set_metric(self.span_id, key, val) + + def set_baggage(self, key: str, val: str): + self._client.span_set_baggage(self.span_id, key, val) + + def get_baggage(self, key: str): + return self._client.span_get_baggage(self.span_id, key) + + def get_all_baggage(self): + return self._client.span_get_all_baggage(self.span_id) + + def remove_baggage(self, key: str): + self._client.span_remove_baggage(self.span_id, key) + + def remove_all_baggage(self): + self._client.span_remove_all_baggage(self.span_id) + + def set_error(self, typestr: str = "", message: str = "", stack: str = ""): + self._client.span_set_error(self.span_id, typestr, message, stack) + + def add_link(self, parent_id: int, attributes: dict | None = None): + self._client.span_add_link(self.span_id, parent_id, attributes) + + def add_event(self, name: str, time_unix_nano: int, attributes: dict | None = None): + self._client.span_add_event(self.span_id, name, time_unix_nano, attributes) + + def finish(self): + self._client.finish_span(self.span_id) + + +class _TestOtelSpan: + def __init__(self, client: "ParametricTestClientApi", span_id: int, trace_id: int): + self._client = client + self.span_id = span_id + self.trace_id = trace_id + + # API methods + + def set_attributes(self, attributes: dict): + self._client.otel_set_attributes(self.span_id, attributes) + + def set_attribute(self, key: str, value: str | float | list[str | float | list[str]] | None): + self._client.otel_set_attributes(self.span_id, {key: value}) + + def set_name(self, name: str): + self._client.otel_set_name(self.span_id, name) + + def set_status(self, code: StatusCode, description: str): + self._client.otel_set_status(self.span_id, code, description) + + def add_event(self, name: str, timestamp: int | None = None, attributes: dict | None = None): + self._client.otel_add_event(self.span_id, name, timestamp, attributes) + + def record_exception(self, message: str, attributes: dict | None = None): + self._client.otel_record_exception(self.span_id, message, attributes) + + def end_span(self, timestamp: int | None = None): + self._client.otel_end_span(self.span_id, timestamp) + + def is_recording(self) -> bool: + return self._client.otel_is_recording(self.span_id) + + def span_context(self) -> OtelSpanContext: + return self._client.otel_get_span_context(self.span_id) + + def set_baggage(self, key: str, value: str): + self._client.otel_set_baggage(self.span_id, key, value) + + +class ParametricTestClientApi: + """API to interact with the tracer+framework server running in a docker container for + PARAMETRIC scenarios. + """ + def __init__(self, library: str, url: str, timeout: int, container: Container): self.library = library + self.lang = library # TODO remove self._base_url = url self._session = requests.Session() self.container = container @@ -64,11 +151,29 @@ def __init__(self, library: str, url: str, timeout: int, container: Container): # wait for server to start self._wait(timeout) + def __enter__(self) -> "ParametricTestClientApi": + return self + + def __exit__( + self, exc_type: type[BaseException] | None, exc_val: BaseException | None, exc_tb: TracebackType | None + ) -> bool | None: + # Only attempt a flush if there was no exception raised. + if exc_type is None: + self.dd_flush() + if self.lang != "cpp": + # C++ does not have an otel/flush endpoint + self.otel_flush(1) + # Logs flush endpoint is not implemented in all parametric apps + # TODO: otel_flush should return False if the logs flush fails + self.otel_logs_flush() + + return None + def _wait(self, timeout: float): delay = 0.01 for _ in range(int(timeout / delay)): try: - if self.is_alive(): + if self._is_alive(): break except Exception: if self.container.status != "running": @@ -85,7 +190,7 @@ def container_restart(self): self.container.restart() self._wait(self.timeout) - def is_alive(self) -> bool: + def _is_alive(self) -> bool: self.container.reload() return ( self.container.status == "running" @@ -93,6 +198,12 @@ def is_alive(self) -> bool: == HTTPStatus.NOT_FOUND ) + def is_alive(self) -> bool: + try: + return self._is_alive() + except Exception: + return False + def _print_logs(self): try: logs = self.container.logs().decode("utf-8") @@ -149,7 +260,8 @@ def container_exec_run(self, command: str) -> tuple[bool, str]: return success, message - def trace_start_span( + @contextlib.contextmanager + def dd_start_span( self, name: str, service: str | None = None, @@ -157,7 +269,7 @@ def trace_start_span( parent_id: str | int | None = None, typestr: str | None = None, tags: list[tuple[str, str]] | None = None, - ): + ) -> Generator[_TestSpan, None, None]: if self.library == "cpp": # TODO: Update the cpp parametric app to accept null values for unset parameters service = service or "" @@ -181,7 +293,17 @@ def trace_start_span( raise pytest.fail(f"Failed to start span: {resp.text}", pytrace=False) resp_json = resp.json() - return StartSpanResponse(span_id=resp_json["span_id"], trace_id=resp_json["trace_id"]) + span_response = StartSpanResponse(span_id=resp_json["span_id"], trace_id=resp_json["trace_id"]) + + span = _TestSpan(self, span_response["span_id"], span_response["trace_id"]) + yield span + span.finish() + + def dd_current_span(self) -> _TestSpan | None: + resp = self.current_span() + if resp is None: + return None + return _TestSpan(self, resp["span_id"], resp["trace_id"]) def current_span(self) -> SpanResponse | None: resp_json = self._session.get(self._url("/trace/span/current")).json() @@ -264,20 +386,29 @@ def span_get_all_baggage(self, span_id: int) -> dict: data = resp.json() return data["baggage"] - def trace_inject_headers(self, span_id: int): + def dd_extract_headers_and_make_child_span(self, name: str, http_headers: Iterable[tuple[str, str]]): + parent_id = self.dd_extract_headers(http_headers=http_headers) + return self.dd_start_span(name=name, parent_id=parent_id) + + def dd_make_child_span_and_get_headers(self, headers: Iterable[tuple[str, str]]) -> dict[str, str]: + with self.dd_extract_headers_and_make_child_span("name", headers) as span: + headers = self.dd_inject_headers(span.span_id) + return {k.lower(): v for k, v in headers} + + def dd_inject_headers(self, span_id: int): resp = self._session.post(self._url("/trace/span/inject_headers"), json={"span_id": span_id}) # TODO: translate json into list within list # so server.xx do not have to return resp.json()["http_headers"] - def trace_extract_headers(self, http_headers: Iterable[tuple[str, str]]): + def dd_extract_headers(self, http_headers: Iterable[tuple[str, str]]): resp = self._session.post( self._url("/trace/span/extract_headers"), json={"http_headers": http_headers}, ) return resp.json()["span_id"] - def trace_flush(self) -> bool: + def dd_flush(self) -> bool: r = self._session.post(self._url("/trace/span/flush"), json={}) if not HTTPStatus(r.status_code).is_success: @@ -315,7 +446,7 @@ def write_log( ) return HTTPStatus(resp.status_code).is_success - def otel_logs_flush(self, timeout_sec: int) -> tuple[bool, str]: + def otel_logs_flush(self, timeout_sec: int = 3) -> tuple[bool, str]: """Flush all OpenTelemetry logs and get provider information. Returns: @@ -334,7 +465,34 @@ def otel_logs_flush(self, timeout_sec: int) -> tuple[bool, str]: except Exception as e: return False, f"Error: {e!s}" - def otel_trace_start_span( + @contextlib.contextmanager + def otel_start_span( + self, + name: str, + timestamp: int | None = None, + span_kind: SpanKind | None = None, + parent_id: int | None = None, + links: list[Link] | None = None, + events: list[Event] | None = None, + attributes: dict | None = None, + *, + end_on_exit: bool = True, + ) -> Generator[_TestOtelSpan, None, None]: + resp = self._otel_trace_start_span( + name=name, + timestamp=timestamp, + span_kind=span_kind, + parent_id=parent_id, + links=links, + events=events if events is not None else [], + attributes=attributes, + ) + span = _TestOtelSpan(self, resp["span_id"], resp["trace_id"]) + yield span + if end_on_exit: + span.end_span() + + def _otel_trace_start_span( self, name: str, timestamp: int | None, @@ -412,11 +570,11 @@ def otel_get_span_context(self, span_id: int) -> OtelSpanContext: remote=resp["remote"], ) - def otel_flush(self, timeout: int) -> bool: - resp = self._session.post(self._url("/trace/otel/flush"), json={"seconds": timeout}).json() + def otel_flush(self, timeout_sec: int) -> bool: + resp = self._session.post(self._url("/trace/otel/flush"), json={"seconds": timeout_sec}).json() return resp["success"] - def otel_set_baggage(self, span_id: int, key: str, value: str) -> None: + def otel_set_baggage(self, span_id: int, key: str, value: str): resp = self._session.post( self._url("/trace/otel/otel_set_baggage"), json={"span_id": span_id, "key": key, "value": value}, @@ -449,13 +607,18 @@ def config(self) -> dict[str, str | None]: "dd_data_streams_enabled": config_dict.get("dd_data_streams_enabled", None), } - def otel_current_span(self) -> SpanResponse | None: + def otel_current_span(self) -> _TestOtelSpan | None: resp = self._session.get(self._url("/trace/otel/current_span"), json={}) if not resp: return None resp_json = resp.json() - return SpanResponse(span_id=resp_json["span_id"], trace_id=resp_json["trace_id"]) + span_response = SpanResponse(span_id=resp_json["span_id"], trace_id=resp_json["trace_id"]) + + if span_response is None: + return None + + return _TestOtelSpan(self, span_response["span_id"], span_response["trace_id"]) def ffe_start(self) -> bool: """Initialize the FFE (Feature Flag Exposure) provider. @@ -643,90 +806,8 @@ def otel_metrics_force_flush(self) -> bool: return resp["success"] -class _TestSpan: - def __init__(self, client: APMLibraryClient, span_id: int, trace_id: int): - self._client = client - self.span_id = span_id - self.trace_id = trace_id - - def set_resource(self, resource: str): - self._client.span_set_resource(self.span_id, resource) - - def set_meta(self, key: str, val: str | bool | list[str | list[str]] | None): # noqa: FBT001 - self._client.span_set_meta(self.span_id, key, value=val) - - def set_metric(self, key: str, val: float | list[int] | None): - self._client.span_set_metric(self.span_id, key, val) - - def set_baggage(self, key: str, val: str): - self._client.span_set_baggage(self.span_id, key, val) - - def get_baggage(self, key: str): - return self._client.span_get_baggage(self.span_id, key) - - def get_all_baggage(self): - return self._client.span_get_all_baggage(self.span_id) - - def remove_baggage(self, key: str): - self._client.span_remove_baggage(self.span_id, key) - - def remove_all_baggage(self): - self._client.span_remove_all_baggage(self.span_id) - - def set_error(self, typestr: str = "", message: str = "", stack: str = ""): - self._client.span_set_error(self.span_id, typestr, message, stack) - - def add_link(self, parent_id: int, attributes: dict | None = None): - self._client.span_add_link(self.span_id, parent_id, attributes) - - def add_event(self, name: str, time_unix_nano: int, attributes: dict | None = None): - self._client.span_add_event(self.span_id, name, time_unix_nano, attributes) - - def finish(self): - self._client.finish_span(self.span_id) - - -class _TestOtelSpan: - def __init__(self, client: APMLibraryClient, span_id: int, trace_id: int): - self._client = client - self.span_id = span_id - self.trace_id = trace_id - - # API methods - - def set_attributes(self, attributes: dict): - self._client.otel_set_attributes(self.span_id, attributes) - - def set_attribute(self, key: str, value: str | float | list[str | float | list[str]] | None): - self._client.otel_set_attributes(self.span_id, {key: value}) - - def set_name(self, name: str): - self._client.otel_set_name(self.span_id, name) - - def set_status(self, code: StatusCode, description: str): - self._client.otel_set_status(self.span_id, code, description) - - def add_event(self, name: str, timestamp: int | None = None, attributes: dict | None = None): - self._client.otel_add_event(self.span_id, name, timestamp, attributes) - - def record_exception(self, message: str, attributes: dict | None = None): - self._client.otel_record_exception(self.span_id, message, attributes) - - def end_span(self, timestamp: int | None = None): - self._client.otel_end_span(self.span_id, timestamp) - - def is_recording(self) -> bool: - return self._client.otel_is_recording(self.span_id) - - def span_context(self) -> OtelSpanContext: - return self._client.otel_get_span_context(self.span_id) - - def set_baggage(self, key: str, value: str): - self._client.otel_set_baggage(self.span_id, key, value) - - class APMLibrary: - def __init__(self, client: APMLibraryClient, lang: str): + def __init__(self, client: ParametricTestClientApi, lang: str): self._client = client self.lang = lang @@ -770,26 +851,21 @@ def dd_start_span( typestr: str | None = None, tags: list[tuple[str, str]] | None = None, ) -> Generator[_TestSpan, None, None]: - resp = self._client.trace_start_span( + with self._client.dd_start_span( name=name, service=service, resource=resource, parent_id=parent_id, typestr=typestr, tags=tags, - ) - span = _TestSpan(self._client, resp["span_id"], resp["trace_id"]) - yield span - span.finish() + ) as resp: + yield resp def dd_extract_headers_and_make_child_span(self, name: str, http_headers: Iterable[tuple[str, str]]): - parent_id = self.dd_extract_headers(http_headers=http_headers) - return self.dd_start_span(name=name, parent_id=parent_id) + return self._client.dd_extract_headers_and_make_child_span(name, http_headers) def dd_make_child_span_and_get_headers(self, headers: Iterable[tuple[str, str]]) -> dict[str, str]: - with self.dd_extract_headers_and_make_child_span("name", headers) as span: - headers = self.dd_inject_headers(span.span_id) - return {k.lower(): v for k, v in headers} + return self._client.dd_make_child_span_and_get_headers(headers) @contextlib.contextmanager def otel_start_span( @@ -804,37 +880,35 @@ def otel_start_span( *, end_on_exit: bool = True, ) -> Generator[_TestOtelSpan, None, None]: - resp = self._client.otel_trace_start_span( + with self._client.otel_start_span( name=name, timestamp=timestamp, span_kind=span_kind, parent_id=parent_id, links=links, - events=events if events is not None else [], + events=events, attributes=attributes, - ) - span = _TestOtelSpan(self._client, resp["span_id"], resp["trace_id"]) - yield span - if end_on_exit: - span.end_span() + end_on_exit=end_on_exit, + ) as span: + yield span def dd_flush(self) -> bool: - return self._client.trace_flush() + return self._client.dd_flush() def otel_flush(self, timeout_sec: int) -> bool: return self._client.otel_flush(timeout_sec) - def otel_logs_flush(self, timeout_sec: int = 3) -> bool: - return self._client.otel_logs_flush(timeout_sec)[0] + def otel_logs_flush(self, timeout_sec: int = 3) -> tuple[bool, str]: + return self._client.otel_logs_flush(timeout_sec) def otel_is_recording(self, span_id: int) -> bool: return self._client.otel_is_recording(span_id) def dd_inject_headers(self, span_id: int) -> list[tuple[str, str]]: - return self._client.trace_inject_headers(span_id) + return self._client.dd_inject_headers(span_id) def dd_extract_headers(self, http_headers: Iterable[tuple[str, str]]) -> int: - return self._client.trace_extract_headers(http_headers) + return self._client.dd_extract_headers(http_headers) def otel_set_baggage(self, span_id: int, key: str, value: str): return self._client.otel_set_baggage(span_id, key, value) @@ -843,16 +917,10 @@ def config(self) -> dict[str, str | None]: return self._client.config() def dd_current_span(self) -> _TestSpan | None: - resp = self._client.current_span() - if resp is None: - return None - return _TestSpan(self._client, resp["span_id"], resp["trace_id"]) + return self._client.dd_current_span() def otel_current_span(self) -> _TestOtelSpan | None: - resp = self._client.otel_current_span() - if resp is None: - return None - return _TestOtelSpan(self._client, resp["span_id"], resp["trace_id"]) + return self._client.otel_current_span() def otel_get_meter( self, name: str, version: str | None = None, schema_url: str | None = None, attributes: dict | None = None @@ -916,10 +984,7 @@ def otel_metrics_force_flush(self) -> bool: return self._client.otel_metrics_force_flush() def is_alive(self) -> bool: - try: - return self._client.is_alive() - except Exception: - return False + return self._client.is_alive() def write_log( self, message: str, level: LogLevel, logger_name: str = "test_logger", logger_type: int = 0, span_id: int = 0 @@ -947,3 +1012,7 @@ def ffe_evaluate( targeting_key=targeting_key, attributes=attributes, ) + + @property + def container(self) -> Container: + return self._client.container