You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Feature Request] Set OpenTelemetry span status to failed for heartbeat timeout cancellations #1047
Is your feature request related to a problem? Please describe.
Heartbeat timeout failures can be a major pain point when working with async Temporal activities. It would be great if the OpenTelemetry TracingInterceptor contrib were able to mark spans as failed for cancellations due to heartbeat timeouts, so we can identify workers with heartbeat issues more easily and assess the scale of the issue.
The SDK metrics also don't help much in this case, since activity_execution_failed doesn't provide a reason/code label.
Describe the solution you'd like
Something like this works for the async/threaded cases, though it doesn't distinguish different kinds of cancellation reasons. It doesn't work for sync, multi-processed activities since they don't raise cancellation errors.
class_TracingActivityInboundInterceptor(
temporalio.worker.ActivityInboundInterceptor
):
def__init__(
self,
next: temporalio.worker.ActivityInboundInterceptor,
root: TracingInterceptor,
) ->None:
super().__init__(next)
self.root=rootasyncdefexecute_activity(
self, input: temporalio.worker.ExecuteActivityInput
) ->Any:
info=temporalio.activity.info()
withself.root.tracer.start_as_current_span(
f"RunActivity:{info.activity_type}",
context=self.root._context_from_headers(input.headers),
attributes={
"temporalWorkflowID": info.workflow_id,
"temporalRunID": info.workflow_run_id,
"temporalActivityID": info.activity_id,
},
kind=opentelemetry.trace.SpanKind.SERVER,
) asspan:
try:
returnawaitsuper().execute_activity(input)
exceptBaseExceptionase:
# Catch heartbeat cancellation errors to mark span as failed# Note: asyncio.CancelledError extends BaseException which would be ingored by spanifisinstance(e, asyncio.CancelledError):
span.record_exception(e)
span.set_status(
opentelemetry.trace.Status(
status_code=opentelemetry.trace.StatusCode.ERROR,
description=f"{type(e).__name__}: {e}",
)
)
raisee
Additional context
This only really applies in the case where the worker/activities are blocked for some reason and fail to heartbeat. Since the activity is still actually running, they receive a cancellation request from the server on their next, eventual heartbeat. If the worker completely died, I wouldn't expect to see any spans at all.
The problem is asyncio.CancelledError extends BaseException, but the OTEL Tracer's start_as_current_span only catches Exception type errors (reference), since exceptions directly subclassing BaseException are usually not considered errors, e.g. GeneratorExit, KeyboardInterrupt, CancelledError (according to that code).
However, in this context, a CancelledError (both async and the sync Temporal kind) can represent a major failure of the system to behave as intended.
The second problem is that it doesn't look like there is enough context in the TracingInterceptor to distinguish different kinds of cancellation reasons, including:
cancelled by user (e.g. in Temporal UI, SDK, or CLI) - shouldn't be marked as failed
cancelled by workflow due to a failure in some other parallel branch, e.g. await asyncio.gather(activity1, activity2, ...) - probably not a failure for this activity?
cancelled by heartbeat timeout - IMO is a failure
cancelled by activity timeout - could be context specific, leaning towards a failure though
cancelled due to activity pause - not a failure
cancelled due to worker shutdown event - not a failure
I'm not sure what the correct semantics should be for each of these cases. It would probably help to align them with the existing SDK metrics, e.g. activity_execution_cancelled and activity_execution_failed (Note: I've not actually seen any activity_execution_cancelled in my app as its Java only...) so not sure if all these cancellation errors are currently being emitted as activity_execution_failed or not? If that is the case, then it might make sense to simply make all cancellations fail the span (though I do think it should be more refined than that, e.g. involuntary cancellations due to something bad happening, vs voluntary cancellations initiated by the user/system).
The _Context object's cancellation_details doesn't seem to contain anything useful, as its not set during the _ActivityWorker's _heartbeat_async method, and the interceptor doesn't have access to the info in _RunningActivity, from what I could tell. Updating the cancellation_details when a heartbeat error is received could solve this part of the issue since we can access it in the interceptor via _Context / cancellation_details accessor.
Lastly, it doesn't look like cancellation happens at all in multi-process workers. After debugging, I saw that even after the whole workflow failed, the sync activity continued until completion. The thread_event=manager.new_event() event set in _ActivityWorker doesn't seem to be actively used yet. The result returned through the TracingInterceptor was the expected return value, not an exception, so these aren't even caught as exception events. However, I rarely have issues with the multi-processor worker, since I do its auto-heartbeating in a background thread. Though, I have seen these fail occasionally, maybe due to GIL blocking or CPU throttling.
It would be nice if cancellations could be applied properly to sync, multi-processed activities to save time and resources. I've raised that as a separate issue: #1048.
Since this fix isn't likely that straight-forward, I'll likely patch this in my own interceptor for now. This has been suggested in these other tracing related issues:
I'm happy to contribute towards all the issues mentioned in this area, if the solution/requirements are clarified. I also want to extend the SDK's runtime metrics support to sync, mp activities too. So I'm not shy to pick up fairly challenging bits of work to help out 🙏🏻
changed the title [-][Feature Request] Set OpenTelemetry span status as failed for heartbeat timeout cancellation[/-][+][Feature Request] Set OpenTelemetry span status to failed for heartbeat timeout cancellations[/+]on Aug 23, 2025
I noticed there is some inconsistency in how spans handle cancellation between async and sync-threaded workers.
Since the threaded worker raises a temporalio.exceptions.CancelledError, which extends Exception, the root span created by this interceptor and any downstream childspans created by the user will be marked automatically as failed.
However, for the async worker, the asyncio.CancelledError isn't treated as a failure at all. This PR fixes the behaviour in the root span, but childspans will be inconsistent, unless they apply the same try/exception same logic.
This also suggests that the interceptor should treat all asyncio.CancelledError errors as failures (regardless of the reason), to be consistent with the sync case, as is. Alternatively, the handling of temporalio.exceptions.CancelledError could be updated to make it consistent with asyncio.CancelledError.
Since [Feature Request] Reclassify Benign Application errors in OpenTelemetry #1041 aims to treat ApplicationError(category=BENIGN) encoded errors (which derives from Exception) as none-failures, this too should probably be handled consistently in the interceptor and user activity implement (if that behaviour is what we want).
Both of these cases suggest it might be a good idea to provide a modified Tracer in activity.py, similar to the metric_meter helper, that applies Temporal specific behaviours when making a span. This would make the interceptor and downstream childspans consistent. However, this would be a much bigger change to create a similar ABC in common.py to avoid bundling the OTEL SDK as a core dependency.
@gregbrowndev appreciate your thoughts. I'm still coming to terms with the details of the OTel ecosystem here. If I follow correctly, the idea of providing a modified Tracer would be to allow the activity author to easily handle span generation in the same way that we would be doing after some of these changes, correct? In that case, I'm not sure I totally follow why we would provide a new abstraction over it inside of the core SDK package. Wouldn't it be possible for the otel integration to provide a modified tracer, and allow the author to set that in otel itself?
If I understand correctly, the concern is that cancellation exceptions for threaded vs asyncio are different? This is a Python issue where they chose to make an asyncio cancellation concept that was not portable. Then they went and changed the base class of their asyncio cancellation exception in 3.8.
We recognize there are a great many differences in how cancellation behavior/exceptions are treated in threaded vs async and the code author may have to account for those. One is that they have different base classes, making libraries like OTel treat them differently (and maybe Sentry and maybe loggers and maybe any number of Python libraries that react to BaseException vs not).
I am not sure trying to make async and threaded cancellation exceptions consistent is worth the confusion to users of each individually. The former expects to do things the Python way. The latter has no Python way, so Temporal does as libraries do and extends Exception. But we do not blindly assume every asyncio cancellation exception raised from an activity is an activity cancellation, we check whether there is a server exception first.
Libraries like OTel choose to treat built-in BaseExceptions differently than user/library defined Exceptions. Same as if you wrote catch Exception and didn't catch asyncio exceptions in any code. I would rather let OTel's behavior be the predictable user behavior that OTel users understand rather than us treat this special asyncio cancelled exception differently than OTel chose to. This comes at the unfortunate cost of different activity types handling exceptions differently, but that happens anyways with loggers, Sentry, homemade metrics doing catch Exception, and, yes OTel context managers etc. But one can have their own interceptor if you want asyncio exceptions to be treated differently than OTel's default.
Activity
[-][Feature Request] Set OpenTelemetry span status as failed for heartbeat timeout cancellation[/-][+][Feature Request] Set OpenTelemetry span status to failed for heartbeat timeout cancellations[/+]gregbrowndev commentedon Aug 25, 2025
Further thoughts on implementation:
I noticed there is some inconsistency in how spans handle cancellation between async and sync-threaded workers.
temporalio.exceptions.CancelledError, which extendsException, the root span created by this interceptor and any downstream childspans created by the user will be marked automatically as failed.asyncio.CancelledErrorisn't treated as a failure at all. This PR fixes the behaviour in the root span, but childspans will be inconsistent, unless they apply the sametry/exceptionsame logic.asyncio.CancelledErrorerrors as failures (regardless of the reason), to be consistent with the sync case, as is. Alternatively, the handling oftemporalio.exceptions.CancelledErrorcould be updated to make it consistent withasyncio.CancelledError.Since [Feature Request] Reclassify Benign Application errors in OpenTelemetry #1041 aims to treat
ApplicationError(category=BENIGN)encoded errors (which derives fromException) as none-failures, this too should probably be handled consistently in the interceptor and user activity implement (if that behaviour is what we want).Both of these cases suggest it might be a good idea to provide a modified
Tracerinactivity.py, similar to themetric_meterhelper, that applies Temporal specific behaviours when making a span. This would make the interceptor and downstream childspans consistent. However, this would be a much bigger change to create a similar ABC in common.py to avoid bundling the OTEL SDK as a core dependency.What do you think?
tconley1428 commentedon Sep 3, 2025
@gregbrowndev appreciate your thoughts. I'm still coming to terms with the details of the OTel ecosystem here. If I follow correctly, the idea of providing a modified
Tracerwould be to allow the activity author to easily handle span generation in the same way that we would be doing after some of these changes, correct? In that case, I'm not sure I totally follow why we would provide a new abstraction over it inside of the core SDK package. Wouldn't it be possible for the otel integration to provide a modified tracer, and allow the author to set that in otel itself?cretz commentedon Sep 4, 2025
If I understand correctly, the concern is that cancellation exceptions for threaded vs asyncio are different? This is a Python issue where they chose to make an asyncio cancellation concept that was not portable. Then they went and changed the base class of their asyncio cancellation exception in 3.8.
We recognize there are a great many differences in how cancellation behavior/exceptions are treated in threaded vs async and the code author may have to account for those. One is that they have different base classes, making libraries like OTel treat them differently (and maybe Sentry and maybe loggers and maybe any number of Python libraries that react to
BaseExceptionvs not).I am not sure trying to make async and threaded cancellation exceptions consistent is worth the confusion to users of each individually. The former expects to do things the Python way. The latter has no Python way, so Temporal does as libraries do and extends
Exception. But we do not blindly assume every asyncio cancellation exception raised from an activity is an activity cancellation, we check whether there is a server exception first.Libraries like OTel choose to treat built-in
BaseExceptions differently than user/library definedExceptions. Same as if you wrotecatch Exceptionand didn't catch asyncio exceptions in any code. I would rather let OTel's behavior be the predictable user behavior that OTel users understand rather than us treat this special asyncio cancelled exception differently than OTel chose to. This comes at the unfortunate cost of different activity types handling exceptions differently, but that happens anyways with loggers, Sentry, homemade metrics doingcatch Exception, and, yes OTel context managers etc. But one can have their own interceptor if you want asyncio exceptions to be treated differently than OTel's default.