feat: OTLP multi-endpoint fan-out via GH_AW_OTLP_ENDPOINTS by Copilot · Pull Request #7446 · github/gh-aw-mcpg · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
11 changes: 9 additions & 2 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,16 @@ func run(cmd *cobra.Command, args []string) error {
sampleRate = tracingCfg.GetSampleRate()
serviceName = tracingCfg.ServiceName
}
if endpoint != "" {
if tracingProvider.IsEnabled() {
// When GH_AW_OTLP_ENDPOINTS is set, InitProvider uses fan-out mode and
// it takes precedence over the primary endpoint; use the env var as the
// display value to accurately reflect what is actually receiving spans.
displayEndpoint := os.Getenv("GH_AW_OTLP_ENDPOINTS")
if displayEndpoint == "" {
displayEndpoint = endpoint
}
logger.StartupInfo("OpenTelemetry tracing enabled: endpoint=%s, service=%s, sampleRate=%.2f",
endpoint, serviceName, sampleRate)
displayEndpoint, serviceName, sampleRate)
} else {
logger.StartupInfo("OpenTelemetry tracing disabled (no OTLP endpoint configured)")
}
Expand Down
35 changes: 35 additions & 0 deletions internal/tracing/config_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,41 @@ func resolveHeaders(cfg *config.TracingConfig) map[string]string {
return parseOTLPHeaders(raw)
}

// resolveExtraEndpoints returns the additional OTLP endpoints from the
// GH_AW_OTLP_ENDPOINTS environment variable as a slice of fully-qualified URL
// strings (with the signal path appended). Each URL is normalised using the
// same signal-path rules as resolveEndpoint. Empty and whitespace-only entries
// are skipped. Returns nil when the variable is unset or yields no valid URLs.
func resolveExtraEndpoints(cfg *config.TracingConfig) []string {
raw := os.Getenv("GH_AW_OTLP_ENDPOINTS")
if raw == "" {
return nil
}
signalPath := ""
if cfg != nil {
signalPath = cfg.SignalPath
}
var endpoints []string
for _, ep := range strings.Split(raw, ",") {
ep = strings.TrimSpace(ep)
if ep == "" {
continue
}
normalized := resolveEndpoint(&config.TracingConfig{
Endpoint: ep,
SignalPath: signalPath,
})
if normalized != "" {
endpoints = append(endpoints, normalized)
}
}
if len(endpoints) == 0 {
return nil
}
logTracing.Printf("GH_AW_OTLP_ENDPOINTS: resolved %d extra endpoint(s)", len(endpoints))
return endpoints
}

// resolveParentContext builds a context carrying the W3C remote parent span context
// from the configured traceId and spanId (spec §4.1.3.6).
// If traceId is absent, or either ID is malformed, the original context is returned unchanged.
Expand Down
74 changes: 74 additions & 0 deletions internal/tracing/fanout.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
package tracing

import (
"context"
"errors"
"sync"

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

// fanoutExporter is a SpanExporter that fans out span export to multiple
// underlying exporters. All exporters are attempted even when earlier ones
// fail (partial-failure tolerance), and collected errors are joined before
// returning.
type fanoutExporter struct {
exporters []sdktrace.SpanExporter
}

// newFanoutExporter returns a SpanExporter that forwards to all given exporters.
// When only one exporter is provided it is returned directly to avoid overhead.
func newFanoutExporter(exporters []sdktrace.SpanExporter) sdktrace.SpanExporter {
if len(exporters) == 1 {
return exporters[0]
}
return &fanoutExporter{exporters: exporters}
}

// ExportSpans exports spans to each underlying exporter concurrently. All
// exporters are invoked in parallel so that a slow or hung backend cannot
// delay delivery to the others. Errors from all exporters are collected and
// joined before returning.
func (f *fanoutExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error {
var (
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
for _, exp := range f.exporters {
wg.Add(1)
go func(e sdktrace.SpanExporter) {
defer wg.Done()
if err := e.ExportSpans(ctx, spans); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(exp)
}
wg.Wait()
return errors.Join(errs...)
}

// Shutdown shuts down each underlying exporter concurrently, collecting any
// errors. All errors are joined and returned.
func (f *fanoutExporter) Shutdown(ctx context.Context) error {
var (
wg sync.WaitGroup
mu sync.Mutex
errs []error
)
for _, exp := range f.exporters {
wg.Add(1)
go func(e sdktrace.SpanExporter) {
defer wg.Done()
if err := e.Shutdown(ctx); err != nil {
mu.Lock()
errs = append(errs, err)
mu.Unlock()
}
}(exp)
}
wg.Wait()
return errors.Join(errs...)
}
143 changes: 143 additions & 0 deletions internal/tracing/fanout_test.go
Loading
Loading