Skip to content
Open
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
3 changes: 3 additions & 0 deletions .craft.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ targets:
- name: github
tagPrefix: otel/v
tagOnly: true
- name: github
tagPrefix: otel/otlp/v
tagOnly: true
- name: github
tagPrefix: echo/v
tagOnly: true
Expand Down
95 changes: 95 additions & 0 deletions _examples/otlp/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// This example demonstrates two ways to set up OpenTelemetry tracing with Sentry.
//
// setupTracerProviderWithSentry exports spans directly to Sentry using
// sentryotlp.NewTraceExporter.
//
// setupTracerProviderWithCollector exports spans to a standard OpenTelemetry
// Collector using otlptracehttp.New.
//
// To link Sentry errors, register sentryotel.NewErrorLinkingIntegration in
// sentry.Init.
package main

import (
"context"
"errors"
"fmt"
"log"
"net/http"
"os"
"time"

"github.com/getsentry/sentry-go"
sentryotel "github.com/getsentry/sentry-go/otel"
sentryotlp "github.com/getsentry/sentry-go/otel/otlp"
"go.opentelemetry.io/otel"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
)

func main() {
dsn := os.Getenv("SENTRY_DSN")
if err := sentry.Init(sentry.ClientOptions{
Dsn: dsn,
EnableTracing: true,
TracesSampleRate: 1.0,
Integrations: func(integrations []sentry.Integration) []sentry.Integration {
return append(integrations, sentryotel.NewErrorLinkingIntegration())
},
}); err != nil {
log.Fatalf("sentry.Init: %v", err)
}
defer sentry.Flush(2 * time.Second)

ctx := context.Background()
// Direct-to-Sentry setup:
tp, err := setupTracerProviderWithSentry(ctx, dsn)
if err != nil {
log.Fatal(err)
}
// When exporting through a collector, keep the same Sentry initialization above
// and switch only the TracerProvider setup:
//
// tp, err := setupTracerProviderWithCollector(ctx)
// ...
defer func() {
if err := tp.Shutdown(ctx); err != nil {
log.Printf("TracerProvider.Shutdown: %v", err)
}
}()

otel.SetTracerProvider(tp)

mux := http.NewServeMux()
mux.HandleFunc("/demo", func(w http.ResponseWriter, r *http.Request) {
ctx, span := otel.Tracer("example-service").Start(r.Context(), "GET /demo")
defer span.End()

hub := sentry.GetHubFromContext(ctx)
if hub == nil {
hub = sentry.CurrentHub()
}
hub.Client().CaptureException(
errors.New("demo handler failure"),
&sentry.EventHint{Context: ctx},
hub.Scope(),
)

w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("captured an error and linked it to the active trace\n"))
})

fmt.Println("Send a request to http://localhost:8080/demo to generate one trace and one linked error.")
log.Fatal(http.ListenAndServe(":8080", mux))
}

// setupTracerProviderWithSentry sends spans directly to Sentry's OTLP endpoint.
func setupTracerProviderWithSentry(ctx context.Context, dsn string) (*sdktrace.TracerProvider, error) {
exporter, err := sentryotlp.NewTraceExporter(ctx, dsn)
if err != nil {
return nil, fmt.Errorf("sentryotlp.NewTraceExporter: %w", err)
}

return sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
), nil
}
23 changes: 23 additions & 0 deletions otel/error_linking.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package sentryotel

import (
"github.com/getsentry/sentry-go"
"github.com/getsentry/sentry-go/otel/internal/common"
)

type errorLinkingIntegration struct{}

// NewErrorLinkingIntegration registers OpenTelemetry error linking with Sentry.
//
// It attaches the active OTel trace and span IDs to captured Sentry errors.
func NewErrorLinkingIntegration() sentry.Integration {
return errorLinkingIntegration{}
}

func (errorLinkingIntegration) Name() string {
return "OtelErrorLinking"
}
Comment on lines +17 to +19
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Initializing multiple Sentry clients with NewErrorLinkingIntegration() registers the same global event processor multiple times, causing redundant event processing and performance overhead.
Severity: MEDIUM

Suggested Fix

To prevent duplicate registration, the SetupOnce method should be made idempotent. This can be achieved by using a global flag, such as a sync.Once or a boolean, to ensure that sentry.AddGlobalEventProcessor() is only called the first time SetupOnce is executed across all client initializations.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent.
Verify if this is a real issue. If it is, propose a fix; if not, explain why it's not
valid.

Location: otel/error_linking.go#L17-L19

Potential issue: When multiple Sentry clients are initialized with
`NewErrorLinkingIntegration()`, the integration's `SetupOnce` method is called for each
client. This method unconditionally calls `sentry.AddGlobalEventProcessor()`, appending
the same processor to the global `globalEventProcessors` slice multiple times. Because
the check for installed integrations is per-client, not global, this duplication is not
prevented. As a result, every event is processed redundantly by each registered instance
of the processor, leading to unnecessary performance overhead.


