Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
49 changes: 49 additions & 0 deletions cmd/snowglobe/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
logapi "go.opentelemetry.io/otel/log"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/trace"
)

Expand Down Expand Up @@ -222,6 +223,54 @@ func urlHost(raw string) string {
return raw
}

// httpServerAttrs returns the attributes an HTTP SERVER span must carry beyond what the caller sets:
// url.scheme and url.path (Required by the OpenTelemetry HTTP server span table) and, when the span is a
// child of an HTTP CLIENT span, that client's server.address and server.port. The convention says these
// "are intended, whenever possible, to be the same on the client and server sides", and asks a server to
// do the best effort from the request it received, so a server reached through a named host reports that
// host (simulated here: the front door is a Host header the generator does not really have).
//
// url.path is the path of the request the server received. When the parent client says what it requested
// (url.full), that is the path; the fallback argument is used only for a server with no HTTP client parent
// (a webhook arriving from outside). A third-party server span is never emitted from here.
func httpServerAttrs(parent context.Context, fallbackPath string) []attribute.KeyValue {
path := fallbackPath
var extra []attribute.KeyValue
if p, ok := trace.SpanFromContext(parent).(sdktrace.ReadOnlySpan); ok && p.SpanKind() == trace.SpanKindClient {
var isHTTP bool
var addr, port *attribute.KeyValue
var full string
for _, a := range p.Attributes() {
a := a
switch string(a.Key) {
case "http.request.method":
isHTTP = true
case "server.address":
addr = &a
case "server.port":
port = &a
case "url.full":
full = a.Value.AsString()
}
}
if isHTTP {
if u, err := url.Parse(full); err == nil && u.Path != "" {
path = u.EscapedPath()
}
if addr != nil {
extra = append(extra, *addr)
if port != nil {
extra = append(extra, *port)
}
}
}
}
return append([]attribute.KeyValue{
attribute.String("url.scheme", "https"),
attribute.String("url.path", path),
}, extra...)
}

type modelInfo struct {
name string // model identifier
system string // gen_ai.provider.name value
Expand Down
15 changes: 15 additions & 0 deletions cmd/snowglobe/scenarios.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ func createOrderFlow(ctx context.Context) {
attribute.String("user_agent.original", "Mozilla/5.0"),
attribute.String("network.peer.address", randomIP()),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/orders")...),
)
defer gateway.End()
emitLog(ctx, "api-gateway", logapi.SeverityInfo, "Incoming request POST /api/v2/orders",
Expand Down Expand Up @@ -255,6 +256,7 @@ func searchAndBrowseFlow(ctx context.Context) {
attribute.Int("http.response.status_code", 200),
attribute.String("search.query", randomSearchTerm()),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/search")...),
)
defer gateway.End()
sleep(5, 15)
Expand Down Expand Up @@ -333,6 +335,7 @@ func userLoginFlow(ctx context.Context) {
attribute.String("http.request.method", "POST"),
attribute.String("http.route", "/api/v2/auth/login"),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/auth/login")...),
)
defer func() {
if isFailure {
Expand Down Expand Up @@ -414,6 +417,7 @@ func failedPaymentFlow(ctx context.Context) {
attribute.Int("http.response.status_code", 402),
attribute.String("network.peer.address", randomIP()),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/orders")...),
)
exc := randomException(paymentExceptions)
recordException(ctx, "api-gateway", gateway, exc.excType, exc.message, exc.stacktrace)
Expand Down Expand Up @@ -510,6 +514,7 @@ func bulkNotificationFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/admin/digest"),
attribute.Int("http.response.status_code", 202),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/admin/digest")...),
)
defer gateway.End()
sleep(3, 10)
Expand Down Expand Up @@ -605,6 +610,7 @@ func healthCheckFlow(ctx context.Context) {
attribute.String("http.route", "/healthz"),
attribute.Int("http.response.status_code", 200),
),
trace.WithAttributes(httpServerAttrs(ctx, "/healthz")...),
)
defer gateway.End()
sleep(1, 5)
Expand Down Expand Up @@ -668,6 +674,7 @@ func inventorySyncFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/admin/inventory/sync"),
attribute.Int("http.response.status_code", 202),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/admin/inventory/sync")...),
)
defer gateway.End()
sleep(3, 10)
Expand Down Expand Up @@ -870,6 +877,7 @@ func stripeWebhookFlow(ctx context.Context) {
attribute.String("webhook.id", fmt.Sprintf("evt_%016d", rand.Int63())),
attribute.String("service.peer.name", "stripe-api"),
),
trace.WithAttributes(httpServerAttrs(ctx, "/webhooks/stripe")...),
)
defer webhook.End()
sleep(5, 15)
Expand Down Expand Up @@ -992,6 +1000,7 @@ func recommendationFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/recommendations"),
attribute.Int("http.response.status_code", 200),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/recommendations")...),
)
defer gateway.End()
sleep(3, 10)
Expand Down Expand Up @@ -1118,6 +1127,7 @@ func addToCartFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/cart/items"),
attribute.Int("http.response.status_code", 200),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/cart/items")...),
)
defer gateway.End()
sleep(3, 10)
Expand Down Expand Up @@ -1263,6 +1273,7 @@ func fullCheckoutFlow(ctx context.Context) {
attribute.Int("http.response.status_code", 200),
attribute.String("network.peer.address", randomIP()),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/checkout")...),
)
defer gateway.End()
emitLog(ctx, "api-gateway", logapi.SeverityInfo, "Checkout initiated",
Expand Down Expand Up @@ -1593,6 +1604,7 @@ func shippingUpdateFlow(ctx context.Context) {
attribute.String("shipping.tracking_number", fmt.Sprintf("1Z%09d", rand.Intn(999999999))),
attribute.String("shipping.status", shippingStatus),
),
trace.WithAttributes(httpServerAttrs(ctx, "/webhooks/carrier")...),
)
defer webhook.End()
sleep(5, 15)
Expand Down Expand Up @@ -1739,6 +1751,7 @@ func sagaCompensationFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/checkout"),
attribute.Int("http.response.status_code", 402),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/checkout")...),
)
defer gateway.End()
sleep(5, 15)
Expand Down Expand Up @@ -2014,6 +2027,7 @@ func timeoutCascadeFlow(ctx context.Context) {
attribute.Int("http.response.status_code", 504),
attribute.Int("retry.attempt", 1),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/search")...),
)
sleep(5, 10)

