Skip to content

Commit ecead76

Browse files
authored
Default opentelemetry_insecure to None so OTEL_EXPORTER_OTLP_INSECURE takes effect (#372)
1 parent 9e35f2c commit ecead76

4 files changed

Lines changed: 93 additions & 14 deletions

File tree

‎docs/introduction/configuration.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ Additional parameters:
214214
- `opentelemetry_endpoint` - will be passed to `OTLPSpanExporter` as endpoint. Under `opentelemetry_exporter_protocol="http"` this is a full URL (e.g. `http://collector:4318/v1/traces`).
215215
- `opentelemetry_namespace` - will be passed to the `Resource`.
216216
- `opentelemetry_exporter_protocol` - OTLP exporter transport: `"grpc"` (default, needs the `otl` extra) or `"http"` (needs the `otl-http` extra, no `grpcio` - installable on free-threaded Python).
217-
- `opentelemetry_insecure` - whether the gRPC OTLP connection is insecure (default: `True`; gRPC only; for `http` the endpoint URL scheme carries security). While it is `True`, an endpoint pointing at a non-local host triggers a warning that telemetry is sent unencrypted.
217+
- `opentelemetry_insecure` - whether the gRPC OTLP connection is insecure (default: `None`; gRPC only; for `http` the endpoint URL scheme carries security). Left `None`, the exporter decides: `OTEL_EXPORTER_OTLP_TRACES_INSECURE` or `OTEL_EXPORTER_OTLP_METRICS_INSECURE` first, then `OTEL_EXPORTER_OTLP_INSECURE`, then the endpoint's scheme, where only `http://` connects in plaintext. Since 2.0 a schemeless endpoint such as `collector:4317` uses TLS, so write `http://collector:4317` for a plaintext collector, or set the field to `True`, which a `unix://` socket needs too. An `https://` endpoint uses TLS whatever the field says. When the connection is plaintext and the endpoint points at a non-local host, a warning says telemetry is sent unencrypted.
218218
- `opentelemetry_instrumentors` - a list of extra instrumentors.
219219
- `opentelemetry_log_traces` - traces will be logged to stdout.
220220
- `opentelemetry_sampler` - an `opentelemetry.sdk.trace.sampling.Sampler` deciding which traces are recorded. Unset, the SDK's own default applies: `parentbased_always_on`, which records every trace that is not the child of a non-recording remote parent, unless `OTEL_TRACES_SAMPLER` / `OTEL_TRACES_SAMPLER_ARG` say otherwise.
@@ -244,8 +244,8 @@ counted by both.
244244
```python
245245
config = FastAPIConfig(
246246
service_name="my-service",
247-
opentelemetry_endpoint="localhost:4317",
248-
opentelemetry_metrics_endpoint="localhost:4317",
247+
opentelemetry_endpoint="http://localhost:4317",
248+
opentelemetry_metrics_endpoint="http://localhost:4317",
249249
)
250250
```
251251

‎lite_bootstrap/instruments/opentelemetry_instrument.py‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ class OpenTelemetryConfig(OpenTelemetryServiceFieldsConfig):
7474
)
7575
opentelemetry_endpoint: str | None = None
7676
opentelemetry_metrics_endpoint: str | None = None
77-
opentelemetry_insecure: bool = True
77+
opentelemetry_insecure: bool | None = None
7878
opentelemetry_exporter_protocol: typing.Literal["grpc", "http"] = "grpc"
7979
opentelemetry_instrumentors: list[typing.Union[InstrumentorWithParams, "BaseInstrumentor"]] = dataclasses.field(
8080
default_factory=list
@@ -96,32 +96,44 @@ def __post_init__(self) -> None:
9696

9797
def _parse_remote_insecure_hosts(self) -> list[str]:
9898
"""Return each distinct host an insecure, non-local endpoint points at, in declaration order."""
99-
endpoints = (self.opentelemetry_endpoint, self.opentelemetry_metrics_endpoint)
100-
hosts = (self._parse_remote_insecure_host(endpoint) for endpoint in endpoints)
99+
endpoints = (
100+
(self.opentelemetry_endpoint, "OTEL_EXPORTER_OTLP_TRACES_INSECURE"),
101+
(self.opentelemetry_metrics_endpoint, "OTEL_EXPORTER_OTLP_METRICS_INSECURE"),
102+
)
103+
hosts = (self._parse_remote_insecure_host(endpoint, env_var) for endpoint, env_var in endpoints)
101104
return list(dict.fromkeys(host for host in hosts if host is not None))
102105

103-
def _parse_remote_insecure_host(self, endpoint: str | None) -> str | None:
106+
def _parse_remote_insecure_host(self, endpoint: str | None, signal_insecure_env_var: str) -> str | None:
104107
"""Return the host name if the endpoint is insecure AND non-local; else None."""
105108
if self.opentelemetry_exporter_protocol != "grpc":
106109
return None
107-
if not endpoint or not self.opentelemetry_insecure:
108-
return None
109-
if endpoint.startswith("unix://"):
110+
if not endpoint or endpoint.startswith("unix://"):
110111
return None
111112
# urlparse treats schemeless input as `path`, misparsing `host:port` forms.
112113
# Prepend `//` so urlparse always sees a network-location-style input.
113114
raw = endpoint
114115
if "://" not in raw:
115116
raw = f"//{raw}"
116117
parsed = urllib.parse.urlparse(raw)
117-
# The gRPC exporter forces TLS for https whatever insecure says.
118-
if parsed.scheme == "https":
118+
if not self._is_grpc_plaintext(parsed.scheme, signal_insecure_env_var):
119119
return None
120120
host = (parsed.hostname or "").lower()
121121
if host in _LOCAL_HOSTS:
122122
return None
123123
return host
124124

125+
def _is_grpc_plaintext(self, scheme: str, signal_insecure_env_var: str) -> bool:
126+
"""Decide insecure as the gRPC exporter does: https, then the field, the variables, the scheme."""
127+
if scheme == "https":
128+
return False
129+
if self.opentelemetry_insecure is not None:
130+
return self.opentelemetry_insecure
131+
for env_var in (signal_insecure_env_var, "OTEL_EXPORTER_OTLP_INSECURE"):
132+
value = os.environ.get(env_var)
133+
if value is not None:
134+
return value.lower() == "true"
135+
return scheme == "http"
136+
125137

126138
@dataclasses.dataclass(kw_only=True, frozen=True)
127139
class OpenTelemetryAsgiConfig(OpenTelemetryConfig):

‎tests/instruments/test_opentelemetry_instrument.py‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,73 @@ def test_opentelemetry_config_no_warning_for_unix_socket_endpoint() -> None:
293293
)
294294

