From b5451c8806f150ed93fa92ca26977ca939a44343 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 28 Jan 2026 09:53:04 +0100 Subject: [PATCH 1/7] Add baggage support to ETOS API I moved the opentelemetry code to a separate module to make it a bit easier to manage. Added a fastapi dependency that retrieves an opentelemetry context and extracts keys and values from headers. Baggage is passed v1alpha via the TestrunSpec and for the v0 version it shall be passed with the Eiffel event but it requires an update of the ETOS library. --- python/src/etos_api/__init__.py | 33 +------- python/src/etos_api/library/opentelemetry.py | 80 ++++++++++++++++++ python/src/etos_api/routers/v0/router.py | 38 +++++++-- python/src/etos_api/routers/v1alpha/router.py | 83 ++++++++++--------- 4 files changed, 157 insertions(+), 77 deletions(-) create mode 100644 python/src/etos_api/library/opentelemetry.py diff --git a/python/src/etos_api/__init__.py b/python/src/etos_api/__init__.py index 4946bba8..522eb785 100644 --- a/python/src/etos_api/__init__.py +++ b/python/src/etos_api/__init__.py @@ -19,21 +19,9 @@ from importlib.metadata import PackageNotFoundError, version from etos_lib.logging.logger import setup_logging -from opentelemetry import trace -from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter -from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor -from opentelemetry.sdk.resources import ( - SERVICE_NAME, - SERVICE_VERSION, - OTELResourceDetector, - ProcessResourceDetector, - Resource, - get_aggregated_resources, -) -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import BatchSpanProcessor from etos_api.library.context_logging import ContextLogging +from etos_api.library.opentelemetry import setup_opentelemetry from .library.providers.register import RegisterProviders from .main import APP @@ -50,25 +38,8 @@ DEV = os.getenv("DEV", "false").lower() == "true" if os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT"): - OTEL_RESOURCE = Resource.create( - { - SERVICE_NAME: "etos-api", - SERVICE_VERSION: VERSION, - }, - ) - - OTEL_RESOURCE = get_aggregated_resources( - [OTELResourceDetector(), ProcessResourceDetector()], - ).merge(OTEL_RESOURCE) - - PROVIDER = TracerProvider(resource=OTEL_RESOURCE) - EXPORTER = OTLPSpanExporter() - PROCESSOR = BatchSpanProcessor(EXPORTER) - PROVIDER.add_span_processor(PROCESSOR) - trace.set_tracer_provider(PROVIDER) + OTEL_RESOURCE = setup_opentelemetry(APP, VERSION) setup_logging("ETOS API", VERSION, otel_resource=OTEL_RESOURCE) - - FastAPIInstrumentor().instrument_app(APP, tracer_provider=PROVIDER, excluded_urls=".*/ping") else: setup_logging("ETOS API", VERSION) diff --git a/python/src/etos_api/library/opentelemetry.py b/python/src/etos_api/library/opentelemetry.py new file mode 100644 index 00000000..cb7ea0ac --- /dev/null +++ b/python/src/etos_api/library/opentelemetry.py @@ -0,0 +1,80 @@ +# Copyright Axis Communications AB. +# +# For a full list of individual contributors, please see the commit history. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""ETOS opentelemetry helpers.""" + +from typing import Annotated + +from fastapi import Header +from opentelemetry import context as otel_context +from opentelemetry import trace +from opentelemetry.baggage.propagation import W3CBaggagePropagator +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor +from opentelemetry.propagate import extract, set_global_textmap +from opentelemetry.propagators.composite import CompositePropagator +from opentelemetry.sdk.resources import ( + SERVICE_NAME, + SERVICE_VERSION, + OTELResourceDetector, + ProcessResourceDetector, + Resource, + get_aggregated_resources, +) +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor +from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator +from pydantic import BaseModel + + +def setup_opentelemetry(app, version: str): + """Set up OpenTelemetry for ETOS API.""" + otel_resource = Resource.create( + { + SERVICE_NAME: "etos-api", + SERVICE_VERSION: version, + }, + ) + + otel_resource = get_aggregated_resources( + [OTELResourceDetector(), ProcessResourceDetector()], + ).merge(otel_resource) + + provider = TracerProvider(resource=otel_resource) + exporter = OTLPSpanExporter() + processor = BatchSpanProcessor(exporter) + provider.add_span_processor(processor) + trace.set_tracer_provider(provider) + propagator = CompositePropagator( + [ + TraceContextTextMapPropagator(), + W3CBaggagePropagator(), + ] + ) + set_global_textmap(propagator) + FastAPIInstrumentor().instrument_app(app, tracer_provider=provider, excluded_urls=".*/ping") + return otel_resource + + +class OTELHeaders(BaseModel): + """Headers model.""" + + baggage: str | None = None + traceparent: str | None = None + + +def context(headers: Annotated[OTELHeaders, Header()]) -> otel_context.Context: + """Extract OpenTelemetry context from headers.""" + return extract(headers.model_dump(), context=otel_context.get_current()) diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index b80c62ed..36c4e764 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -17,57 +17,70 @@ import logging import os +from typing import Annotated from uuid import uuid4 from eiffellib.events import EiffelTestExecutionRecipeCollectionCreatedEvent from etos_lib import ETOS from etos_lib.kubernetes import Kubernetes -from fastapi import FastAPI, HTTPException -from starlette.responses import RedirectResponse, Response +from fastapi import Depends, FastAPI, HTTPException from kubernetes import client +from opentelemetry import baggage as otel_baggage +from opentelemetry import context as otel_context from opentelemetry import trace from opentelemetry.trace import Span +from starlette.responses import RedirectResponse, Response from etos_api.library.environment import Configuration, configure_testrun +from etos_api.library.opentelemetry import context from etos_api.library.utilities import sync_to_async from .schemas import AbortEtosResponse, StartEtosRequest, StartEtosResponse -from .utilities import wait_for_artifact_created, validate_suite +from .utilities import validate_suite, wait_for_artifact_created ETOSv0 = FastAPI( title="ETOS", version="v0", summary="API endpoints for ETOS v0 - I.e. the version before versions", root_path_in_servers=False, + dependencies=[Depends(context)], ) TRACER = trace.get_tracer("etos_api.routers.etos.router") LOGGER = logging.getLogger(__name__) logging.getLogger("pika").setLevel(logging.WARNING) +# pylint:disable=too-many-locals,too-many-statements @ETOSv0.post("/etos", tags=["etos"], response_model=StartEtosResponse) -async def start_etos(etos: StartEtosRequest): +async def start_etos( + etos: StartEtosRequest, + ctx: Annotated[otel_context.Context, Depends(context)], +): """Start ETOS execution on post. :param etos: ETOS pydantic model. :type etos: :obj:`etos_api.routers.etos.schemas.StartEtosRequest` + :param ctx: OpenTelemetry context with extracted headers. + :type ctx: :obj:`opentelemetry.context.Context` :return: JSON dictionary with response. :rtype: dict """ - with TRACER.start_as_current_span("start-etos") as span: - return await _start(etos, span) + with TRACER.start_as_current_span("start-etos", context=ctx) as span: + return await _start(etos, span, ctx) @ETOSv0.delete("/etos/{suite_id}", tags=["etos"], response_model=AbortEtosResponse) -async def abort_etos(suite_id: str): +async def abort_etos(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]): """Abort ETOS execution on delete. :param suite_id: ETOS suite id :type suite_id: str + :param ctx: OpenTelemetry context with extracted headers. + :type ctx: :obj:`opentelemetry.context.Context` :return: JSON dictionary with response. :rtype: dict """ - with TRACER.start_as_current_span("abort-etos"): + with TRACER.start_as_current_span("abort-etos", context=ctx): return await _abort(suite_id) @@ -93,16 +106,20 @@ async def oldping(): return RedirectResponse("/api/ping") -async def _start(etos: StartEtosRequest, span: Span) -> dict: # pylint:disable=too-many-statements +async def _start(etos: StartEtosRequest, span: Span, ctx: otel_context.Context) -> dict: """Start ETOS execution. :param etos: ETOS pydantic model. :param span: An opentelemetry span for tracing. + :param ctx: OpenTelemetry context with extracted headers. :return: JSON dictionary with response. """ tercc = EiffelTestExecutionRecipeCollectionCreatedEvent() LOGGER.identifier.set(tercc.meta.event_id) span.set_attribute("etos.id", tercc.meta.event_id) + span.set_attribute( + "parent_activity", str(etos.parent_activity) if etos.parent_activity else "None" + ) LOGGER.info("Validating test suite.") span.set_attribute("etos.test_suite.uri", etos.test_suite_url) @@ -175,6 +192,9 @@ async def _start(etos: StartEtosRequest, span: Span) -> dict: # pylint:disable= ) from exception LOGGER.info("Environment provider configured.") + ctx = otel_baggage.set_baggage("testrun_id", tercc.meta.event_id, context=ctx) + ctx = otel_baggage.set_baggage("artifact_id", artifact_id, context=ctx) + LOGGER.info("Start event publisher.") await sync_to_async(etos_library.start_publisher) if not etos_library.debug.disable_sending_events: diff --git a/python/src/etos_api/routers/v1alpha/router.py b/python/src/etos_api/routers/v1alpha/router.py index 9644fba6..00ce0afb 100644 --- a/python/src/etos_api/routers/v1alpha/router.py +++ b/python/src/etos_api/routers/v1alpha/router.py @@ -17,33 +17,31 @@ import logging import os +from typing import Annotated from uuid import uuid4 from etos_lib import ETOS -from etos_lib.kubernetes.schemas.testrun import ( - TestRun as TestRunSchema, - TestRunSpec, - Providers, - Image, - Metadata, - Retention, - TestRunner, -) -from etos_lib.kubernetes import TestRun, Environment, Kubernetes -from fastapi import FastAPI, HTTPException -from starlette.responses import Response -from opentelemetry import trace, context +from etos_lib.kubernetes import Environment, Kubernetes, TestRun +from etos_lib.kubernetes.schemas.testrun import Image, Metadata, Providers, Retention +from etos_lib.kubernetes.schemas.testrun import TestRun as TestRunSchema +from etos_lib.kubernetes.schemas.testrun import TestRunner, TestRunSpec +from fastapi import Depends, FastAPI, HTTPException +from opentelemetry import baggage as otel_baggage +from opentelemetry import context as otel_context +from opentelemetry import trace from opentelemetry.propagate import inject from opentelemetry.trace import Span +from starlette.responses import Response +from etos_api.library.opentelemetry import context from .schemas import AbortTestrunResponse, StartTestrunRequest, StartTestrunResponse from .utilities import ( - wait_for_artifact_created, - download_suite, - validate_suite, convert_to_rfc1123, + download_suite, recipes_from_tests, + validate_suite, + wait_for_artifact_created, ) ETOSv1Alpha = FastAPI( @@ -51,35 +49,43 @@ version="v1alpha", summary="API endpoints for ETOS v1 Alpha", root_path_in_servers=False, + dependencies=[Depends(context)], ) TRACER = trace.get_tracer("etos_api.routers.testrun.router") LOGGER = logging.getLogger(__name__) logging.getLogger("pika").setLevel(logging.WARNING) +# pylint:disable=too-many-locals,too-many-statements @ETOSv1Alpha.post("/testrun", tags=["etos"], response_model=StartTestrunResponse) -async def start_testrun(etos: StartTestrunRequest): +async def start_testrun( + etos: StartTestrunRequest, ctx: Annotated[otel_context.Context, Depends(context)] +): """Start ETOS testrun on post. :param etos: ETOS pydantic model. :type etos: :obj:`etos_api.routers.etos.schemas.StartTestrunRequest` + :param ctx: OpenTelemetry context with extracted headers. + :type ctx: :obj:`opentelemetry.context.Context` :return: JSON dictionary with response. :rtype: dict """ - with TRACER.start_as_current_span("start-etos") as span: - return await _create_testrun(etos, span) + with TRACER.start_as_current_span("start-etos", context=ctx) as span: + return await _create_testrun(etos, span, ctx) @ETOSv1Alpha.delete("/testrun/{suite_id}", tags=["etos"], response_model=AbortTestrunResponse) -async def abort_testrun(suite_id: str): +async def abort_testrun(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]): """Abort ETOS testrun on delete. :param suite_id: ETOS suite id :type suite_id: str + :param ctx: OpenTelemetry context with extracted headers. + :type ctx: :obj:`opentelemetry.context.Context` :return: JSON dictionary with response. :rtype: dict """ - with TRACER.start_as_current_span("abort-etos"): + with TRACER.start_as_current_span("abort-etos", context=ctx): return await _abort(suite_id) @@ -106,23 +112,12 @@ async def health_check(): return Response(status_code=204) -def get_current_context() -> str: - """Get the current OpenTelemetry context.""" - ctx = context.get_current() - LOGGER.info("Current OpenTelemetry context: %s", ctx) - carrier = {} - # inject() creates a dict with context reference, - # e. g. {'traceparent': '00-0be6c260d9cbe9772298eaf19cb90a5b-371353ee8fbd3ced-01'} - inject(carrier) - env = ",".join(f"{k}={v}" for k, v in carrier.items()) - return env - - -async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict: +async def _create_testrun(etos: StartTestrunRequest, span: Span, ctx: otel_context.Context) -> dict: """Create a testrun for ETOS to execute. :param etos: Testrun pydantic model. :param span: An opentelemetry span for tracing. + :param ctx: OpenTelemetry context with extracted headers. :return: JSON dictionary with response. """ testrun_id = str(uuid4()) @@ -202,6 +197,22 @@ async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict: success=os.getenv("TESTRUN_SUCCESS_RETENTION"), ) + ctx = otel_baggage.set_baggage("testrun_id", testrun_id, context=ctx) + ctx = otel_baggage.set_baggage("artifact_id", artifact_id, context=ctx) + ctx = otel_baggage.set_baggage( + "etos_cluster", os.getenv("ETOS_CLUSTER", "Unknown"), context=ctx + ) + carrier = {} + # inject() creates a dict with context reference, + # e. g. {'traceparent': '00-0be6c260d9cbe9772298eaf19cb90a5b-371353ee8fbd3ced-01'} + inject(carrier, context=ctx) + + annotations = {} + if carrier.get("traceparent"): + annotations["etos.eiffel-community.github.io/traceparent"] = carrier["traceparent"] + if carrier.get("baggage"): + annotations["etos.eiffel-community.github.io/baggage"] = carrier["baggage"] + kubernetes = Kubernetes() testrun_spec = TestRunSchema( metadata=Metadata( @@ -211,9 +222,7 @@ async def _create_testrun(etos: StartTestrunRequest, span: Span) -> dict: "etos.eiffel-community.github.io/id": testrun_id, "etos.eiffel-community.github.io/cluster": os.getenv("ETOS_CLUSTER", "Unknown"), }, - annotations={ - "etos.eiffel-community.github.io/traceparent": get_current_context(), - }, + annotations=annotations, ), spec=TestRunSpec( cluster=os.getenv("ETOS_CLUSTER", "Unknown"), From ecc5d5623b0ec58fe4ee19174ac01a6660fd991b Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 28 Jan 2026 10:23:15 +0100 Subject: [PATCH 2/7] Fix a complaint by pylint --- python/src/etos_api/main.py | 11 ++++++----- python/src/etos_api/routers/v0/__init__.py | 2 +- python/src/etos_api/routers/v0/router.py | 10 +++++----- python/src/etos_api/routers/v1alpha/__init__.py | 2 +- python/src/etos_api/routers/v1alpha/router.py | 10 +++++----- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/python/src/etos_api/main.py b/python/src/etos_api/main.py index 11337fe8..a54c3642 100644 --- a/python/src/etos_api/main.py +++ b/python/src/etos_api/main.py @@ -16,12 +16,13 @@ """ETOS API.""" from fastapi import FastAPI -from etos_api.routers.v0 import ETOSv0 -from etos_api.routers.v1alpha import ETOSv1Alpha -DEFAULT_VERSION = ETOSv0 +from etos_api.routers.v0 import ETOSV0 +from etos_api.routers.v1alpha import ETOSV1ALPHA + +DEFAULT_VERSION = ETOSV0 APP = FastAPI() -APP.mount("/api/v1alpha", ETOSv1Alpha, "ETOS V1 Alpha") -APP.mount("/api/v0", ETOSv0, "ETOS V0") +APP.mount("/api/v1alpha", ETOSV1ALPHA, "ETOS V1 Alpha") +APP.mount("/api/v0", ETOSV0, "ETOS V0") APP.mount("/api", DEFAULT_VERSION, "ETOS V0") diff --git a/python/src/etos_api/routers/v0/__init__.py b/python/src/etos_api/routers/v0/__init__.py index 7235df4e..2e6d8851 100644 --- a/python/src/etos_api/routers/v0/__init__.py +++ b/python/src/etos_api/routers/v0/__init__.py @@ -15,5 +15,5 @@ # limitations under the License. """ETOS API etos module.""" -from .router import ETOSv0 from . import schemas +from .router import ETOSV0 diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index 36c4e764..51bdbc92 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -38,7 +38,7 @@ from .schemas import AbortEtosResponse, StartEtosRequest, StartEtosResponse from .utilities import validate_suite, wait_for_artifact_created -ETOSv0 = FastAPI( +ETOSV0 = FastAPI( title="ETOS", version="v0", summary="API endpoints for ETOS v0 - I.e. the version before versions", @@ -51,7 +51,7 @@ # pylint:disable=too-many-locals,too-many-statements -@ETOSv0.post("/etos", tags=["etos"], response_model=StartEtosResponse) +@ETOSV0.post("/etos", tags=["etos"], response_model=StartEtosResponse) async def start_etos( etos: StartEtosRequest, ctx: Annotated[otel_context.Context, Depends(context)], @@ -69,7 +69,7 @@ async def start_etos( return await _start(etos, span, ctx) -@ETOSv0.delete("/etos/{suite_id}", tags=["etos"], response_model=AbortEtosResponse) +@ETOSV0.delete("/etos/{suite_id}", tags=["etos"], response_model=AbortEtosResponse) async def abort_etos(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]): """Abort ETOS execution on delete. @@ -84,7 +84,7 @@ async def abort_etos(suite_id: str, ctx: Annotated[otel_context.Context, Depends return await _abort(suite_id) -@ETOSv0.get("/ping", tags=["etos"], status_code=204) +@ETOSV0.get("/ping", tags=["etos"], status_code=204) async def ping(): """Ping the ETOS service in order to check if it is up and running. @@ -94,7 +94,7 @@ async def ping(): return Response(status_code=204) -@ETOSv0.get("/selftest/ping") +@ETOSV0.get("/selftest/ping") async def oldping(): """Ping the ETOS service in order to check if it is up and running. diff --git a/python/src/etos_api/routers/v1alpha/__init__.py b/python/src/etos_api/routers/v1alpha/__init__.py index e7fde715..d4daea7a 100644 --- a/python/src/etos_api/routers/v1alpha/__init__.py +++ b/python/src/etos_api/routers/v1alpha/__init__.py @@ -15,5 +15,5 @@ # limitations under the License. """ETOS API testrun module.""" -from .router import ETOSv1Alpha from . import schemas +from .router import ETOSV1ALPHA diff --git a/python/src/etos_api/routers/v1alpha/router.py b/python/src/etos_api/routers/v1alpha/router.py index 00ce0afb..a3ff05d4 100644 --- a/python/src/etos_api/routers/v1alpha/router.py +++ b/python/src/etos_api/routers/v1alpha/router.py @@ -44,7 +44,7 @@ wait_for_artifact_created, ) -ETOSv1Alpha = FastAPI( +ETOSV1ALPHA = FastAPI( title="ETOS", version="v1alpha", summary="API endpoints for ETOS v1 Alpha", @@ -57,7 +57,7 @@ # pylint:disable=too-many-locals,too-many-statements -@ETOSv1Alpha.post("/testrun", tags=["etos"], response_model=StartTestrunResponse) +@ETOSV1ALPHA.post("/testrun", tags=["etos"], response_model=StartTestrunResponse) async def start_testrun( etos: StartTestrunRequest, ctx: Annotated[otel_context.Context, Depends(context)] ): @@ -74,7 +74,7 @@ async def start_testrun( return await _create_testrun(etos, span, ctx) -@ETOSv1Alpha.delete("/testrun/{suite_id}", tags=["etos"], response_model=AbortTestrunResponse) +@ETOSV1ALPHA.delete("/testrun/{suite_id}", tags=["etos"], response_model=AbortTestrunResponse) async def abort_testrun(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]): """Abort ETOS testrun on delete. @@ -89,7 +89,7 @@ async def abort_testrun(suite_id: str, ctx: Annotated[otel_context.Context, Depe return await _abort(suite_id) -@ETOSv1Alpha.get("/testrun/{sub_suite_id}", tags=["etos"]) +@ETOSV1ALPHA.get("/testrun/{sub_suite_id}", tags=["etos"]) async def get_subsuite(sub_suite_id: str) -> dict: """Get sub suite returns the sub suite definition for the ETOS test runner. @@ -106,7 +106,7 @@ async def get_subsuite(sub_suite_id: str) -> dict: return environment_spec -@ETOSv1Alpha.get("/ping", tags=["etos"], status_code=204) +@ETOSV1ALPHA.get("/ping", tags=["etos"], status_code=204) async def health_check(): """Check the status of the API and verify the client version.""" return Response(status_code=204) From e692938b0e4f84dd0cfb58e4a463cab5e1605ed9 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 28 Jan 2026 14:30:05 +0100 Subject: [PATCH 3/7] Pass the correct context to the start functions --- python/src/etos_api/routers/v0/router.py | 2 +- python/src/etos_api/routers/v1alpha/router.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index 51bdbc92..ef9cb08c 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -66,7 +66,7 @@ async def start_etos( :rtype: dict """ with TRACER.start_as_current_span("start-etos", context=ctx) as span: - return await _start(etos, span, ctx) + return await _start(etos, span, otel_context.get_current()) @ETOSV0.delete("/etos/{suite_id}", tags=["etos"], response_model=AbortEtosResponse) diff --git a/python/src/etos_api/routers/v1alpha/router.py b/python/src/etos_api/routers/v1alpha/router.py index a3ff05d4..58c2e089 100644 --- a/python/src/etos_api/routers/v1alpha/router.py +++ b/python/src/etos_api/routers/v1alpha/router.py @@ -71,7 +71,7 @@ async def start_testrun( :rtype: dict """ with TRACER.start_as_current_span("start-etos", context=ctx) as span: - return await _create_testrun(etos, span, ctx) + return await _create_testrun(etos, span, otel_context.get_current()) @ETOSV1ALPHA.delete("/testrun/{suite_id}", tags=["etos"], response_model=AbortTestrunResponse) From d3b814520d42a76549f8cbaf86c23fb19caa1a48 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Wed, 4 Feb 2026 11:42:41 +0100 Subject: [PATCH 4/7] Add type annotations --- python/src/etos_api/library/opentelemetry.py | 2 +- python/src/etos_api/routers/v0/router.py | 4 ++-- python/src/etos_api/routers/v1alpha/router.py | 6 ++++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/python/src/etos_api/library/opentelemetry.py b/python/src/etos_api/library/opentelemetry.py index cb7ea0ac..8d5ca6c9 100644 --- a/python/src/etos_api/library/opentelemetry.py +++ b/python/src/etos_api/library/opentelemetry.py @@ -39,7 +39,7 @@ from pydantic import BaseModel -def setup_opentelemetry(app, version: str): +def setup_opentelemetry(app, version: str) -> Resource: """Set up OpenTelemetry for ETOS API.""" otel_resource = Resource.create( { diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index ef9cb08c..1698594d 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -55,7 +55,7 @@ async def start_etos( etos: StartEtosRequest, ctx: Annotated[otel_context.Context, Depends(context)], -): +) -> dict: """Start ETOS execution on post. :param etos: ETOS pydantic model. @@ -70,7 +70,7 @@ async def start_etos( @ETOSV0.delete("/etos/{suite_id}", tags=["etos"], response_model=AbortEtosResponse) -async def abort_etos(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]): +async def abort_etos(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]) -> dict: """Abort ETOS execution on delete. :param suite_id: ETOS suite id diff --git a/python/src/etos_api/routers/v1alpha/router.py b/python/src/etos_api/routers/v1alpha/router.py index 58c2e089..cda55ce3 100644 --- a/python/src/etos_api/routers/v1alpha/router.py +++ b/python/src/etos_api/routers/v1alpha/router.py @@ -60,7 +60,7 @@ @ETOSV1ALPHA.post("/testrun", tags=["etos"], response_model=StartTestrunResponse) async def start_testrun( etos: StartTestrunRequest, ctx: Annotated[otel_context.Context, Depends(context)] -): +) -> dict: """Start ETOS testrun on post. :param etos: ETOS pydantic model. @@ -75,7 +75,9 @@ async def start_testrun( @ETOSV1ALPHA.delete("/testrun/{suite_id}", tags=["etos"], response_model=AbortTestrunResponse) -async def abort_testrun(suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)]): +async def abort_testrun( + suite_id: str, ctx: Annotated[otel_context.Context, Depends(context)] +) -> dict: """Abort ETOS testrun on delete. :param suite_id: ETOS suite id From e433cc3af95b66b93e3cc14aa61abf68f6d6f533 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Fri, 6 Feb 2026 09:35:17 +0100 Subject: [PATCH 5/7] Add context to send_event --- python/pyproject.toml | 2 +- python/src/etos_api/routers/v0/router.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/python/pyproject.toml b/python/pyproject.toml index cd579771..e69dedad 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -16,7 +16,7 @@ classifiers = [ ] requires-python = ">=3.10" dependencies = [ - "etos_lib==5.1.6", + "etos_lib==5.2.0", "etcd3gw~=2.3", "uvicorn~=0.22", "fastapi~=0.115.6", diff --git a/python/src/etos_api/routers/v0/router.py b/python/src/etos_api/routers/v0/router.py index 1698594d..c95c42df 100644 --- a/python/src/etos_api/routers/v0/router.py +++ b/python/src/etos_api/routers/v0/router.py @@ -202,7 +202,7 @@ async def _start(etos: StartEtosRequest, span: Span, ctx: otel_context.Context) LOGGER.info("Event published started successfully.") LOGGER.info("Publish TERCC event.") try: - event = etos_library.events.send(tercc, links, data) + event = etos_library.events.send(tercc, links, data, ctx=ctx) await sync_to_async(etos_library.publisher.wait_for_unpublished_events) finally: if not etos_library.debug.disable_sending_events: From c41713617ce22a17f30fb9d761e21ba30a7d9c08 Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Mon, 23 Mar 2026 09:00:51 +0100 Subject: [PATCH 6/7] Log extracted context --- python/src/etos_api/library/opentelemetry.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/python/src/etos_api/library/opentelemetry.py b/python/src/etos_api/library/opentelemetry.py index 8d5ca6c9..8e53fff1 100644 --- a/python/src/etos_api/library/opentelemetry.py +++ b/python/src/etos_api/library/opentelemetry.py @@ -15,6 +15,7 @@ # limitations under the License. """ETOS opentelemetry helpers.""" +import logging from typing import Annotated from fastapi import Header @@ -38,6 +39,8 @@ from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator from pydantic import BaseModel +LOGGER = logging.getLogger(__name__) + def setup_opentelemetry(app, version: str) -> Resource: """Set up OpenTelemetry for ETOS API.""" @@ -77,4 +80,6 @@ class OTELHeaders(BaseModel): def context(headers: Annotated[OTELHeaders, Header()]) -> otel_context.Context: """Extract OpenTelemetry context from headers.""" - return extract(headers.model_dump(), context=otel_context.get_current()) + ctx = extract(headers.model_dump(), context=otel_context.get_current()) + LOGGER.debug("Extracted context from headers: %r", ctx) + return ctx From 90551ddb9f76eae6279a98811339cb3b2ce3b7bd Mon Sep 17 00:00:00 2001 From: Tobias Persson Date: Thu, 26 Mar 2026 14:06:28 +0100 Subject: [PATCH 7/7] Add type-hint --- python/src/etos_api/library/opentelemetry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/python/src/etos_api/library/opentelemetry.py b/python/src/etos_api/library/opentelemetry.py index 8e53fff1..66adb4d4 100644 --- a/python/src/etos_api/library/opentelemetry.py +++ b/python/src/etos_api/library/opentelemetry.py @@ -18,7 +18,7 @@ import logging from typing import Annotated -from fastapi import Header +from fastapi import FastAPI, Header from opentelemetry import context as otel_context from opentelemetry import trace from opentelemetry.baggage.propagation import W3CBaggagePropagator @@ -42,7 +42,7 @@ LOGGER = logging.getLogger(__name__) -def setup_opentelemetry(app, version: str) -> Resource: +def setup_opentelemetry(app: FastAPI, version: str) -> Resource: """Set up OpenTelemetry for ETOS API.""" otel_resource = Resource.create( {