Skip to content

Commit f8d6cf7

Browse files
Allow multiple otel trace and log exporters (#16)
* Allow multiple otel trace and log exporters * Prepare release v42
1 parent 6c74f1f commit f8d6cf7

3 files changed

Lines changed: 71 additions & 51 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.42.0] - 2025-08-22
11+
1012
### Added
1113

1214
- `tilebox-storage`: Added `USGSLandsatStorageClient` to download landsat data from the USGS Landsat S3 bucket.
@@ -21,6 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2123
overwriting the existing task.
2224
- `tilebox-workflows`: Fixed a bug where the `deserialize_task` function would fail to deserialize nested dataclasses or
2325
protobuf messages that are wrapped in an `Optional` or `Annotated` type hint.
26+
- `tilebox-workflows`: Calling `configure_otel_tracing` and `configure_otel_logging` multiple times correctly configures
27+
multiple exporters instead of overwriting the existing ones.
2428

2529
## [0.41.0] - 2025-08-01
2630

@@ -248,7 +252,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
248252
- Released packages: `tilebox-datasets`, `tilebox-workflows`, `tilebox-storage`, `tilebox-grpc`
249253

250254

251-
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.41.0...HEAD
255+
[Unreleased]: https://github.com/tilebox/tilebox-python/compare/v0.42.0...HEAD
256+
[0.42.0]: https://github.com/tilebox/tilebox-python/compare/v0.41.0...v0.42.0
252257
[0.41.0]: https://github.com/tilebox/tilebox-python/compare/v0.40.0...v0.41.0
253258
[0.40.0]: https://github.com/tilebox/tilebox-python/compare/v0.39.0...v0.40.0
254259
[0.39.0]: https://github.com/tilebox/tilebox-python/compare/v0.38.0...v0.39.0

tilebox-workflows/tilebox/workflows/observability/logging.py

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -83,16 +83,11 @@ def _get_attributes(record: logging.LogRecord) -> _ExtendedAttributes:
8383
return attributes
8484

8585

86-
def _otel_handler(
87-
level: int = logging.NOTSET,
88-
service: str | Resource | None = None,
86+
def _otel_log_exporter(
8987
endpoint: str | None = None,
9088
headers: dict[str, str] | None = None,
9189
export_interval: timedelta | None = None,
92-
) -> LoggingHandler:
93-
resource = _get_default_resource(service)
94-
logger_provider = LoggerProvider(resource)
95-
90+
) -> BatchLogRecordProcessor:
9691
if endpoint is None:
9792
endpoint = os.environ.get(_OTEL_LOGS_ENDPOINT_ENV_VAR, None)
9893
if endpoint is None:
@@ -115,19 +110,15 @@ def _otel_handler(
115110
headers=headers,
116111
)
117112
schedule_delay = int(export_interval.total_seconds() * 1000) if export_interval is not None else None
118-
batch_exporter = BatchLogRecordProcessor(exporter, schedule_delay_millis=schedule_delay) # type: ignore[arg-type]
113+
return BatchLogRecordProcessor(exporter, schedule_delay_millis=schedule_delay) # type: ignore[arg-type]
119114

120-
logger_provider.add_log_record_processor(batch_exporter)
121-
return OTELLoggingHandler(level=level, logger_provider=logger_provider)
122115

