feat(tracing): add bearer token authentication for OTLP exporters

Add BearerTokenFunc to support dynamic bearer token authentication
for OTLP exporters. Tokens are injected per-request via gRPC
PerRPCCredentials and HTTP custom RoundTripper.

- Add BearerTokenFunc type and Config field in tracerconfig
- Implement bearerCredentials (gRPC) and bearerRoundTripper (HTTP)
- Wire bearer auth into all exporter creation functions
- Add getHTTPClient helper for DRY HTTP client configuration
- Upgrade OpenTelemetry SDK to v1.39.0 for WithHTTPClient support
This commit is contained in:
2025-12-27 12:52:37 -08:00
parent d43ff0f2a9
commit 1df4b0d4b4
7 changed files with 744 additions and 137 deletions

View File

@@ -23,6 +23,9 @@ type bufferingExporter struct {
// Real exporter (created when tracing is configured)
exporter otellog.Exporter
// Track whether buffer has been flushed (separate from exporter creation)
bufferFlushed bool
// Thread-safe initialization state (managed only by checkReadiness)
initErr error
@@ -71,22 +74,63 @@ func (e *bufferingExporter) initialize() error {
initCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
exporter, err := factory(initCtx, cfg)
if err != nil {
return fmt.Errorf("failed to create OTLP exporter: %w", err)
e.mu.RLock()
hasExporter := e.exporter != nil
e.mu.RUnlock()
// Create exporter if not already created
if !hasExporter {
exporter, err := factory(initCtx, cfg)
if err != nil {
return fmt.Errorf("failed to create OTLP exporter: %w", err)
}
e.mu.Lock()
// Double-check: another goroutine may have created it while we were waiting
if e.exporter == nil {
e.exporter = exporter
} else {
// Another goroutine beat us, close the one we created
_ = exporter.Shutdown(context.Background())
}
e.mu.Unlock()
}
// Check if we can flush (token verification if configured)
if !e.canFlush(initCtx, cfg, false) {
return errors.New("waiting for token authentication")
}
e.mu.Lock()
e.exporter = exporter
flushErr := e.flushBuffer(initCtx)
if !e.bufferFlushed {
flushErr := e.flushBuffer(initCtx)
if flushErr != nil {
e.mu.Unlock()
// Log but don't fail initialization
Setup().Warn("buffer flush failed during initialization", "error", flushErr)
return nil
}
e.bufferFlushed = true
}
e.mu.Unlock()
if flushErr != nil {
// Log but don't fail initialization
Setup().Warn("buffer flush failed during initialization", "error", flushErr)
return nil
}
// canFlush checks if we're ready to flush buffered logs.
// If BearerTokenFunc is configured, it must return without error.
// If forceFlush is true (during shutdown with cancelled context), skip token check.
func (e *bufferingExporter) canFlush(ctx context.Context, cfg *tracerconfig.Config, forceFlush bool) bool {
if cfg.BearerTokenFunc == nil {
return true // No token auth configured, can flush immediately
}
return nil
if forceFlush {
return true // During shutdown, proceed with best-effort flush
}
// Check if token is available (call returns without error)
_, err := cfg.BearerTokenFunc(ctx)
return err == nil
}
// bufferRecords adds records to the buffer for later processing
@@ -119,16 +163,16 @@ func (e *bufferingExporter) checkReadiness() {
for {
select {
case <-ticker.C:
// Check if we already have a working exporter
// Check if we're fully ready (exporter created AND buffer flushed)
e.mu.RLock()
hasExporter := e.exporter != nil
fullyReady := e.exporter != nil && e.bufferFlushed
e.mu.RUnlock()
if hasExporter {
return // Exporter ready, checker no longer needed
if fullyReady {
return // Fully initialized, checker no longer needed
}
// Try to initialize
// Try to initialize (creates exporter and flushes if token ready)
err := e.initialize()
e.mu.Lock()
e.initErr = err
@@ -182,18 +226,42 @@ func (e *bufferingExporter) Shutdown(ctx context.Context) error {
// Wait for readiness checker goroutine to complete
<-e.checkerDone
cfg, _, _ := tracerconfig.Get()
// Check if context is cancelled for best-effort flush
forceFlush := ctx.Err() != nil
// Give one final chance for TLS/tracing to become ready for buffer flushing
e.mu.RLock()
hasExporter := e.exporter != nil
bufferFlushed := e.bufferFlushed
e.mu.RUnlock()
if !hasExporter {
err := e.initialize()
e.mu.Lock()
e.initErr = err
hasExporter = e.exporter != nil
bufferFlushed = e.bufferFlushed
e.mu.Unlock()
}
// If exporter exists but buffer not flushed, try to flush now
if hasExporter && !bufferFlushed {
canFlushNow := cfg == nil || e.canFlush(ctx, cfg, forceFlush)
if canFlushNow {
e.mu.Lock()
if !e.bufferFlushed {
flushErr := e.flushBuffer(ctx)
if flushErr != nil {
Setup().Warn("buffer flush failed during shutdown", "error", flushErr)
}
e.bufferFlushed = true
}
e.mu.Unlock()
}
}
e.mu.Lock()
defer e.mu.Unlock()