Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
8386464
feat(tracer): add OTLP metrics HTTP exporter (SEMCON-1093 PR3)
rachelyangdog Jun 16, 2026
d890b4b
fix(tracer): decode OTLP export proto in test without collector/metri…
rachelyangdog Jun 18, 2026
e313061
refactor(tracer): update exporter to call unexported buildOTLPMetrics…
rachelyangdog Jul 2, 2026
caeab55
refactor(tracer): reuse otlpTransport in otlpMetricsExporter
rachelyangdog Jul 2, 2026
af6bd88
fix(tracer): shorten comments in PR3 OTLP metrics exporter and config…
rachelyangdog Jul 2, 2026
5df63a6
refactor(config): replace resolveOTLPMetricsProtocol with GetStringWi…
rachelyangdog Jul 7, 2026
5da3eac
fix(config): split merged comment blocks for parseAndValidateOTLPURL …
rachelyangdog Jul 21, 2026
756dd52
fix(tracer): use otlpMetricsContentTypeProto constant instead of stri…
rachelyangdog Jul 21, 2026
599e335
refactor(tracer): extract buildBaseResourceAttrs to deduplicate resou…
rachelyangdog Jul 21, 2026
bbdfee2
refactor(tracer): consolidate OTLP content-type constants in otlp_tra…
rachelyangdog Jul 21, 2026
491da4d
fix(tracer): address PR3 review feedback for OTLP span metrics transport
rachelyangdog Jul 21, 2026
a601048
fix(tracer): default OTLP span-metrics protocol to http/protobuf
rachelyangdog Jul 23, 2026
37558e3
Merge remote-tracking branch 'origin/main' into rachel.yang/otlp-span…
rachelyangdog Jul 23, 2026
9c10faa
Merge branch 'main' into rachel.yang/otlp-span-metrics-transport
rachelyangdog Jul 24, 2026
4cead92
Merge branch 'main' into rachel.yang/otlp-span-metrics-transport
rachelyangdog Jul 24, 2026
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
104 changes: 104 additions & 0 deletions ddtrace/tracer/otlp_metrics_exporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// 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"
"fmt"

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"

"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"
)

// otlpMetricsExporter converts ClientStatsPayload to OTLP metrics and sends them over HTTP.
type otlpMetricsExporter struct {
transport *otlpTransport
protocol string // "http/json" or "http/protobuf"
cfg *internalconfig.Config
}

func newOTLPMetricsExporter(cfg *internalconfig.Config) *otlpMetricsExporter {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does newOTLPMetricsExporter need all of cfg? Can't it accept the parameters it needs explicitly/only?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cfg is used across both construction and the export() call path. there are 7 fields in total: AgentTimeout, OTLPMetricsURL, OTLPMetricsHeaders, OTLPMetricsProtocol at construction, plus OTelSemanticsEnabled, ReportHostname, Hostname inside buildOTLPMetricsRequest. Passing those explicitly would produce a 7-parameter constructor, which is harder to read and maintain than the single cfg argument. Storing cfg on the struct also avoids threading those values through separately on every export() call.

This matches how newOTLPTraceWriter is structured. it also takes the full config rather than individual fields.

return &otlpMetricsExporter{
transport: newOTLPTransport(
internal.DefaultHTTPClient(cfg.AgentTimeout(), false),
cfg.OTLPMetricsURL(),
cfg.OTLPMetricsHeaders(),
),
protocol: cfg.OTLPMetricsProtocol(),
cfg: cfg,
}
}

// export converts payload to an OTLP ExportMetricsServiceRequest and sends it.
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 = otlpContentTypeJSON
} else {
body, err = marshalExportRequestProto(rms)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're implicitly defaulting to protobuf unless "http/json" was specified. Does internalconfig already inform the user that anything other than json or protobuf will be dropped/default to protobuf?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, internalconfig already handles this. validateOTLPProtocol is called at config load time for both OTEL_EXPORTER_OTLP_METRICS_PROTOCOL and (now) OTEL_EXPORTER_OTLP_PROTOCOL, and logs a warning naming the specific env var when an unsupported value is set, then falls back to the default. So by the time OTLPMetricsProtocol() is read in the exporter, it's guaranteed to be either "http/json" or "http/protobuf".

Also worth noting: the default was changed to "http/json" in this PR to align with the spec's transport section ("HTTP/JSON as the fallback protocol"), so the implicit behavior now favors JSON rather than protobuf.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. it feels like this block is duplicating some of that.

contentType = otlpContentTypeProto
}
if err != nil {
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
}
log.Debug("otlp_metrics_exporter: exported %d bytes (%s) to %s", len(body), e.protocol, e.transport.endpoint)
return nil
}

// 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 {
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})
}
183 changes: 183 additions & 0 deletions ddtrace/tracer/otlp_metrics_exporter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// 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"
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"
)

// 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{
transport: newOTLPTransport(srv.Server.Client(), srv.URL+"/v1/metrics", 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, otlpContentTypeJSON, 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, otlpContentTypeProto, 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})))

// 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) {
errSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
t.Cleanup(errSrv.Close)
exp := &otlpMetricsExporter{
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}))
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{
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})))
assert.Equal(t, "my-value", gotHeader)
}

// ---- config integration ----

func TestOTLPMetricsProtocolDefaultIsProtobuf(t *testing.T) {
cfg := internalconfig.CreateNew()
assert.Equal(t, "http/protobuf", cfg.OTLPMetricsProtocol())
}
11 changes: 8 additions & 3 deletions ddtrace/tracer/otlp_transport.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -28,13 +33,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)
}
Expand Down
14 changes: 7 additions & 7 deletions ddtrace/tracer/otlp_transport_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand All @@ -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"))
}
Expand All @@ -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)
}
Expand All @@ -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)
})
Expand All @@ -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)
}

Expand All @@ -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")
Expand Down
2 changes: 1 addition & 1 deletion ddtrace/tracer/otlp_writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
scope *otlpcommon.InstrumentationScope
spans []*otlptrace.Span // +checklocks:mu
buffSize int // +checklocks:mu
baseSize int

Check failure on line 34 in ddtrace/tracer/otlp_writer.go

View workflow job for this annotation

GitHub Actions / checklocks

may require checklocks annotation for mu, used with lock held 100% of the time
climit chan struct{}
wg sync.WaitGroup
}
Expand Down Expand Up @@ -131,7 +131,7 @@
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, otlpContentTypeProto)
if sendErr == nil {
log.Debug("OTLP: sent traces after %d attempts", attempt+1)
return
Expand Down
Loading
Loading