Skip to content

Commit 2ce2509

Browse files
ChrisJBurnsclaude
andauthored
Split OTLP endpoint path to fix Langfuse/LangSmith integration (#4815)
* Split OTLP endpoint path to fix Langfuse/LangSmith integration The OTLP HTTP SDK's WithEndpoint() only accepts host:port, but users need to include a path for platforms like Langfuse and LangSmith. The path component was being URL-encoded, breaking requests. Parse the endpoint into host:port and path, using WithURLPath() for the path. Fixes #4811 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Address review feedback on OTLP endpoint path fix - Strip http:// and https:// prefixes defensively in splitEndpointPath so the CLI path (which skips NormalizeTelemetryConfig) works correctly - Extract "/v1/traces" and "/v1/metrics" as package-level constants - Add test cases: bare trailing slash, scheme-prefixed endpoints - Restore error-path coverage for metrics exporter (invalid CA cert) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Document OTLP path suffix constants with spec reference Link to the OTLP/HTTP spec and explain why we need to append these suffixes manually when a custom base path is provided. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb213f1 commit 2ce2509

6 files changed

Lines changed: 174 additions & 14 deletions

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package otlp
5+
6+
import "strings"
7+
8+
// Default URL path suffixes for the OTLP/HTTP protocol, as defined in the
9+
// OpenTelemetry specification:
10+
// https://opentelemetry.io/docs/specs/otlp/#otlphttp-request
11+
//
12+
// The Go OTLP SDK normally appends these automatically. However, when the user
13+
// provides a custom base path (e.g. "/api/public/otel" for Langfuse), we must
14+
// call WithURLPath which replaces the entire path. In that case we concatenate
15+
// the base path with the appropriate suffix ourselves (e.g.
16+
// "/api/public/otel" + "/v1/traces").
17+
const (
18+
otlpTracesPath = "/v1/traces"
19+
otlpMetricsPath = "/v1/metrics"
20+
)
21+
22+
// splitEndpointPath separates an OTLP endpoint string into its host:port and
23+
// path components. If no path is present, basePath is empty.
24+
//
25+
// The function defensively strips http:// and https:// prefixes so it works
26+
// correctly even when the scheme has not been removed upstream (e.g. the CLI
27+
// path, which does not call NormalizeTelemetryConfig).
28+
func splitEndpointPath(endpoint string) (hostPort, basePath string) {
29+
endpoint = strings.TrimPrefix(endpoint, "https://")
30+
endpoint = strings.TrimPrefix(endpoint, "http://")
31+
32+
idx := strings.Index(endpoint, "/")
33+
if idx < 0 {
34+
return endpoint, ""
35+
}
36+
return endpoint[:idx], strings.TrimRight(endpoint[idx:], "/")
37+
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// SPDX-FileCopyrightText: Copyright 2025 Stacklok, Inc.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package otlp
5+
6+
import (
7+
"testing"
8+
9+
"github.com/stretchr/testify/assert"
10+
)
11+
12+
func TestSplitEndpointPath(t *testing.T) {
13+
t.Parallel()
14+
15+
tests := []struct {
16+
name string
17+
endpoint string
18+
wantHostPort string
19+
wantBasePath string
20+
}{
21+
{
22+
name: "host and port only",
23+
endpoint: "localhost:4318",
24+
wantHostPort: "localhost:4318",
25+
wantBasePath: "",
26+
},
27+
{
28+
name: "hostname without port",
29+
endpoint: "otel-collector.local",
30+
wantHostPort: "otel-collector.local",
31+
wantBasePath: "",
32+
},
33+
{
34+
name: "Langfuse endpoint with path",
35+
endpoint: "cloud.langfuse.com/api/public/otel",
36+
wantHostPort: "cloud.langfuse.com",
37+
wantBasePath: "/api/public/otel",
38+
},
39+
{
40+
name: "LangSmith endpoint with port and path",
41+
endpoint: "smith.langchain.com:443/api/v1/otel",
42+
wantHostPort: "smith.langchain.com:443",
43+
wantBasePath: "/api/v1/otel",
44+
},
45+
{
46+
name: "trailing slash stripped",
47+
endpoint: "cloud.langfuse.com/api/public/otel/",
48+
wantHostPort: "cloud.langfuse.com",
49+
wantBasePath: "/api/public/otel",
50+
},
51+
{
52+
name: "host:port with trailing slash only",
53+
endpoint: "localhost:4318/",
54+
wantHostPort: "localhost:4318",
55+
wantBasePath: "",
56+
},
57+
{
58+
name: "https scheme stripped before splitting",
59+
endpoint: "https://cloud.langfuse.com/api/public/otel",
60+
wantHostPort: "cloud.langfuse.com",
61+
wantBasePath: "/api/public/otel",
62+
},
63+
{
64+
name: "http scheme stripped before splitting",
65+
endpoint: "http://localhost:4318",
66+
wantHostPort: "localhost:4318",
67+
wantBasePath: "",
68+
},
69+
{
70+
name: "https scheme with host only",
71+
endpoint: "https://api.honeycomb.io",
72+
wantHostPort: "api.honeycomb.io",
73+
wantBasePath: "",
74+
},
75+
{
76+
name: "empty string",
77+
endpoint: "",
78+
wantHostPort: "",
79+
wantBasePath: "",
80+
},
81+
}
82+
83+
for _, tt := range tests {
84+
t.Run(tt.name, func(t *testing.T) {
85+
t.Parallel()
86+
87+
hostPort, basePath := splitEndpointPath(tt.endpoint)
88+
assert.Equal(t, tt.wantHostPort, hostPort)
89+
assert.Equal(t, tt.wantBasePath, basePath)
90+
})
91+
}
92+
}

pkg/telemetry/providers/otlp/metrics.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,13 @@ func NewMetricReader(ctx context.Context, config Config) (sdkmetric.Reader, erro
2727
}
2828

2929
func createMetricExporter(ctx context.Context, config Config) (sdkmetric.Exporter, error) {
30+
host, basePath := splitEndpointPath(config.Endpoint)
3031
opts := []otlpmetrichttp.Option{
31-
otlpmetrichttp.WithEndpoint(config.Endpoint),
32+
otlpmetrichttp.WithEndpoint(host),
33+
}
34+
35+
if basePath != "" {
36+
opts = append(opts, otlpmetrichttp.WithURLPath(basePath+otlpMetricsPath))
3237
}
3338

3439
if len(config.Headers) > 0 {

pkg/telemetry/providers/otlp/metrics_test.go

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func TestCreateMetricExporter(t *testing.T) {
1616
tests := []struct {
1717
name string
1818
config Config
19+
ctx func() context.Context
1920
wantErr bool
2021
errMsg string
2122
}{
@@ -26,6 +27,7 @@ func TestCreateMetricExporter(t *testing.T) {
2627
Headers: map[string]string{"x-api-key": "secret"},
2728
Insecure: true,
2829
},
30+
ctx: func() context.Context { return context.Background() },
2931
wantErr: false,
3032
},
3133
{
@@ -34,24 +36,37 @@ func TestCreateMetricExporter(t *testing.T) {
3436
Endpoint: "localhost:4318",
3537
Insecure: false,
3638
},
39+
ctx: func() context.Context { return context.Background() },
3740
wantErr: false,
3841
},
3942
{
40-
name: "error creating metrics exporter due to malformed endpoint",
43+
name: "endpoint with custom path",
4144
config: Config{
42-
Endpoint: "malformed//:4318",
45+
Endpoint: "cloud.langfuse.com/api/public/otel",
46+
Headers: map[string]string{"Authorization": "Basic abc123"},
4347
Insecure: false,
4448
},
49+
ctx: func() context.Context { return context.Background() },
50+
wantErr: false,
51+
},
52+
{
53+
name: "error creating metrics exporter due to invalid CA cert",
54+
config: Config{
55+
Endpoint: "localhost:4318",
56+
Insecure: false,
57+
CACertPath: "/nonexistent/ca.crt",
58+
},
59+
ctx: func() context.Context { return context.Background() },
4560
wantErr: true,
46-
errMsg: "invalid URL escape",
61+
errMsg: "failed to configure TLS for metric exporter",
4762
},
4863
}
4964

5065
for _, tt := range tests {
5166
t.Run(tt.name, func(t *testing.T) {
5267
t.Parallel()
5368

54-
ctx := context.Background()
69+
ctx := tt.ctx()
5570
exporter, err := createMetricExporter(ctx, tt.config)
5671

5772
if tt.wantErr {
@@ -109,17 +124,13 @@ func TestNewMetricReader(t *testing.T) {
109124
wantErr: false,
110125
},
111126
{
112-
name: "expect error creating metrics exporter due to malformed endpoint",
127+
name: "endpoint with custom path",
113128
config: Config{
114-
Endpoint: "malformed//:4318",
115-
Headers: map[string]string{
116-
"x-api-key": "secret",
117-
"x-env": "production",
118-
},
129+
Endpoint: "cloud.langfuse.com/api/public/otel",
130+
Headers: map[string]string{"Authorization": "Basic abc123"},
119131
Insecure: false,
120132
},
121-
wantErr: true,
122-
errMsg: "invalid URL escape",
133+
wantErr: false,
123134
},
124135
}
125136

pkg/telemetry/providers/otlp/tracing.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ import (
1515
)
1616

1717
func createTraceExporter(ctx context.Context, config Config) (sdktrace.SpanExporter, error) {
18+
host, basePath := splitEndpointPath(config.Endpoint)
1819
opts := []otlptracehttp.Option{
19-
otlptracehttp.WithEndpoint(config.Endpoint),
20+
otlptracehttp.WithEndpoint(host),
21+
}
22+
23+
if basePath != "" {
24+
opts = append(opts, otlptracehttp.WithURLPath(basePath+otlpTracesPath))
2025
}
2126

2227
if len(config.Headers) > 0 {

pkg/telemetry/providers/otlp/tracing_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ func TestCreateTraceExporter(t *testing.T) {
5555
ctx: func() context.Context { return context.Background() },
5656
wantErr: false,
5757
},
58+
{
59+
name: "endpoint with custom path",
60+
config: Config{
61+
Endpoint: "cloud.langfuse.com/api/public/otel",
62+
Headers: map[string]string{"Authorization": "Basic abc123"},
63+
Insecure: false,
64+
},
65+
ctx: func() context.Context { return context.Background() },
66+
wantErr: false,
67+
},
5868
{
5969
name: "error creating sdk-span-exporter due to error (cancelled context)",
6070
config: Config{

0 commit comments

Comments
 (0)