-
Notifications
You must be signed in to change notification settings - Fork 101
LCORE-1822: Enable OpenTelemetry delivery E2E test with mock OTLP collector #2609
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
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 |
|---|---|---|
|
|
@@ -39,10 +39,23 @@ | |
| run_e2e_ops, | ||
| ) | ||
| from tests.e2e.utils.utils import ( | ||
| force_recreate_compose_service, | ||
| is_prow_environment, | ||
| remove_config_backup, | ||
| restart_container, | ||
| switch_config, | ||
| wait_for_container_health, | ||
| wait_for_lightspeed_stack_http_ready, | ||
| ) | ||
|
|
||
| # OTEL exporter env vars set by the OpenTelemetry feature's steps. They are | ||
| # reverted after the feature so later scenarios do not inherit OTEL export. | ||
| _OTEL_FEATURE_TAG = "OTel" | ||
|
Contributor
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. remove this, the tags are never meant to hide any kind of logic behind them |
||
| _OTEL_ENV_VARS = ( | ||
| "OTEL_SDK_DISABLED", | ||
| "OTEL_EXPORTER_OTLP_ENDPOINT", | ||
| "OTEL_EXPORTER_OTLP_PROTOCOL", | ||
| "OTEL_SERVICE_NAME", | ||
| ) | ||
|
|
||
| FALLBACK_MODEL = "gpt-4o-mini" | ||
|
|
@@ -498,14 +511,42 @@ def _restore_config_after_feature_enabled() -> bool: | |
| } | ||
|
|
||
|
|
||
| def _teardown_otel_export(context: Context) -> None: | ||
| """Revert the OTEL exporter configuration after the OpenTelemetry feature. | ||
|
|
||
| The OpenTelemetry feature sets ``OTEL_*`` env vars and force-recreates | ||
| ``lightspeed-stack`` so it exports telemetry. Those vars were originally | ||
| unset, so popping them restores the Compose defaults. The container is then | ||
| force-recreated (a plain ``docker restart`` would keep the OTEL-enabled | ||
| creation-time env), so later scenarios do not keep exporting to the mock | ||
| collector. No-op on Prow, where the feature is skipped and Compose is | ||
| unavailable. | ||
| """ | ||
| if is_prow_environment(): | ||
| return | ||
| for var in _OTEL_ENV_VARS: | ||
| os.environ.pop(var, None) | ||
| force_recreate_compose_service( | ||
|
Contributor
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. why do we actually need to disable this? I see no relevant reason in having to recreate the whole container just to get rid of the env variables |
||
| "lightspeed-stack", is_library_mode=context.is_library_mode | ||
| ) | ||
| wait_for_container_health("lightspeed-stack") | ||
| wait_for_lightspeed_stack_http_ready() | ||
|
|
||
|
|
||
| def after_feature(context: Context, feature: Feature) -> None: | ||
| """Run after each feature file is exercised. | ||
|
|
||
| Perform feature-level teardown: restore bootstrap configuration when | ||
| Perform feature-level teardown: revert OTEL export configuration for the | ||
| OpenTelemetry feature; restore bootstrap configuration when | ||
| ``E2E_RESTORE_CONFIG_AFTER_FEATURE=1``, otherwise keep the active config; | ||
| when ``context.feedback_e2e_conversation_cleanup`` is set by feedback steps, | ||
| delete tracked feedback test conversations. | ||
| """ | ||
| # Revert OTEL export before other teardown so a later docker restart keeps | ||
| # the OTEL-disabled, freshly-recreated container. | ||
| if _OTEL_FEATURE_TAG in feature.tags: | ||
| _teardown_otel_export(context) | ||
|
|
||
| # Restore OGX FIRST (before any lightspeed-stack restart). | ||
| # Read from module-level state — Behave clears custom context attributes | ||
| # between scenarios, so context.ogx_was_running is unreliable here. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| @cfg_authorized @OTel @skip | ||
| @cfg_authorized @OTel @skip-in-prow | ||
|
Contributor
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. do we have a task to get this working also in the konflux environment? If not create one as we need it to work there as well |
||
| Feature: OpenTelemetry observability tests | ||
|
|
||
| Background: | ||
|
|
@@ -25,5 +25,4 @@ Feature: OpenTelemetry observability tests | |
| } | ||
| """ | ||
| Then The status code of the response is 200 | ||
| And The service exported an OpenTelemetry event containing e2e-otel-delivery-marker | ||
| And The OpenTelemetry service received data containing e2e-otel-delivery-marker | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| """Step definitions for the OpenTelemetry telemetry-delivery E2E scenario. | ||
|
|
||
| These steps reset the mock OTLP/HTTP collector (the ``mock-otel`` Docker Compose | ||
| service, started with the rest of the stack), reconfigure the Lightspeed Core | ||
| Stack to export spans/events to it, and assert that telemetry containing a | ||
| scenario marker is delivered. | ||
|
|
||
| The Lightspeed Core Stack has no in-app tracing configuration: the OTEL SDK is | ||
| enabled only when the container entrypoint launches the app under | ||
| ``opentelemetry-instrument``, which happens when ``OTEL_SDK_DISABLED=false``. | ||
| The exporter target is then read from the standard ``OTEL_*`` environment | ||
| variables. Because those are set at container-creation time, the | ||
| "configure to export" step force-recreates the container rather than restarting | ||
| it. The OTEL environment and container are reset in ``after_feature`` so later | ||
| scenarios do not inherit the OTEL-enabled configuration. | ||
| """ | ||
|
|
||
| import os | ||
| import time | ||
|
|
||
| import requests | ||
| from behave import given, then # pyright: ignore[reportAttributeAccessIssue] | ||
| from behave.runner import Context | ||
|
|
||
| from tests.e2e.utils.utils import ( | ||
| force_recreate_compose_service, | ||
| wait_for_container_health, | ||
| wait_for_lightspeed_stack_http_ready, | ||
| ) | ||
|
|
||
| # Compose service / container name for the mock collector (see docker-compose*.yaml). | ||
| MOCK_OTEL_SERVICE = "mock-otel" | ||
| LIGHTSPEED_STACK_SERVICE = "lightspeed-stack" | ||
|
|
||
| # Endpoint the Lightspeed Core Stack exports to, on the shared compose network. | ||
| # Overridable so the scenario can target an external collector if needed. | ||
| OTEL_EXPORT_ENDPOINT = os.getenv( | ||
| "E2E_OTEL_EXPORT_ENDPOINT", f"http://{MOCK_OTEL_SERVICE}:4318" | ||
| ) | ||
| OTEL_EXPORT_PROTOCOL = "http/protobuf" | ||
| OTEL_EXPORT_SERVICE_NAME = os.getenv("E2E_OTEL_SERVICE_NAME", "lightspeed-stack-e2e") | ||
|
|
||
| # Host-side control API of the mock collector (published port from docker-compose). | ||
| _MOCK_OTEL_HOST = os.getenv("E2E_OTEL_MOCK_HOST", "localhost") | ||
| _MOCK_OTEL_PORT = os.getenv("E2E_OTEL_MOCK_PORT", "4318") | ||
| MOCK_OTEL_CONTROL_BASE = f"http://{_MOCK_OTEL_HOST}:{_MOCK_OTEL_PORT}" | ||
|
|
||
| # Delivery is asynchronous: the SDK batches spans before export. Poll generously. | ||
| _DELIVERY_TIMEOUT_S = float(os.getenv("E2E_OTEL_DELIVERY_TIMEOUT_S", "45")) | ||
| _DELIVERY_POLL_INTERVAL_S = 2.0 | ||
| _HEALTH_TIMEOUT_S = 30.0 | ||
|
|
||
|
|
||
| def _wait_for_mock_health() -> None: | ||
|
Contributor
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. this private method has no point, remove it, since it does the same thing as the healthcheck in the docker compose |
||
| """Poll the mock collector control API until it reports healthy.""" | ||
| url = f"{MOCK_OTEL_CONTROL_BASE}/health" | ||
| deadline = time.monotonic() + _HEALTH_TIMEOUT_S | ||
| last_error = "no response" | ||
| while time.monotonic() < deadline: | ||
| try: | ||
| response = requests.get(url, timeout=5) | ||
| if response.status_code == 200: | ||
| return | ||
| last_error = f"HTTP {response.status_code}" | ||
| except requests.RequestException as exc: | ||
| last_error = f"{exc.__class__.__name__}: {exc}" | ||
| time.sleep(1.0) | ||
| raise AssertionError( | ||
| f"Mock OTEL collector did not become healthy at {url!r} " | ||
| f"within {_HEALTH_TIMEOUT_S:.0f}s (last: {last_error})" | ||
| ) | ||
|
|
||
|
|
||
| def _reset_mock_collector() -> None: | ||
| """Clear any telemetry buffered by the mock collector from prior runs.""" | ||
| response = requests.post(f"{MOCK_OTEL_CONTROL_BASE}/reset", timeout=5) | ||
| assert ( | ||
| response.status_code == 200 | ||
| ), f"Failed to reset mock OTEL collector: HTTP {response.status_code}" | ||
|
|
||
|
|
||
| def _poll_collector_contains(marker: str) -> bool: | ||
| """Return True once the collector has buffered a payload containing ``marker``.""" | ||
| url = f"{MOCK_OTEL_CONTROL_BASE}/received" | ||
| deadline = time.monotonic() + _DELIVERY_TIMEOUT_S | ||
| while True: | ||
|
Contributor
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. something like |
||
| try: | ||
| response = requests.get(url, params={"contains": marker}, timeout=5) | ||
| if response.status_code == 200 and response.json().get("found"): | ||
| return True | ||
| except requests.RequestException: | ||
| pass | ||
| if time.monotonic() >= deadline: | ||
| return False | ||
| time.sleep(_DELIVERY_POLL_INTERVAL_S) | ||
|
|
||
|
|
||
| @given("An OpenTelemetry service is running and listening for OTLP data") | ||
| def otel_service_running(context: Context) -> None: | ||
| """Ensure the mock OTLP collector is healthy and its buffer is empty. | ||
|
|
||
| The ``mock-otel`` Compose service starts with the rest of the stack, so this | ||
| step only waits for it to report healthy (Docker health and its own control | ||
| API) and resets any previously buffered telemetry so the scenario starts | ||
| from a clean slate. | ||
| """ | ||
| wait_for_container_health(MOCK_OTEL_SERVICE) | ||
|
Contributor
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. there are two healthchecks for the same thing, the only thing that should be realistically left in this step definition is the reset of mock collector |
||
| _wait_for_mock_health() | ||
| _reset_mock_collector() | ||
| context.otel_collector_endpoint = OTEL_EXPORT_ENDPOINT | ||
|
|
||
|
|
||
| @given("The service is configured to export data to the OpenTelemetry service") | ||
| def configure_service_export(context: Context) -> None: | ||
| """Enable OTEL export and recreate the service so the exporter is active. | ||
|
|
||
| The OTEL SDK is only initialized when the entrypoint launches the app under | ||
| ``opentelemetry-instrument`` (``OTEL_SDK_DISABLED=false``), and the exporter | ||
| target comes from environment variables fixed at container creation. This | ||
| sets those variables and force-recreates the ``lightspeed-stack`` container | ||
| so it exports HTTP/protobuf to the mock collector. ``after_feature`` reverts | ||
| these variables and recreates the container for later scenarios. | ||
| """ | ||
| os.environ["OTEL_SDK_DISABLED"] = "false" | ||
|
Contributor
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. these steps require the recreation of the container, so why not set them directly in the docker compose to enable this all the time? If anything it will make sure that the application is able to handle this as it should |
||
| os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = OTEL_EXPORT_ENDPOINT | ||
| os.environ["OTEL_EXPORTER_OTLP_PROTOCOL"] = OTEL_EXPORT_PROTOCOL | ||
| os.environ["OTEL_SERVICE_NAME"] = OTEL_EXPORT_SERVICE_NAME | ||
| # before_all sets OTEL_ANONYMIZATION_SECRET; keep any existing value. | ||
| os.environ.setdefault( | ||
| "OTEL_ANONYMIZATION_SECRET", "e2e-test-secret-do-not-use-in-production" | ||
| ) | ||
|
|
||
| force_recreate_compose_service( | ||
| LIGHTSPEED_STACK_SERVICE, | ||
| is_library_mode=getattr(context, "is_library_mode", False), | ||
| ) | ||
| wait_for_container_health(LIGHTSPEED_STACK_SERVICE) | ||
|
Contributor
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. we already have a behave step for lightspeed-stack restart, use that one instead |
||
| wait_for_lightspeed_stack_http_ready() | ||
| context.otel_export_configured = True | ||
|
Comment on lines
+113
to
+139
Contributor
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. 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift Restore OTEL state after the OpenTelemetry scenario. 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| @then("The OpenTelemetry service received data containing {marker}") | ||
| def collector_received_data(context: Context, marker: str) -> None: | ||
| """Assert the mock collector buffered telemetry containing ``marker``. | ||
|
|
||
| Verifies delivery from the collector's perspective; polls to tolerate the | ||
| SDK's batched, asynchronous export. | ||
| """ | ||
| assert getattr( | ||
| context, "otel_collector_endpoint", None | ||
| ), "The OpenTelemetry service must be started before asserting on delivery" | ||
| marker = marker.strip() | ||
| assert _poll_collector_contains(marker), ( | ||
| f"Mock OTEL collector did not receive data containing {marker!r} " | ||
| f"within {_DELIVERY_TIMEOUT_S:.0f}s" | ||
| ) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| FROM python:3.12-slim | ||
| WORKDIR /app | ||
| COPY server.py . | ||
| EXPOSE 4318 | ||
| CMD ["python", "server.py"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # Mock OTEL collector | ||
|
|
||
| A minimal OTLP/HTTP collector used by the OpenTelemetry E2E scenario | ||
| (`tests/e2e/features/opentelemetry.feature`) to verify that the Lightspeed Core | ||
| Stack delivers spans/events to a telemetry backend. | ||
|
|
||
| It is a stdlib-only `http.server` that buffers the raw OTLP export bodies in | ||
| memory and exposes a small control API so Behave steps can assert what was | ||
| received. See `server.py` for the full endpoint list. | ||
|
|
||
| ## Endpoints | ||
|
|
||
| | Method & path | Purpose | | ||
| | -------------------- | ------------------------------------------------------------- | | ||
| | `POST /v1/*` | Receive an OTLP export (traces/logs/metrics); body buffered. | | ||
| | `GET /received` | Report the count of buffered exports. | | ||
| | `GET /received?contains=<text>` | Report whether `<text>` appears in any payload. | | ||
| | `POST /reset` | Clear the buffer (called at scenario start). | | ||
| | `GET /health` | Liveness probe (`{"status": "ok"}`). | | ||
|
|
||
| Substring queries search the raw request bytes. OTLP protobuf encodes string | ||
| fields as UTF-8, so a plaintext marker embedded in a span attribute value is | ||
| found without decoding protobuf. | ||
|
|
||
| ## Running | ||
|
|
||
| Locally: | ||
|
|
||
| ```bash | ||
| python server.py [port] # default port 4318 | ||
| ``` | ||
|
|
||
| In E2E it runs as the `mock-otel` Docker Compose service on the `lightspeednet` | ||
| network. It starts with the rest of the stack (`docker compose up -d`); the | ||
| `An OpenTelemetry service is running and listening for OTLP data` step only waits | ||
| for it to become healthy and resets its buffer. It is intentionally **not** wired | ||
| into `lightspeed-stack`'s `depends_on` (non-OTEL features must not require this | ||
| container). | ||
|
|
||
| ## Pointing the service at it | ||
|
|
||
| The Lightspeed Core Stack exports via HTTP/protobuf when launched with the OTEL | ||
| SDK enabled: | ||
|
|
||
| ```bash | ||
| OTEL_SDK_DISABLED=false | ||
| OTEL_EXPORTER_OTLP_ENDPOINT=http://mock-otel:4318 | ||
| OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf | ||
| ``` | ||
|
|
||
| The `The service is configured to export data to the OpenTelemetry service` step | ||
| sets these and recreates the container so the entrypoint launches it under | ||
| `opentelemetry-instrument`. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🤖 get_repo_knowledge executed:
get_repo_knowledge lightspeed-core/lightspeed-stack /tmp/coderabbit-repo-knowledge/lightspeed-core-lightspeed-stack-d57c5c34/learnings /tmp/coderabbit-repo-knowledge/lightspeed-core-lightspeed-stack-d57c5c34/conventionsLength of output: 19988
🏁 Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 47159
🏁 Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 47158
🏁 Script executed:
Repository: lightspeed-core/lightspeed-stack
Length of output: 50389
Security Misconfiguration
Reachability: External
Exploitability: Trivial
CWE: CWE-16
Restrict access to the mock collector.
The collector listens on
0.0.0.0and accepts unauthenticatedPOST /resetand OTLP requests. A network client can clear or forge telemetry used by E2E assertions. Bind both published ports to loopback:docker-compose-library.yaml#L124-L125docker-compose.yaml#L178-L179Use
127.0.0.1:4318:4318.📍 Affects 2 files
docker-compose-library.yaml#L124-L125(this comment)docker-compose.yaml#L178-L179🤖 Prompt for AI Agents
Source: Coding guidelines