Skip to content

refactor(observability): infer OTLP TLS from endpoint scheme#90

Merged
stwilt merged 4 commits into
mainfrom
refactor/infer-otlp-tls-from-endpoint
May 15, 2026
Merged

refactor(observability): infer OTLP TLS from endpoint scheme#90
stwilt merged 4 commits into
mainfrom
refactor/infer-otlp-tls-from-endpoint

Conversation

@stwilt

@stwilt stwilt commented May 8, 2026

Copy link
Copy Markdown
Contributor

📋 What does this PR do?

Replaces the always-insecure=True default for the OTLP gRPC span exporter and metrics exporter with TLS inferred from the endpoint scheme via urllib.parse.urlparse:

  • http://...insecure=True (plaintext)
  • https://...insecure=False (TLS)
  • bare host:portinsecure=False (TLS — secure default, matches the OTel SDK)
  • Explicit insecure= in exporter_config / kwargs always wins

Also drops the broken OTLP_GRPC_ENDPOINT_SECURITY env-var lookup in the metrics factory, which never bool-parsed strings and so was effectively insecure=True regardless of the env var's value.

🔍 Why is this needed?

The previous default would silently send telemetry over plaintext gRPC even when the configured endpoint was an https:// URL — a quiet security footgun. Inferring from the URL scheme matches what most OTel SDKs (and the Go exporters this code originally mirrored) do, and gives https:// and bare-host endpoints a secure-by-default behavior. The default http://localhost:4317 still infers insecure=True, so local collectors keep working with no config change.

🧪 How was it tested?

  • pytest tests/test_tracing.py — 24/24 pass
  • New cases cover http://, https://, uppercase scheme, bare host:port, empty/None, and explicit insecure=True override against a bare-host endpoint
  • python -c "from openfilter.filter_runtime.open_telemetry.open_telemetry_exporter_factory import ExporterFactory" — imports clean

🔗 Related Issues

None — no ticket for this change.

✅ Checklist

  • I have signed all commits in compliance with the DCO (git commit -s)
  • I have added or updated tests as needed
  • I have added or updated documentation as needed (no user-facing docs touched — internal exporter default)

@stwilt stwilt requested a review from lucasmundim May 8, 2026 17:37

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid security fix and the bug call-out on the env-var lookup is correct. A few items before merging — flagged inline. Two non-inline items not in the diff that also want attention:

  • RELEASE.md:352 and docs/overview.md:935-952 still document OTLP_GRPC_ENDPOINT_SECURITY / don't mention the new scheme-based inference. Worth a docs pass in this PR or a follow-up.

Comment thread openfilter/filter_runtime/open_telemetry/open_telemetry_exporter_factory.py Outdated
Comment thread tests/test_tracing.py Outdated
Comment thread openfilter/observability/tracing.py Outdated
Comment thread openfilter/observability/tracing.py Outdated

@shingonoide shingonoide left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM on the security direction; +1 to Mundão's metrics-factory regression — that's the merge blocker.

Two complementary notes:

  • OTLP_GRPC_ENDPOINT_SECURITY was a full no-op, not a half-working knob — worth being precise in RELEASE.md beyond just deleting the stale doc. os.getenv(name, True) returns the literal True only when unset; any set value came back as a string, and every non-empty string is truthy, so insecure=True regardless of what an operator wrote. No one was ever getting TLS through this var. The actual behavior change is narrower: operators with bare host:port endpoints flip from plaintext to TLS. That's the line worth in the changelog.
  • New hard import coupling. from openfilter.observability.tracing import infer_otlp_insecure at the top of the metrics factory means the metrics module now fails to import if tracing does. Low-probability but easy to avoid with a lazy import inside the otlp_grpc branch, or by moving the helper to a small openfilter/observability/_otlp.py so neither side imports the other's heavy module.

Otherwise clean — urlparse over regex is the right call, and the gRPC path now infers transport the same way otlp_http already does, which is good consistency.

@stwilt stwilt force-pushed the refactor/infer-otlp-tls-from-endpoint branch from 136ffde to b9405dd Compare May 9, 2026 12:35
@stwilt

stwilt commented May 9, 2026

Copy link
Copy Markdown
Contributor Author

Updated in b9405dd — see inline thread replies for the per-comment items.

