From 838646498a17323cc3e394fd033f54587b5c5c8c Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 16 Jun 2026 10:37:48 -0400 Subject: [PATCH 01/12] feat(tracer): add OTLP metrics HTTP exporter (SEMCON-1093 PR3) Adds otlpMetricsExporter which converts a ClientStatsPayload to an ExportMetricsServiceRequest, marshals it as HTTP/JSON (protojson) or HTTP/protobuf based on OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, and POSTs it to the configured OTLP metrics endpoint. The protocol config field defaults to http/protobuf per the OTel spec; system tests set http/json explicitly via OTEL_EXPORTER_OTLP_METRICS_PROTOCOL=http/json. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter.go | 137 +++++++++++++++ ddtrace/tracer/otlp_metrics_exporter_test.go | 171 +++++++++++++++++++ internal/config/config.go | 10 ++ internal/config/config_helpers.go | 10 ++ internal/config/config_test.go | 33 ++++ 5 files changed, 361 insertions(+) create mode 100644 ddtrace/tracer/otlp_metrics_exporter.go create mode 100644 ddtrace/tracer/otlp_metrics_exporter_test.go diff --git a/ddtrace/tracer/otlp_metrics_exporter.go b/ddtrace/tracer/otlp_metrics_exporter.go new file mode 100644 index 00000000000..9b97b9f0222 --- /dev/null +++ b/ddtrace/tracer/otlp_metrics_exporter.go @@ -0,0 +1,137 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package tracer + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" + otlpmetrics "go.opentelemetry.io/proto/otlp/metrics/v1" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/encoding/protowire" + "google.golang.org/protobuf/proto" + + internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" + "github.com/DataDog/dd-trace-go/v2/internal/log" +) + +const ( + otlpMetricsContentTypeJSON = "application/json" + otlpMetricsContentTypeProto = "application/x-protobuf" +) + +// otlpMetricsExporter converts ClientStatsPayload to OTLP metrics and sends them over HTTP. +type otlpMetricsExporter struct { + client *http.Client + url string + headers map[string]string + protocol string // "http/json" or "http/protobuf" + cfg *internalconfig.Config +} + +func newOTLPMetricsExporter(cfg *internalconfig.Config) *otlpMetricsExporter { + return &otlpMetricsExporter{ + client: &http.Client{Timeout: cfg.AgentTimeout()}, + url: cfg.OTLPMetricsURL(), + headers: cfg.OTLPMetricsHeaders(), + protocol: cfg.OTLPMetricsProtocol(), + cfg: cfg, + } +} + +// export converts payload to an OTLP ExportMetricsServiceRequest and sends it. +// A nil or empty payload produces no request. Errors are logged and returned. +func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { + rms := BuildOTLPMetricsRequest(payload, e.cfg) + if len(rms) == 0 { + return nil + } + + var body []byte + var contentType string + var err error + + if e.protocol == "http/json" { + body, err = marshalExportRequestJSON(rms) + contentType = otlpMetricsContentTypeJSON + } else { + body, err = marshalExportRequestProto(rms) + contentType = otlpMetricsContentTypeProto + } + if err != nil { + return fmt.Errorf("otlp_metrics_exporter: marshal failed: %w", err) + } + + if sendErr := e.send(body, contentType); sendErr != nil { + log.Error("otlp_metrics_exporter: export to %s failed: %v", e.url, sendErr.Error()) + return sendErr + } + log.Debug("otlp_metrics_exporter: exported %d bytes (%s) to %s", len(body), e.protocol, e.url) + return nil +} + +// marshalExportRequestProto encodes a ResourceMetrics slice as the protobuf binary of +// ExportMetricsServiceRequest (field 1: repeated ResourceMetrics). This avoids importing +// the collector package which transitively pulls in grpc-gateway and conflicts with +// older monolithic google.golang.org/genproto in some contrib modules. +func marshalExportRequestProto(rms []*otlpmetrics.ResourceMetrics) ([]byte, error) { + var buf []byte + for _, rm := range rms { + b, err := proto.Marshal(rm) + if err != nil { + return nil, err + } + buf = protowire.AppendTag(buf, 1, protowire.BytesType) + buf = protowire.AppendBytes(buf, b) + } + return buf, nil +} + +// marshalExportRequestJSON encodes a ResourceMetrics slice as the JSON of +// ExportMetricsServiceRequest: {"resourceMetrics": [...]}. +func marshalExportRequestJSON(rms []*otlpmetrics.ResourceMetrics) ([]byte, error) { + type exportReq struct { + ResourceMetrics []json.RawMessage `json:"resourceMetrics"` + } + items := make([]json.RawMessage, 0, len(rms)) + for _, rm := range rms { + b, err := protojson.Marshal(rm) + if err != nil { + return nil, err + } + items = append(items, json.RawMessage(b)) + } + return json.Marshal(exportReq{ResourceMetrics: items}) +} + +func (e *otlpMetricsExporter) send(data []byte, contentType string) error { + req, err := http.NewRequest(http.MethodPost, e.url, bytes.NewReader(data)) + if err != nil { + return fmt.Errorf("cannot create request: %w", err) + } + req.Header.Set("Content-Type", contentType) + for k, v := range e.headers { + req.Header.Set(k, v) + } + + resp, err := e.client.Do(req) + if err != nil { + return err + } + defer func() { + _, _ = io.Copy(io.Discard, resp.Body) + resp.Body.Close() + }() + + if resp.StatusCode >= 400 { + return fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)) + } + return nil +} diff --git a/ddtrace/tracer/otlp_metrics_exporter_test.go b/ddtrace/tracer/otlp_metrics_exporter_test.go new file mode 100644 index 00000000000..40682a0e469 --- /dev/null +++ b/ddtrace/tracer/otlp_metrics_exporter_test.go @@ -0,0 +1,171 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2025 Datadog, Inc. + +package tracer + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" + otlpcollectormetrics "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + "google.golang.org/protobuf/proto" + + internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" +) + +// captureServer creates a test HTTP server that records the last request it received. +type captureServer struct { + *httptest.Server + lastBody []byte + lastContentType string +} + +func newCaptureServer(t *testing.T) *captureServer { + t.Helper() + cs := &captureServer{} + cs.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cs.lastContentType = r.Header.Get("Content-Type") + cs.lastBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(cs.Server.Close) + return cs +} + +func makeExporterWithServer(t *testing.T, srv *captureServer, protocol string) *otlpMetricsExporter { + t.Helper() + cfg := internalconfig.CreateNew() + return &otlpMetricsExporter{ + client: srv.Server.Client(), + url: srv.URL + "/v1/metrics", + headers: nil, + protocol: protocol, + cfg: cfg, + } +} + +// ---- otlpMetricsExporter.export ---- + +func TestOTLPMetricsExporterExportEmptyPayload(t *testing.T) { + srv := newCaptureServer(t) + exp := makeExporterWithServer(t, srv, "http/json") + // A payload with no groups yields a nil request; no HTTP call is made. + err := exp.export(makePayload("svc", "", "", nil)) + require.NoError(t, err) + assert.Empty(t, srv.lastBody, "no HTTP call expected for empty payload") +} + +func TestOTLPMetricsExporterExportJSONContentType(t *testing.T) { + srv := newCaptureServer(t) + exp := makeExporterWithServer(t, srv, "http/json") + gs := &pb.ClientGroupedStats{ + Service: "svc", + Resource: "web.request", + OkSummary: encodeSketch(t, 50e6), + } + err := exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs})) + require.NoError(t, err) + assert.Equal(t, otlpMetricsContentTypeJSON, srv.lastContentType) + assert.NotEmpty(t, srv.lastBody) +} + +func TestOTLPMetricsExporterExportProtoContentType(t *testing.T) { + srv := newCaptureServer(t) + exp := makeExporterWithServer(t, srv, "http/protobuf") + gs := &pb.ClientGroupedStats{ + Service: "svc", + Resource: "web.request", + OkSummary: encodeSketch(t, 50e6), + } + err := exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs})) + require.NoError(t, err) + assert.Equal(t, otlpMetricsContentTypeProto, srv.lastContentType) + assert.NotEmpty(t, srv.lastBody) +} + +func TestOTLPMetricsExporterExportJSONIsValidOTLP(t *testing.T) { + srv := newCaptureServer(t) + exp := makeExporterWithServer(t, srv, "http/json") + gs := &pb.ClientGroupedStats{ + Service: "svc", + Resource: "web.request", + OkSummary: encodeSketch(t, 50e6), + } + require.NoError(t, exp.export(makePayload("svc", "prod", "1.0", []*pb.ClientGroupedStats{gs}))) + + // The body must be valid JSON with the expected metric name. + var parsed map[string]any + require.NoError(t, json.Unmarshal(srv.lastBody, &parsed)) + body := string(srv.lastBody) + assert.Contains(t, body, spanDurationMetricName) + assert.Contains(t, body, "service.name") +} + +func TestOTLPMetricsExporterExportProtobufIsDecodable(t *testing.T) { + srv := newCaptureServer(t) + exp := makeExporterWithServer(t, srv, "http/protobuf") + gs := &pb.ClientGroupedStats{ + Service: "svc", + Resource: "web.request", + OkSummary: encodeSketch(t, 50e6), + } + require.NoError(t, exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs}))) + + var decoded otlpcollectormetrics.ExportMetricsServiceRequest + require.NoError(t, proto.Unmarshal(srv.lastBody, &decoded)) + require.Len(t, decoded.ResourceMetrics, 1) + require.Len(t, decoded.ResourceMetrics[0].ScopeMetrics, 1) + assert.Equal(t, spanDurationMetricName, decoded.ResourceMetrics[0].ScopeMetrics[0].Metrics[0].Name) +} + +func TestOTLPMetricsExporterExportHTTPError(t *testing.T) { + errSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + t.Cleanup(errSrv.Close) + exp := &otlpMetricsExporter{ + client: errSrv.Client(), + url: errSrv.URL, + protocol: "http/json", + cfg: internalconfig.CreateNew(), + } + gs := &pb.ClientGroupedStats{Service: "svc", Resource: "op", OkSummary: encodeSketch(t, 50e6)} + err := exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs})) + require.Error(t, err) + assert.Contains(t, err.Error(), "500") +} + +func TestOTLPMetricsExporterCustomHeaders(t *testing.T) { + var gotHeader string + hdrSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Custom-Header") + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(hdrSrv.Close) + exp := &otlpMetricsExporter{ + client: hdrSrv.Client(), + url: hdrSrv.URL, + headers: map[string]string{"X-Custom-Header": "my-value"}, + protocol: "http/json", + cfg: internalconfig.CreateNew(), + } + gs := &pb.ClientGroupedStats{Service: "svc", Resource: "op", OkSummary: encodeSketch(t, 50e6)} + require.NoError(t, exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs}))) + assert.Equal(t, "my-value", gotHeader) +} + +// ---- config integration ---- + +func TestOTLPMetricsProtocolDefaultIsProtobuf(t *testing.T) { + cfg := internalconfig.CreateNew() + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) +} diff --git a/internal/config/config.go b/internal/config/config.go index dca1b1c27e8..bf413c30707 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -192,6 +192,8 @@ type Config struct { otlpMetricsHeaders map[string]string // otlpMetricsFlushInterval is the span metrics flush cadence (default 10s). otlpMetricsFlushInterval time.Duration + // otlpMetricsProtocol is the OTLP export protocol for metrics: "http/json" or "http/protobuf". + otlpMetricsProtocol string // traceID128BitEnabled controls if trace IDs are generated as 128-bits or 64-bits. traceID128BitEnabled bool // apiKey is the Datadog API key from DD_API_KEY (used for agentless intake, LLM Obs, etc.). @@ -347,6 +349,7 @@ func loadConfig() *Config { p.GetMap("OTEL_EXPORTER_OTLP_METRICS_HEADERS", nil, internal.OtelTagsDelimeter), ) cfg.otlpMetricsFlushInterval = resolveOTLPMetricsFlushInterval(env.Get("_DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL")) + cfg.otlpMetricsProtocol = resolveOTLPMetricsProtocol(p.GetString("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "")) cfg.traceID128BitEnabled = p.GetBool("DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED", true) cfg.httpClientTimeout = time.Duration(p.GetIntWithValidator("DD_TRACE_AGENT_TIMEOUT", 10, validateAgentTimeout)) * time.Second cfg.propagationStyleInject = p.GetString("DD_TRACE_PROPAGATION_STYLE_INJECT", "") @@ -1443,6 +1446,13 @@ func (c *Config) OTLPMetricsFlushInterval() time.Duration { return c.otlpMetricsFlushInterval } +// OTLPMetricsProtocol returns the OTLP export protocol for metrics ("http/json" or "http/protobuf"). +func (c *Config) OTLPMetricsProtocol() string { + c.mu.RLock() + defer c.mu.RUnlock() + return c.otlpMetricsProtocol +} + func (c *Config) TraceID128BitEnabled() bool { c.mu.RLock() defer c.mu.RUnlock() diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go index d06b5c97464..a2bb977e3ab 100644 --- a/internal/config/config_helpers.go +++ b/internal/config/config_helpers.go @@ -408,6 +408,16 @@ func buildOTLPMetricsHeaders(genericHeaders, signalHeaders map[string]string) ma return merged } +// resolveOTLPMetricsProtocol normalises OTEL_EXPORTER_OTLP_METRICS_PROTOCOL to one of +// "http/json" or "http/protobuf". Unknown values and the empty string default to "http/protobuf" +// per the OTel specification. +func resolveOTLPMetricsProtocol(v string) string { + if v == "http/json" { + return "http/json" + } + return "http/protobuf" +} + // resolveOTLPMetricsFlushInterval parses _DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL (milliseconds). // The variable is internal and intended for tests only; in production it returns the default 10 s. func resolveOTLPMetricsFlushInterval(raw string) time.Duration { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 765118b33e1..7b4281a91dc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -943,6 +943,39 @@ func TestOTLPMetricsFlushInterval(t *testing.T) { }) } +func TestOTLPMetricsProtocol(t *testing.T) { + t.Run("defaults to http/protobuf", func(t *testing.T) { + resetGlobalState() + defer resetGlobalState() + + cfg := Get() + require.NotNil(t, cfg) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) + }) + + t.Run("http/json via OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", func(t *testing.T) { + resetGlobalState() + defer resetGlobalState() + + t.Setenv("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "http/json") + + cfg := Get() + require.NotNil(t, cfg) + assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + }) + + t.Run("unknown value falls back to http/protobuf", func(t *testing.T) { + resetGlobalState() + defer resetGlobalState() + + t.Setenv("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "grpc") + + cfg := Get() + require.NotNil(t, cfg) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) + }) +} + func TestHostnameConfiguration(t *testing.T) { t.Run("default behavior - hostname empty when not configured", func(t *testing.T) { resetGlobalState() From d890b4b897002da9c53dc7d6060b4a05e689277f Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Thu, 18 Jun 2026 16:47:21 -0400 Subject: [PATCH 02/12] fix(tracer): decode OTLP export proto in test without collector/metrics/v1 Importing go.opentelemetry.io/proto/otlp/collector/metrics/v1 in the test binary triggered the grpc-gateway -> genproto ambiguous-import error when confluent-kafka-go (which requires the old monolithic genproto) was in the same build graph. Replace the collector import with a protowire decode of ExportMetricsServiceRequest field 1 directly into the already-imported ResourceMetrics type. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter_test.go | 29 ++++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter_test.go b/ddtrace/tracer/otlp_metrics_exporter_test.go index 40682a0e469..c64617aa6b2 100644 --- a/ddtrace/tracer/otlp_metrics_exporter_test.go +++ b/ddtrace/tracer/otlp_metrics_exporter_test.go @@ -16,7 +16,8 @@ import ( "github.com/stretchr/testify/require" pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" - otlpcollectormetrics "go.opentelemetry.io/proto/otlp/collector/metrics/v1" + otlpmetrics "go.opentelemetry.io/proto/otlp/metrics/v1" + "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" @@ -120,11 +121,27 @@ func TestOTLPMetricsExporterExportProtobufIsDecodable(t *testing.T) { } require.NoError(t, exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs}))) - var decoded otlpcollectormetrics.ExportMetricsServiceRequest - require.NoError(t, proto.Unmarshal(srv.lastBody, &decoded)) - require.Len(t, decoded.ResourceMetrics, 1) - require.Len(t, decoded.ResourceMetrics[0].ScopeMetrics, 1) - assert.Equal(t, spanDurationMetricName, decoded.ResourceMetrics[0].ScopeMetrics[0].Metrics[0].Name) + // Decode without importing collector/metrics/v1 to avoid the genproto split + // ambiguity with confluent-kafka-go. ExportMetricsServiceRequest wire format: + // field 1 (bytes) = repeated ResourceMetrics. + var resourceMetrics []*otlpmetrics.ResourceMetrics + b := srv.lastBody + for len(b) > 0 { + num, typ, n := protowire.ConsumeTag(b) + require.Positive(t, n) + b = b[n:] + val, n := protowire.ConsumeBytes(b) + require.Positive(t, n) + b = b[n:] + if num == 1 && typ == protowire.BytesType { + var rm otlpmetrics.ResourceMetrics + require.NoError(t, proto.Unmarshal(val, &rm)) + resourceMetrics = append(resourceMetrics, &rm) + } + } + require.Len(t, resourceMetrics, 1) + require.Len(t, resourceMetrics[0].ScopeMetrics, 1) + assert.Equal(t, spanDurationMetricName, resourceMetrics[0].ScopeMetrics[0].Metrics[0].Name) } func TestOTLPMetricsExporterExportHTTPError(t *testing.T) { From e3130611d81359529c4705988f1cf1753e23f397 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Thu, 2 Jul 2026 14:35:42 -0400 Subject: [PATCH 03/12] refactor(tracer): update exporter to call unexported buildOTLPMetricsRequest Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter.go b/ddtrace/tracer/otlp_metrics_exporter.go index 9b97b9f0222..34a17d7b985 100644 --- a/ddtrace/tracer/otlp_metrics_exporter.go +++ b/ddtrace/tracer/otlp_metrics_exporter.go @@ -49,7 +49,7 @@ func newOTLPMetricsExporter(cfg *internalconfig.Config) *otlpMetricsExporter { // export converts payload to an OTLP ExportMetricsServiceRequest and sends it. // A nil or empty payload produces no request. Errors are logged and returned. func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { - rms := BuildOTLPMetricsRequest(payload, e.cfg) + rms := buildOTLPMetricsRequest(payload, e.cfg) if len(rms) == 0 { return nil } From caeab556975e3a90722232db91d8c58b31e190af Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Thu, 2 Jul 2026 14:50:55 -0400 Subject: [PATCH 04/12] refactor(tracer): reuse otlpTransport in otlpMetricsExporter otlpMetricsExporter now holds a *otlpTransport instead of duplicating the HTTP client/send logic. otlpTransport.send accepts a contentType parameter so the metrics exporter can use it for both JSON and protobuf. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter.go | 49 +++++--------------- ddtrace/tracer/otlp_metrics_exporter_test.go | 23 ++++----- ddtrace/tracer/otlp_transport.go | 6 +-- ddtrace/tracer/otlp_transport_test.go | 14 +++--- ddtrace/tracer/otlp_writer.go | 2 +- 5 files changed, 31 insertions(+), 63 deletions(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter.go b/ddtrace/tracer/otlp_metrics_exporter.go index 34a17d7b985..197ca772a04 100644 --- a/ddtrace/tracer/otlp_metrics_exporter.go +++ b/ddtrace/tracer/otlp_metrics_exporter.go @@ -6,10 +6,8 @@ package tracer import ( - "bytes" "encoding/json" "fmt" - "io" "net/http" pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" @@ -29,18 +27,18 @@ const ( // otlpMetricsExporter converts ClientStatsPayload to OTLP metrics and sends them over HTTP. type otlpMetricsExporter struct { - client *http.Client - url string - headers map[string]string - protocol string // "http/json" or "http/protobuf" - cfg *internalconfig.Config + transport *otlpTransport + protocol string // "http/json" or "http/protobuf" + cfg *internalconfig.Config } func newOTLPMetricsExporter(cfg *internalconfig.Config) *otlpMetricsExporter { return &otlpMetricsExporter{ - client: &http.Client{Timeout: cfg.AgentTimeout()}, - url: cfg.OTLPMetricsURL(), - headers: cfg.OTLPMetricsHeaders(), + transport: newOTLPTransport( + &http.Client{Timeout: cfg.AgentTimeout()}, + cfg.OTLPMetricsURL(), + cfg.OTLPMetricsHeaders(), + ), protocol: cfg.OTLPMetricsProtocol(), cfg: cfg, } @@ -69,11 +67,11 @@ func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { return fmt.Errorf("otlp_metrics_exporter: marshal failed: %w", err) } - if sendErr := e.send(body, contentType); sendErr != nil { - log.Error("otlp_metrics_exporter: export to %s failed: %v", e.url, sendErr.Error()) + if sendErr := e.transport.send(body, contentType); sendErr != nil { + log.Error("otlp_metrics_exporter: export to %s failed: %v", e.transport.endpoint, sendErr.Error()) return sendErr } - log.Debug("otlp_metrics_exporter: exported %d bytes (%s) to %s", len(body), e.protocol, e.url) + log.Debug("otlp_metrics_exporter: exported %d bytes (%s) to %s", len(body), e.protocol, e.transport.endpoint) return nil } @@ -110,28 +108,3 @@ func marshalExportRequestJSON(rms []*otlpmetrics.ResourceMetrics) ([]byte, error } return json.Marshal(exportReq{ResourceMetrics: items}) } - -func (e *otlpMetricsExporter) send(data []byte, contentType string) error { - req, err := http.NewRequest(http.MethodPost, e.url, bytes.NewReader(data)) - if err != nil { - return fmt.Errorf("cannot create request: %w", err) - } - req.Header.Set("Content-Type", contentType) - for k, v := range e.headers { - req.Header.Set(k, v) - } - - resp, err := e.client.Do(req) - if err != nil { - return err - } - defer func() { - _, _ = io.Copy(io.Discard, resp.Body) - resp.Body.Close() - }() - - if resp.StatusCode >= 400 { - return fmt.Errorf("HTTP %d: %s", resp.StatusCode, http.StatusText(resp.StatusCode)) - } - return nil -} diff --git a/ddtrace/tracer/otlp_metrics_exporter_test.go b/ddtrace/tracer/otlp_metrics_exporter_test.go index c64617aa6b2..a98575e48da 100644 --- a/ddtrace/tracer/otlp_metrics_exporter_test.go +++ b/ddtrace/tracer/otlp_metrics_exporter_test.go @@ -46,11 +46,9 @@ func makeExporterWithServer(t *testing.T, srv *captureServer, protocol string) * t.Helper() cfg := internalconfig.CreateNew() return &otlpMetricsExporter{ - client: srv.Server.Client(), - url: srv.URL + "/v1/metrics", - headers: nil, - protocol: protocol, - cfg: cfg, + transport: newOTLPTransport(srv.Server.Client(), srv.URL+"/v1/metrics", nil), + protocol: protocol, + cfg: cfg, } } @@ -150,10 +148,9 @@ func TestOTLPMetricsExporterExportHTTPError(t *testing.T) { })) t.Cleanup(errSrv.Close) exp := &otlpMetricsExporter{ - client: errSrv.Client(), - url: errSrv.URL, - protocol: "http/json", - cfg: internalconfig.CreateNew(), + transport: newOTLPTransport(errSrv.Client(), errSrv.URL, nil), + protocol: "http/json", + cfg: internalconfig.CreateNew(), } gs := &pb.ClientGroupedStats{Service: "svc", Resource: "op", OkSummary: encodeSketch(t, 50e6)} err := exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs})) @@ -169,11 +166,9 @@ func TestOTLPMetricsExporterCustomHeaders(t *testing.T) { })) t.Cleanup(hdrSrv.Close) exp := &otlpMetricsExporter{ - client: hdrSrv.Client(), - url: hdrSrv.URL, - headers: map[string]string{"X-Custom-Header": "my-value"}, - protocol: "http/json", - cfg: internalconfig.CreateNew(), + transport: newOTLPTransport(hdrSrv.Client(), hdrSrv.URL, map[string]string{"X-Custom-Header": "my-value"}), + protocol: "http/json", + cfg: internalconfig.CreateNew(), } gs := &pb.ClientGroupedStats{Service: "svc", Resource: "op", OkSummary: encodeSketch(t, 50e6)} require.NoError(t, exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs}))) diff --git a/ddtrace/tracer/otlp_transport.go b/ddtrace/tracer/otlp_transport.go index b4a1acc9efe..0445793cb68 100644 --- a/ddtrace/tracer/otlp_transport.go +++ b/ddtrace/tracer/otlp_transport.go @@ -28,13 +28,13 @@ func newOTLPTransport(client *http.Client, endpoint string, customHeaders map[st } } -// send posts a protobuf-encoded payload to the configured OTLP endpoint. -func (t *otlpTransport) send(data []byte) error { +// send posts a payload to the configured OTLP endpoint with the given content type. +func (t *otlpTransport) send(data []byte, contentType string) error { req, err := http.NewRequest("POST", t.endpoint, bytes.NewReader(data)) if err != nil { return fmt.Errorf("cannot create http request: %w", err) } - req.Header.Set("Content-Type", "application/x-protobuf") + req.Header.Set("Content-Type", contentType) for header, value := range t.customHeaders { req.Header.Set(header, value) } diff --git a/ddtrace/tracer/otlp_transport_test.go b/ddtrace/tracer/otlp_transport_test.go index 555ca873734..c553e9ed839 100644 --- a/ddtrace/tracer/otlp_transport_test.go +++ b/ddtrace/tracer/otlp_transport_test.go @@ -26,7 +26,7 @@ func TestOTLPTransportSendSuccess(t *testing.T) { defer srv.Close() tr := newOTLPTransport(srv.Client(), srv.URL, nil) - err := tr.send([]byte("hello")) + err := tr.send([]byte("hello"), "application/x-protobuf") require.NoError(t, err) assert.Equal(t, []byte("hello"), received) } @@ -43,10 +43,10 @@ func TestOTLPTransportSendHeaders(t *testing.T) { "Api-Key": "secret", "X-Custom": "value", }) - err := tr.send([]byte("data")) + err := tr.send([]byte("data"), "application/x-protobuf") require.NoError(t, err) - assert.Equal(t, "application/x-protobuf", gotHeaders.Get("Content-Type"), "default Content-Type must be set") + assert.Equal(t, "application/x-protobuf", gotHeaders.Get("Content-Type"), "Content-Type is forwarded from caller") assert.Equal(t, "secret", gotHeaders.Get("Api-Key")) assert.Equal(t, "value", gotHeaders.Get("X-Custom")) } @@ -60,7 +60,7 @@ func TestOTLPTransportSendHTTPMethod(t *testing.T) { defer srv.Close() tr := newOTLPTransport(srv.Client(), srv.URL, nil) - err := tr.send([]byte("data")) + err := tr.send([]byte("data"), "application/x-protobuf") require.NoError(t, err) assert.Equal(t, "POST", gotMethod) } @@ -83,7 +83,7 @@ func TestOTLPTransportSendErrorStatus(t *testing.T) { defer srv.Close() tr := newOTLPTransport(srv.Client(), srv.URL, nil) - err := tr.send([]byte("data")) + err := tr.send([]byte("data"), "application/x-protobuf") require.Error(t, err) assert.Contains(t, err.Error(), tt.text) }) @@ -92,7 +92,7 @@ func TestOTLPTransportSendErrorStatus(t *testing.T) { func TestOTLPTransportSendConnectionError(t *testing.T) { tr := newOTLPTransport(http.DefaultClient, "http://127.0.0.1:0/nonexistent", nil) - err := tr.send([]byte("data")) + err := tr.send([]byte("data"), "application/x-protobuf") require.Error(t, err) } @@ -111,7 +111,7 @@ func TestOTLPTransportConnectionReuse(t *testing.T) { tr := newOTLPTransport(srv.Client(), srv.URL, nil) for range 5 { - require.NoError(t, tr.send([]byte("data"))) + require.NoError(t, tr.send([]byte("data"), "application/x-protobuf")) } assert.Equal(t, int64(1), atomic.LoadInt64(&connCount), "expected a single connection to be reused across sends") diff --git a/ddtrace/tracer/otlp_writer.go b/ddtrace/tracer/otlp_writer.go index f772b4dd161..d4dfc8ffc38 100644 --- a/ddtrace/tracer/otlp_writer.go +++ b/ddtrace/tracer/otlp_writer.go @@ -131,7 +131,7 @@ func (w *otlpTraceWriter) flush() { retryInterval := w.config.internalConfig.RetryInterval() for attempt := 0; attempt <= sendRetries; attempt++ { log.Debug("OTLP: attempt %d to send payload: %d bytes, %d spans", attempt+1, len(b), spanCount) - sendErr = w.transport.send(b) + sendErr = w.transport.send(b, "application/x-protobuf") if sendErr == nil { log.Debug("OTLP: sent traces after %d attempts", attempt+1) return From af6bd883500d4e7ccd988e45c34e4b15f7b1b073 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Thu, 2 Jul 2026 14:58:19 -0400 Subject: [PATCH 05/12] fix(tracer): shorten comments in PR3 OTLP metrics exporter and config helpers Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter.go | 7 ++----- internal/config/config_helpers.go | 4 +--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter.go b/ddtrace/tracer/otlp_metrics_exporter.go index 197ca772a04..dc5d1c9599f 100644 --- a/ddtrace/tracer/otlp_metrics_exporter.go +++ b/ddtrace/tracer/otlp_metrics_exporter.go @@ -45,7 +45,6 @@ func newOTLPMetricsExporter(cfg *internalconfig.Config) *otlpMetricsExporter { } // export converts payload to an OTLP ExportMetricsServiceRequest and sends it. -// A nil or empty payload produces no request. Errors are logged and returned. func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { rms := buildOTLPMetricsRequest(payload, e.cfg) if len(rms) == 0 { @@ -75,10 +74,8 @@ func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { return nil } -// marshalExportRequestProto encodes a ResourceMetrics slice as the protobuf binary of -// ExportMetricsServiceRequest (field 1: repeated ResourceMetrics). This avoids importing -// the collector package which transitively pulls in grpc-gateway and conflicts with -// older monolithic google.golang.org/genproto in some contrib modules. +// marshalExportRequestProto hand-encodes ExportMetricsServiceRequest (field 1: repeated ResourceMetrics) +// to avoid importing the collector package, which conflicts with some contrib modules via google.golang.org/genproto. func marshalExportRequestProto(rms []*otlpmetrics.ResourceMetrics) ([]byte, error) { var buf []byte for _, rm := range rms { diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go index a2bb977e3ab..a45ba3a3d2c 100644 --- a/internal/config/config_helpers.go +++ b/internal/config/config_helpers.go @@ -408,9 +408,7 @@ func buildOTLPMetricsHeaders(genericHeaders, signalHeaders map[string]string) ma return merged } -// resolveOTLPMetricsProtocol normalises OTEL_EXPORTER_OTLP_METRICS_PROTOCOL to one of -// "http/json" or "http/protobuf". Unknown values and the empty string default to "http/protobuf" -// per the OTel specification. +// resolveOTLPMetricsProtocol normalises OTEL_EXPORTER_OTLP_METRICS_PROTOCOL to "http/json" or "http/protobuf"; unknown values default to "http/protobuf". func resolveOTLPMetricsProtocol(v string) string { if v == "http/json" { return "http/json" From 5df63a6dd7fb101253abbe79ef5c7c53b71fc495 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 7 Jul 2026 14:39:29 -0400 Subject: [PATCH 06/12] refactor(config): replace resolveOTLPMetricsProtocol with GetStringWithValidator + OTLP_PROTOCOL fallback Co-Authored-By: Claude Sonnet 4.6 --- internal/config/config.go | 2 +- internal/config/config_helpers.go | 11 ++++++----- internal/config/config_test.go | 23 +++++++++++++++++++++++ 3 files changed, 30 insertions(+), 6 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index bf413c30707..1bc41691856 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -349,7 +349,7 @@ func loadConfig() *Config { p.GetMap("OTEL_EXPORTER_OTLP_METRICS_HEADERS", nil, internal.OtelTagsDelimeter), ) cfg.otlpMetricsFlushInterval = resolveOTLPMetricsFlushInterval(env.Get("_DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL")) - cfg.otlpMetricsProtocol = resolveOTLPMetricsProtocol(p.GetString("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "")) + cfg.otlpMetricsProtocol = p.GetStringWithValidator("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", p.GetString("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"), validateOTLPProtocol) cfg.traceID128BitEnabled = p.GetBool("DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED", true) cfg.httpClientTimeout = time.Duration(p.GetIntWithValidator("DD_TRACE_AGENT_TIMEOUT", 10, validateAgentTimeout)) * time.Second cfg.propagationStyleInject = p.GetString("DD_TRACE_PROPAGATION_STYLE_INJECT", "") diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go index a45ba3a3d2c..f5d589cf4dd 100644 --- a/internal/config/config_helpers.go +++ b/internal/config/config_helpers.go @@ -408,12 +408,13 @@ func buildOTLPMetricsHeaders(genericHeaders, signalHeaders map[string]string) ma return merged } -// resolveOTLPMetricsProtocol normalises OTEL_EXPORTER_OTLP_METRICS_PROTOCOL to "http/json" or "http/protobuf"; unknown values default to "http/protobuf". -func resolveOTLPMetricsProtocol(v string) string { - if v == "http/json" { - return "http/json" +// validateOTLPProtocol returns true for the two supported OTLP HTTP protocol values. +func validateOTLPProtocol(v string) bool { + if v == "http/json" || v == "http/protobuf" { + return true } - return "http/protobuf" + log.Warn("Unsupported OTEL_EXPORTER_OTLP_METRICS_PROTOCOL %q; must be http/json or http/protobuf. Falling back to default.", v) + return false } // resolveOTLPMetricsFlushInterval parses _DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL (milliseconds). diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7b4281a91dc..8de301a559c 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -964,6 +964,29 @@ func TestOTLPMetricsProtocol(t *testing.T) { assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) }) + t.Run("falls back to OTEL_EXPORTER_OTLP_PROTOCOL when signal-specific not set", func(t *testing.T) { + resetGlobalState() + defer resetGlobalState() + + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + + cfg := Get() + require.NotNil(t, cfg) + assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + }) + + t.Run("signal-specific takes precedence over generic", func(t *testing.T) { + resetGlobalState() + defer resetGlobalState() + + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + t.Setenv("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", "http/protobuf") + + cfg := Get() + require.NotNil(t, cfg) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) + }) + t.Run("unknown value falls back to http/protobuf", func(t *testing.T) { resetGlobalState() defer resetGlobalState() From 5da3eacb549c85c1442f433850d51f41e126e4bc Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 21 Jul 2026 10:57:22 -0400 Subject: [PATCH 07/12] fix(config): split merged comment blocks for parseAndValidateOTLPURL and resolveOTLPTraceURL Co-Authored-By: Claude Sonnet 4.6 --- internal/config/config_helpers.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go index f5d589cf4dd..10ccd09e112 100644 --- a/internal/config/config_helpers.go +++ b/internal/config/config_helpers.go @@ -265,9 +265,6 @@ func formatDogstatsdAddr(u *url.URL) string { return u.Host } -// resolveOTLPTraceURL resolves the OTLP trace endpoint from OTEL_EXPORTER_OTLP_TRACES_ENDPOINT if set, else agentURL host + default OTLP port 4318 + /v1/traces. -// When the user-provided endpoint is set, it is validated: it must be a parseable URL with an http or https scheme. -// If validation fails, the default endpoint is used instead. // parseAndValidateOTLPURL parses rawURL and validates that it uses http or https. // Logs a warning and returns (nil, false) on failure. func parseAndValidateOTLPURL(envVar, rawURL string) (*url.URL, bool) { @@ -283,6 +280,9 @@ func parseAndValidateOTLPURL(envVar, rawURL string) (*url.URL, bool) { return u, true } +// resolveOTLPTraceURL resolves the OTLP trace endpoint from OTEL_EXPORTER_OTLP_TRACES_ENDPOINT if set, +// else derives a default from agentURL host + port 4318 + /v1/traces. +// When the user-provided endpoint is set it is validated; if invalid the default is used instead. func resolveOTLPTraceURL(rawAgentURL *url.URL, otlpTracesEndpoint string) string { if otlpTracesEndpoint != "" { if _, ok := parseAndValidateOTLPURL("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT", otlpTracesEndpoint); ok { From 756dd523ffa080d31f7bec2dd39c858a7708e1b2 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 21 Jul 2026 11:12:42 -0400 Subject: [PATCH 08/12] fix(tracer): use otlpMetricsContentTypeProto constant instead of string literal Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_writer.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ddtrace/tracer/otlp_writer.go b/ddtrace/tracer/otlp_writer.go index d4dfc8ffc38..d379072f15b 100644 --- a/ddtrace/tracer/otlp_writer.go +++ b/ddtrace/tracer/otlp_writer.go @@ -131,7 +131,7 @@ func (w *otlpTraceWriter) flush() { retryInterval := w.config.internalConfig.RetryInterval() for attempt := 0; attempt <= sendRetries; attempt++ { log.Debug("OTLP: attempt %d to send payload: %d bytes, %d spans", attempt+1, len(b), spanCount) - sendErr = w.transport.send(b, "application/x-protobuf") + sendErr = w.transport.send(b, otlpMetricsContentTypeProto) if sendErr == nil { log.Debug("OTLP: sent traces after %d attempts", attempt+1) return From 599e335acf3258b61cfa8babbe4ddee95d1cce0f Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 21 Jul 2026 11:15:49 -0400 Subject: [PATCH 09/12] refactor(tracer): extract buildBaseResourceAttrs to deduplicate resource attribute logic Both buildResource (trace exports) and buildMetricsResource (metrics exports) built the same telemetry.sdk.* + service.* baseline attributes. Extract a shared buildBaseResourceAttrs helper in span_to_otlp.go and have both callers use it. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/span_to_otlp.go | 30 +++++++++++++++---------- ddtrace/tracer/stats_to_otlp_metrics.go | 14 +----------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/ddtrace/tracer/span_to_otlp.go b/ddtrace/tracer/span_to_otlp.go index a5c7fa332b0..a694186b027 100644 --- a/ddtrace/tracer/span_to_otlp.go +++ b/ddtrace/tracer/span_to_otlp.go @@ -27,25 +27,31 @@ const maxAttributesCount = 128 // Resource construction // ----------------------------------------------------------------------------- -// buildResource constructs the OTLP Resource from resolved tracer configuration. -// If cfg is nil, an empty resource is returned. -func buildResource(cfg *internalconfig.Config) *otlpresource.Resource { - if cfg == nil { - return &otlpresource.Resource{} - } +// buildBaseResourceAttrs returns the telemetry.sdk.* and service.* resource attributes +// shared by both trace and metrics OTLP exports. +func buildBaseResourceAttrs(serviceName, svcVersion, env string) []*otlpcommon.KeyValue { attrs := []*otlpcommon.KeyValue{ - otlpKeyValue("service.name", otlpStringValue(cfg.ServiceName())), + otlpKeyValue("service.name", otlpStringValue(serviceName)), otlpKeyValue("telemetry.sdk.language", otlpStringValue("go")), otlpKeyValue("telemetry.sdk.name", otlpStringValue("datadog")), otlpKeyValue("telemetry.sdk.version", otlpStringValue(version.Tag)), } - if v := cfg.Env(); v != "" { - attrs = append(attrs, otlpKeyValue("deployment.environment.name", otlpStringValue(v))) + if env != "" { + attrs = append(attrs, otlpKeyValue("deployment.environment.name", otlpStringValue(env))) } - if v := cfg.Version(); v != "" { - attrs = append(attrs, otlpKeyValue("service.version", otlpStringValue(v))) + if svcVersion != "" { + attrs = append(attrs, otlpKeyValue("service.version", otlpStringValue(svcVersion))) + } + return attrs +} + +// buildResource constructs the OTLP Resource from resolved tracer configuration. +// If cfg is nil, an empty resource is returned. +func buildResource(cfg *internalconfig.Config) *otlpresource.Resource { + if cfg == nil { + return &otlpresource.Resource{} } - return &otlpresource.Resource{Attributes: attrs} + return &otlpresource.Resource{Attributes: buildBaseResourceAttrs(cfg.ServiceName(), cfg.Version(), cfg.Env())} } // ----------------------------------------------------------------------------- diff --git a/ddtrace/tracer/stats_to_otlp_metrics.go b/ddtrace/tracer/stats_to_otlp_metrics.go index 851f8299162..166154389da 100644 --- a/ddtrace/tracer/stats_to_otlp_metrics.go +++ b/ddtrace/tracer/stats_to_otlp_metrics.go @@ -22,7 +22,6 @@ import ( internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/log" - "github.com/DataDog/dd-trace-go/v2/internal/version" ) const spanDurationMetricName = "traces.span.sdk.metrics.duration" @@ -88,18 +87,7 @@ func buildOTLPMetricsRequest(payload *pb.ClientStatsPayload, cfg *internalconfig // buildMetricsResource builds the OTLP Resource; adds host.name and datadog.* attrs in default mode. func buildMetricsResource(payload *pb.ClientStatsPayload, otelMode bool, reportHostname bool, hostname string) *otlpresource.Resource { - attrs := []*otlpcommon.KeyValue{ - otlpKeyValue("telemetry.sdk.language", otlpStringValue("go")), - otlpKeyValue("telemetry.sdk.name", otlpStringValue("datadog")), - otlpKeyValue("telemetry.sdk.version", otlpStringValue(version.Tag)), - otlpKeyValue("service.name", otlpStringValue(payload.Service)), - } - if payload.Version != "" { - attrs = append(attrs, otlpKeyValue("service.version", otlpStringValue(payload.Version))) - } - if payload.Env != "" { - attrs = append(attrs, otlpKeyValue("deployment.environment.name", otlpStringValue(payload.Env))) - } + attrs := buildBaseResourceAttrs(payload.Service, payload.Version, payload.Env) if reportHostname && hostname != "" { attrs = append(attrs, otlpKeyValue("host.name", otlpStringValue(hostname))) } From bbdfee2999ea96cc894b0b65854bbf8b98f8bc61 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 21 Jul 2026 11:19:19 -0400 Subject: [PATCH 10/12] refactor(tracer): consolidate OTLP content-type constants in otlp_transport.go Move otlpContentTypeJSON and otlpContentTypeProto to otlp_transport.go (where otlpTransport.send takes the content type) and remove the duplicate definitions from otlp_metrics_exporter.go. Update all callers. Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter.go | 9 ++------- ddtrace/tracer/otlp_metrics_exporter_test.go | 4 ++-- ddtrace/tracer/otlp_transport.go | 5 +++++ ddtrace/tracer/otlp_writer.go | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter.go b/ddtrace/tracer/otlp_metrics_exporter.go index dc5d1c9599f..b2b715b596c 100644 --- a/ddtrace/tracer/otlp_metrics_exporter.go +++ b/ddtrace/tracer/otlp_metrics_exporter.go @@ -20,11 +20,6 @@ import ( "github.com/DataDog/dd-trace-go/v2/internal/log" ) -const ( - otlpMetricsContentTypeJSON = "application/json" - otlpMetricsContentTypeProto = "application/x-protobuf" -) - // otlpMetricsExporter converts ClientStatsPayload to OTLP metrics and sends them over HTTP. type otlpMetricsExporter struct { transport *otlpTransport @@ -57,10 +52,10 @@ func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { if e.protocol == "http/json" { body, err = marshalExportRequestJSON(rms) - contentType = otlpMetricsContentTypeJSON + contentType = otlpContentTypeJSON } else { body, err = marshalExportRequestProto(rms) - contentType = otlpMetricsContentTypeProto + contentType = otlpContentTypeProto } if err != nil { return fmt.Errorf("otlp_metrics_exporter: marshal failed: %w", err) diff --git a/ddtrace/tracer/otlp_metrics_exporter_test.go b/ddtrace/tracer/otlp_metrics_exporter_test.go index a98575e48da..838415aeea9 100644 --- a/ddtrace/tracer/otlp_metrics_exporter_test.go +++ b/ddtrace/tracer/otlp_metrics_exporter_test.go @@ -73,7 +73,7 @@ func TestOTLPMetricsExporterExportJSONContentType(t *testing.T) { } err := exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs})) require.NoError(t, err) - assert.Equal(t, otlpMetricsContentTypeJSON, srv.lastContentType) + assert.Equal(t, otlpContentTypeJSON, srv.lastContentType) assert.NotEmpty(t, srv.lastBody) } @@ -87,7 +87,7 @@ func TestOTLPMetricsExporterExportProtoContentType(t *testing.T) { } err := exp.export(makePayload("svc", "", "", []*pb.ClientGroupedStats{gs})) require.NoError(t, err) - assert.Equal(t, otlpMetricsContentTypeProto, srv.lastContentType) + assert.Equal(t, otlpContentTypeProto, srv.lastContentType) assert.NotEmpty(t, srv.lastBody) } diff --git a/ddtrace/tracer/otlp_transport.go b/ddtrace/tracer/otlp_transport.go index 0445793cb68..60cd460182f 100644 --- a/ddtrace/tracer/otlp_transport.go +++ b/ddtrace/tracer/otlp_transport.go @@ -12,6 +12,11 @@ import ( "net/http" ) +const ( + otlpContentTypeJSON = "application/json" + otlpContentTypeProto = "application/x-protobuf" +) + // otlpTransport sends protobuf-encoded OTLP payloads over HTTP. // It is the OTLP counterpart to httpTransport (which handles Datadog-protocol traffic). type otlpTransport struct { diff --git a/ddtrace/tracer/otlp_writer.go b/ddtrace/tracer/otlp_writer.go index d379072f15b..5f4398d3250 100644 --- a/ddtrace/tracer/otlp_writer.go +++ b/ddtrace/tracer/otlp_writer.go @@ -131,7 +131,7 @@ func (w *otlpTraceWriter) flush() { retryInterval := w.config.internalConfig.RetryInterval() for attempt := 0; attempt <= sendRetries; attempt++ { log.Debug("OTLP: attempt %d to send payload: %d bytes, %d spans", attempt+1, len(b), spanCount) - sendErr = w.transport.send(b, otlpMetricsContentTypeProto) + sendErr = w.transport.send(b, otlpContentTypeProto) if sendErr == nil { log.Debug("OTLP: sent traces after %d attempts", attempt+1) return From 491da4dd19f0ed13994458fe54d7d3b6e61b6799 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Tue, 21 Jul 2026 15:01:34 -0400 Subject: [PATCH 11/12] fix(tracer): address PR3 review feedback for OTLP span metrics transport - Use internal.DefaultHTTPClient instead of bare &http.Client{} in newOTLPMetricsExporter for connection pooling and proxy support - Validate OTEL_EXPORTER_OTLP_PROTOCOL fallback through validateOTLPProtocol; pass env var name to warning message so the right variable is named - Change default OTLP metrics protocol to http/json per spec transport section - Remove rpc.method attribute (removed from RFC spec June 25) - Rename _DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL to _DD_TRACE_STATS_INTERVAL to match RFC-specified name used across all 5 SDK implementations - Document intentional no-retry behavior on metrics export - Document datadog.origin fidelity gap (proto only has Synthetics bool) Co-Authored-By: Claude Sonnet 4.6 --- ddtrace/tracer/otlp_metrics_exporter.go | 6 ++-- ddtrace/tracer/otlp_metrics_exporter_test.go | 4 +-- ddtrace/tracer/stats_to_otlp_metrics.go | 10 ++----- ddtrace/tracer/stats_to_otlp_metrics_test.go | 31 -------------------- internal/config/config.go | 10 +++++-- internal/config/config_helpers.go | 9 +++--- internal/config/config_test.go | 25 +++++++++++----- 7 files changed, 40 insertions(+), 55 deletions(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter.go b/ddtrace/tracer/otlp_metrics_exporter.go index b2b715b596c..8c8d7fef08a 100644 --- a/ddtrace/tracer/otlp_metrics_exporter.go +++ b/ddtrace/tracer/otlp_metrics_exporter.go @@ -8,7 +8,6 @@ package tracer import ( "encoding/json" "fmt" - "net/http" pb "github.com/DataDog/datadog-agent/pkg/proto/pbgo/trace" otlpmetrics "go.opentelemetry.io/proto/otlp/metrics/v1" @@ -16,6 +15,7 @@ import ( "google.golang.org/protobuf/encoding/protowire" "google.golang.org/protobuf/proto" + "github.com/DataDog/dd-trace-go/v2/internal" internalconfig "github.com/DataDog/dd-trace-go/v2/internal/config" "github.com/DataDog/dd-trace-go/v2/internal/log" ) @@ -30,7 +30,7 @@ type otlpMetricsExporter struct { func newOTLPMetricsExporter(cfg *internalconfig.Config) *otlpMetricsExporter { return &otlpMetricsExporter{ transport: newOTLPTransport( - &http.Client{Timeout: cfg.AgentTimeout()}, + internal.DefaultHTTPClient(cfg.AgentTimeout(), false), cfg.OTLPMetricsURL(), cfg.OTLPMetricsHeaders(), ), @@ -61,6 +61,8 @@ func (e *otlpMetricsExporter) export(payload *pb.ClientStatsPayload) error { return fmt.Errorf("otlp_metrics_exporter: marshal failed: %w", err) } + // No retry: a failed metrics interval is dropped rather than retried. + // Span metrics are lossy by design — the next flush interval replaces the lost window. if sendErr := e.transport.send(body, contentType); sendErr != nil { log.Error("otlp_metrics_exporter: export to %s failed: %v", e.transport.endpoint, sendErr.Error()) return sendErr diff --git a/ddtrace/tracer/otlp_metrics_exporter_test.go b/ddtrace/tracer/otlp_metrics_exporter_test.go index 838415aeea9..a6742f7e1f9 100644 --- a/ddtrace/tracer/otlp_metrics_exporter_test.go +++ b/ddtrace/tracer/otlp_metrics_exporter_test.go @@ -177,7 +177,7 @@ func TestOTLPMetricsExporterCustomHeaders(t *testing.T) { // ---- config integration ---- -func TestOTLPMetricsProtocolDefaultIsProtobuf(t *testing.T) { +func TestOTLPMetricsProtocolDefaultIsJSON(t *testing.T) { cfg := internalconfig.CreateNew() - assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) } diff --git a/ddtrace/tracer/stats_to_otlp_metrics.go b/ddtrace/tracer/stats_to_otlp_metrics.go index 166154389da..8c1d0bb1165 100644 --- a/ddtrace/tracer/stats_to_otlp_metrics.go +++ b/ddtrace/tracer/stats_to_otlp_metrics.go @@ -196,13 +196,6 @@ func buildDataPointAttributes(gs *pb.ClientGroupedStats, isError bool, defaultSe statusCode = 2 // STATUS_CODE_ERROR } attrs = append(attrs, otlpKeyValue("status.code", otlpIntValue(statusCode))) - // grpc.method.name arrives via PeerTags (no dedicated field in ClientGroupedStats) and maps to rpc.method. - for _, tag := range gs.PeerTags { - if k, v, ok := strings.Cut(tag, ":"); ok && k == "grpc.method.name" && v != "" { - attrs = append(attrs, otlpKeyValue("rpc.method", otlpStringValue(v))) - break - } - } if svc := gs.Service; svc != "" && svc != defaultService { attrs = append(attrs, otlpKeyValue("service.name", otlpStringValue(svc))) @@ -218,6 +211,9 @@ func buildDataPointAttributes(gs *pb.ClientGroupedStats, isError bool, defaultSe } // top_level is true only when all spans in the group were top-level (TopLevelHits == Hits). attrs = append(attrs, otlpKeyValue("datadog.span.top_level", otlpBoolValue(gs.Hits > 0 && gs.TopLevelHits == gs.Hits))) + // ClientGroupedStats carries only a boolean Synthetics field; finer-grained + // origin values (synthetics-browser, rum, ciapp-test, lambda) are not available + // at the stats aggregation layer and require a proto change upstream to support. if gs.Synthetics { attrs = append(attrs, otlpKeyValue("datadog.origin", otlpStringValue("synthetics"))) } diff --git a/ddtrace/tracer/stats_to_otlp_metrics_test.go b/ddtrace/tracer/stats_to_otlp_metrics_test.go index a7a7944128a..85c35d63466 100644 --- a/ddtrace/tracer/stats_to_otlp_metrics_test.go +++ b/ddtrace/tracer/stats_to_otlp_metrics_test.go @@ -381,37 +381,6 @@ func TestDataPointAttributesOptionalFieldsAbsentWhenUnset(t *testing.T) { assert.NotContains(t, m, "rpc.response.status_code") } -func TestDataPointAttributesGRPCMethodName(t *testing.T) { - // grpc.method.name in PeerTags is translated to the RFC-required rpc.method attribute. - gs := &pb.ClientGroupedStats{ - Resource: "grpc.request", - PeerTags: []string{"grpc.method.name:GetUser"}, - } - m := kvAttrsToMap(buildDataPointAttributes(gs, false, "", true)) - assert.Equal(t, "GetUser", m["rpc.method"]) - assert.NotContains(t, m, "grpc.method.name") -} - -func TestDataPointAttributesGRPCMethodNameFirstOnly(t *testing.T) { - // Only the first grpc.method.name value is used; no duplicates emitted. - gs := &pb.ClientGroupedStats{ - Resource: "grpc.request", - PeerTags: []string{"grpc.method.name:GetUser", "grpc.method.name:ListUsers"}, - } - m := kvAttrsToMap(buildDataPointAttributes(gs, false, "", true)) - assert.Equal(t, "GetUser", m["rpc.method"]) -} - -func TestDataPointAttributesGRPCMethodNameEmptySkipped(t *testing.T) { - // A grpc.method.name tag with an empty value must not emit rpc.method. - gs := &pb.ClientGroupedStats{ - Resource: "grpc.request", - PeerTags: []string{"grpc.method.name:"}, - } - m := kvAttrsToMap(buildDataPointAttributes(gs, false, "", true)) - assert.NotContains(t, m, "rpc.method") -} - func TestDataPointAttributesPeerTagsNotEmitted(t *testing.T) { // peer.* tags and other non-grpc.method.name peer tags are not forwarded (out of scope per RFC). gs := &pb.ClientGroupedStats{ diff --git a/internal/config/config.go b/internal/config/config.go index 1bc41691856..2904d871e21 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -348,8 +348,14 @@ func loadConfig() *Config { p.GetMap("OTEL_EXPORTER_OTLP_HEADERS", nil, internal.OtelTagsDelimeter), p.GetMap("OTEL_EXPORTER_OTLP_METRICS_HEADERS", nil, internal.OtelTagsDelimeter), ) - cfg.otlpMetricsFlushInterval = resolveOTLPMetricsFlushInterval(env.Get("_DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL")) - cfg.otlpMetricsProtocol = p.GetStringWithValidator("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", p.GetString("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"), validateOTLPProtocol) + cfg.otlpMetricsFlushInterval = resolveOTLPMetricsFlushInterval(env.Get("_DD_TRACE_STATS_INTERVAL")) + otlpProtocolFallback := p.GetString("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + if !validateOTLPProtocol(otlpProtocolFallback, "OTEL_EXPORTER_OTLP_PROTOCOL") { + otlpProtocolFallback = "http/json" + } + cfg.otlpMetricsProtocol = p.GetStringWithValidator("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", otlpProtocolFallback, func(v string) bool { + return validateOTLPProtocol(v, "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL") + }) cfg.traceID128BitEnabled = p.GetBool("DD_TRACE_128_BIT_TRACEID_GENERATION_ENABLED", true) cfg.httpClientTimeout = time.Duration(p.GetIntWithValidator("DD_TRACE_AGENT_TIMEOUT", 10, validateAgentTimeout)) * time.Second cfg.propagationStyleInject = p.GetString("DD_TRACE_PROPAGATION_STYLE_INJECT", "") diff --git a/internal/config/config_helpers.go b/internal/config/config_helpers.go index 10ccd09e112..be9dd6b1d6d 100644 --- a/internal/config/config_helpers.go +++ b/internal/config/config_helpers.go @@ -409,15 +409,16 @@ func buildOTLPMetricsHeaders(genericHeaders, signalHeaders map[string]string) ma } // validateOTLPProtocol returns true for the two supported OTLP HTTP protocol values. -func validateOTLPProtocol(v string) bool { +// envVar is used in the warning message to identify which env var had the bad value. +func validateOTLPProtocol(v, envVar string) bool { if v == "http/json" || v == "http/protobuf" { return true } - log.Warn("Unsupported OTEL_EXPORTER_OTLP_METRICS_PROTOCOL %q; must be http/json or http/protobuf. Falling back to default.", v) + log.Warn("Unsupported %s %q; must be http/json or http/protobuf. Falling back to default.", envVar, v) return false } -// resolveOTLPMetricsFlushInterval parses _DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL (milliseconds). +// resolveOTLPMetricsFlushInterval parses _DD_TRACE_STATS_INTERVAL (milliseconds). // The variable is internal and intended for tests only; in production it returns the default 10 s. func resolveOTLPMetricsFlushInterval(raw string) time.Duration { if raw == "" { @@ -425,7 +426,7 @@ func resolveOTLPMetricsFlushInterval(raw string) time.Duration { } ms, err := strconv.ParseInt(raw, 10, 64) if err != nil || ms <= 0 { - log.Warn("Invalid _DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL %q; using default %s.", raw, OTLPMetricsFlushInterval) + log.Warn("Invalid _DD_TRACE_STATS_INTERVAL %q; using default %s.", raw, OTLPMetricsFlushInterval) return OTLPMetricsFlushInterval } return time.Duration(ms) * time.Millisecond diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 8de301a559c..6bc10af1dbf 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -918,11 +918,11 @@ func TestOTLPMetricsFlushInterval(t *testing.T) { assert.Equal(t, OTLPMetricsFlushInterval, cfg.OTLPMetricsFlushInterval()) }) - t.Run("_DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL overrides in milliseconds", func(t *testing.T) { + t.Run("_DD_TRACE_STATS_INTERVAL overrides in milliseconds", func(t *testing.T) { resetGlobalState() defer resetGlobalState() - t.Setenv("_DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL", "1000") + t.Setenv("_DD_TRACE_STATS_INTERVAL", "1000") cfg := Get() require.NotNil(t, cfg) @@ -934,7 +934,7 @@ func TestOTLPMetricsFlushInterval(t *testing.T) { resetGlobalState() defer resetGlobalState() - t.Setenv("_DD_TRACE_METRICS_OTEL_FLUSH_INTERVAL", "not-a-number") + t.Setenv("_DD_TRACE_STATS_INTERVAL", "not-a-number") cfg := Get() require.NotNil(t, cfg) @@ -944,13 +944,13 @@ func TestOTLPMetricsFlushInterval(t *testing.T) { } func TestOTLPMetricsProtocol(t *testing.T) { - t.Run("defaults to http/protobuf", func(t *testing.T) { + t.Run("defaults to http/json", func(t *testing.T) { resetGlobalState() defer resetGlobalState() cfg := Get() require.NotNil(t, cfg) - assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) }) t.Run("http/json via OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", func(t *testing.T) { @@ -987,7 +987,7 @@ func TestOTLPMetricsProtocol(t *testing.T) { assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) }) - t.Run("unknown value falls back to http/protobuf", func(t *testing.T) { + t.Run("unknown value in signal-specific falls back to http/json", func(t *testing.T) { resetGlobalState() defer resetGlobalState() @@ -995,7 +995,18 @@ func TestOTLPMetricsProtocol(t *testing.T) { cfg := Get() require.NotNil(t, cfg) - assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + }) + + t.Run("unknown value in generic falls back to http/json", func(t *testing.T) { + resetGlobalState() + defer resetGlobalState() + + t.Setenv("OTEL_EXPORTER_OTLP_PROTOCOL", "grpc") + + cfg := Get() + require.NotNil(t, cfg) + assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) }) } From a60104895db5623dffc9793c5f967a7c92186256 Mon Sep 17 00:00:00 2001 From: Rachel Yang Date: Thu, 23 Jul 2026 11:29:34 -0400 Subject: [PATCH 12/12] fix(tracer): default OTLP span-metrics protocol to http/protobuf Aligns the OTLPMetricsProtocol fallback with dd-trace-java's hardcoded production default and the RFC's revised FR10 (protobuf now satisfies the transport requirement; JSON support is no longer mandatory). The env vars (OTEL_EXPORTER_OTLP_METRICS_PROTOCOL, OTEL_EXPORTER_OTLP_PROTOCOL) remain fully configurable to either value. Co-Authored-By: Claude Sonnet 5 --- ddtrace/tracer/otlp_metrics_exporter_test.go | 4 ++-- internal/config/config.go | 4 ++-- internal/config/config_test.go | 12 ++++++------ 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ddtrace/tracer/otlp_metrics_exporter_test.go b/ddtrace/tracer/otlp_metrics_exporter_test.go index a6742f7e1f9..838415aeea9 100644 --- a/ddtrace/tracer/otlp_metrics_exporter_test.go +++ b/ddtrace/tracer/otlp_metrics_exporter_test.go @@ -177,7 +177,7 @@ func TestOTLPMetricsExporterCustomHeaders(t *testing.T) { // ---- config integration ---- -func TestOTLPMetricsProtocolDefaultIsJSON(t *testing.T) { +func TestOTLPMetricsProtocolDefaultIsProtobuf(t *testing.T) { cfg := internalconfig.CreateNew() - assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) } diff --git a/internal/config/config.go b/internal/config/config.go index 2904d871e21..40c321c2ef6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -349,9 +349,9 @@ func loadConfig() *Config { p.GetMap("OTEL_EXPORTER_OTLP_METRICS_HEADERS", nil, internal.OtelTagsDelimeter), ) cfg.otlpMetricsFlushInterval = resolveOTLPMetricsFlushInterval(env.Get("_DD_TRACE_STATS_INTERVAL")) - otlpProtocolFallback := p.GetString("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json") + otlpProtocolFallback := p.GetString("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf") if !validateOTLPProtocol(otlpProtocolFallback, "OTEL_EXPORTER_OTLP_PROTOCOL") { - otlpProtocolFallback = "http/json" + otlpProtocolFallback = "http/protobuf" } cfg.otlpMetricsProtocol = p.GetStringWithValidator("OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", otlpProtocolFallback, func(v string) bool { return validateOTLPProtocol(v, "OTEL_EXPORTER_OTLP_METRICS_PROTOCOL") diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 6bc10af1dbf..9d0636aab96 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -944,13 +944,13 @@ func TestOTLPMetricsFlushInterval(t *testing.T) { } func TestOTLPMetricsProtocol(t *testing.T) { - t.Run("defaults to http/json", func(t *testing.T) { + t.Run("defaults to http/protobuf", func(t *testing.T) { resetGlobalState() defer resetGlobalState() cfg := Get() require.NotNil(t, cfg) - assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) }) t.Run("http/json via OTEL_EXPORTER_OTLP_METRICS_PROTOCOL", func(t *testing.T) { @@ -987,7 +987,7 @@ func TestOTLPMetricsProtocol(t *testing.T) { assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) }) - t.Run("unknown value in signal-specific falls back to http/json", func(t *testing.T) { + t.Run("unknown value in signal-specific falls back to http/protobuf", func(t *testing.T) { resetGlobalState() defer resetGlobalState() @@ -995,10 +995,10 @@ func TestOTLPMetricsProtocol(t *testing.T) { cfg := Get() require.NotNil(t, cfg) - assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) }) - t.Run("unknown value in generic falls back to http/json", func(t *testing.T) { + t.Run("unknown value in generic falls back to http/protobuf", func(t *testing.T) { resetGlobalState() defer resetGlobalState() @@ -1006,7 +1006,7 @@ func TestOTLPMetricsProtocol(t *testing.T) { cfg := Get() require.NotNil(t, cfg) - assert.Equal(t, "http/json", cfg.OTLPMetricsProtocol()) + assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol()) }) }