295295

296+
_INSECURE_ENV_VARS: typing.Final = (
297+
"OTEL_EXPORTER_OTLP_INSECURE",
298+
"OTEL_EXPORTER_OTLP_TRACES_INSECURE",
299+
"OTEL_EXPORTER_OTLP_METRICS_INSECURE",
300+
)
301+
_SIGNAL_INSECURE_ENV_VAR: typing.Final = "OTEL_EXPORTER_OTLP_{signal}_INSECURE"
302+
_REMOTE_HTTP: typing.Final = "http://collector.example.com:4317"
303+
_REMOTE_SCHEMELESS: typing.Final = "collector.example.com:4317"
304+
305+
306+
@pytest.fixture
307+
def without_insecure_env(monkeypatch: pytest.MonkeyPatch) -> None:
308+
for env_var in _INSECURE_ENV_VARS:
309+
monkeypatch.delenv(env_var, raising=False)
310+
311+
312+
@pytest.mark.usefixtures("without_insecure_env")
313+
@pytest.mark.parametrize("signal", ["traces", "metrics"])
314+
@pytest.mark.parametrize(
315+
("endpoint", "insecure", "env", "expected_plaintext"),
316+
[
317+
pytest.param(_REMOTE_HTTP, None, {}, True, id="http-scheme"),
318+
pytest.param(_REMOTE_SCHEMELESS, None, {}, False, id="schemeless"),
319+
pytest.param("https://collector.example.com:4317", True, {}, False, id="https-forces-tls"),
320+
pytest.param(_REMOTE_SCHEMELESS, True, {}, True, id="field-on"),
321+
pytest.param(_REMOTE_HTTP, False, {}, False, id="field-off"),
322+
pytest.param(_REMOTE_HTTP, None, {"OTEL_EXPORTER_OTLP_INSECURE": "false"}, False, id="generic-env-off"),
323+
pytest.param(_REMOTE_SCHEMELESS, None, {"OTEL_EXPORTER_OTLP_INSECURE": "true"}, True, id="generic-env-on"),
324+
pytest.param(_REMOTE_HTTP, None, {_SIGNAL_INSECURE_ENV_VAR: "false"}, False, id="signal-env-off"),
325+
pytest.param(_REMOTE_SCHEMELESS, None, {_SIGNAL_INSECURE_ENV_VAR: "true"}, True, id="signal-env-on"),
326+
pytest.param(
327+
_REMOTE_SCHEMELESS,
328+
None,
329+
{_SIGNAL_INSECURE_ENV_VAR: "true", "OTEL_EXPORTER_OTLP_INSECURE": "false"},
330+
True,
331+
id="signal-env-wins",
332+
),
333+
pytest.param(_REMOTE_HTTP, True, {_SIGNAL_INSECURE_ENV_VAR: "false"}, True, id="field-wins"),
334+
],
335+
)
336+
def test_insecure_warning_fires_exactly_when_the_grpc_exporter_sends_plaintext(
337+
signal: typing.Literal["traces", "metrics"],
338+
endpoint: str,
339+
insecure: bool | None,
340+
env: dict[str, str],
341+
expected_plaintext: bool,
342+
) -> None:
343+
signal_env = {env_var.format(signal=signal.upper()): value for env_var, value in env.items()}
344+
endpoint_field = "opentelemetry_endpoint" if signal == "traces" else "opentelemetry_metrics_endpoint"
345+
fields: dict[str, typing.Any] = {endpoint_field: endpoint}
346+
if insecure is not None:
347+
fields["opentelemetry_insecure"] = insecure
348+
349+
with patch.dict(os.environ, signal_env):
350+
with warnings.catch_warnings(record=True) as caught:
351+
warnings.simplefilter("always")
352+
config = OpenTelemetryConfig(**fields)
353+
instrument = OpenTelemetryInstrument(bootstrap_config=config)
354+
exporter = instrument._build_span_exporter() if signal == "traces" else instrument._build_metric_exporter() # noqa: SLF001
355+
assert exporter is not None
356+
try:
357+
assert exporter._insecure is expected_plaintext # noqa: SLF001 # ty: ignore[unresolved-attribute]
358+
assert any("unencrypted" in str(w.message) for w in caught) is expected_plaintext
359+
finally:
360+
exporter.shutdown()
361+
362+
296363
def test_opentelemetry_teardown_aggregates_instrumentor_and_shutdown_errors() -> None:
297364
class BoomInstrumentor(BaseInstrumentor):
298365
def instrumentation_dependencies(self) -> typing.Collection[str]:
@@ -401,7 +468,7 @@ def test_bootstrap_grpc_protocol_uses_grpc_exporter() -> None:
401468
try:
402469
with patch.object(otel_module, "OTLPGrpcSpanExporter") as mock_grpc:
403470
instrument.bootstrap()
404-
mock_grpc.assert_called_once_with(endpoint="localhost:4317", insecure=True)
471+
mock_grpc.assert_called_once_with(endpoint="localhost:4317", insecure=None)
405472
finally:
406473
instrument.teardown()
407474

‎tests/test_config_cascade.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515
from lite_bootstrap.instruments.opentelemetry_instrument import OpenTelemetryConfig
1616

1717

18-
_REMOTE_INSECURE_ENDPOINT: typing.Final = "collector.example.com:4317"
18+
_REMOTE_INSECURE_ENDPOINT: typing.Final = "http://collector.example.com:4317"
1919

2020

2121
@pytest.mark.parametrize(

0 commit comments

Comments
 (0)