Skip to content

Commit 7395dc5

Browse files
authored
expose force http functionality and fix it (#212)
* expose force http functionality and fix it * bump version to 0.7.22
1 parent 8d62a28 commit 7395dc5

File tree

5 files changed

+191
-4
lines changed

5 files changed

+191
-4
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
[project]
88
name = "lmnr"
9-
version = "0.7.21"
9+
version = "0.7.22"
1010
description = "Python SDK for Laminar"
1111
authors = [
1212
{ name = "lmnr.ai", email = "[email protected]" }

src/lmnr/opentelemetry_lib/tracing/exporter.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import grpc
22
import re
33
import threading
4+
from urllib.parse import urlparse, urlunparse
45
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
56
from opentelemetry.sdk.trace import ReadableSpan
67
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
@@ -79,11 +80,42 @@ def __init__(
7980
)
8081
self._init_instance()
8182

83+
def _normalize_http_endpoint(self, endpoint: str) -> str:
84+
"""
85+
Normalize HTTP endpoint URL by adding /v1/traces path if no path is present.
86+
87+
Args:
88+
endpoint: The endpoint URL to normalize
89+
90+
Returns:
91+
The normalized endpoint URL with /v1/traces path if needed
92+
"""
93+
try:
94+
parsed = urlparse(endpoint)
95+
# Check if there's no path or only a trailing slash
96+
if not parsed.path or parsed.path == "/":
97+
# Add /v1/traces to the endpoint
98+
new_parsed = parsed._replace(path="/v1/traces")
99+
normalized_url = urlunparse(new_parsed)
100+
logger.info(
101+
f"No path found in HTTP endpoint URL. "
102+
f"Adding default path /v1/traces: {endpoint} -> {normalized_url}"
103+
)
104+
return normalized_url
105+
return endpoint
106+
except Exception as e:
107+
logger.warning(
108+
f"Failed to parse endpoint URL '{endpoint}': {e}. Using as-is."
109+
)
110+
return endpoint
111+
82112
def _init_instance(self):
83113
# Create new instance first (outside critical section for performance)
84114
if self.force_http:
115+
# Normalize HTTP endpoint to ensure it has a path
116+
http_endpoint = self._normalize_http_endpoint(self.endpoint)
85117
new_instance = HTTPOTLPSpanExporter(
86-
endpoint=self.endpoint,
118+
endpoint=http_endpoint,
87119
headers=self.headers,
88120
compression=HTTPCompression.Gzip,
89121
timeout=self.timeout,

src/lmnr/sdk/laminar.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ def initialize(
8181
set_global_tracer_provider: bool = True,
8282
otel_logger_level: int = logging.ERROR,
8383
session_recording_options: SessionRecordingOptions | None = None,
84+
force_http: bool = False,
8485
):
8586
"""Initialize Laminar context across the application.
8687
This method must be called before using any other Laminar methods or
@@ -131,7 +132,8 @@ def initialize(
131132
for browser session recording. Currently supports 'mask_input'\
132133
(bool) to control whether input fields are masked during recording.\
133134
Defaults to None (uses default masking behavior).
134-
135+
force_http (bool, optional): If set to True, the HTTP OTEL exporter will be\
136+
used instead of the gRPC OTEL exporter. Defaults to False.
135137
Raises:
136138
ValueError: If project API key is not set
137139
"""
@@ -198,6 +200,7 @@ def initialize(
198200
set_global_tracer_provider=set_global_tracer_provider,
199201
otel_logger_level=otel_logger_level,
200202
session_recording_options=session_recording_options,
203+
force_http=force_http,
201204
)
202205

203206
@classmethod

src/lmnr/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from packaging import version
44

55

6-
__version__ = "0.7.21"
6+
__version__ = "0.7.22"
77
PYTHON_VERSION = f"{sys.version_info.major}.{sys.version_info.minor}"
88

99

tests/test_exporter.py

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Unit tests for LaminarSpanExporter URL normalization."""
2+
3+
import pytest
4+
from unittest.mock import patch, MagicMock
5+
from lmnr.opentelemetry_lib.tracing.exporter import LaminarSpanExporter
6+
7+
8+
class TestHttpEndpointNormalization:
9+
"""Test cases for HTTP endpoint URL normalization."""
10+
11+
@pytest.fixture
12+
def mock_http_exporter(self):
13+
"""Mock HTTPOTLPSpanExporter to avoid actual initialization."""
14+
with patch(
15+
"lmnr.opentelemetry_lib.tracing.exporter.HTTPOTLPSpanExporter"
16+
) as mock:
17+
mock.return_value = MagicMock()
18+
yield mock
19+
20+
@pytest.fixture
21+
def mock_grpc_exporter(self):
22+
"""Mock OTLPSpanExporter to avoid actual initialization."""
23+
with patch("lmnr.opentelemetry_lib.tracing.exporter.OTLPSpanExporter") as mock:
24+
mock.return_value = MagicMock()
25+
yield mock
26+
27+
def test_http_endpoint_without_path_adds_v1_traces(
28+
self, mock_http_exporter, mock_grpc_exporter
29+
):
30+
"""Test that endpoint without path gets /v1/traces added."""
31+
exporter = LaminarSpanExporter(
32+
base_url="http://localhost:8080",
33+
api_key="test-key",
34+
force_http=True,
35+
)
36+
assert exporter is not None
37+
38+
mock_http_exporter.assert_called_once()
39+
call_kwargs = mock_http_exporter.call_args[1]
40+
assert call_kwargs["endpoint"] == "http://localhost:8080/v1/traces"
41+
42+
def test_http_endpoint_with_trailing_slash_adds_v1_traces(
43+
self, mock_http_exporter, mock_grpc_exporter
44+
):
45+
"""Test that endpoint with trailing slash gets /v1/traces added."""
46+
exporter = LaminarSpanExporter(
47+
base_url="http://localhost:8080/",
48+
api_key="test-key",
49+
force_http=True,
50+
)
51+
assert exporter is not None
52+
53+
mock_http_exporter.assert_called_once()
54+
call_kwargs = mock_http_exporter.call_args[1]
55+
assert call_kwargs["endpoint"] == "http://localhost:8080/v1/traces"
56+
57+
def test_grpc_endpoint_not_normalized(self, mock_http_exporter, mock_grpc_exporter):
58+
"""Test that gRPC endpoints are not normalized (only HTTP)."""
59+
exporter = LaminarSpanExporter(
60+
base_url="http://localhost:8443",
61+
api_key="test-key",
62+
force_http=False,
63+
)
64+
assert exporter is not None
65+
66+
mock_grpc_exporter.assert_called_once()
67+
mock_http_exporter.assert_not_called()
68+
call_kwargs = mock_grpc_exporter.call_args[1]
69+
assert call_kwargs["endpoint"] == "http://localhost:8443"
70+
71+
def test_https_endpoint_without_path_adds_v1_traces(
72+
self, mock_http_exporter, mock_grpc_exporter
73+
):
74+
"""Test that HTTPS endpoint without path gets /v1/traces added."""
75+
exporter = LaminarSpanExporter(
76+
base_url="https://api.example.com:443",
77+
api_key="test-key",
78+
force_http=True,
79+
)
80+
assert exporter is not None
81+
82+
mock_http_exporter.assert_called_once()
83+
call_kwargs = mock_http_exporter.call_args[1]
84+
assert call_kwargs["endpoint"] == "https://api.example.com:443/v1/traces"
85+
86+
def test_info_logging_when_path_added(self, mock_http_exporter, mock_grpc_exporter):
87+
"""Test that info message is logged when path is added."""
88+
with patch("lmnr.opentelemetry_lib.tracing.exporter.logger") as mock_logger:
89+
exporter = LaminarSpanExporter(
90+
base_url="http://localhost:8080",
91+
api_key="test-key",
92+
force_http=True,
93+
)
94+
assert exporter is not None
95+
96+
mock_logger.info.assert_called_once()
97+
call_args = mock_logger.info.call_args[0][0]
98+
assert "No path found in HTTP endpoint URL" in call_args
99+
assert "Adding default path /v1/traces" in call_args
100+
101+
def test_no_logging_when_path_exists(
102+
self, mock_http_exporter, mock_grpc_exporter, caplog
103+
):
104+
"""Test that no info message is logged when path already exists."""
105+
import logging
106+
107+
caplog.set_level(logging.INFO)
108+
109+
exporter = LaminarSpanExporter(
110+
base_url="http://localhost:8080/custom/path",
111+
api_key="test-key",
112+
force_http=True,
113+
)
114+
assert exporter is not None
115+
116+
assert not any(
117+
"No path found in HTTP endpoint URL" in record.message
118+
for record in caplog.records
119+
)
120+
121+
def test_normalize_http_endpoint_directly(self):
122+
"""Test the _normalize_http_endpoint method directly."""
123+
with (
124+
patch(
125+
"lmnr.opentelemetry_lib.tracing.exporter.HTTPOTLPSpanExporter"
126+
) as mock_http,
127+
patch(
128+
"lmnr.opentelemetry_lib.tracing.exporter.OTLPSpanExporter"
129+
) as mock_grpc,
130+
):
131+
mock_http.return_value = MagicMock()
132+
mock_grpc.return_value = MagicMock()
133+
134+
exporter = LaminarSpanExporter(
135+
base_url="http://localhost:8080",
136+
api_key="test-key",
137+
force_http=True,
138+
)
139+
140+
test_cases = [
141+
("http://example.com", "http://example.com/v1/traces"),
142+
("http://example.com/", "http://example.com/v1/traces"),
143+
("https://example.com:443", "https://example.com:443/v1/traces"),
144+
("http://example.com/path", "http://example.com/path"),
145+
("http://example.com/v1/traces", "http://example.com/v1/traces"),
146+
]
147+
148+
for input_url, expected_url in test_cases:
149+
result = exporter._normalize_http_endpoint(input_url)
150+
assert (
151+
result == expected_url
152+
), f"Expected {expected_url}, got {result} for input {input_url}"

0 commit comments

Comments
 (0)