@lucasmundim — five inline points addressed; on the docs you flagged: the dead OTLP_GRPC_ENDPOINT_SECURITY line under v0.1.6 of RELEASE.md is gone, and the new v0.1.31 entry incorporates @shingonoide's precision wording. docs/overview.md got a TLS-inference note + an annotation on the gRPC endpoint row in the env-var table.

@shingonoide — both points taken:

  • Changelog precision: the v0.1.31 entry now spells out that os.getenv(name, True) only ever returned the literal True when unset, every set value came back as a truthy string, and the actual operator-visible change is narrow (bare host:port endpoints flip from plaintext to TLS).
  • Import decoupling: helper moved to openfilter/observability/_otlp.py; both factories import from there. tracing.py re-exports infer_otlp_insecure for backward compat with any existing importers (the test file was the only in-tree caller).

@stwilt stwilt requested review from lucasmundim and shingonoide May 9, 2026 13:25
lucasmundim
lucasmundim previously approved these changes May 9, 2026

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All five inline points addressed and the two follow-ups from @shingonoide too. Verified locally:

  • Metrics-factory or "http://localhost:4317" fallback lands correctly — with empty env and no kwargs, the built exporter opens an insecure channel against localhost:4317. The original regression I flagged would have produced a secure channel against the SDK's default endpoint here.
  • End-to-end TLS behavior verified by intercepting grpc.{secure,insecure}_channel: http:// → insecure, https:// → TLS, bare host:port → TLS, and explicit insecure=True against bare host correctly overrides to insecure.
  • 27/27 tests pass across tests/test_tracing.py and the new tests/test_open_telemetry_exporter_factory.py.
  • Decoupling clean: openfilter/observability/_otlp.py is the single source, metrics factory imports from there (no tracing import), and tracing.py re-exports infer_otlp_insecure in __all__ for any external callers.
  • RELEASE.md v0.1.31 entry is precise about the actual behavior change (only bare host:port operators flip plaintext → TLS), and the stale v0.1.6 line is gone. docs/overview.md got the TLS-inference callout.

One minor observation, not blocking: https://... + explicit insecure=True ends up as a secure channel — the underlying OTel SDK preserves TLS when the URL scheme is https:// regardless of the kwarg. The factory passes the kwarg through faithfully; this is SDK behavior, not a PR bug, and the realistic bare-host override case works correctly.

LGTM. Thanks for the careful follow-through on every point.

Replaces the always-`insecure=True` default for the OTLP gRPC span exporter
and metrics exporter with TLS inferred from the endpoint scheme via
`urllib.parse.urlparse`:

- `http://...` → `insecure=True` (plaintext)
- `https://...` → `insecure=False` (TLS)
- bare `host:port` → `insecure=False` (TLS — secure default, matches the OTel SDK)
- Explicit `insecure=` in `exporter_config` / kwargs always wins

The metrics factory now also has the `http://localhost:4317` fallback that
the tracing factory already had, so an unset endpoint stays
plaintext-against-localhost rather than falling through to the SDK's TLS
default.

Drops the broken `OTLP_GRPC_ENDPOINT_SECURITY` env-var lookup in the
metrics factory, which never bool-parsed strings and so was effectively
`insecure=True` regardless of the env var's value — no operator was ever
getting TLS through this var. The actual behavior change for deployments
is narrow: bare `host:port` endpoints flip from plaintext to TLS;
operators relying on plaintext with a bare-host endpoint must now pass
`insecure=True` (or use an `http://` URL).

The `infer_otlp_insecure` helper lives in a new tiny
`openfilter/observability/_otlp.py` so the metrics factory does not have
to import the heavy `tracing` module to read the scheme.

Tests: 27/27 pass — covers helper input matrix, scheme inference in both
factories, explicit `insecure=` override, and the metrics factory's
unset-env-var localhost fallback.

Signed-off-by: stwilt <swilt@plainsight.ai>
@stwilt stwilt force-pushed the refactor/infer-otlp-tls-from-endpoint branch from b9405dd to 610df4d Compare May 11, 2026 13:14

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code review

Found 1 issue:

  1. The "Mirror the tracing factory's localhost fallback" comment overstates parity — only the fallback is mirrored, not the env-var precedence chain.

Comment thread openfilter/filter_runtime/open_telemetry/open_telemetry_exporter_factory.py Outdated
…th tracing

