Skip to content

Commit c84cecc

Browse files
feat: enable console tracing with auto_instrument
1 parent a52024e commit c84cecc

3 files changed

Lines changed: 87 additions & 24 deletions

File tree

src/sap_cloud_sdk/core/telemetry/auto_instrument.py

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import os
33

44
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter
5+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
56
from traceloop.sdk import Traceloop
67

78
from sap_cloud_sdk.core.telemetry import Module, Operation
@@ -22,24 +23,27 @@ def auto_instrument():
2223
"""
2324
Initialize meta-instrumentation for GenAI tracing. Should be initialized before any AI frameworks.
2425
25-
Traces are exported to the OTEL collector endpoint configured in environment with OTEL_EXPORTER_OTLP_ENDPOINT.
26-
27-
Args:
26+
Traces are exported to the OTEL collector endpoint configured in environment with
27+
OTEL_EXPORTER_OTLP_ENDPOINT, or printed to console when OTEL_TRACES_EXPORTER=console.
2828
"""
29-
otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
29+
otel_endpoint = os.getenv("OTEL_EXPORTER_OTLP_ENDPOINT", "")
30+
console_traces = os.getenv("OTEL_TRACES_EXPORTER", "").lower() == "console"
3031

31-
if not otel_endpoint:
32+
if not otel_endpoint and not console_traces:
3233
logger.warning(
3334
"OTEL_EXPORTER_OTLP_ENDPOINT not set. Instrumentation will be disabled."
3435
)
3536
return
3637

37-
if "v1/traces" not in otel_endpoint:
38-
otel_endpoint = otel_endpoint.rstrip("/") + "/v1/traces"
39-
40-
logger.info(f"Initializing auto instrumentation with endpoint: {otel_endpoint}")
38+
if console_traces:
39+
logger.info("Initializing auto instrumentation with console exporter")
40+
base_exporter = ConsoleSpanExporter()
41+
else:
42+
if "v1/traces" not in otel_endpoint:
43+
otel_endpoint = otel_endpoint.rstrip("/") + "/v1/traces"
44+
logger.info(f"Initializing auto instrumentation with endpoint: {otel_endpoint}")
45+
base_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
4146

42-
base_exporter = OTLPSpanExporter(endpoint=otel_endpoint)
4347
exporter = GenAIAttributeTransformer(base_exporter)
4448

4549
resource = create_resource_attributes_from_env()

src/sap_cloud_sdk/core/telemetry/user-guide.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,15 @@ For production environments, you should ensure that `OTEL_EXPORTER_OTLP_ENDPOINT
139139

140140
### Local Development
141141

142-
Set the OpenTelemetry collector endpoint:
142+
To print traces directly to the console without an OTLP collector, set:
143+
144+
```bash
145+
export OTEL_TRACES_EXPORTER=console
146+
```
147+
148+
Then call `auto_instrument()` as usual — traces will be printed to stdout.
149+
150+
To use an OTLP collector instead:
143151

144152
```bash
145153
export OTEL_EXPORTER_OTLP_ENDPOINT="https://otel-collector.example.com"

tests/core/unit/telemetry/test_auto_instrument.py

Lines changed: 64 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ def mock_traceloop_components():
1414
mocks = {
1515
'traceloop': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.Traceloop')),
1616
'exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.OTLPSpanExporter')),
17+
'console_exporter': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.ConsoleSpanExporter')),
1718
'transformer': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.GenAIAttributeTransformer')),
1819
'create_resource': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument.create_resource_attributes_from_env')),
1920
'get_app_name': stack.enter_context(patch('sap_cloud_sdk.core.telemetry.auto_instrument._get_app_name')),
@@ -24,17 +25,6 @@ def mock_traceloop_components():
2425
class TestAutoInstrument:
2526
"""Test suite for auto_instrument function."""
2627

