Skip to content

Commit d504350

Browse files
Add HyperDX OTel integration test path (#110)
* Add HyperDX OTel integration test path Wire an opt-in integration test against a live HyperDX endpoint via the OTLP-HTTP exporter, plus a "Production swap" section in example 03's docstring showing the same setup. The integration test (``tests/integration/test_otel_hyperdx_export.py``) is env-gated on ``HYPERDX_API_KEY`` + ``HYPERDX_OTLP_ENDPOINT`` and ``@pytest.mark.integration``; skipped by default. Verifies that the ``BatchSpanProcessor`` + ``OTLPSpanExporter`` pipeline drains cleanly within a 15s deadline. HyperDX-side acceptance is verified by the UI, not the test, because the OTel SDK swallows exporter errors silently. ``opentelemetry-exporter-otlp-proto-http`` lands in the dev dependency group only, capped ``<3`` for parity with the ``[otel]`` extras' SDK pins. Not promoted to a public extras group yet; revisit when more than one downstream user wants OTLP-HTTP export packaged. * Document HYPERDX_OTLP_ENDPOINT path requirement The OTLPSpanExporter uses the ``endpoint`` kwarg verbatim and does not append ``/v1/traces`` itself (that auto-append is only for the host-only ``OTEL_EXPORTER_OTLP_ENDPOINT`` env-var convention this test doesn't use). A host-only URL POSTs to ``/`` and HyperDX 404s, which the OTel SDK swallows silently while ``force_flush`` still returns True — the failure mode this test calls out. Note the path-suffix requirement explicitly in the module docstring and the pytestmark skip reason so future readers don't lose half an hour to a silent failure that wasn't surfaced. * Drain observer queue before flush; fix sync force_flush docs Two CoPilot findings on PR #110: 1. Integration test missed ``await graph.drain()`` between ``invoke()`` and ``observer.force_flush()``. ``invoke()`` returns when the graph reaches END, but observer events sit on a per- invocation queue until the background worker delivers them, so a span that hasn't seen its ``completed`` event yet is still open when ``force_flush`` runs. The live HyperDX run worked only because the queue drained inside the 15s flush window; under load the test could ship only ``started`` halves. 2. Example docstring suggested ``await otel_observer.force_flush()``. ``force_flush`` is synchronous (returns bool, wraps ``TracerProvider.force_flush``); the await would raise ``TypeError`` at runtime. Replaced with the canonical short-lived-process pattern: ``await graph.drain()`` then ``otel_observer.force_flush()`` (sync). * Enforce HYPERDX_OTLP_ENDPOINT path at runtime The documented requirement (env var MUST include ``/v1/traces``) was only enforced through docs, not the test itself. Because the OTel SDK swallows exporter-side errors and ``force_flush`` returns True regardless of the HTTP response, a host-only URL would POST to ``/``, HyperDX would 404, and the test would still report green. This is the silent-failure mode the test's docstring already warned about and the one we hit on this PR's manual validation. Assert the endpoint ends with ``/v1/traces`` before constructing the exporter; the assert raises with a pointer to the expected shape if the env var is misconfigured.
1 parent 0661af0 commit d504350

5 files changed

Lines changed: 176 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The
66

77
## [Unreleased]
88

9+
### Added
10+
11+
- **HyperDX OTel integration test path and "Production swap" docs in example 03.** `examples/03-observer-hooks/main.py`'s module docstring grows a "Production swap" section showing how to substitute the demo's `SimpleSpanProcessor` + `ConsoleSpanExporter` for `BatchSpanProcessor` + `OTLPSpanExporter` pointed at HyperDX (or any other OTLP-HTTP collector). A new opt-in integration test (`tests/integration/test_otel_hyperdx_export.py`, gated by `HYPERDX_API_KEY` + `HYPERDX_OTLP_ENDPOINT` env vars and `@pytest.mark.integration`) drives the same production export path end-to-end against a live endpoint. `opentelemetry-exporter-otlp-proto-http` lands as a dev-only dep; not promoted to a public extras group yet.
12+
913
### Changed (breaking)
1014

1115
- **`OpenAIProvider.ready()` default probe flipped to `chat_completions`.** A new constructor kwarg `readiness_probe: Literal["models", "chat_completions", "both"]` selects which wire path `ready()` exercises; the default is now the chat-completions path (`POST /v1/chat/completions` with `max_tokens=1`), which actually exercises the inference path. The previous catalog-only behavior is still available as `readiness_probe="models"`, and `readiness_probe="both"` runs catalog then chat for the strongest signal. Motivation: OpenAI-compatible proxies (Bifrost and similar) can return 200 on `GET /v1/models` while rejecting `POST /v1/chat/completions`, leaving the catalog probe green while every real call fails. The new default surfaces that class of failure at preflight rather than at first inference. Non-200 chat-probe responses route through `classify_http_error`, so the canonical error categories (`provider_authentication`, `provider_unavailable`, `provider_invalid_model`, etc.) surface consistently. Callers that depended on the catalog-only behavior (cost-sensitive cloud setups where every `ready()` would now bill prompt tokens) can opt back in by passing `readiness_probe="models"`.

examples/03-observer-hooks/main.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,46 @@
3535
LLM_API_KEY=sk-... uv run python main.py "explain why NASA is returning to the moon with Artemis"
3636
3737
(``--all-extras`` pulls in ``opentelemetry-sdk`` for the OTel observer.)
38+
39+
**Production swap: real OTLP exporter (e.g. HyperDX).**
40+
41+
The example wires ``OTelObserver`` to a ``SimpleSpanProcessor`` +
42+
``ConsoleSpanExporter`` so every span prints to stdout. That is fine
43+
for a short-lived demo and wrong for production: synchronous export
44+
blocks each node boundary, and printing is not ingestion. For a real
45+
backend (HyperDX, Honeycomb, Tempo, any OTLP-HTTP collector), swap to
46+
``BatchSpanProcessor`` + ``OTLPSpanExporter`` pointing at your
47+
collector and supplying its auth header. The HyperDX shape::
48+
49+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
50+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
51+
52+
otel_observer = OTelObserver(
53+
span_processor=BatchSpanProcessor(
54+
OTLPSpanExporter(
55+
endpoint="https://in-otel.hyperdx.io/v1/traces",
56+
# HyperDX accepts the API key as a bare ``authorization``
57+
# value. Other collectors expect ``Bearer <token>``;
58+
# check your destination's docs. The bracket-form
59+
# ``os.environ[...]`` is intentional: unlike ``LLM_API_KEY``
60+
# (which permits None for unauthenticated local servers),
61+
# a missing HyperDX key would silently send unauthenticated
62+
# requests, so fail-loud at boot is the right shape.
63+
headers={"authorization": os.environ["HYPERDX_API_KEY"]},
64+
)
65+
),
66+
resource=Resource.create({"service.name": "openarmature-demo-answers"}),
67+
)
68+
69+
Same observer call surface; only the processor + exporter change. The
70+
``OTLPSpanExporter`` lives in the ``opentelemetry-exporter-otlp-proto-http``
71+
package (not in ``[otel]`` extras yet; install it directly while OA
72+
gauges demand). Before short-lived processes exit, call
73+
``await graph.drain()`` (drains the observer's per-invocation event
74+
queue so spans see their ``completed`` events) and then
75+
``otel_observer.force_flush()`` (synchronous; pushes
76+
``BatchSpanProcessor``'s tail through the exporter). The drain + flush
77+
pair ensures the tail lands before teardown.
3878
"""
3979

4080
from __future__ import annotations

pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,11 @@ dev = [
7575
"pyyaml>=6.0",
7676
"ruff>=0.5",
7777
"types-pyyaml",
78+
# Used only by ``tests/integration/test_otel_hyperdx_export.py``
79+
# against a live HyperDX endpoint. Not promoted to a public extras
80+
# group yet: one downstream user, one destination; revisit when
81+
# multiple users want OTLP-HTTP export packaged.
82+
"opentelemetry-exporter-otlp-proto-http>=1.27,<3",
7883
]
7984
docs = [
8085
"mkdocs>=1.6,<2",
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Integration test for OTel span export against a live HyperDX endpoint.
2+
3+
Gated by the presence of ``HYPERDX_API_KEY`` + ``HYPERDX_OTLP_ENDPOINT``
4+
env vars. Skipped in CI and local runs that don't have credentials in
5+
scope; runs end-to-end against HyperDX Cloud (or any other OTLP-HTTP
6+
collector) when invoked from a shell with both env vars sourced.
7+
8+
``HYPERDX_OTLP_ENDPOINT`` MUST be the full traces-collector URL
9+
including the ``/v1/traces`` path suffix, e.g.::
10+
11+
HYPERDX_OTLP_ENDPOINT=https://in-otel.hyperdx.io/v1/traces
12+
13+
``OTLPSpanExporter`` uses the ``endpoint`` kwarg verbatim and does
14+
not append the path itself (that auto-append only happens for the
15+
``OTEL_EXPORTER_OTLP_ENDPOINT`` host-only convention this test does
16+
not use). A host-only URL POSTs to ``/`` and HyperDX 404s.
17+
18+
The test verifies the production export path the documentation
19+
recommends (``BatchSpanProcessor`` + ``OTLPSpanExporter``) drains
20+
cleanly from the local pipeline. The assertion is local-side: the
21+
BatchSpanProcessor's ``force_flush`` succeeded within the deadline.
22+
HyperDX-side acceptance (auth, payload accepted, span visible in the
23+
UI) is verified by checking the HyperDX UI for a span named ``ping``
24+
under service ``openarmature-hyperdx-integration``; the OTel SDK
25+
swallows exporter errors silently, so a local-side success does not
26+
prove the collector received the spans.
27+
"""
28+
29+
from __future__ import annotations
30+
31+
import os
32+
33+
import pytest
34+
35+
# Skip the entire module when credentials / endpoint aren't sourced.
36+
# Avoids an ImportError cascade from the OTLP exporter if its env-var
37+
# fallback also can't find a target.
38+
pytestmark = pytest.mark.skipif(
39+
not (os.environ.get("HYPERDX_API_KEY") and os.environ.get("HYPERDX_OTLP_ENDPOINT")),
40+
reason=(
41+
"Requires HYPERDX_API_KEY + HYPERDX_OTLP_ENDPOINT (live HyperDX endpoint); "
42+
"endpoint MUST include the /v1/traces path suffix"
43+
),
44+
)
45+
46+
47+
@pytest.mark.integration
48+
async def test_otel_observer_pipeline_drains_with_hyperdx_exporter() -> None:
49+
"""End-to-end: invoke a tiny graph under an OTelObserver wired to
50+
the OTLPSpanExporter pointing at the configured HyperDX endpoint,
51+
flush, and assert the local pipeline drained within the deadline.
52+
"""
53+
# Imports inside the function so the heavy OTLP-protobuf
54+
# dependencies don't load when the module is collected and skipped
55+
# under the default ``-m "not integration"`` pytest filter.
56+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
57+
from opentelemetry.sdk.resources import Resource
58+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
59+
60+
from openarmature.graph import END, GraphBuilder, State
61+
from openarmature.observability.otel import OTelObserver
62+
63+
# Enforce the documented endpoint shape at runtime. The
64+
# ``OTLPSpanExporter`` uses the URL verbatim and does not append
65+
# ``/v1/traces`` itself, so a host-only URL POSTs to ``/`` and
66+
# HyperDX 404s; the SDK swallows that response and ``force_flush``
67+
# still returns True, which would mask a misconfigured env var
68+
# behind a passing test.
69+
endpoint = os.environ["HYPERDX_OTLP_ENDPOINT"]
70+
assert endpoint.endswith("/v1/traces"), (
71+
f"HYPERDX_OTLP_ENDPOINT must end with /v1/traces (got {endpoint!r}); "
72+
"OTLPSpanExporter uses the URL verbatim and does not append paths."
73+
)
74+
75+
# HyperDX accepts the API key as a bare ``authorization`` header
76+
# value (no ``Bearer`` prefix). Other OTLP collectors that expect
77+
# ``Bearer <token>`` will need the caller to format the header
78+
# themselves; this is the documented HyperDX shape.
79+
exporter = OTLPSpanExporter(
80+
endpoint=endpoint,
81+
headers={"authorization": os.environ["HYPERDX_API_KEY"]},
82+
)
83+
84+
observer = OTelObserver(
85+
span_processor=BatchSpanProcessor(exporter),
86+
resource=Resource.create({"service.name": "openarmature-hyperdx-integration"}),
87+
)
88+
89+
class _PingState(State):
90+
ping: bool = False
91+
92+
async def _node(_s: _PingState) -> dict[str, bool]:
93+
return {"ping": True}
94+
95+
graph = GraphBuilder(_PingState).add_node("ping", _node).add_edge("ping", END).set_entry("ping").compile()
96+
graph.attach_observer(observer)
97+
98+
try:
99+
final = await graph.invoke(_PingState())
100+
assert final.ping is True
101+
102+
# ``invoke()`` returns when the graph reaches END but observer
103+
# events sit on a per-invocation queue until the background
104+
# worker drains them. Without ``drain()``, a span that hasn't
105+
# yet seen its ``completed`` event is still open when
106+
# ``force_flush`` runs, and the exporter would ship only the
107+
# ``started`` half (or nothing at all). The short-lived-process
108+
# pattern in ``docs/agent/non-obvious-shapes.md`` makes this
109+
# explicit.
110+
await graph.drain()
111+
112+
# Local-side assertion. ``BatchSpanProcessor.force_flush``
113+
# returns True when every registered processor finishes
114+
# flushing within the timeout, False when any one times out.
115+
# The OTel SDK swallows exporter-side errors (401s, schema
116+
# rejections) silently, so a True here proves the pipeline
117+
# drained but not that HyperDX accepted the payload; that
118+
# confirmation is in the HyperDX UI.
119+
flushed = observer.force_flush(timeout_ms=15_000)
120+
assert flushed, "BatchSpanProcessor did not finish flushing within 15s"
121+
finally:
122+
# Releases the BatchSpanProcessor's background export thread;
123+
# ``OTelObserver.shutdown`` is idempotent and calls
124+
# ``_provider.shutdown`` under the hood.
125+
observer.shutdown()

uv.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)