├── go.sum ├── .gitignore ├── OSSMETADATA ├── go.mod ├── spectator ├── writer │ ├── noop_writer.go │ ├── stderr_writer.go │ ├── stdout_writer.go │ ├── memory_writer.go │ ├── file_writer.go │ ├── line_buffer.go │ ├── file_writer_test.go │ ├── udp_writer.go │ ├── line_buffer_test.go │ ├── writer.go │ ├── writer_test.go │ ├── unixgram_writer.go │ ├── unixgram_writer_test.go │ ├── lowlatency_buffer_test.go │ ├── udp_writer_test.go │ └── lowlatency_buffer.go ├── meter │ ├── monotonic_counter_test.go │ ├── monotonic_counter_uint_test.go │ ├── percentile_timer.go │ ├── timer.go │ ├── percentile_distsummary.go │ ├── max_gauge.go │ ├── dist_summary.go │ ├── counter_test.go │ ├── gauge.go │ ├── counter.go │ ├── age_gauge.go │ ├── monotonic_counter.go │ ├── monotonic_counter_uint.go │ ├── max_gauge_test.go │ ├── dist_summary_test.go │ ├── age_gauge_test.go │ ├── gauge_test.go │ ├── timer_test.go │ ├── percentile_distsummary_test.go │ ├── percentile_timer_test.go │ ├── id.go │ └── id_test.go ├── common_tags.go ├── protocol_parser.go ├── logger │ └── logger.go ├── common_tags_test.go ├── protocol_parser_test.go ├── config_test.go ├── config.go ├── registry.go └── registry_test.go ├── .golangci.yml ├── .github └── workflows │ ├── pr.yml │ ├── snapshot.yml │ └── release.yml ├── README.md ├── Makefile └── LICENSE /go.sum: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | CLAUDE.md 3 | -------------------------------------------------------------------------------- /OSSMETADATA: -------------------------------------------------------------------------------- 1 | osslifecycle=active 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Netflix/spectator-go/v2 2 | 3 | go 1.21 4 | -------------------------------------------------------------------------------- /spectator/writer/noop_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | // NoopWriter is a writer that does nothing. 4 | type NoopWriter struct{} 5 | 6 | func (n *NoopWriter) Write(_ string) {} 7 | 8 | func (n *NoopWriter) WriteBytes(_ []byte) {} 9 | 10 | func (n *NoopWriter) WriteString(_ string) {} 11 | 12 | func (n *NoopWriter) Close() error { 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | disable: 4 | - errcheck 5 | exclusions: 6 | generated: lax 7 | presets: 8 | - comments 9 | - common-false-positives 10 | - legacy 11 | - std-error-handling 12 | paths: 13 | - third_party$ 14 | - builtin$ 15 | - examples$ 16 | formatters: 17 | exclusions: 18 | generated: lax 19 | paths: 20 | - third_party$ 21 | - builtin$ 22 | - examples$ 23 | -------------------------------------------------------------------------------- /spectator/meter/monotonic_counter_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestMonotonicCounter_Set(t *testing.T) { 9 | w := writer.MemoryWriter{} 10 | id := NewId("set", nil) 11 | c := NewMonotonicCounter(id, &w) 12 | 13 | c.Set(4) 14 | 15 | expected := "C:set:4.000000" 16 | if w.Lines()[0] != expected { 17 | t.Error("Expected ", expected, " got ", w.Lines()[0]) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /spectator/meter/monotonic_counter_uint_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestMonotonicCounterUint_Set(t *testing.T) { 9 | w := writer.MemoryWriter{} 10 | id := NewId("set", nil) 11 | c := NewMonotonicCounterUint(id, &w) 12 | 13 | c.Set(4) 14 | 15 | expected := "U:set:4" 16 | if w.Lines()[0] != expected { 17 | t.Error("Expected ", expected, " got ", w.Lines()[0]) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /spectator/writer/stderr_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // StderrWriter is a writer that writes to stderr. 9 | type StderrWriter struct{} 10 | 11 | func (s *StderrWriter) Write(line string) { 12 | s.WriteString(line) 13 | } 14 | 15 | func (s *StderrWriter) WriteBytes(line []byte) { 16 | s.WriteString(string(line)) 17 | } 18 | 19 | func (s *StderrWriter) WriteString(line string) { 20 | _, _ = fmt.Fprintln(os.Stderr, line) 21 | } 22 | 23 | func (s *StderrWriter) Close() error { 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /spectator/writer/stdout_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | // StdoutWriter is a writer that writes to stdout. 9 | type StdoutWriter struct{} 10 | 11 | func (s *StdoutWriter) Write(line string) { 12 | s.WriteString(line) 13 | } 14 | 15 | func (s *StdoutWriter) WriteBytes(line []byte) { 16 | s.WriteString(string(line)) 17 | } 18 | 19 | func (s *StdoutWriter) WriteString(line string) { 20 | _, _ = fmt.Fprintln(os.Stdout, line) 21 | } 22 | 23 | func (s *StdoutWriter) Close() error { 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: PR Build 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | go-version: [1.23, 1.24] 11 | steps: 12 | - uses: actions/checkout@v4 13 | 14 | - name: Setup Go ${{ matrix.go-version }} 15 | uses: actions/setup-go@v5 16 | with: 17 | go-version: ${{ matrix.go-version }} 18 | 19 | - name: Install Dependencies 20 | run: | 21 | sudo make install 22 | 23 | - name: Build 24 | run: | 25 | make build 26 | 27 | - name: Test 28 | run: | 29 | make test 30 | 31 | - name: Lint 32 | run: | 33 | make lint 34 | -------------------------------------------------------------------------------- /.github/workflows/snapshot.yml: -------------------------------------------------------------------------------- 1 | name: Snapshot 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | if: ${{ github.repository == 'Netflix/spectator-go' }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | go-version: [1.23, 1.24] 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Setup Go ${{ matrix.go-version }} 19 | uses: actions/setup-go@v5 20 | with: 21 | go-version: ${{ matrix.go-version }} 22 | 23 | - name: Install Dependencies 24 | run: | 25 | sudo make install 26 | 27 | - name: Build 28 | run: | 29 | make build 30 | 31 | - name: Test 32 | run: | 33 | make test 34 | 35 | - name: Lint 36 | run: | 37 | make lint 38 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v[0-9]+.[0-9]+.[0-9]+ 7 | - v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+ 8 | 9 | jobs: 10 | build: 11 | if: ${{ github.repository == 'Netflix/spectator-go' }} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | go-version: [1.23, 1.24] 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Go ${{ matrix.go-version }} 20 | uses: actions/setup-go@v5 21 | with: 22 | go-version: ${{ matrix.go-version }} 23 | 24 | - name: Install Dependencies 25 | run: | 26 | sudo make install 27 | 28 | - name: Build 29 | run: | 30 | make build 31 | 32 | - name: Test 33 | run: | 34 | make test 35 | 36 | - name: Lint 37 | run: | 38 | make lint 39 | -------------------------------------------------------------------------------- /spectator/common_tags.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | ) 7 | 8 | func addNonEmpty(tags map[string]string, tag string, envVars ...string) { 9 | for _, envVar := range envVars { 10 | value, exists := os.LookupEnv(envVar) 11 | if !exists { 12 | continue 13 | } 14 | value = strings.TrimSpace(value) 15 | if len(value) != 0 { 16 | tags[tag] = value 17 | break 18 | } 19 | } 20 | } 21 | 22 | // tagsFromEnvVars Extract common infrastructure tags from the Netflix environment variables. 23 | // 24 | // The extracted variables are specific to a process and thus cannot be managed by a shared 25 | // SpectatorD instance. 26 | func tagsFromEnvVars() map[string]string { 27 | tags := make(map[string]string) 28 | addNonEmpty(tags, "nf.container", "TITUS_CONTAINER_NAME") 29 | addNonEmpty(tags, "nf.process", "NETFLIX_PROCESS_NAME") 30 | return tags 31 | } 32 | -------------------------------------------------------------------------------- /spectator/meter/percentile_timer.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | "time" 7 | ) 8 | 9 | // PercentileTimer represents timing events, while capturing the histogram 10 | // (percentiles) of those values. 11 | type PercentileTimer struct { 12 | id *Id 13 | writer writer.Writer 14 | meterTypeSymbol string 15 | } 16 | 17 | func NewPercentileTimer( 18 | id *Id, 19 | writer writer.Writer, 20 | ) *PercentileTimer { 21 | return &PercentileTimer{id, writer, "T"} 22 | } 23 | 24 | func (t *PercentileTimer) MeterId() *Id { 25 | return t.id 26 | } 27 | 28 | // Record records the value for a single event. 29 | func (t *PercentileTimer) Record(amount time.Duration) { 30 | if amount >= 0 { 31 | var line = fmt.Sprintf("%s:%s:%f", t.meterTypeSymbol, t.id.spectatordId, amount.Seconds()) 32 | t.writer.Write(line) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /spectator/writer/memory_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "slices" 5 | "sync" 6 | ) 7 | 8 | // MemoryWriter stores lines in memory in an array, so updates can be inspected for test validation. 9 | type MemoryWriter struct { 10 | lines []string 11 | mu sync.RWMutex 12 | } 13 | 14 | func (m *MemoryWriter) Write(line string) { 15 | m.WriteString(line) 16 | } 17 | 18 | func (m *MemoryWriter) WriteBytes(line []byte) { 19 | m.WriteString(string(line)) 20 | } 21 | 22 | func (m *MemoryWriter) WriteString(line string) { 23 | m.mu.Lock() 24 | defer m.mu.Unlock() 25 | m.lines = append(m.lines, line) 26 | } 27 | 28 | func (m *MemoryWriter) Lines() []string { 29 | m.mu.RLock() 30 | defer m.mu.RUnlock() 31 | return slices.Clone(m.lines) 32 | } 33 | 34 | func (m *MemoryWriter) Reset() { 35 | m.mu.Lock() 36 | defer m.mu.Unlock() 37 | m.lines = []string{} 38 | } 39 | 40 | func (m *MemoryWriter) Close() error { 41 | return nil 42 | } 43 | -------------------------------------------------------------------------------- /spectator/protocol_parser.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/meter" 6 | "strings" 7 | ) 8 | 9 | // ParseProtocolLine parses a line of the spectator protocol. Utility exposed for testing. 10 | func ParseProtocolLine(line string) (string, *meter.Id, string, error) { 11 | parts := strings.Split(line, ":") 12 | if len(parts) != 3 { 13 | return "", nil, "", fmt.Errorf("invalid line format") 14 | } 15 | 16 | meterSymbol := parts[0] 17 | meterId := parts[1] 18 | value := parts[2] 19 | 20 | meterIdParts := strings.Split(meterId, ",") 21 | name := meterIdParts[0] 22 | 23 | tags := make(map[string]string) 24 | for _, tag := range meterIdParts[1:] { 25 | kv := strings.Split(tag, "=") 26 | if len(kv) != 2 { 27 | return "", nil, "", fmt.Errorf("invalid tag format") 28 | } 29 | tags[kv[0]] = kv[1] 30 | } 31 | 32 | return meterSymbol, meter.NewId(name, tags), value, nil 33 | } 34 | -------------------------------------------------------------------------------- /spectator/meter/timer.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | "time" 7 | ) 8 | 9 | // Timer is used to measure how long (in seconds) some event is taking. This 10 | // type is safe for concurrent use. 11 | type Timer struct { 12 | id *Id 13 | writer writer.Writer 14 | meterTypeSymbol string 15 | } 16 | 17 | // NewTimer generates a new timer, using the provided meter identifier. 18 | func NewTimer(id *Id, writer writer.Writer) *Timer { 19 | return &Timer{id, writer, "t"} 20 | } 21 | 22 | // MeterId returns the meter identifier. 23 | func (t *Timer) MeterId() *Id { 24 | return t.id 25 | } 26 | 27 | // Record records the duration this specific event took. 28 | func (t *Timer) Record(amount time.Duration) { 29 | if amount >= 0 { 30 | var line = fmt.Sprintf("%s:%s:%f", t.meterTypeSymbol, t.id.spectatordId, amount.Seconds()) 31 | t.writer.Write(line) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /spectator/writer/file_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "os" 7 | ) 8 | 9 | type FileWriter struct { 10 | file *os.File 11 | logger logger.Logger 12 | } 13 | 14 | func NewFileWriter(filename string, logger logger.Logger) (*FileWriter, error) { 15 | file, err := os.OpenFile(filename, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) 16 | if err != nil { 17 | return nil, err 18 | } 19 | return &FileWriter{file, logger}, nil 20 | } 21 | 22 | func (f *FileWriter) Write(line string) { 23 | f.logger.Debugf("Sending line: %s", line) 24 | f.WriteString(line) 25 | } 26 | 27 | func (f *FileWriter) WriteBytes(line []byte) { 28 | f.WriteString(string(line)) 29 | } 30 | 31 | func (f *FileWriter) WriteString(line string) { 32 | _, err := fmt.Fprintln(f.file, line) 33 | if err != nil { 34 | f.logger.Errorf("Error writing to file: %s", err) 35 | } 36 | } 37 | 38 | func (f *FileWriter) Close() error { 39 | return f.file.Close() 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Snapshot](https://github.com/Netflix/spectator-go/actions/workflows/snapshot.yml/badge.svg)](https://github.com/Netflix/spectator-go/actions/workflows/snapshot.yml) 2 | [![Release](https://github.com/Netflix/spectator-go/actions/workflows/release.yml/badge.svg)](https://github.com/Netflix/spectator-go/actions/workflows/release.yml) 3 | [![Go Reference](https://pkg.go.dev/badge/github.com/Netflix/spectator-go.svg)](https://pkg.go.dev/github.com/Netflix/spectator-go/v2) 4 | 5 | ## Spectator-go 6 | 7 | Go thin-client metrics library for use with [Atlas] and [SpectatorD]. 8 | 9 | See the [Atlas Documentation] site for more details on `spectator-go`. 10 | 11 | [Atlas]: https://netflix.github.io/atlas-docs/overview/ 12 | [SpectatorD]: https://netflix.github.io/atlas-docs/spectator/agent/usage/ 13 | [Atlas Documentation]: https://netflix.github.io/atlas-docs/spectator/lang/go/usage/ 14 | 15 | ## Local Development 16 | 17 | Install a recent version of Go, possibly with [Homebrew](https://brew.sh/). 18 | 19 | ```shell 20 | make test 21 | make test/cover 22 | ``` 23 | -------------------------------------------------------------------------------- /spectator/meter/percentile_distsummary.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // PercentileDistributionSummary is a distribution summary used to track the 9 | // distribution of events, while also presenting the results as percentiles. 10 | type PercentileDistributionSummary struct { 11 | id *Id 12 | writer writer.Writer 13 | meterTypeSymbol string 14 | } 15 | 16 | func (p *PercentileDistributionSummary) MeterId() *Id { 17 | return p.id 18 | } 19 | 20 | // NewPercentileDistributionSummary creates a new *PercentileDistributionSummary using the meter identifier. 21 | func NewPercentileDistributionSummary(id *Id, writer writer.Writer) *PercentileDistributionSummary { 22 | return &PercentileDistributionSummary{id, writer, "D"} 23 | } 24 | 25 | // Record records an amount to track within the distribution. 26 | func (p *PercentileDistributionSummary) Record(amount int64) { 27 | if amount >= 0 { 28 | var line = fmt.Sprintf("%s:%s:%d", p.meterTypeSymbol, p.id.spectatordId, amount) 29 | p.writer.Write(line) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /spectator/logger/logger.go: -------------------------------------------------------------------------------- 1 | package logger 2 | 3 | import ( 4 | "fmt" 5 | "log/slog" 6 | ) 7 | 8 | // Logger represents the shape of the logging dependency that the spectator 9 | // library expects. 10 | type Logger interface { 11 | Debugf(format string, v ...interface{}) 12 | Infof(format string, v ...interface{}) 13 | Errorf(format string, v ...interface{}) 14 | } 15 | 16 | // DefaultLogger is a plain text stdout logger. 17 | type DefaultLogger struct { 18 | } 19 | 20 | func NewDefaultLogger() *DefaultLogger { 21 | return &DefaultLogger{} 22 | } 23 | 24 | // Debugf is for debug level messages. Satisfies Logger interface. 25 | func (l *DefaultLogger) Debugf(format string, v ...interface{}) { 26 | slog.Debug(fmt.Sprintf(format, v...)) 27 | } 28 | 29 | // Infof is for info level messages. Satisfies Logger interface. 30 | func (l *DefaultLogger) Infof(format string, v ...interface{}) { 31 | slog.Info(fmt.Sprintf(format, v...)) 32 | } 33 | 34 | // Errorf is for error level messages. Satisfies Logger interface. 35 | func (l *DefaultLogger) Errorf(format string, v ...interface{}) { 36 | slog.Error(fmt.Sprintf(format, v...)) 37 | } 38 | -------------------------------------------------------------------------------- /spectator/meter/max_gauge.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // MaxGauge represents a value that is sampled at a specific point in time. One 9 | // example might be the pending messages in a queue. This type is safe for 10 | // concurrent use. 11 | // 12 | // You can find more about this type by viewing the relevant Java Spectator 13 | // documentation here: 14 | // 15 | // https://netflix.github.io/spectator/en/latest/intro/gauge/ 16 | type MaxGauge struct { 17 | id *Id 18 | writer writer.Writer 19 | meterTypeSymbol string 20 | } 21 | 22 | // NewMaxGauge generates a new gauge, using the provided meter identifier. 23 | func NewMaxGauge(id *Id, writer writer.Writer) *MaxGauge { 24 | return &MaxGauge{id, writer, "m"} 25 | } 26 | 27 | // MeterId returns the meter identifier. 28 | func (g *MaxGauge) MeterId() *Id { 29 | return g.id 30 | } 31 | 32 | // Set records the current value. 33 | func (g *MaxGauge) Set(value float64) { 34 | var line = fmt.Sprintf("%s:%s:%f", g.meterTypeSymbol, g.id.spectatordId, value) 35 | g.writer.Write(line) 36 | } 37 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # https://gist.github.com/alexedwards/3b40775846535d0014ab1ff477e4a568 2 | 3 | ## help: print this help message 4 | .PHONY: help 5 | help: 6 | @echo 'Usage:' 7 | @sed -n 's/^##//p' ${MAKEFILE_LIST} | column -t -s ':' | sed -e 's/^/ /' 8 | 9 | ## tidy: format code and tidy modfile 10 | .PHONY: tidy 11 | tidy: 12 | go fmt ./... 13 | go mod tidy -v 14 | 15 | ## build: build the project 16 | .PHONY: build 17 | build: 18 | go build ./... 19 | 20 | ## test: run all tests 21 | .PHONY: test 22 | test: 23 | go test -race -v ./... 24 | 25 | ## test/cover: run all tests and display coverage 26 | .PHONY: test/cover 27 | test/cover: 28 | go test -v -race -buildvcs -coverprofile=/tmp/coverage.out ./... 29 | go tool cover -html=/tmp/coverage.out 30 | 31 | ## lint: run golangci-lint 32 | .PHONY: lint 33 | lint: 34 | golangci-lint run 35 | 36 | ## bench: run go benchmarks 37 | .PHONY: bench 38 | bench: 39 | go test -bench=. ./spectator/meter 40 | 41 | ## install: install golangci-lint and check versions 42 | .PHONY: install 43 | install: 44 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b $(go env GOPATH)/bin v2.2.1 45 | go version 46 | golangci-lint --version 47 | -------------------------------------------------------------------------------- /spectator/meter/dist_summary.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // DistributionSummary is used to track the distribution of events. This is safe 9 | // for concurrent use. 10 | // 11 | // You can find more about this type by viewing the relevant Java Spectator 12 | // documentation here: 13 | // 14 | // https://netflix.github.io/spectator/en/latest/intro/dist-summary/ 15 | type DistributionSummary struct { 16 | id *Id 17 | writer writer.Writer 18 | meterTypeSymbol string 19 | } 20 | 21 | // NewDistributionSummary generates a new distribution summary, using the 22 | // provided meter identifier. 23 | func NewDistributionSummary(id *Id, writer writer.Writer) *DistributionSummary { 24 | return &DistributionSummary{id, writer, "d"} 25 | } 26 | 27 | // MeterId returns the meter identifier. 28 | func (d *DistributionSummary) MeterId() *Id { 29 | return d.id 30 | } 31 | 32 | // Record records a value to track within the distribution. 33 | func (d *DistributionSummary) Record(amount int64) { 34 | if amount >= 0 { 35 | var line = fmt.Sprintf("%s:%s:%d", d.meterTypeSymbol, d.id.spectatordId, amount) 36 | d.writer.Write(line) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /spectator/meter/counter_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestCounter_Increment(t *testing.T) { 9 | w := writer.MemoryWriter{} 10 | id := NewId("inc", nil) 11 | c := NewCounter(id, &w) 12 | 13 | c.Increment() 14 | 15 | expected := "c:inc:1" 16 | if w.Lines()[0] != expected { 17 | t.Error("Expected ", expected, " got ", w.Lines()[0]) 18 | } 19 | } 20 | 21 | func TestCounter_Add(t *testing.T) { 22 | w := writer.MemoryWriter{} 23 | id := NewId("add", nil) 24 | c := NewCounter(id, &w) 25 | 26 | c.Add(4) 27 | 28 | expected := "c:add:4" 29 | if w.Lines()[0] != expected { 30 | t.Error("Expected ", expected, " got ", w.Lines()[0]) 31 | } 32 | 33 | c.Add(-1) 34 | if len(w.Lines()) != 1 { 35 | t.Error("Negative deltas should be ignored") 36 | } 37 | } 38 | 39 | func TestCounter_AddFloat(t *testing.T) { 40 | w := writer.MemoryWriter{} 41 | id := NewId("addFloat", nil) 42 | c := NewCounter(id, &w) 43 | 44 | c.AddFloat(4.2) 45 | 46 | expected := "c:addFloat:4.200000" 47 | if w.Lines()[0] != expected { 48 | t.Error("Expected ", expected, " got ", w.Lines()[0]) 49 | } 50 | 51 | c.AddFloat(-0.1) 52 | if len(w.Lines()) != 1 { 53 | t.Error("Negative deltas should be ignored") 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /spectator/meter/gauge.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | "time" 7 | ) 8 | 9 | // Gauge represents a value that is sampled at a specific point in time. One 10 | // example might be the pending messages in a queue. This type is safe for 11 | // concurrent use. 12 | // 13 | // You can find more about this type by viewing the relevant Java Spectator 14 | // documentation here: 15 | // 16 | // https://netflix.github.io/spectator/en/latest/intro/gauge/ 17 | type Gauge struct { 18 | id *Id 19 | writer writer.Writer 20 | meterTypeSymbol string 21 | } 22 | 23 | // NewGauge generates a new gauge, using the provided meter identifier. 24 | func NewGauge(id *Id, writer writer.Writer) *Gauge { 25 | return &Gauge{id, writer, "g"} 26 | } 27 | 28 | // NewGaugeWithTTL generates a new gauge, using the provided meter identifier and ttl. 29 | func NewGaugeWithTTL(id *Id, writer writer.Writer, ttl time.Duration) *Gauge { 30 | return &Gauge{id, writer, fmt.Sprintf("g,%d", int(ttl.Seconds()))} 31 | } 32 | 33 | // MeterId returns the meter identifier. 34 | func (g *Gauge) MeterId() *Id { 35 | return g.id 36 | } 37 | 38 | // Set records the current value. 39 | func (g *Gauge) Set(value float64) { 40 | var line = fmt.Sprintf("%s:%s:%f", g.meterTypeSymbol, g.id.spectatordId, value) 41 | g.writer.Write(line) 42 | } 43 | -------------------------------------------------------------------------------- /spectator/meter/counter.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // Counter is used to measure the rate at which some event is occurring. This 9 | // type is safe for concurrent use. 10 | // 11 | // You can find more about this type by viewing the relevant Java Spectator 12 | // documentation here: 13 | // 14 | // https://netflix.github.io/spectator/en/latest/intro/counter/ 15 | type Counter struct { 16 | id *Id 17 | writer writer.Writer 18 | meterTypeSymbol string 19 | } 20 | 21 | // NewCounter generates a new counter, using the provided meter identifier. 22 | func NewCounter(id *Id, writer writer.Writer) *Counter { 23 | return &Counter{id, writer, "c"} 24 | } 25 | 26 | // MeterId returns the meter identifier. 27 | func (c *Counter) MeterId() *Id { 28 | return c.id 29 | } 30 | 31 | // Increment increments the counter. 32 | func (c *Counter) Increment() { 33 | var line = fmt.Sprintf("%s:%s:%d", c.meterTypeSymbol, c.id.spectatordId, 1) 34 | c.writer.Write(line) 35 | } 36 | 37 | // Add adds an int64 delta to the current measurement. 38 | func (c *Counter) Add(delta int64) { 39 | if delta > 0 { 40 | var line = fmt.Sprintf("%s:%s:%d", c.meterTypeSymbol, c.id.spectatordId, delta) 41 | c.writer.Write(line) 42 | } 43 | } 44 | 45 | // AddFloat adds a float64 delta to the current measurement. 46 | func (c *Counter) AddFloat(delta float64) { 47 | if delta > 0.0 { 48 | var line = fmt.Sprintf("%s:%s:%f", c.meterTypeSymbol, c.id.spectatordId, delta) 49 | c.writer.Write(line) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /spectator/meter/age_gauge.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // AgeGauge represents a value that is the time in seconds since the epoch at which an event 9 | // has successfully occurred, or 0 to use the current time in epoch seconds. After an Age Gauge 10 | // has been set, it will continue reporting the number of seconds since the last time recorded, 11 | // for as long as the spectatord process runs. The purpose of this metric type is to enable users 12 | // to more easily implement the Time Since Last Success alerting pattern. 13 | // 14 | // To set `now()` as the last success, set a value of 0. 15 | type AgeGauge struct { 16 | id *Id 17 | writer writer.Writer 18 | meterTypeSymbol string 19 | } 20 | 21 | // NewAgeGauge generates a new gauge, using the provided meter identifier. 22 | func NewAgeGauge(id *Id, writer writer.Writer) *AgeGauge { 23 | return &AgeGauge{id, writer, "A"} 24 | } 25 | 26 | // MeterId returns the meter identifier. 27 | func (g *AgeGauge) MeterId() *Id { 28 | return g.id 29 | } 30 | 31 | // Set records the current time in seconds since the epoch. 32 | func (g *AgeGauge) Set(seconds int64) { 33 | if seconds >= 0 { 34 | var line = fmt.Sprintf("%s:%s:%d", g.meterTypeSymbol, g.id.spectatordId, seconds) 35 | g.writer.Write(line) 36 | } 37 | } 38 | 39 | // Now records the current time in epoch seconds, using a spectatord feature. 40 | func (g *AgeGauge) Now() { 41 | var line = fmt.Sprintf("%s:%s:0", g.meterTypeSymbol, g.id.spectatordId) 42 | g.writer.Write(line) 43 | } 44 | -------------------------------------------------------------------------------- /spectator/meter/monotonic_counter.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // MonotonicCounter is used to measure the rate at which some event is occurring. This 9 | // type is safe for concurrent use. 10 | // 11 | // The value is a monotonically increasing number. A minimum of two samples must be received 12 | // in order for spectatord to calculate a delta value and report it to the backend. 13 | // 14 | // This version of the monotonic counter is intended to support use cases where a data source value 15 | // needs to be transformed into base units through division (e.g. nanoseconds into seconds), and 16 | // thus, the data type is float64. 17 | // 18 | // A variety of networking metrics may be reported monotonically and this metric type provides a 19 | // convenient means of recording these values, at the expense of a slower time-to-first metric. 20 | type MonotonicCounter struct { 21 | id *Id 22 | writer writer.Writer 23 | meterTypeSymbol string 24 | } 25 | 26 | // NewMonotonicCounter generates a new counter, using the provided meter identifier. 27 | func NewMonotonicCounter(id *Id, writer writer.Writer) *MonotonicCounter { 28 | return &MonotonicCounter{id, writer, "C"} 29 | } 30 | 31 | // MeterId returns the meter identifier. 32 | func (c *MonotonicCounter) MeterId() *Id { 33 | return c.id 34 | } 35 | 36 | // Set sets a value as the current measurement; spectatord calculates the delta. 37 | func (c *MonotonicCounter) Set(value float64) { 38 | var line = fmt.Sprintf("%s:%s:%f", c.meterTypeSymbol, c.id.spectatordId, value) 39 | c.writer.Write(line) 40 | } 41 | -------------------------------------------------------------------------------- /spectator/common_tags_test.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "os" 5 | "testing" 6 | ) 7 | 8 | func TestAddNonEmptyWithExistingEnvVar(t *testing.T) { 9 | _ = os.Setenv("EXISTING_ENV_VAR", "test_value") 10 | defer os.Unsetenv("EXISTING_ENV_VAR") 11 | 12 | tags := make(map[string]string) 13 | addNonEmpty(tags, "test_tag", "EXISTING_ENV_VAR") 14 | 15 | if tags["test_tag"] != "test_value" { 16 | t.Errorf("Expected 'test_value', got '%s'", tags["test_tag"]) 17 | } 18 | } 19 | 20 | func TestAddNonEmptyWithNonExistingEnvVar(t *testing.T) { 21 | tags := make(map[string]string) 22 | addNonEmpty(tags, "test_tag", "NON_EXISTING_ENV_VAR") 23 | 24 | if _, ok := tags["test_tag"]; ok { 25 | t.Errorf("Expected tag not to be set") 26 | } 27 | } 28 | 29 | func TestAddNonEmptyWithEmptyEnvVar(t *testing.T) { 30 | _ = os.Setenv("EMPTY_ENV_VAR", "") 31 | defer os.Unsetenv("EMPTY_ENV_VAR") 32 | 33 | tags := make(map[string]string) 34 | addNonEmpty(tags, "test_tag", "EMPTY_ENV_VAR") 35 | 36 | if _, ok := tags["test_tag"]; ok { 37 | t.Errorf("Expected tag not to be set") 38 | } 39 | } 40 | 41 | func TestTagsFromEnvVars(t *testing.T) { 42 | _ = os.Setenv("TITUS_CONTAINER_NAME", "container_name") 43 | _ = os.Setenv("NETFLIX_PROCESS_NAME", "process_name") 44 | defer os.Unsetenv("TITUS_CONTAINER_NAME") 45 | defer os.Unsetenv("NETFLIX_PROCESS_NAME") 46 | 47 | tags := tagsFromEnvVars() 48 | 49 | if tags["nf.container"] != "container_name" { 50 | t.Errorf("Expected 'container_name', got '%s'", tags["nf.container"]) 51 | } 52 | 53 | if tags["nf.process"] != "process_name" { 54 | t.Errorf("Expected 'process_name', got '%s'", tags["nf.process"]) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /spectator/meter/monotonic_counter_uint.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | ) 7 | 8 | // MonotonicCounterUint is used to measure the rate at which some event is occurring. This 9 | // type is safe for concurrent use. 10 | // 11 | // The value is a monotonically increasing number. A minimum of two samples must be received 12 | // in order for spectatord to calculate a delta value and report it to the backend. 13 | // 14 | // This version of the monotonic counter is intended to support use cases where a data source value 15 | // can be sampled as-is, because it is already in base units, such as bytes, and thus, the data type 16 | // is uint64. 17 | // 18 | // A variety of networking metrics may be reported monotonically and this metric type provides a 19 | // convenient means of recording these values, at the expense of a slower time-to-first metric. 20 | type MonotonicCounterUint struct { 21 | id *Id 22 | writer writer.Writer 23 | meterTypeSymbol string 24 | } 25 | 26 | // NewMonotonicCounterUint generates a new counter, using the provided meter identifier. 27 | func NewMonotonicCounterUint(id *Id, writer writer.Writer) *MonotonicCounterUint { 28 | return &MonotonicCounterUint{id, writer, "U"} 29 | } 30 | 31 | // MeterId returns the meter identifier. 32 | func (c *MonotonicCounterUint) MeterId() *Id { 33 | return c.id 34 | } 35 | 36 | // Set sets a value as the current measurement; spectatord calculates the delta. 37 | func (c *MonotonicCounterUint) Set(value uint64) { 38 | var line = fmt.Sprintf("%s:%s:%d", c.meterTypeSymbol, c.id.spectatordId, value) 39 | c.writer.Write(line) 40 | } 41 | -------------------------------------------------------------------------------- /spectator/meter/max_gauge_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestMaxGauge_Set(t *testing.T) { 9 | id := NewId("setMaxGauge", nil) 10 | w := writer.MemoryWriter{} 11 | g := NewMaxGauge(id, &w) 12 | g.Set(100.1) 13 | 14 | expected := "m:setMaxGauge:100.100000" 15 | if w.Lines()[0] != expected { 16 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 17 | } 18 | } 19 | 20 | func TestMaxGauge_SetZero(t *testing.T) { 21 | id := NewId("setMaxGaugeZero", nil) 22 | w := writer.MemoryWriter{} 23 | g := NewMaxGauge(id, &w) 24 | g.Set(0) 25 | 26 | expected := "m:setMaxGaugeZero:0.000000" 27 | if w.Lines()[0] != expected { 28 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 29 | } 30 | } 31 | 32 | func TestMaxGauge_SetNegative(t *testing.T) { 33 | id := NewId("setMaxGaugeNegative", nil) 34 | w := writer.MemoryWriter{} 35 | g := NewMaxGauge(id, &w) 36 | g.Set(-100.1) 37 | 38 | expected := "m:setMaxGaugeNegative:-100.100000" 39 | if w.Lines()[0] != expected { 40 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 41 | } 42 | } 43 | 44 | func TestMaxGauge_SetMultipleValues(t *testing.T) { 45 | id := NewId("setMaxGaugeMultiple", nil) 46 | w := writer.MemoryWriter{} 47 | g := NewMaxGauge(id, &w) 48 | g.Set(100.1) 49 | g.Set(200.2) 50 | g.Set(300.3) 51 | 52 | expectedLines := []string{"m:setMaxGaugeMultiple:100.100000", "m:setMaxGaugeMultiple:200.200000", "m:setMaxGaugeMultiple:300.300000"} 53 | for i, line := range w.Lines() { 54 | if line != expectedLines[i] { 55 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /spectator/protocol_parser_test.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestParseProtocolLineWithValidInput(t *testing.T) { 8 | line := "c:meterId,tag1=value1,tag2=value2:value" 9 | meterType, meterId, value, err := ParseProtocolLine(line) 10 | 11 | if err != nil { 12 | t.Errorf("Unexpected error: %v", err) 13 | } 14 | 15 | if meterType != "c" { 16 | t.Errorf("Expected 'c', got '%s'", meterType) 17 | } 18 | 19 | if meterId.Name() != "meterId" || meterId.Tags()["tag1"] != "value1" || meterId.Tags()["tag2"] != "value2" { 20 | t.Errorf("Unexpected meterId: %v", meterId) 21 | } 22 | 23 | if value != "value" { 24 | t.Errorf("Expected 'value', got '%s'", value) 25 | } 26 | } 27 | 28 | func TestParseProtocolLineWithInvalidFormat(t *testing.T) { 29 | line := "invalid_format_line" 30 | _, _, _, err := ParseProtocolLine(line) 31 | 32 | if err == nil { 33 | t.Errorf("Expected error, got nil") 34 | } 35 | } 36 | 37 | func TestParseProtocolLineWithInvalidTagFormat(t *testing.T) { 38 | line := "c:meterId=value=value2,tag1=value1,tag2:value" 39 | _, _, _, err := ParseProtocolLine(line) 40 | 41 | if err == nil { 42 | t.Errorf("Expected error, got nil") 43 | } 44 | } 45 | 46 | func TestParseGaugeWithTTL(t *testing.T) { 47 | line := "g,120:test:1" 48 | meterSymbol, meterId, value, err := ParseProtocolLine(line) 49 | 50 | if err != nil { 51 | t.Errorf("Unexpected error: %v", err) 52 | } 53 | 54 | if meterSymbol != "g,120" { 55 | t.Errorf("Expected 'g', got '%s'", meterSymbol) 56 | } 57 | 58 | if meterId.Name() != "test" { 59 | t.Errorf("Unexpected meterId: %v", meterId) 60 | } 61 | 62 | if value != "1" { 63 | t.Errorf("Expected '1', got '%s'", value) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /spectator/meter/dist_summary_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestDistributionSummary_RecordPositiveValue(t *testing.T) { 9 | id := NewId("recordPositive", nil) 10 | w := writer.MemoryWriter{} 11 | ds := NewDistributionSummary(id, &w) 12 | ds.Record(100) 13 | 14 | expected := "d:recordPositive:100" 15 | if w.Lines()[0] != expected { 16 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 17 | } 18 | } 19 | 20 | func TestDistributionSummary_RecordZeroValue(t *testing.T) { 21 | id := NewId("recordZero", nil) 22 | w := writer.MemoryWriter{} 23 | ds := NewDistributionSummary(id, &w) 24 | ds.Record(0) 25 | 26 | expected := "d:recordZero:0" 27 | if w.Lines()[0] != expected { 28 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 29 | } 30 | } 31 | 32 | func TestDistributionSummary_RecordNegativeValue(t *testing.T) { 33 | id := NewId("recordNegative", nil) 34 | w := writer.MemoryWriter{} 35 | ds := NewDistributionSummary(id, &w) 36 | ds.Record(-100) 37 | 38 | if len(w.Lines()) != 0 { 39 | t.Errorf("Expected no lines, got %d", len(w.Lines())) 40 | } 41 | } 42 | 43 | func TestDistributionSummary_RecordMultipleValues(t *testing.T) { 44 | id := NewId("recordMultiple", nil) 45 | w := writer.MemoryWriter{} 46 | ds := NewDistributionSummary(id, &w) 47 | ds.Record(100) 48 | ds.Record(200) 49 | ds.Record(300) 50 | 51 | expectedLines := []string{"d:recordMultiple:100", "d:recordMultiple:200", "d:recordMultiple:300"} 52 | for i, line := range w.Lines() { 53 | if line != expectedLines[i] { 54 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /spectator/meter/age_gauge_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestAgeGauge_Set(t *testing.T) { 9 | id := NewId("set", nil) 10 | w := writer.MemoryWriter{} 11 | g := NewAgeGauge(id, &w) 12 | g.Set(100) 13 | 14 | expected := "A:set:100" 15 | if w.Lines()[0] != expected { 16 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 17 | } 18 | } 19 | 20 | func TestAgeGauge_SetZero(t *testing.T) { 21 | id := NewId("setZero", nil) 22 | w := writer.MemoryWriter{} 23 | g := NewAgeGauge(id, &w) 24 | g.Set(0) 25 | 26 | expected := "A:setZero:0" 27 | if w.Lines()[0] != expected { 28 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 29 | } 30 | } 31 | 32 | func TestAgeGauge_SetNegative(t *testing.T) { 33 | id := NewId("setNegative", nil) 34 | w := writer.MemoryWriter{} 35 | g := NewAgeGauge(id, &w) 36 | g.Set(-100) 37 | 38 | if len(w.Lines()) != 0 { 39 | t.Error("Negative values should be ignored") 40 | } 41 | } 42 | 43 | func TestAgeGauge_SetMultipleValues(t *testing.T) { 44 | id := NewId("setMultiple", nil) 45 | w := writer.MemoryWriter{} 46 | g := NewAgeGauge(id, &w) 47 | g.Set(100) 48 | g.Set(200) 49 | g.Set(300) 50 | 51 | expectedLines := []string{"A:setMultiple:100", "A:setMultiple:200", "A:setMultiple:300"} 52 | for i, line := range w.Lines() { 53 | if line != expectedLines[i] { 54 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 55 | } 56 | } 57 | } 58 | 59 | func TestAgeGauge_Now(t *testing.T) { 60 | id := NewId("now", nil) 61 | w := writer.MemoryWriter{} 62 | g := NewAgeGauge(id, &w) 63 | g.Now() 64 | 65 | expected := "A:now:0" 66 | if w.Lines()[0] != expected { 67 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /spectator/meter/gauge_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/writer" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | func TestGauge_Set(t *testing.T) { 11 | id := NewId("set", nil) 12 | w := writer.MemoryWriter{} 13 | g := NewGauge(id, &w) 14 | g.Set(100.1) 15 | 16 | expected := "g:set:100.100000" 17 | if w.Lines()[0] != expected { 18 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 19 | } 20 | } 21 | 22 | func TestGauge_SetZero(t *testing.T) { 23 | id := NewId("setZero", nil) 24 | w := writer.MemoryWriter{} 25 | g := NewGauge(id, &w) 26 | g.Set(0) 27 | 28 | expected := "g:setZero:0.000000" 29 | if w.Lines()[0] != expected { 30 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 31 | } 32 | } 33 | 34 | func TestGauge_SetNegative(t *testing.T) { 35 | id := NewId("setNegative", nil) 36 | w := writer.MemoryWriter{} 37 | g := NewGauge(id, &w) 38 | g.Set(-100.1) 39 | 40 | expected := "g:setNegative:-100.100000" 41 | if w.Lines()[0] != expected { 42 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 43 | } 44 | } 45 | 46 | func TestGauge_SetMultipleValues(t *testing.T) { 47 | id := NewId("setMultiple", nil) 48 | w := writer.MemoryWriter{} 49 | g := NewGauge(id, &w) 50 | g.Set(100.1) 51 | g.Set(200.2) 52 | g.Set(300.3) 53 | 54 | expectedLines := []string{"g:setMultiple:100.100000", "g:setMultiple:200.200000", "g:setMultiple:300.300000"} 55 | for i, line := range w.Lines() { 56 | if line != expectedLines[i] { 57 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 58 | } 59 | } 60 | } 61 | 62 | func TestGaugeWithTTL_Set(t *testing.T) { 63 | id := NewId("setWithTTL", nil) 64 | w := writer.MemoryWriter{} 65 | ttl := 60 * time.Second 66 | g := NewGaugeWithTTL(id, &w, ttl) 67 | g.Set(100.1) 68 | 69 | expected := fmt.Sprintf("g,%d:setWithTTL:100.100000", int(ttl.Seconds())) 70 | if w.Lines()[0] != expected { 71 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /spectator/meter/timer_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestTimer_Record(t *testing.T) { 10 | id := NewId("recordTimer", nil) 11 | w := writer.MemoryWriter{} 12 | timer := NewTimer(id, &w) 13 | timer.Record(1000 * time.Millisecond) 14 | timer.Record(2000 * time.Millisecond) 15 | timer.Record(3000 * time.Millisecond) 16 | timer.Record(3001 * time.Millisecond) 17 | 18 | expectedLines := []string{"t:recordTimer:1.000000", "t:recordTimer:2.000000", "t:recordTimer:3.000000", "t:recordTimer:3.001000"} 19 | for i, line := range w.Lines() { 20 | if line != expectedLines[i] { 21 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 22 | } 23 | } 24 | } 25 | 26 | func TestTimer_RecordZero(t *testing.T) { 27 | id := NewId("recordTimerZero", nil) 28 | w := writer.MemoryWriter{} 29 | timer := NewTimer(id, &w) 30 | timer.Record(0) 31 | 32 | expected := "t:recordTimerZero:0.000000" 33 | if w.Lines()[0] != expected { 34 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 35 | } 36 | } 37 | 38 | func TestTimer_RecordNegative(t *testing.T) { 39 | id := NewId("recordTimerNegative", nil) 40 | w := writer.MemoryWriter{} 41 | timer := NewTimer(id, &w) 42 | timer.Record(-100 * time.Millisecond) 43 | 44 | if len(w.Lines()) != 0 { 45 | t.Error("Negative durations should be ignored") 46 | } 47 | } 48 | 49 | func TestTimer_RecordMultipleValues(t *testing.T) { 50 | id := NewId("recordTimerMultiple", nil) 51 | w := writer.MemoryWriter{} 52 | timer := NewTimer(id, &w) 53 | timer.Record(100 * time.Millisecond) 54 | timer.Record(200 * time.Millisecond) 55 | timer.Record(300 * time.Millisecond) 56 | 57 | expectedLines := []string{ 58 | "t:recordTimerMultiple:0.100000", 59 | "t:recordTimerMultiple:0.200000", 60 | "t:recordTimerMultiple:0.300000", 61 | } 62 | for i, line := range w.Lines() { 63 | if line != expectedLines[i] { 64 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /spectator/meter/percentile_distsummary_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | ) 7 | 8 | func TestPercentileDistributionSummary_Record(t *testing.T) { 9 | id := NewId("recordPercentile", nil) 10 | w := writer.MemoryWriter{} 11 | ds := NewPercentileDistributionSummary(id, &w) 12 | ds.Record(1000) 13 | ds.Record(2000) 14 | ds.Record(3000) 15 | ds.Record(3001) 16 | 17 | expectedLines := []string{"D:recordPercentile:1000", "D:recordPercentile:2000", "D:recordPercentile:3000", "D:recordPercentile:3001"} 18 | for i, line := range w.Lines() { 19 | if line != expectedLines[i] { 20 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 21 | } 22 | } 23 | } 24 | 25 | func TestPercentileDistributionSummary_RecordZero(t *testing.T) { 26 | id := NewId("recordPercentileZero", nil) 27 | w := writer.MemoryWriter{} 28 | ds := NewPercentileDistributionSummary(id, &w) 29 | ds.Record(0) 30 | 31 | expected := "D:recordPercentileZero:0" 32 | if w.Lines()[0] != expected { 33 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 34 | } 35 | } 36 | 37 | func TestPercentileDistributionSummary_RecordNegative(t *testing.T) { 38 | id := NewId("recordPercentileNegative", nil) 39 | w := writer.MemoryWriter{} 40 | ds := NewPercentileDistributionSummary(id, &w) 41 | ds.Record(-100) 42 | 43 | if len(w.Lines()) != 0 { 44 | t.Errorf("Expected no lines to be written, got %d", len(w.Lines())) 45 | } 46 | } 47 | 48 | func TestPercentileDistributionSummary_RecordMultipleValues(t *testing.T) { 49 | id := NewId("recordPercentileMultiple", nil) 50 | w := writer.MemoryWriter{} 51 | ds := NewPercentileDistributionSummary(id, &w) 52 | ds.Record(100) 53 | ds.Record(200) 54 | ds.Record(300) 55 | 56 | expectedLines := []string{"D:recordPercentileMultiple:100", "D:recordPercentileMultiple:200", "D:recordPercentileMultiple:300"} 57 | for i, line := range w.Lines() { 58 | if line != expectedLines[i] { 59 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /spectator/meter/percentile_timer_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/writer" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestPercentileTimer_Record(t *testing.T) { 10 | id := NewId("recordPercentileTimer", nil) 11 | w := writer.MemoryWriter{} 12 | pt := NewPercentileTimer(id, &w) 13 | pt.Record(1000 * time.Millisecond) 14 | pt.Record(2000 * time.Millisecond) 15 | pt.Record(3000 * time.Millisecond) 16 | pt.Record(3001 * time.Millisecond) 17 | 18 | expectedLines := []string{ 19 | "T:recordPercentileTimer:1.000000", 20 | "T:recordPercentileTimer:2.000000", 21 | "T:recordPercentileTimer:3.000000", 22 | "T:recordPercentileTimer:3.001000", 23 | } 24 | for i, line := range w.Lines() { 25 | if line != expectedLines[i] { 26 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 27 | } 28 | } 29 | } 30 | 31 | func TestPercentileTimer_RecordZero(t *testing.T) { 32 | id := NewId("recordPercentileTimerZero", nil) 33 | w := writer.MemoryWriter{} 34 | pt := NewPercentileTimer(id, &w) 35 | pt.Record(0) 36 | 37 | expected := "T:recordPercentileTimerZero:0.000000" 38 | if w.Lines()[0] != expected { 39 | t.Errorf("Expected line to be %s, got %s", expected, w.Lines()[0]) 40 | } 41 | } 42 | 43 | func TestPercentileTimer_RecordNegative(t *testing.T) { 44 | id := NewId("recordPercentileTimerNegative", nil) 45 | w := writer.MemoryWriter{} 46 | pt := NewPercentileTimer(id, &w) 47 | pt.Record(-100 * time.Millisecond) 48 | 49 | if len(w.Lines()) != 0 { 50 | t.Error("Negative durations should be ignored") 51 | } 52 | } 53 | 54 | func TestPercentileTimer_RecordMultipleValues(t *testing.T) { 55 | id := NewId("recordPercentileTimerMultiple", nil) 56 | w := writer.MemoryWriter{} 57 | pt := NewPercentileTimer(id, &w) 58 | pt.Record(100 * time.Millisecond) 59 | pt.Record(200 * time.Millisecond) 60 | pt.Record(300 * time.Millisecond) 61 | 62 | expectedLines := []string{ 63 | "T:recordPercentileTimerMultiple:0.100000", 64 | "T:recordPercentileTimerMultiple:0.200000", 65 | "T:recordPercentileTimerMultiple:0.300000", 66 | } 67 | for i, line := range w.Lines() { 68 | if line != expectedLines[i] { 69 | t.Errorf("Expected line to be %s, got %s", expectedLines[i], line) 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /spectator/writer/line_buffer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/logger" 5 | "strconv" 6 | "strings" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type LineBuffer struct { 12 | writer Writer 13 | logger logger.Logger 14 | 15 | bufferSize int 16 | buffer strings.Builder 17 | lineCount int 18 | flushInterval time.Duration 19 | lastFlush time.Time 20 | flushTimer *time.Timer 21 | 22 | mu sync.Mutex 23 | } 24 | 25 | func NewLineBuffer(writer Writer, logger logger.Logger, bufferSize int, flushInterval time.Duration) *LineBuffer { 26 | logger.Infof("Initialize LineBuffer with size %d bytes, and flushInterval of %.2f seconds", bufferSize, flushInterval.Seconds()) 27 | 28 | lb := &LineBuffer{ 29 | writer: writer, 30 | logger: logger, 31 | bufferSize: bufferSize, 32 | lineCount: 0, 33 | flushInterval: flushInterval, 34 | lastFlush: time.Now(), 35 | } 36 | 37 | lb.startFlushTimer() 38 | 39 | return lb 40 | } 41 | 42 | func (lb *LineBuffer) Write(line string) { 43 | lb.mu.Lock() 44 | defer lb.mu.Unlock() 45 | 46 | if lb.buffer.Len() > 0 { 47 | // buffer has data, so add the separator to indicate the end of the previous line 48 | lb.buffer.WriteString(separator) 49 | } 50 | 51 | lb.buffer.WriteString(line) 52 | lb.lineCount++ 53 | 54 | if lb.buffer.Len() >= lb.bufferSize { 55 | lb.writer.WriteString("c:spectator-go.lineBuffer.overflows:1") 56 | lb.flush() 57 | } 58 | } 59 | 60 | func (lb *LineBuffer) startFlushTimer() { 61 | lb.flushTimer = time.AfterFunc(lb.flushInterval, lb.flushLocked) 62 | } 63 | 64 | func (lb *LineBuffer) flushLocked() { 65 | lb.mu.Lock() 66 | defer lb.mu.Unlock() 67 | 68 | if time.Since(lb.lastFlush) >= lb.flushInterval { 69 | lb.flush() 70 | } 71 | 72 | lb.startFlushTimer() 73 | } 74 | 75 | func (lb *LineBuffer) flush() { 76 | // If there is no data to flush from the buffer, then skip socket writes 77 | if lb.buffer.Len() == 0 { 78 | return 79 | } 80 | 81 | lb.logger.Debugf("Flushing buffer with %d lines (%d bytes)", lb.lineCount, lb.buffer.Len()) 82 | lb.writer.WriteString(lb.buffer.String()) 83 | lb.writer.WriteString("c:spectator-go.lineBuffer.bytesWritten:" + strconv.Itoa(lb.buffer.Len())) 84 | lb.buffer.Reset() 85 | lb.lineCount = 0 86 | lb.lastFlush = time.Now() 87 | } 88 | 89 | func (lb *LineBuffer) Close() { 90 | lb.mu.Lock() 91 | defer lb.mu.Unlock() 92 | 93 | if lb.flushTimer != nil { 94 | lb.flushTimer.Stop() 95 | } 96 | 97 | lb.flush() 98 | } 99 | -------------------------------------------------------------------------------- /spectator/writer/file_writer_test.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/logger" 5 | "os" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | const testFileName = "test.txt" 11 | 12 | func TestNewFileWriter(t *testing.T) { 13 | defer os.Remove(testFileName) 14 | 15 | writer, err := NewFileWriter(testFileName, logger.NewDefaultLogger()) 16 | 17 | if err != nil { 18 | t.Errorf("Unexpected error: %v", err) 19 | } 20 | if writer == nil { 21 | t.Errorf("Expected writer to be not nil") 22 | } 23 | } 24 | 25 | func TestFileWriter_Write(t *testing.T) { 26 | defer os.Remove(testFileName) 27 | 28 | writer, _ := NewFileWriter(testFileName, logger.NewDefaultLogger()) 29 | 30 | line := "test line" 31 | writer.Write(line) 32 | 33 | content, _ := os.ReadFile(testFileName) 34 | if strings.TrimRight(string(content), "\n") != line { 35 | t.Errorf("Expected '%s', got '%s'", line, string(content)) 36 | } 37 | } 38 | 39 | func TestFileWriter_WriteBytes(t *testing.T) { 40 | defer os.Remove(testFileName) 41 | 42 | writer, _ := NewFileWriter(testFileName, logger.NewDefaultLogger()) 43 | 44 | line := "test line" 45 | writer.WriteBytes([]byte(line)) 46 | 47 | content, _ := os.ReadFile(testFileName) 48 | if strings.TrimRight(string(content), "\n") != line { 49 | t.Errorf("Expected '%s', got '%s'", line, string(content)) 50 | } 51 | } 52 | 53 | func TestFileWriter_WriteString(t *testing.T) { 54 | defer os.Remove(testFileName) 55 | 56 | writer, _ := NewFileWriter(testFileName, logger.NewDefaultLogger()) 57 | 58 | line := "test line" 59 | writer.WriteString(line) 60 | 61 | content, _ := os.ReadFile(testFileName) 62 | if strings.TrimRight(string(content), "\n") != line { 63 | t.Errorf("Expected '%s', got '%s'", line, string(content)) 64 | } 65 | } 66 | 67 | // Test using a FileWriter with an existing file 68 | func TestFileWriter_WriteExistingFile(t *testing.T) { 69 | defer os.Remove(testFileName) 70 | 71 | // Create a file with some content 72 | os.WriteFile(testFileName, []byte("existing content\n"), 0644) 73 | 74 | writer, _ := NewFileWriter(testFileName, logger.NewDefaultLogger()) 75 | 76 | line := "test line" 77 | writer.Write(line) 78 | 79 | content, _ := os.ReadFile(testFileName) 80 | expected := "existing content\ntest line\n" 81 | if string(content) != expected { 82 | t.Errorf("Expected '%s', got '%s'", expected, string(content)) 83 | } 84 | 85 | } 86 | 87 | func TestFileWriter_Close(t *testing.T) { 88 | defer os.Remove(testFileName) 89 | 90 | writer, _ := NewFileWriter(testFileName, logger.NewDefaultLogger()) 91 | err := writer.Close() 92 | if err != nil { 93 | t.Errorf("Unexpected error: %v", err) 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /spectator/writer/udp_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/logger" 5 | "net" 6 | "time" 7 | ) 8 | 9 | type UdpWriter struct { 10 | conn *net.UDPConn 11 | logger logger.Logger 12 | lineBuffer *LineBuffer 13 | lowLatencyBuffer *LowLatencyBuffer 14 | } 15 | 16 | type udpBufferWriter struct { 17 | *UdpWriter 18 | } 19 | 20 | func NewUdpWriter(address string, logger logger.Logger) (*UdpWriter, error) { 21 | return NewUdpWriterWithBuffer(address, logger, 0, 5*time.Second) 22 | } 23 | 24 | func NewUdpWriterWithBuffer(address string, logger logger.Logger, bufferSize int, flushInterval time.Duration) (*UdpWriter, error) { 25 | udpAddr, err := net.ResolveUDPAddr("udp", address) 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | conn, err := net.DialUDP("udp", nil, udpAddr) 31 | if err != nil { 32 | return nil, err 33 | } 34 | 35 | baseWriter := &UdpWriter{ 36 | conn: conn, 37 | logger: logger, 38 | } 39 | 40 | var lineBuffer *LineBuffer 41 | var lowLatencyBuffer *LowLatencyBuffer 42 | if bufferSize > 0 && bufferSize <= 65536 { 43 | lineBuffer = NewLineBuffer(&udpBufferWriter{baseWriter}, logger, bufferSize, flushInterval) 44 | } else if bufferSize > 0 { 45 | lowLatencyBuffer = NewLowLatencyBuffer(&udpBufferWriter{baseWriter}, logger, bufferSize, flushInterval) 46 | } 47 | baseWriter.lineBuffer = lineBuffer 48 | baseWriter.lowLatencyBuffer = lowLatencyBuffer 49 | 50 | return baseWriter, nil 51 | } 52 | 53 | func (u *UdpWriter) Write(line string) { 54 | u.logger.Debugf("Sending line: %s", line) 55 | 56 | if u.lineBuffer != nil { 57 | u.lineBuffer.Write(line) 58 | return 59 | } 60 | 61 | if u.lowLatencyBuffer != nil { 62 | u.lowLatencyBuffer.Write(line) 63 | return 64 | } 65 | 66 | u.WriteString(line) 67 | } 68 | 69 | func (u *UdpWriter) WriteBytes(line []byte) { 70 | _, err := u.conn.Write(line) 71 | if err != nil { 72 | u.logger.Errorf("Error writing to UDP: %s", err) 73 | } 74 | } 75 | 76 | func (u *UdpWriter) WriteString(line string) { 77 | _, err := u.conn.Write([]byte(line)) 78 | if err != nil { 79 | u.logger.Errorf("Error writing to UDP: %s", err) 80 | } 81 | } 82 | 83 | func (u *UdpWriter) Close() error { 84 | // Stop flush timer, and flush remaining lines 85 | if u.lineBuffer != nil { 86 | u.lineBuffer.Close() 87 | } 88 | 89 | // Stop flush goroutines 90 | if u.lowLatencyBuffer != nil { 91 | u.lowLatencyBuffer.Close() 92 | } 93 | 94 | // Close the connection, if it exists 95 | if u.conn != nil { 96 | return u.conn.Close() 97 | } 98 | 99 | return nil 100 | } 101 | -------------------------------------------------------------------------------- /spectator/writer/line_buffer_test.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/logger" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestLineBuffer_FlushesOnSizeExceeded(t *testing.T) { 10 | memWriter := &MemoryWriter{} 11 | buffer := NewLineBuffer(memWriter, logger.NewDefaultLogger(), 20, 5*time.Second) 12 | 13 | buffer.Write("short") 14 | lines := memWriter.Lines() 15 | if len(lines) != 0 { 16 | t.Errorf("Expected 0 lines before size exceeded, got %d: %s", len(lines), lines) 17 | } 18 | 19 | buffer.Write("this_is_a_longer_line") 20 | lines = memWriter.Lines() 21 | if len(lines) != 3 { 22 | t.Errorf("Expected 3 lines after buffer size exceeded, got %d: %s", len(lines), lines) 23 | } 24 | 25 | expected := [...]string{ 26 | "c:spectator-go.lineBuffer.overflows:1", 27 | "short\nthis_is_a_longer_line", 28 | "c:spectator-go.lineBuffer.bytesWritten:27", 29 | } 30 | for idx, line := range lines { 31 | if line != expected[idx] { 32 | t.Errorf("Expected '%s', got '%s'", expected[idx], line) 33 | } 34 | } 35 | } 36 | 37 | func TestLineBuffer_FlushesOnTimeout(t *testing.T) { 38 | memWriter := &MemoryWriter{} 39 | buffer := NewLineBuffer(memWriter, logger.NewDefaultLogger(), 1000, 1*time.Millisecond) 40 | 41 | buffer.Write("line1") 42 | buffer.Write("line2") 43 | 44 | lines := memWriter.Lines() 45 | if len(lines) != 0 { 46 | t.Errorf("Expected 0 lines before timeout, got %d", len(lines)) 47 | } 48 | 49 | time.Sleep(2 * time.Millisecond) 50 | 51 | lines = memWriter.Lines() 52 | if len(lines) != 2 { 53 | t.Errorf("Expected 2 lines after timeout, got %d: %s", len(lines), lines) 54 | } 55 | 56 | expected := [...]string{ 57 | "line1\nline2", 58 | "c:spectator-go.lineBuffer.bytesWritten:11", 59 | } 60 | for idx, expect := range expected { 61 | if expect != lines[idx] { 62 | t.Errorf("Expected '%s', got '%s'", expect, lines[idx]) 63 | } 64 | } 65 | } 66 | 67 | func TestLineBuffer_FlushesOnClose(t *testing.T) { 68 | memWriter := &MemoryWriter{} 69 | buffer := NewLineBuffer(memWriter, logger.NewDefaultLogger(), 1000, 5*time.Second) 70 | 71 | buffer.Write("line1") 72 | buffer.Write("line2") 73 | 74 | lines := memWriter.Lines() 75 | if len(lines) != 0 { 76 | t.Errorf("Expected 0 lines before close, got %d", len(lines)) 77 | } 78 | 79 | buffer.Close() 80 | 81 | lines = memWriter.Lines() 82 | if len(lines) != 2 { 83 | t.Errorf("Expected 2 lines after close, got %d: %s", len(lines), lines) 84 | } 85 | 86 | expected := [...]string{ 87 | "line1\nline2", 88 | "c:spectator-go.lineBuffer.bytesWritten:11", 89 | } 90 | for idx, expect := range expected { 91 | if expect != lines[idx] { 92 | t.Errorf("Expected '%s', got '%s'", expect, lines[idx]) 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /spectator/writer/writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | // Writer that accepts SpectatorD line protocol. 11 | type Writer interface { 12 | // Write is the primary interface, for meters 13 | Write(line string) 14 | // WriteBytes and WriteString are secondary interfaces, for buffers 15 | WriteBytes(line []byte) 16 | WriteString(line string) 17 | Close() error 18 | } 19 | 20 | func IsValidOutputLocation(output string) bool { 21 | return output == "none" || 22 | output == "memory" || 23 | output == "stdout" || 24 | output == "stderr" || 25 | output == "udp" || 26 | output == "unix" || 27 | strings.HasPrefix(output, "file://") || 28 | strings.HasPrefix(output, "udp://") || 29 | strings.HasPrefix(output, "unix://") 30 | } 31 | 32 | // NewWriter Create a new writer based on the GetLocation string provided 33 | func NewWriter(outputLocation string, logger logger.Logger) (Writer, error) { 34 | return NewWriterWithBuffer(outputLocation, logger, 0, 5*time.Second) 35 | } 36 | 37 | // NewWriterWithBuffer Create a new writer with buffer support 38 | func NewWriterWithBuffer(outputLocation string, logger logger.Logger, bufferSize int, flushInterval time.Duration) (Writer, error) { 39 | switch { 40 | case outputLocation == "none": 41 | logger.Infof("Initialize NoopWriter") 42 | return &NoopWriter{}, nil 43 | case outputLocation == "memory": 44 | logger.Infof("Initialize MemoryWriter") 45 | return &MemoryWriter{}, nil 46 | case outputLocation == "stdout": 47 | logger.Infof("Initialize StdoutWriter") 48 | return &StdoutWriter{}, nil 49 | case outputLocation == "stderr": 50 | logger.Infof("Initialize StderrWriter") 51 | return &StderrWriter{}, nil 52 | case outputLocation == "udp": 53 | // default udp port for spectatord 54 | outputLocation = "udp://127.0.0.1:1234" 55 | logger.Infof("Initialize UdpWriter with address %s", outputLocation) 56 | address := strings.TrimPrefix(outputLocation, "udp://") 57 | return NewUdpWriterWithBuffer(address, logger, bufferSize, flushInterval) 58 | case outputLocation == "unix": 59 | // default unix domain socket for spectatord 60 | outputLocation = "unix:///run/spectatord/spectatord.unix" 61 | logger.Infof("Initialize UnixgramWriter with path %s", outputLocation) 62 | path := strings.TrimPrefix(outputLocation, "unix://") 63 | return NewUnixgramWriterWithBuffer(path, logger, bufferSize, flushInterval) 64 | case strings.HasPrefix(outputLocation, "file://"): 65 | logger.Infof("Initialize FileWriter with path %s", outputLocation) 66 | filePath := strings.TrimPrefix(outputLocation, "file://") 67 | return NewFileWriter(filePath, logger) 68 | case strings.HasPrefix(outputLocation, "udp://"): 69 | logger.Infof("Initialize UdpWriter with address %s", outputLocation) 70 | address := strings.TrimPrefix(outputLocation, "udp://") 71 | return NewUdpWriterWithBuffer(address, logger, bufferSize, flushInterval) 72 | case strings.HasPrefix(outputLocation, "unix://"): 73 | logger.Infof("Initialize UnixgramWriter with path %s", outputLocation) 74 | path := strings.TrimPrefix(outputLocation, "unix://") 75 | return NewUnixgramWriterWithBuffer(path, logger, bufferSize, flushInterval) 76 | default: 77 | return nil, fmt.Errorf("unknown output location: %s", outputLocation) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /spectator/writer/writer_test.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "os" 7 | "sync" 8 | "testing" 9 | ) 10 | 11 | func TestValidOutputLocation(t *testing.T) { 12 | testCases := []struct { 13 | outputLocation string 14 | expected bool 15 | }{ 16 | {"none", true}, 17 | {"memory", true}, 18 | {"stdout", true}, 19 | {"stderr", true}, 20 | {"udp", true}, 21 | {"unix", true}, 22 | {"file://testfile.txt", true}, 23 | {"udp://localhost:1234", true}, 24 | {"unix:///tmp/socket.sock", true}, 25 | {"invalid", false}, 26 | } 27 | 28 | for _, tc := range testCases { 29 | result := IsValidOutputLocation(tc.outputLocation) 30 | if result != tc.expected { 31 | t.Errorf("Expected %v for output location '%s', got %v", tc.expected, tc.outputLocation, result) 32 | } 33 | } 34 | } 35 | 36 | func TestNewWriter(t *testing.T) { 37 | testCases := []struct { 38 | outputLocation string 39 | expectedType string 40 | }{ 41 | {"none", "*writer.NoopWriter"}, 42 | {"memory", "*writer.MemoryWriter"}, 43 | {"stdout", "*writer.StdoutWriter"}, 44 | {"stderr", "*writer.StderrWriter"}, 45 | {"file://testfile.txt", "*writer.FileWriter"}, 46 | {"udp://localhost:5000", "*writer.UdpWriter"}, 47 | {"unix:///tmp/socket.sock", "*writer.UnixgramWriter"}, 48 | } 49 | 50 | for _, tc := range testCases { 51 | writer, _ := NewWriter(tc.outputLocation, logger.NewDefaultLogger()) 52 | resultType := fmt.Sprintf("%T", writer) 53 | if resultType != tc.expectedType { 54 | t.Errorf("Expected %s for output location '%s', got %s", tc.expectedType, tc.outputLocation, resultType) 55 | } 56 | 57 | // Cleanup test file 58 | _ = os.Remove("testfile.txt") 59 | } 60 | } 61 | 62 | func TestNewWriter_InvalidOutputLocation(t *testing.T) { 63 | _, err := NewWriter("invalid", logger.NewDefaultLogger()) 64 | if err == nil { 65 | t.Errorf("Expected error, got nil") 66 | } 67 | } 68 | 69 | func TestNewWriter_EmptyOutputLocation(t *testing.T) { 70 | _, err := NewWriter("", logger.NewDefaultLogger()) 71 | if err == nil { 72 | t.Errorf("Expected error, got nil") 73 | } 74 | } 75 | 76 | func TestMemoryWriter_Write(t *testing.T) { 77 | w, err := NewWriter("memory", logger.NewDefaultLogger()) 78 | if err != nil { 79 | t.Errorf("failed to create writer: %s", err) 80 | } 81 | 82 | wg := sync.WaitGroup{} 83 | for i := 0; i < 100; i++ { 84 | wg.Add(1) 85 | go func() { 86 | defer wg.Done() 87 | for j := 0; j < 100; j++ { 88 | w.Write("") 89 | } 90 | }() 91 | } 92 | wg.Wait() 93 | 94 | linesWritten := len(w.(*MemoryWriter).Lines()) 95 | if linesWritten != 10000 { 96 | t.Errorf("expected 10000 lines written to writer but found %d", linesWritten) 97 | } 98 | } 99 | 100 | func TestMemoryWriter_Reset(t *testing.T) { 101 | w, err := NewWriter("memory", logger.NewDefaultLogger()) 102 | if err != nil { 103 | t.Errorf("failed to create writer: %s", err) 104 | } 105 | mw := w.(*MemoryWriter) 106 | 107 | linesLength := len(mw.Lines()) 108 | if linesLength != 0 { 109 | t.Errorf("expected 0 lines written to writer but found %d", linesLength) 110 | } 111 | 112 | mw.Write("") 113 | mw.Write("") 114 | 115 | linesLength = len(mw.Lines()) 116 | if linesLength != 2 { 117 | t.Errorf("expected 2 lines written to writer but found %d", linesLength) 118 | } 119 | 120 | mw.Reset() 121 | 122 | linesLength = len(mw.Lines()) 123 | if linesLength != 0 { 124 | t.Errorf("expected 0 lines written to writer but found %d", linesLength) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /spectator/writer/unixgram_writer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/logger" 5 | "net" 6 | "strings" 7 | "time" 8 | ) 9 | 10 | type UnixgramWriter struct { 11 | addr *net.UnixAddr 12 | conn *net.UnixConn 13 | logger logger.Logger 14 | lineBuffer *LineBuffer 15 | lowLatencyBuffer *LowLatencyBuffer 16 | } 17 | 18 | type unixgramBufferWriter struct { 19 | *UnixgramWriter 20 | } 21 | 22 | func NewUnixgramWriter(path string, logger logger.Logger) (*UnixgramWriter, error) { 23 | return NewUnixgramWriterWithBuffer(path, logger, 0, 5 * time.Second) 24 | } 25 | 26 | func NewUnixgramWriterWithBuffer(path string, logger logger.Logger, bufferSize int, flushInterval time.Duration) (*UnixgramWriter, error) { 27 | addr := &net.UnixAddr{Name: path, Net: "unixgram"} 28 | conn, err := net.DialUnix("unixgram", nil, addr) 29 | if err != nil { 30 | logger.Errorf("failed to dial unix socket: %v", err) 31 | conn = nil 32 | } 33 | 34 | baseWriter := &UnixgramWriter{ 35 | addr: addr, 36 | conn: conn, 37 | logger: logger, 38 | } 39 | 40 | var lineBuffer *LineBuffer 41 | var lowLatencyBuffer *LowLatencyBuffer 42 | if bufferSize > 0 && bufferSize <= 65536 { 43 | lineBuffer = NewLineBuffer(&unixgramBufferWriter{baseWriter}, logger, bufferSize, flushInterval) 44 | } else if bufferSize > 0 { 45 | lowLatencyBuffer = NewLowLatencyBuffer(&unixgramBufferWriter{baseWriter}, logger, bufferSize, flushInterval) 46 | } 47 | baseWriter.lineBuffer = lineBuffer 48 | baseWriter.lowLatencyBuffer = lowLatencyBuffer 49 | 50 | return baseWriter, nil 51 | } 52 | 53 | func (u *UnixgramWriter) Write(line string) { 54 | u.logger.Debugf("Sending line: %s", line) 55 | 56 | if u.lineBuffer != nil { 57 | u.lineBuffer.Write(line) 58 | return 59 | } 60 | 61 | if u.lowLatencyBuffer != nil { 62 | u.lowLatencyBuffer.Write(line) 63 | return 64 | } 65 | 66 | u.WriteString(line) 67 | } 68 | 69 | func (u *UnixgramWriter) WriteBytes(line []byte) { 70 | if u.conn != nil { 71 | if _, err := u.conn.Write(line); err != nil { 72 | u.maybeCloseSocket(err) 73 | } 74 | } else { 75 | u.redialSocket() 76 | } 77 | } 78 | 79 | func (u *UnixgramWriter) WriteString(line string) { 80 | if u.conn != nil { 81 | if _, err := u.conn.Write([]byte(line)); err != nil { 82 | u.maybeCloseSocket(err) 83 | } 84 | } else { 85 | u.redialSocket() 86 | } 87 | } 88 | 89 | // If anything disturbs access to the unix socket, such as a spectatord process restart (or another 90 | // unknown condition), then all future writes to the unix socket will fail with a "transport endpoint 91 | // is not connected" error. 92 | // 93 | // This means that the UdpWriter is generally more resilient across more operating conditions than the 94 | // UnixgramWriter. The UdpWriter does not continue to fail once it encounters a single failure to write, 95 | // it resumes writing when the port is available again, and it does not require any special connection 96 | // handling. 97 | // 98 | // The addition of reconnect logic to the UnixgramWriter mitigates ongoing issues with unix socket write 99 | // errors. Some packet delivery failure will occur until it can reconnect. With the reconnect logic in 100 | // place, the initialization is now more resilient if the unix socket is not available at program start. 101 | func (u *UnixgramWriter) maybeCloseSocket(err error) { 102 | u.logger.Errorf("failed to write to unix socket: %v\n", err) 103 | 104 | if strings.Contains(err.Error(), "transport endpoint is not connected") { 105 | u.logger.Infof("close unix socket") 106 | err := u.conn.Close() 107 | if err != nil { 108 | u.logger.Errorf("failed to close unix socket: %v\n", err) 109 | } 110 | u.conn = nil 111 | } 112 | } 113 | 114 | func (u *UnixgramWriter) redialSocket() { 115 | u.logger.Infof("re-dial unix socket") 116 | 117 | conn, err := net.DialUnix("unixgram", nil, u.addr) 118 | if err != nil { 119 | u.logger.Errorf("failed to dial unix socket: %v", err) 120 | } else { 121 | u.conn = conn 122 | } 123 | } 124 | 125 | 126 | func (u *UnixgramWriter) Close() error { 127 | // Stop flush timer, and flush remaining lines 128 | if u.lineBuffer != nil { 129 | u.lineBuffer.Close() 130 | } 131 | 132 | // Stop flush goroutines 133 | if u.lowLatencyBuffer != nil { 134 | u.lowLatencyBuffer.Close() 135 | } 136 | 137 | // Close the connection 138 | if u.conn != nil { 139 | return u.conn.Close() 140 | } 141 | 142 | return nil 143 | } 144 | -------------------------------------------------------------------------------- /spectator/meter/id.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | "strings" 7 | "sync" 8 | ) 9 | 10 | // Id represents a meter's identifying information and dimensions (tags). 11 | type Id struct { 12 | name string 13 | tags map[string]string 14 | // keyOnce protects access to key, allowing it to be computed on demand 15 | // without racing other readers. 16 | keyOnce sync.Once 17 | key string 18 | // spectatordId is the Id formatted for spectatord line protocol 19 | spectatordId string 20 | } 21 | 22 | var builderPool = &sync.Pool{ 23 | New: func() interface{} { 24 | return &strings.Builder{} 25 | }, 26 | } 27 | 28 | // MapKey computes and saves a key within the struct to be used to uniquely 29 | // identify this *Id in a map. This does use the information from within the 30 | // *Id, so it assumes you've not accidentally double-declared this *Id. 31 | func (id *Id) MapKey() string { 32 | id.keyOnce.Do(func() { 33 | // if the key was set directly during Id construction, then do not 34 | // compute a value. 35 | if id.key != "" { 36 | return 37 | } 38 | 39 | buf := builderPool.Get().(*strings.Builder) 40 | buf.Reset() 41 | defer builderPool.Put(buf) 42 | 43 | const errKey = "ERR" 44 | id.key = func() string { 45 | _, err := buf.WriteString(id.name) 46 | if err != nil { 47 | return errKey 48 | } 49 | keys := make([]string, 0, len(id.tags)) 50 | for k := range id.tags { 51 | keys = append(keys, k) 52 | } 53 | sort.Strings(keys) 54 | 55 | for _, k := range keys { 56 | v := id.tags[k] 57 | _, err = buf.WriteRune('|') 58 | if err != nil { 59 | return errKey 60 | } 61 | _, err = buf.WriteString(k) 62 | if err != nil { 63 | return errKey 64 | } 65 | _, err = buf.WriteRune('|') 66 | if err != nil { 67 | return errKey 68 | } 69 | _, err = buf.WriteString(v) 70 | if err != nil { 71 | return errKey 72 | } 73 | } 74 | return buf.String() 75 | }() 76 | }) 77 | return id.key 78 | } 79 | 80 | // NewId generates a new *Id from the metric name, and the tags you want to 81 | // include on your metric. 82 | func NewId(name string, tags map[string]string) *Id { 83 | myTags := make(map[string]string) 84 | for k, v := range tags { 85 | myTags[k] = v 86 | } 87 | 88 | spectatorId := toSpectatorId(name, tags) 89 | 90 | return &Id{ 91 | name: name, 92 | tags: myTags, 93 | spectatordId: spectatorId, 94 | } 95 | } 96 | 97 | // WithTag creates a deep copy of the *Id, adding the requested tag to the 98 | // internal collection. 99 | func (id *Id) WithTag(key string, value string) *Id { 100 | newTags := make(map[string]string) 101 | 102 | for k, v := range id.tags { 103 | newTags[k] = v 104 | } 105 | newTags[key] = value 106 | 107 | return NewId(id.name, newTags) 108 | } 109 | 110 | func (id *Id) String() string { 111 | return fmt.Sprintf("Id{name=%s,tags=%v}", id.name, id.tags) 112 | } 113 | 114 | // Name exposes the internal metric name field. 115 | func (id *Id) Name() string { 116 | return id.name 117 | } 118 | 119 | // Tags directly exposes the internal tags map. This is not a copy of the map, 120 | // so any modifications to it will be observed by the *Id. 121 | func (id *Id) Tags() map[string]string { 122 | return id.tags 123 | } 124 | 125 | // WithTags takes a map of tags, and returns a deep copy of *Id with the new 126 | // tags appended to the original ones. Overlapping keys are overwritten. If the 127 | // input to this method is empty, this does not return a deep copy of *Id. 128 | func (id *Id) WithTags(tags map[string]string) *Id { 129 | if len(tags) == 0 { 130 | return id 131 | } 132 | 133 | newTags := make(map[string]string) 134 | 135 | for k, v := range id.tags { 136 | newTags[k] = v 137 | } 138 | 139 | for k, v := range tags { 140 | newTags[k] = v 141 | } 142 | return NewId(id.name, newTags) 143 | } 144 | 145 | func toSpectatorId(name string, tags map[string]string) string { 146 | var sb strings.Builder 147 | writeSanitized(&sb, name) 148 | 149 | // Append sanitized keys and values. 150 | for k, v := range tags { 151 | sb.WriteString(",") 152 | writeSanitized(&sb, k) 153 | sb.WriteString("=") 154 | writeSanitized(&sb, v) 155 | } 156 | 157 | return sb.String() 158 | } 159 | 160 | func writeSanitized(sb *strings.Builder, input string) { 161 | for _, r := range input { 162 | if !isValidCharacter(r) { 163 | sb.WriteRune('_') 164 | } else { 165 | sb.WriteRune(r) 166 | } 167 | } 168 | } 169 | 170 | func isValidCharacter(r rune) bool { 171 | return (r >= 'a' && r <= 'z') || 172 | (r >= 'A' && r <= 'Z') || 173 | (r >= '0' && r <= '9') || 174 | r == '-' || 175 | r == '.' || 176 | r == '_' || 177 | r == '~' || 178 | r == '^' 179 | } 180 | -------------------------------------------------------------------------------- /spectator/meter/id_test.go: -------------------------------------------------------------------------------- 1 | package meter 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "reflect" 7 | "strings" 8 | "sync" 9 | "testing" 10 | ) 11 | 12 | func TestId_mapKey(t *testing.T) { 13 | id := NewId("foo", nil) 14 | k := id.MapKey() 15 | if k != "foo" { 16 | t.Error("Expected foo, got", k) 17 | } 18 | 19 | reusesKey := Id{ 20 | name: "foo", 21 | key: "bar", 22 | } 23 | k2 := reusesKey.MapKey() 24 | if k2 != "bar" { 25 | t.Error("Expected MapKey to be reused: bar !=", k2) 26 | } 27 | } 28 | 29 | func TestId_mapKeyConcurrent(t *testing.T) { 30 | id := NewId("foo", nil) 31 | 32 | wg := sync.WaitGroup{} 33 | wg.Add(2) 34 | go func() { 35 | _ = id.MapKey() 36 | wg.Done() 37 | }() 38 | go func() { 39 | _ = id.MapKey() 40 | wg.Done() 41 | }() 42 | 43 | wg.Wait() 44 | } 45 | 46 | func TestId_mapKeySortsTags(t *testing.T) { 47 | tags := map[string]string{} 48 | 49 | for i := 0; i < 100; i++ { 50 | k := fmt.Sprintf("%03d", i) 51 | tags[k] = "v" 52 | } 53 | id := NewId("foo", tags) 54 | 55 | var buf bytes.Buffer 56 | buf.WriteString("foo") 57 | for i := 0; i < 100; i++ { 58 | k := fmt.Sprintf("|%03d|v", i) 59 | buf.WriteString(k) 60 | } 61 | 62 | k := id.MapKey() 63 | if k != buf.String() { 64 | t.Errorf("Expected %s, got %s", buf.String(), k) 65 | } 66 | } 67 | 68 | func TestId_copiesTags(t *testing.T) { 69 | tags := map[string]string{"foo": "abc", "bar": "def"} 70 | id := NewId("foo", tags) 71 | 72 | tags["foo"] = "zzz" 73 | if id.Tags()["foo"] != "abc" { 74 | t.Errorf("Expected ids to create a copy of the tags. Got '%s', expected 'abc'", id.Tags()["foo"]) 75 | } 76 | } 77 | 78 | func TestId_Accessors(t *testing.T) { 79 | id := NewId("foo", map[string]string{"foo": "abc", "bar": "def"}) 80 | if id.Name() != "foo" { 81 | t.Errorf("Expected name=foo, got name=%s", id.Name()) 82 | } 83 | 84 | expected := map[string]string{"foo": "abc", "bar": "def"} 85 | if !reflect.DeepEqual(expected, id.Tags()) { 86 | t.Errorf("Expected tags=%v, got %v", expected, id.Tags()) 87 | } 88 | } 89 | 90 | func TestId_WithTags(t *testing.T) { 91 | id1 := NewId("c", map[string]string{"statistic": "baz", "a": "b"}) 92 | id2 := id1.WithTags(map[string]string{"statistic": "foo", "k": "v"}) 93 | expected := map[string]string{"statistic": "foo", "k": "v", "a": "b"} 94 | if id2.Name() != "c" { 95 | t.Errorf("WithTags must copy the name. Got %s instead of c", id2.Name()) 96 | } 97 | 98 | if !reflect.DeepEqual(expected, id2.Tags()) { 99 | t.Errorf("Expected %v, got %v tags", expected, id2.Tags()) 100 | } 101 | } 102 | 103 | func TestToSpectatorId(t *testing.T) { 104 | name := "test" 105 | tags := map[string]string{ 106 | "tag1": "value1", 107 | "tag2": "value2", 108 | } 109 | 110 | // The order of the tags is not guaranteed 111 | expected1 := "test,tag1=value1,tag2=value2" 112 | expected2 := "test,tag2=value2,tag1=value1" 113 | result := toSpectatorId(name, tags) 114 | 115 | if result != expected1 && result != expected2 { 116 | t.Errorf("Expected '%s' or '%s', got '%s'", expected1, expected2, result) 117 | } 118 | } 119 | 120 | func TestToSpectatorId_EmptyTags(t *testing.T) { 121 | name := "test" 122 | tags := map[string]string{} 123 | 124 | expected := "test" 125 | result := toSpectatorId(name, tags) 126 | 127 | if result != expected { 128 | t.Errorf("Expected '%s', got '%s'", expected, result) 129 | } 130 | } 131 | 132 | func TestToSpectatorId_InvalidTags(t *testing.T) { 133 | name := "test`!@#$%^&*()-=~_+[]{}\\|;:'\",<.>/?foo" 134 | tags := map[string]string{ 135 | "tag1,:=": "value1,:=", 136 | "tag2,;=": "value2,;=", 137 | } 138 | 139 | expected1 := "test______^____-_~______________.___foo,tag1___=value1___,tag2___=value2___" 140 | expected2 := "test______^____-_~______________.___foo,tag2___=value2___,tag1___=value1___" 141 | result := toSpectatorId(name, tags) 142 | 143 | if result != expected1 && result != expected2 { 144 | t.Errorf("Expected '%s' or '%s', got '%s'", expected1, expected2, result) 145 | } 146 | } 147 | 148 | var benchName = "my.metric.with_a_fairly_long_name.and.some.invalid.chars!@#" 149 | var benchTags = map[string]string{ 150 | "tag1": "value1", 151 | "another_tag": "another_value_with_some_length", 152 | "invalid-key!": "invalid-value@", 153 | "tag4": "value4", 154 | "last.tag": "final~value", 155 | } 156 | 157 | func BenchmarkToSpectatorId(b *testing.B) { 158 | replaceInvalidCharacters := func(input string) string { 159 | var result strings.Builder 160 | for _, r := range input { 161 | if !isValidCharacter(r) { 162 | result.WriteRune('_') 163 | } else { 164 | result.WriteRune(r) 165 | } 166 | } 167 | return result.String() 168 | 169 | } 170 | originalToSpectatorId := func(name string, tags map[string]string) string { 171 | result := replaceInvalidCharacters(name) 172 | 173 | for k, v := range tags { 174 | k = replaceInvalidCharacters(k) 175 | v = replaceInvalidCharacters(v) 176 | result += fmt.Sprintf(",%s=%s", k, v) 177 | 178 | } 179 | 180 | return result 181 | } 182 | 183 | b.ReportAllocs() 184 | for n := 0; n < b.N; n++ { 185 | _ = originalToSpectatorId(benchName, benchTags) 186 | } 187 | } 188 | 189 | func BenchmarkToSpectatorIdBuilder(b *testing.B) { 190 | b.ReportAllocs() 191 | for n := 0; n < b.N; n++ { 192 | _ = toSpectatorId(benchName, benchTags) 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /spectator/config_test.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestConfigMergesCommonTagsWithEnvVariables(t *testing.T) { 10 | _ = os.Setenv("TITUS_CONTAINER_NAME", "container_name") 11 | _ = os.Setenv("NETFLIX_PROCESS_NAME", "process_name") 12 | defer os.Unsetenv("TITUS_CONTAINER_NAME") 13 | defer os.Unsetenv("NETFLIX_PROCESS_NAME") 14 | 15 | tags := map[string]string{ 16 | "nf.app": "app", 17 | "nf.account": "1234", 18 | } 19 | 20 | config, err := NewConfig("", tags, nil) 21 | if err != nil { 22 | t.Errorf("Unexpected error: %v", err) 23 | } 24 | 25 | r, _ := NewRegistry(config) 26 | 27 | meterId := r.NewId("test_id", nil) 28 | 29 | // check that meter id has the expected tags 30 | expectedTags := map[string]string{ 31 | "nf.app": "app", 32 | "nf.account": "1234", 33 | "nf.container": "container_name", 34 | "nf.process": "process_name", 35 | } 36 | 37 | if !reflect.DeepEqual(expectedTags, meterId.Tags()) { 38 | t.Errorf("Expected tags %#v, got %#v", expectedTags, meterId.Tags()) 39 | } 40 | } 41 | 42 | // Test passed in values wins over env variables 43 | func TestConfigMergesCommonTagsWithEnvVariablesAndPassedInValues(t *testing.T) { 44 | _ = os.Setenv("TITUS_CONTAINER_NAME", "container_name_via_env") 45 | _ = os.Setenv("NETFLIX_PROCESS_NAME", "process_name_by_env") 46 | defer os.Unsetenv("TITUS_CONTAINER_NAME") 47 | defer os.Unsetenv("NETFLIX_PROCESS_NAME") 48 | 49 | tags := map[string]string{ 50 | "nf.app": "app", 51 | "nf.account": "1234", 52 | "nf.container": "passed_in_container", 53 | "nf.process": "passed_in_process", 54 | } 55 | 56 | config, err := NewConfig("", tags, nil) 57 | if err != nil { 58 | t.Errorf("Unexpected error: %v", err) 59 | } 60 | 61 | r, _ := NewRegistry(config) 62 | 63 | meterId := r.NewId("test_id", nil) 64 | 65 | // check that meter id has the expected tags 66 | expectedTags := map[string]string{ 67 | "nf.app": "app", 68 | "nf.account": "1234", 69 | "nf.container": "container_name_via_env", 70 | "nf.process": "process_name_by_env", 71 | } 72 | 73 | if !reflect.DeepEqual(expectedTags, meterId.Tags()) { 74 | t.Errorf("Expected tags %#v, got %#v", expectedTags, meterId.Tags()) 75 | } 76 | } 77 | 78 | func TestGetLocation_ConfigValue(t *testing.T) { 79 | cfg, err := NewConfig("memory", nil, nil) 80 | if err != nil { 81 | t.Errorf("Unexpected error: %v", err) 82 | } 83 | 84 | result := cfg.location 85 | expected := "memory" 86 | if result != expected { 87 | t.Errorf("Expected '%s', got '%s'", expected, result) 88 | } 89 | } 90 | 91 | func TestGetLocation_EnvValue(t *testing.T) { 92 | _ = os.Setenv("SPECTATOR_OUTPUT_LOCATION", "stdout") 93 | defer os.Unsetenv("SPECTATOR_OUTPUT_LOCATION") 94 | 95 | cfg, err := NewConfig("", nil, nil) 96 | if err != nil { 97 | t.Errorf("Unexpected error: %v", err) 98 | } 99 | 100 | result := cfg.location 101 | expected := "stdout" 102 | if result != expected { 103 | t.Errorf("Expected '%s', got '%s'", expected, result) 104 | } 105 | } 106 | 107 | func TestGetLocation_DefaultValue(t *testing.T) { 108 | cfg, err := NewConfig("", nil, nil) 109 | if err != nil { 110 | t.Errorf("Unexpected error: %v", err) 111 | } 112 | 113 | result := cfg.location 114 | expected := "udp" 115 | if result != expected { 116 | t.Errorf("Expected '%s', got '%s'", expected, result) 117 | } 118 | } 119 | 120 | // NewConfigShouldReturnErrorForInvalidLocation tests that NewConfig returns an error when provided an invalid location. 121 | func TestNewConfigShouldReturnErrorForInvalidLocation(t *testing.T) { 122 | _, err := NewConfig("invalid_location", nil, nil) 123 | if err == nil { 124 | t.Errorf("Expected error for invalid location, got nil") 125 | } 126 | } 127 | 128 | func TestConfigMergesIgnoresCommonTagsWithEnvVariablesEmptyValues(t *testing.T) { 129 | _ = os.Setenv("TITUS_CONTAINER_NAME", "") 130 | _ = os.Setenv("NETFLIX_PROCESS_NAME", "") 131 | defer os.Unsetenv("TITUS_CONTAINER_NAME") 132 | defer os.Unsetenv("NETFLIX_PROCESS_NAME") 133 | 134 | tags := map[string]string{ 135 | "nf.app": "app", 136 | "nf.account": "1234", 137 | } 138 | 139 | config, err := NewConfig("", tags, nil) 140 | if err != nil { 141 | t.Errorf("Unexpected error: %v", err) 142 | } 143 | 144 | r, _ := NewRegistry(config) 145 | 146 | meterId := r.NewId("test_id", nil) 147 | 148 | // check that meter id has the expected tags 149 | expectedTags := map[string]string{ 150 | "nf.app": "app", 151 | "nf.account": "1234", 152 | } 153 | 154 | if !reflect.DeepEqual(expectedTags, meterId.Tags()) { 155 | t.Errorf("Expected tags %#v, got %#v", expectedTags, meterId.Tags()) 156 | } 157 | } 158 | 159 | func TestConfigMergesIgnoresTagsWithPassedInValuesEmptyValues(t *testing.T) { 160 | tags := map[string]string{ 161 | "nf.app": "app", 162 | "nf.account": "", 163 | } 164 | 165 | config, err := NewConfig("", tags, nil) 166 | if err != nil { 167 | t.Errorf("Unexpected error: %v", err) 168 | } 169 | 170 | r, _ := NewRegistry(config) 171 | meterId := r.NewId("test_id", nil) 172 | 173 | // check that meter id has the expected tags 174 | expectedTags := map[string]string{ 175 | "nf.app": "app", 176 | } 177 | 178 | if !reflect.DeepEqual(expectedTags, meterId.Tags()) { 179 | t.Errorf("Expected tags %#v, got %#v", expectedTags, meterId.Tags()) 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /spectator/config.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "github.com/Netflix/spectator-go/v2/spectator/writer" 7 | "os" 8 | "time" 9 | ) 10 | 11 | // Config represents the Registry's configuration. 12 | type Config struct { 13 | location string 14 | extraCommonTags map[string]string 15 | log logger.Logger 16 | bufferSize int 17 | flushInterval time.Duration 18 | } 19 | 20 | // NewConfig creates a new configuration with the provided location, extra common tags, and logger. All fields are 21 | // optional. The extra common tags are added to every metric, on top of the common tags provided by spectatord. 22 | // 23 | // Possible values for location are: 24 | // 25 | // - `""` - Empty string will default to `udp`. 26 | // - `none` - Configure a no-op writer that does nothing. Can be used to disable metrics collection. 27 | // - `memory` - Write metrics to memory. Useful for testing. 28 | // - `stderr` - Write metrics to standard error. 29 | // - `stdout` - Write metrics to standard output. 30 | // - `udp` - Write metrics to the default spectatord UDP port. This is the default value. 31 | // - `unix` - Write metrics to the default spectatord Unix Domain Socket. Useful for high-volume scenarios. 32 | // - `file:///path/to/file` - Write metrics to a file. 33 | // - `udp://host:port` - Write metrics to a UDP socket. 34 | // - `unix:///path/to/socket` - Write metrics to a Unix Domain Socket. 35 | // 36 | // The output location can be overridden by configuring an environment variable SPECTATOR_OUTPUT_LOCATION 37 | // with one of the values listed above. Overriding the output location may be useful for integration testing. 38 | func NewConfig( 39 | location string, // defaults to `udp` 40 | extraCommonTags map[string]string, // defaults to empty map 41 | log logger.Logger, // defaults to default logger 42 | ) (*Config, error) { 43 | return NewConfigWithBuffer(location, extraCommonTags, log, 0, 5 * time.Second) 44 | } 45 | 46 | // NewConfigWithBuffer creates a new configuration with the provided location, extra common tags, logger, 47 | // bufferSize, and flushInterval. This factory function should be used when you need additional performance 48 | // when publishing metrics. 49 | // 50 | // Three modes of operation are available, for applications that operate at different scales: 51 | // 52 | // - Small. No buffer (size 0 bytes). Write immediately to the socket upon every metric update, up to ~150K 53 | // lines/sec, with delays from 2 to 450 us, depending on thread count. No metrics are dropped, due to mutex 54 | // locks. 55 | // - Medium. LineBuffer (size <= 65536 bytes), which writes to the socket upon overflow, or upon a flush 56 | // interval, up to ~1M lines/sec, with delays from 0.1 to 32 us, depending on thread count. No metrics are 57 | // dropped. Status metrics are published to monitor usage. 58 | // - Large. LowLatencyBuffer (size > 65536 bytes), which writes to the socket on a flush interval, up to ~1M 59 | // lines/sec, with delays from 0.6 to 7 us, depending on thread count. The true minimum size is 2 * CPU * 60 | // 60KB, or 122,880 bytes for 1 CPU. Metrics may be dropped. Status metrics are published to monitor usage. 61 | // 62 | // The buffers are available for the UdpWriter and the UnixWriter. 63 | // 64 | // See https://netflix.github.io/atlas-docs/spectator/lang/go/usage/#buffers for a more detailed explanation. 65 | // 66 | func NewConfigWithBuffer( 67 | location string, // defaults to `udp` 68 | extraCommonTags map[string]string, // defaults to empty map 69 | log logger.Logger, // defaults to default logger 70 | bufferSize int, // defaults to 0 (disabled) 71 | flushInterval time.Duration, // defaults to 5 seconds 72 | ) (*Config, error) { 73 | location, err := calculateLocation(location) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | mergedTags := calculateExtraCommonTags(extraCommonTags) 79 | 80 | return &Config{ 81 | location: location, 82 | extraCommonTags: mergedTags, 83 | log: calculateLogger(log), 84 | bufferSize: bufferSize, 85 | flushInterval: flushInterval, 86 | }, nil 87 | } 88 | 89 | func calculateLogger(log logger.Logger) logger.Logger { 90 | if log == nil { 91 | return logger.NewDefaultLogger() 92 | } else { 93 | return log 94 | } 95 | } 96 | 97 | func calculateExtraCommonTags(extraCommonTags map[string]string) map[string]string { 98 | mergedTags := make(map[string]string) 99 | 100 | for k, v := range extraCommonTags { 101 | // tag keys and values may not be empty strings 102 | if k != "" && v != "" { 103 | mergedTags[k] = v 104 | } 105 | } 106 | 107 | // merge extra common tags with select env var tags; env var tags take precedence 108 | for k, v := range tagsFromEnvVars() { 109 | // env tags are validated to be non-empty 110 | mergedTags[k] = v 111 | } 112 | 113 | return mergedTags 114 | } 115 | 116 | func calculateLocation(location string) (string, error) { 117 | if location != "" && !writer.IsValidOutputLocation(location) { 118 | return "", fmt.Errorf("invalid spectatord output location: %s", location) 119 | } 120 | 121 | if override, ok := os.LookupEnv("SPECTATOR_OUTPUT_LOCATION"); ok { 122 | if !writer.IsValidOutputLocation(override) { 123 | return "", fmt.Errorf("SPECTATOR_OUTPUT_LOCATION is invalid: %s", override) 124 | } 125 | location = override 126 | } 127 | 128 | if location == "" { // use the default, if there is no location or override 129 | location = "udp" 130 | } 131 | 132 | return location, nil 133 | } 134 | -------------------------------------------------------------------------------- /spectator/writer/unixgram_writer_test.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "errors" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "log" 7 | "net" 8 | "os" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | const testUnixgramSocket = "/tmp/spectator-go_unixgram.sock" 14 | 15 | // newUnixgramServer creates a new unixgram server and returns a connection to it. 16 | // The server listens for incoming messages and sends them to the provided channel. 17 | func newUnixgramServer() (*net.UnixConn, chan string, error) { 18 | if err := os.RemoveAll(testUnixgramSocket); err != nil { 19 | return nil, nil, err 20 | } 21 | 22 | addr := &net.UnixAddr{ 23 | Name: testUnixgramSocket, 24 | Net: "unixgram", 25 | } 26 | 27 | conn, err := net.ListenUnixgram("unixgram", addr) 28 | if err != nil { 29 | return nil, nil, err 30 | } 31 | 32 | messages := make(chan string) 33 | go handleConnections(conn, messages) 34 | 35 | return conn, messages, nil 36 | } 37 | 38 | func handleConnections(conn *net.UnixConn, msgCh chan string) { 39 | buffer := make([]byte, 1024) 40 | 41 | for { 42 | n, _, err := conn.ReadFromUnix(buffer) 43 | if err != nil { 44 | return 45 | } 46 | data := string(buffer[:n]) 47 | // Send received data to channel 48 | msgCh <- data 49 | log.Printf("Received message '%s'", data) 50 | } 51 | } 52 | 53 | func readMessage(messages chan string) (string, error) { 54 | select { 55 | case message := <-messages: 56 | return message, nil 57 | case <-time.After(time.Second): 58 | return "", errors.New("timeout waiting for message") 59 | } 60 | } 61 | 62 | func TestUnixgramWriter_NoBuffer(t *testing.T) { 63 | // Create server 64 | server, _, serverErr := newUnixgramServer() 65 | if serverErr != nil { 66 | t.Fatalf("Failed to create unixgram server: %v", serverErr) 67 | } 68 | defer server.Close() 69 | 70 | // Create writer 71 | writer, err := NewUnixgramWriterWithBuffer(testUnixgramSocket, logger.NewDefaultLogger(), 0, time.Second) 72 | if err != nil { 73 | t.Fatalf("Failed to create writer: %v", err) 74 | } 75 | if writer.lineBuffer != nil { 76 | t.Errorf("Expected nil LineBuffer") 77 | } 78 | if writer.lowLatencyBuffer != nil { 79 | t.Errorf("Expected nil LowLatencyBuffer") 80 | } 81 | err = writer.Close() 82 | if err != nil { 83 | t.Errorf("Unexpected error: %v", err) 84 | } 85 | } 86 | 87 | func TestUnixgramWriter_LineBuffer(t *testing.T) { 88 | // Create server 89 | server, _, serverErr := newUnixgramServer() 90 | if serverErr != nil { 91 | t.Fatalf("Failed to create unixgram server: %v", serverErr) 92 | } 93 | defer server.Close() 94 | 95 | // Create writer 96 | writer, err := NewUnixgramWriterWithBuffer(testUnixgramSocket, logger.NewDefaultLogger(), 65536, time.Second) 97 | if err != nil { 98 | t.Fatalf("Failed to create writer: %v", err) 99 | } 100 | if writer.lineBuffer == nil { 101 | t.Errorf("Expected LineBuffer") 102 | } 103 | if writer.lowLatencyBuffer != nil { 104 | t.Errorf("Expected nil LowLatencyBuffer") 105 | } 106 | err = writer.Close() 107 | if err != nil { 108 | t.Errorf("Unexpected error: %v", err) 109 | } 110 | } 111 | 112 | func TestUnixgramWriter_LowLatencyBuffer(t *testing.T) { 113 | // Create server 114 | server, _, serverErr := newUnixgramServer() 115 | if serverErr != nil { 116 | t.Fatalf("Failed to create unixgram server: %v", serverErr) 117 | } 118 | defer server.Close() 119 | 120 | // Create writer 121 | writer, err := NewUnixgramWriterWithBuffer(testUnixgramSocket, logger.NewDefaultLogger(), 65537, time.Second) 122 | if err != nil { 123 | t.Fatalf("Failed to create writer: %v", err) 124 | } 125 | if writer.lineBuffer != nil { 126 | t.Errorf("Expected nil LineBuffer") 127 | } 128 | if writer.lowLatencyBuffer == nil { 129 | t.Errorf("Expected LowLatencyBuffer") 130 | } 131 | err = writer.Close() 132 | if err != nil { 133 | t.Errorf("Unexpected error: %v", err) 134 | } 135 | } 136 | 137 | func TestUnixgramWriter_Write(t *testing.T) { 138 | // Create server 139 | server, msgCh, serverErr := newUnixgramServer() 140 | if serverErr != nil { 141 | t.Fatalf("Failed to create unixgram server: %v", serverErr) 142 | } 143 | defer server.Close() 144 | 145 | // Create writer 146 | writer, err := NewUnixgramWriter(testUnixgramSocket, logger.NewDefaultLogger()) 147 | if err != nil { 148 | t.Fatalf("Failed to create writer: %v", err) 149 | } 150 | 151 | // Write messages 152 | messages := []string{"message1", "message2", "message3"} 153 | for _, msg := range messages { 154 | writer.Write(msg) 155 | } 156 | 157 | // Read messages from server 158 | for _, origMsg := range messages { 159 | recvMsg, recvErr := readMessage(msgCh) 160 | if recvErr != nil { 161 | t.Errorf("Failed to receive message: %v", recvErr) 162 | } 163 | if recvMsg != origMsg { 164 | t.Errorf("Received message '%s' does not match original message '%s'", recvMsg, origMsg) 165 | } 166 | } 167 | 168 | // Allow time for messages to deliver 169 | time.Sleep(2 * time.Millisecond) 170 | } 171 | 172 | func TestUnixgramWriter_LineBuffer_Write(t *testing.T) { 173 | // Create server 174 | server, msgCh, serverErr := newUnixgramServer() 175 | if serverErr != nil { 176 | t.Fatalf("Failed to create unixgram server: %v", serverErr) 177 | } 178 | defer server.Close() 179 | 180 | // Create writer 181 | writer, err := NewUnixgramWriterWithBuffer(testUnixgramSocket, logger.NewDefaultLogger(), 20, 5*time.Second) 182 | if err != nil { 183 | t.Fatalf("Failed to create writer: %v", err) 184 | } 185 | 186 | // Write messages 187 | messages := []string{"message1", "message2", "message3"} 188 | for _, msg := range messages { 189 | writer.Write(msg) 190 | } 191 | 192 | // Read messages from server 193 | expected := "c:spectator-go.lineBuffer.overflows:1" 194 | recvMsg, recvErr := readMessage(msgCh) 195 | if recvErr != nil { 196 | t.Errorf("Failed to receive message: %v", recvErr) 197 | } 198 | if recvMsg != expected { 199 | t.Errorf("Received message '%s' does not match original message '%s'", recvMsg, expected) 200 | } 201 | 202 | expected = "message1\nmessage2\nmessage3" 203 | recvMsg, recvErr = readMessage(msgCh) 204 | if recvErr != nil { 205 | t.Errorf("Failed to receive message: %v", recvErr) 206 | } 207 | if recvMsg != expected { 208 | t.Errorf("Received message '%s' does not match original message '%s'", recvMsg, expected) 209 | } 210 | 211 | // Allow time for messages to deliver 212 | time.Sleep(2 * time.Millisecond) 213 | } 214 | -------------------------------------------------------------------------------- /spectator/writer/lowlatency_buffer_test.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "runtime" 7 | "strings" 8 | "testing" 9 | "time" 10 | ) 11 | 12 | func splitAndFilterMetricLines(memWriter *MemoryWriter) []string { 13 | var lines []string 14 | for _, mwLine := range memWriter.Lines() { 15 | for _, protocolLine := range strings.Split(mwLine, separator) { 16 | if strings.Contains(protocolLine, "spectator-go.lowLatencyBuffer") { 17 | // The number of buffer stats metric lines depends on flush activity - discard 18 | continue 19 | } 20 | lines = append(lines, protocolLine) 21 | } 22 | } 23 | return lines 24 | } 25 | 26 | func TestLowLatencyBuffer_ShardMessageDistribution(t *testing.T) { 27 | memWriter := &MemoryWriter{} 28 | shards := runtime.NumCPU() 29 | bufferSize := 2 * 2 * chunkSize * shards // two buffer sets, two 60 KB chunks/shard 30 | buffer := NewLowLatencyBuffer(memWriter, logger.NewDefaultLogger(), bufferSize, 5*time.Millisecond) 31 | defer buffer.Close() 32 | 33 | // Verify that the buffer sets have the expected shard count 34 | if len(buffer.frontBuffers) != shards { 35 | t.Errorf("Expected %d shards in the front buffer set, got %d", shards, len(buffer.frontBuffers)) 36 | } 37 | if len(buffer.backBuffers) != shards { 38 | t.Errorf("Expected %d shards in the back buffer set, got %d", shards, len(buffer.backBuffers)) 39 | } 40 | 41 | // Write messages and verify they're distributed across buffer shards 42 | numMessages := shards * 10 43 | for i := 0; i < numMessages; i++ { 44 | buffer.Write(fmt.Sprintf("message=%v,", i)) 45 | } 46 | 47 | // Check that multiple buffer shards have data 48 | shardsWithData := 0 49 | for _, shard := range buffer.frontBuffers { 50 | shard.mu.Lock() 51 | if len(shard.data) > 0 { 52 | shardsWithData++ 53 | } 54 | shard.mu.Unlock() 55 | } 56 | 57 | if shardsWithData == 0 { 58 | t.Error("No buffer shards have data, data distribution may not be working") 59 | } 60 | 61 | // Wait for flush and verify all messages are received, filtering out statistics metrics 62 | time.Sleep(15 * time.Millisecond) 63 | 64 | lines := splitAndFilterMetricLines(memWriter) 65 | if len(lines) != numMessages { 66 | t.Errorf("Expected %d protocol lines in MemoryWriter, got %d", numMessages, len(lines)) 67 | } 68 | } 69 | 70 | func TestLowLatencyBuffer_FrontBuffersFlushFirst(t *testing.T) { 71 | // Create a buffer instance with a long flush interval timer, to allow for manual flush trigger 72 | memWriter := &MemoryWriter{} 73 | shards := runtime.NumCPU() 74 | bufferSize := 2 * 2 * chunkSize * shards // two buffer sets, two 60 KB chunks/shard 75 | buffer := NewLowLatencyBuffer(memWriter, logger.NewDefaultLogger(), bufferSize, 3*time.Minute) 76 | defer buffer.Close() 77 | 78 | if buffer.useFrontBuffers.Load() != true { 79 | t.Errorf("Expected useFrontBuffers to be true") 80 | } 81 | 82 | // Ensure that buffer swapping and flushing logic is correct 83 | buffer.Write("message1") 84 | buffer.swapAndFlush() 85 | 86 | if buffer.useFrontBuffers.Load() != false { 87 | t.Errorf("Expected useFrontBuffers to be false") 88 | } 89 | 90 | lines := splitAndFilterMetricLines(memWriter) 91 | if len(lines) != 1 { 92 | t.Errorf("Expected %d lines in the front buffer set, got %d", 1, len(lines)) 93 | } 94 | if lines[0] != "message1" { 95 | t.Errorf("Expected first message to be message1, got %s", lines[0]) 96 | } 97 | } 98 | 99 | func TestLowLatencyBuffer_ChunkBoundaries_HalfSize(t *testing.T) { 100 | // Create a buffer instance with a long flush interval timer, to allow for manual flush trigger 101 | memWriter := &MemoryWriter{} 102 | shards := runtime.NumCPU() 103 | bufferSize := 2 * 2 * chunkSize * shards // two buffer sets, two 60 KB chunks/shard 104 | buffer := NewLowLatencyBuffer(memWriter, logger.NewDefaultLogger(), bufferSize, 3*time.Minute) 105 | defer buffer.Close() 106 | 107 | var totalMessages int 108 | 109 | // Fill all chunks with half the max size message 110 | for i := 0; i < 2; i++ { 111 | for j := 0; j < shards; j++ { 112 | msg := strings.Repeat("x", chunkSize/2) 113 | buffer.Write(msg) 114 | totalMessages++ 115 | } 116 | } 117 | 118 | buffer.swapAndFlush() 119 | 120 | // Verify the total number of lines received 121 | lines := splitAndFilterMetricLines(memWriter) 122 | if len(lines) != totalMessages { 123 | t.Errorf("Expected %d lines, got %d", totalMessages, len(lines)) 124 | } 125 | 126 | // Verify the messages match the chunk size 127 | for _, line := range lines { 128 | if len(line) != chunkSize/2 { 129 | t.Errorf("Expected %d message size, got %d", chunkSize/2, len(line)) 130 | } 131 | } 132 | } 133 | 134 | func TestLowLatencyBuffer_ChunkBoundaries_MaxSize(t *testing.T) { 135 | // Create a buffer instance with a long flush interval timer, to allow for manual flush trigger 136 | memWriter := &MemoryWriter{} 137 | shards := runtime.NumCPU() 138 | bufferSize := 2 * 2 * chunkSize * shards // two buffer sets, two 60 KB chunks/shard 139 | buffer := NewLowLatencyBuffer(memWriter, logger.NewDefaultLogger(), bufferSize, 3*time.Minute) 140 | defer buffer.Close() 141 | 142 | var totalMessages int 143 | 144 | // Fill all chunks with the max size message 145 | for i := 0; i < 2; i++ { 146 | for j := 0; j < shards; j++ { 147 | msg := strings.Repeat("x", chunkSize) 148 | buffer.Write(msg) 149 | totalMessages++ 150 | } 151 | } 152 | 153 | buffer.swapAndFlush() 154 | 155 | // Verify the total number of lines received 156 | lines := splitAndFilterMetricLines(memWriter) 157 | if len(lines) != totalMessages { 158 | t.Errorf("Expected %d lines, got %d", totalMessages, len(lines)) 159 | } 160 | 161 | // Verify the messages match the chunk size 162 | for _, line := range lines { 163 | if len(line) != chunkSize { 164 | t.Errorf("Expected %d message size, got %d", chunkSize, len(line)) 165 | } 166 | } 167 | } 168 | 169 | func TestLowLatencyBuffer_ChunkBoundaries_OverMaxSizeIsDropped(t *testing.T) { 170 | // Create a buffer instance with a long flush interval timer, to allow for manual flush trigger 171 | memWriter := &MemoryWriter{} 172 | shards := runtime.NumCPU() 173 | bufferSize := 2 * 2 * chunkSize * shards // two buffer sets, two 60 KB chunks/shard 174 | buffer := NewLowLatencyBuffer(memWriter, logger.NewDefaultLogger(), bufferSize, 3*time.Minute) 175 | defer buffer.Close() 176 | 177 | // Fill all chunks with an over max size message 178 | for i := 0; i < 2; i++ { 179 | for j := 0; j < shards; j++ { 180 | msg := strings.Repeat("x", chunkSize + 1) 181 | buffer.Write(msg) 182 | } 183 | } 184 | 185 | buffer.swapAndFlush() 186 | 187 | // Verify the total number of lines received 188 | lines := splitAndFilterMetricLines(memWriter) 189 | if len(lines) != 0 { 190 | t.Errorf("Expected %d lines, got %d", 0, len(lines)) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /spectator/registry.go: -------------------------------------------------------------------------------- 1 | // Package spectator provides a minimal Go implementation of the Netflix Java 2 | // Spectator library. The goal of this package is to allow Go programs to emit 3 | // metrics to Atlas. 4 | // 5 | // Please refer to the Java Spectator documentation for information on 6 | // spectator / Atlas fundamentals: https://netflix.github.io/spectator/en/latest/ 7 | package spectator 8 | 9 | import ( 10 | "fmt" 11 | "github.com/Netflix/spectator-go/v2/spectator/logger" 12 | "github.com/Netflix/spectator-go/v2/spectator/meter" 13 | "github.com/Netflix/spectator-go/v2/spectator/writer" 14 | "time" 15 | ) 16 | 17 | // Meter represents the functionality presented by the individual meter types. 18 | type Meter interface { 19 | MeterId() *meter.Id 20 | } 21 | 22 | // Registry is the main entry point for interacting with the Spectator library. 23 | type Registry interface { 24 | GetLogger() logger.Logger 25 | NewId(name string, tags map[string]string) *meter.Id 26 | AgeGauge(name string, tags map[string]string) *meter.AgeGauge 27 | AgeGaugeWithId(id *meter.Id) *meter.AgeGauge 28 | Counter(name string, tags map[string]string) *meter.Counter 29 | CounterWithId(id *meter.Id) *meter.Counter 30 | DistributionSummary(name string, tags map[string]string) *meter.DistributionSummary 31 | DistributionSummaryWithId(id *meter.Id) *meter.DistributionSummary 32 | Gauge(name string, tags map[string]string) *meter.Gauge 33 | GaugeWithId(id *meter.Id) *meter.Gauge 34 | GaugeWithTTL(name string, tags map[string]string, ttl time.Duration) *meter.Gauge 35 | GaugeWithIdWithTTL(id *meter.Id, ttl time.Duration) *meter.Gauge 36 | MaxGauge(name string, tags map[string]string) *meter.MaxGauge 37 | MaxGaugeWithId(id *meter.Id) *meter.MaxGauge 38 | MonotonicCounter(name string, tags map[string]string) *meter.MonotonicCounter 39 | MonotonicCounterWithId(id *meter.Id) *meter.MonotonicCounter 40 | MonotonicCounterUint(name string, tags map[string]string) *meter.MonotonicCounterUint 41 | MonotonicCounterUintWithId(id *meter.Id) *meter.MonotonicCounterUint 42 | PercentileDistributionSummary(name string, tags map[string]string) *meter.PercentileDistributionSummary 43 | PercentileDistributionSummaryWithId(id *meter.Id) *meter.PercentileDistributionSummary 44 | PercentileTimer(name string, tags map[string]string) *meter.PercentileTimer 45 | PercentileTimerWithId(id *meter.Id) *meter.PercentileTimer 46 | Timer(name string, tags map[string]string) *meter.Timer 47 | TimerWithId(id *meter.Id) *meter.Timer 48 | GetWriter() writer.Writer 49 | Close() 50 | } 51 | 52 | // Used to validate that spectatordRegistry implements Registry at build time. 53 | var _ Registry = (*spectatordRegistry)(nil) 54 | 55 | type spectatordRegistry struct { 56 | config *Config 57 | writer writer.Writer 58 | logger logger.Logger 59 | } 60 | 61 | // NewRegistry generates a new registry from a passed Config created through NewConfig. 62 | func NewRegistry(config *Config) (Registry, error) { 63 | if config == nil { 64 | return nil, fmt.Errorf("Config cannot be nil") 65 | } 66 | 67 | if config.location == "" { 68 | // Config was not created using NewConfig. Set a default config instead of using the passed one. 69 | config, _ = NewConfig("", nil, nil) 70 | } 71 | 72 | newWriter, err := writer.NewWriterWithBuffer(config.location, config.log, config.bufferSize, config.flushInterval) 73 | if err != nil { 74 | return nil, err 75 | } 76 | 77 | config.log.Infof("Create Registry with extraCommonTags=%v", config.extraCommonTags) 78 | 79 | r := &spectatordRegistry{ 80 | config: config, 81 | writer: newWriter, 82 | logger: config.log, 83 | } 84 | 85 | return r, nil 86 | } 87 | 88 | // GetLogger returns the internal logger. 89 | func (r *spectatordRegistry) GetLogger() logger.Logger { 90 | return r.logger 91 | } 92 | 93 | // NewId calls meters.NewId() and adds the extraCommonTags registered in the config. 94 | func (r *spectatordRegistry) NewId(name string, tags map[string]string) *meter.Id { 95 | newId := meter.NewId(name, tags) 96 | 97 | if len(r.config.extraCommonTags) > 0 { 98 | newId = newId.WithTags(r.config.extraCommonTags) 99 | } 100 | 101 | return newId 102 | } 103 | 104 | func (r *spectatordRegistry) AgeGauge(name string, tags map[string]string) *meter.AgeGauge { 105 | return meter.NewAgeGauge(r.NewId(name, tags), r.writer) 106 | } 107 | 108 | func (r *spectatordRegistry) AgeGaugeWithId(id *meter.Id) *meter.AgeGauge { 109 | return meter.NewAgeGauge(id, r.writer) 110 | } 111 | 112 | func (r *spectatordRegistry) Counter(name string, tags map[string]string) *meter.Counter { 113 | return meter.NewCounter(r.NewId(name, tags), r.writer) 114 | } 115 | 116 | func (r *spectatordRegistry) CounterWithId(id *meter.Id) *meter.Counter { 117 | return meter.NewCounter(id, r.writer) 118 | } 119 | 120 | func (r *spectatordRegistry) DistributionSummary(name string, tags map[string]string) *meter.DistributionSummary { 121 | return meter.NewDistributionSummary(r.NewId(name, tags), r.writer) 122 | } 123 | 124 | func (r *spectatordRegistry) DistributionSummaryWithId(id *meter.Id) *meter.DistributionSummary { 125 | return meter.NewDistributionSummary(id, r.writer) 126 | } 127 | 128 | func (r *spectatordRegistry) Gauge(name string, tags map[string]string) *meter.Gauge { 129 | return meter.NewGauge(r.NewId(name, tags), r.writer) 130 | } 131 | 132 | func (r *spectatordRegistry) GaugeWithId(id *meter.Id) *meter.Gauge { 133 | return meter.NewGauge(id, r.writer) 134 | } 135 | 136 | func (r *spectatordRegistry) GaugeWithTTL(name string, tags map[string]string, duration time.Duration) *meter.Gauge { 137 | return meter.NewGaugeWithTTL(r.NewId(name, tags), r.writer, duration) 138 | } 139 | 140 | func (r *spectatordRegistry) GaugeWithIdWithTTL(id *meter.Id, duration time.Duration) *meter.Gauge { 141 | return meter.NewGaugeWithTTL(id, r.writer, duration) 142 | } 143 | 144 | func (r *spectatordRegistry) MaxGauge(name string, tags map[string]string) *meter.MaxGauge { 145 | return meter.NewMaxGauge(r.NewId(name, tags), r.writer) 146 | } 147 | 148 | func (r *spectatordRegistry) MaxGaugeWithId(id *meter.Id) *meter.MaxGauge { 149 | return meter.NewMaxGauge(id, r.writer) 150 | } 151 | 152 | func (r *spectatordRegistry) MonotonicCounter(name string, tags map[string]string) *meter.MonotonicCounter { 153 | return meter.NewMonotonicCounter(r.NewId(name, tags), r.writer) 154 | } 155 | 156 | func (r *spectatordRegistry) MonotonicCounterWithId(id *meter.Id) *meter.MonotonicCounter { 157 | return meter.NewMonotonicCounter(id, r.writer) 158 | } 159 | 160 | func (r *spectatordRegistry) MonotonicCounterUint(name string, tags map[string]string) *meter.MonotonicCounterUint { 161 | return meter.NewMonotonicCounterUint(r.NewId(name, tags), r.writer) 162 | } 163 | 164 | func (r *spectatordRegistry) MonotonicCounterUintWithId(id *meter.Id) *meter.MonotonicCounterUint { 165 | return meter.NewMonotonicCounterUint(id, r.writer) 166 | } 167 | 168 | func (r *spectatordRegistry) PercentileDistributionSummary(name string, tags map[string]string) *meter.PercentileDistributionSummary { 169 | return meter.NewPercentileDistributionSummary(r.NewId(name, tags), r.writer) 170 | } 171 | 172 | func (r *spectatordRegistry) PercentileDistributionSummaryWithId(id *meter.Id) *meter.PercentileDistributionSummary { 173 | return meter.NewPercentileDistributionSummary(id, r.writer) 174 | } 175 | 176 | func (r *spectatordRegistry) PercentileTimer(name string, tags map[string]string) *meter.PercentileTimer { 177 | return meter.NewPercentileTimer(r.NewId(name, tags), r.writer) 178 | } 179 | 180 | func (r *spectatordRegistry) PercentileTimerWithId(id *meter.Id) *meter.PercentileTimer { 181 | return meter.NewPercentileTimer(id, r.writer) 182 | } 183 | 184 | func (r *spectatordRegistry) Timer(name string, tags map[string]string) *meter.Timer { 185 | return meter.NewTimer(r.NewId(name, tags), r.writer) 186 | } 187 | 188 | func (r *spectatordRegistry) TimerWithId(id *meter.Id) *meter.Timer { 189 | return meter.NewTimer(id, r.writer) 190 | } 191 | 192 | func (r *spectatordRegistry) GetWriter() writer.Writer { 193 | return r.writer 194 | } 195 | 196 | func (r *spectatordRegistry) Close() { 197 | r.GetLogger().Infof("Close Registry Writer") 198 | err := r.writer.Close() 199 | if err != nil { 200 | r.GetLogger().Errorf("Error closing Registry Writer: %v", err) 201 | } 202 | } 203 | -------------------------------------------------------------------------------- /spectator/writer/udp_writer_test.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "github.com/Netflix/spectator-go/v2/spectator/logger" 5 | "net" 6 | "sort" 7 | "strconv" 8 | "sync" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | func TestNewUdpWriter(t *testing.T) { 14 | writer, err := NewUdpWriter("localhost:5000", logger.NewDefaultLogger()) 15 | if err != nil { 16 | t.Errorf("Unexpected error: %v", err) 17 | } 18 | if writer == nil { 19 | t.Errorf("Expected writer to be not nil") 20 | } 21 | } 22 | 23 | func TestUdpWriter_NoBuffer(t *testing.T) { 24 | writer, _ := NewUdpWriterWithBuffer("localhost:5000", logger.NewDefaultLogger(), 0, time.Second) 25 | if writer.lineBuffer != nil { 26 | t.Errorf("Expected nil LineBuffer") 27 | } 28 | if writer.lowLatencyBuffer != nil { 29 | t.Errorf("Expected nil LowLatencyBuffer") 30 | } 31 | err := writer.Close() 32 | if err != nil { 33 | t.Errorf("Unexpected error: %v", err) 34 | } 35 | } 36 | 37 | func TestUdpWriter_LineBuffer(t *testing.T) { 38 | writer, _ := NewUdpWriterWithBuffer("localhost:5000", logger.NewDefaultLogger(), 65536, time.Second) 39 | if writer.lineBuffer == nil { 40 | t.Errorf("Expected LineBuffer") 41 | } 42 | if writer.lowLatencyBuffer != nil { 43 | t.Errorf("Expected nil LowLatencyBuffer") 44 | } 45 | err := writer.Close() 46 | if err != nil { 47 | t.Errorf("Unexpected error: %v", err) 48 | } 49 | } 50 | 51 | func TestUdpWriter_LowLatencyBuffer(t *testing.T) { 52 | writer, _ := NewUdpWriterWithBuffer("localhost:5000", logger.NewDefaultLogger(), 65537, time.Second) 53 | if writer.lineBuffer != nil { 54 | t.Errorf("Expected nil LineBuffer") 55 | } 56 | if writer.lowLatencyBuffer == nil { 57 | t.Errorf("Expected LowLatencyBuffer") 58 | } 59 | err := writer.Close() 60 | if err != nil { 61 | t.Errorf("Unexpected error: %v", err) 62 | } 63 | } 64 | 65 | func TestUdpWriter_Close(t *testing.T) { 66 | writer, _ := NewUdpWriter("localhost:5000", logger.NewDefaultLogger()) 67 | err := writer.Close() 68 | if err != nil { 69 | t.Errorf("Unexpected error: %v", err) 70 | } 71 | } 72 | 73 | func TestNewUdpWriter_InvalidAddress(t *testing.T) { 74 | writer, err := NewUdpWriter("invalid address", logger.NewDefaultLogger()) 75 | if err == nil { 76 | t.Errorf("Expected error, got nil") 77 | } 78 | if writer != nil { 79 | t.Errorf("Expected writer to be nil") 80 | } 81 | } 82 | 83 | // Test write after close using a local UDP server 84 | func TestUdpWriter_WriteAfterClose(t *testing.T) { 85 | // Start a local UDP server 86 | server, err := net.ListenPacket("udp", "localhost:0") 87 | if err != nil { 88 | t.Fatalf("Could not start UDP server: %v", err) 89 | } 90 | defer server.Close() 91 | 92 | // Create a new UDP writer 93 | writer, err := NewUdpWriter(server.LocalAddr().String(), logger.NewDefaultLogger()) 94 | if err != nil { 95 | t.Fatalf("Could not create UDP writer: %v", err) 96 | } 97 | 98 | // Close the writer 99 | _ = writer.Close() 100 | 101 | // Write a message 102 | writer.Write("test message") 103 | 104 | // Check that no message was received 105 | buffer := make([]byte, 1024) 106 | _ = server.SetReadDeadline(time.Now().Add(time.Second)) // prevent infinite blocking 107 | _, _, err = server.ReadFrom(buffer) 108 | // ReadFrom will throw error if no message is received after timeout 109 | if err == nil { 110 | t.Errorf("Expected error, got nil") 111 | } 112 | 113 | } 114 | 115 | func TestUdpWriter_Write(t *testing.T) { 116 | // Start a local UDP server 117 | server, err := net.ListenPacket("udp", "localhost:0") 118 | if err != nil { 119 | t.Fatalf("Could not start UDP server: %v", err) 120 | } 121 | defer server.Close() 122 | 123 | // Create a new UDP writer 124 | writer, err := NewUdpWriter(server.LocalAddr().String(), logger.NewDefaultLogger()) 125 | if err != nil { 126 | t.Fatalf("Could not create UDP writer: %v", err) 127 | } 128 | 129 | // Write a message 130 | message := "test message" 131 | writer.Write(message) 132 | 133 | // Read the message from the UDP server 134 | buffer := make([]byte, len(message)) 135 | _ = server.SetReadDeadline(time.Now().Add(time.Second)) // prevent infinite blocking 136 | n, _, err := server.ReadFrom(buffer) 137 | if err != nil { 138 | t.Fatalf("Could not read from UDP server: %v", err) 139 | } 140 | 141 | // Check the message 142 | if string(buffer[:n]) != message { 143 | t.Errorf("Expected '%s', got '%s'", message, string(buffer[:n])) 144 | } 145 | } 146 | 147 | func TestUdpWriter_LineBuffer_Write(t *testing.T) { 148 | // Start a local UDP server 149 | server, err := net.ListenPacket("udp", "localhost:0") 150 | if err != nil { 151 | t.Fatalf("Could not start UDP server: %v", err) 152 | } 153 | defer server.Close() 154 | 155 | // Create a new UDP writer 156 | writer, err := NewUdpWriterWithBuffer(server.LocalAddr().String(), logger.NewDefaultLogger(), 20, 5*time.Second) 157 | if err != nil { 158 | t.Fatalf("Could not create UDP writer: %v", err) 159 | } 160 | 161 | // Write messages and overflow the buffer, to trigger a flush 162 | writer.Write("message1") 163 | writer.Write("message2") 164 | writer.Write("message3") 165 | 166 | // Read the message from the UDP server 167 | buffer := make([]byte, 50) 168 | _ = server.SetReadDeadline(time.Now().Add(time.Second)) // prevent infinite blocking 169 | n, _, err := server.ReadFrom(buffer) 170 | if err != nil { 171 | t.Fatalf("Could not read from UDP server: %v", err) 172 | } 173 | 174 | // Check the message 175 | expected := "c:spectator-go.lineBuffer.overflows:1" 176 | if string(buffer[:n]) != expected { 177 | t.Errorf("Expected '%s', got '%s'", expected, string(buffer[:n])) 178 | } 179 | 180 | // Read the message from the UDP server 181 | buffer = make([]byte, 50) 182 | _ = server.SetReadDeadline(time.Now().Add(time.Second)) // prevent infinite blocking 183 | n, _, err = server.ReadFrom(buffer) 184 | if err != nil { 185 | t.Fatalf("Could not read from UDP server: %v", err) 186 | } 187 | 188 | // Check the message 189 | expected = "message1\nmessage2\nmessage3" 190 | if string(buffer[:n]) != expected { 191 | t.Errorf("Expected '%s', got '%s'", expected, string(buffer[:n])) 192 | } 193 | } 194 | 195 | func TestConcurrentWrites(t *testing.T) { 196 | messagesPerThread := 1000 197 | writerThreadCount := 4 198 | var lines []string 199 | 200 | // Start a local UDP server 201 | server, err := net.ListenPacket("udp", "localhost:0") 202 | if err != nil { 203 | t.Fatalf("Could not start UDP server: %v", err) 204 | } 205 | defer server.Close() 206 | 207 | // Create a new UDP writer 208 | writer, err := NewUdpWriter(server.LocalAddr().String(), logger.NewDefaultLogger()) 209 | if err != nil { 210 | t.Fatalf("Could not create UDP writer: %v", err) 211 | } 212 | defer writer.Close() 213 | 214 | var writerWg sync.WaitGroup 215 | var readerWg sync.WaitGroup 216 | 217 | reader := func() { 218 | defer readerWg.Done() 219 | 220 | for { 221 | // read line from UDP server 222 | buffer := make([]byte, 1024) 223 | _ = server.SetReadDeadline(time.Now().Add(time.Second)) // prevent infinite blocking 224 | n, _, err := server.ReadFrom(buffer) 225 | if err != nil { 226 | t.Errorf("Error reading from UDP server: %v", err) 227 | break 228 | } 229 | 230 | line := string(buffer[:n]) 231 | 232 | if line == "done" { 233 | break 234 | } 235 | 236 | lines = append(lines, line) 237 | } 238 | } 239 | 240 | readerWg.Add(1) 241 | go reader() 242 | 243 | writerFunc := func(n int) { 244 | defer writerWg.Done() 245 | base := n * messagesPerThread 246 | for i := 0; i < messagesPerThread; i++ { 247 | writer.Write(strconv.Itoa(base + i)) 248 | } 249 | } 250 | 251 | // Start writer goroutines 252 | for j := 0; j < writerThreadCount; j++ { 253 | writerWg.Add(1) 254 | go writerFunc(j) 255 | } 256 | 257 | // Wait writer goroutines to finish 258 | writerWg.Wait() 259 | 260 | writer.Write("done") 261 | 262 | // Wait for reader goroutine to finish 263 | readerWg.Wait() 264 | 265 | m := writerThreadCount * messagesPerThread 266 | if len(lines) != m { 267 | t.Errorf("Expected %d, got %d", m, len(lines)) 268 | } 269 | 270 | // Create array of expected lines, sort lines and compare both 271 | expected := make([]int, m) 272 | for i := 0; i < m; i++ { 273 | expected[i] = i 274 | } 275 | 276 | // Convert lines to integers and sort 277 | intLines := make([]int, len(lines)) 278 | for i, line := range lines { 279 | value, err := strconv.Atoi(line) 280 | if err != nil { 281 | t.Errorf("Error converting line to integer: %v", err) 282 | return 283 | } 284 | intLines[i] = value 285 | } 286 | 287 | // sort intLines 288 | sort.Ints(intLines) 289 | 290 | // Compare lines with expected 291 | for i := 0; i < m; i++ { 292 | if intLines[i] != expected[i] { 293 | t.Errorf("Expected %d, got %d", expected[i], intLines[i]) 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /spectator/writer/lowlatency_buffer.go: -------------------------------------------------------------------------------- 1 | package writer 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "sync" 7 | "sync/atomic" 8 | "time" 9 | 10 | "github.com/Netflix/spectator-go/v2/spectator/logger" 11 | ) 12 | 13 | // chunkSize is set to 60KB, to ensure each message fits in the socket buffer (64KB), with some room 14 | // to accommodate the last spectatord protocol line appended. The maximum length of a well-formed 15 | // protocol line is 3,927 characters (3.8KB). 16 | const chunkSize = 60 * 1024 17 | 18 | // separator is the character used to indicate the end of a spectatord protocol line, when combining 19 | // lines into a larger socket payload 20 | const separator = "\n" 21 | 22 | // bufferShard is the atomic unit of buffering, used to store one or more chunks of spectatord 23 | // protocol lines. Each shard is accessed in round-robin format within either the front buffer 24 | // or the back buffer, and the number of shards is scaled to the number of CPUs on the system. 25 | // The purpose of this design is to spread buffer access across a reasonable number of mutexes, 26 | // in order to reduce overall latency when writing to the buffer. The impact of the shard design 27 | // is less than the impact of the front and back buffer design, but it is still important for 28 | // throughput reasons. 29 | type bufferShard struct { 30 | data [][]byte // Array of chunkSize chunks of spectatord protocol lines, stored as bytes 31 | chunkIndex int // Index of the chunk available for writes 32 | overflows int // Count the buffer overflows, which correspond to data drops, for reporting metrics 33 | overflowBytes int64 // Count the bytes that were dropped, for reporting metrics 34 | mu sync.Mutex 35 | } 36 | 37 | // getChunkIndexForLine returns the chunkIndex that should be used for storing the line, or -1, if there is 38 | // an overflow and the line cannot be stored in the bufferShard. 39 | func (b *bufferShard) getChunkIndexForLine(line []byte) int { 40 | totalWriteLength := len(line) 41 | 42 | // All chunks are full for the shard, drop the data 43 | if b.chunkIndex >= len(b.data) { 44 | b.overflows++ 45 | b.overflowBytes += int64(totalWriteLength) 46 | return -1 47 | } 48 | 49 | // This should not happen, drop the data. The maximum length of a well-formed protocol line is 3.8KB. 50 | if len(line) > chunkSize { 51 | b.overflows++ 52 | b.overflowBytes += int64(totalWriteLength) 53 | return -1 54 | } 55 | 56 | if len(b.data[b.chunkIndex]) > 0 { 57 | // Chunk has data, so account for the separator character 58 | totalWriteLength++ 59 | } 60 | 61 | if len(b.data[b.chunkIndex])+totalWriteLength > chunkSize { 62 | // Line does not fit in the current chunk, go to the next chunk 63 | b.chunkIndex++ 64 | } 65 | 66 | // Out of space in the shard, drop the data 67 | if b.chunkIndex == len(b.data) { 68 | b.overflows++ 69 | b.overflowBytes += int64(totalWriteLength) 70 | return -1 71 | } 72 | 73 | return b.chunkIndex 74 | } 75 | 76 | type LowLatencyBuffer struct { 77 | writer Writer 78 | logger logger.Logger 79 | 80 | // Two sets of bufferShard, each scaled to the number of CPUs on the system. There are two sets, 81 | // so that one can be drained for writes to the spectatord socket, while the other can be filled 82 | // with writes from the application without contending for the same mutexes. They are swapped back 83 | // and forth during periodic flushes, according to the flushInterval. 84 | frontBuffers []*bufferShard 85 | backBuffers []*bufferShard 86 | bufferSetSize int 87 | useFrontBuffers atomic.Bool 88 | flushInterval time.Duration 89 | 90 | // Distribute writes across the shards in the active buffer, with a round-robin scheme. 91 | counter uint64 92 | 93 | stopCh chan struct{} 94 | wg sync.WaitGroup 95 | } 96 | 97 | func NewLowLatencyBuffer(writer Writer, logger logger.Logger, bufferSize int, flushInterval time.Duration) *LowLatencyBuffer { 98 | numCPUs := runtime.NumCPU() 99 | frontBuffers := make([]*bufferShard, numCPUs) 100 | backBuffers := make([]*bufferShard, numCPUs) 101 | maxChunks := bufferSize / (2 * numCPUs * chunkSize) 102 | if maxChunks < 1 { 103 | maxChunks = 1 104 | bufferSize = maxChunks * 2 * numCPUs * chunkSize 105 | } 106 | 107 | logger.Infof("Initialize LowLatencyBuffer with size %d bytes (%d shards of %d chunks), and flushInterval of %.2f seconds", bufferSize, numCPUs, maxChunks, flushInterval.Seconds()) 108 | 109 | for i := 0; i < numCPUs; i++ { 110 | frontBuffers[i] = &bufferShard{ 111 | data: make([][]byte, maxChunks), 112 | chunkIndex: 0, 113 | overflows: 0, 114 | } 115 | backBuffers[i] = &bufferShard{ 116 | data: make([][]byte, maxChunks), 117 | chunkIndex: 0, 118 | overflows: 0, 119 | } 120 | // Allocate buffer memory up-front 121 | for j := 0; j < maxChunks; j++ { 122 | frontBuffers[i].data[j] = make([]byte, 0, chunkSize) 123 | backBuffers[i].data[j] = make([]byte, 0, chunkSize) 124 | } 125 | } 126 | 127 | llb := &LowLatencyBuffer{ 128 | writer: writer, 129 | logger: logger, 130 | frontBuffers: frontBuffers, 131 | backBuffers: backBuffers, 132 | bufferSetSize: bufferSize / 2, 133 | flushInterval: flushInterval, 134 | stopCh: make(chan struct{}), 135 | } 136 | 137 | llb.useFrontBuffers.Store(true) 138 | 139 | // Start the flush goroutine 140 | llb.wg.Add(1) 141 | go llb.flushLoop() 142 | 143 | return llb 144 | } 145 | 146 | func (llb *LowLatencyBuffer) Write(line string) { 147 | // Pick a shard index across all shards in the active buffer, with a round-robin distribution 148 | shardIndex := int(atomic.AddUint64(&llb.counter, 1)) % len(llb.frontBuffers) 149 | 150 | // Acquire read lock, to check which buffers are active 151 | var buffer *bufferShard 152 | if llb.useFrontBuffers.Load() { 153 | buffer = llb.frontBuffers[shardIndex] 154 | } else { 155 | buffer = llb.backBuffers[shardIndex] 156 | } 157 | 158 | // Add the line to the appropriate chunk in the buffer shard, or drop, if it overflows 159 | buffer.mu.Lock() 160 | defer buffer.mu.Unlock() 161 | lineBytes := []byte(line) 162 | 163 | // Check if current chunk can fit the new data 164 | idx := buffer.getChunkIndexForLine(lineBytes) 165 | if idx == -1 { 166 | // overflows (drops) are counted in getChunkIndexForLine, for metric reporting 167 | return 168 | } 169 | 170 | // We can write to the selected chunk 171 | if len(buffer.data[buffer.chunkIndex]) > 0 { 172 | // buffer has data, so add the separator, to indicate the end of the previous line 173 | buffer.data[buffer.chunkIndex] = append(buffer.data[buffer.chunkIndex], []byte(separator)...) 174 | } 175 | buffer.data[buffer.chunkIndex] = append(buffer.data[buffer.chunkIndex], lineBytes...) 176 | } 177 | 178 | // flushLoop runs in a separate goroutine and handles buffer swapping and flushing 179 | func (llb *LowLatencyBuffer) flushLoop() { 180 | defer llb.wg.Done() 181 | 182 | ticker := time.NewTicker(llb.flushInterval) 183 | defer ticker.Stop() 184 | 185 | for { 186 | select { 187 | case <-ticker.C: 188 | llb.swapAndFlush() 189 | case <-llb.stopCh: 190 | // Final flush before shutdown 191 | llb.swapAndFlush() 192 | return 193 | } 194 | } 195 | } 196 | 197 | // swapAndFlush swaps the front and back buffers and flushes the deactivated buffers 198 | func (llb *LowLatencyBuffer) swapAndFlush() { 199 | start := time.Now() 200 | 201 | // Swap the buffer sets, so one can be drained, while the other accepts application writes 202 | old := llb.useFrontBuffers.Load() 203 | llb.useFrontBuffers.CompareAndSwap(old, !old) 204 | 205 | var bufferSet string 206 | var buffersToFlush []*bufferShard 207 | if llb.useFrontBuffers.Load() { 208 | // Front buffers are now in use for application writes, so flush the back buffers 209 | bufferSet = "back" 210 | buffersToFlush = llb.backBuffers 211 | } else { 212 | // Back buffers are now in use application writes, so flush the front buffers 213 | bufferSet = "front" 214 | buffersToFlush = llb.frontBuffers 215 | } 216 | 217 | // Flush each buffer shard, from the deactivated set 218 | var bytesWritten int 219 | for _, buffer := range buffersToFlush { 220 | bytesWritten += llb.flushBufferShard(buffer, bufferSet) 221 | } 222 | 223 | pctUsage := float64(bytesWritten) / float64(llb.bufferSetSize) 224 | if bytesWritten > 0 { 225 | llb.writer.WriteString(fmt.Sprintf("c:spectator-go.lowLatencyBuffer.bytesWritten,bufferSet=%s:%d", bufferSet, bytesWritten)) 226 | } 227 | if pctUsage > 0 { 228 | llb.writer.WriteString(fmt.Sprintf("g,1:spectator-go.lowLatencyBuffer.pctUsage,bufferSet=%s:%f", bufferSet, pctUsage)) 229 | } 230 | 231 | llb.writer.WriteString(fmt.Sprintf("t:spectator-go.lowLatencyBuffer.flushTime,bufferSet=%s:%f", bufferSet, time.Since(start).Seconds())) 232 | } 233 | 234 | // flushBufferShard flushes a single bufferShard to the socket, iterating through all chunks 235 | func (llb *LowLatencyBuffer) flushBufferShard(buffer *bufferShard, bufferSet string) int { 236 | buffer.mu.Lock() 237 | defer buffer.mu.Unlock() 238 | 239 | // If there is no data to flush from the shard, then skip socket writes 240 | if buffer.chunkIndex == 0 && len(buffer.data[0]) == 0 { 241 | return 0 242 | } 243 | 244 | // Write each chunk to the socket, and reset the chunk 245 | var bytesWritten int 246 | for i := 0; i <= buffer.chunkIndex && i < len(buffer.data); i++ { 247 | if len(buffer.data[i]) > 0 { 248 | bytesWritten += len(buffer.data[i]) 249 | llb.writer.WriteBytes(buffer.data[i]) 250 | buffer.data[i] = buffer.data[i][:0] 251 | } 252 | } 253 | 254 | // record status metrics and reset shard statistics 255 | if buffer.overflows > 0 { 256 | llb.writer.WriteString(fmt.Sprintf("c:spectator-go.lowLatencyBuffer.overflows,bufferSet=%s:%d", bufferSet, buffer.overflows)) 257 | llb.writer.WriteString(fmt.Sprintf("d:spectator-go.lowLatencyBuffer.overflowBytes,bufferSet=%s:%d", bufferSet, buffer.overflowBytes)) 258 | buffer.overflows = 0 259 | buffer.overflowBytes = 0 260 | } 261 | buffer.chunkIndex = 0 262 | return bytesWritten 263 | } 264 | 265 | func (llb *LowLatencyBuffer) Close() { 266 | // Signal the flush goroutine to stop 267 | close(llb.stopCh) 268 | 269 | // Wait for the goroutine to finish 270 | llb.wg.Wait() 271 | } 272 | -------------------------------------------------------------------------------- /spectator/registry_test.go: -------------------------------------------------------------------------------- 1 | package spectator 2 | 3 | import ( 4 | "fmt" 5 | "github.com/Netflix/spectator-go/v2/spectator/logger" 6 | "github.com/Netflix/spectator-go/v2/spectator/writer" 7 | "testing" 8 | "time" 9 | ) 10 | 11 | func NewTestRegistry() Registry { 12 | config, _ := NewConfig("memory", nil, logger.NewDefaultLogger()) 13 | r, _ := NewRegistry(config) 14 | return r 15 | } 16 | 17 | func NewTestRegistryWithCommonTags() Registry { 18 | config, _ := NewConfig("memory", map[string]string{"extra-tag": "foo"}, logger.NewDefaultLogger()) 19 | r, _ := NewRegistry(config) 20 | return r 21 | } 22 | 23 | func TestRegistryWithMemoryWriter_AgeGauge(t *testing.T) { 24 | r := NewTestRegistry() 25 | mw := r.GetWriter().(*writer.MemoryWriter) 26 | 27 | ageGauge := r.AgeGauge("test_age_gauge", nil) 28 | ageGauge.Set(100) 29 | 30 | expected := "A:test_age_gauge:100" 31 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 32 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 33 | } 34 | } 35 | 36 | func TestRegistryWithMemoryWriter_AgeGaugeWithId(t *testing.T) { 37 | r := NewTestRegistryWithCommonTags() 38 | mw := r.GetWriter().(*writer.MemoryWriter) 39 | 40 | ageGauge := r.AgeGaugeWithId(r.NewId("test_age_gauge", nil)) 41 | ageGauge.Set(100) 42 | 43 | expected := "A:test_age_gauge,extra-tag=foo:100" 44 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 45 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 46 | } 47 | } 48 | 49 | func TestRegistryWithMemoryWriter_Counter(t *testing.T) { 50 | r := NewTestRegistry() 51 | mw := r.GetWriter().(*writer.MemoryWriter) 52 | 53 | counter := r.Counter("test_counter", nil) 54 | counter.Increment() 55 | 56 | expected := "c:test_counter:1" 57 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 58 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 59 | } 60 | } 61 | 62 | func TestRegistryWithMemoryWriter_CounterWithId(t *testing.T) { 63 | r := NewTestRegistryWithCommonTags() 64 | mw := r.GetWriter().(*writer.MemoryWriter) 65 | 66 | counter := r.CounterWithId(r.NewId("test_counter", nil)) 67 | counter.Increment() 68 | 69 | expected := "c:test_counter,extra-tag=foo:1" 70 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 71 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 72 | } 73 | } 74 | 75 | func TestRegistryWithMemoryWriter_DistributionSummary(t *testing.T) { 76 | r := NewTestRegistry() 77 | mw := r.GetWriter().(*writer.MemoryWriter) 78 | 79 | distSummary := r.DistributionSummary("test_distributionsummary", nil) 80 | distSummary.Record(300) 81 | 82 | expected := "d:test_distributionsummary:300" 83 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 84 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 85 | } 86 | } 87 | 88 | func TestRegistryWithMemoryWriter_DistributionSummaryWithId(t *testing.T) { 89 | r := NewTestRegistryWithCommonTags() 90 | mw := r.GetWriter().(*writer.MemoryWriter) 91 | 92 | distSummary := r.DistributionSummaryWithId(r.NewId("test_distributionsummary", nil)) 93 | distSummary.Record(300) 94 | 95 | expected := "d:test_distributionsummary,extra-tag=foo:300" 96 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 97 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 98 | } 99 | } 100 | 101 | func TestRegistryWithMemoryWriter_Gauge(t *testing.T) { 102 | r := NewTestRegistry() 103 | mw := r.GetWriter().(*writer.MemoryWriter) 104 | 105 | gauge := r.Gauge("test_gauge", nil) 106 | gauge.Set(100) 107 | 108 | expected := "g:test_gauge:100.000000" 109 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 110 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 111 | } 112 | } 113 | 114 | func TestRegistryWithMemoryWriter_GaugeWithId(t *testing.T) { 115 | r := NewTestRegistryWithCommonTags() 116 | mw := r.GetWriter().(*writer.MemoryWriter) 117 | 118 | gauge := r.GaugeWithId(r.NewId("test_gauge", nil)) 119 | gauge.Set(100) 120 | 121 | expected := "g:test_gauge,extra-tag=foo:100.000000" 122 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 123 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 124 | } 125 | } 126 | 127 | func TestRegistryWithMemoryWriter_GaugeWithTTL(t *testing.T) { 128 | r := NewTestRegistry() 129 | mw := r.GetWriter().(*writer.MemoryWriter) 130 | 131 | ttl := 60 * time.Second 132 | gauge := r.GaugeWithTTL("test_gauge_ttl", nil, ttl) 133 | gauge.Set(100.1) 134 | 135 | expected := fmt.Sprintf("g,%d:test_gauge_ttl:100.100000", int(ttl.Seconds())) 136 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 137 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 138 | } 139 | } 140 | 141 | func TestRegistryWithMemoryWriter_GaugeWithIdWithTTL(t *testing.T) { 142 | r := NewTestRegistryWithCommonTags() 143 | mw := r.GetWriter().(*writer.MemoryWriter) 144 | 145 | ttl := 60 * time.Second 146 | gauge := r.GaugeWithIdWithTTL(r.NewId("test_gauge_ttl", nil), ttl) 147 | gauge.Set(100.1) 148 | 149 | expected := fmt.Sprintf("g,%d:test_gauge_ttl,extra-tag=foo:100.100000", int(ttl.Seconds())) 150 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 151 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 152 | } 153 | } 154 | 155 | func TestRegistryWithMemoryWriter_MaxGauge(t *testing.T) { 156 | r := NewTestRegistry() 157 | mw := r.GetWriter().(*writer.MemoryWriter) 158 | 159 | maxGauge := r.MaxGauge("test_maxgauge", nil) 160 | maxGauge.Set(200) 161 | 162 | expected := "m:test_maxgauge:200.000000" 163 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 164 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 165 | } 166 | } 167 | 168 | func TestRegistryWithMemoryWriter_MaxGaugeWithId(t *testing.T) { 169 | r := NewTestRegistryWithCommonTags() 170 | mw := r.GetWriter().(*writer.MemoryWriter) 171 | 172 | maxGauge := r.MaxGaugeWithId(r.NewId("test_maxgauge", nil)) 173 | maxGauge.Set(200) 174 | 175 | expected := "m:test_maxgauge,extra-tag=foo:200.000000" 176 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 177 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 178 | } 179 | } 180 | 181 | func TestRegistryWithMemoryWriter_MonotonicCounter(t *testing.T) { 182 | r := NewTestRegistry() 183 | mw := r.GetWriter().(*writer.MemoryWriter) 184 | 185 | counter := r.MonotonicCounter("test_monotonic_counter", nil) 186 | counter.Set(1) 187 | expected := "C:test_monotonic_counter:1.000000" 188 | 189 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 190 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 191 | } 192 | } 193 | 194 | func TestRegistryWithMemoryWriter_MonotonicCounterWithId(t *testing.T) { 195 | r := NewTestRegistryWithCommonTags() 196 | mw := r.GetWriter().(*writer.MemoryWriter) 197 | 198 | counter := r.MonotonicCounterWithId(r.NewId("test_monotonic_counter", nil)) 199 | counter.Set(1) 200 | 201 | expected := "C:test_monotonic_counter,extra-tag=foo:1.000000" 202 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 203 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 204 | } 205 | } 206 | 207 | func TestRegistryWithMemoryWriter_MonotonicCounterUint(t *testing.T) { 208 | r := NewTestRegistry() 209 | mw := r.GetWriter().(*writer.MemoryWriter) 210 | 211 | counter := r.MonotonicCounterUint("test_monotonic_counter_uint", nil) 212 | counter.Set(1) 213 | 214 | expected := "U:test_monotonic_counter_uint:1" 215 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 216 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 217 | } 218 | } 219 | 220 | func TestRegistryWithMemoryWriter_MonotonicCounterUintWithId(t *testing.T) { 221 | r := NewTestRegistryWithCommonTags() 222 | mw := r.GetWriter().(*writer.MemoryWriter) 223 | 224 | counter := r.MonotonicCounterUintWithId(r.NewId("test_monotonic_counter_uint", nil)) 225 | counter.Set(1) 226 | 227 | expected := "U:test_monotonic_counter_uint,extra-tag=foo:1" 228 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 229 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 230 | } 231 | } 232 | 233 | func TestRegistryWithMemoryWriter_PercentileDistributionSummary(t *testing.T) { 234 | r := NewTestRegistry() 235 | mw := r.GetWriter().(*writer.MemoryWriter) 236 | 237 | percentileDistSummary := r.PercentileDistributionSummary("test_percentiledistributionsummary", nil) 238 | percentileDistSummary.Record(400) 239 | 240 | expected := "D:test_percentiledistributionsummary:400" 241 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 242 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 243 | } 244 | } 245 | 246 | func TestRegistryWithMemoryWriter_PercentileDistributionSummaryWithId(t *testing.T) { 247 | r := NewTestRegistryWithCommonTags() 248 | mw := r.GetWriter().(*writer.MemoryWriter) 249 | 250 | percentileDistSummary := r.PercentileDistributionSummaryWithId(r.NewId("test_percentiledistributionsummary", nil)) 251 | percentileDistSummary.Record(400) 252 | 253 | expected := "D:test_percentiledistributionsummary,extra-tag=foo:400" 254 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 255 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 256 | } 257 | } 258 | 259 | func TestRegistryWithMemoryWriter_PercentileTimer(t *testing.T) { 260 | r := NewTestRegistry() 261 | mw := r.GetWriter().(*writer.MemoryWriter) 262 | 263 | percentileTimer := r.PercentileTimer("test_percentiletimer", nil) 264 | percentileTimer.Record(500 * time.Millisecond) 265 | 266 | expected := "T:test_percentiletimer:0.500000" 267 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 268 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 269 | } 270 | } 271 | 272 | func TestRegistryWithMemoryWriter_PercentileTimerWithId(t *testing.T) { 273 | r := NewTestRegistryWithCommonTags() 274 | mw := r.GetWriter().(*writer.MemoryWriter) 275 | 276 | percentileTimer := r.PercentileTimerWithId(r.NewId("test_percentiletimer", nil)) 277 | percentileTimer.Record(500 * time.Millisecond) 278 | 279 | expected := "T:test_percentiletimer,extra-tag=foo:0.500000" 280 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 281 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 282 | } 283 | } 284 | 285 | func TestRegistryWithMemoryWriter_Timer(t *testing.T) { 286 | r := NewTestRegistry() 287 | mw := r.GetWriter().(*writer.MemoryWriter) 288 | 289 | timer := r.Timer("test_timer", nil) 290 | timer.Record(100 * time.Millisecond) 291 | 292 | expected := "t:test_timer:0.100000" 293 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 294 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 295 | } 296 | } 297 | 298 | func TestRegistryWithMemoryWriter_TimerWithId(t *testing.T) { 299 | r := NewTestRegistryWithCommonTags() 300 | mw := r.GetWriter().(*writer.MemoryWriter) 301 | 302 | timer := r.TimerWithId(r.NewId("test_timer", nil)) 303 | timer.Record(100 * time.Millisecond) 304 | 305 | expected := "t:test_timer,extra-tag=foo:0.100000" 306 | if len(mw.Lines()) != 1 || mw.Lines()[0] != expected { 307 | t.Errorf("Expected '%s', got '%s'", expected, mw.Lines()[0]) 308 | } 309 | } 310 | 311 | func TestNewRegistryWithEmptyConfig(t *testing.T) { 312 | _, err := NewRegistry(&Config{}) 313 | 314 | if err != nil { 315 | t.Errorf("Registry should not return an error for empty config, got '%v'", err) 316 | } 317 | } 318 | 319 | func TestNewRegistryWithNilConfig(t *testing.T) { 320 | _, err := NewRegistry(nil) 321 | 322 | if err == nil { 323 | t.Errorf("Registry should return an error for nil config, got nil") 324 | } 325 | } 326 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | --------------------------------------------------------------------------------