Skip to content

feat(ai): add OpenTelemetry integration for AI span export#494

Open
richardsolomou wants to merge 3 commits intomasterfrom
feat/otel-integration
Open

feat(ai): add OpenTelemetry integration for AI span export#494
richardsolomou wants to merge 3 commits intomasterfrom
feat/otel-integration

Conversation

@richardsolomou
Copy link
Copy Markdown
Member

@richardsolomou richardsolomou commented Apr 9, 2026

Problem

Users instrumenting their AI/LLM applications with OpenTelemetry have no way to route AI spans to PostHog without writing custom glue code. This is the Python equivalent of PostHog/posthog-js#3358.

Changes

Adds a posthog.ai.otel module with two integration patterns:

  • PostHogSpanProcessor (recommended) — self-contained SpanProcessor that wraps BatchSpanProcessor + OTLPSpanExporter, filtering to only forward AI spans to PostHog's OTLP endpoint at /i/v0/ai/otel
  • PostHogTraceExporterSpanExporter that filters AI spans before forwarding, for setups that only accept an exporter (e.g. passed to SimpleSpanProcessor)
  • is_ai_span() — shared predicate matching gen_ai.*, llm.*, ai.*, traceloop.* prefixes on span names and attribute keys

Also adds posthog[otel] optional dependency group for opentelemetry-sdk and opentelemetry-exporter-otlp-proto-http.

Usage

from opentelemetry import trace
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.resources import Resource, SERVICE_NAME
from posthog.ai.otel import PostHogSpanProcessor

resource = Resource(
    attributes={
        SERVICE_NAME: "my-app",
        "posthog.distinct_id": "user-123",
    }
)
provider = TracerProvider(resource=resource)
provider.add_span_processor(
    PostHogSpanProcessor(api_key="phc_...")
)
trace.set_tracer_provider(provider)

Consistency with #482

Uses the same OTLP endpoint (/i/v0/ai/otel) and auth header format (Authorization: Bearer {api_key}) as the migrated examples in #482.

How did you test this code?

31 unit tests covering span filtering, processor forwarding/dropping, exporter filtering, endpoint configuration, and lifecycle delegation.

Add PostHogSpanProcessor and PostHogTraceExporter that filter
AI-related OTel spans and forward them to PostHog's OTLP endpoint.

Generated-By: PostHog Code
Task-Id: 1ba1f07a-1453-4162-90a8-665958c5fe46
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 9, 2026

posthog-python Compliance Report

Date: 2026-04-09 08:45:50 UTC
Duration: 172ms

✅ All Tests Passed!

0/0 tests passed


@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps bot commented Apr 9, 2026

Vulnerabilities

No security concerns identified. The API key is passed as a Bearer token in HTTP headers to PostHog's own endpoint, which is the expected pattern. No user-controlled data reaches the exporter endpoint URL.

Prompt To Fix All With AI
This is a comment left during a code review.
Path: posthog/ai/otel/processor.py
Line: 17

Comment:
**`DEFAULT_HOST` duplicated in both modules**

`DEFAULT_HOST = "https://us.i.posthog.com"` is defined identically in both `processor.py` and `exporter.py`, violating the OnceAndOnlyOnce rule. Move it to `spans.py` (or a small `_constants.py`) and import it in both places.

```suggestion
from .spans import is_ai_span, DEFAULT_HOST
```
(after moving the constant to `spans.py`)

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: posthog/test/ai/otel/test_spans.py
Line: 15-58

Comment:
**Non-parameterised tests — repo convention violated**

The four name-prefix tests and four attribute-key tests are structurally identical; the only difference is the input string. The repo convention is to use parameterised tests for exactly this pattern. Using `@parameterized.expand` would halve the line count and make adding a new prefix a one-line change.

```python
from parameterized import parameterized

class TestIsAISpan(unittest.TestCase):
    @parameterized.expand([
        ("gen_ai", "gen_ai.chat"),
        ("llm",    "llm.call"),
        ("ai",     "ai.completion"),
        ("traceloop", "traceloop.workflow"),
    ])
    def test_matches_ai_name_prefix(self, _name, span_name):
        self.assertTrue(is_ai_span(_make_span(span_name)))

    @parameterized.expand([
        ("gen_ai",    {"gen_ai.system": "openai"}),
        ("llm",       {"llm.model": "gpt-4"}),
        ("ai",        {"ai.provider": "anthropic"}),
        ("traceloop", {"traceloop.entity.name": "chain"}),
    ])
    def test_matches_ai_attribute_key(self, _name, attrs):
        self.assertTrue(is_ai_span(_make_span("http.request", attrs)))
```
The same pattern applies to the negative cases in `test_rejects_non_ai_name`.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: posthog/test/ai/otel/test_exporter.py
Line: 9-13

Comment:
**`_make_span` helper duplicated across all three test files**

The same `_make_span` helper (lines 9-13 here, lines 7-11 in `test_processor.py`, lines 7-11 in `test_spans.py`) is copy-pasted verbatim into every test module. Extract it to a shared `posthog/test/ai/otel/conftest.py` or a small `_helpers.py` so it is expressed once.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "feat(ai): add OpenTelemetry integration ..." | Re-trigger Greptile

Aligns with the endpoint used by the OTel examples in PR #482.

Generated-By: PostHog Code
Task-Id: 1ba1f07a-1453-4162-90a8-665958c5fe46
Move DEFAULT_HOST to spans.py, extract shared make_span helper to
conftest.py, and parameterize span filter tests.

Generated-By: PostHog Code
Task-Id: 1ba1f07a-1453-4162-90a8-665958c5fe46
@richardsolomou richardsolomou requested a review from a team April 9, 2026 08:49
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.

1 participant