From 7485e1fded9f4656ae4a8b47d7f19b6763212439 Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 20 Oct 2026 09:47:14 -0700 Subject: [PATCH 1/3] fix(ingest): map current RPC semantic conventions to rpc_service and rpc_method Current RPC conventions deprecate rpc.service and fold it into a fully qualified rpc.method such as oteldemo.CurrencyService/Convert. Ingest read only rpc.service and rpc.method, so those spans had an empty rpc_service and a qualified rpc_method, unlike spans from older SDKs. When rpc.service is absent, split a qualified rpc.method at its last slash into service and method, so both columns mean the same thing whichever convention the producer follows. Spans that carry rpc.service keep their values, and raw attributes are unchanged. No schema or format change. Fixes #362 Claude-Session: https://claude.ai/code/session_018HfyEXHyGD1utmKF4CQtyQ --- internal/ingest/conversion_test.go | 50 ++++++++++++++++++++++++++++++ internal/ingest/server.go | 7 +++++ 2 files changed, 57 insertions(+) diff --git a/internal/ingest/conversion_test.go b/internal/ingest/conversion_test.go index 5311e61a..751606de 100644 --- a/internal/ingest/conversion_test.go +++ b/internal/ingest/conversion_test.go @@ -185,6 +185,56 @@ func TestTraceSemanticConventionPriority(t *testing.T) { } } +func TestTraceRPCSemanticConventions(t *testing.T) { + for _, tc := range []struct { + name string + attrs map[string]string + service, method string + }{ + {"old convention", map[string]string{"rpc.service": "oteldemo.CurrencyService", "rpc.method": "Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"new convention", map[string]string{"rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"explicit service with qualified method", map[string]string{"rpc.service": "CurrencyService", "rpc.method": "oteldemo.CurrencyService/Convert"}, "CurrencyService", "oteldemo.CurrencyService/Convert"}, + {"method without slash", map[string]string{"rpc.method": "Convert"}, "", "Convert"}, + {"last slash", map[string]string{"rpc.method": "package/CurrencyService/Convert"}, "package/CurrencyService", "Convert"}, + {"trailing slash", map[string]string{"rpc.method": "oteldemo.CurrencyService/"}, "oteldemo.CurrencyService", ""}, + {"leading slash", map[string]string{"rpc.method": "/Convert"}, "", "Convert"}, + {"slash only", map[string]string{"rpc.method": "/"}, "", ""}, + {"empty method", map[string]string{"rpc.method": ""}, "", ""}, + {"empty service with qualified method", map[string]string{"rpc.service": "", "rpc.method": "oteldemo.CurrencyService/Convert"}, "", "oteldemo.CurrencyService/Convert"}, + {"empty values", map[string]string{"rpc.service": "", "rpc.method": ""}, "", ""}, + {"service only", map[string]string{"rpc.service": "CurrencyService"}, "CurrencyService", ""}, + {"absent", nil, "", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + var attrs []*common.KeyValue + for k, v := range tc.attrs { + attrs = append(attrs, &common.KeyValue{Key: k, Value: &common.AnyValue{Value: &common.AnyValue_StringValue{StringValue: v}}}) + } + var got telemetrystore.Batch + srv := NewServer(config.Config{}, conversionSubmitter(func(_ context.Context, b telemetrystore.Batch) error { got = b; return nil })) + req := &collectortrace.ExportTraceServiceRequest{ResourceSpans: []*tracepb.ResourceSpans{{ScopeSpans: []*tracepb.ScopeSpans{{Spans: []*tracepb.Span{{Attributes: attrs}}}}}}} + if _, err := srv.exportTraces(t.Context(), req); err != nil { + t.Fatal(err) + } + if len(got.Spans) != 1 { + t.Fatalf("got %d spans, want 1", len(got.Spans)) + } + span := got.Spans[0] + if span.RPCService != tc.service || span.RPCMethod != tc.method { + t.Fatalf("extracted service=%q method=%q, want service=%q method=%q", span.RPCService, span.RPCMethod, tc.service, tc.method) + } + if len(span.Attributes) != len(tc.attrs) { + t.Fatalf("raw attributes changed: got %v, want %v", span.Attributes, tc.attrs) + } + for k, v := range tc.attrs { + if span.Attributes[k] != v { + t.Errorf("raw attribute %q = %v, want %q", k, span.Attributes[k], v) + } + } + }) + } +} + func BenchmarkExportConversion(b *testing.B) { traces, logs, metrics := conversionExports(250) srv := NewServer(config.Config{DefaultNamespace: "fallback"}, conversionSubmitter(func(context.Context, telemetrystore.Batch) error { return nil })) diff --git a/internal/ingest/server.go b/internal/ingest/server.go index 0c2ae926..678521a0 100644 --- a/internal/ingest/server.go +++ b/internal/ingest/server.go @@ -135,6 +135,13 @@ func (s *Server) exportTraces(ctx context.Context, req *collectortrace.ExportTra ServiceVersion: serviceVersion, DeploymentEnv: deploymentEnv, } + // Current conventions fold rpc.service into a qualified rpc.method. + if _, ok := attrs["rpc.service"]; !ok { + if i := strings.LastIndex(row.RPCMethod, "/"); i >= 0 { + row.RPCService = row.RPCMethod[:i] + row.RPCMethod = row.RPCMethod[i+1:] + } + } // Pre-extracted exception info from span events. excType, excMsg := extractException(sp.Events) row.ExceptionType = excType From 306a26ccafac5caa98f91d27b16773bb1ee17bbf Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 20 Oct 2026 11:27:55 -0700 Subject: [PATCH 2/3] fix(ingest): split qualified rpc.method only for gRPC and Connect RPC JSON-RPC method names may contain a slash, such as LSP's textDocument/hover, and must not become a service and method. Split a qualified rpc.method only when rpc.system.name, or the deprecated rpc.system, is grpc or connect_rpc, whose conventions define it as service/method. Claude-Session: https://claude.ai/code/session_018HfyEXHyGD1utmKF4CQtyQ --- internal/ingest/conversion_test.go | 24 +++++++++++++++++------- internal/ingest/server.go | 5 +++-- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/internal/ingest/conversion_test.go b/internal/ingest/conversion_test.go index 751606de..86cce920 100644 --- a/internal/ingest/conversion_test.go +++ b/internal/ingest/conversion_test.go @@ -192,15 +192,25 @@ func TestTraceRPCSemanticConventions(t *testing.T) { service, method string }{ {"old convention", map[string]string{"rpc.service": "oteldemo.CurrencyService", "rpc.method": "Convert"}, "oteldemo.CurrencyService", "Convert"}, - {"new convention", map[string]string{"rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, - {"explicit service with qualified method", map[string]string{"rpc.service": "CurrencyService", "rpc.method": "oteldemo.CurrencyService/Convert"}, "CurrencyService", "oteldemo.CurrencyService/Convert"}, + {"grpc", map[string]string{"rpc.system.name": "grpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"deprecated grpc", map[string]string{"rpc.system": "grpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"connect rpc", map[string]string{"rpc.system.name": "connect_rpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"deprecated connect rpc", map[string]string{"rpc.system": "connect_rpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"jsonrpc", map[string]string{"rpc.system.name": "jsonrpc", "rpc.method": "textDocument/hover"}, "", "textDocument/hover"}, + {"deprecated jsonrpc", map[string]string{"rpc.system": "jsonrpc", "rpc.method": "textDocument/hover"}, "", "textDocument/hover"}, + {"unknown system", map[string]string{"rpc.system.name": "unknown", "rpc.method": "service/method"}, "", "service/method"}, + {"deprecated unknown system", map[string]string{"rpc.system": "unknown", "rpc.method": "service/method"}, "", "service/method"}, + {"no system", map[string]string{"rpc.method": "service/method"}, "", "service/method"}, + {"current grpc wins", map[string]string{"rpc.system.name": "grpc", "rpc.system": "jsonrpc", "rpc.method": "service/method"}, "service", "method"}, + {"current jsonrpc wins", map[string]string{"rpc.system.name": "jsonrpc", "rpc.system": "grpc", "rpc.method": "textDocument/hover"}, "", "textDocument/hover"}, + {"explicit service with qualified method", map[string]string{"rpc.system.name": "grpc", "rpc.service": "CurrencyService", "rpc.method": "oteldemo.CurrencyService/Convert"}, "CurrencyService", "oteldemo.CurrencyService/Convert"}, {"method without slash", map[string]string{"rpc.method": "Convert"}, "", "Convert"}, - {"last slash", map[string]string{"rpc.method": "package/CurrencyService/Convert"}, "package/CurrencyService", "Convert"}, - {"trailing slash", map[string]string{"rpc.method": "oteldemo.CurrencyService/"}, "oteldemo.CurrencyService", ""}, - {"leading slash", map[string]string{"rpc.method": "/Convert"}, "", "Convert"}, - {"slash only", map[string]string{"rpc.method": "/"}, "", ""}, + {"last slash", map[string]string{"rpc.system.name": "grpc", "rpc.method": "package/CurrencyService/Convert"}, "package/CurrencyService", "Convert"}, + {"trailing slash", map[string]string{"rpc.system.name": "grpc", "rpc.method": "oteldemo.CurrencyService/"}, "oteldemo.CurrencyService", ""}, + {"leading slash", map[string]string{"rpc.system.name": "grpc", "rpc.method": "/Convert"}, "", "Convert"}, + {"slash only", map[string]string{"rpc.system.name": "grpc", "rpc.method": "/"}, "", ""}, {"empty method", map[string]string{"rpc.method": ""}, "", ""}, - {"empty service with qualified method", map[string]string{"rpc.service": "", "rpc.method": "oteldemo.CurrencyService/Convert"}, "", "oteldemo.CurrencyService/Convert"}, + {"empty service with qualified method", map[string]string{"rpc.system.name": "grpc", "rpc.service": "", "rpc.method": "oteldemo.CurrencyService/Convert"}, "", "oteldemo.CurrencyService/Convert"}, {"empty values", map[string]string{"rpc.service": "", "rpc.method": ""}, "", ""}, {"service only", map[string]string{"rpc.service": "CurrencyService"}, "CurrencyService", ""}, {"absent", nil, "", ""}, diff --git a/internal/ingest/server.go b/internal/ingest/server.go index 678521a0..59194f29 100644 --- a/internal/ingest/server.go +++ b/internal/ingest/server.go @@ -135,9 +135,10 @@ func (s *Server) exportTraces(ctx context.Context, req *collectortrace.ExportTra ServiceVersion: serviceVersion, DeploymentEnv: deploymentEnv, } - // Current conventions fold rpc.service into a qualified rpc.method. + // gRPC and Connect RPC fold rpc.service into a qualified rpc.method. if _, ok := attrs["rpc.service"]; !ok { - if i := strings.LastIndex(row.RPCMethod, "/"); i >= 0 { + rpcSystem := spanAttr(sp.Attributes, "rpc.system.name", "rpc.system") + if i := strings.LastIndex(row.RPCMethod, "/"); i >= 0 && (rpcSystem == "grpc" || rpcSystem == "connect_rpc") { row.RPCService = row.RPCMethod[:i] row.RPCMethod = row.RPCMethod[i+1:] } From c3349ef89085c3b71ad5262b4559dcca8a0469ee Mon Sep 17 00:00:00 2001 From: Vishal Rana Date: Tue, 20 Oct 2026 11:31:09 -0700 Subject: [PATCH 3/3] fix(ingest): recognize the current connectrpc system name Current conventions spell Connect RPC as rpc.system.name=connectrpc; the deprecated rpc.system value is connect_rpc. Accept both, so compliant Connect spans still split their qualified rpc.method. Claude-Session: https://claude.ai/code/session_018HfyEXHyGD1utmKF4CQtyQ --- internal/ingest/conversion_test.go | 3 ++- internal/ingest/server.go | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/ingest/conversion_test.go b/internal/ingest/conversion_test.go index 86cce920..8e588e4e 100644 --- a/internal/ingest/conversion_test.go +++ b/internal/ingest/conversion_test.go @@ -194,7 +194,8 @@ func TestTraceRPCSemanticConventions(t *testing.T) { {"old convention", map[string]string{"rpc.service": "oteldemo.CurrencyService", "rpc.method": "Convert"}, "oteldemo.CurrencyService", "Convert"}, {"grpc", map[string]string{"rpc.system.name": "grpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, {"deprecated grpc", map[string]string{"rpc.system": "grpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, - {"connect rpc", map[string]string{"rpc.system.name": "connect_rpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"connect rpc", map[string]string{"rpc.system.name": "connectrpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, + {"connect rpc with deprecated system", map[string]string{"rpc.system.name": "connectrpc", "rpc.system": "connect_rpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, {"deprecated connect rpc", map[string]string{"rpc.system": "connect_rpc", "rpc.method": "oteldemo.CurrencyService/Convert"}, "oteldemo.CurrencyService", "Convert"}, {"jsonrpc", map[string]string{"rpc.system.name": "jsonrpc", "rpc.method": "textDocument/hover"}, "", "textDocument/hover"}, {"deprecated jsonrpc", map[string]string{"rpc.system": "jsonrpc", "rpc.method": "textDocument/hover"}, "", "textDocument/hover"}, diff --git a/internal/ingest/server.go b/internal/ingest/server.go index 59194f29..26ba46dd 100644 --- a/internal/ingest/server.go +++ b/internal/ingest/server.go @@ -135,10 +135,10 @@ func (s *Server) exportTraces(ctx context.Context, req *collectortrace.ExportTra ServiceVersion: serviceVersion, DeploymentEnv: deploymentEnv, } - // gRPC and Connect RPC fold rpc.service into a qualified rpc.method. + // gRPC and Connect RPC (current and deprecated spellings) fold rpc.service into a qualified rpc.method. if _, ok := attrs["rpc.service"]; !ok { rpcSystem := spanAttr(sp.Attributes, "rpc.system.name", "rpc.system") - if i := strings.LastIndex(row.RPCMethod, "/"); i >= 0 && (rpcSystem == "grpc" || rpcSystem == "connect_rpc") { + if i := strings.LastIndex(row.RPCMethod, "/"); i >= 0 && (rpcSystem == "grpc" || rpcSystem == "connectrpc" || rpcSystem == "connect_rpc") { row.RPCService = row.RPCMethod[:i] row.RPCMethod = row.RPCMethod[i+1:] }