Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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/quiet-span-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-go": minor
---

Add an optional OpenTelemetry context processor and HTTP middleware for session and person IDs on application spans. Both take the PostHog client and add no IDs when that client is nil, disabled, or closed.
3 changes: 3 additions & 0 deletions noop_posthog.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ func (c *noopClient) ReloadFeatureFlags() error {
return ErrSDKDisabled
}

// IsEnabled returns false because a client without a project API key sends no data.
func (c *noopClient) IsEnabled() bool { return false }

func (c *noopClient) Close() error {
return ErrSDKDisabled
}
Expand Down
14 changes: 14 additions & 0 deletions otel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,3 +101,17 @@ 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.

## General application spans

Register `NewContextSpanProcessor(client)` on your existing OpenTelemetry tracer provider to add `sessionId` and `posthogDistinctId` to application spans. A nil, disabled (empty API key), or closed client adds no IDs; a custom `posthog.Client` wrapper counts as enabled unless it implements `IsEnabled() bool`. Place it before processors that read attributes in `OnStart`. Keep your existing exporter and sampler.

Use `posthogotel.NewRequestContextMiddleware(client, handler, options...)` for HTTP requests. This wrapper uses the core SDK's header extraction and options. Place it inside the OpenTelemetry HTTP middleware so the request span already exists. It also works outside the OpenTelemetry middleware, because the processor reads context when that middleware starts the span. Pass `r.Context()` when you start child spans.

The bridge adds only missing IDs and preserves explicit span attributes. It does not create IDs or export spans. Register your normal application exporter to send general traces to PostHog. The AI `SpanProcessor` and `Exporter` above keep their separate AI-only behavior.

The HTTP wrapper stops new spans from using the request IDs after the handler returns or panics. Spans already started keep their IDs when they end later. Detached work that needs its own IDs must use a fresh context, such as `context.Background()` with `posthog.WithFreshRequestContext`. A context derived from an ended request remains expired.

For manual operations, `posthog.WithRequestContext` supplies IDs to the processor. The processor cannot determine when a manual operation ends. Use a new context for each operation and do not reuse its context for unrelated work.

OpenTelemetry remains optional through this separate module. This bridge adds no OpenTelemetry dependency to the core SDK. Non-recording spans and spans without readable public attributes receive no changes. Span attribute limits can omit new IDs while preserving existing attributes.
114 changes: 114 additions & 0 deletions otel/context_span_processor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package posthogotel

import (
"context"
"net/http"
"sync/atomic"

"github.com/posthog/posthog-go"
"go.opentelemetry.io/otel/attribute"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)

// ContextSpanProcessor adds PostHog IDs to spans from the application's provider.
// Register it before processors that consume attributes in OnStart. It does not export spans.
type ContextSpanProcessor struct {
client contextClient
shutdown atomic.Bool
}

var _ sdktrace.SpanProcessor = (*ContextSpanProcessor)(nil)

// NewContextSpanProcessor creates an optional bridge for general application spans.
// It adds IDs only while client is enabled, so a nil, disabled, or closed client adds none.
// A client wrapper can implement IsEnabled() bool to report its state; otherwise it counts as enabled.
func NewContextSpanProcessor(client posthog.Client) *ContextSpanProcessor {
return &ContextSpanProcessor{client: newContextClient(client)}
}

func (p *ContextSpanProcessor) OnStart(ctx context.Context, span sdktrace.ReadWriteSpan) {
if !p.shutdown.Load() {
enrichContextSpan(ctx, span, p.client)
}
}

func (*ContextSpanProcessor) OnEnd(sdktrace.ReadOnlySpan) {}
func (p *ContextSpanProcessor) Shutdown(context.Context) error {
p.shutdown.Store(true)
return nil
}
func (*ContextSpanProcessor) ForceFlush(context.Context) error { return nil }

type contextClient struct {
present bool
enabled interface{ IsEnabled() bool }
}

func newContextClient(client posthog.Client) contextClient {
enabled, _ := client.(interface{ IsEnabled() bool })
return contextClient{present: client != nil, enabled: enabled}
}

func (c contextClient) isEnabled() bool {
return c.present && (c.enabled == nil || c.enabled.IsEnabled())
Comment thread
pauldambra marked this conversation as resolved.
Resolved
}

type contextSpanRequestKey struct{}
type contextSpanRequest struct{ active atomic.Bool }

func enrichContextSpan(ctx context.Context, span trace.Span, client contextClient) {
if !client.present || ctx == nil || span == nil || !span.IsRecording() {
return
}
if request, ok := ctx.Value(contextSpanRequestKey{}).(*contextSpanRequest); ok && !request.active.Load() {
return
}
if !client.isEnabled() {
return
}
readable, ok := span.(interface{ Attributes() []attribute.KeyValue })
if !ok {
return
}
request, ok := posthog.RequestContextFromContext(ctx)
if !ok {
return
}
hasDistinctID, hasSessionID := false, false
for _, attr := range readable.Attributes() {
switch string(attr.Key) {
case "posthogDistinctId":
hasDistinctID = true
case "sessionId":
hasSessionID = true
}
}
attrs := make([]attribute.KeyValue, 0, 2)
if request.DistinctId != "" && !hasDistinctID {
attrs = append(attrs, attribute.String("posthogDistinctId", request.DistinctId))
}
if request.SessionId != "" && !hasSessionID {
attrs = append(attrs, attribute.String("sessionId", request.SessionId))
}
span.SetAttributes(attrs...)
}

// NewRequestContextMiddleware also enriches the existing request span after header extraction.
// It adds IDs to spans only while client is enabled, like NewContextSpanProcessor.
// It accepts the core middleware options. Spans started from an inherited request context
// after the handler returns omit request IDs. Register ContextSpanProcessor for child spans.
func NewRequestContextMiddleware(client posthog.Client, next http.Handler, opts ...posthog.RequestContextOption) http.Handler {
spanClient := newContextClient(client)
if next == nil {
return posthog.NewRequestContextMiddleware(nil, opts...)
}
return posthog.NewRequestContextMiddleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
request := &contextSpanRequest{}
request.active.Store(true)
defer request.active.Store(false)
ctx := context.WithValue(r.Context(), contextSpanRequestKey{}, request)
enrichContextSpan(ctx, trace.SpanFromContext(ctx), spanClient)
next.ServeHTTP(w, r.WithContext(ctx))
}), opts...)
}
Loading
Loading