Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-otters-observe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-go": minor
---

Add the OpenTelemetry bridge for AI observability as an independently installable nested Go module.
10 changes: 10 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ updates:
go-dependencies:
patterns:
- "*"
- package-ecosystem: "gomod"
directory: "/otel"
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
go-dependencies:
patterns:
- "*"
- package-ecosystem: "npm"
directory: "/"
schedule:
Expand Down
27 changes: 27 additions & 0 deletions .github/workflows/fmt.yml
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,30 @@ jobs:
run: |
go mod tidy
git diff --exit-code || (echo "go.mod/go.sum are not tidy. Run \`go mod tidy\` locally and commit the changes." && exit 1)

# The jobs above run at the repository root, and `./...` never descends into a
# nested module, so neither reaches otel/.
otel-fmt-tidy:
name: Check OTel module formatting and go.mod
runs-on: ubuntu-latest
defaults:
run:
working-directory: otel
steps:
- name: Check out source code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Go
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version-file: otel/go.mod

- name: Check formatting
run: |
UNFORMATTED=$(gofmt -l .)
test -z "$UNFORMATTED" || (echo "Files are not formatted:" && echo "$UNFORMATTED" && echo "Run \`gofmt -w .\` in otel/ and commit the changes." && exit 1)

- name: Check go.mod/go.sum
run: |
go mod tidy
git diff --exit-code . || (echo "otel/go.mod or otel/go.sum are not tidy. Run \`go mod tidy\` in otel/ and commit the changes." && exit 1)
25 changes: 24 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,14 +136,37 @@ jobs:
branch: main
env:
GITHUB_TOKEN: ${{ steps.releaser.outputs.token }}

- name: Create OTel module tag
if: steps.commit-version-bump.outputs.commit-hash != ''
env:
GH_TOKEN: ${{ steps.releaser.outputs.token }}
NEW_VERSION: ${{ steps.apply-changesets.outputs.new-version }}
RELEASE_COMMIT: ${{ steps.commit-version-bump.outputs.commit-hash }}
run: |
TAG="otel/v${NEW_VERSION}"
EXISTING_SHA=$(git ls-remote origin "refs/tags/${TAG}" | cut -f1)
if [ -n "$EXISTING_SHA" ]; then
if [ "$EXISTING_SHA" != "$RELEASE_COMMIT" ]; then
echo "Tag ${TAG} already exists at ${EXISTING_SHA}, expected ${RELEASE_COMMIT}" >&2
exit 1
fi
echo "Tag ${TAG} already exists at the release commit"
else
gh api --method POST "repos/${GITHUB_REPOSITORY}/git/refs" \
-f ref="refs/tags/${TAG}" \
-f sha="$RELEASE_COMMIT"
fi

- name: Create GitHub release
if: steps.commit-version-bump.outputs.commit-hash != ''
env:
GH_TOKEN: ${{ steps.releaser.outputs.token }}
NEW_VERSION: ${{ steps.apply-changesets.outputs.new-version }}
RELEASE_COMMIT: ${{ steps.commit-version-bump.outputs.commit-hash }}
run: |
CHANGELOG_ENTRY=$(awk -v defText="see CHANGELOG.md" '/^## /{if (flag) exit; flag=1} flag && /^##$/{exit} flag; END{if (!flag) print defText}' CHANGELOG.md)
gh release create "v${NEW_VERSION}" --target main --title "${NEW_VERSION}" --notes "$CHANGELOG_ENTRY"
gh release create "v${NEW_VERSION}" --target "$RELEASE_COMMIT" --title "${NEW_VERSION}" --notes "$CHANGELOG_ENTRY"

- name: Dispatch posthog upgrade for posthog-go
if: steps.commit-version-bump.outputs.commit-hash != ''
Expand Down
24 changes: 24 additions & 0 deletions .github/workflows/unit-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,30 @@ jobs:
- name: Build
run: go build .

