Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
56 changes: 56 additions & 0 deletions pkg/instrumentation/gin/server/context_hook.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package server

import (
"github.com/gin-gonic/gin"
"github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg/inst"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
"go.opentelemetry.io/otel/trace"
)

// routeSetKey is stored on the gin.Context to prevent repeated span updates
// when multiple middleware layers call c.Next(). The key is reserved by this
// package; user middleware must not set or read it.
const routeSetKey = "otel.gin.route.set"

// BeforeNext runs before (*gin.Context).Next. By the time Next is called,
// gin's router has already matched the request to a route and populated
// c.FullPath(). We use this to update the span name from the initial
// "METHOD" to "METHOD /route/pattern" and record the http.route attribute.
func BeforeNext(ictx inst.HookContext, c *gin.Context) {
if !serverEnabler.Enable() {
return
}
if c == nil || c.Request == nil {
return
}

route := c.FullPath()
if route == "" {
// No route matched (e.g. 404). Leave the span name as the method only.
return
}

// c.Next() is called by each middleware in the chain, so this hook fires
// multiple times per request. Only the first call needs to update the span.
if _, already := c.Get(routeSetKey); already {
return
}

span := trace.SpanFromContext(c.Request.Context())
if !span.IsRecording() {
return
}
Comment on lines +51 to +60

// Set the gate only after confirming we have a recording span to update.
// Otherwise a non-recording first call would burn the gate and block a
// later recording span on the same request from being enriched.
c.Set(routeSetKey, struct{}{})

span.SetName(c.Request.Method + " " + route)
span.SetAttributes(semconv.HTTPRouteKey.String(route))

logger.Debug("gin route resolved", "route", route)
}
185 changes: 185 additions & 0 deletions pkg/instrumentation/gin/server/context_hook_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package server

import (
"context"
"net/http/httptest"
"testing"

"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
"go.opentelemetry.io/otel/trace"

"github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg/inst/insttest"
)

func init() {
gin.SetMode(gin.TestMode)
}

// newGinContextWithRoute creates a *gin.Context that has FullPath() populated
// by routing a real request through a minimal gin engine. The returned context
// has the given span embedded in c.Request.Context().
func newGinContextWithRoute(t *testing.T, method, routePattern, url string, span trace.Span) *gin.Context {
t.Helper()

var captured *gin.Context
r := gin.New()
r.Handle(method, routePattern, func(c *gin.Context) {
captured = c
})

req := httptest.NewRequest(method, url, nil)
if span != nil {
ctx := trace.ContextWithSpan(context.Background(), span)
req = req.WithContext(ctx)
}

w := httptest.NewRecorder()
r.ServeHTTP(w, req)

require.NotNil(t, captured, "no handler was invoked; check route pattern and URL")
return captured
}

func setupContextTracer(t *testing.T) (*tracetest.SpanRecorder, trace.Tracer) {
t.Helper()
sr := tracetest.NewSpanRecorder()
tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr))
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.TraceContext{})
t.Cleanup(func() {
_ = tp.Shutdown(context.Background())
})
return sr, tp.Tracer("test")
}

func TestBeforeNext_UpdatesSpanNameAndRoute(t *testing.T) {
sr, tr := setupContextTracer(t)
t.Setenv("OTEL_GO_ENABLED_INSTRUMENTATIONS", "gin")

_, span := tr.Start(context.Background(), "GET")
c := newGinContextWithRoute(t, "GET", "/users/:id", "/users/42", span)

ictx := insttest.NewMockHookContext(c)
BeforeNext(ictx, c)

span.End()
require.Len(t, sr.Ended(), 1)
ended := sr.Ended()[0]

assert.Equal(t, "GET /users/:id", ended.Name(), "span name should include route pattern")

attrs := make(map[string]interface{})
for _, a := range ended.Attributes() {
attrs[string(a.Key)] = a.Value.AsInterface()
}
assert.Equal(t, "/users/:id", attrs["http.route"], "http.route attribute should be the pattern, not the URL")
}

func TestBeforeNext_EmptyRouteIsNoop(t *testing.T) {
sr, tr := setupContextTracer(t)
t.Setenv("OTEL_GO_ENABLED_INSTRUMENTATIONS", "gin")

_, span := tr.Start(context.Background(), "GET")

// gin.CreateTestContext produces a context with no router match,
// so FullPath() returns "". BeforeNext should be a no-op.
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/does-not-exist", nil).
WithContext(trace.ContextWithSpan(context.Background(), span))

ictx := insttest.NewMockHookContext(c)
BeforeNext(ictx, c)
span.End()

require.Len(t, sr.Ended(), 1)
assert.Equal(t, "GET", sr.Ended()[0].Name(), "span name must not be modified when route is empty")
}

