@marcel91200 / caddy Mirror · read-only / commits / 1f1be3f4
Fork 0

Fast and extensible multi-platform HTTP/1-2-3 web server with automatic HTTPS

Mirrored from https://github.com/Slazee/caddy.git

Synced Nov 11, 17:15 UTC

tracing: Add span attributes to tracing module (#7269)

* WIP tracing span attributes * better test * only write attributes after other middleware (and request) * Fix test to use header response placeholders

Felix Hildén committed Dec 31, 2025 at 20:33 UTC 1f1be3f4fe281643fd4ac8c1217d5ade7f82f6b9
5 files changed +280 -13
README.md
+8 -1
@@ -117,11 +117,18 @@ username ALL=(ALL:ALL) NOPASSWD: /usr/sbin/setcap
117
118 replacing `username` with your actual username. Please be careful and only do this if you know what you are doing! We are only qualified to document how to use Caddy, not Go tooling or your computer, and we are providing these instructions for convenience only; please learn how to use your own computer at your own risk and make any needful adjustments.
119
120 +Then you can run the tests in all modules or a specific one:
121 +
122 +````bash
123 +$ go test ./...
124 +$ go test ./modules/caddyhttp/tracing/
125 +```
126 +
127 ### With version information and/or plugins
128
129 Using [our builder tool, `xcaddy`](https://github.com/caddyserver/xcaddy)...
130
124 -```
131 +```bash
132 $ xcaddy build
133 ```
134
modules/caddyhttp/tracing/module.go
+31 -6
@@ -27,6 +27,9 @@ type Tracing struct {
27 // https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#span
28 SpanName string `json:"span"`
29
30 + // SpanAttributes are custom key-value pairs to be added to spans
31 + SpanAttributes map[string]string `json:"span_attributes,omitempty"`
32 +
33 // otel implements opentelemetry related logic.
34 otel openTelemetryWrapper
35
@@ -46,7 +49,7 @@ func (ot *Tracing) Provision(ctx caddy.Context) error {
49 ot.logger = ctx.Logger()
50
51 var err error
49 - ot.otel, err = newOpenTelemetryWrapper(ctx, ot.SpanName)
52 + ot.otel, err = newOpenTelemetryWrapper(ctx, ot.SpanName, ot.SpanAttributes)
53
54 return err
55 }
@@ -69,6 +72,10 @@ func (ot *Tracing) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh
72 //
73 // tracing {
74 // [span <span_name>]
75 +// [span_attributes {
76 +// attr1 value1
77 +// attr2 value2
78 +// }]
79 // }
80 func (ot *Tracing) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
81 setParameter := func(d *caddyfile.Dispenser, val *string) error {
@@ -94,12 +101,30 @@ func (ot *Tracing) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
101 }
102
103 for d.NextBlock(0) {
97 - if dst, ok := paramsMap[d.Val()]; ok {
98 - if err := setParameter(d, dst); err != nil {
99 - return err
104 + switch d.Val() {
105 + case "span_attributes":
106 + if ot.SpanAttributes == nil {
107 + ot.SpanAttributes = make(map[string]string)
108 + }
109 + for d.NextBlock(1) {
110 + key := d.Val()
111 + if !d.NextArg() {
112 + return d.ArgErr()
113 + }
114 + value := d.Val()
115 + if d.NextArg() {
116 + return d.ArgErr()
117 + }
118 + ot.SpanAttributes[key] = value
119 + }
120 + default:
121 + if dst, ok := paramsMap[d.Val()]; ok {
122 + if err := setParameter(d, dst); err != nil {
123 + return err
124 + }
125 + } else {
126 + return d.ArgErr()
127 }
101 - } else {
102 - return d.ArgErr()
128 }
129 }
130 return nil
modules/caddyhttp/tracing/module_test.go
+220 -4
@@ -2,12 +2,16 @@ package tracing
2
3 import (
4 "context"
5 + "encoding/json"
6 "errors"
7 "net/http"
8 "net/http/httptest"
9 "strings"
10 "testing"
11
12 + "go.opentelemetry.io/otel/sdk/trace"
13 + "go.opentelemetry.io/otel/sdk/trace/tracetest"
14 +
15 "github.com/caddyserver/caddy/v2"
16 "github.com/caddyserver/caddy/v2/caddyconfig/caddyfile"
17 "github.com/caddyserver/caddy/v2/modules/caddyhttp"
@@ -15,17 +19,26 @@ import (
19
20 func TestTracing_UnmarshalCaddyfile(t *testing.T) {
21 tests := []struct {
18 - name string
19 - spanName string
20 - d *caddyfile.Dispenser
21 - wantErr bool
22 + name string
23 + spanName string
24 + spanAttributes map[string]string
25 + d *caddyfile.Dispenser
26 + wantErr bool
27 }{
28 {
29 name: "Full config",
30 spanName: "my-span",
31 + spanAttributes: map[string]string{
32 + "attr1": "value1",
33 + "attr2": "value2",
34 + },
35 d: caddyfile.NewTestDispenser(`
36 tracing {
37 span my-span
38 + span_attributes {
39 + attr1 value1
40 + attr2 value2
41 + }
42 }`),
43 wantErr: false,
44 },
@@ -42,6 +55,21 @@ tracing {
55 name: "Empty config",
56 d: caddyfile.NewTestDispenser(`
57 tracing {
58 +}`),
59 + wantErr: false,
60 + },
61 + {
62 + name: "Only span attributes",
63 + spanAttributes: map[string]string{
64 + "service.name": "my-service",
65 + "service.version": "1.0.0",
66 + },
67 + d: caddyfile.NewTestDispenser(`
68 +tracing {
69 + span_attributes {
70 + service.name my-service
71 + service.version 1.0.0
72 + }
73 }`),
74 wantErr: false,
75 },
@@ -56,6 +84,20 @@ tracing {
84 if ot.SpanName != tt.spanName {
85 t.Errorf("UnmarshalCaddyfile() SpanName = %v, want SpanName %v", ot.SpanName, tt.spanName)
86 }
87 +
88 + if len(tt.spanAttributes) > 0 {
89 + if ot.SpanAttributes == nil {
90 + t.Errorf("UnmarshalCaddyfile() SpanAttributes is nil, expected %v", tt.spanAttributes)
91 + } else {
92 + for key, expectedValue := range tt.spanAttributes {
93 + if actualValue, exists := ot.SpanAttributes[key]; !exists {
94 + t.Errorf("UnmarshalCaddyfile() SpanAttributes missing key %v", key)
95 + } else if actualValue != expectedValue {
96 + t.Errorf("UnmarshalCaddyfile() SpanAttributes[%v] = %v, want %v", key, actualValue, expectedValue)
97 + }
98 + }
99 + }
100 + }
101 })
102 }
103 }
@@ -79,6 +121,26 @@ func TestTracing_UnmarshalCaddyfile_Error(t *testing.T) {
121 d: caddyfile.NewTestDispenser(`
122 tracing {
123 span
124 +}`),
125 + wantErr: true,
126 + },
127 + {
128 + name: "Span attributes missing value",
129 + d: caddyfile.NewTestDispenser(`
130 +tracing {
131 + span_attributes {
132 + key
133 + }
134 +}`),
135 + wantErr: true,
136 + },
137 + {
138 + name: "Span attributes too many arguments",
139 + d: caddyfile.NewTestDispenser(`
140 +tracing {
141 + span_attributes {
142 + key value extra
143 + }
144 }`),
145 wantErr: true,
146 },
@@ -181,6 +243,160 @@ func TestTracing_ServeHTTP_Next_Error(t *testing.T) {
243 }
244 }
245
246 +func TestTracing_JSON_Configuration(t *testing.T) {
247 + // Test that our struct correctly marshals to and from JSON
248 + original := &Tracing{
249 + SpanName: "test-span",
250 + SpanAttributes: map[string]string{
251 + "service.name": "test-service",
252 + "service.version": "1.0.0",
253 + "env": "test",
254 + },
255 + }
256 +
257 + jsonData, err := json.Marshal(original)
258 + if err != nil {
259 + t.Fatalf("Failed to marshal to JSON: %v", err)
260 + }
261 +
262 + var unmarshaled Tracing
263 + if err := json.Unmarshal(jsonData, &unmarshaled); err != nil {
264 + t.Fatalf("Failed to unmarshal from JSON: %v", err)
265 + }
266 +
267 + if unmarshaled.SpanName != original.SpanName {
268 + t.Errorf("Expected SpanName %s, got %s", original.SpanName, unmarshaled.SpanName)
269 + }
270 +
271 + if len(unmarshaled.SpanAttributes) != len(original.SpanAttributes) {
272 + t.Errorf("Expected %d span attributes, got %d", len(original.SpanAttributes), len(unmarshaled.SpanAttributes))
273 + }
274 +
275 + for key, expectedValue := range original.SpanAttributes {
276 + if actualValue, exists := unmarshaled.SpanAttributes[key]; !exists {
277 + t.Errorf("Expected span attribute %s to exist", key)
278 + } else if actualValue != expectedValue {
279 + t.Errorf("Expected span attribute %s = %s, got %s", key, expectedValue, actualValue)
280 + }
281 + }
282 +
283 + t.Logf("JSON representation: %s", string(jsonData))
284 +}
285 +
286 +func TestTracing_OpenTelemetry_Span_Attributes(t *testing.T) {
287 + // Create an in-memory span recorder to capture actual span data
288 + spanRecorder := tracetest.NewSpanRecorder()
289 + provider := trace.NewTracerProvider(
290 + trace.WithSpanProcessor(spanRecorder),
291 + )
292 +
293 + // Create our tracing module with span attributes that include placeholders
294 + ot := &Tracing{
295 + SpanName: "test-span",
296 + SpanAttributes: map[string]string{
297 + "static": "test-service",
298 + "request-placeholder": "{http.request.method}",
299 + "response-placeholder": "{http.response.header.X-Some-Header}",
300 + "mixed": "prefix-{http.request.method}-{http.response.header.X-Some-Header}",
301 + },
302 + }
303 +
304 + // Create a specific request to test against
305 + req, _ := http.NewRequest("POST", "https://api.example.com/v1/users?id=123", nil)
306 + req.Host = "api.example.com"
307 +
308 + w := httptest.NewRecorder()
309 +
310 + // Set up the replacer
311 + repl := caddy.NewReplacer()
312 + ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
313 + ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, make(map[string]any))
314 + req = req.WithContext(ctx)
315 +
316 + // Set up request placeholders
317 + repl.Set("http.request.method", req.Method)
318 + repl.Set("http.request.uri", req.URL.RequestURI())
319 +
320 + // Handler to generate the response
321 + var handler caddyhttp.HandlerFunc = func(writer http.ResponseWriter, request *http.Request) error {
322 + writer.Header().Set("X-Some-Header", "some-value")
323 + writer.WriteHeader(200)
324 +
325 + // Make response headers available to replacer
326 + repl.Set("http.response.header.X-Some-Header", writer.Header().Get("X-Some-Header"))
327 +
328 + return nil
329 + }
330 +
331 + // Set up Caddy context
332 + caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
333 + defer cancel()
334 +
335 + // Override the global tracer provider with our test provider
336 + // This is a bit hacky but necessary to capture the actual spans
337 + originalProvider := globalTracerProvider
338 + globalTracerProvider = &tracerProvider{
339 + tracerProvider: provider,
340 + tracerProvidersCounter: 1, // Simulate one user
341 + }
342 + defer func() {
343 + globalTracerProvider = originalProvider
344 + }()
345 +
346 + // Provision the tracing module
347 + if err := ot.Provision(caddyCtx); err != nil {
348 + t.Errorf("Provision error: %v", err)
349 + t.FailNow()
350 + }
351 +
352 + // Execute the request
353 + if err := ot.ServeHTTP(w, req, handler); err != nil {
354 + t.Errorf("ServeHTTP error: %v", err)
355 + }
356 +
357 + // Get the recorded spans
358 + spans := spanRecorder.Ended()
359 + if len(spans) == 0 {
360 + t.Fatal("Expected at least one span to be recorded")
361 + }
362 +
363 + // Find our span (should be the one with our test span name)
364 + var testSpan trace.ReadOnlySpan
365 + for _, span := range spans {
366 + if span.Name() == "test-span" {
367 + testSpan = span
368 + break
369 + }
370 + }
371 +
372 + if testSpan == nil {
373 + t.Fatal("Could not find test span in recorded spans")
374 + }
375 +
376 + // Verify that the span attributes were set correctly with placeholder replacement
377 + expectedAttributes := map[string]string{
378 + "static": "test-service",
379 + "request-placeholder": "POST",
380 + "response-placeholder": "some-value",
381 + "mixed": "prefix-POST-some-value",
382 + }
383 +
384 + actualAttributes := make(map[string]string)
385 + for _, attr := range testSpan.Attributes() {
386 + actualAttributes[string(attr.Key)] = attr.Value.AsString()
387 + }
388 +
389 + for key, expectedValue := range expectedAttributes {
390 + if actualValue, exists := actualAttributes[key]; !exists {
391 + t.Errorf("Expected span attribute %s to be set", key)
392 + } else if actualValue != expectedValue {
393 + t.Errorf("Expected span attribute %s = %s, got %s", key, expectedValue, actualValue)
394 + }
395 + }
396 +
397 + t.Logf("Recorded span attributes: %+v", actualAttributes)
398 +}
399 +
400 func createRequestWithContext(method string, url string) *http.Request {
401 r, _ := http.NewRequest(method, url, nil)
402 repl := caddy.NewReplacer()
modules/caddyhttp/tracing/tracer.go
+20 -2
@@ -8,6 +8,7 @@ import (
8 "go.opentelemetry.io/contrib/exporters/autoexport"
9 "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
10 "go.opentelemetry.io/contrib/propagators/autoprop"
11 + "go.opentelemetry.io/otel/attribute"
12 "go.opentelemetry.io/otel/propagation"
13 "go.opentelemetry.io/otel/sdk/resource"
14 sdktrace "go.opentelemetry.io/otel/sdk/trace"
@@ -37,20 +38,23 @@ type openTelemetryWrapper struct {
38
39 handler http.Handler
40
40 - spanName string
41 + spanName string
42 + spanAttributes map[string]string
43 }
44
45 // newOpenTelemetryWrapper is responsible for the openTelemetryWrapper initialization using provided configuration.
46 func newOpenTelemetryWrapper(
47 ctx context.Context,
48 spanName string,
49 + spanAttributes map[string]string,
50 ) (openTelemetryWrapper, error) {
51 if spanName == "" {
52 spanName = defaultSpanName
53 }
54
55 ot := openTelemetryWrapper{
53 - spanName: spanName,
56 + spanName: spanName,
57 + spanAttributes: spanAttributes,
58 }
59
60 version, _ := caddy.Version()
@@ -99,8 +103,22 @@ func (ot *openTelemetryWrapper) serveHTTP(w http.ResponseWriter, r *http.Request
103 extra.Add(zap.String("spanID", spanID))
104 }
105 }
106 +
107 next := ctx.Value(nextCallCtxKey).(*nextCall)
108 next.err = next.next.ServeHTTP(w, r)
109 +
110 + // Add custom span attributes to the current span
111 + span := trace.SpanFromContext(ctx)
112 + if span.IsRecording() && len(ot.spanAttributes) > 0 {
113 + replacer := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
114 + attributes := make([]attribute.KeyValue, 0, len(ot.spanAttributes))
115 + for key, value := range ot.spanAttributes {
116 + // Allow placeholder replacement in attribute values
117 + replacedValue := replacer.ReplaceAll(value, "")
118 + attributes = append(attributes, attribute.String(key, replacedValue))
119 + }
120 + span.SetAttributes(attributes...)
121 + }
122 }
123
124 // ServeHTTP propagates call to the by wrapped by `otelhttp` next handler.
modules/caddyhttp/tracing/tracer_test.go
+1
@@ -16,6 +16,7 @@ func TestOpenTelemetryWrapper_newOpenTelemetryWrapper(t *testing.T) {
16
17 if otw, err = newOpenTelemetryWrapper(ctx,
18 "",
19 + nil,
20 ); err != nil {
21 t.Errorf("newOpenTelemetryWrapper() error = %v", err)
22 t.FailNow()