Skip to content

Commit 69b7ea0

Browse files
authored
LLO Handler Setup w/ Lazy Initialization (#394)
1 parent 5e106d6 commit 69b7ea0

File tree

4 files changed

+271
-2
lines changed

4 files changed

+271
-2
lines changed

aws-opentelemetry-distro/src/amazon/opentelemetry/distro/exporter/otlp/aws/traces/otlp_aws_span_exporter.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,20 @@
11
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
22
# SPDX-License-Identifier: Apache-2.0
33

4-
from typing import Dict, Optional
4+
import logging
5+
from typing import Dict, Optional, Sequence
56

7+
from amazon.opentelemetry.distro._utils import is_agent_observability_enabled
68
from amazon.opentelemetry.distro.exporter.otlp.aws.common.aws_auth_session import AwsAuthSession
9+
from amazon.opentelemetry.distro.llo_handler import LLOHandler
10+
from opentelemetry._logs import get_logger_provider
711
from opentelemetry.exporter.otlp.proto.http import Compression
812
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
913
from opentelemetry.sdk._logs import LoggerProvider
14+
from opentelemetry.sdk.trace import ReadableSpan
15+
from opentelemetry.sdk.trace.export import SpanExportResult
16+
17+
logger = logging.getLogger(__name__)
1018

1119

1220
class OTLPAwsSpanExporter(OTLPSpanExporter):
@@ -23,6 +31,7 @@ def __init__(
2331
):
2432
self._aws_region = None
2533
self._logger_provider = logger_provider
34+
self._llo_handler = None
2635

2736
if endpoint:
2837
self._aws_region = endpoint.split(".")[1]
@@ -38,3 +47,29 @@ def __init__(
3847
compression,
3948
session=AwsAuthSession(aws_region=self._aws_region, service="xray"),
4049
)
50+
51+
def _ensure_llo_handler(self):
52+
"""Lazily initialize LLO handler when needed to avoid initialization order issues"""
53+
if self._llo_handler is None and is_agent_observability_enabled():
54+
if self._logger_provider is None:
55+
try:
56+
self._logger_provider = get_logger_provider()
57+
except Exception as exc: # pylint: disable=broad-exception-caught
58+
logger.debug("Failed to get logger provider: %s", exc)
59+
return False
60+
61+
if self._logger_provider:
62+
self._llo_handler = LLOHandler(self._logger_provider)
63+
return True
64+
65+
return self._llo_handler is not None
66+
67+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
68+
try:
69+
if is_agent_observability_enabled() and self._ensure_llo_handler():
70+
llo_processed_spans = self._llo_handler.process_spans(spans)
71+
return super().export(llo_processed_spans)
72+
except Exception: # pylint: disable=broad-exception-caught
73+
return SpanExportResult.FAILURE
74+
75+
return super().export(spans)
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
from opentelemetry.sdk._logs import LoggerProvider
4+
5+
6+
class LLOHandler:
7+
"""
8+
Utility class for handling Large Language Objects (LLO) in OpenTelemetry spans.
9+
10+
LLOHandler performs three primary functions:
11+
1. Identifies input/output prompt content in spans
12+
2. Extracts and transforms these attributes into an OpenTelemetry Gen AI Event
13+
3. Filters input/output prompts from spans to maintain privacy and reduce span size
14+
15+
This LLOHandler supports the following third-party instrumentation libraries:
16+
- Strands
17+
- OpenInference
18+
- Traceloop/OpenLLMetry
19+
- OpenLIT
20+
"""
21+
22+
def __init__(self, logger_provider: LoggerProvider):
23+
"""
24+
Initialize an LLOHandler with the specified logger provider.
25+
26+
This constructor sets up the event logger provider, configures the event logger,
27+
and initializes the patterns used to identify LLO attributes.
28+
29+
Args:
30+
logger_provider: The OpenTelemetry LoggerProvider used for emitting events.
31+
Global LoggerProvider instance injected from our AwsOpenTelemetryConfigurator
32+
"""
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
from unittest import TestCase
5+
from unittest.mock import MagicMock
6+
7+
from amazon.opentelemetry.distro.llo_handler import LLOHandler
8+
from opentelemetry.sdk._logs import LoggerProvider
9+
10+
11+
class TestLLOHandler(TestCase):
12+
def test_init_with_logger_provider(self):
13+
# Test LLOHandler initialization with a logger provider
14+
mock_logger_provider = MagicMock(spec=LoggerProvider)
15+
16+
handler = LLOHandler(logger_provider=mock_logger_provider)
17+
18+
# Since the __init__ method only has 'pass' in the implementation,
19+
# we can only verify that the handler is created without errors
20+
self.assertIsInstance(handler, LLOHandler)
21+
22+
def test_init_stores_logger_provider(self):
23+
# Test that logger provider is stored (if implementation is added)
24+
mock_logger_provider = MagicMock(spec=LoggerProvider)
25+
26+
handler = LLOHandler(logger_provider=mock_logger_provider)
27+
28+
# This test assumes the implementation will store the logger_provider
29+
# When the actual implementation is added, update this test accordingly
30+
self.assertIsInstance(handler, LLOHandler)
31+
32+
def test_process_spans_method_exists(self): # pylint: disable=no-self-use
33+
# Test that process_spans method exists (for interface contract)
34+
mock_logger_provider = MagicMock(spec=LoggerProvider)
35+
LLOHandler(logger_provider=mock_logger_provider)
36+
37+
# Verify the handler has the process_spans method
38+
# This will fail until the method is implemented
39+
# self.assertTrue(hasattr(handler, 'process_spans'))
40+
# self.assertTrue(callable(getattr(handler, 'process_spans', None)))

aws-opentelemetry-distro/tests/amazon/opentelemetry/distro/test_otlp_aws_span_exporter.py

Lines changed: 163 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@
22
# SPDX-License-Identifier: Apache-2.0
33

44
from unittest import TestCase
5-
from unittest.mock import MagicMock
5+
from unittest.mock import MagicMock, patch
66

77
from amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter import OTLPAwsSpanExporter
8+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
89
from opentelemetry.sdk._logs import LoggerProvider
10+
from opentelemetry.sdk.trace import ReadableSpan
11+
from opentelemetry.sdk.trace.export import SpanExportResult
912

1013

1114
class TestOTLPAwsSpanExporter(TestCase):
@@ -27,3 +30,162 @@ def test_init_without_logger_provider(self):
2730

2831
self.assertIsNone(exporter._logger_provider)
2932
self.assertEqual(exporter._aws_region, "us-west-2")
33+
self.assertIsNone(exporter._llo_handler)
34+
35+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
36+
def test_ensure_llo_handler_when_disabled(self, mock_is_enabled):
37+
# Test _ensure_llo_handler when agent observability is disabled
38+
mock_is_enabled.return_value = False
39+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
40+
41+
exporter = OTLPAwsSpanExporter(endpoint=endpoint)
42+
result = exporter._ensure_llo_handler()
43+
44+
self.assertFalse(result)
45+
self.assertIsNone(exporter._llo_handler)
46+
mock_is_enabled.assert_called_once()
47+
48+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.get_logger_provider")
49+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
50+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.LLOHandler")
51+
def test_ensure_llo_handler_lazy_initialization(
52+
self, mock_llo_handler_class, mock_is_enabled, mock_get_logger_provider
53+
):
54+
# Test lazy initialization of LLO handler when enabled
55+
mock_is_enabled.return_value = True
56+
mock_logger_provider = MagicMock(spec=LoggerProvider)
57+
mock_get_logger_provider.return_value = mock_logger_provider
58+
mock_llo_handler = MagicMock()
59+
mock_llo_handler_class.return_value = mock_llo_handler
60+
61+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
62+
exporter = OTLPAwsSpanExporter(endpoint=endpoint)
63+
64+
# First call should initialize
65+
result = exporter._ensure_llo_handler()
66+
67+
self.assertTrue(result)
68+
self.assertEqual(exporter._llo_handler, mock_llo_handler)
69+
mock_llo_handler_class.assert_called_once_with(mock_logger_provider)
70+
mock_get_logger_provider.assert_called_once()
71+
72+
# Second call should not re-initialize
73+
mock_llo_handler_class.reset_mock()
74+
mock_get_logger_provider.reset_mock()
75+
76+
result = exporter._ensure_llo_handler()
77+
78+
self.assertTrue(result)
79+
mock_llo_handler_class.assert_not_called()
80+
mock_get_logger_provider.assert_not_called()
81+
82+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.get_logger_provider")
83+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
84+
def test_ensure_llo_handler_with_existing_logger_provider(self, mock_is_enabled, mock_get_logger_provider):
85+
# Test when logger_provider is already provided
86+
mock_is_enabled.return_value = True
87+
mock_logger_provider = MagicMock(spec=LoggerProvider)
88+
89+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
90+
exporter = OTLPAwsSpanExporter(endpoint=endpoint, logger_provider=mock_logger_provider)
91+
92+
with patch(
93+
"amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.LLOHandler"
94+
) as mock_llo_handler_class:
95+
mock_llo_handler = MagicMock()
96+
mock_llo_handler_class.return_value = mock_llo_handler
97+
98+
result = exporter._ensure_llo_handler()
99+
100+
self.assertTrue(result)
101+
self.assertEqual(exporter._llo_handler, mock_llo_handler)
102+
mock_llo_handler_class.assert_called_once_with(mock_logger_provider)
103+
mock_get_logger_provider.assert_not_called()
104+
105+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.get_logger_provider")
106+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
107+
def test_ensure_llo_handler_get_logger_provider_fails(self, mock_is_enabled, mock_get_logger_provider):
108+
# Test when get_logger_provider raises exception
109+
mock_is_enabled.return_value = True
110+
mock_get_logger_provider.side_effect = Exception("Failed to get logger provider")
111+
112+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
113+
exporter = OTLPAwsSpanExporter(endpoint=endpoint)
114+
115+
result = exporter._ensure_llo_handler()
116+
117+
self.assertFalse(result)
118+
self.assertIsNone(exporter._llo_handler)
119+
120+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
121+
def test_export_with_llo_disabled(self, mock_is_enabled):
122+
# Test export when LLO is disabled
123+
mock_is_enabled.return_value = False
124+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
125+
126+
exporter = OTLPAwsSpanExporter(endpoint=endpoint)
127+
128+
# Mock the parent class export method
129+
with patch.object(OTLPSpanExporter, "export") as mock_parent_export:
130+
mock_parent_export.return_value = SpanExportResult.SUCCESS
131+
132+
spans = [MagicMock(spec=ReadableSpan), MagicMock(spec=ReadableSpan)]
133+
result = exporter.export(spans)
134+
135+
self.assertEqual(result, SpanExportResult.SUCCESS)
136+
mock_parent_export.assert_called_once_with(spans)
137+
self.assertIsNone(exporter._llo_handler)
138+
139+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
140+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.get_logger_provider")
141+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.LLOHandler")
142+
def test_export_with_llo_enabled(self, mock_llo_handler_class, mock_get_logger_provider, mock_is_enabled):
143+
# Test export when LLO is enabled and successfully processes spans
144+
mock_is_enabled.return_value = True
145+
mock_logger_provider = MagicMock(spec=LoggerProvider)
146+
mock_get_logger_provider.return_value = mock_logger_provider
147+
148+
mock_llo_handler = MagicMock()
149+
mock_llo_handler_class.return_value = mock_llo_handler
150+
151+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
152+
exporter = OTLPAwsSpanExporter(endpoint=endpoint)
153+
154+
# Mock spans and processed spans
155+
original_spans = [MagicMock(spec=ReadableSpan), MagicMock(spec=ReadableSpan)]
156+
processed_spans = [MagicMock(spec=ReadableSpan), MagicMock(spec=ReadableSpan)]
157+
mock_llo_handler.process_spans.return_value = processed_spans
158+
159+
# Mock the parent class export method
160+
with patch.object(OTLPSpanExporter, "export") as mock_parent_export:
161+
mock_parent_export.return_value = SpanExportResult.SUCCESS
162+
163+
result = exporter.export(original_spans)
164+
165+
self.assertEqual(result, SpanExportResult.SUCCESS)
166+
mock_llo_handler.process_spans.assert_called_once_with(original_spans)
167+
mock_parent_export.assert_called_once_with(processed_spans)
168+
169+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.is_agent_observability_enabled")
170+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.get_logger_provider")
171+
@patch("amazon.opentelemetry.distro.exporter.otlp.aws.traces.otlp_aws_span_exporter.LLOHandler")
172+
def test_export_with_llo_processing_failure(
173+
self, mock_llo_handler_class, mock_get_logger_provider, mock_is_enabled
174+
):
175+
# Test export when LLO processing fails
176+
mock_is_enabled.return_value = True
177+
mock_logger_provider = MagicMock(spec=LoggerProvider)
178+
mock_get_logger_provider.return_value = mock_logger_provider
179+
180+
mock_llo_handler = MagicMock()
181+
mock_llo_handler_class.return_value = mock_llo_handler
182+
mock_llo_handler.process_spans.side_effect = Exception("LLO processing failed")
183+
184+
endpoint = "https://xray.us-east-1.amazonaws.com/v1/traces"
185+
exporter = OTLPAwsSpanExporter(endpoint=endpoint)
186+
187+
spans = [MagicMock(spec=ReadableSpan), MagicMock(spec=ReadableSpan)]
188+
189+
result = exporter.export(spans)
190+
191+
self.assertEqual(result, SpanExportResult.FAILURE)

0 commit comments

Comments
 (0)