From e96d8b153a0debe1abd1a4b6754f3bf23caab81a Mon Sep 17 00:00:00 2001 From: Dan Kowalski Date: Mon, 12 Oct 2026 23:22:55 -0400 Subject: [PATCH] fix(otel): client spans identify their callee (db.namespace, server.address) Three postgresql client spans carried no db.namespace, so a backend keyed them as plain "postgresql" and drew a node beside postgresql-orders_db and the rest. Five SendGrid and Stripe client calls named the peer only by service.peer.name, with no server.address; a bare single-label name cannot be told apart from an internal service, so a backend that never saw it answer read it as missing. Adds db.namespace (orders_db, inventory_db) and server.address/server.port (api.sendgrid.com, api.stripe.com, 443) to those spans, matching the flows that already set them, and a conformance test that fails on any postgresql client span without db.namespace or any peer-naming client span without server.address and server.port. Co-Authored-By: Claude Opus 5.5 --- cmd/snowglobe/scenarios.go | 12 ++++++ cmd/snowglobe/scenarios_ai.go | 2 + cmd/snowglobe/semconv_client_peer_test.go | 47 +++++++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 cmd/snowglobe/semconv_client_peer_test.go diff --git a/cmd/snowglobe/scenarios.go b/cmd/snowglobe/scenarios.go index a7748b9..d7b576b 100644 --- a/cmd/snowglobe/scenarios.go +++ b/cmd/snowglobe/scenarios.go @@ -439,6 +439,7 @@ func failedPaymentFlow(ctx context.Context) { trace.WithAttributes( attribute.String("db.system.name", "postgresql"), attribute.String("db.operation.name", "INSERT"), + attribute.String("db.namespace", "orders_db"), ), ) sleep(5, 15) @@ -467,6 +468,8 @@ func failedPaymentFlow(ctx context.Context) { trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("http.request.method", "POST"), + attribute.String("server.address", "api.stripe.com"), + attribute.Int("server.port", 443), attribute.String("service.peer.name", "stripe-api"), attribute.Int("http.response.status_code", 402), attribute.String("error.type", "card_declined"), @@ -535,6 +538,7 @@ func bulkNotificationFlow(ctx context.Context) { trace.WithAttributes( attribute.String("db.system.name", "postgresql"), attribute.String("db.operation.name", "SELECT"), + attribute.String("db.namespace", "orders_db"), attribute.String("db.query.text", "SELECT * FROM notifications WHERE status = 'pending' LIMIT 100"), ), ) @@ -1059,6 +1063,7 @@ func recommendationFlow(ctx context.Context) { trace.WithAttributes( attribute.String("db.system.name", "postgresql"), attribute.String("db.operation.name", "SELECT"), + attribute.String("db.namespace", "inventory_db"), attribute.Int("batch.size", 20+rand.Intn(30)), ), ) @@ -1690,6 +1695,8 @@ func shippingUpdateFlow(ctx context.Context) { trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("http.request.method", "POST"), + attribute.String("server.address", "api.sendgrid.com"), + attribute.Int("server.port", 443), attribute.String("service.peer.name", "sendgrid"), attribute.String("email.template", "shipping_update"), ), @@ -1833,6 +1840,8 @@ func sagaCompensationFlow(ctx context.Context) { trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("http.request.method", "POST"), + attribute.String("server.address", "api.stripe.com"), + attribute.Int("server.port", 443), attribute.String("service.peer.name", "stripe-api"), attribute.Int("http.response.status_code", 402), attribute.Int("retry.attempt", attempt), @@ -1955,6 +1964,9 @@ func sagaCompensationFlow(ctx context.Context) { _, email := tracer("email-service").Start(ctx4, "SendEmail", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( + attribute.String("http.request.method", "POST"), + attribute.String("server.address", "api.sendgrid.com"), + attribute.Int("server.port", 443), attribute.String("service.peer.name", "sendgrid"), attribute.String("email.template", "order_canceled"), ), diff --git a/cmd/snowglobe/scenarios_ai.go b/cmd/snowglobe/scenarios_ai.go index b64e037..541b522 100644 --- a/cmd/snowglobe/scenarios_ai.go +++ b/cmd/snowglobe/scenarios_ai.go @@ -909,6 +909,8 @@ func returnRefundFlow(ctx context.Context) { trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("http.request.method", "POST"), + attribute.String("server.address", "api.sendgrid.com"), + attribute.Int("server.port", 443), attribute.String("service.peer.name", "sendgrid"), attribute.String("email.template", "refund_confirmation"), ), diff --git a/cmd/snowglobe/semconv_client_peer_test.go b/cmd/snowglobe/semconv_client_peer_test.go new file mode 100644 index 0000000..43b115d --- /dev/null +++ b/cmd/snowglobe/semconv_client_peer_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + + apitrace "go.opentelemetry.io/otel/trace" +) + +// A CLIENT span names what it called. Without the attribute that identifies the callee, a backend can only +// key the dependency on the bare system or peer name, so the grid grows a second node for the same thing: +// +// - postgresql spans (db-spans): db.namespace is Conditionally Required "if available", and the generator +// always knows which database a flow uses. The grid runs several postgresql databases, so without it a +// call keys as plain "postgresql", beside "postgresql-orders_db" and the rest. Systems the grid runs as +// a single instance (redis, the search and vector stores) do not split, so they are not checked here. +// - outbound calls that name a peer (service.peer.name): server.address is Required on an HTTP client +// span (http-spans), and server.port is Conditionally Required once server.address is set. A bare +// single-label peer name such as "sendgrid" cannot be told apart from an internal service, so a +// backend that never sees it answer reads it as missing. + +func TestClientSpansIdentifyTheirCallee(t *testing.T) { + spans := recordedSpans(t) + + var dbClients, peerClients int + violations := map[string]int{} + for _, s := range spans { + if s.SpanKind() != apitrace.SpanKindClient { + continue + } + if sys, ok := attrOf(s, "db.system.name"); ok && sys.AsString() == "postgresql" { + dbClients++ + for _, m := range missing(s, "db.namespace") { + violations["CLIENT "+s.Name()+" missing "+m]++ + } + } + if _, ok := attrOf(s, "service.peer.name"); ok { + peerClients++ + for _, m := range missing(s, "server.address", "server.port") { + violations["CLIENT "+s.Name()+" missing "+m+" (service.peer.name set)"]++ + } + } + } + if dbClients == 0 || peerClients == 0 { + t.Fatalf("recorded %d database and %d peer-naming client spans: the harness is not driving the flows", dbClients, peerClients) + } + reportViolations(t, violations, "callee-identifying attribute") +}