func (errorLinkingIntegration) SetupOnce(_ *sentry.Client) {
sentry.AddGlobalEventProcessor(common.NewEventProcessor())
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integration uses global processor instead of per-client processor

Medium Severity

errorLinkingIntegration.SetupOnce calls sentry.AddGlobalEventProcessor instead of using the provided *sentry.Client parameter (which is discarded with _). The established convention for Sentry integrations (e.g. modulesIntegration) is to use client.AddEventProcessor. Because SetupOnce is called on every sentry.Init, each initialization appends another copy of the processor to the global list, causing it to run multiple times per event. Using client.AddEventProcessor would scope the processor to the specific client and prevent unbounded accumulation.

Fix in Cursor Fix in Web

38 changes: 0 additions & 38 deletions otel/event_processor.go

This file was deleted.

55 changes: 0 additions & 55 deletions otel/event_processor_test.go

This file was deleted.

39 changes: 39 additions & 0 deletions otel/internal/common/event_processor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package common

import (
"github.com/getsentry/sentry-go"
"go.opentelemetry.io/otel/trace"
)

// NewEventProcessor creates a Sentry event processor that attaches OTel trace
// information from the active SpanContext to an error event.
func NewEventProcessor() sentry.EventProcessor {
return linkTraceContextToErrorEvent
}

func linkTraceContextToErrorEvent(event *sentry.Event, hint *sentry.EventHint) *sentry.Event {
if hint == nil || hint.Context == nil {
return event
}
if event.Type == "transaction" {
return event
}

otelSpanContext := trace.SpanContextFromContext(hint.Context)
if !otelSpanContext.IsValid() {
return event
}

if event.Contexts == nil {
event.Contexts = make(map[string]sentry.Context)
}

traceContext, found := event.Contexts["trace"]
if !found {
event.Contexts["trace"] = make(map[string]any)
traceContext = event.Contexts["trace"]
}
traceContext["trace_id"] = otelSpanContext.TraceID().String()
traceContext["span_id"] = otelSpanContext.SpanID().String()
return event
}
71 changes: 71 additions & 0 deletions otel/internal/common/event_processor_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package common

import (
"context"
"testing"

"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"go.opentelemetry.io/otel/trace"
)

func TestLinkTraceContextToErrorEventSetsOTelIDs(t *testing.T) {
t.Parallel()

traceID := trace.TraceID{0xd4, 0xcd, 0xa9, 0x5b, 0x65, 0x2f, 0x4a, 0x15, 0x92, 0xb4, 0x49, 0xd5, 0x92, 0x9f, 0xda, 0x1b}
spanID := trace.SpanID{0x6e, 0x0c, 0x63, 0x25, 0x7d, 0xe3, 0x4c, 0x92}

event := &sentry.Event{}

ctx := trace.ContextWithSpanContext(context.Background(), trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID,
SpanID: spanID,
}))

got := linkTraceContextToErrorEvent(event, &sentry.EventHint{Context: ctx})

assert.Equal(t, map[string]any{
"trace_id": traceID.String(),
"span_id": spanID.String(),
}, got.Contexts["trace"])
}

func TestLinkTraceContextToErrorEventPreservesExistingTraceContext(t *testing.T) {
t.Parallel()

traceID := trace.TraceID{0xd4, 0xcd, 0xa9, 0x5b, 0x65, 0x2f, 0x4a, 0x15, 0x92, 0xb4, 0x49, 0xd5, 0x92, 0x9f, 0xda, 0x1b}
spanID := trace.SpanID{0x6e, 0x0c, 0x63, 0x25, 0x7d, 0xe3, 0x4c, 0x92}

event := &sentry.Event{
Contexts: map[string]map[string]any{
"trace": {
"trace_id": "123",
"span_id": "456",
"op": "http.server",
},
},
}

ctx := trace.ContextWithSpanContext(context.Background(), trace.NewSpanContext(trace.SpanContextConfig{
TraceID: traceID,
SpanID: spanID,
}))

got := linkTraceContextToErrorEvent(event, &sentry.EventHint{Context: ctx})

assert.Equal(t, map[string]any{
"trace_id": traceID.String(),
"span_id": spanID.String(),
"op": "http.server",
}, got.Contexts["trace"])
}

func TestLinkTraceContextToErrorEventSkipsInvalidSpanContext(t *testing.T) {
t.Parallel()

event := &sentry.Event{}
got := linkTraceContextToErrorEvent(event, &sentry.EventHint{Context: context.Background()})

_, found := got.Contexts["trace"]
assert.False(t, found)
}
32 changes: 32 additions & 0 deletions otel/otlp/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
module github.com/getsentry/sentry-go/otel/otlp

go 1.24.0

replace github.com/getsentry/sentry-go => ../../

require (
github.com/getsentry/sentry-go v0.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.11.0
go.opentelemetry.io/otel/sdk v1.11.0
)

require (
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.2.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect
go.opentelemetry.io/otel v1.11.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.0 // indirect
go.opentelemetry.io/otel/trace v1.11.0 // indirect
go.opentelemetry.io/proto/otlp v0.19.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/genproto v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect
google.golang.org/grpc v1.58.3 // indirect
google.golang.org/protobuf v1.31.0 // indirect
)
Loading
Loading