otel-bridge:
name: OTel bridge (Go ${{ matrix.go-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Test the minimum supported Go version from go.mod and the latest stable Go 1 release.
go-version: ['1.25', '1.x']
steps:
- name: Check out source code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Set up Go ${{ matrix.go-version }}
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: ${{ matrix.go-version }}

- name: Build, vet, and test
working-directory: otel
run: |
go build ./...
go vet ./...
go test -race -count=1 -timeout=5m ./...

public-api:
name: Public API
runs-on: ubuntu-latest
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ SDK usage examples and code snippets live in the official documentation so they

- [Go library docs](https://posthog.com/docs/libraries/go)

## AI observability

The [`otel`](otel) module is an OpenTelemetry bridge that forwards AI spans
(`gen_ai.*`, `llm.*`, and similar) to PostHog AI observability, including spans
from a Google Agent Development Kit (ADK) for Go agent. It is a separate Go
module, so the core SDK stays free of OpenTelemetry dependencies.

## Questions?

### [Visit the community forum.](https://posthog.com/questions)
Expand Down
23 changes: 23 additions & 0 deletions otel/LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
The MIT License (MIT)

Copyright (c) 2020 PostHog (part of Hiberly Inc)

Copyright (c) 2016 Segment, Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
52 changes: 52 additions & 0 deletions otel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# PostHog OpenTelemetry bridge for AI observability

`posthogotel` forwards OpenTelemetry AI spans to [PostHog AI observability](https://posthog.com/docs/ai-engineering/observability).

It keeps only spans that follow a known AI semantic convention β€” a span whose
name or any attribute key starts with `gen_ai.`, `llm.`, `ai.`, or
`traceloop.` β€” and drops every other span. Kept spans go over OTLP/HTTP to the
PostHog `/i/v0/ai/otel` endpoint with the project API key as a bearer token.

This is a separate Go module, so the core `posthog-go` SDK does not depend on
OpenTelemetry.

## Usage

`SpanProcessor` is the recommended integration. Register it on the
`TracerProvider` your application already owns β€” rather than replacing the
global provider with a new one β€” so your resource, sampler, and existing
exporters are kept and tracers already handed out (such as ADK Go's) route
through it. Shut down the processor with a fresh context to flush its buffered
spans without shutting down the application-owned provider.

If you don't already have a `TracerProvider`, create one and register the
processor on it, as [`example/`](example) does. Use `WithHost` for a host other
than PostHog US cloud. For a framework that accepts only a span exporter, use
`NewExporter` instead and pair it with your own batch span processor.

## Failed generations

PostHog decides that a generation failed from the OpenTelemetry span status, so set the
status to the error code when a model call fails. Recording the error on the span is not
enough on its own: in OpenTelemetry for Go that only adds an exception event and leaves
the span status unset, so the failed generation reaches PostHog looking successful with an
empty response. The Python and JavaScript instrumentation sets the status for you, which
is why this step is specific to Go. Once the status is set, PostHog fills in the error
message and HTTP status from the recorded exception event.

## Google Agent Development Kit (ADK) for Go

[ADK Go](https://google.golang.org/adk) instruments its agents with
OpenTelemetry and emits `gen_ai.*` spans on the global tracer provider. Register
the PostHog span processor on that provider before you run the agent, and the
agent's `gen_ai.*` spans reach PostHog with no further code.
Comment thread
posthog[bot] marked this conversation as resolved.

Those spans carry the generation's model, token counts, latency, and finish
reason. They do **not** carry prompt and response message content: ADK Go emits
message bodies as OpenTelemetry **log records** (event names
`gen_ai.system.message`, `gen_ai.user.message`, and `gen_ai.choice`), gated
behind the `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT` environment
variable, not as span attributes. This bridge forwards spans only, so prompt and
response fields stay empty for ADK Go generations.

See [`example/`](example) for a runnable program.
141 changes: 141 additions & 0 deletions otel/config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
package posthogotel

import (
"context"
"errors"
"fmt"
"net/url"
"strings"

"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

const (
// DefaultHost is the PostHog US cloud host used when WithHost is not set.
DefaultHost = "https://us.i.posthog.com"

// ingestPath is the PostHog AI observability OTLP endpoint path.
ingestPath = "/i/v0/ai/otel"

// maxSpansPerRequest is the maximum number of AI spans the PostHog AI
// observability endpoint accepts in a single OTLP request. Larger requests
// are rejected with a non-retryable HTTP 400 and the whole batch is lost, so
// batches must be capped at this limit.
maxSpansPerRequest = 100
)

// errEmptyAPIKey is returned when the project API key is missing.
var errEmptyAPIKey = errors.New("posthogotel: apiKey must not be empty")

// errInvalidHost is returned when the configured host is not an absolute http
// or https URL with a hostname.
var errInvalidHost = errors.New("posthogotel: host must be an absolute http or https URL, for example https://us.i.posthog.com")

// config holds the resolved settings for the exporter and the span processor.
type config struct {
host string
// endpoint is the resolved OTLP URL, host joined with ingestPath.
endpoint string
}

// Option configures the exporter or the span processor.
type Option func(*config)

// WithHost sets the PostHog host, for example "https://eu.i.posthog.com".
// An empty or blank value keeps DefaultHost.
func WithHost(host string) Option {
return func(c *config) {
if h := strings.TrimSpace(host); h != "" {
c.host = h
}
}
}

func newConfig(opts ...Option) (config, error) {
c := config{host: DefaultHost}
for _, opt := range opts {
opt(&c)
}
c.host = strings.TrimRight(c.host, "/")
endpoint, err := resolveEndpoint(c.host)
if err != nil {
return config{}, err
}
c.endpoint = endpoint
return c, nil
}

// resolveEndpoint builds the OTLP URL for host and rejects a host that
// otlptracehttp.WithEndpointURL would silently discard. On a parse failure it
// keeps its localhost defaults, so spans go nowhere while the request still
// carries the API key; a scheme-less host such as "us.i.posthog.com" parses but
// yields an empty endpoint. Requiring an absolute http or https URL with a
// hostname turns both into an upfront error.
//
// ingestPath is joined rather than concatenated. A host that carries a query or
// fragment, such as "https://us.i.posthog.com?region=eu", would concatenate into
// a URL whose path is empty, and the exporter would then fall back to the OTLP
// default "/v1/traces" and send every AI span, with the API key attached, to a
// path PostHog does not serve.
func resolveEndpoint(host string) (string, error) {
u, err := url.Parse(host)
if err != nil {
return "", fmt.Errorf("%w: %v", errInvalidHost, err)
}
if (u.Scheme != "http" && u.Scheme != "https") || u.Hostname() == "" {
return "", errInvalidHost
}
return u.JoinPath(ingestPath).String(), nil
}

// newOTLPExporter builds an OTLP/HTTP exporter that targets the PostHog AI
// observability endpoint with the project API key as a bearer token. The
// exporter is wrapped so that no single request exceeds maxSpansPerRequest,
// which protects both public entry points regardless of the batch size of the
// span processor that feeds them.
func newOTLPExporter(ctx context.Context, apiKey string, cfg config) (sdktrace.SpanExporter, error) {
apiKey = strings.TrimSpace(apiKey)
exporter, err := otlptracehttp.New(ctx,
otlptracehttp.WithEndpointURL(cfg.endpoint),
otlptracehttp.WithHeaders(map[string]string{
"Authorization": "Bearer " + apiKey,
}),
Comment thread
marandaneto marked this conversation as resolved.
)
Comment thread
posthog[bot] marked this conversation as resolved.
if err != nil {
return nil, err
}
return &chunkingExporter{inner: exporter, limit: maxSpansPerRequest}, nil
}

// chunkingExporter splits each ExportSpans batch into requests of at most limit
// spans. The PostHog AI observability endpoint rejects larger requests with a
// non-retryable HTTP 400 that discards the whole batch, and nothing below this
// module splits a batch: the OTLP exporter turns whatever slice it receives
// into exactly one request. Chunking here caps every request for both the
// SpanProcessor and the caller-supplied Exporter path.
type chunkingExporter struct {
inner sdktrace.SpanExporter
limit int
}

var _ sdktrace.SpanExporter = (*chunkingExporter)(nil)

// ExportSpans forwards spans to the inner exporter in slices of at most limit.
func (e *chunkingExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error {
for start := 0; start < len(spans); start += e.limit {
end := start + e.limit
if end > len(spans) {
end = len(spans)
}
if err := e.inner.ExportSpans(ctx, spans[start:end]); err != nil {
return err
}
}
return nil
}

// Shutdown shuts down the inner exporter.
func (e *chunkingExporter) Shutdown(ctx context.Context) error {
return e.inner.Shutdown(ctx)
}
21 changes: 21 additions & 0 deletions otel/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Package posthogotel forwards OpenTelemetry AI spans to PostHog AI observability.
//
// It keeps only spans that follow a known AI semantic convention. A span
// qualifies when its name or any of its attribute keys starts with one of
// "gen_ai.", "llm.", "ai.", or "traceloop.". Every other span is dropped.
// Kept spans go over OTLP/HTTP to the PostHog "/i/v0/ai/otel" endpoint with the
// project API key in an Authorization: Bearer header.
//
// The package offers two integrations:
//
// - SpanProcessor is the recommended integration. It filters spans, batches
// the AI spans, and exports them. Register it with
// TracerProvider.RegisterSpanProcessor (or the WithSpanProcessor option).
//
// - Exporter is for setups that supply their own span processor, or
// frameworks that accept only a span exporter. It filters spans and
// delegates the AI spans to an OTLP/HTTP exporter.
//
// This is a separate Go module so that the core posthog-go SDK does not depend
// on OpenTelemetry.
package posthogotel
Loading