Skip to content

Commit 10404e6

Browse files
committed
WIP tracing span attributes
1 parent 39ace45 commit 10404e6

File tree

5 files changed

+240
-13
lines changed

5 files changed

+240
-13
lines changed

README.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,11 +117,18 @@ username ALL=(ALL:ALL) NOPASSWD: /usr/sbin/setcap
117117

118118
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.
119119

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+
120127
### With version information and/or plugins
121128

122129
Using [our builder tool, `xcaddy`](https://github.com/caddyserver/xcaddy)...
123130

124-
```
131+
```bash
125132
$ xcaddy build
126133
```
127134

modules/caddyhttp/tracing/module.go

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ type Tracing struct {
2727
// https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/trace/api.md#span
2828
SpanName string `json:"span"`
2929

30+
// SpanAttributes are custom key-value pairs to be added to spans
31+
SpanAttributes map[string]string `json:"span_attributes,omitempty"`
32+
3033
// otel implements opentelemetry related logic.
3134
otel openTelemetryWrapper
3235

@@ -46,7 +49,7 @@ func (ot *Tracing) Provision(ctx caddy.Context) error {
4649
ot.logger = ctx.Logger()
4750

4851
var err error
49-
ot.otel, err = newOpenTelemetryWrapper(ctx, ot.SpanName)
52+
ot.otel, err = newOpenTelemetryWrapper(ctx, ot.SpanName, ot.SpanAttributes)
5053

5154
return err
5255
}
@@ -69,6 +72,10 @@ func (ot *Tracing) ServeHTTP(w http.ResponseWriter, r *http.Request, next caddyh
6972
//
7073
// tracing {
7174
// [span <span_name>]
75+
// [span_attributes {
76+
// attr1 value1
77+
// attr2 value2
78+
// }]
7279
// }
7380
func (ot *Tracing) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
7481
setParameter := func(d *caddyfile.Dispenser, val *string) error {
@@ -94,12 +101,30 @@ func (ot *Tracing) UnmarshalCaddyfile(d *caddyfile.Dispenser) error {
94101
}
95102

96103
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()
100127
}
101-
} else {
102-
return d.ArgErr()
103128
}
104129
}
105130
return nil

modules/caddyhttp/tracing/module_test.go

Lines changed: 180 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package tracing
22

33
import (
44
"context"
5+
"encoding/json"
56
"errors"
67
"net/http"
78
"net/http/httptest"
@@ -15,17 +16,26 @@ import (
1516

1617
func TestTracing_UnmarshalCaddyfile(t *testing.T) {
1718
tests := []struct {
18-
name string
19-
spanName string
20-
d *caddyfile.Dispenser
21-
wantErr bool
19+
name string
20+
spanName string
21+
spanAttributes map[string]string
22+
d *caddyfile.Dispenser
23+
wantErr bool
2224
}{
2325
{
2426
name: "Full config",
2527
spanName: "my-span",
28+
spanAttributes: map[string]string{
29+
"attr1": "value1",
30+
"attr2": "value2",
31+
},
2632
d: caddyfile.NewTestDispenser(`
2733
tracing {
2834
span my-span
35+
span_attributes {
36+
attr1 value1
37+
attr2 value2
38+
}
2939
}`),
3040
wantErr: false,
3141
},
@@ -42,6 +52,21 @@ tracing {
4252
name: "Empty config",
4353
d: caddyfile.NewTestDispenser(`
4454
tracing {
55+
}`),
56+
wantErr: false,
57+
},
58+
{
59+
name: "Only span attributes",
60+
spanAttributes: map[string]string{
61+
"service.name": "my-service",
62+
"service.version": "1.0.0",
63+
},
64+
d: caddyfile.NewTestDispenser(`
65+
tracing {
66+
span_attributes {
67+
service.name my-service
68+
service.version 1.0.0
69+
}
4570
}`),
4671
wantErr: false,
4772
},
@@ -56,6 +81,20 @@ tracing {
5681
if ot.SpanName != tt.spanName {
5782
t.Errorf("UnmarshalCaddyfile() SpanName = %v, want SpanName %v", ot.SpanName, tt.spanName)
5883
}
84+
85+
if len(tt.spanAttributes) > 0 {
86+
if ot.SpanAttributes == nil {
87+
t.Errorf("UnmarshalCaddyfile() SpanAttributes is nil, expected %v", tt.spanAttributes)
88+
} else {
89+
for key, expectedValue := range tt.spanAttributes {
90+
if actualValue, exists := ot.SpanAttributes[key]; !exists {
91+
t.Errorf("UnmarshalCaddyfile() SpanAttributes missing key %v", key)
92+
} else if actualValue != expectedValue {
93+
t.Errorf("UnmarshalCaddyfile() SpanAttributes[%v] = %v, want %v", key, actualValue, expectedValue)
94+
}
95+
}
96+
}
97+
}
5998
})
6099
}
61100
}
@@ -79,6 +118,26 @@ func TestTracing_UnmarshalCaddyfile_Error(t *testing.T) {
79118
d: caddyfile.NewTestDispenser(`
80119
tracing {
81120
span
121+
}`),
122+
wantErr: true,
123+
},
124+
{
125+
name: "Span attributes missing value",
126+
d: caddyfile.NewTestDispenser(`
127+
tracing {
128+
span_attributes {
129+
key
130+
}
131+
}`),
132+
wantErr: true,
133+
},
134+
{
135+
name: "Span attributes too many arguments",
136+
d: caddyfile.NewTestDispenser(`
137+
tracing {
138+
span_attributes {
139+
key value extra
140+
}
82141
}`),
83142
wantErr: true,
84143
},
@@ -181,6 +240,123 @@ func TestTracing_ServeHTTP_Next_Error(t *testing.T) {
181240
}
182241
}
183242

243+
func TestTracing_Span_Attributes_With_Placeholders(t *testing.T) {
244+
ot := &Tracing{
245+
SpanName: "test-span",
246+
SpanAttributes: map[string]string{
247+
"http.method": "{http.request.method}",
248+
"service.name": "test-service",
249+
"mixed.attribute": "prefix-{http.request.method}-suffix",
250+
},
251+
}
252+
253+
// Create a specific request to test against
254+
req, _ := http.NewRequest("POST", "https://api.example.com/v1/users?id=123&action=create", nil)
255+
req.Host = "api.example.com"
256+
257+
// Set up the request context with proper replacer and vars
258+
repl := caddy.NewReplacer()
259+
ctx := context.WithValue(req.Context(), caddy.ReplacerCtxKey, repl)
260+
ctx = context.WithValue(ctx, caddyhttp.VarsCtxKey, make(map[string]any))
261+
req = req.WithContext(ctx)
262+
263+
// Manually populate the HTTP variables that would normally be set by the server
264+
// This simulates what addHTTPVarsToReplacer would do
265+
repl.Set("http.request.method", req.Method)
266+
267+
w := httptest.NewRecorder()
268+
269+
// Handler that can verify the context and span attributes
270+
var handler caddyhttp.HandlerFunc = func(writer http.ResponseWriter, request *http.Request) error {
271+
// Just ensure the request gets processed
272+
writer.WriteHeader(200)
273+
return nil
274+
}
275+
276+
caddyCtx, cancel := caddy.NewContext(caddy.Context{Context: context.Background()})
277+
defer cancel()
278+
279+
if err := ot.Provision(caddyCtx); err != nil {
280+
t.Errorf("Provision error: %v", err)
281+
t.FailNow()
282+
}
283+
284+
// Execute the request
285+
if err := ot.ServeHTTP(w, req, handler); err != nil {
286+
t.Errorf("ServeHTTP error: %v", err)
287+
}
288+
289+
// Verify that the span attributes were configured correctly in the otel wrapper
290+
expectedRawAttrs := map[string]string{
291+
"http.method": "{http.request.method}",
292+
"service.name": "test-service",
293+
"mixed.attribute": "prefix-{http.request.method}-suffix",
294+
}
295+
296+
for key, expectedValue := range expectedRawAttrs {
297+
if actualValue, exists := ot.otel.spanAttributes[key]; !exists {
298+
t.Errorf("Expected span attribute %s to exist", key)
299+
} else if actualValue != expectedValue {
300+
t.Errorf("Expected span attribute %s = %s, got %s", key, expectedValue, actualValue)
301+
}
302+
}
303+
304+
// Now test that the replacement would work correctly if called directly
305+
// This verifies that our placeholder values would be replaced correctly
306+
expectedReplacements := map[string]string{
307+
"{http.request.method}": "POST",
308+
"service.name": "service.name",
309+
"prefix-{http.request.method}-suffix": "prefix-POST-suffix",
310+
}
311+
312+
for placeholder, expected := range expectedReplacements {
313+
replaced := repl.ReplaceAll(placeholder, "")
314+
if replaced != expected {
315+
t.Errorf("Expected %s to be replaced with %s, got %s", placeholder, expected, replaced)
316+
}
317+
}
318+
}
319+
320+
func TestTracing_JSON_Configuration(t *testing.T) {
321+
// Test that our struct correctly marshals to and from JSON
322+
original := &Tracing{
323+
SpanName: "test-span",
324+
SpanAttributes: map[string]string{
325+
"service.name": "test-service",
326+
"service.version": "1.0.0",
327+
"env": "test",
328+
},
329+
}
330+
331+
jsonData, err := json.Marshal(original)
332+
if err != nil {
333+
t.Fatalf("Failed to marshal to JSON: %v", err)
334+
}
335+
336+
var unmarshaled Tracing
337+
if err := json.Unmarshal(jsonData, &unmarshaled); err != nil {
338+
t.Fatalf("Failed to unmarshal from JSON: %v", err)
339+
}
340+
341+
if unmarshaled.SpanName != original.SpanName {
342+
t.Errorf("Expected SpanName %s, got %s", original.SpanName, unmarshaled.SpanName)
343+
}
344+
345+
if len(unmarshaled.SpanAttributes) != len(original.SpanAttributes) {
346+
t.Errorf("Expected %d span attributes, got %d", len(original.SpanAttributes), len(unmarshaled.SpanAttributes))
347+
}
348+
349+
for key, expectedValue := range original.SpanAttributes {
350+
if actualValue, exists := unmarshaled.SpanAttributes[key]; !exists {
351+
t.Errorf("Expected span attribute %s to exist", key)
352+
} else if actualValue != expectedValue {
353+
t.Errorf("Expected span attribute %s = %s, got %s", key, expectedValue, actualValue)
354+
}
355+
}
356+
357+
t.Logf("JSON representation: %s", string(jsonData))
358+
}
359+
184360
func createRequestWithContext(method string, url string) *http.Request {
185361
r, _ := http.NewRequest(method, url, nil)
186362
repl := caddy.NewReplacer()

modules/caddyhttp/tracing/tracer.go

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77

88
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
99
"go.opentelemetry.io/contrib/propagators/autoprop"
10+
"go.opentelemetry.io/otel/attribute"
1011
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
1112
"go.opentelemetry.io/otel/propagation"
1213
"go.opentelemetry.io/otel/sdk/resource"
@@ -37,20 +38,23 @@ type openTelemetryWrapper struct {
3738

3839
handler http.Handler
3940

40-
spanName string
41+
spanName string
42+
spanAttributes map[string]string
4143
}
4244

4345
// newOpenTelemetryWrapper is responsible for the openTelemetryWrapper initialization using provided configuration.
4446
func newOpenTelemetryWrapper(
4547
ctx context.Context,
4648
spanName string,
49+
spanAttributes map[string]string,
4750
) (openTelemetryWrapper, error) {
4851
if spanName == "" {
4952
spanName = defaultSpanName
5053
}
5154

5255
ot := openTelemetryWrapper{
53-
spanName: spanName,
56+
spanName: spanName,
57+
spanAttributes: spanAttributes,
5458
}
5559

5660
version, _ := caddy.Version()
@@ -99,6 +103,20 @@ func (ot *openTelemetryWrapper) serveHTTP(w http.ResponseWriter, r *http.Request
99103
extra.Add(zap.String("spanID", spanID))
100104
}
101105
}
106+
107+
// Add custom span attributes to the current span
108+
span := trace.SpanFromContext(ctx)
109+
if span.IsRecording() && len(ot.spanAttributes) > 0 {
110+
replacer := ctx.Value(caddy.ReplacerCtxKey).(*caddy.Replacer)
111+
attributes := make([]attribute.KeyValue, 0, len(ot.spanAttributes))
112+
for key, value := range ot.spanAttributes {
113+
// Allow placeholder replacement in attribute values
114+
replacedValue := replacer.ReplaceAll(value, "")
115+
attributes = append(attributes, attribute.String(key, replacedValue))
116+
}
117+
span.SetAttributes(attributes...)
118+
}
119+
102120
next := ctx.Value(nextCallCtxKey).(*nextCall)
103121
next.err = next.next.ServeHTTP(w, r)
104122
}

modules/caddyhttp/tracing/tracer_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func TestOpenTelemetryWrapper_newOpenTelemetryWrapper(t *testing.T) {
1616

1717
if otw, err = newOpenTelemetryWrapper(ctx,
1818
"",
19+
nil,
1920
); err != nil {
2021
t.Errorf("newOpenTelemetryWrapper() error = %v", err)
2122
t.FailNow()

0 commit comments

Comments
 (0)