Per review feedback on PR #90: the metrics ExporterFactory only checked
`OTEL_EXPORTER_OTLP_GRPC_ENDPOINT`, while `build_span_exporter` walked
`TELEMETRY_EXPORTER_OTLP_ENDPOINT` -> `OTEL_EXPORTER_OTLP_ENDPOINT` ->
`OTEL_EXPORTER_OTLP_GRPC_ENDPOINT` -> fallback. An operator setting the
Plainsight-convention `TELEMETRY_EXPORTER_OTLP_ENDPOINT` got traces but
metrics silently fell through to localhost.

- `open_telemetry_exporter_factory.py`: extend `otlp_grpc` and `otlp_http`
  endpoint resolution to the same 4-step chain that `build_span_exporter`
  uses; replace the "Mirror the tracing factory's localhost fallback"
  comment with the explicit precedence list.
- `tracing.py`: drop the latent-inconsistency note from
  `build_span_exporter`'s docstring — the chain it called out is now
  aligned across both factories.
- `test_open_telemetry_exporter_factory.py`: add two precedence tests
  covering `TELEMETRY_EXPORTER_OTLP_ENDPOINT` (wins over the OTel-spec
  and legacy GRPC vars) and `OTEL_EXPORTER_OTLP_ENDPOINT` (wins over the
  legacy GRPC var when the Plainsight var is unset).

Tests: 6/6 in test_open_telemetry_exporter_factory.py, 23/23 in
test_tracing.py.

Signed-off-by: stwilt <swilt@plainsight.ai>
@stwilt stwilt requested a review from lucasmundim May 12, 2026 16:01

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review on 3cecd64.

The env-var precedence chain is the right shape and the comment block at lines 25-33 clearly documents the ordering. Two items inline — neither blocking, but the test one is the same shape as the assertIsNotNone weakness we dropped in the prior round and worth tightening before this merges.

Comment thread tests/test_open_telemetry_exporter_factory.py Outdated
Per review feedback on PR #90: the six tests in
``test_open_telemetry_exporter_factory.py`` asserted only
``assertIsInstance(exporter, OTLPGrpcExporter)``, which is the same class
regardless of which env var wins. The precedence-named tests would pass
identically if the precedence were reversed.

- Patch ``OTLPGrpcExporter`` in the factory module's namespace and
  assert ``call_args`` for each case so the test names match what is
  actually being verified.
- Pin the localhost fallback to the literal ``http://localhost:4317``
  and ``insecure=True`` so a regression that lets ``endpoint=None`` or
  TLS reach the SDK against a plaintext local collector fails loudly.
- ``otlp_http`` branch: add an inline note that the missing literal
  fallback is intentional — the OTLP HTTP SDK already defaults
  endpoint=None to ``http://localhost:4318/v1/metrics`` and hard-coding
  a bare-host fallback here would drop the ``/v1/metrics`` path suffix.

Tests: 29/29 in test_open_telemetry_exporter_factory.py + test_tracing.py.
Signed-off-by: stwilt <swilt@plainsight.ai>

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review on 20c0c65.

Both inline items from the prior round are addressed:

  • Test assertions are now load-bearing. Every case patches OTLPGrpcExporter in the factory module's namespace and asserts the exact (endpoint, insecure) kwargs. test_unset_endpoint_falls_back_to_localhost_plaintext pins ("http://localhost:4317", True); the two precedence tests pin the winning endpoint and inferred insecure. Reversing precedence or letting endpoint=None reach the SDK would now fail loudly.
  • otlp_http asymmetry documented. The new comment at L55-60 makes the intentional divergence vs. the gRPC branch explicit, and the SDK-default claim checks out — OTLPMetricExporter(endpoint=None)._endpoint == "http://localhost:4318/v1/metrics", so a bare-host fallback would have silently dropped the path suffix.

29/29 tests pass locally. LGTM.

One non-blocking follow-up worth noting: the otlp_http branch grew the same env-var precedence chain but the new test file only covers otlp_grpc. Reasonable scope cut for this PR; if symmetric coverage is wanted later, the existing tests are a cheap template.

build_exporter in openfilter.observability.client is the metrics builder
that OpenTelemetryClient (and therefore every running filter pod) actually
uses — the file docstring calls it "for the demo" but Filter.__init__ in
filter_runtime imports it on every init. The otlp branch passed neither
insecure= nor a scheme-aware default to OTLPMetricExporter, so the SDK
default (TLS) kicked in against every endpoint shape — including the
Plainsight in-cluster otel-collector's bare host:port form, which is
plaintext. The live failure mode in prod filter pods is a continuous
stream of:

  ssl_transport_security.cc:2125] Handshake failed with error SSL_ERROR_SSL:
    error:100000f7:SSL routines:OPENSSL_internal:WRONG_VERSION_NUMBER
  WARNING  Transient error StatusCode.UNAVAILABLE encountered while
    exporting metrics to otel-collector.monitoring.svc.cluster.local:4317

