Skip to content

Commit b9154c8

Browse files
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.
1 parent 0661af0 commit b9154c8

5 files changed

Lines changed: 138 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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,43 @@
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). Remember to ``await otel_observer.force_flush()``
73+
before short-lived processes exit; ``BatchSpanProcessor`` ships in
74+
the background and would otherwise drop the tail.
3875
"""
3976

4077
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: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
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+
The test verifies the production export path the documentation
9+
recommends (``BatchSpanProcessor`` + ``OTLPSpanExporter``) drains
10+
cleanly from the local pipeline. The assertion is local-side: the
11+
BatchSpanProcessor's ``force_flush`` succeeded within the deadline.
12+
HyperDX-side acceptance (auth, payload accepted, span visible in the
13+
UI) is verified by checking the HyperDX UI for a span named ``ping``
14+
under service ``openarmature-hyperdx-integration``; the OTel SDK
15+
swallows exporter errors silently, so a local-side success does not
16+
prove the collector received the spans.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import os
22+
23+
import pytest
24+
25+
# Skip the entire module when credentials / endpoint aren't sourced.
26+
# Avoids an ImportError cascade from the OTLP exporter if its env-var
27+
# fallback also can't find a target.
28+
pytestmark = pytest.mark.skipif(
29+
not (os.environ.get("HYPERDX_API_KEY") and os.environ.get("HYPERDX_OTLP_ENDPOINT")),
30+
reason="Requires HYPERDX_API_KEY + HYPERDX_OTLP_ENDPOINT (live HyperDX endpoint)",
31+
)
32+
33+
34+
@pytest.mark.integration
35+
async def test_otel_observer_pipeline_drains_with_hyperdx_exporter() -> None:
36+
"""End-to-end: invoke a tiny graph under an OTelObserver wired to
37+
the OTLPSpanExporter pointing at the configured HyperDX endpoint,
38+
flush, and assert the local pipeline drained within the deadline.
39+
"""
40+
# Imports inside the function so the heavy OTLP-protobuf
41+
# dependencies don't load when the module is collected and skipped
42+
# under the default ``-m "not integration"`` pytest filter.
43+
from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
44+
from opentelemetry.sdk.resources import Resource
45+
from opentelemetry.sdk.trace.export import BatchSpanProcessor
46+
47+
from openarmature.graph import END, GraphBuilder, State
48+
from openarmature.observability.otel import OTelObserver
49+
50+
# HyperDX accepts the API key as a bare ``authorization`` header
51+
# value (no ``Bearer`` prefix). Other OTLP collectors that expect
52+
# ``Bearer <token>`` will need the caller to format the header
53+
# themselves; this is the documented HyperDX shape.
54+
exporter = OTLPSpanExporter(
55+
endpoint=os.environ["HYPERDX_OTLP_ENDPOINT"],
56+
headers={"authorization": os.environ["HYPERDX_API_KEY"]},
57+
)
58+
59+
observer = OTelObserver(
60+
span_processor=BatchSpanProcessor(exporter),
61+
resource=Resource.create({"service.name": "openarmature-hyperdx-integration"}),
62+
)
63+
64+
class _PingState(State):
65+
ping: bool = False
66+
67+
async def _node(_s: _PingState) -> dict[str, bool]:
68+
return {"ping": True}
69+
70+
graph = GraphBuilder(_PingState).add_node("ping", _node).add_edge("ping", END).set_entry("ping").compile()
71+
graph.attach_observer(observer)
72+
73+
try:
74+
final = await graph.invoke(_PingState())
75+
assert final.ping is True
76+
77+
# Local-side assertion. ``BatchSpanProcessor.force_flush``
78+
# returns True when every registered processor finishes
79+
# flushing within the timeout, False when any one times out.
80+
# The OTel SDK swallows exporter-side errors (401s, schema
81+
# rejections) silently, so a True here proves the pipeline
82+
# drained but not that HyperDX accepted the payload; that
83+
# confirmation is in the HyperDX UI.
84+
flushed = observer.force_flush(timeout_ms=15_000)
85+
assert flushed, "BatchSpanProcessor did not finish flushing within 15s"
86+
finally:
87+
# Releases the BatchSpanProcessor's background export thread;
88+
# ``OTelObserver.shutdown`` is idempotent and calls
89+
# ``_provider.shutdown`` under the hood.
90+
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)