Expand Down Expand Up @@ -2051,6 +2065,7 @@ func timeoutCascadeFlow(ctx context.Context) {
attribute.Bool("circuit_breaker.open", true),
attribute.String("circuit_breaker.fallback", "stale_cache"),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/search")...),
)
sleep(3, 8)

Expand Down
5 changes: 5 additions & 0 deletions cmd/snowglobe/scenarios_ai.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ func ragSearchFlow(ctx context.Context) {
attribute.Int("http.response.status_code", 200),
attribute.String("search.query", query),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/search/ai")...),
)
defer gateway.End()
sleep(3, 10)
Expand Down Expand Up @@ -209,6 +210,7 @@ func aiChatbotFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/chat"),
attribute.Int("http.response.status_code", 200),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/chat")...),
)
defer gateway.End()
sleep(3, 8)
Expand Down Expand Up @@ -370,6 +372,7 @@ func contentModerationFlow(ctx context.Context) {
attribute.String("http.request.method", "POST"),
attribute.String("http.route", "/api/v2/reviews"),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/reviews")...),
)
defer gateway.End()
sleep(3, 8)
Expand Down Expand Up @@ -542,6 +545,7 @@ func multiStepAgentFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/agent/task"),
attribute.Int("http.response.status_code", 200),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/agent/task")...),
)
defer gateway.End()
sleep(3, 8)
Expand Down Expand Up @@ -767,6 +771,7 @@ func returnRefundFlow(ctx context.Context) {
attribute.String("http.route", "/api/v2/returns"),
attribute.Int("http.response.status_code", 200),
),
trace.WithAttributes(httpServerAttrs(ctx, "/api/v2/returns")...),
)
defer gateway.End()
sleep(5, 15)
Expand Down
118 changes: 118 additions & 0 deletions cmd/snowglobe/semconv_e1_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package main

import (
"net/url"
"testing"

"go.opentelemetry.io/otel/sdk/trace"
apitrace "go.opentelemetry.io/otel/trace"
)