27-
def test_auto_instrument_without_endpoint(self):
28-
"""Test that auto_instrument warns when OTEL_EXPORTER_OTLP_ENDPOINT is not set."""
29-
with patch.dict('os.environ', {}, clear=True):
30-
with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger:
31-
auto_instrument()
32-
33-
# Should log warning about missing endpoint
34-
mock_logger.warning.assert_called_once()
35-
warning_message = mock_logger.warning.call_args[0][0]
36-
assert "OTEL_EXPORTER_OTLP_ENDPOINT not set" in warning_message
37-
3828
def test_auto_instrument_with_endpoint_success(self, mock_traceloop_components):
3929
"""Test successful auto-instrumentation with valid endpoint."""
4030
mock_traceloop_components['get_app_name'].return_value = 'test-app'
@@ -146,12 +136,73 @@ def test_auto_instrument_legacy_schema_parameter_ignored(self, mock_traceloop_co
146136
"""Test that legacy_schema parameter is accepted but doesn't affect behavior."""
147137
mock_traceloop_components['get_app_name'].return_value = 'test-app'
148138
mock_traceloop_components['create_resource'].return_value = {}
149-
139+
150140
with patch.dict('os.environ', {'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317'}, clear=True):
151141
# Should not raise an error
152142
auto_instrument()
153143
auto_instrument()
154144
auto_instrument()
155-
145+
156146
# Verify Traceloop was initialized each time
157147
assert mock_traceloop_components['traceloop'].init.call_count == 3
148+
149+
def test_auto_instrument_with_console_exporter(self, mock_traceloop_components):
150+
"""Test that auto_instrument uses ConsoleSpanExporter when OTEL_TRACES_EXPORTER=console."""
151+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
152+
mock_traceloop_components['create_resource'].return_value = {}
153+
154+
with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True):
155+
auto_instrument()
156+
157+
mock_traceloop_components['console_exporter'].assert_called_once_with()
158+
mock_traceloop_components['exporter'].assert_not_called()
159+
mock_traceloop_components['traceloop'].init.assert_called_once()
160+
161+
def test_auto_instrument_console_exporter_case_insensitive(self, mock_traceloop_components):
162+
"""Test that OTEL_TRACES_EXPORTER=console matching is case insensitive."""
163+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
164+
mock_traceloop_components['create_resource'].return_value = {}
165+
166+
for value in ['CONSOLE', 'Console', 'CONSOLE']:
167+
mock_traceloop_components['console_exporter'].reset_mock()
168+
mock_traceloop_components['traceloop'].reset_mock()
169+
with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': value}, clear=True):
170+
auto_instrument()
171+
mock_traceloop_components['console_exporter'].assert_called_once_with()
172+
173+
def test_auto_instrument_console_wins_when_both_set(self, mock_traceloop_components):
174+
"""Test that console exporter is used when OTEL_TRACES_EXPORTER=console, even if OTLP endpoint is also set."""
175+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
176+
mock_traceloop_components['create_resource'].return_value = {}
177+
178+
with patch.dict('os.environ', {
179+
'OTEL_EXPORTER_OTLP_ENDPOINT': 'http://localhost:4317',
180+
'OTEL_TRACES_EXPORTER': 'console',
181+
}, clear=True):
182+
auto_instrument()
183+
184+
mock_traceloop_components['console_exporter'].assert_called_once_with()
185+
mock_traceloop_components['exporter'].assert_not_called()
186+
187+
def test_auto_instrument_console_wraps_with_transformer(self, mock_traceloop_components):
188+
"""Test that ConsoleSpanExporter is wrapped with GenAIAttributeTransformer."""
189+
mock_traceloop_components['get_app_name'].return_value = 'test-app'
190+
mock_traceloop_components['create_resource'].return_value = {}
191+
mock_console_instance = MagicMock()
192+
mock_traceloop_components['console_exporter'].return_value = mock_console_instance
193+
194+
with patch.dict('os.environ', {'OTEL_TRACES_EXPORTER': 'console'}, clear=True):
195+
auto_instrument()
196+
197+
mock_traceloop_components['transformer'].assert_called_once_with(mock_console_instance)
198+
199+
def test_auto_instrument_without_endpoint_or_console(self):
200+
"""Test that auto_instrument warns when neither OTLP endpoint nor console exporter is configured."""
201+
with patch.dict('os.environ', {}, clear=True):
202+
with patch('sap_cloud_sdk.core.telemetry.auto_instrument.logger') as mock_logger:
203+
auto_instrument()
204+
205+
mock_logger.warning.assert_called_once()
206+
warning_message = mock_logger.warning.call_args[0][0]
207+
assert "OTEL_EXPORTER_OTLP_ENDPOINT not set" in warning_message
208+

0 commit comments

Comments
 (0)