Skip to content

Commit 2a9672c

Browse files
committed
llo handler setup w/ lazy initialization
1 parent abe6c6b commit 2a9672c

File tree

4 files changed

+267
-2
lines changed

4 files changed

+267
-2
lines changed

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

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
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+
from typing import Dict, Optional, Sequence
55

6+
from amazon.opentelemetry.distro._utils import is_agent_observability_enabled
67
from amazon.opentelemetry.distro.exporter.otlp.aws.common.aws_auth_session import AwsAuthSession
8+
from amazon.opentelemetry.distro.llo_handler import LLOHandler
9+
from opentelemetry._logs import get_logger_provider
710
from opentelemetry.exporter.otlp.proto.http import Compression
811
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
912
from opentelemetry.sdk._logs import LoggerProvider
13+
from opentelemetry.sdk.trace import ReadableSpan
14+
from opentelemetry.sdk.trace.export import SpanExportResult
1015

1116

1217
class OTLPAwsSpanExporter(OTLPSpanExporter):
@@ -23,6 +28,7 @@ def __init__(
2328
):
2429
self._aws_region = None
2530
self._logger_provider = logger_provider
31+
self._llo_handler = None
2632

2733
if endpoint:
2834
self._aws_region = endpoint.split(".")[1]
@@ -38,3 +44,28 @@ def __init__(
3844
compression,
3945
session=AwsAuthSession(aws_region=self._aws_region, service="xray"),
4046
)
47+
48+
def _ensure_llo_handler(self):
49+
"""Lazily initialize LLO handler when needed to avoid initialization order issues"""
50+
if self._llo_handler is None and is_agent_observability_enabled():
51+
if self._logger_provider is None:
52+
try:
53+
self._logger_provider = get_logger_provider()
54+
except Exception: # pylint: disable=broad-exception-caught
55+
return False
56+
57+
if self._logger_provider:
58+
self._llo_handler = LLOHandler(self._logger_provider)
59+
return True
60+
61+
return self._llo_handler is not None
62+
63+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
64+
try:
65+
if is_agent_observability_enabled() and self._ensure_llo_handler():
66+
llo_processed_spans = self._llo_handler.process_spans(spans)
67+
return super().export(llo_processed_spans)
68+
except Exception: # pylint: disable=broad-exception-caught
69+
return SpanExportResult.FAILURE
70+
71+
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)