Skip to content

Commit 19c796e

Browse files
committed
feat(contrib/labstack/echo.v4): support OpenTelemetry server semantics
1 parent 313a8a6 commit 19c796e

3 files changed

Lines changed: 324 additions & 7 deletions

File tree

contrib/labstack/echo.v4/echotrace.go

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,14 +69,20 @@ func Middleware(opts ...Option) echo.MiddlewareFunc {
6969
request := c.Request()
7070
route := c.Path()
7171
resource := request.Method + " " + route
72+
if cfg.otelEnabled {
73+
resource = httptrace.ServerSpanName(request.Method, route)
74+
}
7275
opts := options.Copy(spanOpts) // opts must be a copy of spanOpts, locally scoped, to avoid races.
7376
if !math.IsNaN(cfg.analyticsRate) {
7477
opts = append(opts, tracer.Tag(ext.EventSampleRate, cfg.analyticsRate))
7578
}
76-
opts = append(opts,
77-
tracer.ResourceName(resource),
78-
tracer.Tag(ext.HTTPRoute, route),
79-
httptrace.HeaderTagsFromRequest(request, cfg.headerTags))
79+
opts = append(opts, tracer.ResourceName(resource))
80+
if cfg.otelEnabled {
81+
opts = append(opts, httptrace.HTTPEndpointTag(route, request))
82+
} else {
83+
opts = append(opts, tracer.Tag(ext.HTTPRoute, route))
84+
}
85+
opts = append(opts, httptrace.HeaderTagsFromRequest(request, cfg.headerTags))
8086

8187
var finishOpts []tracer.FinishOption
8288
if cfg.noDebugStack {
@@ -89,7 +95,7 @@ func Middleware(opts ...Option) echo.MiddlewareFunc {
8995
c.SetRequest(request.WithContext(ctx))
9096

9197
if instr.AppSecEnabled() {
92-
next = withAppSec(next, span)
98+
next = withAppSec(next, httptrace.AppSecSpanTagSetter(span))
9399
}
94100
// serve the request to the next middleware
95101
err := next(c)
@@ -112,14 +118,14 @@ func Middleware(opts ...Option) echo.MiddlewareFunc {
112118
}
113119
} else if status := c.Response().Status; status > 0 {
114120
if cfg.isStatusError(status) {
115-
if statusErr := errorFromStatusCode(status); !shouldIgnoreError(cfg, statusErr) {
121+
if statusErr := errorFromStatusCode(status); !shouldIgnoreError(cfg, statusErr) && !cfg.otelEnabled {
116122
finishOpts = append(finishOpts, tracer.WithError(statusErr))
117123
}
118124
}
119125
echoStatus = status
120126
} else {
121127
if cfg.isStatusError(200) {
122-
if statusErr := errorFromStatusCode(200); !shouldIgnoreError(cfg, statusErr) {
128+
if statusErr := errorFromStatusCode(200); !shouldIgnoreError(cfg, statusErr) && !cfg.otelEnabled {
123129
finishOpts = append(finishOpts, tracer.WithError(statusErr))
124130
}
125131
}

contrib/labstack/echo.v4/option.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type config struct {
3030
headerTags instrumentation.HeaderTags
3131
errCheck func(error) bool
3232
tags map[string]interface{}
33+
otelEnabled bool
3334
}
3435

3536
// Option describes options for the Echo.v4 integration.
@@ -58,6 +59,7 @@ func defaults(cfg *config) {
5859
}
5960
cfg.headerTags = instr.HTTPHeadersAsTags()
6061
cfg.tags = make(map[string]interface{})
62+
cfg.otelEnabled = instr.OTelSemanticsEnabled()
6163
cfg.translateError = func(err error) (*echo.HTTPError, bool) {
6264
var echoErr *echo.HTTPError
6365
if errors.As(err, &echoErr) {
Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,309 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016 Datadog, Inc.
5+
6+
package echo
7+
8+
import (
9+
"errors"
10+
"net/http"
11+
"net/http/httptest"
12+
"os"
13+
"testing"
14+
15+
"github.com/DataDog/dd-trace-go/v2/ddtrace/ext"
16+
"github.com/DataDog/dd-trace-go/v2/ddtrace/mocktracer"
17+
"github.com/DataDog/dd-trace-go/v2/ddtrace/tracer"
18+
"github.com/DataDog/dd-trace-go/v2/instrumentation"
19+
"github.com/DataDog/dd-trace-go/v2/instrumentation/httptrace"
20+
"github.com/DataDog/dd-trace-go/v2/instrumentation/testutils"
21+
22+
"github.com/labstack/echo/v4"
23+
"github.com/stretchr/testify/assert"
24+
"github.com/stretchr/testify/require"
25+
)
26+
27+
func TestDatadogSemantics(t *testing.T) {
28+
for _, tt := range []struct {
29+
name string
30+
value string
31+
}{
32+
{name: "unset"},
33+
{name: "disabled", value: "false"},
34+
} {
35+
t.Run(tt.name, func(t *testing.T) {
36+
setEchoHTTPConfig(t, tt.value)
37+
t.Setenv("DD_TRACE_RESOURCE_RENAMING_ENABLED", "true")
38+
httptrace.ResetCfg()
39+
40+
matched := traceEchoRequest(t, http.MethodGet, "http://example.com/users/123", http.StatusOK)
41+
assert.Equal(t, "GET /users/:id", matched.Tag(ext.ResourceName))
42+
assert.Equal(t, "/users/:id", matched.Tag(ext.HTTPRoute))
43+
assert.Equal(t, "GET", matched.Tag(ext.HTTPMethod))
44+
assert.Equal(t, "http://example.com/users/123", matched.Tag(ext.HTTPURL))
45+
assert.Equal(t, "200", matched.Tag(ext.HTTPCode))
46+
assert.Nil(t, matched.Tag(ext.HTTPEndpoint))
47+
assert.Nil(t, matched.Tag(ext.HTTPRequestMethod))
48+
assert.Nil(t, matched.Tag(ext.URLPath))
49+
assert.Nil(t, matched.Tag(ext.HTTPResponseStatusCode))
50+
51+
unmatched := traceEchoRequestWithRoute(t, http.MethodGet, "http://example.com/missing", http.StatusOK, "", "", nil)
52+
assert.Equal(t, "GET ", unmatched.Tag(ext.ResourceName))
53+
assert.Contains(t, unmatched.Tags(), ext.HTTPRoute)
54+
assert.Equal(t, "", unmatched.Tag(ext.HTTPRoute))
55+
})
56+
}
57+
}
58+
59+
func TestOTelSemantics(t *testing.T) {
60+
setEchoHTTPConfig(t, "true")
61+
t.Setenv("DD_TRACE_CLIENT_IP_ENABLED", "true")
62+
httptrace.ResetCfg()
63+
64+
t.Run("route and attributes", func(t *testing.T) {
65+
span := traceEchoRequest(t, http.MethodGet, "http://example.com/users/123?password=secret&keep=value", http.StatusOK, WithCustomTag("echo.custom", "value"))
66+
assert.Equal(t, "GET /users/:id", span.Tag(ext.ResourceName))
67+
assert.Equal(t, "/users/:id", span.Tag(ext.HTTPRoute))
68+
assert.Equal(t, "GET", span.Tag(ext.HTTPRequestMethod))
69+
assert.Nil(t, span.Tag(ext.HTTPRequestMethodOriginal))
70+
assert.Equal(t, "/users/123", span.Tag(ext.URLPath))
71+
assert.Equal(t, "http", span.Tag(ext.URLScheme))
72+
assert.Equal(t, "<redacted>&keep=value", span.Tag(ext.URLQuery))
73+
assert.Equal(t, "example.com", span.Tag(ext.ServerAddress))
74+
assert.Equal(t, "semantic-agent", span.Tag(ext.UserAgentOriginal))
75+
assert.Equal(t, "203.0.113.10", span.Tag(ext.ClientAddress))
76+
assert.Equal(t, "192.0.2.1", span.Tag(ext.NetworkPeerAddress))
77+
assert.Equal(t, "200", span.Tag(ext.HTTPResponseStatusCode))
78+
assert.Equal(t, "semantic-service", span.Tag(ext.ServiceName))
79+
assert.Equal(t, ext.SpanKindServer, span.Tag(ext.SpanKind))
80+
assert.Equal(t, "labstack/echo.v4", span.Tag(ext.Component))
81+
assert.Equal(t, string(instrumentation.PackageLabstackEchoV4), span.Integration())
82+
assert.Equal(t, "http.request", span.OperationName())
83+
assert.Equal(t, ext.SpanTypeWeb, span.Tag(ext.SpanType))
84+
assert.Equal(t, "value", span.Tag("echo.custom"))
85+
assert.Nil(t, span.Tag(ext.HTTPMethod))
86+
assert.Nil(t, span.Tag(ext.HTTPURL))
87+
assert.Nil(t, span.Tag(ext.HTTPCode))
88+
assert.Nil(t, span.Tag(ext.HTTPUserAgent))
89+
assert.Nil(t, span.Tag(ext.HTTPClientIP))
90+
assert.Nil(t, span.Tag(ext.NetworkClientIP))
91+
})
92+
93+
t.Run("route is invariant across parameters", func(t *testing.T) {
94+
first := traceEchoRequest(t, http.MethodGet, "http://example.com/users/123", http.StatusOK)
95+
second := traceEchoRequest(t, http.MethodGet, "http://example.com/users/456", http.StatusOK)
96+
assert.Equal(t, "GET /users/:id", first.Tag(ext.ResourceName))
97+
assert.Equal(t, first.Tag(ext.ResourceName), second.Tag(ext.ResourceName))
98+
})
99+
100+
for _, tt := range []struct {
101+
name string
102+
method string
103+
target string
104+
routeMethod string
105+
route string
106+
wantResource string
107+
wantRoute any
108+
wantMethod string
109+
wantOriginal any
110+
wantPath string
111+
wantStatus string
112+
}{
113+
{name: "not found", method: "gEt", target: "http://example.com/actual/path", wantResource: "GET", wantMethod: "GET", wantOriginal: "gEt", wantPath: "/actual/path", wantStatus: "404"},
114+
{name: "method not allowed", method: http.MethodPost, target: "http://example.com/users/123", routeMethod: http.MethodGet, route: "/users/:id", wantResource: "POST /users/:id", wantRoute: "/users/:id", wantMethod: "POST", wantPath: "/users/123", wantStatus: "405"},
115+
{name: "unknown method with route", method: "PROPFIND", target: "http://example.com/users/123", routeMethod: "PROPFIND", route: "/users/:id", wantResource: "HTTP /users/:id", wantRoute: "/users/:id", wantMethod: "_OTHER", wantOriginal: "PROPFIND", wantPath: "/users/123", wantStatus: "200"},
116+
} {
117+
t.Run(tt.name, func(t *testing.T) {
118+
span := traceEchoRequestWithRoute(t, tt.method, tt.target, http.StatusOK, tt.routeMethod, tt.route, nil)
119+
assert.Equal(t, tt.wantResource, span.Tag(ext.ResourceName))
120+
assert.Equal(t, tt.wantRoute, span.Tag(ext.HTTPRoute))
121+
if tt.wantRoute == nil {
122+
assert.NotContains(t, span.Tags(), ext.HTTPRoute)
123+
}
124+
assert.Equal(t, tt.wantMethod, span.Tag(ext.HTTPRequestMethod))
125+
assert.Equal(t, tt.wantOriginal, span.Tag(ext.HTTPRequestMethodOriginal))
126+
assert.Equal(t, tt.wantPath, span.Tag(ext.URLPath))
127+
assert.Equal(t, tt.wantStatus, span.Tag(ext.HTTPResponseStatusCode))
128+
})
129+
}
130+
}
131+
132+
func TestOTelSemanticsStatus(t *testing.T) {
133+
setEchoHTTPConfig(t, "true")
134+
135+
for _, tt := range []struct {
136+
name string
137+
status int
138+
isStatusError func(int) bool
139+
errCheck func(error) bool
140+
wantErrorType any
141+
}{
142+
{name: "success", status: http.StatusOK},
143+
{name: "client error", status: http.StatusBadRequest},
144+
{name: "server error", status: http.StatusInternalServerError, wantErrorType: "500"},
145+
{name: "custom client error inclusion", status: http.StatusBadRequest, isStatusError: func(status int) bool { return status == http.StatusBadRequest }, wantErrorType: "400"},
146+
{name: "custom success inclusion", status: http.StatusCreated, isStatusError: func(status int) bool { return status == http.StatusCreated }, wantErrorType: "201"},
147+
{name: "custom exclusion", status: http.StatusInternalServerError, isStatusError: func(int) bool { return false }},
148+
{name: "error check exclusion", status: http.StatusInternalServerError, errCheck: func(error) bool { return false }},
149+
} {
150+
t.Run(tt.name, func(t *testing.T) {
151+
var opts []Option
152+
if tt.isStatusError != nil {
153+
opts = append(opts, WithStatusCheck(tt.isStatusError))
154+
}
155+
if tt.errCheck != nil {
156+
opts = append(opts, WithErrorCheck(tt.errCheck))
157+
}
158+
span := traceEchoRequest(t, http.MethodGet, "http://example.com/users/123", tt.status, opts...)
159+
assert.Equal(t, tt.wantErrorType, span.Tag(ext.ErrorType))
160+
})
161+
}
162+
}
163+
164+
func TestOTelSemanticsErrors(t *testing.T) {
165+
setEchoHTTPConfig(t, "true")
166+
responseErr := errors.New("oh no")
167+
168+
t.Run("retained real error", func(t *testing.T) {
169+
span := traceEchoError(t, responseErr)
170+
require.NotNil(t, span.Tag(ext.ErrorType))
171+
assert.NotEqual(t, "500", span.Tag(ext.ErrorType))
172+
assert.Equal(t, responseErr.Error(), span.Tag(ext.ErrorMsg))
173+
assert.Equal(t, "500", span.Tag(ext.HTTPResponseStatusCode))
174+
})
175+
176+
t.Run("ignored real error", func(t *testing.T) {
177+
span := traceEchoError(t, responseErr, WithErrorCheck(func(error) bool { return false }))
178+
assert.Nil(t, span.Tag(ext.ErrorType))
179+
assert.Nil(t, span.Tag(ext.ErrorMsg))
180+
})
181+
182+
t.Run("translator and status inclusion", func(t *testing.T) {
183+
err := &testCustomError{TestCode: http.StatusBadRequest}
184+
span := traceEchoError(t, err,
185+
WithErrorTranslator(func(err error) (*echo.HTTPError, bool) {
186+
return echo.NewHTTPError(err.(*testCustomError).TestCode), true
187+
}),
188+
WithStatusCheck(func(status int) bool { return status == http.StatusBadRequest }),
189+
)
190+
require.NotNil(t, span.Tag(ext.ErrorType))
191+
assert.NotEqual(t, "400", span.Tag(ext.ErrorType))
192+
assert.Equal(t, "400", span.Tag(ext.HTTPResponseStatusCode))
193+
})
194+
195+
t.Run("no debug stack", func(t *testing.T) {
196+
span := traceEchoError(t, responseErr, NoDebugStack())
197+
assert.Empty(t, span.Tag(ext.ErrorStack))
198+
assert.Equal(t, responseErr.Error(), span.Tag(ext.ErrorMsg))
199+
})
200+
}
201+
202+
func TestOTelSemanticsContextPropagationAndWrap(t *testing.T) {
203+
setEchoHTTPConfig(t, "true")
204+
mt := mocktracer.Start()
205+
defer mt.Stop()
206+
207+
var handlerSpan *tracer.Span
208+
router := Wrap(echo.New(), WithService("semantic-service"))
209+
router.GET("/users/:id", func(c echo.Context) error {
210+
handlerSpan, _ = tracer.SpanFromContext(c.Request().Context())
211+
return c.NoContent(http.StatusOK)
212+
})
213+
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/users/123", nil))
214+
215+
require.NotNil(t, handlerSpan)
216+
spans := mt.FinishedSpans()
217+
require.Len(t, spans, 1)
218+
assert.Equal(t, "GET /users/:id", spans[0].Tag(ext.ResourceName))
219+
}
220+
221+
func TestOTelSemanticsAppSecRouteParams(t *testing.T) {
222+
setEchoHTTPConfig(t, "true")
223+
testutils.StartAppSec(t)
224+
httptrace.ResetCfg()
225+
226+
mt := mocktracer.Start()
227+
defer mt.Stop()
228+
router := Wrap(echo.New(), WithService("semantic-service"))
229+
router.GET("/users/:id", func(c echo.Context) error {
230+
return c.String(http.StatusOK, "ok")
231+
})
232+
req := httptest.NewRequest(http.MethodGet, "/users/appscan_fingerprint", nil)
233+
req.RemoteAddr = "192.0.2.1:1234"
234+
req.Header.Set("X-Forwarded-For", "203.0.113.10")
235+
router.ServeHTTP(httptest.NewRecorder(), req)
236+
237+
spans := mt.FinishedSpans()
238+
require.Len(t, spans, 1)
239+
assert.Equal(t, "GET /users/:id", spans[0].Tag(ext.ResourceName))
240+
assert.Equal(t, "/users/:id", spans[0].Tag(ext.HTTPRoute))
241+
assert.Equal(t, "/users/:id", spans[0].Tag(ext.HTTPEndpoint))
242+
assert.Equal(t, "203.0.113.10", spans[0].Tag(ext.ClientAddress))
243+
assert.Equal(t, "192.0.2.1", spans[0].Tag(ext.NetworkPeerAddress))
244+
assert.Nil(t, spans[0].Tag(ext.HTTPClientIP))
245+
assert.Nil(t, spans[0].Tag(ext.NetworkClientIP))
246+
event, ok := spans[0].Tag("_dd.appsec.json").(string)
247+
require.True(t, ok)
248+
assert.Contains(t, event, "server.request.path_params")
249+
assert.Contains(t, event, "appscan_fingerprint")
250+
}
251+
252+
func traceEchoRequest(t *testing.T, method, target string, status int, opts ...Option) *mocktracer.Span {
253+
t.Helper()
254+
return traceEchoRequestWithRoute(t, method, target, status, method, "/users/:id", nil, opts...)
255+
}
256+
257+
func traceEchoRequestWithRoute(t *testing.T, method, target string, status int, routeMethod, route string, handler echo.HandlerFunc, opts ...Option) *mocktracer.Span {
258+
t.Helper()
259+
mt := mocktracer.Start()
260+
defer mt.Stop()
261+
262+
router := echo.New()
263+
router.Use(Middleware(append([]Option{WithService("semantic-service")}, opts...)...))
264+
if route != "" {
265+
if handler == nil {
266+
handler = func(c echo.Context) error { return c.NoContent(status) }
267+
}
268+
router.Add(routeMethod, route, handler)
269+
}
270+
271+
req := httptest.NewRequest(method, target, nil)
272+
req.RemoteAddr = "192.0.2.1:1234"
273+
req.Header.Set("User-Agent", "semantic-agent")
274+
req.Header.Set("X-Forwarded-For", "203.0.113.10")
275+
router.ServeHTTP(httptest.NewRecorder(), req)
276+
277+
spans := mt.FinishedSpans()
278+
require.Len(t, spans, 1)
279+
return spans[0]
280+
}
281+
282+
func traceEchoError(t *testing.T, responseErr error, opts ...Option) *mocktracer.Span {
283+
t.Helper()
284+
return traceEchoRequestWithRoute(t, http.MethodGet, "http://example.com/error", http.StatusOK, http.MethodGet, "/error", func(echo.Context) error {
285+
return responseErr
286+
}, opts...)
287+
}
288+
289+
func setEchoHTTPConfig(t *testing.T, otel string) {
290+
t.Helper()
291+
oldOTel, hadOTel := os.LookupEnv("DD_TRACE_OTEL_SEMANTICS_ENABLED")
292+
if otel == "" {
293+
require.NoError(t, os.Unsetenv("DD_TRACE_OTEL_SEMANTICS_ENABLED"))
294+
} else {
295+
require.NoError(t, os.Setenv("DD_TRACE_OTEL_SEMANTICS_ENABLED", otel))
296+
}
297+
require.NoError(t, tracer.Start(tracer.WithTraceEnabled(false)))
298+
httptrace.ResetCfg()
299+
t.Cleanup(func() {
300+
if hadOTel {
301+
require.NoError(t, os.Setenv("DD_TRACE_OTEL_SEMANTICS_ENABLED", oldOTel))
302+
} else {
303+
require.NoError(t, os.Unsetenv("DD_TRACE_OTEL_SEMANTICS_ENABLED"))
304+
}
305+
require.NoError(t, tracer.Start(tracer.WithTraceEnabled(false)))
306+
httptrace.ResetCfg()
307+
tracer.Stop()
308+
})
309+
}

0 commit comments

Comments
 (0)