// SP-120 E1. The HTTP semantic conventions (http-spans, semconv 1.44.0) make these Required on a SERVER
// span: http.request.method, url.path, url.scheme. server.address is Recommended, server.port is
// Conditionally Required when server.address is set, and both "are intended, whenever possible, to be the
// same on the client and server sides". A span is an HTTP span here when it carries http.request.method.

func TestHTTPServerSpansCarryRequiredAttributes(t *testing.T) {
spans := recordedSpans(t)

var httpServer int
violations := map[string]int{}
for _, s := range spans {
if s.SpanKind() != apitrace.SpanKindServer {
continue
}
if _, ok := attrOf(s, "http.request.method"); !ok {
continue
}
httpServer++
for _, m := range missing(s, "url.path", "url.scheme") {
violations["SERVER "+s.Name()+" missing "+m]++
}
if _, ok := attrOf(s, "server.address"); ok {
for _, m := range missing(s, "server.port") {
violations["SERVER "+s.Name()+" missing "+m+" (server.address set)"]++
}
}
}
if httpServer == 0 {
t.Fatal("recorded no HTTP server spans: the harness is not driving the flows")
}
reportViolations(t, violations, "Required-attribute")
}

// For EVERY HTTP client -> HTTP server pair the server reports what the client asked for: the same host,
// the same port, and the path of the client's url.full. Presence alone would let a wrong value through
// (the healthz server once said /healthz while its client requested /api/v2/healthz).
func TestHTTPServerSpanMirrorsCallingClientRequest(t *testing.T) {
spans := recordedSpans(t)

byID := map[apitrace.SpanID]trace.ReadOnlySpan{}
for _, s := range spans {
byID[s.SpanContext().SpanID()] = s
}

pairs := 0
sawHealthz := false
for _, s := range spans {
if s.SpanKind() != apitrace.SpanKindServer {
continue
}
if _, ok := attrOf(s, "http.request.method"); !ok {
continue
}
parent, ok := byID[s.Parent().SpanID()]
if !ok || parent.SpanKind() != apitrace.SpanKindClient {
continue
}
if _, isHTTP := attrOf(parent, "http.request.method"); !isHTTP {
continue
}
pairs++
if s.Name() == "GET /healthz" {
sawHealthz = true
}

full, _ := attrOf(parent, "url.full")
u, err := url.Parse(full.AsString())
if err != nil || u.Path == "" {
t.Errorf("client %q has no parseable url.full %q", parent.Name(), full.AsString())
continue
}
if got, ok := attrOf(s, "url.path"); !ok || got.AsString() != u.EscapedPath() {
t.Errorf("server %q url.path=%q ok=%v, want the client's requested path %q", s.Name(), got.AsString(), ok, u.EscapedPath())
}
if got, ok := attrOf(s, "server.address"); !ok || got.AsString() != u.Hostname() {
t.Errorf("server %q server.address=%q ok=%v, want the client's host %q", s.Name(), got.AsString(), ok, u.Hostname())
}
pAddr, _ := attrOf(parent, "server.address")
if got, _ := attrOf(s, "server.address"); got.AsString() != pAddr.AsString() {
t.Errorf("server %q server.address=%q differs from client attribute %q", s.Name(), got.AsString(), pAddr.AsString())
}
pPort, _ := attrOf(parent, "server.port")
if got, ok := attrOf(s, "server.port"); !ok || got.AsInt64() != pPort.AsInt64() {
t.Errorf("server %q server.port=%d ok=%v, want the client's %d", s.Name(), got.AsInt64(), ok, pPort.AsInt64())
}
if got, _ := attrOf(s, "url.scheme"); got.AsString() != u.Scheme {
t.Errorf("server %q url.scheme=%q, client url.full scheme %q", s.Name(), got.AsString(), u.Scheme)
}
}
if pairs == 0 {
t.Fatal("no HTTP client -> HTTP server pair observed")
}
if !sawHealthz {
t.Fatal("the health-check pair (the one that once disagreed) was not observed")
}
}

// Nothing is invented: only the shop front door is ever a server host.
func TestNoInventedThirdPartyServers(t *testing.T) {
for _, s := range recordedSpans(t) {
if s.SpanKind() != apitrace.SpanKindServer {
continue
}
if v, ok := attrOf(s, "server.address"); ok && v.AsString() != "shop.example.com" {
t.Errorf("SERVER span %q carries server.address=%q: only the shop front door is served here", s.Name(), v.AsString())
}
}
}
Loading
Loading