Skip to content

Commit

Permalink
profiler: make configured tags read-only (#1383)
Browse files Browse the repository at this point in the history
* profiler: make configured tags read-only

After being bitten by a bug which was in part caused by implicitly
relying on append() creating a slice backed by new memory rather than
sharing the same allocation, we should regard the configured tags as
read-only. See PR #1377.

This commit adds a small immutable package with a StringSlice type which
wraps the slice and provides fresh copies on any access or modification,
and uses the package for the configured tags.

Co-authored-by: Felix Geisendörfer <felix@datadoghq.com>
  • Loading branch information
nsrip-dd and felixge committed Jul 21, 2022
1 parent e1c0bef commit 85c05b2
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 32 deletions.
33 changes: 33 additions & 0 deletions profiler/internal/immutable/stringslice.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016 Datadog, Inc.

// Package immutable provides read-only types
package immutable

// StringSlice holds a slice which cannot be modified and must be copied to
// access. The zero value for a StringSlice is an empty slice (not nil). All StringSlice
// methods are safe to call from multiple goroutines concurrently.
type StringSlice struct {
s []string
}

// NewStringSlice creates a StringSlice from a copy of the input slice
func NewStringSlice(s []string) StringSlice {
return StringSlice{s: append([]string{}, s...)}
}

// Slice returns a copy of the slice held by s
func (s StringSlice) Slice() []string {
return append([]string{}, s.s...)
}

// Append creates a new StringSlice by concatenating the given strings to a copy
// of the slice held by s.
func (s StringSlice) Append(strings ...string) StringSlice {
dup := make([]string, len(s.s)+len(strings))
copy(dup, s.s)
copy(dup[len(s.s):], strings)
return StringSlice{s: dup}
}
61 changes: 61 additions & 0 deletions profiler/internal/immutable/stringslice_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Unless explicitly stated otherwise all files in this repository are licensed
// under the Apache License Version 2.0.
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2016 Datadog, Inc.

package immutable_test

import (
"testing"

"github.com/stretchr/testify/assert"

"gopkg.in/DataDog/dd-trace-go.v1/profiler/internal/immutable"
)

func TestStringSlice(t *testing.T) {
tags := []string{"service:foo", "env:bar", "ggthingy:baz"}
f := immutable.NewStringSlice(tags)
assert.Equal(t, tags, f.Slice())
}

func TestStringSliceModify(t *testing.T) {
t.Run("modify-original", func(t *testing.T) {
tags := []string{"service:foo", "env:bar", "thingy:baz"}
f := immutable.NewStringSlice(tags)
tags[0] = "service:different"
assert.Equal(t, "service:foo", f.Slice()[0])
})

t.Run("modify-copy", func(t *testing.T) {
tags := []string{"service:foo", "env:bar", "thingy:baz"}
f := immutable.NewStringSlice(tags)
dup := f.Slice()
dup[0] = "service:different"
assert.Equal(t, "service:foo", tags[0])
})

t.Run("modify-2-copies", func(t *testing.T) {
tags := []string{"service:foo", "env:bar", "thingy:baz"}
f := immutable.NewStringSlice(tags)
dup := f.Slice()
dup[0] = "service:different"
dup2 := f.Slice()
dup2[0] = "service:alsodifferent"
assert.Equal(t, "service:foo", tags[0])
assert.Equal(t, "service:different", dup[0])
assert.Equal(t, "service:alsodifferent", dup2[0])
})

t.Run("append-duplicates", func(t *testing.T) {
var f immutable.StringSlice
before := f.Slice()
g := f.Append("foo:bar")
h := f.Append("other:tag")
after := g.Slice()
after2 := h.Slice()
assert.NotEqual(t, before, after)
assert.NotEqual(t, before, after2)
assert.NotEqual(t, after, after2)
})
}
9 changes: 5 additions & 4 deletions profiler/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"gopkg.in/DataDog/dd-trace-go.v1/internal/osinfo"
"gopkg.in/DataDog/dd-trace-go.v1/internal/version"
"gopkg.in/DataDog/dd-trace-go.v1/profiler/internal/extensions"
"gopkg.in/DataDog/dd-trace-go.v1/profiler/internal/immutable"

"github.com/DataDog/datadog-go/v5/statsd"
)
Expand Down Expand Up @@ -95,7 +96,7 @@ type config struct {
hostname string
statsd StatsdClient
httpClient *http.Client
tags []string
tags immutable.StringSlice
types map[ProfileType]struct{}
period time.Duration
cpuDuration time.Duration
Expand Down Expand Up @@ -150,7 +151,7 @@ func logStartup(c *config) {
Env: c.env,
TargetURL: c.targetURL,
Agentless: c.agentless,
Tags: c.tags,
Tags: c.tags.Slice(),
ProfilePeriod: c.period.String(),
CPUDuration: c.cpuDuration.String(),
CPUProfileRate: c.cpuProfileRate,
Expand Down Expand Up @@ -211,12 +212,12 @@ func defaultConfig() (*config, error) {
mutexFraction: DefaultMutexFraction,
uploadTimeout: DefaultUploadTimeout,
maxGoroutinesWait: 1000, // arbitrary value, should limit STW to ~30ms
tags: []string{fmt.Sprintf("process_id:%d", os.Getpid())},
deltaProfiles: internal.BoolEnv("DD_PROFILING_DELTA", true),
logStartup: internal.BoolEnv("DD_TRACE_STARTUP_LOGS", true),
cmemprofEnabled: false,
cmemprofRate: extensions.DefaultCAllocationSamplingRate,
}
c.tags = c.tags.Append(fmt.Sprintf("process_id:%d", os.Getpid()))
for _, t := range defaultProfileTypes {
c.addProfileType(t)
}
Expand Down Expand Up @@ -441,7 +442,7 @@ func WithVersion(version string) Option {
// filter the profiling view based on various information.
func WithTags(tags ...string) Option {
return func(cfg *config) {
cfg.tags = append(cfg.tags, tags...)
cfg.tags = cfg.tags.Append(tags...)
}
}

Expand Down
33 changes: 18 additions & 15 deletions profiler/options_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ func TestOptions(t *testing.T) {
t.Run("WithVersion", func(t *testing.T) {
var cfg config
WithVersion("1.2.3")(&cfg)
assert.Contains(t, cfg.tags, "version:1.2.3")
assert.Contains(t, cfg.tags.Slice(), "version:1.2.3")
})

t.Run("WithVersion/override", func(t *testing.T) {
Expand All @@ -189,15 +189,16 @@ func TestOptions(t *testing.T) {
cfg, err := defaultConfig()
require.NoError(t, err)
WithVersion("1.2.3")(cfg)
assert.Contains(t, cfg.tags, "version:1.2.3")
assert.Contains(t, cfg.tags.Slice(), "version:1.2.3")
})

t.Run("WithTags", func(t *testing.T) {
var cfg config
WithTags("a:1", "b:2", "c:3")(&cfg)
assert.Contains(t, cfg.tags, "a:1")
assert.Contains(t, cfg.tags, "b:2")
assert.Contains(t, cfg.tags, "c:3")
tags := cfg.tags.Slice()
assert.Contains(t, tags, "a:1")
assert.Contains(t, tags, "b:2")
assert.Contains(t, tags, "c:3")
})

t.Run("WithTags/override", func(t *testing.T) {
Expand All @@ -206,11 +207,12 @@ func TestOptions(t *testing.T) {
cfg, err := defaultConfig()
require.NoError(t, err)
WithTags("a:1", "b:2", "c:3")(cfg)
assert.Contains(t, cfg.tags, "a:1")
assert.Contains(t, cfg.tags, "b:2")
assert.Contains(t, cfg.tags, "c:3")
assert.Contains(t, cfg.tags, "env1:tag1")
assert.Contains(t, cfg.tags, "env2:tag2")
tags := cfg.tags.Slice()
assert.Contains(t, tags, "a:1")
assert.Contains(t, tags, "b:2")
assert.Contains(t, tags, "c:3")
assert.Contains(t, tags, "env1:tag1")
assert.Contains(t, tags, "env2:tag2")
})

t.Run("WithDeltaProfiles", func(t *testing.T) {
Expand Down Expand Up @@ -294,17 +296,18 @@ func TestEnvVars(t *testing.T) {
defer os.Unsetenv("DD_VERSION")
cfg, err := defaultConfig()
require.NoError(t, err)
assert.Contains(t, cfg.tags, "version:1.2.3")
assert.Contains(t, cfg.tags.Slice(), "version:1.2.3")
})

t.Run("DD_TAGS", func(t *testing.T) {
os.Setenv("DD_TAGS", "a:1,b:2,c:3")
defer os.Unsetenv("DD_TAGS")
cfg, err := defaultConfig()
require.NoError(t, err)
assert.Contains(t, cfg.tags, "a:1")
assert.Contains(t, cfg.tags, "b:2")
assert.Contains(t, cfg.tags, "c:3")
tags := cfg.tags.Slice()
assert.Contains(t, tags, "a:1")
assert.Contains(t, tags, "b:2")
assert.Contains(t, tags, "c:3")
})

t.Run("DD_PROFILING_DELTA", func(t *testing.T) {
Expand Down Expand Up @@ -338,7 +341,7 @@ func TestDefaultConfig(t *testing.T) {
assert.Equal(0, cfg.cpuProfileRate)
assert.Equal(DefaultMutexFraction, cfg.mutexFraction)
assert.Equal(DefaultBlockRate, cfg.blockRate)
assert.Contains(cfg.tags, "runtime-id:"+globalconfig.RuntimeID())
assert.Contains(cfg.tags.Slice(), "runtime-id:"+globalconfig.RuntimeID())
assert.Equal(true, cfg.deltaProfiles)
})
}
Expand Down
8 changes: 2 additions & 6 deletions profiler/profile.go
Original file line number Diff line number Diff line change
Expand Up @@ -195,9 +195,7 @@ func collectGenericProfile(name string, delta *pprofutils.Delta) func(p *profile

start := time.Now()
delta, err := p.deltaProfile(name, delta, data, extra...)
tags := make([]string, len(p.cfg.tags), len(p.cfg.tags)+1)
copy(tags, p.cfg.tags)
tags = append(tags, fmt.Sprintf("profile_type:%s", name))
tags := append(p.cfg.tags.Slice(), fmt.Sprintf("profile_type:%s", name))
p.cfg.statsd.Timing("datadog.profiling.go.delta_time", time.Since(start), tags, 1)
if err != nil {
return nil, fmt.Errorf("delta profile error: %s", err)
Expand Down Expand Up @@ -266,9 +264,7 @@ func (p *profiler) runProfile(pt ProfileType) ([]*profile, error) {
return nil, err
}
end := now()
tags := make([]string, len(p.cfg.tags), len(p.cfg.tags)+1)
copy(tags, p.cfg.tags)
tags = append(tags, pt.Tag())
tags := append(p.cfg.tags.Slice(), pt.Tag())
filename := t.Filename
// TODO(fg): Consider making Collect() return the filename.
if p.cfg.deltaProfiles && t.SupportsDelta {
Expand Down
7 changes: 3 additions & 4 deletions profiler/profiler.go
Original file line number Diff line number Diff line change
Expand Up @@ -242,9 +242,8 @@ func (p *profiler) collect(ticker <-chan time.Time) {
profs, err := p.runProfile(t)
if err != nil {
log.Error("Error getting %s profile: %v; skipping.", t, err)
tags := make([]string, len(p.cfg.tags), len(p.cfg.tags)+1)
copy(tags, p.cfg.tags)
p.cfg.statsd.Count("datadog.profiling.go.collect_error", 1, append(tags, t.Tag()), 1)
tags := append(p.cfg.tags.Slice(), t.Tag())
p.cfg.statsd.Count("datadog.profiling.go.collect_error", 1, tags, 1)
}
mu.Lock()
defer mu.Unlock()
Expand Down Expand Up @@ -297,7 +296,7 @@ func (p *profiler) enqueueUpload(bat batch) {
// queue is full; evict oldest
select {
case <-p.out:
p.cfg.statsd.Count("datadog.profiling.go.queue_full", 1, p.cfg.tags, 1)
p.cfg.statsd.Count("datadog.profiling.go.queue_full", 1, p.cfg.tags.Slice(), 1)
log.Warn("Evicting one profile batch from the upload queue to make room.")
default:
// this case should be almost impossible to trigger, it would require a
Expand Down
4 changes: 1 addition & 3 deletions profiler/upload.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,7 @@ func (e retriableError) Error() string { return e.err.Error() }
// doRequest makes an HTTP POST request to the Datadog Profiling API with the
// given profile.
func (p *profiler) doRequest(bat batch) error {
tags := make([]string, len(p.cfg.tags))
copy(tags, p.cfg.tags)
tags = append(tags,
tags := append(p.cfg.tags.Slice(),
fmt.Sprintf("service:%s", p.cfg.service),
fmt.Sprintf("env:%s", p.cfg.env),
// The profile_seq tag can be used to identify the first profile
Expand Down

0 comments on commit 85c05b2

Please sign in to comment.