Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
b7e1150
fix(sdk): flush concurrent processors inline when executor shut down …
RichardoMrMu Oct 26, 2026
1b89bcf
fix(sdk): use LF line endings to match repo convention
RichardoMrMu Oct 26, 2026
c3d00ea
changelog: rename fragment to match PR number
RichardoMrMu Oct 26, 2026
28a7abe
changelog: strip BOM from fragment
RichardoMrMu Oct 26, 2026
071c23e
tests: disable pylint no-self-use on inline fallback test
RichardoMrMu Oct 26, 2026
961ab09
fix(sdk): contain concurrent processor callback errors and prevent do…
RichardoMrMu Oct 26, 2026
c7446c1
test(sdk): address pylint/ruff nits in CallbackAttempt tests
RichardoMrMu Oct 26, 2026
1c6050a
fix(sdk): guard callback logging reentry deadlock; add changelog
RichardoMrMu Oct 26, 2026
d87a6a0
fix(sdk): scope reentry guard to diagnostic dispatch; contain handler…
RichardoMrMu Oct 26, 2026
dda89e6
style(sdk): move sys/traceback imports to top-level (fix pylint/preco…
RichardoMrMu Oct 26, 2026
cba9b5a
style(sdk): sort imports (fix pre-commit I001)
RichardoMrMu Oct 26, 2026
9a0221e
style(sdk): fix pylint W0621/W0212 and ruff I001 in test imports
RichardoMrMu Oct 26, 2026
d16c476
style(sdk): ruff format + pylint W0212 disable on _executor test access
RichardoMrMu Oct 26, 2026
094b2aa
style(sdk): use public shutdown() contract in span/log processor tests
RichardoMrMu Oct 26, 2026
885b576
fix(logs): inline concurrent dispatch from real LoggingHandler bridge…
RichardoMrMu Oct 26, 2026
cd4e2ed
test(logs): fix comment typo and remove stray control char in real-br…
RichardoMrMu Oct 26, 2026
8b7e960
ci: retrigger public-symbols-check (previous run flaky/infrastructure…
RichardoMrMu Oct 26, 2026
7954da9
fix(sdk): isolate logging-handler errors from inline callback dispatch
RichardoMrMu Oct 26, 2026
c4616bf
test(sdk): outward-raising handler regression for inline fallback
RichardoMrMu Oct 26, 2026
5fa8845
test(sdk): outward-raising handler for resolve and future paths
RichardoMrMu Oct 26, 2026
e47c4e8
test(sdk): ruff format and scoped protected-access disable
RichardoMrMu Oct 26, 2026
c150213
test(sdk): queued submit RuntimeError + outward handler combo
RichardoMrMu Oct 26, 2026
5c67262
test(sdk): combo test waits enqueued futures and asserts stderr fallback
RichardoMrMu Oct 26, 2026
cf60050
test(sdk): move io/sys imports to top-level for lint compliance
RichardoMrMu Oct 26, 2026
14adf75
test(sdk): rename loop variable f to fut for pylint C0103
RichardoMrMu Oct 26, 2026
7b99521
test(sdk): trim combo test docstring to satisfy pylint C0302
RichardoMrMu Oct 26, 2026
89c2936
Merge remote-tracking branch 'upstream/main' into fix/concurrent-proc…
RichardoMrMu Oct 26, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changelog/5835.fixed
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Fix concurrent span/log processor shutdown at exit so that a failing processor callback no longer blocks healthy sibling processors and a Thread.start RuntimeError no longer double-invokes a callback.
150 changes: 146 additions & 4 deletions opentelemetry-sdk/src/opentelemetry/sdk/_logs/_internal/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import json
import logging
import os
import sys
import threading
import traceback
import warnings
Expand Down Expand Up @@ -87,6 +88,49 @@
_ENV_VALUE_UNSET = ""

_logger = logging.getLogger(__name__)
_callback_log_reentry = threading.local()


def _safe_log_exception(logger, msg): # pylint: disable=redefined-outer-name
"""Log an exception safely against recursive logging handlers.

If we are already inside a diagnostic log dispatch on this thread, write
the record to stderr directly instead of going through the logging module
to prevent infinite feedback loops. Any exception raised by a user logging
handler during the outer dispatch is contained here.
"""
if getattr(_callback_log_reentry, "in_diag", False):
print(msg, file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return
_callback_log_reentry.in_diag = True
try:
logger.exception(msg)
except Exception: # pylint: disable=broad-exception-caught
print(msg, file=sys.stderr)
traceback.print_exc(file=sys.stderr)
finally:
_callback_log_reentry.in_diag = False


def _safe_log_debug(logger, msg): # pylint: disable=redefined-outer-name
"""Emit a DEBUG diagnostic without letting a user logging handler error escape.

The inline fallback path must run even when an attached logging handler
raises; the DEBUG record is still attempted through the standard handler,
and any handler error falls back to stderr instead of aborting the
callback dispatch.
"""
if getattr(_callback_log_reentry, "in_diag", False):
print(msg, file=sys.stderr)
return
_callback_log_reentry.in_diag = True
try:
logger.debug(msg) # noqa: SHADOW
except Exception: # pylint: disable=broad-exception-caught
print(msg, file=sys.stderr)
finally:
_callback_log_reentry.in_diag = False


class BytesEncoder(json.JSONEncoder):
Expand Down Expand Up @@ -476,6 +520,49 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
return all_flushed


class _CallbackAttempt:
"""Per-dispatch at-most-once wrapper for a log record processor callback.

See :class:`opentelemetry.sdk.trace._CallbackAttempt` for the rationale.
"""

__slots__ = ("_callback", "_args", "_kwargs", "_lock", "_started", "_finished")

def __init__(self, callback: Callable[..., None], args: tuple[Any, ...], kwargs: dict[str, Any]):
self._callback = callback
self._args = args
self._kwargs = kwargs
self._lock = threading.Lock()
self._started = False
self._finished = threading.Event()

def run(self) -> None:
with self._lock:
if self._started:
return
self._started = True
# Mark that we are executing a processor callback on this thread. If a
# logging handler (e.g. LoggingHandler) synchronously calls back into
# on_emit, _submit_and_wait sees this flag and runs callbacks inline
# instead of submitting to the executor (whose workers are blocked
# inside the outer callback, which would deadlock).
reentry = getattr(_callback_log_reentry, "active", False)
_callback_log_reentry.active = True
try:
self._callback(*self._args, **self._kwargs)
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(_logger, "Exception while calling LogRecordProcessor callback.")
finally:
_callback_log_reentry.active = reentry
self._finished.set()

def wait_if_started(self) -> None:
with self._lock:
started = self._started
if started:
self._finished.wait()


class ConcurrentMultiLogRecordProcessor(LogRecordProcessor):
"""Implementation of :class:`LogRecordProcessor` that forwards all received
events to a list of log processors in parallel.
Expand Down Expand Up @@ -506,12 +593,57 @@ def _submit_and_wait(
*args: Any,
**kwargs: Any,
):
# Reentry guard: if we are already running a callback on this thread, or
# we were dispatched from inside a real logging handler that holds its own
# lock across emit(), run the callbacks inline instead of through the
# executor. Submitting would either queue work no worker can pick up
# (workers blocked inside the outer callback) or block a re-entrant
# worker on the logging handler lock the dispatching thread still holds,
# and future.result() would deadlock in both cases.
if getattr(_callback_log_reentry, "active", False) or getattr(_callback_log_reentry, "in_bridge", False):
for lp in self._log_record_processors:
try:
callback = func(lp)
callback(*args, **kwargs)
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(
_logger,
"Exception while re-entering LogRecordProcessor callback inline.",
)
return
attempts = []
futures = []
for lp in self._log_record_processors:
future = self._executor.submit(func(lp), *args, **kwargs)
futures.append(future)
try:
callback = func(lp)
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(_logger, "Exception while resolving LogRecordProcessor callback.")
continue
attempt = _CallbackAttempt(callback, args, kwargs)
attempts.append(attempt)
try:
futures.append(self._executor.submit(attempt.run))
except RuntimeError:
# The executor may already be shut down when this runs from an
# atexit handler: CPython runs threading._shutdown() before the
# atexit queue, so the LoggerProvider.shutdown() runs. Run
# inline through the SAME attempt; the shared lock prevents
# double invocation even if the work item was already enqueued
# before Thread.start failed.
_safe_log_debug(
_logger,
"Executor is shut down; running LogRecordProcessor callback inline.",
)
attempt.run()
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(_logger, "Exception while submitting LogRecordProcessor callback.")
for future in futures:
future.result()
try:
future.result()
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(_logger, "Exception in LogRecordProcessor worker.")
for attempt in attempts:
attempt.wait_if_started()

def on_emit(self, log_record: ReadWriteLogRecord) -> None:
self._submit_and_wait(lambda lp: lp.on_emit, log_record)
Expand Down Expand Up @@ -710,7 +842,17 @@ def emit(self, record: logging.LogRecord) -> None:
"""
logger = get_logger(record.name, logger_provider=self._logger_provider)
if not isinstance(logger, NoOpLogger):
logger.emit(self._translate(record))
# A real logging handler holds its own lock across emit(). If the
# concurrent multi-processor submits work to its executor and then
# blocks waiting on it, a worker that re-enters logging through this
# same handler would deadlock on that lock. Mark the bridge boundary
# so the multi-processor dispatches inline while we hold the handler
# lock, matching the re-entry guard for callbacks.
_callback_log_reentry.in_bridge = True
try:
logger.emit(self._translate(record))
finally:
_callback_log_reentry.in_bridge = False

def flush(self) -> None:
"""
Expand Down
147 changes: 144 additions & 3 deletions opentelemetry-sdk/src/opentelemetry/sdk/trace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import json
import logging
import os
import sys
import threading
import traceback
import typing
Expand Down Expand Up @@ -75,6 +76,49 @@
from opentelemetry.util._decorator import _agnosticcontextmanager

logger = logging.getLogger(__name__)
_callback_log_reentry = threading.local()


def _safe_log_exception(logger, msg): # pylint: disable=redefined-outer-name
"""Log an exception safely against recursive logging handlers.

If we are already inside a diagnostic log dispatch on this thread, write
the record to stderr directly instead of going through the logging module
to prevent infinite feedback loops. Any exception raised by a user logging
handler during the outer dispatch is contained here.
"""
if getattr(_callback_log_reentry, "in_diag", False):
print(msg, file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return
_callback_log_reentry.in_diag = True
try:
logger.exception(msg) # noqa: SHADOW
except Exception: # pylint: disable=broad-exception-caught
print(msg, file=sys.stderr)
traceback.print_exc(file=sys.stderr)
finally:
_callback_log_reentry.in_diag = False


def _safe_log_debug(logger, msg): # pylint: disable=redefined-outer-name
"""Emit a DEBUG diagnostic without letting a user logging handler error escape.

The inline fallback path must run even when an attached logging handler
raises; the DEBUG record is still attempted through the standard handler,
and any handler error falls back to stderr instead of aborting the
callback dispatch.
"""
if getattr(_callback_log_reentry, "in_diag", False):
print(msg, file=sys.stderr)
return
_callback_log_reentry.in_diag = True
try:
logger.debug(msg) # noqa: SHADOW
except Exception: # pylint: disable=broad-exception-caught
print(msg, file=sys.stderr)
finally:
_callback_log_reentry.in_diag = False


_DEFAULT_OTEL_ATTRIBUTE_COUNT_LIMIT = 128
Expand Down Expand Up @@ -224,6 +268,58 @@ def force_flush(self, timeout_millis: int = 30000) -> bool:
return all_flushed


class _CallbackAttempt:
"""Per-dispatch at-most-once wrapper for a processor callback.

The queue worker and the inline fallback (when ``executor.submit`` raises
``RuntimeError``) share the same attempt. A lock-guarded claim ensures the
underlying callback runs at most once even when ``Thread.start`` fails
after the work item was already enqueued, which previously caused the
callback to run twice. Callback exceptions are contained and logged so a
faulty processor does not block healthy siblings or replace the caller's
own exception.

This scopes to a single dispatch entry; it does not promise global
exactly-once delivery, retry, or bounded shutdown.
"""

__slots__ = ("_callback", "_args", "_kwargs", "_lock", "_started", "_finished")

def __init__(self, callback: Callable[..., None], args: tuple[Any, ...], kwargs: dict[str, Any]):
self._callback = callback
self._args = args
self._kwargs = kwargs
self._lock = threading.Lock()
self._started = False
self._finished = threading.Event()

def run(self) -> None:
with self._lock:
if self._started:
return
self._started = True
# Mark that we are executing a processor callback on this thread. If a
# logging handler (e.g. LoggingHandler) synchronously calls back into
# on_emit/on_start, _submit_and_await sees this flag and runs callbacks
# inline instead of submitting to the executor (whose worker threads
# are blocked inside the outer callback, which would deadlock).
reentry = getattr(_callback_log_reentry, "active", False)
_callback_log_reentry.active = True
try:
self._callback(*self._args, **self._kwargs)
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(logger, "Exception while calling SpanProcessor callback.")
finally:
_callback_log_reentry.active = reentry
self._finished.set()

def wait_if_started(self) -> None:
with self._lock:
started = self._started
if started:
self._finished.wait()


class ConcurrentMultiSpanProcessor(SpanProcessor):
"""Implementation of :class:`SpanProcessor` that forwards all received
events to a list of span processors in parallel.
Expand Down Expand Up @@ -273,12 +369,57 @@ def _submit_and_await(
*args: Any,
**kwargs: Any,
):
# Reentry guard: if we are already running a callback on this thread
# (e.g. a logging handler synchronously called back into on_start),
# run the remaining callbacks inline instead of through the executor.
# Submitting would queue work that no worker can pick up (workers are
# blocked inside the outer callback), and future.result() would
# deadlock. This is bounded: it runs synchronously on the calling
# thread and does not recurse through the executor.
if getattr(_callback_log_reentry, "active", False):
for sp in self._span_processors:
try:
callback = func(sp)
callback(*args, **kwargs)
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(
logger,
"Exception while re-entering SpanProcessor callback inline.",
)
return
attempts = []
futures = []
for sp in self._span_processors:
future = self._executor.submit(func(sp), *args, **kwargs)
futures.append(future)
try:
callback = func(sp)
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(logger, "Exception while resolving SpanProcessor callback.")
continue
attempt = _CallbackAttempt(callback, args, kwargs)
attempts.append(attempt)
try:
futures.append(self._executor.submit(attempt.run))
except RuntimeError:
# The executor may already be shut down when this runs from an
# atexit handler: CPython runs threading._shutdown() before the
# atexit queue, so the ThreadPoolExecutor is closed by the time
# TracerProvider.shutdown() runs. Run inline through the SAME
# attempt; the shared lock prevents double invocation even if
# the work item was already enqueued before Thread.start failed.
_safe_log_debug(
logger,
"Executor is shut down; running SpanProcessor callback inline.",
)
attempt.run()
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(logger, "Exception while submitting SpanProcessor callback.")
for future in futures:
future.result()
try:
future.result()
except Exception: # pylint: disable=broad-exception-caught
_safe_log_exception(logger, "Exception in SpanProcessor worker.")
for attempt in attempts:
attempt.wait_if_started()

def on_start(
self,
Expand Down
Loading
Loading