— TLS client bytes hitting a plaintext gRPC listener.

- client.py: import infer_otlp_insecure from ._otlp and apply the same
  scheme inference build_span_exporter and the metrics ExporterFactory
  use. Explicit insecure= in exporter_config still wins.
- tests/test_observability_client_build_exporter.py: mirror the
  exporter-factory test style — patch OTLPMetricExporter on its source
  module and pin (endpoint, insecure) kwargs for http://, https://, bare
  host:port (the production-regression case), explicit-insecure override,
  unset-everything localhost fallback, and TELEMETRY_EXPORTER_OTLP_ENDPOINT.

Env-var precedence chain is intentionally left as-is here (kwarg ->
TELEMETRY_EXPORTER_OTLP_ENDPOINT -> http://localhost:4317 fallback)
rather than expanded to the four-step chain build_span_exporter uses;
TELEMETRY_EXPORTER_OTLP_ENDPOINT is the only var the live filter
Deployment spec sets, so the narrower chain covers production while
keeping this commit a behavior fix and not a separate refactor.

Tests: 35/35 across tests/test_observability_client_build_exporter.py +
tests/test_open_telemetry_exporter_factory.py + tests/test_tracing.py.

Signed-off-by: stwilt <swilt@plainsight.ai>

Signed-off-by: stwilt <swilt@plainsight.ai>
@stwilt stwilt merged commit 28b4d70 into main May 15, 2026
11 checks passed
@stwilt stwilt deleted the refactor/infer-otlp-tls-from-endpoint branch May 15, 2026 01:30

@lucasmundim lucasmundim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review on 440a52f. The new commit closes the third call site — client.py:build_exporter is the metrics builder OpenTelemetryClient wires up in the live runtime (line 157), so this is the path that was producing the WRONG_VERSION_NUMBER handshake error in filter pods against the in-cluster collector.

35/35 tests pass locally across the three relevant files. The new test file pins exact (endpoint, insecure) kwargs — same load-bearing shape as test_open_telemetry_exporter_factory.py from the prior round.

LGTM. Two non-blocking notes inline, both about scope-symmetry vs. the other two builders, neither introduced by this PR.

@@ -69,7 +70,15 @@ def shutdown(self, timeout: float = 30000) -> None:
or os.getenv("TELEMETRY_EXPORTER_OTLP_ENDPOINT")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing, not introduced here, but worth a follow-up: this env-var chain is shorter than the other two builders we aligned in 3cecd64. ExporterFactory.otlp_grpc and tracing.build_span_exporter.otlp_grpc both also consult OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_GRPC_ENDPOINT before falling back to localhost. An operator setting only the standard OTel env vars would get them honored by traces and by filter_runtime's metrics factory, but not by this builder. Out of scope for this PR (the diff doesn't touch the chain) — flagging for a follow-up that fans out the same 4-step chain here.

Comment thread RELEASE.md

### Changed

- **OTLP gRPC `insecure` flag now inferred from endpoint scheme**: The exporter factory and tracing builder now use `urllib.parse.urlparse` on the configured endpoint — `http://` infers plaintext, `https://` infers TLS, and bare `host:port` infers TLS (secure default, matches the OTel SDK). Explicit `insecure=` in `exporter_config` / kwargs always wins. The default `http://localhost:4317` endpoint still infers `insecure=True`, so local collectors keep working with no config change. The metrics factory's missing localhost fallback was also fixed in this change — an unset endpoint no longer falls through to the SDK's TLS default against a plaintext local collector.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worth a small tightening: this entry says "the exporter factory and tracing builder," but 440a52f adds the same inference to a third site — observability/client.py:build_exporter — which is the metrics path OpenTelemetryClient uses in the live runtime and the one that produced the WRONG_VERSION_NUMBER symptom operators would search the changelog for. Suggest something like "the exporter factory, tracing builder, and OpenTelemetryClient's metrics builder now use urllib.parse.urlparse..." so the changelog matches symptom to fix.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants