Skip to content

Commit de0884f

Browse files
authored
fix(forwarder): add OTelSyncForwarder for synchronous OTel metric sending (OTAGENT-1024) (#51384)
## Summary - Adds `OTelSyncForwarder` to `comp/forwarder/defaultforwarder` — a synchronous, error-propagating forwarder used by the OTel serializer exporter when the `datadog.serializerexporter.UseSyncForwarder` feature gate is enabled - Unlike `DefaultForwarder` (async queue) and `SyncForwarder` (sync but swallows errors), `OTelSyncForwarder.sendHTTPTransactions` returns `multierr`-combined errors so HTTP failures propagate back to the OTel exporterhelper queue/retry layer (OTAGENT-1024) ## Context This is the `comp/forwarder/defaultforwarder` portion extracted from #51333, which wires the new forwarder into the OTel serializer exporter. This PR can be reviewed and merged independently. ## Test plan Will be covered in the parent PR (#51333) under `serializerexporter` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: yang.song <yang.song@datadoghq.com>
1 parent 813ba03 commit de0884f

4 files changed

Lines changed: 602 additions & 1 deletion

File tree

comp/forwarder/defaultforwarder/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ require (
3030
github.com/stretchr/testify v1.11.1
3131
go.uber.org/atomic v1.11.0
3232
go.uber.org/fx v1.24.0
33+
go.uber.org/multierr v1.11.0
3334
golang.org/x/sync v0.21.0
3435
google.golang.org/protobuf v1.36.12-0.20260116114154-8c4c4ae446ca
3536
)
@@ -92,7 +93,6 @@ require (
9293
github.com/tklauser/numcpus v0.11.0 // indirect
9394
github.com/yusufpapurcu/wmi v1.2.4 // indirect
9495
go.uber.org/dig v1.19.0 // indirect
95-
go.uber.org/multierr v1.11.0 // indirect
9696
go.uber.org/zap v1.28.0 // indirect
9797
go.yaml.in/yaml/v2 v2.4.4 // indirect
9898
go.yaml.in/yaml/v3 v3.0.4 // indirect

comp/forwarder/defaultforwarder/impl/BUILD.bazel

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ go_library(
88
"domain_forwarder.go",
99
"forwarder.go",
1010
"forwarder_health.go",
11+
"otel_sync_forwarder.go",
1112
"shared_connection.go",
1213
"status.go",
1314
"sync_forwarder.go",
@@ -49,6 +50,7 @@ go_library(
4950
"@com_github_stretchr_testify//mock",
5051
"@org_golang_x_sync//semaphore",
5152
"@org_uber_go_atomic//:atomic",
53+
"@org_uber_go_multierr//:multierr",
5254
],
5355
)
5456

@@ -60,6 +62,7 @@ go_test(
6062
"domain_forwarder_test.go",
6163
"forwarder_health_test.go",
6264
"forwarder_test.go",
65+
"otel_sync_forwarder_test.go",
6366
"status_test.go",
6467
"worker_test.go",
6568
],
Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
// Unless explicitly stated otherwise all files in this repository are licensed
2+
// under the Apache License Version 2.0.
3+
// This product includes software developed at Datadog (https://www.datadoghq.com/).
4+
// Copyright 2016-present Datadog, Inc.
5+
6+
package defaultforwarderimpl
7+
8+
import (
9+
"context"
10+
"errors"
11+
"fmt"
12+
"net/http"
13+
14+
"go.uber.org/multierr"
15+
16+
"github.com/DataDog/datadog-agent/comp/core/config"
17+
log "github.com/DataDog/datadog-agent/comp/core/log/def"
18+
secrets "github.com/DataDog/datadog-agent/comp/core/secrets/def"
19+
defaultforwarderdef "github.com/DataDog/datadog-agent/comp/forwarder/defaultforwarder/def"
20+
"github.com/DataDog/datadog-agent/comp/forwarder/defaultforwarder/endpoints"
21+
"github.com/DataDog/datadog-agent/comp/forwarder/defaultforwarder/resolver"
22+
"github.com/DataDog/datadog-agent/comp/forwarder/defaultforwarder/transaction"
23+
"github.com/DataDog/datadog-agent/pkg/config/utils"
24+
"github.com/DataDog/datadog-agent/pkg/version"
25+
)
26+
27+
// ErrPermanentHTTPError is the sentinel wrapped into errors returned when the
28+
// intake responds with a permanent failure (400, 413, 403-drop). Callers at
29+
// the OTel exporter boundary can detect this with errors.Is and convert it to
30+
// consumererror.NewPermanent so the exporterhelper queue does not retry it.
31+
var ErrPermanentHTTPError = errors.New("permanent intake error")
32+
33+
// errOTelSyncNotSupported is returned by Forwarder interface methods that are
34+
// not used by the OTel serializer metrics path and therefore not implemented by
35+
// OTelSyncForwarder. It replaces a delegation to the stopped embedded
36+
// DefaultForwarder, which would return a less informative "forwarder is not
37+
// started" error.
38+
var errOTelSyncNotSupported = errors.New("not supported by OTelSyncForwarder: only the serializer metrics path is implemented")
39+
40+
// OTelSyncForwarder is a synchronous forwarder that aggregates and returns
41+
// transaction errors instead of swallowing them. It is intended for use by the
42+
// OTel serializer exporter so that failures surface back through ConsumeMetrics
43+
// to the exporterhelper queue/retry layer (see OTAGENT-1024).
44+
//
45+
// Supported methods (the OTel serializer metrics path):
46+
// - SubmitTransaction, SubmitV1Series, SubmitV1Intake, SubmitV1IntakeDirect
47+
// - SubmitV1CheckRuns, SubmitHostMetadata, SubmitMetadata, SubmitAgentChecksMetadata
48+
// - GetDomainResolvers
49+
//
50+
// All other Forwarder interface methods (process, container, orchestrator checks)
51+
// return errOTelSyncNotSupported; they exist only for interface compliance.
52+
//
53+
// Differences vs SyncForwarder:
54+
// - Errors from HTTP transactions are returned (multierr-combined) rather than logged.
55+
// - Each transaction is attempted once; the OTel exporterhelper drives retries.
56+
type OTelSyncForwarder struct {
57+
config config.Component
58+
log log.Component
59+
secrets secrets.Component
60+
defaultForwarder *DefaultForwarder
61+
client *http.Client
62+
}
63+
64+
// Compile-time check that OTelSyncForwarder implements the Forwarder interface.
65+
var _ defaultforwarderdef.Forwarder = &OTelSyncForwarder{}
66+
67+
// NewOTelSyncForwarder returns a new synchronous, error-propagating forwarder.
68+
// The caller supplies the *http.Client (typically built from confighttp.ClientConfig
69+
// via ToClient so OTel-native HTTP settings are honored).
70+
func NewOTelSyncForwarder(config config.Component, log log.Component, secrets secrets.Component, endpoints utils.EndpointDescriptorSet, client *http.Client) (*OTelSyncForwarder, error) {
71+
options, err := NewOptionsWithOPW(config, log, endpoints)
72+
if err != nil {
73+
return nil, err
74+
}
75+
options.Secrets = secrets
76+
return &OTelSyncForwarder{
77+
config: config,
78+
log: log,
79+
secrets: secrets,
80+
defaultForwarder: NewDefaultForwarder(config, log, options),
81+
client: client,
82+
}, nil
83+
}
84+
85+
// Start is a no-op; the OTel exporterhelper owns the lifecycle.
86+
func (f *OTelSyncForwarder) Start() error { return nil }
87+
88+
// Stop is a no-op; the OTel exporterhelper owns the lifecycle.
89+
func (f *OTelSyncForwarder) Stop() {}
90+
91+
// mrfChecker is satisfied by domainResolver so we can gate MRF transactions
92+
// without importing the resolver package.
93+
type mrfChecker interface{ IsMRF() bool }
94+
95+
// sendHTTPTransactions sends each transaction synchronously and returns a
96+
// multierr-combined error covering all failures. Unlike SyncForwarder, no
97+
// per-transaction retry is performed: retries are the OTel layer's job.
98+
//
99+
// HTTPTransaction.Process silently drops certain permanent failures (400, 413,
100+
// and 403 without a secret refresh) by returning nil. We wrap the completion
101+
// handler to intercept those status codes and surface them as errors so the OTel
102+
// exporterhelper can count them and drive retries or permanent-error handling.
103+
//
104+
// MRF gating: non-metadata transactions for MRF resolvers are skipped unless
105+
// both multi_region_failover.enabled and multi_region_failover.failover_metrics
106+
// are true, mirroring the steady-state guard in domainForwarder.shouldSendHTTPTransaction.
107+
func (f *OTelSyncForwarder) sendHTTPTransactions(ctx context.Context, transactions []*transaction.HTTPTransaction) error {
108+
var errs error
109+
for _, txn := range transactions {
110+
if dr, ok := txn.Resolver.(mrfChecker); ok && dr.IsMRF() {
111+
if txn.Kind != transaction.Metadata && (!f.config.GetBool("multi_region_failover.enabled") || !f.config.GetBool("multi_region_failover.failover_metrics")) {
112+
continue
113+
}
114+
}
115+
var permanentErr error
116+
var responseReceived bool
117+
origHandler := txn.CompletionHandler
118+
txn.CompletionHandler = func(tx *transaction.HTTPTransaction, statusCode int, body []byte, err error) {
119+
if statusCode > 0 {
120+
responseReceived = true
121+
}
122+
if err == nil && (statusCode == 400 || statusCode == 413 || statusCode == 403) {
123+
permanentErr = fmt.Errorf("HTTP %d dropping transaction to %s%s: %w", statusCode, tx.Domain, tx.Endpoint.Route, ErrPermanentHTTPError)
124+
}
125+
origHandler(tx, statusCode, body, err)
126+
}
127+
if err := txn.Process(ctx, f.config, f.log, f.secrets, f.client, nil); err != nil {
128+
errs = multierr.Append(errs, err)
129+
} else if permanentErr != nil {
130+
errs = multierr.Append(errs, permanentErr)
131+
} else if !responseReceived {
132+
// internalProcess silently drops canceled requests (returns nil, statusCode 0).
133+
// Only propagate ctx.Err() when no real HTTP response was received; a post-success
134+
// cancellation must not turn an already-accepted payload into a reported failure.
135+
if ctxErr := ctx.Err(); ctxErr != nil {
136+
errs = multierr.Append(errs, ctxErr)
137+
break
138+
}
139+
}
140+
}
141+
return errs
142+
}
143+
144+
// SubmitTransaction sends a single transaction synchronously. This is the main
145+
// path used by pkg/serializer's v2 pipelines.
146+
// Mirrors DefaultForwarder.SubmitTransaction header injection so agent-version,
147+
// user-agent, and allow-arbitrary-tag headers are present on v2 series payloads.
148+
func (f *OTelSyncForwarder) SubmitTransaction(txn *transaction.HTTPTransaction) error {
149+
txn.Headers.Set(versionHTTPHeaderKey, version.AgentVersion)
150+
txn.Headers.Set(useragentHTTPHeaderKey, "datadog-agent/"+version.AgentVersion)
151+
if f.config.GetBool("allow_arbitrary_tags") {
152+
txn.Headers.Set(arbitraryTagHTTPHeaderKey, "true")
153+
}
154+
return f.sendHTTPTransactions(context.Background(), []*transaction.HTTPTransaction{txn})
155+
}
156+
157+
// SubmitV1Series sends timeseries to the v1 endpoint.
158+
func (f *OTelSyncForwarder) SubmitV1Series(payload transaction.BytesPayloads, extra http.Header) error {
159+
transactions := f.defaultForwarder.createHTTPTransactions(endpoints.V1SeriesEndpoint, payload, transaction.Series, extra)
160+
return f.sendHTTPTransactions(context.Background(), transactions)
161+
}
162+
163+
// SubmitV1Intake sends payloads to the universal `/intake/` endpoint.
164+
func (f *OTelSyncForwarder) SubmitV1Intake(payload transaction.BytesPayloads, kind transaction.Kind, extra http.Header) error {
165+
transactions := f.defaultForwarder.createHTTPTransactions(endpoints.V1IntakeEndpoint, payload, kind, extra)
166+
// the intake endpoint requires the Content-Type header to be set
167+
for _, t := range transactions {
168+
t.Headers.Set("Content-Type", "application/json")
169+
}
170+
return f.sendHTTPTransactions(context.Background(), transactions)
171+
}
172+
173+
// SubmitV1IntakeDirect sends payloads synchronously to the universal `/intake/` endpoint.
174+
// Unlike SubmitV1Intake, the caller's context is honored so cancellations and
175+
// deadlines propagate to the underlying HTTP request.
176+
func (f *OTelSyncForwarder) SubmitV1IntakeDirect(ctx context.Context, payload transaction.BytesPayloads, kind transaction.Kind, extra http.Header) error {
177+
transactions := f.defaultForwarder.createHTTPTransactions(endpoints.V1IntakeEndpoint, payload, kind, extra)
178+
for _, t := range transactions {
179+
t.Headers.Set("Content-Type", "application/json")
180+
}
181+
return f.sendHTTPTransactions(ctx, transactions)
182+
}
183+
184+
// SubmitV1CheckRuns sends service checks to the v1 endpoint.
185+
func (f *OTelSyncForwarder) SubmitV1CheckRuns(payload transaction.BytesPayloads, extra http.Header) error {
186+
transactions := f.defaultForwarder.createHTTPTransactions(endpoints.V1CheckRunsEndpoint, payload, transaction.CheckRuns, extra)
187+
return f.sendHTTPTransactions(context.Background(), transactions)
188+
}
189+
190+
// SubmitHostMetadata sends a host_metadata payload.
191+
func (f *OTelSyncForwarder) SubmitHostMetadata(payload transaction.BytesPayloads, extra http.Header) error {
192+
return f.SubmitV1Intake(payload, transaction.Metadata, extra)
193+
}
194+
195+
// SubmitMetadata sends a metadata payload.
196+
func (f *OTelSyncForwarder) SubmitMetadata(payload transaction.BytesPayloads, extra http.Header) error {
197+
return f.SubmitV1Intake(payload, transaction.Metadata, extra)
198+
}
199+
200+
// SubmitAgentChecksMetadata sends an agentchecks_metadata payload.
201+
func (f *OTelSyncForwarder) SubmitAgentChecksMetadata(payload transaction.BytesPayloads, extra http.Header) error {
202+
return f.SubmitV1Intake(payload, transaction.Metadata, extra)
203+
}
204+
205+
// SubmitProcessChecks is not supported; see errOTelSyncNotSupported.
206+
func (f *OTelSyncForwarder) SubmitProcessChecks(_ transaction.BytesPayloads, _ http.Header) (chan defaultforwarderdef.Response, error) {
207+
return nil, errOTelSyncNotSupported
208+
}
209+
210+
// SubmitProcessDiscoveryChecks is not supported; see errOTelSyncNotSupported.
211+
func (f *OTelSyncForwarder) SubmitProcessDiscoveryChecks(_ transaction.BytesPayloads, _ http.Header) (chan defaultforwarderdef.Response, error) {
212+
return nil, errOTelSyncNotSupported
213+
}
214+
215+
// SubmitRTProcessChecks is not supported; see errOTelSyncNotSupported.
216+
func (f *OTelSyncForwarder) SubmitRTProcessChecks(_ transaction.BytesPayloads, _ http.Header) (chan defaultforwarderdef.Response, error) {
217+
return nil, errOTelSyncNotSupported
218+
}
219+
220+
// SubmitContainerChecks is not supported; see errOTelSyncNotSupported.
221+
func (f *OTelSyncForwarder) SubmitContainerChecks(_ transaction.BytesPayloads, _ http.Header) (chan defaultforwarderdef.Response, error) {
222+
return nil, errOTelSyncNotSupported
223+
}
224+
225+
// SubmitRTContainerChecks is not supported; see errOTelSyncNotSupported.
226+
func (f *OTelSyncForwarder) SubmitRTContainerChecks(_ transaction.BytesPayloads, _ http.Header) (chan defaultforwarderdef.Response, error) {
227+
return nil, errOTelSyncNotSupported
228+
}
229+
230+
// SubmitConnectionChecks is not supported; see errOTelSyncNotSupported.
231+
func (f *OTelSyncForwarder) SubmitConnectionChecks(_ transaction.BytesPayloads, _ http.Header) (chan defaultforwarderdef.Response, error) {
232+
return nil, errOTelSyncNotSupported
233+
}
234+
235+
// SubmitOrchestratorChecks is not supported; see errOTelSyncNotSupported.
236+
func (f *OTelSyncForwarder) SubmitOrchestratorChecks(_ transaction.BytesPayloads, _ http.Header, _ int) error {
237+
return errOTelSyncNotSupported
238+
}
239+
240+
// SubmitOrchestratorManifests is not supported; see errOTelSyncNotSupported.
241+
func (f *OTelSyncForwarder) SubmitOrchestratorManifests(_ transaction.BytesPayloads, _ http.Header) error {
242+
return errOTelSyncNotSupported
243+
}
244+
245+
// GetDomainResolvers returns the list of resolvers used by this forwarder.
246+
func (f *OTelSyncForwarder) GetDomainResolvers() []resolver.DomainResolver {
247+
return f.defaultForwarder.GetDomainResolvers()
248+
}

0 commit comments

Comments
 (0)