11from __future__ import annotations
22
33import asyncio
4+ import gc
45import logging
6+ import queue
7+ import sys
8+ import threading
59import uuid
610from concurrent .futures import ThreadPoolExecutor
711from dataclasses import dataclass
812from datetime import timedelta
913from typing import Iterable , List , Optional
1014
15+ import opentelemetry .context
1116import pytest
1217from opentelemetry .sdk .trace import ReadableSpan , TracerProvider
1318from opentelemetry .sdk .trace .export import SimpleSpanProcessor
1722from temporalio import activity , workflow
1823from temporalio .client import Client , WithStartWorkflowOperation , WorkflowUpdateStage
1924from temporalio .common import RetryPolicy , WorkflowIDConflictPolicy
20- from temporalio .contrib .opentelemetry import TracingInterceptor
25+ from temporalio .contrib .opentelemetry import (
26+ TracingInterceptor ,
27+ TracingWorkflowInboundInterceptor ,
28+ )
2129from temporalio .contrib .opentelemetry import workflow as otel_workflow
2230from temporalio .exceptions import ApplicationError , ApplicationErrorCategory
2331from temporalio .testing import WorkflowEnvironment
2432from temporalio .worker import UnsandboxedWorkflowRunner , Worker
33+ from tests .helpers import LogCapturer
34+ from tests .helpers .cache_eviction import (
35+ CacheEvictionTearDownWorkflow ,
36+ WaitForeverWorkflow ,
37+ wait_forever_activity ,
38+ )
2539
2640
2741@dataclass
@@ -420,7 +434,10 @@ def dump_spans(
420434 span_links : List [str ] = []
421435 for link in span .links :
422436 for link_span in spans :
423- if link_span .context .span_id == link .context .span_id :
437+ if (
438+ link_span .context is not None
439+ and link_span .context .span_id == link .context .span_id
440+ ):
424441 span_links .append (link_span .name )
425442 span_str += f" (links: { ', ' .join (span_links )} )"
426443 # Signals can duplicate in rare situations, so we make sure not to
@@ -430,7 +447,7 @@ def dump_spans(
430447 ret .append (span_str )
431448 ret += dump_spans (
432449 spans ,
433- parent_id = span .context .span_id ,
450+ parent_id = span .context .span_id if span . context else None ,
434451 with_attributes = with_attributes ,
435452 indent_depth = indent_depth + 1 ,
436453 )
@@ -547,3 +564,50 @@ async def test_opentelemetry_benign_exception(client: Client):
547564# * workflow failure and wft failure
548565# * signal with start
549566# * signal failure and wft failure from signal
567+
568+
569+ def test_opentelemetry_safe_detach ():
570+ class _fake_self :
571+ def _load_workflow_context_carrier (* args ):
572+ return None
573+
574+ def _set_on_context (self , ctx ):
575+ return opentelemetry .context .set_value ("test-key" , "test-value" , ctx )
576+
577+ def _completed_span (* args , ** kwargs ):
578+ pass
579+
580+ # create a context manager and force enter to happen on this thread
581+ context_manager = TracingWorkflowInboundInterceptor ._top_level_workflow_context (
582+ _fake_self (), # type: ignore
583+ success_is_complete = True ,
584+ )
585+ context_manager .__enter__ ()
586+
587+ # move reference to context manager into queue
588+ q : queue .Queue = queue .Queue ()
589+ q .put (context_manager )
590+ del context_manager
591+
592+ def worker ():
593+ # pull reference from queue and delete the last reference
594+ context_manager = q .get ()
595+ del context_manager
596+ # force gc
597+ gc .collect ()
598+
599+ with LogCapturer ().logs_captured (opentelemetry .context .logger ) as capturer :
600+ # run forced gc on other thread so exit happens there
601+ t = threading .Thread (target = worker )
602+ t .start ()
603+ t .join (timeout = 5 )
604+
605+ def otel_context_error (record : logging .LogRecord ) -> bool :
606+ return (
607+ record .name == "opentelemetry.context"
608+ and "Failed to detach context" in record .message
609+ )
610+
611+ assert (
612+ capturer .find (otel_context_error ) is None
613+ ), "Detach from context message should not be logged"
0 commit comments