func TestBeforeNext_IdempotentOnMultipleNextCalls(t *testing.T) {
sr, tr := setupContextTracer(t)
t.Setenv("OTEL_GO_ENABLED_INSTRUMENTATIONS", "gin")

_, span := tr.Start(context.Background(), "GET")
c := newGinContextWithRoute(t, "GET", "/items/:id", "/items/7", span)
ictx := insttest.NewMockHookContext(c)

// Simulate multiple middleware calling c.Next().
BeforeNext(ictx, c)
BeforeNext(ictx, c)
BeforeNext(ictx, c)

span.End()
require.Len(t, sr.Ended(), 1)

// http.route should appear exactly once (not duplicated by repeated calls).
var routeAttrCount int
for _, a := range sr.Ended()[0].Attributes() {
if string(a.Key) == "http.route" {
routeAttrCount++
}
}
assert.Equal(
t,
1,
routeAttrCount,
"http.route should be set exactly once regardless of how many times Next is called",
)
}

func TestBeforeNext_DisabledIsNoop(t *testing.T) {
sr, tr := setupContextTracer(t)
t.Setenv("OTEL_GO_DISABLED_INSTRUMENTATIONS", "gin")

_, span := tr.Start(context.Background(), "GET")
c := newGinContextWithRoute(t, "GET", "/ping", "/ping", span)

ictx := insttest.NewMockHookContext(c)
BeforeNext(ictx, c)

span.End()
require.Len(t, sr.Ended(), 1)

// Name should still be the initial "GET" since the hook is disabled.
assert.Equal(t, "GET", sr.Ended()[0].Name())
}

func TestBeforeNext_NonRecordingSpanDoesNotBurnGate(t *testing.T) {
sr, tr := setupContextTracer(t)
t.Setenv("OTEL_GO_ENABLED_INSTRUMENTATIONS", "gin")

// First call: request context has no span attached, so trace.SpanFromContext
// returns a non-recording no-op span. The gate must NOT be set, because no
// span update happened.
c := newGinContextWithRoute(t, "GET", "/users/:id", "/users/42", nil)
ictx := insttest.NewMockHookContext(c)
BeforeNext(ictx, c)

// Second call: attach a recording span. Because the previous call did not
// burn the gate, this call must still enrich the span.
_, recording := tr.Start(context.Background(), "GET")
c.Request = c.Request.WithContext(
trace.ContextWithSpan(c.Request.Context(), recording),
)
BeforeNext(ictx, c)
recording.End()

require.Len(t, sr.Ended(), 1)
assert.Equal(t, "GET /users/:id", sr.Ended()[0].Name(),
"recording span must be enriched even after a non-recording call ran first")

attrs := make(map[string]interface{})
for _, a := range sr.Ended()[0].Attributes() {
attrs[string(a.Key)] = a.Value.AsInterface()
}
assert.Equal(t, "/users/:id", attrs["http.route"])
}
21 changes: 21 additions & 0 deletions pkg/instrumentation/gin/server/gin.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package server

import (
"github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg/instrumentation/shared"
)

const instrumentationKey = "GIN"

var logger = shared.Logger()

// ginEnabler controls whether gin instrumentation is enabled.
type ginEnabler struct{}

func (g ginEnabler) Enable() bool {
return shared.Instrumented(instrumentationKey)
}

var serverEnabler = ginEnabler{}
86 changes: 86 additions & 0 deletions pkg/instrumentation/gin/server/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
module github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg/instrumentation/gin/server

go 1.25.0

require (
github.com/gin-gonic/gin v1.10.0
github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg v0.0.0-00010101000000-000000000000
github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg/instrumentation/shared v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
)

require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.20.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.65.0 // indirect
github.com/prometheus/otlptranslator v0.0.2 // indirect
github.com/prometheus/procfs v0.17.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 // indirect
go.opentelemetry.io/contrib/instrumentation/runtime v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/otel/log v0.19.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.19.0 // indirect
go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

replace (
github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg => ../../..
github.com/open-telemetry/opentelemetry-go-compile-instrumentation/pkg/instrumentation/shared => ../../shared
)
Loading
Loading