diff --git a/packages/traceloop-sdk/tests/test_sdk_initialization.py b/packages/traceloop-sdk/tests/test_sdk_initialization.py index 124e8d25e2..538631f381 100644 --- a/packages/traceloop-sdk/tests/test_sdk_initialization.py +++ b/packages/traceloop-sdk/tests/test_sdk_initialization.py @@ -86,6 +86,22 @@ def test_grpc_schemes(self, endpoint, expected_endpoint, insecure): init_spans_exporter(endpoint, {}) mock.assert_called_once_with(endpoint=expected_endpoint, headers={}, insecure=insecure) + def test_grpc_metadata_keys_are_lowercase(self): + from traceloop.sdk.tracing.tracing import init_spans_exporter + from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter + + with patch.object(OTLPSpanExporter, "__init__", return_value=None) as mock: + init_spans_exporter( + "grpc://localhost:4317", + {"Authorization": "Bearer test", "X-Api-Key": "test"}, + ) + + mock.assert_called_once_with( + endpoint="localhost:4317", + headers={"authorization": "Bearer test", "x-api-key": "test"}, + insecure=True, + ) + @pytest.mark.parametrize("endpoint,match", [ ("grpc://collector.example.com:4317", "grpc:// OTLP export is insecure"), ("grpc://host.docker.internal:4317", "grpc:// OTLP export is insecure"), @@ -127,6 +143,24 @@ def test_logging_local_grpc_endpoint_can_be_insecure(self): insecure=True, ) + @pytest.mark.parametrize("signal", ["metrics", "logging"]) + def test_grpc_signal_metadata_keys_are_lowercase(self, signal): + if signal == "metrics": + from traceloop.sdk.metrics.metrics import init_metrics_exporter as factory + from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter as exporter + else: + from traceloop.sdk.logging.logging import init_logging_exporter as factory + from opentelemetry.exporter.otlp.proto.grpc._log_exporter import OTLPLogExporter as exporter + + with patch.object(exporter, "__init__", return_value=None) as mock: + factory("grpc://localhost:4317", {"Authorization": "Bearer test"}) + + mock.assert_called_once_with( + endpoint="localhost:4317", + headers={"authorization": "Bearer test"}, + insecure=True, + ) + @pytest.mark.parametrize("factory_path,endpoint", [ ("traceloop.sdk.metrics.metrics.init_metrics_exporter", "grpc://collector.example.com:4317"), ("traceloop.sdk.logging.logging.init_logging_exporter", "otlp://collector.example.com:4317"), diff --git a/packages/traceloop-sdk/traceloop/sdk/exporters/headers.py b/packages/traceloop-sdk/traceloop/sdk/exporters/headers.py new file mode 100644 index 0000000000..c72dbca080 --- /dev/null +++ b/packages/traceloop-sdk/traceloop/sdk/exporters/headers.py @@ -0,0 +1,11 @@ +from typing import Dict + + +def grpc_metadata_headers(headers: Dict[str, str]) -> Dict[str, str]: + """Normalize HTTP-style headers for gRPC metadata. + + gRPC metadata keys must be lowercase. The public SDK accepts conventional + HTTP casing (for example ``Authorization``), so every gRPC signal exporter + must normalize keys before passing them to grpcio. + """ + return {str(key).lower(): str(value) for key, value in headers.items()} diff --git a/packages/traceloop-sdk/traceloop/sdk/logging/logging.py b/packages/traceloop-sdk/traceloop/sdk/logging/logging.py index 375f5f9dd6..99e9f09788 100644 --- a/packages/traceloop-sdk/traceloop/sdk/logging/logging.py +++ b/packages/traceloop-sdk/traceloop/sdk/logging/logging.py @@ -17,6 +17,7 @@ FortifyRootGRPCLogExporter as GRPCExporter, FortifyRootHTTPLogExporter as HTTPExporter, ) +from traceloop.sdk.exporters.headers import grpc_metadata_headers LOCAL_EXPORT_HOSTS = {"localhost"} @@ -139,7 +140,11 @@ def init_logging_exporter(endpoint: str, headers: Dict[str, str]) -> LogExporter grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(trimmed_endpoint) return cast( LogExporter, - GRPCExporter(endpoint=grpc_endpoint, headers=headers, insecure=insecure), + GRPCExporter( + endpoint=grpc_endpoint, + headers=grpc_metadata_headers(headers), + insecure=insecure, + ), ) diff --git a/packages/traceloop-sdk/traceloop/sdk/metrics/metrics.py b/packages/traceloop-sdk/traceloop/sdk/metrics/metrics.py index b85d66f0d2..f3308b39f3 100644 --- a/packages/traceloop-sdk/traceloop/sdk/metrics/metrics.py +++ b/packages/traceloop-sdk/traceloop/sdk/metrics/metrics.py @@ -21,6 +21,7 @@ FortifyRootGRPCMetricExporter as GRPCExporter, FortifyRootHTTPMetricExporter as HTTPExporter, ) +from traceloop.sdk.exporters.headers import grpc_metadata_headers LOCAL_EXPORT_HOSTS = {"localhost"} @@ -125,7 +126,11 @@ def init_metrics_exporter(endpoint: str, headers: Dict[str, str]) -> MetricExpor base_url = f"{base_url}/v1/metrics" return HTTPExporter(endpoint=base_url, headers=headers) grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(trimmed_endpoint) - return GRPCExporter(endpoint=grpc_endpoint, headers=headers, insecure=insecure) + return GRPCExporter( + endpoint=grpc_endpoint, + headers=grpc_metadata_headers(headers), + insecure=insecure, + ) def init_metrics_provider( diff --git a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py index 81d0d3bbb2..d9c10b7c55 100644 --- a/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py +++ b/packages/traceloop-sdk/traceloop/sdk/tracing/tracing.py @@ -34,6 +34,7 @@ FortifyRootGRPCSpanExporter as GRPCExporter, FortifyRootHTTPSpanExporter as HTTPExporter, ) +from traceloop.sdk.exporters.headers import grpc_metadata_headers from traceloop.sdk.tracing.content_allow_list import ContentAllowList from traceloop.sdk.utils import is_notebook from traceloop.sdk.utils.package_check import is_package_installed @@ -412,17 +413,23 @@ def init_spans_exporter(api_endpoint: str, headers: Dict[str, str]) -> SpanExpor case "grpc": grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(api_endpoint) return GRPCExporter( - endpoint=grpc_endpoint, headers=headers, insecure=insecure + endpoint=grpc_endpoint, + headers=grpc_metadata_headers(headers), + insecure=insecure, ) case "grpcs": grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(api_endpoint) return GRPCExporter( - endpoint=grpc_endpoint, headers=headers, insecure=insecure + endpoint=grpc_endpoint, + headers=grpc_metadata_headers(headers), + insecure=insecure, ) case _: grpc_endpoint, insecure = _resolve_grpc_exporter_endpoint(api_endpoint) return GRPCExporter( - endpoint=grpc_endpoint, headers=headers, insecure=insecure + endpoint=grpc_endpoint, + headers=grpc_metadata_headers(headers), + insecure=insecure, )