123-
124-
def configure_otel_logging( # noqa: PLR0913
116+
def configure_otel_logging(
125117
service: str | Resource | None = None,
126118
level: int = logging.DEBUG,
127119
endpoint: str | None = None,
128120
headers: dict[str, str] | None = None,
129121
export_interval: timedelta | None = None,
130-
reconfigure: bool = True,
131122
) -> None:
132123
"""
133124
Configure logging to an OTLP compatible endpoint.
@@ -154,22 +145,21 @@ def configure_otel_logging( # noqa: PLR0913
154145
export_interval: The interval at which to export logs to the endpoint. If not provided, the
155146
environment variable OTEL_EXPORT_INTERVAL will be used. If that is not set either, the default open
156147
telemetry export interval of 5s will be used.
157-
reconfigure: Only relevant if configure_otel_logging is called multiple times. If True, any previously
158-
configured OTEL logging handlers will be removed. If False, the existing handlers will be kept. Useful
159-
if you want to log to multiple OTEL endpoints.
160148
161149
Raises:
162150
ValueError: If no endpoint is provided and no OTEL_LOGS_ENDPOINT environment variable is set.
163151
"""
164-
handler = _otel_handler(level, service, endpoint, headers, export_interval)
152+
provider = LoggerProvider(resource=_get_default_resource(service))
153+
154+
batch_exporter = _otel_log_exporter(endpoint, headers, export_interval)
155+
provider.add_log_record_processor(batch_exporter)
156+
handler = OTELLoggingHandler(level=level, logger_provider=provider)
157+
165158
root_logger = _root_logger()
166159

167-
# clean up previous handlers:
168-
# remove the default handler if it exists, and all other OtelHandlers if reconfigure is True
160+
# clean up the default handler if it exists
169161
handlers_to_remove_indices = [
170-
i
171-
for i, handler in enumerate(root_logger.handlers)
172-
if hasattr(handler, "_is_default") or (reconfigure and isinstance(handler, OTELLoggingHandler))
162+
i for i, handler in enumerate(root_logger.handlers) if hasattr(handler, "_is_default")
173163
]
174164
for i in reversed(handlers_to_remove_indices): # reversed to avoid index shifting after deletion
175165
root_logger.handlers.pop(i)
@@ -182,7 +172,6 @@ def configure_otel_logging_axiom(
182172
level: int = logging.DEBUG,
183173
dataset: str | None = None,
184174
api_key: str | None = None,
185-
reconfigure: bool = True,
186175
) -> None:
187176
"""
188177
Configure opentelemetry logging to Axiom.
@@ -205,9 +194,6 @@ def configure_otel_logging_axiom(
205194
AXIOM_LOGS_DATASET will be used. If that is not set either, an error will be raised.
206195
api_key: The API key to use for authentication. If not provided, the environment variable AXIOM_API_KEY will be
207196
used. If that is not set either, an error will be raised.
208-
reconfigure: Only relevant if configure_otel_logging_axiom is called multiple times. If True, any previously
209-
configured OTEL logging handlers will be removed. If False, the existing handlers will be kept. Useful
210-
if you want to log to multiple OTEL endpoints.
211197
212198
Raises:
213199
ValueError: If no dataset is provided and no AXIOM_LOGS_DATASET environment variable is set
@@ -234,7 +220,6 @@ def configure_otel_logging_axiom(
234220
level,
235221
endpoint=_AXIOM_ENDPOINT,
236222
headers={"Authorization": f"Bearer {api_key}", "X-Axiom-Dataset": dataset},
237-
reconfigure=reconfigure,
238223
)
239224

240225

tilebox-workflows/tilebox/workflows/observability/tracing.py

Lines changed: 53 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,9 @@
1212
)
1313
from opentelemetry.sdk.resources import Resource
1414
from opentelemetry.sdk.trace import TracerProvider
15-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
16-
from opentelemetry.trace import ProxyTracerProvider, get_current_span, get_tracer_provider, set_tracer_provider
15+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanProcessor
1716
from opentelemetry.trace import Span as OTSpan
17+
from opentelemetry.trace import get_current_span
1818
from opentelemetry.trace.propagation.tracecontext import TraceContextTextMapPropagator
1919

2020
from tilebox.workflows.data import Job
@@ -30,17 +30,44 @@
3030
_OTEL_TRACES_ENDPOINT_ENV_VAR = "OTEL_TRACES_ENDPOINT"
3131
_OTEL_EXPORT_INTERVAL_ENV_VAR = "OTEL_EXPORT_INTERVAL"
3232

33+
# the globally configured tilebox opentelemetry tracer provider.
34+
# we explicitly avoid using the global opentelemetry tracer provider, because other libraries might also configure
35+
# that (e.g. pytorch does sometimes), and we don't want to interfere with that.
36+
# as default we don't use a proxy tracer provider, that only returns no-op tracers, because we still want to be able
37+
# to extract trace_ids and spans, in case other runners / workflow clients have tracing configured.
38+
# So instead we use a tracer provider without any exporters, which will still create traces and spans,
39+
# but will not send them anywhere.
40+
_tilebox_tracer_provider = TracerProvider()
41+
_workflow_tracers = []
42+
43+
44+
def _get_tilebox_tracer_provider() -> TracerProvider:
45+
return _tilebox_tracer_provider
46+
47+
48+
def _set_tilebox_tracer_provider(provider: TracerProvider) -> None:
49+
global _tilebox_tracer_provider # noqa: PLW0603
50+
_tilebox_tracer_provider = provider
51+
52+
for tracer in _workflow_tracers:
53+
tracer._swap_provider(provider) # noqa: SLF001
54+
3355

3456
class WorkflowTracer:
3557
def __init__(self) -> None:
3658
"""Instantiate a tracer from the currently configured global tracer provider."""
37-
provider = get_tracer_provider()
38-
if isinstance(provider, ProxyTracerProvider):
39-
# if no tracer provider is configured, we still don't want to use the no-op tracer, because we
40-
# want to be able to extract trace_ids and spans, in case other runners / workflow clients have tracing
41-
# configured. So instead we use a tracer provider without any exporters, which will still create traces
42-
# and spans, but will not send them anywhere.
43-
provider = TracerProvider()
59+
provider = _get_tilebox_tracer_provider()
60+
self._tracer = provider.get_tracer(_INSTRUMENTATION_MODULE_NAME)
61+
62+
# keep track of all workflow tracers, to be able to update them in case the
63+
# global tracer provider is replaced.
64+
_workflow_tracers.append(self)
65+
66+
def _swap_provider(self, provider: TracerProvider) -> None:
67+
"""
68+
A callback function that get's invoked in case a new tracer provider is configured, to make sure
69+
existing workflow tracers are updated to use the new provider.
70+
"""
4471
self._tracer = provider.get_tracer(_INSTRUMENTATION_MODULE_NAME)
4572

4673
# functools.wraps is a bit buggy with class methods, so we are not using it here
@@ -62,16 +89,11 @@ def get_trace_parent_of_current_span() -> str:
6289
return carrier["traceparent"]
6390

6491

65-
def _otel_tracer_provider(
66-
service: str | Resource | None = None,
92+
def _otel_span_exporter(
6793
endpoint: str | None = None,
6894
headers: dict[str, str] | None = None,
6995
export_interval: timedelta | None = None,
70-
) -> TracerProvider:
71-
resource = _get_default_resource(service)
72-
73-
provider = TracerProvider(resource=resource)
74-
96+
) -> SpanProcessor:
7597
if endpoint is None:
7698
endpoint = os.environ.get(_OTEL_TRACES_ENDPOINT_ENV_VAR, None)
7799
if endpoint is None:
@@ -94,11 +116,7 @@ def _otel_tracer_provider(
94116
headers=headers,
95117
)
96118
schedule_delay = int(export_interval.total_seconds() * 1000) if export_interval is not None else None
97-
batch_processor = BatchSpanProcessor(exporter, schedule_delay_millis=schedule_delay) # type: ignore[arg-type]
98-
99-
provider.add_span_processor(batch_processor)
100-
101-
return provider
119+
return BatchSpanProcessor(exporter, schedule_delay_millis=schedule_delay) # type: ignore[arg-type]
102120

103121

104122
class SpanEventLoggingHandler(logging.Handler):
@@ -156,8 +174,20 @@ def configure_otel_tracing(
156174
Raises:
157175
ValueError: If no endpoint is provided and no OTEL_TRACES_ENDPOINT environment variable is set.
158176
"""
159-
provider = _otel_tracer_provider(service, endpoint, headers, export_interval)
160-
set_tracer_provider(provider)
177+
provider = _get_tilebox_tracer_provider()
178+
resource = _get_default_resource(service)
179+
if provider.resource.attributes != resource.attributes:
180+
# It's either the first time we configure tracing, or we are trying to reconfigure it with a different resource.
181+
# That means we need to create a new provider.
182+
provider = TracerProvider(
183+
resource=resource,
184+
# keep the existing span processor, so that all previously configured exports are still used as well
185+
active_span_processor=provider._active_span_processor, # noqa: SLF001
186+
)
187+
_set_tilebox_tracer_provider(provider)
188+
189+
exporter = _otel_span_exporter(endpoint, headers, export_interval)
190+
provider.add_span_processor(exporter)
161191

162192
# if we configure tracing, we also want to add log messages to active spans, which is a mixture of a logging
163193
# tracing feature. But configure this here, because we anyways don't need to do this if tracing is not configured.

0 commit comments

Comments
 (0)