Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
8 changes: 8 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,14 @@ type Config struct {
// EnableOTELMetrics enables OpenTelemetry metrics export via OTLP alongside
// Prometheus. Does not replace Prometheus. Default false.
EnableOTELMetrics bool `envconfig:"ENABLE_OTEL_METRICS" default:"false"`

// OTELGRPCSpanNameFormat controls gRPC span naming.
// "short" extracts just the method name (e.g., "V0GetStats")
// "full" keeps the full path (e.g., "/pkg.Service/V0GetStats") - default
OTELGRPCSpanNameFormat string `envconfig:"OTEL_GRPC_SPAN_NAME_FORMAT" default:"full"`
// OTELFilterSpanNames is a comma-separated list of span names to filter out (exact match).
// Common use: "ServeHTTP" to filter HTTP transport spans.
OTELFilterSpanNames string `envconfig:"OTEL_FILTER_SPAN_NAMES" default:""`
Comment on lines +183 to +189

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Warn on unsupported OTEL_GRPC_SPAN_NAME_FORMAT values.

Anything other than "short" currently falls back to full naming silently, so a typo just disables the feature. Adding a Validate() warning here would match how other enum-like config fields are handled in this file.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@config/config.go` around lines 183 - 189, Add validation for the
OTELGRPCSpanNameFormat config field inside the existing Validate() function so
values other than "short" or "full" produce a warning and the code falls back to
"full"; specifically, check the OTELGRPCSpanNameFormat string (from the struct
field named OTELGRPCSpanNameFormat) and if it is non-empty and not equal to
"short" or "full", emit a warning via the same logger used by other Validate()
checks and set/keep the value as "full" to preserve current behavior.

// OTELMetricsInterval controls the export interval in seconds for OTEL
// metrics. Default 60.
OTELMetricsInterval int `envconfig:"OTEL_METRICS_INTERVAL" default:"60"`
Expand Down
49 changes: 22 additions & 27 deletions core.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,38 +180,33 @@ func (c *cb) processConfig() {
if c.config.OTLPEndpoint != "" {
headers := parseHeaders(c.config.OTLPHeaders)
otlpConfig = OTLPConfig{
Endpoint: c.config.OTLPEndpoint,
Headers: headers,
ServiceName: c.config.AppName,
ServiceVersion: c.config.ReleaseName,
SamplingRatio: c.config.OTLPSamplingRatio,
Compression: c.config.OTLPCompression,
Insecure: c.config.OTLPInsecure,
Endpoint: c.config.OTLPEndpoint,
Headers: headers,
ServiceName: c.config.AppName,
ServiceVersion: c.config.ReleaseName,
SamplingRatio: c.config.OTLPSamplingRatio,
Compression: c.config.OTLPCompression,
Insecure: c.config.OTLPInsecure,
GRPCSpanNameFormat: c.config.OTELGRPCSpanNameFormat,
FilterSpanNames: c.config.OTELFilterSpanNames,
}
if err := SetupOpenTelemetry(otlpConfig); err != nil {
log.Error(context.Background(), "msg", "Failed to setup custom OTLP", "err", err)
}
} else if c.config.NewRelicOpentelemetry {
err := SetupNROpenTelemetry(
nrName,
c.config.NewRelicLicenseKey,
c.config.ReleaseName,
c.config.NewRelicOpentelemetrySample,
)
if err != nil {
log.Error(context.Background(), "msg", "Failed to setup New Relic OpenTelemetry", "err", err)
} else if c.config.NewRelicOpentelemetry && strings.TrimSpace(c.config.NewRelicLicenseKey) != "" {
// Build full config for NR path to include filter/transformer settings.
otlpConfig = OTLPConfig{
Endpoint: nrOTLPEndpoint,
Headers: map[string]string{"api-key": c.config.NewRelicLicenseKey},
ServiceName: nrName,
ServiceVersion: c.config.ReleaseName,
SamplingRatio: c.config.NewRelicOpentelemetrySample,
Compression: "gzip",
GRPCSpanNameFormat: c.config.OTELGRPCSpanNameFormat,
FilterSpanNames: c.config.OTELFilterSpanNames,
}
// Build otlpConfig for NR path so OTEL metrics can reuse the endpoint.
// Only populate when the license key is non-empty (SetupNROpenTelemetry
// no-ops without it, so metrics would just get auth failures).
if strings.TrimSpace(c.config.NewRelicLicenseKey) != "" {
otlpConfig = OTLPConfig{
Endpoint: nrOTLPEndpoint,
Headers: map[string]string{"api-key": c.config.NewRelicLicenseKey},
ServiceName: nrName,
ServiceVersion: c.config.ReleaseName,
Compression: "gzip",
}
if err := SetupOpenTelemetry(otlpConfig); err != nil {
log.Error(context.Background(), "msg", "Failed to setup New Relic OpenTelemetry", "err", err)
}
}

Expand Down
51 changes: 49 additions & 2 deletions initializers.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"time"

metricCollector "github.com/afex/hystrix-go/hystrix/metric_collector"
cbotel "github.com/go-coldbrew/core/otel"
"github.com/go-coldbrew/errors/notifier"
"github.com/go-coldbrew/hystrixprometheus" //nolint:staticcheck // deprecated but still in use
"github.com/go-coldbrew/interceptors"
Expand All @@ -26,9 +27,9 @@ import (
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otelmetric "go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
Expand Down Expand Up @@ -145,6 +146,15 @@ type OTLPConfig struct {
// Insecure disables TLS verification for the connection
// Only use this for local development or testing
Insecure bool

// GRPCSpanNameFormat controls gRPC span naming.
// "short" extracts just the method name (e.g., "V0GetStats")
// "full" keeps the full path (e.g., "/pkg.Service/V0GetStats") - default
GRPCSpanNameFormat string

// FilterSpanNames is a comma-separated string of span names to filter out.
// Common use: "ServeHTTP" to filter HTTP transport spans.
FilterSpanNames string
}

// nrOTLPEndpoint is the New Relic OTLP gRPC endpoint.
Expand All @@ -157,6 +167,9 @@ var otelResource *resource.Resource
// otelTracerProvider stores the concrete TracerProvider for shutdown.
var otelTracerProvider *sdktrace.TracerProvider

// otelSpanProcessor stores the custom span processor for runtime filter/transformer additions.
var otelSpanProcessor *cbotel.SpanProcessor

// buildOTELResource builds a resource with service name, version, build info,
// and VCS metadata. The result is cached in otelResource for reuse.
func buildOTELResource(serviceName, serviceVersion string) (*resource.Resource, error) {
Expand Down Expand Up @@ -268,9 +281,25 @@ func SetupOpenTelemetry(config OTLPConfig) error {
ratio = 0.2
}

// Wrap the batcher with custom SpanProcessor for filtering/transformation.
batcher := sdktrace.NewBatchSpanProcessor(otlpExporter)
var filterNames []string
if config.FilterSpanNames != "" {
for _, name := range strings.Split(config.FilterSpanNames, ",") {
if trimmed := strings.TrimSpace(name); trimmed != "" {
filterNames = append(filterNames, trimmed)
}
}
}
processor := cbotel.NewSpanProcessor(batcher, cbotel.SpanProcessorConfig{
GRPCSpanNameFormat: config.GRPCSpanNameFormat,
FilterSpanNames: filterNames,
})
otelSpanProcessor = processor

tracerProvider := sdktrace.NewTracerProvider(
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
sdktrace.WithBatcher(otlpExporter),
sdktrace.WithSpanProcessor(processor),
sdktrace.WithResource(r),
)
otelTracerProvider = tracerProvider
Expand Down Expand Up @@ -506,3 +535,21 @@ func (vtprotoCodec) Name() string {
// name registered for the proto compressor
return "proto"
}

// AddOTELSpanFilter adds a custom span filter at runtime.
// Filters are checked for each span; if any filter returns true, the span is dropped.
// Must be called after SetupOpenTelemetry; no-op if OTEL is not initialized.
func AddOTELSpanFilter(f cbotel.SpanFilter) {
if otelSpanProcessor != nil {
otelSpanProcessor.AddFilter(f)
}
}

// AddOTELSpanTransformer adds a custom span transformer at runtime.
// Transformers are applied in order; first non-empty result wins.
// Must be called after SetupOpenTelemetry; no-op if OTEL is not initialized.
func AddOTELSpanTransformer(t cbotel.SpanTransformer) {
if otelSpanProcessor != nil {
otelSpanProcessor.AddTransformer(t)
}
}
183 changes: 183 additions & 0 deletions otel/spanprocessor.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// Package otel provides OpenTelemetry utilities for ColdBrew services.
package otel

import (
"context"
"strings"
"sync"

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

// SpanFilter allows clients to implement custom span filtering logic.
// Return true to drop the span (not export it), false to keep it.
type SpanFilter interface {
// ShouldDrop returns true if the span should be filtered out.
ShouldDrop(span sdktrace.ReadOnlySpan) bool
}

// SpanFilterFunc is a function adapter for SpanFilter interface.
type SpanFilterFunc func(span sdktrace.ReadOnlySpan) bool

// ShouldDrop implements SpanFilter.
func (f SpanFilterFunc) ShouldDrop(span sdktrace.ReadOnlySpan) bool {
return f(span)
}

// SpanTransformer allows clients to transform span data before export.
// This is useful for renaming spans, adding attributes, etc.
type SpanTransformer interface {
// Transform returns a modified span name. Return empty string to keep original.
Transform(span sdktrace.ReadOnlySpan) string
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

// SpanTransformerFunc is a function adapter for SpanTransformer interface.
type SpanTransformerFunc func(span sdktrace.ReadOnlySpan) string

// Transform implements SpanTransformer.
func (f SpanTransformerFunc) Transform(span sdktrace.ReadOnlySpan) string {
return f(span)
}

// SpanProcessorConfig configures the custom span processor.
type SpanProcessorConfig struct {
// GRPCSpanNameFormat controls gRPC span naming.
// "short" extracts just the method name (e.g., "V0GetStats")
// "full" keeps the full path (e.g., "/pkg.Service/V0GetStats") - default
GRPCSpanNameFormat string

// FilterSpanNames is a list of span names to filter out (exact match).
// Common use: []string{"ServeHTTP"} to filter HTTP transport spans.
FilterSpanNames []string

// Filters are custom filters provided by the client.
// All filters are checked; if any returns true, the span is dropped.
Filters []SpanFilter

// Transformers are custom transformers provided by the client.
// Applied in order; first non-empty result wins.
Transformers []SpanTransformer
}

// SpanProcessor wraps a SpanProcessor with filtering and transformation.
// It implements sdktrace.SpanProcessor.
type SpanProcessor struct {
next sdktrace.SpanProcessor
config SpanProcessorConfig
filterNames map[string]struct{}
mu sync.RWMutex
}

// NewSpanProcessor creates a new SpanProcessor wrapping the given processor.
func NewSpanProcessor(next sdktrace.SpanProcessor, config SpanProcessorConfig) *SpanProcessor {
filterNames := make(map[string]struct{}, len(config.FilterSpanNames))
for _, name := range config.FilterSpanNames {
filterNames[name] = struct{}{}
}
return &SpanProcessor{
next: next,
config: config,
filterNames: filterNames,
}
}

// OnStart is called when a span starts.
func (p *SpanProcessor) OnStart(parent context.Context, s sdktrace.ReadWriteSpan) {
p.next.OnStart(parent, s)
}

// OnEnd is called when a span ends. This is where filtering and transformation happen.
func (p *SpanProcessor) OnEnd(s sdktrace.ReadOnlySpan) {
// Check exact name filter
if _, ok := p.filterNames[s.Name()]; ok {
return // filtered out
}

// Check custom filters (need lock since AddFilter can modify concurrently)
p.mu.RLock()
filters := p.config.Filters
p.mu.RUnlock()
for _, filter := range filters {
if filter.ShouldDrop(s) {
return // filtered out
}
}

// Apply transformations if needed
// Note: ReadOnlySpan doesn't allow modification, so we use a wrapper
// that applies name transformation on read.
transformed := p.maybeTransform(s)

p.next.OnEnd(transformed)
}

// maybeTransform applies transformations and returns a potentially wrapped span.
func (p *SpanProcessor) maybeTransform(s sdktrace.ReadOnlySpan) sdktrace.ReadOnlySpan {
newName := ""

// Apply short gRPC span name format
if p.config.GRPCSpanNameFormat == "short" {
name := s.Name()
// gRPC spans have format "/pkg.Service/Method"
if strings.HasPrefix(name, "/") && strings.Contains(name, "/") {
parts := strings.Split(name, "/")
if len(parts) >= 2 {
newName = parts[len(parts)-1]
}
}
}

// Apply custom transformers (first non-empty wins)
// Need lock since AddTransformer can modify concurrently
p.mu.RLock()
transformers := p.config.Transformers
p.mu.RUnlock()
for _, t := range transformers {
if result := t.Transform(s); result != "" {
newName = result
break
}
}

if newName != "" && newName != s.Name() {
return &renamedSpan{ReadOnlySpan: s, name: newName}
}
return s
}

// Shutdown shuts down the processor.
func (p *SpanProcessor) Shutdown(ctx context.Context) error {
return p.next.Shutdown(ctx)
}

// ForceFlush forces a flush of the processor.
func (p *SpanProcessor) ForceFlush(ctx context.Context) error {
return p.next.ForceFlush(ctx)
}

// AddFilter adds a custom filter at runtime.
// Thread-safe.
func (p *SpanProcessor) AddFilter(f SpanFilter) {
p.mu.Lock()
defer p.mu.Unlock()
p.config.Filters = append(p.config.Filters, f)
}

// AddTransformer adds a custom transformer at runtime.
// Thread-safe.
func (p *SpanProcessor) AddTransformer(t SpanTransformer) {
p.mu.Lock()
defer p.mu.Unlock()
p.config.Transformers = append(p.config.Transformers, t)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// renamedSpan wraps a ReadOnlySpan to return a different name.
type renamedSpan struct {
sdktrace.ReadOnlySpan
name string
}

// Name returns the transformed span name.
func (s *renamedSpan) Name() string {
return s.name
}
Loading