diff --git a/CHANGELOG.md b/CHANGELOG.md index 8dd2fcd57..31ae9ccd6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,10 +18,17 @@ OpenTelemetry Go Automatic Instrumentation adheres to [Semantic Versioning](http - Introduce `config.Provider` as an option to set the initial configuration and update it in runtime. ([#1010](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/1010)) - Support `go.opentelemetry.io/otel@v1.29.0`. ([#1032](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/1032)) - Support `google.golang.org/grpc` `1.66.0`. ([#1046](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/1046)) +- `Sampler` interface that can be passed to `Instrumentation` via the new `WithSampler` option. + This configuration allows customization of what sampler is used by the `Instrumentation`. ([#982](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/982)) +- The `OTEL_TRACES_SAMPLER` and `OTEL_TRACES_SAMPLER_ARG` environment variables are now supported when the `WithEnv` option is used. ([#982](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/982)) - Support `golang.org/x/net` `v0.29.0`. ([#1051](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/1051)) - Support Go `1.22.7`. ([#1051](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/1051)) - Support Go `1.23.1`. ([#1051](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/1051)) +### Changed + +- The `WithSampler` option function now accepts the new `Sampler` interface instead of `trace.Sampler`. ([#982](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/982)) + ### Fixed - Fix dirty shutdown caused by panic. ([#980](https://github.com/open-telemetry/opentelemetry-go-instrumentation/pull/980)) diff --git a/config/provider.go b/config/provider.go index 64011e95c..fd55083fc 100644 --- a/config/provider.go +++ b/config/provider.go @@ -39,6 +39,9 @@ type InstrumentationConfig struct { // If set to false, traces are enabled by default for all libraries, unless the library is explicitly disabled. // default is false - traces are enabled by default. DefaultTracesDisabled bool + + // Sampler is used to determine whether a trace should be sampled and exported. + Sampler Sampler } // Provider provides the initial configuration and updates to the instrumentation configuration. @@ -52,15 +55,19 @@ type Provider interface { Shutdown(ctx context.Context) error } -type noopProvider struct{} +type noopProvider struct { + Sampler Sampler +} // NewNoopProvider returns a provider that does not provide any updates and provide the default configuration as the initial one. -func NewNoopProvider() Provider { - return &noopProvider{} +func NewNoopProvider(s Sampler) Provider { + return &noopProvider{Sampler: s} } func (p *noopProvider) InitialConfig(_ context.Context) InstrumentationConfig { - return InstrumentationConfig{} + return InstrumentationConfig{ + Sampler: p.Sampler, + } } func (p *noopProvider) Watch() <-chan InstrumentationConfig { diff --git a/config/sampling.go b/config/sampling.go new file mode 100644 index 000000000..8fecd5352 --- /dev/null +++ b/config/sampling.go @@ -0,0 +1,298 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package config + +import ( + "errors" + "strconv" + "strings" + + "go.opentelemetry.io/auto/internal/pkg/instrumentation/probe/sampling" +) + +// Sampler decides whether a trace should be sampled and exported. +type Sampler interface { + validate() error + convert() (*sampling.Config, error) +} + +// OpenTelemetry spec-defined sampler names and environment variables for configuration. +const ( + TracesSamplerKey = "OTEL_TRACES_SAMPLER" + TracesSamplerArgKey = "OTEL_TRACES_SAMPLER_ARG" + + SamplerNameAlwaysOn = "always_on" + SamplerNameAlwaysOff = "always_off" + SamplerNameTraceIDRatio = "traceidratio" + SamplerNameParentBasedAlwaysOn = "parentbased_always_on" + SamplerNameParsedBasedAlwaysOff = "parentbased_always_off" + SamplerNameParentBasedTraceIDRatio = "parentbased_traceidratio" +) + +// AlwaysOn is a Sampler that samples every trace. +// Be careful about using this sampler in a production application with +// significant traffic: a new trace will be started and exported for every +// request. +type AlwaysOn struct{} + +var _ Sampler = AlwaysOn{} + +func (AlwaysOn) validate() error { + return nil +} + +func (AlwaysOn) convert() (*sampling.Config, error) { + return &sampling.Config{ + Samplers: map[sampling.SamplerID]sampling.SamplerConfig{ + sampling.AlwaysOnID: { + SamplerType: sampling.SamplerAlwaysOn, + }, + }, + ActiveSampler: sampling.AlwaysOnID, + }, nil +} + +// AlwaysOff returns a Sampler that samples no traces. +type AlwaysOff struct{} + +var _ Sampler = AlwaysOff{} + +func (AlwaysOff) validate() error { + return nil +} + +func (AlwaysOff) convert() (*sampling.Config, error) { + return &sampling.Config{ + Samplers: map[sampling.SamplerID]sampling.SamplerConfig{ + sampling.AlwaysOffID: { + SamplerType: sampling.SamplerAlwaysOff, + }, + }, + ActiveSampler: sampling.AlwaysOffID, + }, nil +} + +// TraceIDRatio samples a given fraction of traces. Fraction should be in the closed interval [0, 1]. +// To respect the parent trace's SampledFlag, the TraceIDRatio sampler should be used +// as a delegate of a [ParentBased] sampler. +type TraceIDRatio struct { + // Fraction is the fraction of traces to sample. This value needs to be in the interval [0, 1]. + Fraction float64 +} + +var _ Sampler = TraceIDRatio{} + +func (t TraceIDRatio) validate() error { + if t.Fraction < 0 || t.Fraction > 1 { + return errors.New("fraction in TraceIDRatio must be in the range [0, 1]") + } + + return nil +} + +func (t TraceIDRatio) convert() (*sampling.Config, error) { + tidConfig, err := sampling.NewTraceIDRatioConfig(t.Fraction) + if err != nil { + return nil, err + } + return &sampling.Config{ + Samplers: map[sampling.SamplerID]sampling.SamplerConfig{ + sampling.TraceIDRatioID: { + SamplerType: sampling.SamplerTraceIDRatio, + Config: tidConfig, + }, + }, + ActiveSampler: sampling.TraceIDRatioID, + }, nil +} + +// ParentBased is a [Sampler] which behaves differently, +// based on the parent of the span. If the span has no parent, +// the Root sampler is used to make sampling decision. If the span has +// a parent, depending on whether the parent is remote and whether it +// is sampled, one of the following samplers will apply: +// - RemoteSampled (default: [AlwaysOn]) +// - RemoteNotSampled (default: [AlwaysOff]) +// - LocalSampled (default: [AlwaysOn]) +// - LocalNotSampled (default: [AlwaysOff]) +type ParentBased struct { + // Root is the Sampler used when a span is created without a parent. + Root Sampler + // RemoteSampled is the Sampler used when the span parent is remote and sampled. + RemoteSampled Sampler + // RemoteNotSampled is the Sampler used when the span parent is remote and not sampled. + RemoteNotSampled Sampler + // LocalSampled is the Sampler used when the span parent is local and sampled. + LocalSampled Sampler + // LocalNotSampled is the Sampler used when the span parent is local and not sampled. + LocalNotSampled Sampler +} + +var _ Sampler = ParentBased{} + +func validateParentBasedComponent(s Sampler) error { + if s == nil { + return nil + } + if _, ok := s.(ParentBased); ok { + return errors.New("parent-based sampler cannot wrap parent-based sampler") + } + return s.validate() +} + +func (p ParentBased) validate() error { + var err error + return errors.Join(err, + validateParentBasedComponent(p.LocalNotSampled), + validateParentBasedComponent(p.LocalSampled), + validateParentBasedComponent(p.RemoteNotSampled), + validateParentBasedComponent(p.RemoteSampled), + validateParentBasedComponent(p.Root)) +} + +func (p ParentBased) convert() (*sampling.Config, error) { + pbc := sampling.DefaultParentBasedSampler() + samplers := make(map[sampling.SamplerID]sampling.SamplerConfig) + rootSampler, err := ConvertSamplerToConfig(p.Root) + if err != nil { + return nil, err + } + if rootSampler != nil { + pbc.Root = rootSampler.ActiveSampler + for id, config := range rootSampler.Samplers { + samplers[id] = config + } + } + + remoteSampledSampler, err := ConvertSamplerToConfig(p.RemoteSampled) + if err != nil { + return nil, err + } + if remoteSampledSampler != nil { + pbc.RemoteSampled = remoteSampledSampler.ActiveSampler + for id, config := range remoteSampledSampler.Samplers { + samplers[id] = config + } + } + + remoteNotSampledSampler, err := ConvertSamplerToConfig(p.RemoteNotSampled) + if err != nil { + return nil, err + } + if remoteNotSampledSampler != nil { + pbc.RemoteNotSampled = remoteNotSampledSampler.ActiveSampler + for id, config := range remoteNotSampledSampler.Samplers { + samplers[id] = config + } + } + + localSampledSamplers, err := ConvertSamplerToConfig(p.LocalSampled) + if err != nil { + return nil, err + } + if localSampledSamplers != nil { + pbc.LocalSampled = localSampledSamplers.ActiveSampler + for id, config := range localSampledSamplers.Samplers { + samplers[id] = config + } + } + + localNotSampledSampler, err := ConvertSamplerToConfig(p.LocalNotSampled) + if err != nil { + return nil, err + } + if localNotSampledSampler != nil { + pbc.LocalNotSampled = localNotSampledSampler.ActiveSampler + for id, config := range localNotSampledSampler.Samplers { + samplers[id] = config + } + } + + samplers[sampling.ParentBasedID] = sampling.SamplerConfig{ + SamplerType: sampling.SamplerParentBased, + Config: pbc, + } + + return &sampling.Config{ + Samplers: samplers, + ActiveSampler: sampling.ParentBasedID, + }, nil +} + +// DefaultSampler returns a ParentBased sampler with the following defaults: +// - Root: AlwaysOn +// - RemoteSampled: AlwaysOn +// - RemoteNotSampled: AlwaysOff +// - LocalSampled: AlwaysOn +// - LocalNotSampled: AlwaysOff +func DefaultSampler() Sampler { + return ParentBased{ + Root: AlwaysOn{}, + RemoteSampled: AlwaysOn{}, + RemoteNotSampled: AlwaysOff{}, + LocalSampled: AlwaysOn{}, + LocalNotSampled: AlwaysOff{}, + } +} + +// NewSamplerFromEnv creates a Sampler based on the environment variables. +// If the environment variables are not set, it returns a nil Sampler. +func NewSamplerFromEnv(lookupEnv func(string) (string, bool)) (Sampler, error) { + samplerName, ok := lookupEnv(TracesSamplerKey) + if !ok { + return nil, nil + } + + defaultSampler := DefaultSampler().(ParentBased) + + samplerName = strings.ToLower(strings.TrimSpace(samplerName)) + samplerArg, hasSamplerArg := lookupEnv(TracesSamplerArgKey) + samplerArg = strings.TrimSpace(samplerArg) + + switch samplerName { + case SamplerNameAlwaysOn: + return AlwaysOn{}, nil + case SamplerNameAlwaysOff: + return AlwaysOff{}, nil + case SamplerNameTraceIDRatio: + if hasSamplerArg { + ratio, err := strconv.ParseFloat(samplerArg, 64) + if err != nil { + return nil, err + } + return TraceIDRatio{Fraction: ratio}, nil + } + return TraceIDRatio{Fraction: 1}, nil + case SamplerNameParentBasedAlwaysOn: + defaultSampler.Root = AlwaysOn{} + return defaultSampler, nil + case SamplerNameParsedBasedAlwaysOff: + defaultSampler.Root = AlwaysOff{} + return defaultSampler, nil + case SamplerNameParentBasedTraceIDRatio: + if !hasSamplerArg { + defaultSampler.Root = TraceIDRatio{Fraction: 1} + return defaultSampler, nil + } + ratio, err := strconv.ParseFloat(samplerArg, 64) + if err != nil { + return nil, err + } + defaultSampler.Root = TraceIDRatio{Fraction: ratio} + return defaultSampler, nil + default: + return nil, errors.New("unknown sampler name") + } +} + +// ConvertSamplerToConfig converts a Sampler its internal representation. +func ConvertSamplerToConfig(s Sampler) (*sampling.Config, error) { + if s == nil { + return nil, nil + } + if err := s.validate(); err != nil { + return nil, err + } + return s.convert() +} diff --git a/instrumentation.go b/instrumentation.go index 0a6d48168..399b5204e 100644 --- a/instrumentation.go +++ b/instrumentation.go @@ -214,7 +214,6 @@ type InstrumentationOption interface { } type instConfig struct { - sampler trace.Sampler traceExp trace.SpanExporter target process.TargetArgs serviceName string @@ -222,6 +221,7 @@ type instConfig struct { globalImpl bool loadIndicator chan struct{} logLevel LogLevel + sampler config.Sampler cp config.Provider } @@ -250,7 +250,7 @@ func newInstConfig(ctx context.Context, opts []InstrumentationOption) (instConfi } if c.sampler == nil { - c.sampler = trace.AlwaysSample() + c.sampler = config.DefaultSampler() } if c.logLevel == logLevelUndefined { @@ -258,7 +258,7 @@ func newInstConfig(ctx context.Context, opts []InstrumentationOption) (instConfi } if c.cp == nil { - c.cp = config.NewNoopProvider() + c.cp = config.NewNoopProvider(c.sampler) } return c, err @@ -285,7 +285,9 @@ func (c instConfig) validate() error { func (c instConfig) tracerProvider(bi *buildinfo.BuildInfo) *trace.TracerProvider { return trace.NewTracerProvider( - trace.WithSampler(c.sampler), + // the actual sampling is done in the eBPF probes. + // this is just to make sure that we export all spans we get from the probes + trace.WithSampler(trace.AlwaysSample()), trace.WithResource(c.res(bi)), trace.WithBatcher(c.traceExp), trace.WithIDGenerator(opentelemetry.NewEBPFSourceIDGenerator()), @@ -401,9 +403,11 @@ var lookupEnv = os.LookupEnv // - OTEL_TRACES_EXPORTER: sets the trace exporter // - OTEL_GO_AUTO_GLOBAL: enables the OpenTelemetry global implementation // - OTEL_LOG_LEVEL: sets the log level +// - OTEL_TRACES_SAMPLER: sets the trace sampler +// - OTEL_TRACES_SAMPLER_ARG: optionally sets the trace sampler argument // // This option may conflict with [WithTarget], [WithPID], [WithTraceExporter], -// [WithServiceName], [WithGlobal] and [WithLogLevel] if their respective environment variable is defined. +// [WithServiceName], [WithGlobal], [WithLogLevel] and [WithSampler] if their respective environment variable is defined. // If more than one of these options are used, the last one provided to an // [Instrumentation] will be used. // @@ -447,6 +451,11 @@ func WithEnv() InstrumentationOption { err = errors.Join(err, e) } + if s, e := config.NewSamplerFromEnv(lookupEnv); e != nil { + err = errors.Join(err, e) + } else { + c.sampler = s + } return c, err }) } @@ -500,7 +509,7 @@ func WithTraceExporter(exp trace.SpanExporter) InstrumentationOption { // WithSampler returns an [InstrumentationOption] that will configure // an [Instrumentation] to use the provided sampler to sample OpenTelemetry traces. -func WithSampler(sampler trace.Sampler) InstrumentationOption { +func WithSampler(sampler config.Sampler) InstrumentationOption { return fnOpt(func(_ context.Context, c instConfig) (instConfig, error) { c.sampler = sampler return c, nil diff --git a/instrumentation_test.go b/instrumentation_test.go index ccedf3487..0bc581ecb 100644 --- a/instrumentation_test.go +++ b/instrumentation_test.go @@ -13,6 +13,9 @@ import ( "go.opentelemetry.io/otel/attribute" semconv "go.opentelemetry.io/otel/semconv/v1.26.0" + + "go.opentelemetry.io/auto/config" + "go.opentelemetry.io/auto/internal/pkg/instrumentation/probe/sampling" ) func TestWithServiceName(t *testing.T) { @@ -198,6 +201,90 @@ func TestWithLogLevel(t *testing.T) { }) } +func TestWithSampler(t *testing.T) { + t.Run("Default sampler", func(t *testing.T) { + c, err := newInstConfig(context.Background(), []InstrumentationOption{}) + require.NoError(t, err) + sc, err := config.ConvertSamplerToConfig(c.sampler) + assert.NoError(t, err) + assert.Equal(t, sc.Samplers, sampling.DefaultConfig().Samplers) + assert.Equal(t, sc.ActiveSampler, sampling.ParentBasedID) + conf, ok := sc.Samplers[sampling.ParentBasedID] + assert.True(t, ok) + assert.Equal(t, conf.SamplerType, sampling.SamplerParentBased) + pbConfig, ok := conf.Config.(sampling.ParentBasedConfig) + assert.True(t, ok) + assert.Equal(t, pbConfig, sampling.DefaultParentBasedSampler()) + }) + + t.Run("Env config", func(t *testing.T) { + mockEnv(t, map[string]string{ + config.TracesSamplerKey: config.SamplerNameParentBasedTraceIDRatio, + config.TracesSamplerArgKey: "0.42", + }) + + c, err := newInstConfig(context.Background(), []InstrumentationOption{WithEnv()}) + require.NoError(t, err) + sc, err := config.ConvertSamplerToConfig(c.sampler) + assert.NoError(t, err) + assert.Equal(t, sc.ActiveSampler, sampling.ParentBasedID) + parentBasedConfig, ok := sc.Samplers[sampling.ParentBasedID] + assert.True(t, ok) + assert.Equal(t, parentBasedConfig.SamplerType, sampling.SamplerParentBased) + pbConfig, ok := parentBasedConfig.Config.(sampling.ParentBasedConfig) + assert.True(t, ok) + assert.Equal(t, pbConfig.Root, sampling.TraceIDRatioID) + tidRatio, ok := sc.Samplers[sampling.TraceIDRatioID] + assert.True(t, ok) + assert.Equal(t, tidRatio.SamplerType, sampling.SamplerTraceIDRatio) + config, ok := tidRatio.Config.(sampling.TraceIDRatioConfig) + assert.True(t, ok) + expected, _ := sampling.NewTraceIDRatioConfig(0.42) + assert.Equal(t, expected, config) + }) + + t.Run("Invalid Env config", func(t *testing.T) { + mockEnv(t, map[string]string{ + config.TracesSamplerKey: "invalid", + config.TracesSamplerArgKey: "0.42", + }) + + _, err := newInstConfig(context.Background(), []InstrumentationOption{WithEnv()}) + require.Error(t, err) + require.Contains(t, err.Error(), "unknown sampler name") + }) + + t.Run("WithSampler", func(t *testing.T) { + c, err := newInstConfig(context.Background(), []InstrumentationOption{ + WithSampler(config.ParentBased{ + Root: config.TraceIDRatio{Fraction: 0.42}, + }), + }) + require.NoError(t, err) + sc, err := config.ConvertSamplerToConfig(c.sampler) + assert.NoError(t, err) + assert.Equal(t, sc.ActiveSampler, sampling.ParentBasedID) + parentBasedConfig, ok := sc.Samplers[sampling.ParentBasedID] + assert.True(t, ok) + assert.Equal(t, parentBasedConfig.SamplerType, sampling.SamplerParentBased) + pbConfig, ok := parentBasedConfig.Config.(sampling.ParentBasedConfig) + assert.True(t, ok) + assert.Equal(t, pbConfig.Root, sampling.TraceIDRatioID) + assert.Equal(t, pbConfig.RemoteSampled, sampling.AlwaysOnID) + assert.Equal(t, pbConfig.RemoteNotSampled, sampling.AlwaysOffID) + assert.Equal(t, pbConfig.LocalSampled, sampling.AlwaysOnID) + assert.Equal(t, pbConfig.LocalNotSampled, sampling.AlwaysOffID) + + tidRatio, ok := sc.Samplers[sampling.TraceIDRatioID] + assert.True(t, ok) + assert.Equal(t, tidRatio.SamplerType, sampling.SamplerTraceIDRatio) + config, ok := tidRatio.Config.(sampling.TraceIDRatioConfig) + assert.True(t, ok) + expected, _ := sampling.NewTraceIDRatioConfig(0.42) + assert.Equal(t, expected, config) + }) +} + func mockEnv(t *testing.T, env map[string]string) { orig := lookupEnv t.Cleanup(func() { lookupEnv = orig }) diff --git a/internal/pkg/instrumentation/manager.go b/internal/pkg/instrumentation/manager.go index 62f09c97d..fe85095c1 100644 --- a/internal/pkg/instrumentation/manager.go +++ b/internal/pkg/instrumentation/manager.go @@ -211,7 +211,7 @@ func (m *Manager) applyConfig(c config.InstrumentationConfig) error { if !currentlyEnabled && newEnabled { m.logger.Info("Enabling probe", "id", id) - err = errors.Join(err, p.Load(m.exe, m.td)) + err = errors.Join(err, p.Load(m.exe, m.td, c.Sampler)) if err == nil { m.runProbe(p) } @@ -324,7 +324,7 @@ func (m *Manager) load(target *process.TargetDetails) error { for name, i := range m.probes { if isProbeEnabled(name, m.currentConfig) { m.logger.V(0).Info("loading probe", "name", name) - err := i.Load(exe, target) + err := i.Load(exe, target, m.currentConfig.Sampler) if err != nil { m.logger.Error(err, "error while loading probes, cleaning up", "name", name) return errors.Join(err, m.cleanup(target)) diff --git a/internal/pkg/instrumentation/manager_load_test.go b/internal/pkg/instrumentation/manager_load_test.go index 855d92ea8..e11948ace 100644 --- a/internal/pkg/instrumentation/manager_load_test.go +++ b/internal/pkg/instrumentation/manager_load_test.go @@ -50,7 +50,7 @@ func fakeManager(t *testing.T) *Manager { logger := stdr.New(log.New(os.Stderr, "", log.LstdFlags)) logger = logger.WithName("Instrumentation") - m, err := NewManager(logger, nil, true, nil, config.NewNoopProvider()) + m, err := NewManager(logger, nil, true, nil, config.NewNoopProvider(nil)) assert.NoError(t, err) assert.NotNil(t, m) diff --git a/internal/pkg/instrumentation/manager_test.go b/internal/pkg/instrumentation/manager_test.go index 219bea372..75687da31 100644 --- a/internal/pkg/instrumentation/manager_test.go +++ b/internal/pkg/instrumentation/manager_test.go @@ -190,7 +190,7 @@ func fakeManager(t *testing.T) *Manager { logger := stdr.New(log.New(os.Stderr, "", log.LstdFlags)) logger = logger.WithName("Instrumentation") - m, err := NewManager(logger, nil, true, nil, config.NewNoopProvider()) + m, err := NewManager(logger, nil, true, nil, config.NewNoopProvider(nil)) assert.NoError(t, err) assert.NotNil(t, m) @@ -242,7 +242,7 @@ func TestRunStopping(t *testing.T) { logger: logger.WithName("Manager"), probes: map[probe.ID]probe.Probe{{}: p}, eventCh: make(chan *probe.Event), - cp: config.NewNoopProvider(), + cp: config.NewNoopProvider(nil), } mockExeAndBpffs(t) @@ -290,7 +290,7 @@ func newSlowProbe(stop chan struct{}) slowProbe { } } -func (p slowProbe) Load(*link.Executable, *process.TargetDetails) error { +func (p slowProbe) Load(*link.Executable, *process.TargetDetails, config.Sampler) error { return nil } @@ -309,7 +309,7 @@ type noopProbe struct { var _ probe.Probe = (*noopProbe)(nil) -func (p *noopProbe) Load(*link.Executable, *process.TargetDetails) error { +func (p *noopProbe) Load(*link.Executable, *process.TargetDetails, config.Sampler) error { p.loaded = true return nil } diff --git a/internal/pkg/instrumentation/probe/probe.go b/internal/pkg/instrumentation/probe/probe.go index 4a10894f7..2126719ab 100644 --- a/internal/pkg/instrumentation/probe/probe.go +++ b/internal/pkg/instrumentation/probe/probe.go @@ -18,6 +18,7 @@ import ( "github.com/go-logr/logr" "github.com/hashicorp/go-version" + "go.opentelemetry.io/auto/config" "go.opentelemetry.io/auto/internal/pkg/inject" "go.opentelemetry.io/auto/internal/pkg/instrumentation/bpffs" "go.opentelemetry.io/auto/internal/pkg/instrumentation/utils" @@ -32,8 +33,11 @@ type Probe interface { // the information about the package the Probe instruments. Manifest() Manifest - // Load loads all instrumentation offsets. - Load(*link.Executable, *process.TargetDetails) error + // Load loads all the eBPF programs ans maps required by the Probe. + // It also attaches the eBPF programs to the target process. + // TODO: currently passing Sampler as an initial configuration - this will be + // updated to a more generic configuration in the future. + Load(*link.Executable, *process.TargetDetails, config.Sampler) error // Run runs the events processing loop. Run(eventsChan chan<- *Event) @@ -97,7 +101,7 @@ func (i *Base[BPFObj, BPFEvent]) Spec() (*ebpf.CollectionSpec, error) { } // Load loads all instrumentation offsets. -func (i *Base[BPFObj, BPFEvent]) Load(exec *link.Executable, td *process.TargetDetails) error { +func (i *Base[BPFObj, BPFEvent]) Load(exec *link.Executable, td *process.TargetDetails, sampler config.Sampler) error { spec, err := i.SpecFn() if err != nil { return err @@ -122,6 +126,11 @@ func (i *Base[BPFObj, BPFEvent]) Load(exec *link.Executable, td *process.TargetD if err != nil { return err } + + // TODO: Initialize sampling manager based on the sampling configuration and the eBPF collection. + // The manager will be responsible for writing to eBPF maps - configuring the sampling. + // In addition the sampling manager will be responsible for handling updates for the configuration. + i.closers = append(i.closers, i.reader) return nil diff --git a/internal/pkg/instrumentation/probe/sampling/sampling.go b/internal/pkg/instrumentation/probe/sampling/sampling.go new file mode 100644 index 000000000..6921b6597 --- /dev/null +++ b/internal/pkg/instrumentation/probe/sampling/sampling.go @@ -0,0 +1,110 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package sampling + +import "math" + +// SamplerType defines the type of a sampler. +type SamplerType uint64 + +// OpenTelemetry spec-defined samplers. +const ( + SamplerAlwaysOn SamplerType = iota + SamplerAlwaysOff + SamplerTraceIDRatio + SamplerParentBased + + // Custom samplers TODO. +) + +type TraceIDRatioConfig struct { + // samplingRateNumerator is the numerator of the sampling rate. + // see samplingRateDenominator for more information. + samplingRateNumerator uint64 +} + +func NewTraceIDRatioConfig(ratio float64) (TraceIDRatioConfig, error) { + numerator, err := floatToNumerator(ratio, samplingRateDenominator) + if err != nil { + return TraceIDRatioConfig{}, err + } + return TraceIDRatioConfig{numerator}, nil +} + +// SamplerID is a unique identifier for a sampler. It is used as a key in the samplers config map, +// and as a value in the active sampler map. In addition samplers can reference other samplers in their configuration by their ID. +type SamplerID uint32 + +// Config holds the configuration for the eBPF samplers. +type Config struct { + // Samplers is a map of sampler IDs to their configuration. + Samplers map[SamplerID]SamplerConfig + // ActiveSampler is the ID of the currently active sampler. + // The active sampler id must be one of the keys in the samplers map. + // Each sampler can reference other samplers in their configuration by their ID. + // When referencing another sampler, the ID must be one of the keys in the samplers map. + ActiveSampler SamplerID +} + +func DefaultConfig() *Config { + return &Config{ + Samplers: map[SamplerID]SamplerConfig{ + AlwaysOnID: { + SamplerType: SamplerAlwaysOn, + }, + AlwaysOffID: { + SamplerType: SamplerAlwaysOff, + }, + ParentBasedID: { + SamplerType: SamplerParentBased, + Config: DefaultParentBasedSampler(), + }, + }, + ActiveSampler: ParentBasedID, + } +} + +// ParentBasedConfig holds the configuration for the ParentBased sampler. +type ParentBasedConfig struct { + Root SamplerID + RemoteSampled SamplerID + RemoteNotSampled SamplerID + LocalSampled SamplerID + LocalNotSampled SamplerID +} + +// the following are constants which are used by the eBPF code. +// they should be kept in sync with the definitions there. +const ( + // since eBPF does not support floating point arithmetic, we use a rational number to represent the ratio. + // the denominator is fixed and the numerator is used to represent the ratio. + // This value can limit the precision of the sampling rate, hence setting it to a high value should be enough in terms of precision. + samplingRateDenominator = math.MaxUint32 +) + +// The spec-defined samplers have a constant ID, and are always available. +const ( + AlwaysOnID SamplerID = 0 + AlwaysOffID SamplerID = 1 + TraceIDRatioID SamplerID = 2 + ParentBasedID SamplerID = 3 +) + +// SamplerConfig holds the configuration for a specific sampler. data for samplers is a union of all possible sampler configurations. +// the size of the data is fixed, and the actual configuration is stored in the first part of the data. +// the rest of the data is padding to make sure the size is fixed. +type SamplerConfig struct { + SamplerType SamplerType + Config any +} + +func DefaultParentBasedSampler() ParentBasedConfig { + return ParentBasedConfig{ + Root: AlwaysOnID, + RemoteSampled: AlwaysOnID, + RemoteNotSampled: AlwaysOffID, + LocalSampled: AlwaysOnID, + LocalNotSampled: AlwaysOffID, + } +} diff --git a/internal/pkg/instrumentation/probe/sampling/utils.go b/internal/pkg/instrumentation/probe/sampling/utils.go new file mode 100644 index 000000000..5a413485d --- /dev/null +++ b/internal/pkg/instrumentation/probe/sampling/utils.go @@ -0,0 +1,31 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package sampling + +import ( + "fmt" +) + +var ( + errInvalidFraction = fmt.Errorf("fraction must be a positive float between 0 and 1") + errPrecisionLoss = fmt.Errorf("the given float cannot be represented as a fraction with the current precision") +) + +// floatToNumerator converts a float to a numerator of a fraction with the given denominator. +func floatToNumerator(f float64, maxDenominator uint64) (uint64, error) { + if f < 0 || f > 1 { + return 0, errInvalidFraction + } + if f == 0 { + return 0, nil + } + if f == 1 { + return maxDenominator, nil + } + x := uint64(f * float64(maxDenominator)) + if x == 0 { + return 0, errPrecisionLoss + } + return x, nil +} diff --git a/internal/pkg/instrumentation/probe/sampling/utils_test.go b/internal/pkg/instrumentation/probe/sampling/utils_test.go new file mode 100644 index 000000000..3e9bd8b47 --- /dev/null +++ b/internal/pkg/instrumentation/probe/sampling/utils_test.go @@ -0,0 +1,87 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package sampling + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestFloat64ToNumerator(t *testing.T) { + tests := []struct { + name string + f float64 + maxDenominator uint64 + expectedNumerator uint64 + expectedError error + }{ + { + name: "50 = 0.5 * 100", + f: 0.5, + maxDenominator: 100, + expectedNumerator: 50, + expectedError: nil, + }, + { + name: "invalid input", + f: 1.5, + maxDenominator: 100, + expectedNumerator: 0, + expectedError: errInvalidFraction, + }, + { + name: "1 = 0.01 * 100", + f: 0.01, + maxDenominator: 100, + expectedNumerator: 1, + expectedError: nil, + }, + { + name: "precision loss", + f: 0.00001, + maxDenominator: 100, + expectedError: errPrecisionLoss, + }, + { + name: "0 = 0 * 100", + f: 0, + maxDenominator: 100, + expectedNumerator: 0, + expectedError: nil, + }, + { + name: "100 = 1 * 100", + f: 1, + maxDenominator: 100, + expectedNumerator: 100, + expectedError: nil, + }, + { + name: "1 = 0.00001 * 100000", + f: 0.00001, + maxDenominator: 100000, + expectedNumerator: 1, + expectedError: nil, + }, + { + name: "99999 = 0.99999 * 100000", + f: 0.99999, + maxDenominator: 100000, + expectedNumerator: 99999, + expectedError: nil, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + numerator, err := floatToNumerator(test.f, test.maxDenominator) + if err != nil { + assert.ErrorIs(t, err, test.expectedError) + return + } + assert.Equal(t, test.expectedNumerator, numerator) + }) + } +}