diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..14030fd5 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "gomod" + directory: "/" # Location of package manifests + schedule: + interval: "daily" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 00000000..aae81cb0 --- /dev/null +++ b/.github/labeler.yml @@ -0,0 +1,11 @@ +with tests: +- any: ['**/*_test.go'] + +config: +- any: ['config/**/*'] + +logger: +- any: ['logger/**/*'] + +stats: +- any: ['stats/**/*'] diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5167e3db --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +# Description + +< Replace with adequate description for this PR as per [Pull Request document](https://www.notion.so/rudderstacks/Pull-Requests-40a4c6bd7a5e4387ba9029bab297c9e3) > + +## Notion Ticket + +< Replace with Notion Link > + +## Security + +- [ ] The code changed/added as part of this pull request won't create any security issues with how the software is being used. diff --git a/.github/workflows/labeler.yaml b/.github/workflows/labeler.yaml new file mode 100644 index 00000000..af629198 --- /dev/null +++ b/.github/workflows/labeler.yaml @@ -0,0 +1,15 @@ +name: "Pull request labeler" +on: +- pull_request + +jobs: + triage: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@v4 + with: + repo-token: "${{ secrets.GITHUB_TOKEN }}" + sync-labels: true diff --git a/.github/workflows/pr-description-enforcer.yaml b/.github/workflows/pr-description-enforcer.yaml new file mode 100644 index 00000000..dbaadd7e --- /dev/null +++ b/.github/workflows/pr-description-enforcer.yaml @@ -0,0 +1,17 @@ +name: 'Pull request description' +on: + pull_request: + types: + - opened + - edited + - reopened + +jobs: + enforce: + runs-on: ubuntu-latest + + steps: + - uses: rudderlabs/pr-description-enforcer@v1.0.0 + with: + repo-token: '${{ secrets.GITHUB_TOKEN }}' + placeholder-regex: '< Replace .* >' diff --git a/.github/workflows/prerelease.yaml b/.github/workflows/prerelease.yaml new file mode 100644 index 00000000..af798430 --- /dev/null +++ b/.github/workflows/prerelease.yaml @@ -0,0 +1,54 @@ +on: + push: + branches: + - "prerelease/*" +name: Prerelease +jobs: + prerelease: + runs-on: ubuntu-latest + steps: + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + - uses: google-github-actions/release-please-action@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + pull-request-title-pattern: "chore: prerelease ${version}" + release-type: go + package-name: rudder-server + default-branch: ${{ steps.extract_branch.outputs.branch }} + changelog-types: ' + [ + { + "type": "feat", + "section": "Features", + "hidden": false + }, + { + "type": "fix", + "section": "Bug Fixes", + "hidden": false + }, + { + "type": "chore", + "section":"Miscellaneous", + "hidden": false}, + { + "type": "refactor", + "section": "Miscellaneous", + "hidden": false + }, + { + "type": "test", + "section": "Miscellaneous", + "hidden": false + }, + { + "type": "doc", + "section": "Documentation", + "hidden": false + } + ]' + prerelease: true + release-as: ${{ steps.extract_branch.outputs.branch }} diff --git a/.github/workflows/release-please.yaml b/.github/workflows/release-please.yaml new file mode 100644 index 00000000..e133dac8 --- /dev/null +++ b/.github/workflows/release-please.yaml @@ -0,0 +1,22 @@ +on: + push: + branches: + - "main" +name: release-please +jobs: + release-please: + runs-on: ubuntu-latest + steps: + - name: Extract branch name + shell: bash + run: echo "##[set-output name=branch;]$(echo ${GITHUB_REF#refs/heads/})" + id: extract_branch + - uses: google-github-actions/release-please-action@v3 + with: + token: ${{ secrets.GITHUB_TOKEN }} + pull-request-title-pattern: "chore: release ${version}" + release-type: go + package-name: rudder-server + default-branch: ${{ steps.extract_branch.outputs.branch }} + changelog-types: '[{"type":"feat","section":"Features","hidden":false},{"type":"fix","section":"Bug Fixes","hidden":false},{"type":"chore","section":"Miscellaneous","hidden":false},{"type":"refactor","section":"Miscellaneous","hidden":false},{"type":"test","section":"Miscellaneous","hidden":false},{"type":"doc","section":"Documentation","hidden":false}]' + bump-minor-pre-major: true diff --git a/.github/workflows/semantic-pr.yaml b/.github/workflows/semantic-pr.yaml new file mode 100644 index 00000000..90e13381 --- /dev/null +++ b/.github/workflows/semantic-pr.yaml @@ -0,0 +1,66 @@ +name: "Semantic pull request" + +on: + pull_request: + types: + - opened + - edited + - labeled + - unlabeled + - converted_to_draft + - ready_for_review + - synchronize + +jobs: + main: + name: title + runs-on: ubuntu-latest + steps: + - + uses: amannn/action-semantic-pull-request@v4 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + types: | + fix + feat + chore + refactor + exp + doc + test + scopes: | + config + logger + stats + testhelper + utils + ci + requireScope: false + subjectPattern: ^(?![A-Z]).+$ + subjectPatternError: | + The subject "{subject}" found in the pull request title "{title}" + didn't match the configured pattern. Please ensure that the subject + doesn't start with an uppercase character. + # For work-in-progress PRs you can typically use draft pull requests + # from GitHub. However, private repositories on the free plan don't have + # this option and therefore this action allows you to opt-in to using the + # special "[WIP]" prefix to indicate this state. This will avoid the + # validation of the PR title and the pull request checks remain pending. + # Note that a second check will be reported if this is enabled. + wip: true + # When using "Squash and merge" on a PR with only one commit, GitHub + # will suggest using that commit message instead of the PR title for the + # merge commit, and it's easy to commit this by mistake. Enable this option + # to also validate the commit message for one commit PRs. + validateSingleCommit: false + # Related to `validateSingleCommit` you can opt-in to validate that the PR + # title matches a single commit to avoid confusion. + validateSingleCommitMatchesPrTitle: false + # If the PR contains one of these labels, the validation is skipped. + # Multiple labels can be separated by newlines. + # If you want to rerun the validation when labels change, you might want + # to use the `labeled` and `unlabeled` event triggers in your workflow. + ignoreLabels: | + bot + dependencies diff --git a/.github/workflows/stale-pr.yaml b/.github/workflows/stale-pr.yaml new file mode 100644 index 00000000..9ce85dc4 --- /dev/null +++ b/.github/workflows/stale-pr.yaml @@ -0,0 +1,40 @@ +name: Stale PR + +on: + schedule: + - cron: '42 1 * * *' + +jobs: + prs: + name: cleanup + runs-on: ubuntu-latest + + permissions: + pull-requests: write + + steps: + - uses: actions/stale@v5 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + operations-per-run: 200 + stale-pr-message: 'This PR is considered to be stale. It has been open 20 days with no further activity thus it is going to be closed in 5 days. To avoid such a case please consider removing the stale label manually or add a comment to the PR.' + days-before-pr-stale: 20 + days-before-pr-close: 7 + stale-pr-label: 'Stale' + + branches: + name: Cleanup old branches + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: Run delete-old-branches-action + uses: beatlabs/delete-old-branches-action@v0.0.9 + with: + repo_token: ${{ github.token }} + date: '2 months ago' + dry_run: false + delete_tags: false + extra_protected_branch_regex: ^(main|master|release.*|rudder-saas)$ + exclude_open_pr_branches: true + diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 00000000..0f26120c --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,24 @@ +name: Tests +on: + push: + branches: + - master + - main + - "release/*" + pull_request: +jobs: + unit: + name: unit + runs-on: 'ubuntu-20.04' + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v3 + with: + go-version: '~1.20.1' + check-latest: true + cache: true + + - run: go version + - run: go mod download # Not required, used to segregate module download vs test times + - run: make test + - uses: codecov/codecov-action@v2 diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml new file mode 100644 index 00000000..7015aa0d --- /dev/null +++ b/.github/workflows/verify.yml @@ -0,0 +1,53 @@ +name: Verify +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: +jobs: + generate: + name: generated files + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-go@v3 + with: + check-latest: true + cache: true + go-version: '~1.20.1' + - run: go version + + - run: go mod tidy + - run: git diff --exit-code go.mod + - name: Error message + if: ${{ failure() }} + run: echo '::error file=go.mod,line=1,col=1::Inconsistent go mod file. Ensure you have run `go mod tidy` and committed the files locally.'; echo '::error file=enterprise_mod.go,line=1,col=1::Possible missing enterprise exclusive dependencies.' + + - run: make generate + - run: git diff --exit-code + - name: Error message + if: ${{ failure() }} + run: echo '::error file=Makefile,line=11,col=1::Incorrectly generated files. Ensure you have run `make generate` and committed the files locally.' + + - run: make fmt + - run: git diff --exit-code + - name: Error message + if: ${{ failure() }} + run: echo 'Not formatted files. Ensure you have run `make fmt` and committed the files locally.' + linting: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-go@v3 + with: + go-version: '~1.20.1' + check-latest: true + cache: true + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: v1.51.1 diff --git a/.gitignore b/.gitignore index 66fd13c9..84af1749 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,16 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE +.DS_Store +.vscode +*.coverprofile +runtime.log +*.coverprofile +junit*.xml +**/profile.out +**/*.test +.idea/* +*.out.* *.out - -# Dependency directories (remove the comment below to include it) -# vendor/ +coverage.txt +coverage.html +*.orig +**/gomock_reflect_*/* +ginkgo.report diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 00000000..51e12532 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,45 @@ +run: + timeout: 5m + go: '1.20' + +linters: + enable: + - deadcode + - errcheck + - gosimple + - govet + - ineffassign + - staticcheck + - structcheck + - typecheck + - unused + - varcheck + - bodyclose + - decorder + - makezero + - nilnil + - nilerr + - rowserrcheck + - tenv + - wastedassign + - unparam + - misspell + - unconvert + - depguard + +issues: + exclude-use-default: true + exclude-case-sensitive: false + max-issues-per-linter: 50 + max-same-issues: 10 + new: false + +linters-settings: + depguard: + # Kind of list is passed in. + # Allowed values: allowlist|denylist + # Default: denylist + list-type: denylist + # Check the list against standard lib. + # Default: false + include-go-root: true \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..23395684 --- /dev/null +++ b/Makefile @@ -0,0 +1,60 @@ +.PHONY: help default test test-run test-teardown generate lint fmt + +GO=go +LDFLAGS?=-s -w +TESTFILE=_testok + +default: lint + +generate: install-tools + $(GO) generate ./... + +test: install-tools test-run test-teardown + +test-run: ## Run all unit tests +ifeq ($(filter 1,$(debug) $(RUNNER_DEBUG)),) + $(eval TEST_CMD = SLOW=0 gotestsum --format pkgname-and-test-fails --) + $(eval TEST_OPTIONS = -p=1 -v -failfast -shuffle=on -coverprofile=profile.out -covermode=count -coverpkg=./... -vet=all --timeout=15m) +else + $(eval TEST_CMD = SLOW=0 go test) + $(eval TEST_OPTIONS = -p=1 -v -failfast -shuffle=on -coverprofile=profile.out -covermode=count -coverpkg=./... -vet=all --timeout=15m) +endif +ifdef package + $(TEST_CMD) $(TEST_OPTIONS) $(package) && touch $(TESTFILE) || true +else + $(TEST_CMD) -count=1 $(TEST_OPTIONS) ./... && touch $(TESTFILE) || true +endif + +test-teardown: + @if [ -f "$(TESTFILE)" ]; then \ + echo "Tests passed, tearing down..." ;\ + rm -f $(TESTFILE) ;\ + echo "mode: atomic" > coverage.txt ;\ + find . -name "profile.out" | while read file; do grep -v 'mode: atomic' $${file} >> coverage.txt; rm -f $${file}; done ;\ + else \ + rm -f coverage.txt coverage.html ; find . -name "profile.out" | xargs rm -f ;\ + echo "Tests failed :-(" ;\ + exit 1 ;\ + fi + +coverage: + go tool cover -html=coverage.txt -o coverage.html + +test-with-coverage: test coverage + +help: ## Show the available commands + @grep -E '^[0-9a-zA-Z_-]+:.*?## .*$$' ./Makefile | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' + +install-tools: + go install github.com/golang/mock/mockgen@v1.6.0 + go install mvdan.cc/gofumpt@latest + go install gotest.tools/gotestsum@v1.8.2 + +.PHONY: lint +lint: fmt ## Run linters on all go files + docker run --rm -v $(shell pwd):/app:ro -w /app golangci/golangci-lint:v1.51.1 bash -e -c \ + 'golangci-lint run -v --timeout 5m' + +.PHONY: fmt +fmt: install-tools ## Formats all go files + gofumpt -l -w -extra . diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..c3648ad6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,10 @@ +coverage: + status: + project: + default: + informational: true + patch: + default: + informational: true +ignore: + - "**/mock_*.go" \ No newline at end of file diff --git a/config/config.go b/config/config.go new file mode 100644 index 00000000..c3cda421 --- /dev/null +++ b/config/config.go @@ -0,0 +1,316 @@ +// Package config uses the exact same precedence order as Viper, where +// each item takes precedence over the item below it: +// +// - explicit call to Set (case insensitive) +// - flag (case insensitive) +// - env (case sensitive - see notes below) +// - config (case insensitive) +// - key/value store (case insensitive) +// - default (case insensitive) +// +// Environment variable resolution is performed based on the following rules: +// - If the key contains only uppercase characters, numbers and underscores, the environment variable is looked up in its entirety, e.g. SOME_VARIABLE -> SOME_VARIABLE +// - In all other cases, the environment variable is transformed before being looked up as following: +// 1. camelCase is converted to snake_case, e.g. someVariable -> some_variable +// 2. dots (.) are replaced with underscores (_), e.g. some.variable -> some_variable +// 3. the resulting string is uppercased and prefixed with ${PREFIX}_ (default RSERVER_), e.g. some_variable -> RSERVER_SOME_VARIABLE +package config + +import ( + "fmt" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/spf13/viper" +) + +const DefaultEnvPrefix = "RSERVER" + +// regular expression matching lowercase letter followed by an uppercase letter +var camelCaseMatch = regexp.MustCompile("([a-z0-9])([A-Z])") + +// regular expression matching uppercase letters contained in environment variable names +var upperCaseMatch = regexp.MustCompile("^[A-Z0-9_]+$") + +// default, singleton config instance +var Default *Config + +func init() { + Default = New() +} + +// Reset resets the default, singleton config instance. +// Shall only be used by tests, until we move to a proper DI framework +func Reset() { + Default = New() +} + +type Opt func(*Config) + +// WithEnvPrefix sets the environment variable prefix (default: RSERVER) +func WithEnvPrefix(prefix string) Opt { + return func(c *Config) { + c.envPrefix = prefix + } +} + +// New creates a new config instance +func New(opts ...Opt) *Config { + c := &Config{ + envPrefix: DefaultEnvPrefix, + } + for _, opt := range opts { + opt(c) + } + c.load() + return c +} + +// Config is the entry point for accessing configuration +type Config struct { + vLock sync.RWMutex // protects reading and writing to the config (viper is not thread-safe) + v *viper.Viper + hotReloadableConfigLock sync.RWMutex // protects map holding hot reloadable config keys + hotReloadableConfig map[string][]*configValue + envsLock sync.RWMutex // protects the envs map below + envs map[string]string + envPrefix string // prefix for environment variables +} + +// GetBool gets bool value from config +func GetBool(key string, defaultValue bool) (value bool) { + return Default.GetBool(key, defaultValue) +} + +// GetBool gets bool value from config +func (c *Config) GetBool(key string, defaultValue bool) (value bool) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetBool(key) +} + +// GetInt gets int value from config +func GetInt(key string, defaultValue int) (value int) { + return Default.GetInt(key, defaultValue) +} + +// GetInt gets int value from config +func (c *Config) GetInt(key string, defaultValue int) (value int) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetInt(key) +} + +// GetStringMap gets string map value from config +func GetStringMap(key string, defaultValue map[string]interface{}) (value map[string]interface{}) { + return Default.GetStringMap(key, defaultValue) +} + +// GetStringMap gets string map value from config +func (c *Config) GetStringMap(key string, defaultValue map[string]interface{}) (value map[string]interface{}) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetStringMap(key) +} + +// MustGetInt gets int value from config or panics if the config doesn't exist +func MustGetInt(key string) (value int) { + return Default.MustGetInt(key) +} + +// MustGetInt gets int value from config or panics if the config doesn't exist +func (c *Config) MustGetInt(key string) (value int) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + panic(fmt.Errorf("config key %s not found", key)) + } + return c.v.GetInt(key) +} + +// GetInt64 gets int64 value from config +func GetInt64(key string, defaultValue int64) (value int64) { + return Default.GetInt64(key, defaultValue) +} + +// GetInt64 gets int64 value from config +func (c *Config) GetInt64(key string, defaultValue int64) (value int64) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetInt64(key) +} + +// GetFloat64 gets float64 value from config +func GetFloat64(key string, defaultValue float64) (value float64) { + return Default.GetFloat64(key, defaultValue) +} + +// GetFloat64 gets float64 value from config +func (c *Config) GetFloat64(key string, defaultValue float64) (value float64) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetFloat64(key) +} + +// GetString gets string value from config +func GetString(key, defaultValue string) (value string) { + return Default.GetString(key, defaultValue) +} + +// GetString gets string value from config +func (c *Config) GetString(key, defaultValue string) (value string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetString(key) +} + +// MustGetString gets string value from config or panics if the config doesn't exist +func MustGetString(key string) (value string) { + return Default.MustGetString(key) +} + +// MustGetString gets string value from config or panics if the config doesn't exist +func (c *Config) MustGetString(key string) (value string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + panic(fmt.Errorf("config key %s not found", key)) + } + return c.v.GetString(key) +} + +// GetStringSlice gets string slice value from config +func GetStringSlice(key string, defaultValue []string) (value []string) { + return Default.GetStringSlice(key, defaultValue) +} + +// GetStringSlice gets string slice value from config +func (c *Config) GetStringSlice(key string, defaultValue []string) (value []string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return defaultValue + } + return c.v.GetStringSlice(key) +} + +// GetDuration gets duration value from config +func GetDuration(key string, defaultValueInTimescaleUnits int64, timeScale time.Duration) (value time.Duration) { + return Default.GetDuration(key, defaultValueInTimescaleUnits, timeScale) +} + +// GetDuration gets duration value from config +func (c *Config) GetDuration(key string, defaultValueInTimescaleUnits int64, timeScale time.Duration) (value time.Duration) { + c.vLock.RLock() + defer c.vLock.RUnlock() + if !c.IsSet(key) { + return time.Duration(defaultValueInTimescaleUnits) * timeScale + } else { + v := c.v.GetString(key) + parseDuration, err := time.ParseDuration(v) + if err == nil { + return parseDuration + } else { + _, err = strconv.ParseFloat(v, 64) + if err == nil { + return c.v.GetDuration(key) * timeScale + } else { + return time.Duration(defaultValueInTimescaleUnits) * timeScale + } + } + } +} + +// IsSet checks if config is set for a key +func IsSet(key string) bool { + return Default.IsSet(key) +} + +// IsSet checks if config is set for a key +func (c *Config) IsSet(key string) bool { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.bindEnv(key) + return c.v.IsSet(key) +} + +// Override Config by application or command line + +// Set override existing config +func Set(key string, value interface{}) { + Default.Set(key, value) +} + +// Set override existing config +func (c *Config) Set(key string, value interface{}) { + c.vLock.Lock() + c.v.Set(key, value) + c.vLock.Unlock() + c.onConfigChange() +} + +// bindEnv handles rudder server's unique snake case replacement by registering +// the environment variables to viper, that would otherwise be ignored. +// Viper uppercases keys before sending them to its EnvKeyReplacer, thus +// the replacer cannot detect camelCase keys. +func (c *Config) bindEnv(key string) { + envVar := key + if !upperCaseMatch.MatchString(key) { + envVar = ConfigKeyToEnv(c.envPrefix, key) + } + // bind once + c.envsLock.RLock() + if _, ok := c.envs[key]; !ok { + c.envsLock.RUnlock() + c.envsLock.Lock() // don't really care about race here, setting the same value + c.envs[strings.ToUpper(key)] = envVar + c.envsLock.Unlock() + } else { + c.envsLock.RUnlock() + } +} + +type envReplacer struct { + c *Config +} + +func (r *envReplacer) Replace(s string) string { + r.c.envsLock.RLock() + defer r.c.envsLock.RUnlock() + if v, ok := r.c.envs[s]; ok { + return v + } + return s // bound environment variables +} + +// Fallback environment variables supported (historically) by rudder-server +func bindLegacyEnv(v *viper.Viper) { + _ = v.BindEnv("DB.host", "JOBS_DB_HOST") + _ = v.BindEnv("DB.user", "JOBS_DB_USER") + _ = v.BindEnv("DB.name", "JOBS_DB_DB_NAME") + _ = v.BindEnv("DB.port", "JOBS_DB_PORT") + _ = v.BindEnv("DB.password", "JOBS_DB_PASSWORD") + _ = v.BindEnv("DB.sslMode", "JOBS_DB_SSL_MODE") + _ = v.BindEnv("SharedDB.dsn", "SHARED_DB_DSN") +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 00000000..9d859e12 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,331 @@ +package config + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func Test_Getters_Existing_and_Default(t *testing.T) { + tc := New() + tc.Set("string", "string") + require.Equal(t, "string", tc.GetString("string", "default"), "it should return the key value") + require.Equal(t, "default", tc.GetString("other", "default"), "it should return the default value") + + tc.Set("bool", false) + require.Equal(t, false, tc.GetBool("bool", true), "it should return the key value") + require.Equal(t, true, tc.GetBool("other", true), "it should return the default value") + + tc.Set("int", 0) + require.Equal(t, 0, tc.GetInt("int", 1), "it should return the key value") + require.Equal(t, 1, tc.GetInt("other", 1), "it should return the default value") + require.EqualValues(t, 0, tc.GetInt64("int", 1), "it should return the key value") + require.EqualValues(t, 1, tc.GetInt64("other", 1), "it should return the default value") + + tc.Set("float", 0.0) + require.EqualValues(t, 0, tc.GetFloat64("float", 1), "it should return the key value") + require.EqualValues(t, 1, tc.GetFloat64("other", 1), "it should return the default value") + + tc.Set("stringslice", []string{"string", "string"}) + require.Equal(t, []string{"string", "string"}, tc.GetStringSlice("stringslice", []string{"default"}), "it should return the key value") + require.Equal(t, []string{"default"}, tc.GetStringSlice("other", []string{"default"}), "it should return the default value") + + tc.Set("duration", "2ms") + require.Equal(t, 2*time.Millisecond, tc.GetDuration("duration", 1, time.Second), "it should return the key value") + require.Equal(t, time.Second, tc.GetDuration("other", 1, time.Second), "it should return the default value") + + tc.Set("duration", "2") + require.Equal(t, 2*time.Second, tc.GetDuration("duration", 1, time.Second), "it should return the key value") + require.Equal(t, time.Second, tc.GetDuration("other", 1, time.Second), "it should return the default value") + + tc.Set("stringmap", map[string]interface{}{"string": "any"}) + require.Equal(t, map[string]interface{}{"string": "any"}, tc.GetStringMap("stringmap", map[string]interface{}{"default": "value"}), "it should return the key value") + require.Equal(t, map[string]interface{}{"default": "value"}, tc.GetStringMap("other", map[string]interface{}{"default": "value"}), "it should return the default value") +} + +func Test_MustGet(t *testing.T) { + tc := New() + tc.Set("string", "string") + require.Equal(t, "string", tc.MustGetString("string"), "it should return the key value") + require.Panics(t, func() { tc.MustGetString("other") }) + + tc.Set("int", 0) + require.Equal(t, 0, tc.MustGetInt("int"), "it should return the key value") + require.Panics(t, func() { tc.MustGetInt("other") }) +} + +func Test_Register_Existing_and_Default(t *testing.T) { + tc := New() + tc.Set("string", "string") + var stringValue string + var otherStringValue string + tc.RegisterStringConfigVariable("default", &stringValue, false, "string") + require.Equal(t, "string", stringValue, "it should return the key value") + tc.RegisterStringConfigVariable("default", &otherStringValue, false, "other") + require.Equal(t, "default", otherStringValue, "it should return the default value") + + tc.Set("bool", false) + var boolValue bool + var otherBoolValue bool + tc.RegisterBoolConfigVariable(true, &boolValue, false, "bool") + require.Equal(t, false, boolValue, "it should return the key value") + tc.RegisterBoolConfigVariable(true, &otherBoolValue, false, "other") + require.Equal(t, true, otherBoolValue, "it should return the default value") + + tc.Set("int", 0) + var intValue int + var otherIntValue int + var int64Value int64 + var otherInt64Value int64 + tc.RegisterIntConfigVariable(1, &intValue, false, 1, "int") + require.Equal(t, 0, intValue, "it should return the key value") + tc.RegisterIntConfigVariable(1, &otherIntValue, false, 1, "other") + require.Equal(t, 1, otherIntValue, "it should return the default value") + tc.RegisterInt64ConfigVariable(1, &int64Value, false, 1, "int") + require.EqualValues(t, 0, int64Value, "it should return the key value") + tc.RegisterInt64ConfigVariable(1, &otherInt64Value, false, 1, "other") + require.EqualValues(t, 1, otherInt64Value, "it should return the default value") + + tc.Set("float", 0.0) + var floatValue float64 + var otherFloatValue float64 + tc.RegisterFloat64ConfigVariable(1, &floatValue, false, "float") + require.EqualValues(t, 0, floatValue, "it should return the key value") + tc.RegisterFloat64ConfigVariable(1, &otherFloatValue, false, "other") + require.EqualValues(t, 1, otherFloatValue, "it should return the default value") + + tc.Set("stringslice", []string{"string", "string"}) + var stringSliceValue []string + var otherStringSliceValue []string + tc.RegisterStringSliceConfigVariable([]string{"default"}, &stringSliceValue, false, "stringslice") + require.Equal(t, []string{"string", "string"}, stringSliceValue, "it should return the key value") + tc.RegisterStringSliceConfigVariable([]string{"default"}, &otherStringSliceValue, false, "other") + require.Equal(t, []string{"default"}, otherStringSliceValue, "it should return the default value") + + tc.Set("duration", "2ms") + var durationValue time.Duration + var otherDurationValue time.Duration + tc.RegisterDurationConfigVariable(1, &durationValue, false, time.Second, "duration") + require.Equal(t, 2*time.Millisecond, durationValue, "it should return the key value") + tc.RegisterDurationConfigVariable(1, &otherDurationValue, false, time.Second, "other") + require.Equal(t, time.Second, otherDurationValue, "it should return the default value") + + tc.Set("stringmap", map[string]interface{}{"string": "any"}) + var stringMapValue map[string]interface{} + var otherStringMapValue map[string]interface{} + tc.RegisterStringMapConfigVariable(map[string]interface{}{"default": "value"}, &stringMapValue, false, "stringmap") + require.Equal(t, map[string]interface{}{"string": "any"}, stringMapValue, "it should return the key value") + tc.RegisterStringMapConfigVariable(map[string]interface{}{"default": "value"}, &otherStringMapValue, false, "other") + require.Equal(t, map[string]interface{}{"default": "value"}, otherStringMapValue, "it should return the default value") +} + +func TestStatic_checkAndHotReloadConfig(t *testing.T) { + configMap := make(map[string][]*configValue) + + var var1 string + var var2 string + configVar1 := newConfigValue(&var1, 1, "var1", []string{"keyVar"}) + configVar2 := newConfigValue(&var2, 1, "var2", []string{"keyVar"}) + + configMap["keyVar"] = []*configValue{configVar1, configVar2} + t.Setenv("RSERVER_KEY_VAR", "value_changed") + + Default.checkAndHotReloadConfig(configMap) + + varptr1 := configVar1.value.(*string) + varptr2 := configVar2.value.(*string) + require.Equal(t, *varptr1, "value_changed") + require.Equal(t, *varptr2, "value_changed") +} + +func TestCheckAndHotReloadConfig(t *testing.T) { + var ( + stringValue string + stringConfigValue = newConfigValue(&stringValue, nil, "default", []string{"string"}) + boolValue bool + boolConfigValue = newConfigValue(&boolValue, nil, false, []string{"bool"}) + intValue int + intConfigValue = newConfigValue(&intValue, 1, 0, []string{"int"}) + int64Value int64 + int64ConfigValue = newConfigValue(&int64Value, int64(1), int64(0), []string{"int64"}) + float64Value float64 + float64ConfigValue = newConfigValue(&float64Value, 1.0, 0.0, []string{"float64"}) + stringSliceValue []string + stringSliceConfigValue = newConfigValue(&stringSliceValue, nil, []string{"default"}, []string{"stringslice"}) + durationValue time.Duration + durationConfigValue = newConfigValue(&durationValue, time.Second, int64(1), []string{"duration"}) + stringMapValue map[string]interface{} + stringMapConfigValue = newConfigValue(&stringMapValue, nil, map[string]interface{}{"default": "value"}, []string{"stringmap"}) + ) + + t.Run("with envs", func(t *testing.T) { + t.Setenv("RSERVER_INT", "1") + t.Setenv("RSERVER_INT64", "1") + t.Setenv("RSERVER_STRING", "string") + t.Setenv("RSERVER_DURATION", "2s") + t.Setenv("RSERVER_BOOL", "true") + t.Setenv("RSERVER_FLOAT64", "1.0") + t.Setenv("RSERVER_STRINGSLICE", "string string") + t.Setenv("RSERVER_STRINGMAP", "{\"string\":\"any\"}") + + Default.checkAndHotReloadConfig(map[string][]*configValue{ + "string": {stringConfigValue}, + "bool": {boolConfigValue}, + "int": {intConfigValue}, + "int64": {int64ConfigValue}, + "float64": {float64ConfigValue}, + "stringslice": {stringSliceConfigValue}, + "duration": {durationConfigValue}, + "stringmap": {stringMapConfigValue}, + }) + + require.Equal(t, *stringConfigValue.value.(*string), "string") + require.Equal(t, *boolConfigValue.value.(*bool), true) + require.Equal(t, *intConfigValue.value.(*int), 1) + require.Equal(t, *int64ConfigValue.value.(*int64), int64(1)) + require.Equal(t, *float64ConfigValue.value.(*float64), 1.0) + require.Equal(t, *durationConfigValue.value.(*time.Duration), 2*time.Second) + require.Equal(t, *stringSliceConfigValue.value.(*[]string), []string{"string", "string"}) + require.Equal(t, *stringMapConfigValue.value.(*map[string]any), map[string]any{"string": "any"}) + }) + + t.Run("without envs", func(t *testing.T) { + Default.checkAndHotReloadConfig(map[string][]*configValue{ + "string": {stringConfigValue}, + "bool": {boolConfigValue}, + "int": {intConfigValue}, + "int64": {int64ConfigValue}, + "float64": {float64ConfigValue}, + "stringslice": {stringSliceConfigValue}, + "duration": {durationConfigValue}, + "stringmap": {stringMapConfigValue}, + }) + + require.Equal(t, *stringConfigValue.value.(*string), "default") + require.Equal(t, *boolConfigValue.value.(*bool), false) + require.Equal(t, *intConfigValue.value.(*int), 0) + require.Equal(t, *int64ConfigValue.value.(*int64), int64(0)) + require.Equal(t, *float64ConfigValue.value.(*float64), 0.0) + require.Equal(t, *durationConfigValue.value.(*time.Duration), 1*time.Second) + require.Equal(t, *stringSliceConfigValue.value.(*[]string), []string{"default"}) + require.Equal(t, *stringMapConfigValue.value.(*map[string]any), map[string]any{"default": "value"}) + }) +} + +func TestConfigKeyToEnv(t *testing.T) { + expected := "RSERVER_KEY_VAR1_VAR2" + require.Equal(t, expected, ConfigKeyToEnv(DefaultEnvPrefix, "Key.Var1.Var2")) + require.Equal(t, expected, ConfigKeyToEnv(DefaultEnvPrefix, "key.var1.var2")) + require.Equal(t, expected, ConfigKeyToEnv(DefaultEnvPrefix, "KeyVar1Var2")) + require.Equal(t, expected, ConfigKeyToEnv(DefaultEnvPrefix, "RSERVER_KEY_VAR1_VAR2")) + require.Equal(t, "KEY_VAR1_VAR2", ConfigKeyToEnv(DefaultEnvPrefix, "KEY_VAR1_VAR2")) +} + +func TestGetEnvThroughViper(t *testing.T) { + expectedValue := "VALUE" + + t.Run("detects dots", func(t *testing.T) { + t.Setenv("RSERVER_KEY_VAR1_VAR2", expectedValue) + tc := New() + require.Equal(t, expectedValue, tc.GetString("Key.Var1.Var2", "")) + }) + + t.Run("detects camelcase", func(t *testing.T) { + t.Setenv("RSERVER_KEY_VAR1_VAR2", expectedValue) + tc := New() + require.Equal(t, expectedValue, tc.GetString("KeyVar1Var2", "")) + }) + + t.Run("detects dots with camelcase", func(t *testing.T) { + t.Setenv("RSERVER_KEY_VAR1_VAR_VAR", expectedValue) + tc := New() + require.Equal(t, expectedValue, tc.GetString("Key.Var1VarVar", "")) + }) + + t.Run("detects uppercase env variables", func(t *testing.T) { + t.Setenv("SOMEENVVARIABLE", expectedValue) + tc := New() + require.Equal(t, expectedValue, tc.GetString("SOMEENVVARIABLE", "")) + + t.Setenv("SOME_ENV_VARIABLE", expectedValue) + require.Equal(t, expectedValue, tc.GetString("SOME_ENV_VARIABLE", "")) + + t.Setenv("SOME_ENV_VARIABLE12", expectedValue) + require.Equal(t, expectedValue, tc.GetString("SOME_ENV_VARIABLE12", "")) + }) + + t.Run("doesn't use viper's default env var matcher (uppercase)", func(t *testing.T) { + t.Setenv("KEYVAR1VARVAR", expectedValue) + tc := New() + require.Equal(t, "", tc.GetString("KeyVar1VarVar", "")) + }) + + t.Run("can retrieve legacy env", func(t *testing.T) { + t.Setenv("JOBS_DB_HOST", expectedValue) + tc := New() + require.Equal(t, expectedValue, tc.GetString("DB.host", "")) + }) +} + +func TestRegisterEnvThroughViper(t *testing.T) { + expectedValue := "VALUE" + + t.Run("detects dots", func(t *testing.T) { + t.Setenv("RSERVER_KEY_VAR1_VAR2", expectedValue) + tc := New() + var v string + tc.RegisterStringConfigVariable("", &v, true, "Key.Var1.Var2") + require.Equal(t, expectedValue, v) + }) + + t.Run("detects camelcase", func(t *testing.T) { + t.Setenv("RSERVER_KEY_VAR_VAR", expectedValue) + tc := New() + var v string + tc.RegisterStringConfigVariable("", &v, true, "KeyVarVar") + require.Equal(t, expectedValue, v) + }) + + t.Run("detects dots with camelcase", func(t *testing.T) { + t.Setenv("RSERVER_KEY_VAR1_VAR_VAR", expectedValue) + tc := New() + var v string + tc.RegisterStringConfigVariable("", &v, true, "Key.Var1VarVar") + require.Equal(t, expectedValue, v) + }) +} + +func Test_Set_CaseInsensitive(t *testing.T) { + tc := New() + tc.Set("sTrIng.One", "string") + require.Equal(t, "string", tc.GetString("String.one", "default"), "it should return the key value") +} + +func Test_Misc(t *testing.T) { + t.Setenv("KUBE_NAMESPACE", "value") + require.Equal(t, "value", GetKubeNamespace()) + + t.Setenv("KUBE_NAMESPACE", "") + require.Equal(t, "none", GetNamespaceIdentifier()) + + t.Setenv("WORKSPACE_TOKEN", "value1") + t.Setenv("CONFIG_BACKEND_TOKEN", "value2") + require.Equal(t, "value1", GetWorkspaceToken()) + + t.Setenv("WORKSPACE_TOKEN", "") + t.Setenv("CONFIG_BACKEND_TOKEN", "value2") + require.Equal(t, "value2", GetWorkspaceToken()) + + t.Setenv("RELEASE_NAME", "value") + require.Equal(t, "value", GetReleaseName()) + + t.Setenv("INSTANCE_ID", "allbirds-v0-rudderstack-gw-ha-0-85d66f748f-8w4td") + require.Equal(t, "0", GetInstanceID()) + + t.Setenv("INSTANCE_ID", "prousmtusmt-v0-rs-gw-0") + require.Equal(t, "0", GetInstanceID()) + + t.Setenv("INSTANCE_ID", "prousmtusmt-v0-rs") + require.Equal(t, "", GetInstanceID()) +} diff --git a/config/env.go b/config/env.go new file mode 100644 index 00000000..cd67c68d --- /dev/null +++ b/config/env.go @@ -0,0 +1,24 @@ +package config + +// TODO: everything in this file should be either removed or unexported +import ( + "os" + "strings" +) + +// ConfigKeyToEnv gets the env variable name from a given config key +func ConfigKeyToEnv(envPrefix, s string) string { + if upperCaseMatch.MatchString(s) { + return s + } + snake := camelCaseMatch.ReplaceAllString(s, "${1}_${2}") + return envPrefix + "_" + strings.ToUpper(strings.ReplaceAll(snake, ".", "_")) +} + +// getEnv returns the environment value stored in key variable +func getEnv(key, defaultVal string) string { + if value, exists := os.LookupEnv(key); exists { + return value + } + return defaultVal +} diff --git a/config/hotreloadable.go b/config/hotreloadable.go new file mode 100644 index 00000000..46981848 --- /dev/null +++ b/config/hotreloadable.go @@ -0,0 +1,257 @@ +package config + +import "time" + +// RegisterIntConfigVariable registers int config variable +func RegisterIntConfigVariable(defaultValue int, ptr *int, isHotReloadable bool, valueScale int, keys ...string) { + Default.RegisterIntConfigVariable(defaultValue, ptr, isHotReloadable, valueScale, keys...) +} + +// RegisterIntConfigVariable registers int config variable +func (c *Config) RegisterIntConfigVariable(defaultValue int, ptr *int, isHotReloadable bool, valueScale int, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + multiplier: valueScale, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + if c.IsSet(key) { + *ptr = c.GetInt(key, defaultValue) * valueScale + return + } + } + *ptr = defaultValue * valueScale +} + +// RegisterBoolConfigVariable registers bool config variable +func RegisterBoolConfigVariable(defaultValue bool, ptr *bool, isHotReloadable bool, keys ...string) { + Default.RegisterBoolConfigVariable(defaultValue, ptr, isHotReloadable, keys...) +} + +// RegisterBoolConfigVariable registers bool config variable +func (c *Config) RegisterBoolConfigVariable(defaultValue bool, ptr *bool, isHotReloadable bool, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + c.bindEnv(key) + if c.IsSet(key) { + *ptr = c.GetBool(key, defaultValue) + return + } + } + *ptr = defaultValue +} + +// RegisterFloat64ConfigVariable registers float64 config variable +func RegisterFloat64ConfigVariable(defaultValue float64, ptr *float64, isHotReloadable bool, keys ...string) { + Default.RegisterFloat64ConfigVariable(defaultValue, ptr, isHotReloadable, keys...) +} + +// RegisterFloat64ConfigVariable registers float64 config variable +func (c *Config) RegisterFloat64ConfigVariable(defaultValue float64, ptr *float64, isHotReloadable bool, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + multiplier: 1.0, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + c.bindEnv(key) + if c.IsSet(key) { + *ptr = c.GetFloat64(key, defaultValue) + return + } + } + *ptr = defaultValue +} + +// RegisterInt64ConfigVariable registers int64 config variable +func RegisterInt64ConfigVariable(defaultValue int64, ptr *int64, isHotReloadable bool, valueScale int64, keys ...string) { + Default.RegisterInt64ConfigVariable(defaultValue, ptr, isHotReloadable, valueScale, keys...) +} + +// RegisterInt64ConfigVariable registers int64 config variable +func (c *Config) RegisterInt64ConfigVariable(defaultValue int64, ptr *int64, isHotReloadable bool, valueScale int64, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + multiplier: valueScale, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + c.bindEnv(key) + if c.IsSet(key) { + *ptr = c.GetInt64(key, defaultValue) * valueScale + return + } + } + *ptr = defaultValue * valueScale +} + +// RegisterDurationConfigVariable registers duration config variable +func RegisterDurationConfigVariable(defaultValueInTimescaleUnits int64, ptr *time.Duration, isHotReloadable bool, timeScale time.Duration, keys ...string) { + Default.RegisterDurationConfigVariable(defaultValueInTimescaleUnits, ptr, isHotReloadable, timeScale, keys...) +} + +// RegisterDurationConfigVariable registers duration config variable +func (c *Config) RegisterDurationConfigVariable(defaultValueInTimescaleUnits int64, ptr *time.Duration, isHotReloadable bool, timeScale time.Duration, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + multiplier: timeScale, + defaultValue: defaultValueInTimescaleUnits, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + if c.IsSet(key) { + *ptr = c.GetDuration(key, defaultValueInTimescaleUnits, timeScale) + return + } + } + *ptr = time.Duration(defaultValueInTimescaleUnits) * timeScale +} + +// RegisterStringConfigVariable registers string config variable +func RegisterStringConfigVariable(defaultValue string, ptr *string, isHotReloadable bool, keys ...string) { + Default.RegisterStringConfigVariable(defaultValue, ptr, isHotReloadable, keys...) +} + +// RegisterStringConfigVariable registers string config variable +func (c *Config) RegisterStringConfigVariable(defaultValue string, ptr *string, isHotReloadable bool, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + if c.IsSet(key) { + *ptr = c.GetString(key, defaultValue) + return + } + } + *ptr = defaultValue +} + +// RegisterStringSliceConfigVariable registers string slice config variable +func RegisterStringSliceConfigVariable(defaultValue []string, ptr *[]string, isHotReloadable bool, keys ...string) { + Default.RegisterStringSliceConfigVariable(defaultValue, ptr, isHotReloadable, keys...) +} + +// RegisterStringSliceConfigVariable registers string slice config variable +func (c *Config) RegisterStringSliceConfigVariable(defaultValue []string, ptr *[]string, isHotReloadable bool, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + if c.IsSet(key) { + *ptr = c.GetStringSlice(key, defaultValue) + return + } + } + *ptr = defaultValue +} + +// RegisterStringMapConfigVariable registers string map config variable +func RegisterStringMapConfigVariable(defaultValue map[string]interface{}, ptr *map[string]interface{}, isHotReloadable bool, keys ...string) { + Default.RegisterStringMapConfigVariable(defaultValue, ptr, isHotReloadable, keys...) +} + +// RegisterStringMapConfigVariable registers string map config variable +func (c *Config) RegisterStringMapConfigVariable(defaultValue map[string]interface{}, ptr *map[string]interface{}, isHotReloadable bool, keys ...string) { + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.Lock() + defer c.hotReloadableConfigLock.Unlock() + configVar := configValue{ + value: ptr, + defaultValue: defaultValue, + keys: keys, + } + + if isHotReloadable { + c.appendVarToConfigMaps(keys[0], &configVar) + } + + for _, key := range keys { + if c.IsSet(key) { + *ptr = c.GetStringMap(key, defaultValue) + return + } + } + *ptr = defaultValue +} + +func (c *Config) appendVarToConfigMaps(key string, configVar *configValue) { + if _, ok := c.hotReloadableConfig[key]; !ok { + c.hotReloadableConfig[key] = make([]*configValue, 0) + } + c.hotReloadableConfig[key] = append(c.hotReloadableConfig[key], configVar) +} diff --git a/config/load.go b/config/load.go new file mode 100644 index 00000000..424e24e4 --- /dev/null +++ b/config/load.go @@ -0,0 +1,235 @@ +package config + +import ( + "fmt" + "reflect" + "time" + + "golang.org/x/exp/slices" + + "github.com/fsnotify/fsnotify" + "github.com/joho/godotenv" + "github.com/spf13/viper" +) + +func (c *Config) load() { + c.hotReloadableConfig = make(map[string][]*configValue) + c.envs = make(map[string]string) + + if err := godotenv.Load(); err != nil { + fmt.Println("INFO: No .env file found.") + } + + configPath := getEnv("CONFIG_PATH", "./config/config.yaml") + + v := viper.NewWithOptions(viper.EnvKeyReplacer(&envReplacer{c: c})) + v.AutomaticEnv() + bindLegacyEnv(v) + + v.SetConfigFile(configPath) + err := v.ReadInConfig() // Find and read the config file + // Don't panic if config.yaml is not found or error with parsing. Use the default config values instead + if err != nil { + fmt.Printf("[Config] :: Failed to parse config file from path %q, using default values: %v\n", configPath, err) + } + v.OnConfigChange(func(e fsnotify.Event) { + c.onConfigChange() + }) + v.WatchConfig() + + c.v = v +} + +func (c *Config) onConfigChange() { + defer func() { + if r := recover(); r != nil { + err := fmt.Errorf("cannot update Config Variables: %v", r) + fmt.Println(err) + } + }() + c.vLock.RLock() + defer c.vLock.RUnlock() + c.hotReloadableConfigLock.RLock() + defer c.hotReloadableConfigLock.RUnlock() + c.checkAndHotReloadConfig(c.hotReloadableConfig) +} + +func (c *Config) checkAndHotReloadConfig(configMap map[string][]*configValue) { + for key, configValArr := range configMap { + for _, configVal := range configValArr { + value := configVal.value + switch value := value.(type) { + case *int: + var _value int + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetInt(key, configVal.defaultValue.(int)) + break + } + } + if !isSet { + _value = configVal.defaultValue.(int) + } + _value = _value * configVal.multiplier.(int) + if _value != *value { + fmt.Printf("The value of key:%s & variable:%p changed from %d to %d\n", key, configVal, *value, _value) + *value = _value + } + case *int64: + var _value int64 + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetInt64(key, configVal.defaultValue.(int64)) + break + } + } + if !isSet { + _value = configVal.defaultValue.(int64) + } + _value = _value * configVal.multiplier.(int64) + if _value != *value { + fmt.Printf("The value of key:%s & variable:%p changed from %d to %d\n", key, configVal, *value, _value) + *value = _value + } + case *string: + var _value string + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetString(key, configVal.defaultValue.(string)) + break + } + } + if !isSet { + _value = configVal.defaultValue.(string) + } + if _value != *value { + fmt.Printf("The value of key:%s & variable:%p changed from %v to %v\n", key, configVal, *value, _value) + *value = _value + } + case *time.Duration: + var _value time.Duration + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetDuration(key, configVal.defaultValue.(int64), configVal.multiplier.(time.Duration)) + break + } + } + if !isSet { + _value = time.Duration(configVal.defaultValue.(int64)) * configVal.multiplier.(time.Duration) + } + if _value != *value { + fmt.Printf("The value of key:%s & variable:%p changed from %v to %v\n", key, configVal, *value, _value) + *value = _value + } + case *bool: + var _value bool + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetBool(key, configVal.defaultValue.(bool)) + break + } + } + if !isSet { + _value = configVal.defaultValue.(bool) + } + if _value != *value { + fmt.Printf("The value of key:%s & variable:%p changed from %v to %v\n", key, configVal, *value, _value) + *value = _value + } + case *float64: + var _value float64 + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetFloat64(key, configVal.defaultValue.(float64)) + break + } + } + if !isSet { + _value = configVal.defaultValue.(float64) + } + _value = _value * configVal.multiplier.(float64) + if _value != *value { + fmt.Printf("The value of key:%s & variable:%p changed from %v to %v\n", key, configVal, *value, _value) + *value = _value + } + case *[]string: + var _value []string + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetStringSlice(key, configVal.defaultValue.([]string)) + break + } + } + if !isSet { + _value = configVal.defaultValue.([]string) + } + if slices.Compare(_value, *value) != 0 { + fmt.Printf("The value of key:%s & variable:%p changed from %v to %v\n", key, configVal, *value, _value) + *value = _value + } + case *map[string]interface{}: + var _value map[string]interface{} + var isSet bool + for _, key := range configVal.keys { + if c.IsSet(key) { + isSet = true + _value = c.GetStringMap(key, configVal.defaultValue.(map[string]interface{})) + break + } + } + if !isSet { + _value = configVal.defaultValue.(map[string]interface{}) + } + + if !mapDeepEqual(_value, *value) { + fmt.Printf("The value of key:%s & variable:%p changed from %v to %v\n", key, configVal, *value, _value) + *value = _value + } + } + } + } +} + +type configValue struct { + value interface{} + multiplier interface{} + defaultValue interface{} + keys []string +} + +func newConfigValue(value, multiplier, defaultValue interface{}, keys []string) *configValue { + return &configValue{ + value: value, + multiplier: multiplier, + defaultValue: defaultValue, + keys: keys, + } +} + +func mapDeepEqual[K comparable, V any](a, b map[K]V) bool { + if len(a) != len(b) { + return false + } + + for k, v := range a { + if w, ok := b[k]; !ok || !reflect.DeepEqual(v, w) { + return false + } + } + + return true +} diff --git a/config/misc.go b/config/misc.go new file mode 100644 index 00000000..354a52fc --- /dev/null +++ b/config/misc.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + "strconv" + "strings" +) + +// GetWorkspaceToken returns the workspace token provided in the environment variables +// Env variable CONFIG_BACKEND_TOKEN is deprecating soon +// WORKSPACE_TOKEN is newly introduced. This will override CONFIG_BACKEND_TOKEN +func GetWorkspaceToken() string { + token := GetString("WORKSPACE_TOKEN", "") + if token != "" && token != "" { + return token + } + return GetString("CONFIG_BACKEND_TOKEN", "") +} + +// GetNamespaceIdentifier returns value stored in KUBE_NAMESPACE env var or "none" if empty +func GetNamespaceIdentifier() string { + k8sNamespace := GetKubeNamespace() + if k8sNamespace != "" { + return k8sNamespace + } + return "none" +} + +// GetKubeNamespace returns value stored in KUBE_NAMESPACE env var +func GetKubeNamespace() string { + return os.Getenv("KUBE_NAMESPACE") +} + +func GetInstanceID() string { + instance := GetString("INSTANCE_ID", "") + instanceArr := strings.Split(instance, "-") + length := len(instanceArr) + // This handles 2 kinds of server instances + // a) Processor OR Gateway running in non HA mod where the instance name ends with the index + // b) Gateway running in HA mode, where the instance name is of the form *-gw-ha--- + potentialServerIndexIndices := []int{length - 1, length - 3} + for _, i := range potentialServerIndexIndices { + if i < 0 { + continue + } + serverIndex := instanceArr[i] + _, err := strconv.Atoi(serverIndex) + if err == nil { + return serverIndex + } + } + return "" +} + +func GetReleaseName() string { + return os.Getenv("RELEASE_NAME") +} diff --git a/config/mode.go b/config/mode.go new file mode 100644 index 00000000..6b849657 --- /dev/null +++ b/config/mode.go @@ -0,0 +1,11 @@ +package config + +// Rudder server supported config constants +const ( + EmbeddedMode = "embedded" + MasterMode = "master" + MasterSlaveMode = "master_and_slave" + SlaveMode = "slave" + OffMode = "off" + EmbeddedMasterMode = "embedded_master" +) diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..212d8744 --- /dev/null +++ b/go.mod @@ -0,0 +1,85 @@ +module github.com/rudderlabs/rudder-go-kit + +go 1.20 + +require ( + github.com/cenkalti/backoff/v4 v4.2.0 + github.com/fsnotify/fsnotify v1.6.0 + github.com/golang/mock v1.6.0 + github.com/gorilla/mux v1.8.0 + github.com/joho/godotenv v1.5.1 + github.com/ory/dockertest/v3 v3.9.1 + github.com/spf13/viper v1.15.0 + github.com/stretchr/testify v1.8.2 + go.opentelemetry.io/otel v1.11.2 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.34.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.11.2 + go.opentelemetry.io/otel/metric v0.34.0 + go.opentelemetry.io/otel/sdk v1.11.2 + go.opentelemetry.io/otel/sdk/metric v0.34.0 + golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/Microsoft/go-winio v0.5.2 // indirect + github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 // indirect + github.com/containerd/continuity v0.3.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/docker/cli v20.10.14+incompatible // indirect + github.com/docker/docker v20.10.21+incompatible // indirect + github.com/docker/go-connections v0.4.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/go-logr/logr v1.2.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang/protobuf v1.5.2 // indirect + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect + github.com/imdario/mergo v0.3.12 // indirect + github.com/lib/pq v1.10.7 + github.com/magiconair/properties v1.8.7 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect + github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.0-rc2 // indirect + github.com/opencontainers/runc v1.1.4 // indirect + github.com/pelletier/go-toml/v2 v2.0.6 // indirect + github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.3.0 + github.com/prometheus/common v0.42.0 + github.com/sirupsen/logrus v1.9.0 // indirect + github.com/spf13/afero v1.9.3 // indirect + github.com/spf13/cast v1.5.0 + github.com/spf13/jwalterweatherman v1.1.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/subosito/gotenv v1.4.2 // indirect + github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f // indirect + github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect + github.com/xeipuuv/gojsonschema v1.2.0 // indirect + github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 + go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.2 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.34.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.2 // indirect + go.opentelemetry.io/otel/trace v1.11.2 // indirect + go.opentelemetry.io/proto/otlp v0.19.0 // indirect + go.uber.org/atomic v1.10.0 // indirect + go.uber.org/multierr v1.8.0 // indirect + go.uber.org/zap v1.24.0 + golang.org/x/net v0.7.0 // indirect + golang.org/x/sync v0.1.0 + golang.org/x/sys v0.5.0 // indirect + golang.org/x/text v0.7.0 // indirect + google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f // indirect + google.golang.org/grpc v1.53.0 // indirect + google.golang.org/protobuf v1.28.1 // indirect + gopkg.in/alexcesaro/statsd.v2 v2.0.0 + gopkg.in/ini.v1 v1.67.0 // indirect + gopkg.in/natefinch/lumberjack.v2 v2.2.1 + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..81dbde09 --- /dev/null +++ b/go.sum @@ -0,0 +1,684 @@ +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU= +cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU= +cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.44.3/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY= +cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc= +cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0= +cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To= +cloud.google.com/go v0.52.0/go.mod h1:pXajvRH/6o3+F9jDHZWQ5PbGhn+o8w9qiu/CffaVdO4= +cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M= +cloud.google.com/go v0.54.0/go.mod h1:1rq2OEkV3YMf6n/9ZvGWI3GWw0VoqH/1x2nd8Is/bPc= +cloud.google.com/go v0.56.0/go.mod h1:jr7tqZxxKOVYizybht9+26Z/gUq7tiRzu+ACVAMbKVk= +cloud.google.com/go v0.57.0/go.mod h1:oXiQ6Rzq3RAkkY7N6t3TcE6jE+CIBBbA36lwQ1JyzZs= +cloud.google.com/go v0.62.0/go.mod h1:jmCYTdRCQuc1PHIIJ/maLInMho30T/Y0M4hTdTShOYc= +cloud.google.com/go v0.65.0/go.mod h1:O5N8zS7uWy9vkA9vayVHs65eM1ubvY4h553ofrNHObY= +cloud.google.com/go v0.72.0/go.mod h1:M+5Vjvlc2wnp6tjzE102Dw08nGShTscUx2nZMufOKPI= +cloud.google.com/go v0.74.0/go.mod h1:VV1xSbzvo+9QJOxLDaJfTjx5e+MePCpCWwvftOeQmWk= +cloud.google.com/go v0.75.0/go.mod h1:VGuuCn7PG0dwsd5XPVm2Mm3wlh3EL55/79EKB6hlPTY= +cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o= +cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE= +cloud.google.com/go/bigquery v1.4.0/go.mod h1:S8dzgnTigyfTmLBfrtrhyYhwRxG72rYxvftPBK2Dvzc= +cloud.google.com/go/bigquery v1.5.0/go.mod h1:snEHRnqQbz117VIFhE8bmtwIDY80NLUZUMb4Nv6dBIg= +cloud.google.com/go/bigquery v1.7.0/go.mod h1://okPTzCYNXSlb24MZs83e2Do+h+VXtc4gLoIoXIAPc= +cloud.google.com/go/bigquery v1.8.0/go.mod h1:J5hqkt3O0uAFnINi6JXValWIb1v0goeZM77hZzJN/fQ= +cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE= +cloud.google.com/go/datastore v1.1.0/go.mod h1:umbIZjpQpHh4hmRpGhH4tLFup+FVzqBi1b3c64qFpCk= +cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I= +cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw= +cloud.google.com/go/pubsub v1.2.0/go.mod h1:jhfEVHT8odbXTkndysNHCcx0awwzvfOlguIAii9o8iA= +cloud.google.com/go/pubsub v1.3.1/go.mod h1:i+ucay31+CNRpDW4Lu78I4xXG+O1r/MAHgjpRVR+TSU= +cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw= +cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos= +cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohlUTyfDhBk= +cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs= +cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0= +cloud.google.com/go/storage v1.14.0/go.mod h1:GrKmX003DSIwi9o29oFT7YDnHYwZoctc3fOKtUw0Xmo= +dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= +github.com/Microsoft/go-winio v0.5.2 h1:a9IhgEQBCUEk6QCdml9CiJGhAws+YwffDHEMp1VMrpA= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw= +github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk= +github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= +github.com/cenkalti/backoff/v4 v4.2.0 h1:HN5dHm3WBOgndBH6E8V0q2jIYIR3s9yglV8k/+MN3u4= +github.com/cenkalti/backoff/v4 v4.2.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= +github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2usCA= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/cncf/udpa/go v0.0.0-20200629203442-efcf912fb354/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk= +github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= +github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= +github.com/containerd/continuity v0.3.0 h1:nisirsYROK15TAMVukJOUyGJjz4BNQJBVsNvAXZJ/eg= +github.com/containerd/continuity v0.3.0/go.mod h1:wJEAIwKOm/pBZuBd0JmeTvnLquTB1Ag8espWhkykbPM= +github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= +github.com/creack/pty v1.1.11 h1:07n33Z8lZxZ2qwegKbObQohDhXDQxiMMz1NOUGYlesw= +github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/docker/cli v20.10.14+incompatible h1:dSBKJOVesDgHo7rbxlYjYsXe7gPzrTT+/cKQgpDAazg= +github.com/docker/cli v20.10.14+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/docker v20.10.21+incompatible h1:UTLdBmHk3bEY+w8qeO5KttOhy6OmXWsl/FEet9Uswog= +github.com/docker/docker v20.10.21+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.4.0 h1:El9xVISelRB7BuFusrZozjnkIM5YnzCViNKohAFqRJQ= +github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec= +github.com/docker/go-units v0.4.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/go-control-plane v0.9.7/go.mod h1:cwu0lG7PUMfa9snN8LXBig5ynNVH9qI8YYLbd1fK2po= +github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk= +github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ= +github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021/go.mod h1:AFq3mo9L8Lqqiid3OhADV3RfLJnjiw63cSpi+fDTRC0= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= +github.com/frankban/quicktest v1.11.3/go.mod h1:wRf/ReqHper53s+kmmSZizM8NamnL3IM0I9ntUbOk+k= +github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE= +github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= +github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= +github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= +github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.0.6/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/glog v1.0.0 h1:nfP3RFugxnNRyKgeWd4oI1nYvXpxrx8ck8ZrcizshdQ= +github.com/golang/glog v1.0.0/go.mod h1:EWib/APOK0SL3dFbYqvxE3UYd8E6s1ouQ7iEp/0LWV4= +github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y= +github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.1/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= +github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= +github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= +github.com/golang/protobuf v1.3.5/go.mod h1:6O5/vntMXwX2lRkT1hjjk0nAC1IDOTvTlVgjlRvqsdk= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs= +github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/martian/v3 v3.1.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0= +github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc= +github.com/google/pprof v0.0.0-20191218002539-d4f498aebedc/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200229191704-1ebb73c60ed3/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200430221834-fc25d7d30c6d/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM= +github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= +github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= +github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0 h1:BZHcxBETFHIdVyhyEfOvn/RdU/QGdLI4y34qQGjGWO0= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.7.0/go.mod h1:hgWBS7lorOAVIJEQMi4ZsPv9hVvWI6+ch50m39Pf2Ks= +github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.12 h1:b6R2BslTbIEToALKP7LxUvijTsNI9TAe80pLWN2g/HU= +github.com/imdario/mergo v0.3.12/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= +github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/lib/pq v1.10.7/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= +github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= +github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae h1:O4SWKdcHVCvYqyDV+9CJA1fcDN2L11Bule0iFy3YlAI= +github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= +github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.0-rc2 h1:2zx/Stx4Wc5pIPDvIxHXvXtQFW/7XWJGmnM7r3wg034= +github.com/opencontainers/image-spec v1.1.0-rc2/go.mod h1:3OVijpioIKYWTqjiG0zfF6wvoJ4fAXGbjdZuI2NgsRQ= +github.com/opencontainers/runc v1.1.4 h1:nRCz/8sKg6K6jgYAFLDlXzPeITBZJyX28DBVhWD+5dg= +github.com/opencontainers/runc v1.1.4/go.mod h1:1J5XiS+vdZ3wCyZybsuxXZWGrgSr8fFJHLXuG2PsnNg= +github.com/opencontainers/runtime-spec v1.0.3-0.20210326190908-1c3f411f0417/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= +github.com/opencontainers/selinux v1.10.0/go.mod h1:2i0OySw99QjzBBQByd1Gr9gSjvuho1lHsJxIJ3gGbJI= +github.com/ory/dockertest/v3 v3.9.1 h1:v4dkG+dlu76goxMiTT2j8zV7s4oPPEppKT8K8p2f1kY= +github.com/ory/dockertest/v3 v3.9.1/go.mod h1:42Ir9hmvaAPm0Mgibk6mBPi7SFvTXxEcnztDYOJ//uM= +github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= +github.com/pelletier/go-toml/v2 v2.0.6/go.mod h1:eumQOmlWiOPt5WriQQqoM5y18pDHwha2N+QD+EUNTek= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.3.0 h1:UBgGFHqYdG/TPFD1B1ogZywDqEkwp3fBMvqdiQ7Xew4= +github.com/prometheus/client_model v0.3.0/go.mod h1:LDGWKZIo7rky3hgvBe+caln+Dr3dPggB5dvjtD7w9+w= +github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= +github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/seccomp/libseccomp-golang v0.9.2-0.20220502022130-f33da4d89646/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg= +github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= +github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= +github.com/spf13/afero v1.9.3 h1:41FoI0fD7OR7mGcKE/aOiLkGreyf8ifIOQmJANWogMk= +github.com/spf13/afero v1.9.3/go.mod h1:iUV7ddyEEZPO5gA3zD4fJt6iStLlL+Lg4m2cihcDf8Y= +github.com/spf13/cast v1.5.0 h1:rj3WzYc11XZaIZMPKmwP96zkFEnnAmV8s6XbB2aY32w= +github.com/spf13/cast v1.5.0/go.mod h1:SpXXQ5YoyJw6s3/6cMTQuxvgRl3PCJiyaX9p6b155UU= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.15.0 h1:js3yy885G8xwJa6iOISGFwd+qlUo5AvyXb7CiihdtiU= +github.com/spf13/viper v1.15.0/go.mod h1:fFcTBJxvhhzSJiZy8n+PeW6t8l+KeT/uTARa0jHOQLA= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/subosito/gotenv v1.4.2 h1:X1TuBLAMDFbaTAChgCBLu3DU3UPyELpnF2jjJ2cz/S8= +github.com/subosito/gotenv v1.4.2/go.mod h1:ayKnFf/c6rvx/2iiLrJUk1e6plDbT3edrFNGqEflhK0= +github.com/syndtr/gocapability v0.0.0-20200815063812-42c35b437635/go.mod h1:hkRG7XYTFWNJGYcbNJQlaLq0fg1yr4J4t/NcTQtrfww= +github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= +github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE= +github.com/vishvananda/netns v0.0.0-20191106174202-0a2b9b5464df/go.mod h1:JP3t17pCcGlemwknint6hfoeCVQrEMVwxRLRjXpq+BU= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f h1:J9EGpcZtP0E/raorCMxlFGSTBrsSlaDGf3jU/qvAE2c= +github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74= +github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y= +github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04 h1:qXafrlZL1WsJW5OokjraLLRURHiw0OzKHD/RNdspp4w= +github.com/zenizh/go-capturer v0.0.0-20211219060012-52ea6c8fed04/go.mod h1:FiwNQxz6hGoNFBC4nIx+CxZhI3nne5RmIOlT/MXcSD4= +go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU= +go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8= +go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw= +go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= +go.opentelemetry.io/otel v1.11.2 h1:YBZcQlsVekzFsFbjygXMOXSs6pialIZxcjfO/mBDmR0= +go.opentelemetry.io/otel v1.11.2/go.mod h1:7p4EUV+AqgdlNV9gL97IgUZiVR3yrFXYo53f9BM3tRI= +go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.2 h1:htgM8vZIF8oPSCxa341e3IZ4yr/sKxgu8KZYllByiVY= +go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.11.2/go.mod h1:rqbht/LlhVBgn5+k3M5QK96K5Xb0DvXpMJ5SFQpY6uw= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.34.0 h1:kpskzLZ60cJ48SJ4uxWa6waBL+4kSV6nVK8rP+QM8Wg= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric v0.34.0/go.mod h1:4+x3i62TEegDHuzNva0bMcAN8oUi5w4liGb1d/VgPYo= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.34.0 h1:e7kFb4pJLbhJgAwUdoVTHzB9pGujs5O8/7gFyZL88fg= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v0.34.0/go.mod h1:3x00m9exjIbhK+zTO4MsCSlfbVmgvLP0wjDgDKa/8bw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.2 h1:fqR1kli93643au1RKo0Uma3d2aPQKT+WBKfTSBaKbOc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.11.2/go.mod h1:5Qn6qvgkMsLDX+sYK64rHb1FPhpn0UtxF+ouX1uhyJE= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.11.2 h1:ERwKPn9Aer7Gxsc0+ZlutlH1bEEAUXAUhqm3Y45ABbk= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.11.2/go.mod h1:jWZUM2MWhWCJ9J9xVbRx7tzK1mXKpAlze4CeulycwVY= +go.opentelemetry.io/otel/metric v0.34.0 h1:MCPoQxcg/26EuuJwpYN1mZTeCYAUGx8ABxfW07YkjP8= +go.opentelemetry.io/otel/metric v0.34.0/go.mod h1:ZFuI4yQGNCupurTXCwkeD/zHBt+C2bR7bw5JqUm/AP8= +go.opentelemetry.io/otel/sdk v1.11.2 h1:GF4JoaEx7iihdMFu30sOyRx52HDHOkl9xQ8SMqNXUiU= +go.opentelemetry.io/otel/sdk v1.11.2/go.mod h1:wZ1WxImwpq+lVRo4vsmSOxdd+xwoUJ6rqyLc3SyX9aU= +go.opentelemetry.io/otel/sdk/metric v0.34.0 h1:7ElxfQpXCFZlRTvVRTkcUvK8Gt5DC8QzmzsLsO2gdzo= +go.opentelemetry.io/otel/sdk/metric v0.34.0/go.mod h1:l4r16BIqiqPy5rd14kkxllPy/fOI4tWo1jkpD9Z3ffQ= +go.opentelemetry.io/otel/trace v1.11.2 h1:Xf7hWSF2Glv0DE3MH7fBHvtpSBsjcBUe5MYAmZM/+y0= +go.opentelemetry.io/otel/trace v1.11.2/go.mod h1:4N+yC7QEz7TTsG9BSRLNAa63eg5E06ObSbKPmxQ/pKA= +go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI= +go.opentelemetry.io/proto/otlp v0.19.0 h1:IVN6GR+mhC4s5yfcTbmzHYODqvWAp3ZedA2SJPI1Nnw= +go.opentelemetry.io/proto/otlp v0.19.0/go.mod h1:H7XAot3MsfNsj7EXtrA2q5xSNQ10UqI405h3+duxN4U= +go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= +go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= +go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= +go.uber.org/multierr v1.8.0 h1:dg6GjLku4EH+249NNmoIciG9N/jURbDG+pFlTkhzIC8= +go.uber.org/multierr v1.8.0/go.mod h1:7EAYxJLBy9rStEaz58O2t4Uvip6FSURkq8/ppBp95ak= +go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60= +go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= +golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= +golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek= +golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY= +golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= +golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= +golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0 h1:LGJsf5LRplCck6jUCH3dBL2dmycNruWNF5xugkSlfXw= +golang.org/x/exp v0.0.0-20230310171629-522b1b587ee0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs= +golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= +golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE= +golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc= +golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= +golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190628185345-da137c7871d7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200222125558-5a598a2470a0/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= +golang.org/x/net v0.7.0 h1:rJrUqqhjsgNp7KqAIc25s9pZnjU7TUcSY7HcVZjdn1g= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= +golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20201208152858-08078c50e5b5/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20210218202405-ba52d332ba99/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190606203320-7fc4e5ec1444/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191115151921-52ab43148777/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200302150141-5c8b2ff67527/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200331124033-c3d80250170d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.7.0 h1:4BRB4x83lYWy72KwLD/qYDuTu7q9PjSagHvijDw7cLo= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191130070609-6e064ea0cf2d/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200117161641-43d50277825c/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200122220014-bf1340f18c4a/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200204074204-1cc6d1ef6c74/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200224181240-023911ca70b2/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200227222343-706bc42d1f0d/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= +golang.org/x/tools v0.0.0-20200304193943-95d2e580d8eb/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200312045724-11d5b4c81c7d/go.mod h1:o4KQGtdN14AW+yjsvvwRTJJuXz8XRtIHtEnmAXLyFUw= +golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWclx+PTx3U+LnKx/seiNR+3G19Ar8= +golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= +golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= +golang.org/x/tools v0.0.0-20201110124207-079ba7bd75cd/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201201161351-ac6f37ff4c2a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20201208233053-a543418bbed2/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210105154028-b0ab187a4818/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210108195828-e2f9c7f1fc8e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= +golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE= +google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M= +google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg= +google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI= +google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.18.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.19.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.20.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.22.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE= +google.golang.org/api v0.24.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.28.0/go.mod h1:lIXQywCXRcnZPGlsd8NbLnOjtAoL6em04bJ9+z0MncE= +google.golang.org/api v0.29.0/go.mod h1:Lcubydp8VUV7KeIHD9z2Bys/sm/vGKnG1UHuDBSrHWM= +google.golang.org/api v0.30.0/go.mod h1:QGmEvQ87FHZNiUVJkT14jQNYJ4ZJjdRF23ZXz5138Fc= +google.golang.org/api v0.35.0/go.mod h1:/XrVsuzM0rZmrsbjJutiuftIzeuTQcEeaYcSk/mQ1dg= +google.golang.org/api v0.36.0/go.mod h1:+z5ficQTmoYpPn8LCUNVpK5I7hwkpjbcgqA7I34qYtE= +google.golang.org/api v0.40.0/go.mod h1:fYKFpnQN0DsDSKRVRcQSDQNtqWPfM9i+zNPxepjRCQ8= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0= +google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE= +google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8= +google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200115191322-ca5a22157cba/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200122232147-0452cf42e150/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc= +google.golang.org/genproto v0.0.0-20200204135345-fa8e72b47b90/go.mod h1:GmwEX6Z4W5gMy59cAlVYjN9JhxgbQH6Gn+gFDQe2lzA= +google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200224152610-e50cd9704f63/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200228133532-8c2c7df3a383/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200305110556-506484158171/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200312145019-da6875a35672/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200331122359-1ee6d9798940/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200430143042-b979b6f78d84/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200511104702-f5ebc3bea380/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= +google.golang.org/genproto v0.0.0-20200515170657-fc4c6c6a6587/go.mod h1:YsZOwe1myG/8QRHRsmBRE1LrgQY60beZKjly0O1fX9U= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA= +google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20200904004341-0bd0a958aa1d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201109203340-2640f1f9cdfb/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201201144952-b05cb90ed32e/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no= +google.golang.org/genproto v0.0.0-20211118181313-81c1377c94b1/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f h1:BWUVssLB0HVOSY78gIdvk1dTVYtT1y8SBWtPYuTJ/6w= +google.golang.org/genproto v0.0.0-20230110181048-76db0878b65f/go.mod h1:RGgjbofJ8xD9Sq1VVhDM1Vok1vRONV+rg+CjzG4SZKM= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38= +google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKal+60= +google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk= +google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= +google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= +google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8= +google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU= +google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34= +google.golang.org/grpc v1.42.0/go.mod h1:k+4IHHFw41K8+bbowsex27ge2rCb65oeWqe4jJ590SU= +google.golang.org/grpc v1.53.0 h1:LAv2ds7cmFV/XTS3XG1NneeENYrXGmorPxsBbptIjNc= +google.golang.org/grpc v1.53.0/go.mod h1:OnIrk0ipVdj4N5d9IUoFUx72/VlD7+jUsHwZgwSMQpw= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= +google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +gopkg.in/alexcesaro/statsd.v2 v2.0.0 h1:FXkZSCZIH17vLCO5sO2UucTHsH9pc+17F6pl3JVCwMc= +gopkg.in/alexcesaro/statsd.v2 v2.0.0/go.mod h1:i0ubccKGzBVNBpdGV5MocxyA/XlLUJzA7SLonnE4drU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= +gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= +gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= +gotest.tools/v3 v3.2.0 h1:I0DwBVMGAx26dttAj1BtJLAkVGncrkkUXfJLC4Flt/I= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= +honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= +rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8= +rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0= +rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA= diff --git a/gorillaware/stats.go b/gorillaware/stats.go new file mode 100644 index 00000000..3767a432 --- /dev/null +++ b/gorillaware/stats.go @@ -0,0 +1,81 @@ +package gorillaware + +import ( + "context" + "fmt" + "net/http" + "strconv" + "sync/atomic" + "time" + + "github.com/gorilla/mux" + "github.com/rudderlabs/rudder-go-kit/stats" +) + +func StatMiddleware(ctx context.Context, router *mux.Router, s stats.Stats, component string) func(http.Handler) http.Handler { + var concurrentRequests int32 + activeClientCount := s.NewStat(fmt.Sprintf("%s.concurrent_requests_count", component), stats.GaugeType) + go func() { + for { + select { + case <-ctx.Done(): + return + case <-time.After(10 * time.Second): + activeClientCount.Gauge(atomic.LoadInt32(&concurrentRequests)) + } + } + }() + + // getPath retrieves the path from the request. + // The matched route's template is used if a match is found, + // otherwise the request's URL path is used instead. + getPath := func(r *http.Request) string { + var match mux.RouteMatch + if router.Match(r, &match) { + if path, err := match.Route.GetPathTemplate(); err == nil { + return path + } + } + return r.URL.Path + } + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + sw := newStatusCapturingWriter(w) + path := getPath(r) + start := time.Now() + atomic.AddInt32(&concurrentRequests, 1) + defer atomic.AddInt32(&concurrentRequests, -1) + + next.ServeHTTP(sw, r) + + s.NewSampledTaggedStat( + fmt.Sprintf("%s.response_time", component), + stats.TimerType, + map[string]string{ + "reqType": path, + "method": r.Method, + "code": strconv.Itoa(sw.status), + }).Since(start) + }) + } +} + +// newStatusCapturingWriter returns a new, properly initialized statusCapturingWriter +func newStatusCapturingWriter(w http.ResponseWriter) *statusCapturingWriter { + return &statusCapturingWriter{ + ResponseWriter: w, + status: http.StatusOK, + } +} + +// statusCapturingWriter is a response writer decorator that captures the status code. +type statusCapturingWriter struct { + http.ResponseWriter + status int +} + +// WriteHeader override the http.ResponseWriter's `WriteHeader` method +func (w *statusCapturingWriter) WriteHeader(status int) { + w.status = status + w.ResponseWriter.WriteHeader(status) +} diff --git a/gorillaware/stats_test.go b/gorillaware/stats_test.go new file mode 100644 index 00000000..2cb9f29c --- /dev/null +++ b/gorillaware/stats_test.go @@ -0,0 +1,57 @@ +package gorillaware_test + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + + "github.com/golang/mock/gomock" + "github.com/gorilla/mux" + "github.com/rudderlabs/rudder-go-kit/gorillaware" + "github.com/rudderlabs/rudder-go-kit/stats" + "github.com/rudderlabs/rudder-go-kit/stats/mock_stats" + "github.com/stretchr/testify/require" +) + +func TestStatsMiddleware(t *testing.T) { + component := "test" + testCase := func(expectedStatusCode int, pathTemplate, requestPath, expectedReqType, expectedMethod string) func(t *testing.T) { + return func(t *testing.T) { + ctrl := gomock.NewController(t) + mockStats := mock_stats.NewMockStats(ctrl) + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(expectedStatusCode) + }) + + measurement := mock_stats.NewMockMeasurement(ctrl) + mockStats.EXPECT().NewStat(fmt.Sprintf("%s.concurrent_requests_count", component), stats.GaugeType).Return(measurement).Times(1) + mockStats.EXPECT().NewSampledTaggedStat(fmt.Sprintf("%s.response_time", component), stats.TimerType, + map[string]string{ + "reqType": expectedReqType, + "method": expectedMethod, + "code": strconv.Itoa(expectedStatusCode), + }).Return(measurement).Times(1) + measurement.EXPECT().Since(gomock.Any()).Times(1) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + router := mux.NewRouter() + router.Use( + gorillaware.StatMiddleware(ctx, router, mockStats, component), + ) + router.HandleFunc(pathTemplate, handler).Methods(expectedMethod) + + response := httptest.NewRecorder() + request := httptest.NewRequest("GET", "http://example.com"+requestPath, http.NoBody) + router.ServeHTTP(response, request) + require.Equal(t, expectedStatusCode, response.Code) + } + } + + t.Run("template with param in path", testCase(http.StatusNotFound, "/v1/{param}", "/v1/abc", "/v1/{param}", "GET")) + + t.Run("template without param in path", testCase(http.StatusNotFound, "/v1/some-other/key", "/v1/some-other/key", "/v1/some-other/key", "GET")) +} diff --git a/httputil/client.go b/httputil/client.go new file mode 100644 index 00000000..440646e0 --- /dev/null +++ b/httputil/client.go @@ -0,0 +1,17 @@ +package httputil + +import ( + "io" + "net/http" +) + +// CloseResponse closes the response's body. But reads at least some of the body so if it's +// small the underlying TCP connection will be re-used. No need to check for errors: if it +// fails, the Transport won't reuse it anyway. +func CloseResponse(resp *http.Response) { + if resp != nil && resp.Body != nil { + const maxBodySlurpSize = 2 << 10 // 2KB + _, _ = io.CopyN(io.Discard, resp.Body, maxBodySlurpSize) + resp.Body.Close() + } +} diff --git a/logger/config.go b/logger/config.go new file mode 100644 index 00000000..c76603dd --- /dev/null +++ b/logger/config.go @@ -0,0 +1,79 @@ +package logger + +import ( + "errors" + "sync" + + "go.uber.org/zap/zapcore" +) + +// factoryConfig is the configuration for the logger +type factoryConfig struct { + rootLevel int // the level for the root logger + enableNameInLog bool // whether to include the logger name in the log message + enableStackTrace bool // for fatal logs + + levelConfig *syncMap[string, int] // preconfigured log levels for loggers + levelConfigCache *syncMap[string, int] // cache of all calculated log levels for loggers + + // zap specific config + clock zapcore.Clock +} + +// SetLogLevel sets the log level for the given logger name +func (fc *factoryConfig) SetLogLevel(name, levelStr string) error { + level, ok := levelMap[levelStr] + if !ok { + return errors.New("invalid level value : " + levelStr) + } + if name == "" { + fc.rootLevel = level + } else { + fc.levelConfig.set(name, level) + } + fc.levelConfigCache = newSyncMap[string, int]() + return nil +} + +// getOrSetLogLevel returns the log level for the given logger name or sets it using the provided function if no level is set +func (fc *factoryConfig) getOrSetLogLevel(name string, parentLevelFunc func() int) int { + if name == "" { + return fc.rootLevel + } + + if level, found := fc.levelConfigCache.get(name); found { + return level + } + level := func() int { // either get the level from the config or use the parent's level + if level, ok := fc.levelConfig.get(name); ok { + return level + } + return parentLevelFunc() + }() + fc.levelConfigCache.set(name, level) // cache the level + return level +} + +// newSyncMap creates a new syncMap +func newSyncMap[K comparable, V any]() *syncMap[K, V] { + return &syncMap[K, V]{m: map[K]V{}} +} + +// syncMap is a thread safe map for getting and setting keys concurrently +type syncMap[K comparable, V any] struct { + mu sync.RWMutex + m map[K]V +} + +func (sm *syncMap[K, V]) get(key K) (V, bool) { + sm.mu.RLock() + defer sm.mu.RUnlock() + v, ok := sm.m[key] + return v, ok +} + +func (sm *syncMap[K, V]) set(key K, value V) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.m[key] = value +} diff --git a/logger/factory.go b/logger/factory.go new file mode 100644 index 00000000..8ffa4844 --- /dev/null +++ b/logger/factory.go @@ -0,0 +1,175 @@ +package logger + +import ( + "os" + "strings" + + "github.com/rudderlabs/rudder-go-kit/config" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "gopkg.in/natefinch/lumberjack.v2" +) + +// default factory instance +var Default *Factory + +func init() { + Default = NewFactory(config.Default) +} + +// Reset resets the default logger factory. +// Shall only be used by tests, until we move to a proper DI framework +func Reset() { + Default = NewFactory(config.Default) +} + +// NewFactory creates a new logger factory +func NewFactory(config *config.Config, options ...Option) *Factory { + f := &Factory{} + f.config = newConfig(config) + for _, option := range options { + option.apply(f) + } + f.zap = newZapLogger(config, f.config) + return f +} + +// Factory is a factory for creating new loggers +type Factory struct { + config *factoryConfig + zap *zap.SugaredLogger +} + +// NewLogger creates a new logger using the default logger factory +func NewLogger() Logger { + return Default.NewLogger() +} + +// NewLogger creates a new logger +func (f *Factory) NewLogger() Logger { + return &logger{ + logConfig: f.config, + zap: f.zap, + } +} + +// GetLoggingConfig returns the log levels for default logger factory +func GetLoggingConfig() map[string]int { + return Default.GetLoggingConfig() +} + +// GetLoggingConfig returns the log levels +func (f *Factory) GetLoggingConfig() map[string]int { + return f.config.levelConfigCache.m +} + +// SetLogLevel sets the log level for a module for the default logger factory +func SetLogLevel(name, levelStr string) error { + return Default.SetLogLevel(name, levelStr) +} + +// SetLogLevel sets the log level for a module +func (f *Factory) SetLogLevel(name, levelStr string) error { + err := f.config.SetLogLevel(name, levelStr) + if err != nil { + f.zap.Info(f.config.levelConfig) + } + return err +} + +// Sync flushes the loggers' output buffers for the default logger factory +func Sync() { + Default.Sync() +} + +// Sync flushes the loggers' output buffers +func (f *Factory) Sync() { + _ = f.zap.Sync() +} + +func newConfig(config *config.Config) *factoryConfig { + fc := &factoryConfig{ + levelConfig: &syncMap[string, int]{m: make(map[string]int)}, + levelConfigCache: &syncMap[string, int]{m: make(map[string]int)}, + } + fc.rootLevel = levelMap[config.GetString("LOG_LEVEL", "INFO")] + fc.enableNameInLog = config.GetBool("Logger.enableLoggerNameInLog", true) + config.RegisterBoolConfigVariable(false, &fc.enableStackTrace, true, "Logger.enableStackTrace") + config.GetBool("Logger.enableLoggerNameInLog", true) + + // colon separated key value pairs + // Example: "router.GA=DEBUG:warehouse.REDSHIFT=DEBUG" + levelConfigStr := strings.TrimSpace(config.GetString("Logger.moduleLevels", "")) + if levelConfigStr != "" { + moduleLevelKVs := strings.Split(levelConfigStr, ":") + for _, moduleLevelKV := range moduleLevelKVs { + pair := strings.SplitN(moduleLevelKV, "=", 2) + if len(pair) < 2 { + continue + } + module := strings.TrimSpace(pair[0]) + if module == "" { + continue + } + levelStr := strings.TrimSpace(pair[1]) + level, ok := levelMap[levelStr] + if !ok { + continue + } + fc.levelConfig.set(module, level) + } + } + return fc +} + +// newZapLogger configures the zap logger based on the config provide in config.toml +func newZapLogger(config *config.Config, fc *factoryConfig) *zap.SugaredLogger { + var cores []zapcore.Core + if config.GetBool("Logger.enableConsole", true) { + writer := zapcore.Lock(os.Stdout) + core := zapcore.NewCore(zapEncoder(config, config.GetBool("Logger.consoleJsonFormat", false)), writer, zapcore.DebugLevel) + cores = append(cores, core) + } + if config.GetBool("Logger.enableFile", false) { + writer := zapcore.AddSync(&lumberjack.Logger{ + Filename: config.GetString("Logger.logFileLocation", "/tmp/rudder_log.log"), + MaxSize: config.GetInt("Logger.logFileSize", 100), + Compress: true, + LocalTime: true, + }) + core := zapcore.NewCore(zapEncoder(config, config.GetBool("Logger.fileJsonFormat", false)), writer, zapcore.DebugLevel) + cores = append(cores, core) + } + combinedCore := zapcore.NewTee(cores...) + var options []zap.Option + if config.GetBool("Logger.enableFileNameInLog", true) { + options = append(options, zap.AddCaller(), zap.AddCallerSkip(1)) + } + if config.GetBool("Logger.enableStackTrace", false) { + // enables stack track for log level error + options = append(options, zap.AddStacktrace(zap.ErrorLevel)) + } + + if fc.clock != nil { + options = append(options, zap.WithClock(fc.clock)) + } + + zapLogger := zap.New(combinedCore, options...) + return zapLogger.Sugar() +} + +// zapEncoder configures the output of the log +func zapEncoder(config *config.Config, json bool) zapcore.Encoder { + encoderConfig := zap.NewProductionEncoderConfig() + encoderConfig.EncodeLevel = zapcore.CapitalLevelEncoder + if config.GetBool("Logger.enableTimestamp", true) { + encoderConfig.TimeKey = "ts" + encoderConfig.EncodeTime = zapcore.ISO8601TimeEncoder + } else { + encoderConfig.TimeKey = "" + } + if json { + return zapcore.NewJSONEncoder(encoderConfig) + } + return zapcore.NewConsoleEncoder(encoderConfig) +} diff --git a/logger/factory_test.go b/logger/factory_test.go new file mode 100644 index 00000000..ae6b75c9 --- /dev/null +++ b/logger/factory_test.go @@ -0,0 +1,171 @@ +package logger_test + +import ( + "bufio" + "os" + "testing" + + "github.com/rudderlabs/rudder-go-kit/config" + "github.com/rudderlabs/rudder-go-kit/logger" + "github.com/stretchr/testify/require" + "github.com/zenizh/go-capturer" +) + +func Test_Config_Default(t *testing.T) { + c := config.New() + stdout := capturer.CaptureStdout(func() { + loggerFactory := logger.NewFactory(c, constantClockOpt) + logger := loggerFactory.NewLogger() + logger.Info("hello world") + loggerFactory.Sync() + }) + require.Contains(t, stdout, "2077-01-23T10:15:13.000Z") + require.Contains(t, stdout, "INFO") + require.Contains(t, stdout, "hello world") +} + +func Test_Config_FileOutput(t *testing.T) { + tmpDir := t.TempDir() + c := config.New() + c.Set("Logger.enableTimestamp", false) + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.enableLoggerNameInLog", false) + c.Set("Logger.logFileLocation", tmpDir+"out.log") + + stdout := capturer.CaptureStdout(func() { + loggerFactory := logger.NewFactory(c, constantClockOpt) + logger := loggerFactory.NewLogger() + logger.Info("hello world") + }) + require.Empty(t, stdout, "it should not log anything to stdout") + + fileOut, err := os.ReadFile(tmpDir + "out.log") + + require.NoError(t, err, "file should exist") + require.Equal(t, "INFO hello world\n", string(fileOut)) +} + +func TestLogLevelFromConfig(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "INFO") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.enableLoggerNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + + c.Set("Logger.moduleLevels", "1=DEBUG:1.2=WARN:1.2.3=ERROR") + loggerFactory := logger.NewFactory(c, constantClockOpt) + rootLogger := loggerFactory.NewLogger() + lvl1Logger := rootLogger.Child("1") + lvl2Logger := lvl1Logger.Child("2") + lvl3Logger := lvl2Logger.Child("3") + + rootLogger.Info("hello world") + scanner := bufio.NewScanner(f) + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO hello world", scanner.Text()) + + lvl1Logger.Debug("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z DEBUG hello world", scanner.Text()) + + lvl2Logger.Warn("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z WARN hello world", scanner.Text()) + + lvl3Logger.Error("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z ERROR hello world", scanner.Text()) + + require.Equal(t, map[string]int{"1": 1, "1.2": 3, "1.2.3": 4}, loggerFactory.GetLoggingConfig()) +} + +func Test_SetLogLevel(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "WARN") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.enableLoggerNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + rootLogger := loggerFactory.NewLogger() + + rootLogger.Info("hello world") + require.False(t, bufio.NewScanner(f).Scan(), "it should not print a log statement for a level lower than WARN") + + rootLogger.Warn("hello world") + scanner := bufio.NewScanner(f) + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z WARN hello world", scanner.Text()) + + // change level to INFO + require.NoError(t, loggerFactory.SetLogLevel("", "INFO")) + rootLogger.Info("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO hello world", scanner.Text()) + + otherLogger := rootLogger.Child("other") + otherLogger.Info("other hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO other hello world", scanner.Text()) + + require.NoError(t, loggerFactory.SetLogLevel("other", "DEBUG")) + otherLogger.Debug("other hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z DEBUG other hello world", scanner.Text()) + rootLogger.Debug("other hello world") + require.False(t, scanner.Scan(), "it should not print a log statement for a level lower than INFO") +} + +func Test_Config_Suppressed_Logs(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "FATAL") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + rootLogger := loggerFactory.NewLogger() + + rootLogger.Debug("hello world") + rootLogger.Debugf("hello %s", "world") + rootLogger.Debugw("hello world", "key", "value") + require.False(t, bufio.NewScanner(f).Scan(), "it should not print a log statement for a level lower than FATAL") + + rootLogger.Info("hello world") + rootLogger.Infof("hello %s", "world") + rootLogger.Infow("hello world", "key", "value") + require.False(t, bufio.NewScanner(f).Scan(), "it should not print a log statement for a level lower than FATAL") + + rootLogger.Warn("hello world") + rootLogger.Warnf("hello %s", "world") + rootLogger.Warnw("hello world", "key", "value") + require.False(t, bufio.NewScanner(f).Scan(), "it should not print a log statement for a level lower than FATAL") + + rootLogger.Error("hello world") + rootLogger.Errorf("hello %s", "world") + rootLogger.Errorw("hello world", "key", "value") + require.False(t, bufio.NewScanner(f).Scan(), "it should not print a log statement for a level lower than FATAL") +} diff --git a/logger/level.go b/logger/level.go new file mode 100644 index 00000000..66824f3c --- /dev/null +++ b/logger/level.go @@ -0,0 +1,19 @@ +package logger + +const ( + levelEvent = iota // Logs Event + levelDebug // Most verbose logging level + levelInfo // Logs about state of the application + levelWarn // Logs about warnings + levelError // Logs about errors which dont immediately halt the application + levelFatal // Logs which crashes the application +) + +var levelMap = map[string]int{ + "EVENT": levelEvent, + "DEBUG": levelDebug, + "INFO": levelInfo, + "WARN": levelWarn, + "ERROR": levelError, + "FATAL": levelFatal, +} diff --git a/logger/logger.go b/logger/logger.go new file mode 100644 index 00000000..4f06e196 --- /dev/null +++ b/logger/logger.go @@ -0,0 +1,299 @@ +/* +Logger Interface Use instance of logger instead of exported functions + +usage example + + import ( + "errors" + "github.com/rudderlabs/rudder-go-kit/config" + "github.com/rudderlabs/rudder-go-kit/logger" + ) + + var c = config.New() + var loggerFactory = logger.NewFactory(c) + var log logger.Logger = loggerFactory.NewLogger() + ... + log.Error(...) + +or if you want to use the default logger factory (not advised): + + var log logger.Logger = logger.NewLogger() + ... + log.Error(...) + +*/ +//go:generate mockgen -destination=mock_logger/mock_logger.go -package mock_logger github.com/rudderlabs/rudder-go-kit/logger Logger +package logger + +import ( + "bytes" + "io" + "net/http" + "runtime" + "strings" + + "go.uber.org/zap" +) + +/* +Using levels(like Debug, Info etc.) in logging is a way to categorize logs based on their importance. +The idea is to have the option of running the application in different logging levels based on +how verbose we want the logging to be. +For example, using Debug level of logging, logs everything and it might slow the application, so we run application +in DEBUG level for local development or when we want to look through the entire flow of events in detail. +We use 4 logging levels here Debug, Info, Warn and Error. +*/ + +type Logger interface { + // IsDebugLevel Returns true is debug lvl is enabled + IsDebugLevel() bool + + // Debug level logging. Most verbose logging level. + Debug(args ...interface{}) + + // Debugf does debug level logging similar to fmt.Printf. Most verbose logging level + Debugf(format string, args ...interface{}) + + // Debugw does debug level structured logging. Most verbose logging level + Debugw(msg string, keysAndValues ...interface{}) + + // Info level logging. Use this to log the state of the application. Dont use Logger.Info in the flow of individual events. Use Logger.Debug instead. + Info(args ...interface{}) + + // Infof does info level logging similar to fmt.Printf. Use this to log the state of the application. Dont use Logger.Info in the flow of individual events. Use Logger.Debug instead. + Infof(format string, args ...interface{}) + + // Infof does info level structured logging. Use this to log the state of the application. Dont use Logger.Info in the flow of individual events. Use Logger.Debug instead. + Infow(msg string, keysAndValues ...interface{}) + + // Warn level logging. Use this to log warnings + Warn(args ...interface{}) + + // Warnf does warn level logging similar to fmt.Printf. Use this to log warnings + Warnf(format string, args ...interface{}) + + // Warnf does warn level structured logging. Use this to log warnings + Warnw(msg string, keysAndValues ...interface{}) + + // Error level logging. Use this to log errors which dont immediately halt the application. + Error(args ...interface{}) + + // Errorf does error level logging similar to fmt.Printf. Use this to log errors which dont immediately halt the application. + Errorf(format string, args ...interface{}) + + // Errorf does error level structured logging. Use this to log errors which dont immediately halt the application. + Errorw(msg string, keysAndValues ...interface{}) + + // Fatal level logging. Use this to log errors which crash the application. + Fatal(args ...interface{}) + + // Fatalf does fatal level logging similar to fmt.Printf. Use this to log errors which crash the application. + Fatalf(format string, args ...interface{}) + + // Fatalf does fatal level structured logging. Use this to log errors which crash the application. + Fatalw(format string, keysAndValues ...interface{}) + + LogRequest(req *http.Request) + + // Child creates a child logger with the given name + Child(s string) Logger + + // With adds the provided key value pairs to the logger context + With(args ...interface{}) Logger +} + +type logger struct { + logConfig *factoryConfig + name string + zap *zap.SugaredLogger + parent *logger +} + +func (l *logger) Child(s string) Logger { + if s == "" { + return l + } + cp := *l + cp.parent = l + if l.name == "" { + cp.name = s + } else { + cp.name = strings.Join([]string{l.name, s}, ".") + } + if l.logConfig.enableNameInLog { + cp.zap = l.zap.Named(s) + } + return &cp +} + +// With adds a variadic number of fields to the logging context. It accepts a mix of strongly-typed Field objects and loosely-typed key-value pairs. When processing pairs, the first element of the pair is used as the field key and the second as the field value. +func (l *logger) With(args ...interface{}) Logger { + cp := *l + cp.zap = l.zap.With(args...) + return &cp +} + +func (l *logger) getLoggingLevel() int { + return l.logConfig.getOrSetLogLevel(l.name, l.parent.getLoggingLevel) +} + +// IsDebugLevel Returns true is debug lvl is enabled +func (l *logger) IsDebugLevel() bool { + return levelDebug >= l.getLoggingLevel() +} + +// Debug level logging. +// Most verbose logging level. +func (l *logger) Debug(args ...interface{}) { + if levelDebug >= l.getLoggingLevel() { + l.zap.Debug(args...) + } +} + +// Info level logging. +// Use this to log the state of the application. Dont use Logger.Info in the flow of individual events. Use Logger.Debug instead. +func (l *logger) Info(args ...interface{}) { + if levelInfo >= l.getLoggingLevel() { + l.zap.Info(args...) + } +} + +// Warn level logging. +// Use this to log warnings +func (l *logger) Warn(args ...interface{}) { + if levelWarn >= l.getLoggingLevel() { + l.zap.Warn(args...) + } +} + +// Error level logging. +// Use this to log errors which dont immediately halt the application. +func (l *logger) Error(args ...interface{}) { + if levelError >= l.getLoggingLevel() { + l.zap.Error(args...) + } +} + +// Fatal level logging. +// Use this to log errors which crash the application. +func (l *logger) Fatal(args ...interface{}) { + l.zap.Error(args...) + + // If enableStackTrace is true, Zaplogger will take care of writing stacktrace to the file. + // Else, we are force writing the stacktrace to the file. + if !l.logConfig.enableStackTrace { + byteArr := make([]byte, 2048) + n := runtime.Stack(byteArr, false) + stackTrace := string(byteArr[:n]) + l.zap.Error(stackTrace) + } + _ = l.zap.Sync() +} + +// Debugf does debug level logging similar to fmt.Printf. +// Most verbose logging level +func (l *logger) Debugf(format string, args ...interface{}) { + if levelDebug >= l.getLoggingLevel() { + l.zap.Debugf(format, args...) + } +} + +// Infof does info level logging similar to fmt.Printf. +// Use this to log the state of the application. Dont use Logger.Info in the flow of individual events. Use Logger.Debug instead. +func (l *logger) Infof(format string, args ...interface{}) { + if levelInfo >= l.getLoggingLevel() { + l.zap.Infof(format, args...) + } +} + +// Warnf does warn level logging similar to fmt.Printf. +// Use this to log warnings +func (l *logger) Warnf(format string, args ...interface{}) { + if levelWarn >= l.getLoggingLevel() { + l.zap.Warnf(format, args...) + } +} + +// Errorf does error level logging similar to fmt.Printf. +// Use this to log errors which dont immediately halt the application. +func (l *logger) Errorf(format string, args ...interface{}) { + if levelError >= l.getLoggingLevel() { + l.zap.Errorf(format, args...) + } +} + +// Fatalf does fatal level logging similar to fmt.Printf. +// Use this to log errors which crash the application. +func (l *logger) Fatalf(format string, args ...interface{}) { + l.zap.Errorf(format, args...) + + // If enableStackTrace is true, Zaplogger will take care of writing stacktrace to the file. + // Else, we are force writing the stacktrace to the file. + if !l.logConfig.enableStackTrace { + byteArr := make([]byte, 2048) + n := runtime.Stack(byteArr, false) + stackTrace := string(byteArr[:n]) + l.zap.Error(stackTrace) + } + _ = l.zap.Sync() +} + +// Debugw does debug level structured logging. +// Most verbose logging level +func (l *logger) Debugw(msg string, keysAndValues ...interface{}) { + if levelDebug >= l.getLoggingLevel() { + l.zap.Debugw(msg, keysAndValues...) + } +} + +// Infof does info level structured logging. +// Use this to log the state of the application. Dont use Logger.Info in the flow of individual events. Use Logger.Debug instead. +func (l *logger) Infow(msg string, keysAndValues ...interface{}) { + if levelInfo >= l.getLoggingLevel() { + l.zap.Infow(msg, keysAndValues...) + } +} + +// Warnf does warn level structured logging. +// Use this to log warnings +func (l *logger) Warnw(msg string, keysAndValues ...interface{}) { + if levelWarn >= l.getLoggingLevel() { + l.zap.Warnw(msg, keysAndValues...) + } +} + +// Errorf does error level structured logging. +// Use this to log errors which dont immediately halt the application. +func (l *logger) Errorw(msg string, keysAndValues ...interface{}) { + if levelError >= l.getLoggingLevel() { + l.zap.Errorw(msg, keysAndValues...) + } +} + +// Fatalf does fatal level structured logging. +// Use this to log errors which crash the application. +func (l *logger) Fatalw(msg string, keysAndValues ...interface{}) { + l.zap.Errorw(msg, keysAndValues...) + + // If enableStackTrace is true, Zaplogger will take care of writing stacktrace to the file. + // Else, we are force writing the stacktrace to the file. + if !l.logConfig.enableStackTrace { + byteArr := make([]byte, 2048) + n := runtime.Stack(byteArr, false) + stackTrace := string(byteArr[:n]) + l.zap.Error(stackTrace) + } + _ = l.zap.Sync() +} + +// LogRequest reads and logs the request body and resets the body to original state. +func (l *logger) LogRequest(req *http.Request) { + if levelEvent >= l.getLoggingLevel() { + defer func() { _ = req.Body.Close() }() + bodyBytes, _ := io.ReadAll(req.Body) + bodyString := string(bodyBytes) + req.Body = io.NopCloser(bytes.NewBuffer(bodyBytes)) + // print raw request body for debugging purposes + l.zap.Debug("Request Body: ", bodyString) + } +} diff --git a/logger/logger_test.go b/logger/logger_test.go new file mode 100644 index 00000000..267d4541 --- /dev/null +++ b/logger/logger_test.go @@ -0,0 +1,290 @@ +package logger_test + +import ( + "bufio" + "bytes" + "net/http" + "os" + "testing" + "time" + + "github.com/rudderlabs/rudder-go-kit/config" + "github.com/rudderlabs/rudder-go-kit/logger" + "github.com/stretchr/testify/require" + "github.com/zenizh/go-capturer" +) + +type constantClock time.Time + +func (c constantClock) Now() time.Time { return time.Time(c) } +func (constantClock) NewTicker(_ time.Duration) *time.Ticker { + return &time.Ticker{} +} + +var ( + date = time.Date(2077, 1, 23, 10, 15, 13, 0o00, time.UTC) + constantClockOpt = logger.WithClock(constantClock(date)) +) + +func Test_Print_All_Levels(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "EVENT") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + + rootLogger := loggerFactory.NewLogger() + require.True(t, rootLogger.IsDebugLevel()) + + scanner := bufio.NewScanner(f) + + rootLogger.Debug("hello ", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z DEBUG hello world", scanner.Text()) + + rootLogger.Info("hello ", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO hello world", scanner.Text()) + + rootLogger.Warn("hello ", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z WARN hello world", scanner.Text()) + + rootLogger.Error("hello ", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z ERROR hello world", scanner.Text()) + + rootLogger.Fatal("hello ", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z ERROR hello world", scanner.Text()) +} + +func Test_Printf_All_Levels(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "EVENT") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + + rootLogger := loggerFactory.NewLogger() + require.True(t, rootLogger.IsDebugLevel()) + + scanner := bufio.NewScanner(f) + + rootLogger.Debugf("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z DEBUG hello world", scanner.Text()) + + rootLogger.Infof("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO hello world", scanner.Text()) + + rootLogger.Warnf("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z WARN hello world", scanner.Text()) + + rootLogger.Errorf("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z ERROR hello world", scanner.Text()) + + rootLogger.Fatalf("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z ERROR hello world", scanner.Text()) +} + +func Test_Printw_All_Levels(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "EVENT") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.enableLoggerNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + + rootLogger := loggerFactory.NewLogger() + require.True(t, rootLogger.IsDebugLevel()) + + scanner := bufio.NewScanner(f) + + rootLogger.Debugw("hello world", "key", "value") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z DEBUG hello world {"key": "value"}`, scanner.Text()) + + rootLogger.Infow("hello world", "key", "value") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world {"key": "value"}`, scanner.Text()) + + rootLogger.Warnw("hello world", "key", "value") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z WARN hello world {"key": "value"}`, scanner.Text()) + + rootLogger.Errorw("hello world", "key", "value") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z ERROR hello world {"key": "value"}`, scanner.Text()) + + rootLogger.Fatalw("hello world", "key", "value") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z ERROR hello world {"key": "value"}`, scanner.Text()) +} + +func Test_Logger_With_Context(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "INFO") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.enableLoggerNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + rootLogger := loggerFactory.NewLogger() + ctxLogger := rootLogger.With("key", "value") + + scanner := bufio.NewScanner(f) + + rootLogger.Info("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world`, scanner.Text()) + ctxLogger.Info("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world {"key": "value"}`, scanner.Text()) + + rootLogger.Infof("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world`, scanner.Text()) + ctxLogger.Infof("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world {"key": "value"}`, scanner.Text()) + + rootLogger.Infow("hello world", "key1", "value1") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world {"key1": "value1"}`, scanner.Text()) + ctxLogger.Infow("hello world", "key1", "value1") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `2077-01-23T10:15:13.000Z INFO hello world {"key": "value", "key1": "value1"}`, scanner.Text()) +} + +func Test_Logger_Deep_Hierarchy(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "INFO") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + loggerFactory := logger.NewFactory(c, constantClockOpt) + rootLogger := loggerFactory.NewLogger() + lvl1Logger := rootLogger.Child("logger1") + lvl2Logger := lvl1Logger.Child("logger2") + lvl3Logger := lvl2Logger.Child("logger3") + + rootLogger.Info("hello world 0") + scanner := bufio.NewScanner(f) + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO hello world 0", scanner.Text()) + + lvl1Logger.Info("hello world 1") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO logger1 hello world 1", scanner.Text()) + + lvl2Logger.Info("hello world 2") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO logger1.logger2 hello world 2", scanner.Text()) + + lvl3Logger.Info("hello world 3") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, "2077-01-23T10:15:13.000Z INFO logger1.logger2.logger3 hello world 3", scanner.Text()) +} + +func Test_Logger_Json_Output(t *testing.T) { + fileName := t.TempDir() + "out.log" + f, err := os.Create(fileName) + require.NoError(t, err) + defer func() { _ = f.Close() }() + + c := config.New() + // start with default log level WARN + c.Set("LOG_LEVEL", "INFO") + c.Set("Logger.enableConsole", false) + c.Set("Logger.enableFile", true) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.logFileLocation", fileName) + c.Set("Logger.fileJsonFormat", true) + loggerFactory := logger.NewFactory(c, constantClockOpt) + rootLogger := loggerFactory.NewLogger().Child("mylogger") + ctxLogger := rootLogger.With("key", "value") + + scanner := bufio.NewScanner(f) + + rootLogger.Info("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `{"level":"INFO","ts":"2077-01-23T10:15:13.000Z","logger":"mylogger","msg":"hello world"}`, scanner.Text()) + ctxLogger.Info("hello world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `{"level":"INFO","ts":"2077-01-23T10:15:13.000Z","logger":"mylogger","msg":"hello world","key":"value"}`, scanner.Text()) + + rootLogger.Infof("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `{"level":"INFO","ts":"2077-01-23T10:15:13.000Z","logger":"mylogger","msg":"hello world"}`, scanner.Text()) + ctxLogger.Infof("hello %s", "world") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `{"level":"INFO","ts":"2077-01-23T10:15:13.000Z","logger":"mylogger","msg":"hello world","key":"value"}`, scanner.Text()) + + rootLogger.Infow("hello world", "key1", "value1") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `{"level":"INFO","ts":"2077-01-23T10:15:13.000Z","logger":"mylogger","msg":"hello world","key1":"value1"}`, scanner.Text()) + ctxLogger.Infow("hello world", "key1", "value1") + require.True(t, scanner.Scan(), "it should print a log statement") + require.Equal(t, `{"level":"INFO","ts":"2077-01-23T10:15:13.000Z","logger":"mylogger","msg":"hello world","key":"value","key1":"value1"}`, scanner.Text()) +} + +func Test_LogRequest(t *testing.T) { + json := `{"key":"value"}` + request, err := http.NewRequest(http.MethodPost, "https://example.com", bytes.NewReader([]byte(json))) + require.NoError(t, err) + c := config.New() + c.Set("LOG_LEVEL", "EVENT") + c.Set("Logger.enableTimestamp", false) + c.Set("Logger.enableFileNameInLog", false) + c.Set("Logger.enableLoggerNameInLog", false) + stdout := capturer.CaptureStdout(func() { + loggerFactory := logger.NewFactory(c, constantClockOpt) + logger := loggerFactory.NewLogger() + logger.LogRequest(request) + loggerFactory.Sync() + }) + require.Equal(t, `DEBUG Request Body: {"key":"value"}`+"\n", stdout) +} diff --git a/logger/mock_logger/mock_logger.go b/logger/mock_logger/mock_logger.go new file mode 100644 index 00000000..997a009b --- /dev/null +++ b/logger/mock_logger/mock_logger.go @@ -0,0 +1,344 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rudderlabs/rudder-go-kit/logger (interfaces: Logger) + +// Package mock_logger is a generated GoMock package. +package mock_logger + +import ( + http "net/http" + reflect "reflect" + + gomock "github.com/golang/mock/gomock" + logger "github.com/rudderlabs/rudder-go-kit/logger" +) + +// MockLogger is a mock of Logger interface. +type MockLogger struct { + ctrl *gomock.Controller + recorder *MockLoggerMockRecorder +} + +// MockLoggerMockRecorder is the mock recorder for MockLogger. +type MockLoggerMockRecorder struct { + mock *MockLogger +} + +// NewMockLogger creates a new mock instance. +func NewMockLogger(ctrl *gomock.Controller) *MockLogger { + mock := &MockLogger{ctrl: ctrl} + mock.recorder = &MockLoggerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockLogger) EXPECT() *MockLoggerMockRecorder { + return m.recorder +} + +// Child mocks base method. +func (m *MockLogger) Child(arg0 string) logger.Logger { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Child", arg0) + ret0, _ := ret[0].(logger.Logger) + return ret0 +} + +// Child indicates an expected call of Child. +func (mr *MockLoggerMockRecorder) Child(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Child", reflect.TypeOf((*MockLogger)(nil).Child), arg0) +} + +// Debug mocks base method. +func (m *MockLogger) Debug(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debug", varargs...) +} + +// Debug indicates an expected call of Debug. +func (mr *MockLoggerMockRecorder) Debug(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debug", reflect.TypeOf((*MockLogger)(nil).Debug), arg0...) +} + +// Debugf mocks base method. +func (m *MockLogger) Debugf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugf", varargs...) +} + +// Debugf indicates an expected call of Debugf. +func (mr *MockLoggerMockRecorder) Debugf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugf", reflect.TypeOf((*MockLogger)(nil).Debugf), varargs...) +} + +// Debugw mocks base method. +func (m *MockLogger) Debugw(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Debugw", varargs...) +} + +// Debugw indicates an expected call of Debugw. +func (mr *MockLoggerMockRecorder) Debugw(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Debugw", reflect.TypeOf((*MockLogger)(nil).Debugw), varargs...) +} + +// Error mocks base method. +func (m *MockLogger) Error(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Error", varargs...) +} + +// Error indicates an expected call of Error. +func (mr *MockLoggerMockRecorder) Error(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Error", reflect.TypeOf((*MockLogger)(nil).Error), arg0...) +} + +// Errorf mocks base method. +func (m *MockLogger) Errorf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Errorf", varargs...) +} + +// Errorf indicates an expected call of Errorf. +func (mr *MockLoggerMockRecorder) Errorf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorf", reflect.TypeOf((*MockLogger)(nil).Errorf), varargs...) +} + +// Errorw mocks base method. +func (m *MockLogger) Errorw(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Errorw", varargs...) +} + +// Errorw indicates an expected call of Errorw. +func (mr *MockLoggerMockRecorder) Errorw(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Errorw", reflect.TypeOf((*MockLogger)(nil).Errorw), varargs...) +} + +// Fatal mocks base method. +func (m *MockLogger) Fatal(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Fatal", varargs...) +} + +// Fatal indicates an expected call of Fatal. +func (mr *MockLoggerMockRecorder) Fatal(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatal", reflect.TypeOf((*MockLogger)(nil).Fatal), arg0...) +} + +// Fatalf mocks base method. +func (m *MockLogger) Fatalf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Fatalf", varargs...) +} + +// Fatalf indicates an expected call of Fatalf. +func (mr *MockLoggerMockRecorder) Fatalf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatalf", reflect.TypeOf((*MockLogger)(nil).Fatalf), varargs...) +} + +// Fatalw mocks base method. +func (m *MockLogger) Fatalw(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Fatalw", varargs...) +} + +// Fatalw indicates an expected call of Fatalw. +func (mr *MockLoggerMockRecorder) Fatalw(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Fatalw", reflect.TypeOf((*MockLogger)(nil).Fatalw), varargs...) +} + +// Info mocks base method. +func (m *MockLogger) Info(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Info", varargs...) +} + +// Info indicates an expected call of Info. +func (mr *MockLoggerMockRecorder) Info(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Info", reflect.TypeOf((*MockLogger)(nil).Info), arg0...) +} + +// Infof mocks base method. +func (m *MockLogger) Infof(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infof", varargs...) +} + +// Infof indicates an expected call of Infof. +func (mr *MockLoggerMockRecorder) Infof(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infof", reflect.TypeOf((*MockLogger)(nil).Infof), varargs...) +} + +// Infow mocks base method. +func (m *MockLogger) Infow(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Infow", varargs...) +} + +// Infow indicates an expected call of Infow. +func (mr *MockLoggerMockRecorder) Infow(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Infow", reflect.TypeOf((*MockLogger)(nil).Infow), varargs...) +} + +// IsDebugLevel mocks base method. +func (m *MockLogger) IsDebugLevel() bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsDebugLevel") + ret0, _ := ret[0].(bool) + return ret0 +} + +// IsDebugLevel indicates an expected call of IsDebugLevel. +func (mr *MockLoggerMockRecorder) IsDebugLevel() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsDebugLevel", reflect.TypeOf((*MockLogger)(nil).IsDebugLevel)) +} + +// LogRequest mocks base method. +func (m *MockLogger) LogRequest(arg0 *http.Request) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "LogRequest", arg0) +} + +// LogRequest indicates an expected call of LogRequest. +func (mr *MockLoggerMockRecorder) LogRequest(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LogRequest", reflect.TypeOf((*MockLogger)(nil).LogRequest), arg0) +} + +// Warn mocks base method. +func (m *MockLogger) Warn(arg0 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warn", varargs...) +} + +// Warn indicates an expected call of Warn. +func (mr *MockLoggerMockRecorder) Warn(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warn", reflect.TypeOf((*MockLogger)(nil).Warn), arg0...) +} + +// Warnf mocks base method. +func (m *MockLogger) Warnf(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnf", varargs...) +} + +// Warnf indicates an expected call of Warnf. +func (mr *MockLoggerMockRecorder) Warnf(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnf", reflect.TypeOf((*MockLogger)(nil).Warnf), varargs...) +} + +// Warnw mocks base method. +func (m *MockLogger) Warnw(arg0 string, arg1 ...interface{}) { + m.ctrl.T.Helper() + varargs := []interface{}{arg0} + for _, a := range arg1 { + varargs = append(varargs, a) + } + m.ctrl.Call(m, "Warnw", varargs...) +} + +// Warnw indicates an expected call of Warnw. +func (mr *MockLoggerMockRecorder) Warnw(arg0 interface{}, arg1 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{arg0}, arg1...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Warnw", reflect.TypeOf((*MockLogger)(nil).Warnw), varargs...) +} + +// With mocks base method. +func (m *MockLogger) With(arg0 ...interface{}) logger.Logger { + m.ctrl.T.Helper() + varargs := []interface{}{} + for _, a := range arg0 { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "With", varargs...) + ret0, _ := ret[0].(logger.Logger) + return ret0 +} + +// With indicates an expected call of With. +func (mr *MockLoggerMockRecorder) With(arg0 ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "With", reflect.TypeOf((*MockLogger)(nil).With), arg0...) +} diff --git a/logger/nop.go b/logger/nop.go new file mode 100644 index 00000000..dc175123 --- /dev/null +++ b/logger/nop.go @@ -0,0 +1,27 @@ +package logger + +import "net/http" + +var NOP Logger = nop{} + +type nop struct{} + +func (nop) Debug(_ ...interface{}) {} +func (nop) Info(_ ...interface{}) {} +func (nop) Warn(_ ...interface{}) {} +func (nop) Error(_ ...interface{}) {} +func (nop) Fatal(_ ...interface{}) {} +func (nop) Debugf(_ string, _ ...interface{}) {} +func (nop) Infof(_ string, _ ...interface{}) {} +func (nop) Warnf(_ string, _ ...interface{}) {} +func (nop) Errorf(_ string, _ ...interface{}) {} +func (nop) Fatalf(_ string, _ ...interface{}) {} +func (nop) Debugw(_ string, _ ...interface{}) {} +func (nop) Infow(_ string, _ ...interface{}) {} +func (nop) Warnw(_ string, _ ...interface{}) {} +func (nop) Errorw(_ string, _ ...interface{}) {} +func (nop) Fatalw(_ string, _ ...interface{}) {} +func (nop) LogRequest(_ *http.Request) {} +func (nop) With(_ ...interface{}) Logger { return NOP } +func (nop) Child(_ string) Logger { return NOP } +func (nop) IsDebugLevel() bool { return false } diff --git a/logger/options.go b/logger/options.go new file mode 100644 index 00000000..f6e53bf9 --- /dev/null +++ b/logger/options.go @@ -0,0 +1,23 @@ +package logger + +import "go.uber.org/zap/zapcore" + +// An Option configures a Factory. +type Option interface { + apply(*Factory) +} + +// optionFunc wraps a func so it satisfies the Option interface. +type optionFunc func(*Factory) + +func (f optionFunc) apply(factory *Factory) { + f(factory) +} + +// WithClock specifies the clock used by the logger to determine the current +// time for logged entries. Defaults to the system clock with time.Now. +func WithClock(clock zapcore.Clock) Option { + return optionFunc(func(factory *Factory) { + factory.config.clock = clock + }) +} diff --git a/stats/internal/otel/options.go b/stats/internal/otel/options.go new file mode 100644 index 00000000..532ba94b --- /dev/null +++ b/stats/internal/otel/options.go @@ -0,0 +1,113 @@ +package otel + +import ( + "time" + + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/instrumentation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/aggregation" +) + +type ( + // Option allows to configure the OpenTelemetry initialization + Option func(*config) + // TracerProviderOption allows to configure the tracer provider + TracerProviderOption func(providerConfig *tracerProviderConfig) + // MeterProviderOption allows to configure the meter provider + MeterProviderOption func(providerConfig *meterProviderConfig) +) + +// WithRetryConfig allows to set the retry configuration +func WithRetryConfig(rc RetryConfig) Option { + return func(c *config) { + c.retryConfig = &rc + } +} + +// WithInsecure allows to set the GRPC connection to be insecure +func WithInsecure() Option { + return func(c *config) { + // Note the use of insecure transport here. TLS is recommended in production. + c.withInsecure = true + } +} + +// WithTextMapPropagator allows to set the text map propagator +// e.g. propagation.TraceContext{} +func WithTextMapPropagator(tmp propagation.TextMapPropagator) Option { + return func(c *config) { + c.textMapPropagator = tmp + } +} + +// WithTracerProvider allows to set the tracer provider and specify if it should be the global one +// It is also possible to configure the sampling rate: +// samplingRate >= 1 will always sample. +// samplingRate < 0 is treated as zero. +func WithTracerProvider(endpoint string, samplingRate float64, opts ...TracerProviderOption) Option { + return func(c *config) { + c.tracesEndpoint = endpoint + c.tracerProviderConfig.enabled = true + c.tracerProviderConfig.samplingRate = samplingRate + for _, opt := range opts { + opt(&c.tracerProviderConfig) + } + } +} + +// WithGlobalTracerProvider allows to set the tracer provider as the global one +func WithGlobalTracerProvider() TracerProviderOption { + return func(c *tracerProviderConfig) { + c.global = true + } +} + +// WithMeterProvider allows to set the meter provider and specify if it should be the global one plus other options. +func WithMeterProvider(endpoint string, opts ...MeterProviderOption) Option { + return func(c *config) { + c.metricsEndpoint = endpoint + c.meterProviderConfig.enabled = true + for _, opt := range opts { + opt(&c.meterProviderConfig) + } + } +} + +// WithGlobalMeterProvider allows to set the meter provider as the global one +func WithGlobalMeterProvider() MeterProviderOption { + return func(c *meterProviderConfig) { + c.global = true + } +} + +// WithMeterProviderExportsInterval configures the intervening time between exports (if less than or equal to zero, +// 60 seconds is used) +func WithMeterProviderExportsInterval(interval time.Duration) MeterProviderOption { + return func(c *meterProviderConfig) { + c.exportsInterval = interval + } +} + +// WithHistogramBucketBoundaries allows the creation of a view to overwrite the default buckets of a given histogram. +// meterName is optional. +func WithHistogramBucketBoundaries(instrumentName, meterName string, boundaries []float64) MeterProviderOption { + var scope instrumentation.Scope + if meterName != "" { + scope.Name = meterName + } + newView := sdkmetric.NewView( + sdkmetric.Instrument{ + Name: instrumentName, + Scope: scope, + }, + sdkmetric.Stream{ + Aggregation: aggregation.ExplicitBucketHistogram{ + Boundaries: boundaries, + }, + }, + ) + return func(c *meterProviderConfig) { + c.views = append(c.views, newView) + } +} diff --git a/stats/internal/otel/otel.go b/stats/internal/otel/otel.go new file mode 100644 index 00000000..a1e4a3da --- /dev/null +++ b/stats/internal/otel/otel.go @@ -0,0 +1,209 @@ +package otel + +import ( + "context" + "fmt" + "time" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc" + "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" + "go.opentelemetry.io/otel/metric/global" + "go.opentelemetry.io/otel/propagation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.12.0" + "golang.org/x/sync/errgroup" +) + +// DefaultRetryConfig represents the default retry configuration +var DefaultRetryConfig = RetryConfig{ + Enabled: true, + InitialInterval: 5 * time.Second, + MaxInterval: 30 * time.Second, + MaxElapsedTime: time.Minute, +} + +type Manager struct { + tp *sdktrace.TracerProvider + mp *sdkmetric.MeterProvider +} + +// Setup simplifies the creation of tracer and meter providers with GRPC +func (m *Manager) Setup( + ctx context.Context, res *resource.Resource, opts ...Option, +) ( + *sdktrace.TracerProvider, + *sdkmetric.MeterProvider, + error, +) { + var c config + for _, opt := range opts { + opt(&c) + } + if c.retryConfig == nil { + c.retryConfig = &DefaultRetryConfig + } + + if !c.tracerProviderConfig.enabled && !c.meterProviderConfig.enabled { + return nil, nil, fmt.Errorf("no trace provider or meter provider to initialize") + } + + if c.tracerProviderConfig.enabled { + tracerProviderOptions := []otlptracegrpc.Option{ + otlptracegrpc.WithEndpoint(c.tracesEndpoint), + otlptracegrpc.WithRetry(otlptracegrpc.RetryConfig{ + Enabled: c.retryConfig.Enabled, + InitialInterval: c.retryConfig.InitialInterval, + MaxInterval: c.retryConfig.MaxInterval, + MaxElapsedTime: c.retryConfig.MaxElapsedTime, + }), + } + if c.withInsecure { + tracerProviderOptions = append(tracerProviderOptions, otlptracegrpc.WithInsecure()) + } + traceExporter, err := otlptracegrpc.New(ctx, tracerProviderOptions...) + if err != nil { + return nil, nil, fmt.Errorf("failed to create trace exporter: %w", err) + } + + m.tp = sdktrace.NewTracerProvider( + sdktrace.WithSampler(sdktrace.TraceIDRatioBased(c.tracerProviderConfig.samplingRate)), + sdktrace.WithResource(res), + sdktrace.WithSpanProcessor(sdktrace.NewBatchSpanProcessor(traceExporter)), + ) + + if c.tracerProviderConfig.global { + otel.SetTracerProvider(m.tp) + } + } + + if c.meterProviderConfig.enabled { + meterProviderOptions := []otlpmetricgrpc.Option{ + otlpmetricgrpc.WithEndpoint(c.metricsEndpoint), + otlpmetricgrpc.WithRetry(otlpmetricgrpc.RetryConfig{ + Enabled: c.retryConfig.Enabled, + InitialInterval: c.retryConfig.InitialInterval, + MaxInterval: c.retryConfig.MaxInterval, + MaxElapsedTime: c.retryConfig.MaxElapsedTime, + }), + } + if c.withInsecure { + meterProviderOptions = append(meterProviderOptions, otlpmetricgrpc.WithInsecure()) + } + exp, err := otlpmetricgrpc.New(ctx, meterProviderOptions...) + if err != nil { + return nil, nil, fmt.Errorf("failed to create metric exporter: %w", err) + } + + m.mp = sdkmetric.NewMeterProvider( + sdkmetric.WithResource(res), + sdkmetric.WithReader(sdkmetric.NewPeriodicReader( + exp, + sdkmetric.WithInterval(c.meterProviderConfig.exportsInterval), + )), + sdkmetric.WithView(c.meterProviderConfig.views...), + ) + + if c.meterProviderConfig.global { + global.SetMeterProvider(m.mp) + } + } + + if c.textMapPropagator != nil { + otel.SetTextMapPropagator(c.textMapPropagator) + } + + return m.tp, m.mp, nil +} + +// Shutdown allows you to gracefully clean up after the OTel manager (e.g. close underlying gRPC connection) +func (m *Manager) Shutdown(ctx context.Context) error { + var g errgroup.Group + if m.tp != nil { + g.Go(func() error { + return m.tp.Shutdown(ctx) + }) + } + if m.mp != nil { + g.Go(func() error { + return m.mp.Shutdown(ctx) + }) + } + + done := make(chan error) + go func() { + done <- g.Wait() + close(done) + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + return err + } +} + +// NewResource allows the creation of an OpenTelemetry resource +// https://opentelemetry.io/docs/concepts/glossary/#resource +func NewResource(svcName, instanceID, svcVersion string, attrs ...attribute.KeyValue) (*resource.Resource, error) { + defaultAttrs := []attribute.KeyValue{ + semconv.ServiceNameKey.String(svcName), + semconv.ServiceVersionKey.String(svcVersion), + semconv.ServiceInstanceIDKey.String(instanceID), + } + return resource.Merge( + resource.Default(), + resource.NewWithAttributes(semconv.SchemaURL, append(defaultAttrs, attrs...)...), + ) +} + +// RetryConfig defines configuration for retrying batches in case of export failure +// using an exponential backoff. +type RetryConfig struct { + // Enabled indicates whether to not retry sending batches in case of + // export failure. + Enabled bool + // InitialInterval the time to wait after the first failure before + // retrying. + InitialInterval time.Duration + // MaxInterval is the upper bound on backoff interval. Once this value is + // reached the delay between consecutive retries will always be + // `MaxInterval`. + MaxInterval time.Duration + // MaxElapsedTime is the maximum amount of time (including retries) spent + // trying to send a request/batch. Once this value is reached, the data + // is discarded. + MaxElapsedTime time.Duration +} + +type config struct { + retryConfig *RetryConfig + withInsecure bool + + *sdktrace.TracerProvider + *sdkmetric.MeterProvider + + tracesEndpoint string + tracerProviderConfig tracerProviderConfig + metricsEndpoint string + meterProviderConfig meterProviderConfig + + textMapPropagator propagation.TextMapPropagator +} + +type tracerProviderConfig struct { + enabled bool + global bool + samplingRate float64 +} + +type meterProviderConfig struct { + enabled bool + global bool + exportsInterval time.Duration + views []sdkmetric.View +} diff --git a/stats/internal/otel/otel_test.go b/stats/internal/otel/otel_test.go new file mode 100644 index 00000000..ce9e52f1 --- /dev/null +++ b/stats/internal/otel/otel_test.go @@ -0,0 +1,277 @@ +package otel + +import ( + "context" + "fmt" + "math" + "net/http" + "os" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + promClient "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/global" + + "github.com/rudderlabs/rudder-go-kit/httputil" + statsTest "github.com/rudderlabs/rudder-go-kit/stats/testhelper" + "github.com/rudderlabs/rudder-go-kit/testhelper" + dt "github.com/rudderlabs/rudder-go-kit/testhelper/docker" +) + +const ( + metricsPort = "8889" +) + +// see https://opentelemetry.io/docs/collector/getting-started/ +func TestCollector(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + + container, grpcEndpoint := statsTest.StartOTelCollector(t, metricsPort, + filepath.Join(cwd, "testdata", "otel-collector-config.yaml"), + ) + + ctx := context.Background() + res, err := NewResource(t.Name(), "my-instance-id", "1.0.0") + require.NoError(t, err) + var om Manager + tp, mp, err := om.Setup(ctx, res, + WithInsecure(), + WithTracerProvider(grpcEndpoint, 1.0), + WithMeterProvider(grpcEndpoint, + WithMeterProviderExportsInterval(100*time.Millisecond), + WithHistogramBucketBoundaries("baz", "some-test", []float64{10, 20, 30}), + ), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, om.Shutdown(context.Background())) }) + require.NotEqual(t, tp, otel.GetTracerProvider()) + require.NotEqual(t, mp, global.MeterProvider()) + + m := mp.Meter("some-test") + // foo counter + counter, err := m.SyncInt64().Counter("foo") + require.NoError(t, err) + counter.Add(ctx, 1, attribute.String("hello", "world")) + // bar counter + counter, err = m.SyncInt64().Counter("bar") + require.NoError(t, err) + counter.Add(ctx, 5) + // baz histogram + h, err := m.SyncInt64().Histogram("baz") + require.NoError(t, err) + h.Record(ctx, 20, attribute.String("a", "b")) + + var ( + resp *http.Response + metrics map[string]*promClient.MetricFamily + metricsEndpoint = fmt.Sprintf("http://localhost:%d/metrics", dt.GetHostPort(t, metricsPort, container)) + ) + require.Eventuallyf(t, func() bool { + resp, err = http.Get(metricsEndpoint) + if err != nil { + return false + } + defer func() { httputil.CloseResponse(resp) }() + metrics, err = statsTest.ParsePrometheusMetrics(resp.Body) + if err != nil { + return false + } + if _, ok := metrics["foo"]; !ok { + return false + } + if _, ok := metrics["bar"]; !ok { + return false + } + if _, ok := metrics["baz"]; !ok { + return false + } + return true + }, 5*time.Second, 100*time.Millisecond, "err: %v, metrics: %+v", err, metrics) + + require.EqualValues(t, ptr("foo"), metrics["foo"].Name) + require.EqualValues(t, ptr(promClient.MetricType_COUNTER), metrics["foo"].Type) + require.Len(t, metrics["foo"].Metric, 1) + require.EqualValues(t, &promClient.Counter{Value: ptr(1.0)}, metrics["foo"].Metric[0].Counter) + require.ElementsMatch(t, []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("hello"), Value: ptr("world")}, + {Name: ptr("job"), Value: ptr("TestCollector")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + }, metrics["foo"].Metric[0].Label) + + require.EqualValues(t, ptr("bar"), metrics["bar"].Name) + require.EqualValues(t, ptr(promClient.MetricType_COUNTER), metrics["bar"].Type) + require.Len(t, metrics["bar"].Metric, 1) + require.EqualValues(t, &promClient.Counter{Value: ptr(5.0)}, metrics["bar"].Metric[0].Counter) + require.ElementsMatch(t, []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("job"), Value: ptr("TestCollector")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + }, metrics["bar"].Metric[0].Label) + + require.EqualValues(t, ptr("baz"), metrics["baz"].Name) + require.EqualValues(t, ptr(promClient.MetricType_HISTOGRAM), metrics["baz"].Type) + require.Len(t, metrics["baz"].Metric, 1) + require.EqualValues(t, ptr(uint64(1)), metrics["baz"].Metric[0].Histogram.SampleCount) + require.EqualValues(t, ptr(20.0), metrics["baz"].Metric[0].Histogram.SampleSum) + require.ElementsMatch(t, []*promClient.Bucket{ + {CumulativeCount: ptr(uint64(0)), UpperBound: ptr(10.0)}, + {CumulativeCount: ptr(uint64(1)), UpperBound: ptr(20.0)}, + {CumulativeCount: ptr(uint64(1)), UpperBound: ptr(30.0)}, + {CumulativeCount: ptr(uint64(1)), UpperBound: ptr(math.Inf(0))}, + }, metrics["baz"].Metric[0].Histogram.Bucket) + require.ElementsMatch(t, []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("a"), Value: ptr("b")}, + {Name: ptr("job"), Value: ptr("TestCollector")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + }, metrics["baz"].Metric[0].Label) +} + +func TestCollectorGlobals(t *testing.T) { + grpcPort, err := testhelper.GetFreePort() + require.NoError(t, err) + + pool, err := dockertest.NewPool("") + require.NoError(t, err) + + collector, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "otel/opentelemetry-collector", + Tag: "0.67.0", + PortBindings: map[docker.Port][]docker.PortBinding{ + "4317/tcp": {{HostPort: strconv.Itoa(grpcPort)}}, + }, + }) + require.NoError(t, err) + t.Cleanup(func() { + if err := pool.Purge(collector); err != nil { + t.Logf("Could not purge resource: %v", err) + } + }) + + var ( + om Manager + ctx = context.Background() + endpoint = fmt.Sprintf("localhost:%d", grpcPort) + ) + res, err := NewResource(t.Name(), "my-instance-id", "1.0.0") + require.NoError(t, err) + tp, mp, err := om.Setup(ctx, res, + WithInsecure(), + WithTracerProvider(endpoint, 1.0, WithGlobalTracerProvider()), + WithMeterProvider(endpoint, WithGlobalMeterProvider()), + ) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, om.Shutdown(context.Background())) }) + require.Equal(t, tp, otel.GetTracerProvider()) + require.Equal(t, mp, global.MeterProvider()) +} + +func TestNonBlockingConnection(t *testing.T) { + grpcPort, err := testhelper.GetFreePort() + require.NoError(t, err) + + res, err := NewResource(t.Name(), "my-instance-id", "1.0.0") + require.NoError(t, err) + + var ( + om Manager + ctx = context.Background() + endpoint = fmt.Sprintf("localhost:%d", grpcPort) + ) + _, mp, err := om.Setup(ctx, res, + WithInsecure(), + WithMeterProvider(endpoint, WithMeterProviderExportsInterval(100*time.Millisecond)), + WithRetryConfig(RetryConfig{ + Enabled: true, + InitialInterval: time.Second, + MaxInterval: time.Second, + MaxElapsedTime: time.Minute, + }), + ) + require.NoError(t, err) + defer func() { + require.NoError(t, om.Shutdown(context.Background())) + }() + + meter := mp.Meter("test") + fooCounter, err := meter.SyncInt64().Counter("foo") + require.NoError(t, err) + barCounter, err := meter.SyncFloat64().Counter("bar") + require.NoError(t, err) + + // this counter will not be lost even though the container isn't even started. see MaxElapsedTime. + fooCounter.Add(ctx, 123, attribute.String("hello", "world")) + + cwd, err := os.Getwd() + require.NoError(t, err) + + container, _ := statsTest.StartOTelCollector(t, metricsPort, + filepath.Join(cwd, "testdata", "otel-collector-config.yaml"), + statsTest.WithStartCollectorPort(grpcPort), + ) + barCounter.Add(ctx, 456) // this should be recorded + + var ( + resp *http.Response + metrics map[string]*promClient.MetricFamily + metricsEndpoint = fmt.Sprintf("http://localhost:%d/metrics", dt.GetHostPort(t, metricsPort, container)) + ) + + require.Eventuallyf(t, func() bool { + resp, err = http.Get(metricsEndpoint) + if err != nil { + return false + } + defer func() { httputil.CloseResponse(resp) }() + metrics, err = statsTest.ParsePrometheusMetrics(resp.Body) + if err != nil { + return false + } + if _, ok := metrics["foo"]; !ok { + return false + } + if _, ok := metrics["bar"]; !ok { + return false + } + return true + }, 10*time.Second, 100*time.Millisecond, "err: %v, metrics: %+v", err, metrics) + + require.EqualValues(t, ptr("foo"), metrics["foo"].Name) + require.EqualValues(t, ptr(promClient.MetricType_COUNTER), metrics["foo"].Type) + require.Len(t, metrics["foo"].Metric, 1) + require.EqualValues(t, &promClient.Counter{Value: ptr(123.0)}, metrics["foo"].Metric[0].Counter) + require.ElementsMatch(t, []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("hello"), Value: ptr("world")}, + {Name: ptr("job"), Value: ptr("TestNonBlockingConnection")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + }, metrics["foo"].Metric[0].Label) + + require.EqualValues(t, ptr("bar"), metrics["bar"].Name) + require.EqualValues(t, ptr(promClient.MetricType_COUNTER), metrics["bar"].Type) + require.Len(t, metrics["bar"].Metric, 1) + require.EqualValues(t, &promClient.Counter{Value: ptr(456.0)}, metrics["bar"].Metric[0].Counter) + require.ElementsMatch(t, []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("job"), Value: ptr("TestNonBlockingConnection")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + }, metrics["bar"].Metric[0].Label) +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/stats/internal/otel/testdata/otel-collector-config.yaml b/stats/internal/otel/testdata/otel-collector-config.yaml new file mode 100644 index 00000000..9bad23b5 --- /dev/null +++ b/stats/internal/otel/testdata/otel-collector-config.yaml @@ -0,0 +1,24 @@ +receivers: + otlp: + protocols: + grpc: + +exporters: + prometheus: + endpoint: "0.0.0.0:8889" + const_labels: + label1: value1 + +processors: + batch: + +extensions: + health_check: + +service: + extensions: [health_check] + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] diff --git a/stats/measurement.go b/stats/measurement.go new file mode 100644 index 00000000..c855c020 --- /dev/null +++ b/stats/measurement.go @@ -0,0 +1,86 @@ +package stats + +import ( + "fmt" + "time" +) + +// Counter represents a counter metric +type Counter interface { + Count(n int) + Increment() +} + +// Gauge represents a gauge metric +type Gauge interface { + Gauge(value interface{}) +} + +// Histogram represents a histogram metric +type Histogram interface { + Observe(value float64) +} + +// Timer represents a timer metric +type Timer interface { + SendTiming(duration time.Duration) + Since(start time.Time) + RecordDuration() func() +} + +// Measurement provides all stat measurement functions +// TODO: the API should not return a union of measurement methods, but rather a distinct type for each measurement type +type Measurement interface { + Counter + Gauge + Histogram + Timer +} + +type genericMeasurement struct { + statType string +} + +// Count default behavior is to panic as not supported operation +func (m *genericMeasurement) Count(_ int) { + panic(fmt.Errorf("operation Count not supported for measurement type:%s", m.statType)) +} + +// Increment default behavior is to panic as not supported operation +func (m *genericMeasurement) Increment() { + panic(fmt.Errorf("operation Increment not supported for measurement type:%s", m.statType)) +} + +// Gauge default behavior is to panic as not supported operation +func (m *genericMeasurement) Gauge(_ interface{}) { + panic(fmt.Errorf("operation Gauge not supported for measurement type:%s", m.statType)) +} + +// Observe default behavior is to panic as not supported operation +func (m *genericMeasurement) Observe(_ float64) { + panic(fmt.Errorf("operation Observe not supported for measurement type:%s", m.statType)) +} + +// Start default behavior is to panic as not supported operation +func (m *genericMeasurement) Start() { + panic(fmt.Errorf("operation Start not supported for measurement type:%s", m.statType)) +} + +func (m *genericMeasurement) End() { + panic(fmt.Errorf("operation End not supported for measurement type:%s", m.statType)) +} + +// SendTiming default behavior is to panic as not supported operation +func (m *genericMeasurement) SendTiming(_ time.Duration) { + panic(fmt.Errorf("operation SendTiming not supported for measurement type:%s", m.statType)) +} + +// Since default behavior is to panic as not supported operation +func (m *genericMeasurement) Since(_ time.Time) { + panic(fmt.Errorf("operation Since not supported for measurement type:%s", m.statType)) +} + +// RecordDuration default behavior is to panic as not supported operation +func (m *genericMeasurement) RecordDuration() func() { + panic(fmt.Errorf("operation RecordDuration not supported for measurement type:%s", m.statType)) +} diff --git a/stats/memstats/stats.go b/stats/memstats/stats.go new file mode 100644 index 00000000..74313879 --- /dev/null +++ b/stats/memstats/stats.go @@ -0,0 +1,223 @@ +package memstats + +import ( + "context" + "sync" + "time" + + "github.com/spf13/cast" + + "github.com/rudderlabs/rudder-go-kit/stats" +) + +var _ stats.Stats = (*Store)(nil) + +var _ stats.Measurement = (*Measurement)(nil) + +type Store struct { + mu sync.Mutex + byKey map[string]*Measurement + now func() time.Time +} + +type Measurement struct { + mu sync.Mutex + now func() time.Time + + tags stats.Tags + name string + mType string + + sum float64 + values []float64 + durations []time.Duration +} + +func (m *Measurement) LastValue() float64 { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.values) == 0 { + return 0 + } + + return m.values[len(m.values)-1] +} + +func (m *Measurement) Values() []float64 { + m.mu.Lock() + defer m.mu.Unlock() + + s := make([]float64, len(m.values)) + copy(s, m.values) + + return s +} + +func (m *Measurement) LastDuration() time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + + if len(m.durations) == 0 { + return 0 + } + + return m.durations[len(m.durations)-1] +} + +func (m *Measurement) Durations() []time.Duration { + m.mu.Lock() + defer m.mu.Unlock() + + s := make([]time.Duration, len(m.durations)) + copy(s, m.durations) + + return s +} + +// Count implements stats.Measurement +func (m *Measurement) Count(n int) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.mType != stats.CountType { + panic("operation Count not supported for measurement type:" + m.mType) + } + + m.sum += float64(n) + m.values = append(m.values, m.sum) +} + +// Increment implements stats.Measurement +func (m *Measurement) Increment() { + if m.mType != stats.CountType { + panic("operation Increment not supported for measurement type:" + m.mType) + } + + m.Count(1) +} + +// Gauge implements stats.Measurement +func (m *Measurement) Gauge(value interface{}) { + if m.mType != stats.GaugeType { + panic("operation Gauge not supported for measurement type:" + m.mType) + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.values = append(m.values, cast.ToFloat64(value)) +} + +// Observe implements stats.Measurement +func (m *Measurement) Observe(value float64) { + if m.mType != stats.HistogramType { + panic("operation Observe not supported for measurement type:" + m.mType) + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.values = append(m.values, value) +} + +// Since implements stats.Measurement +func (m *Measurement) Since(start time.Time) { + if m.mType != stats.TimerType { + panic("operation Since not supported for measurement type:" + m.mType) + } + + m.SendTiming(m.now().Sub(start)) +} + +// SendTiming implements stats.Measurement +func (m *Measurement) SendTiming(duration time.Duration) { + if m.mType != stats.TimerType { + panic("operation SendTiming not supported for measurement type:" + m.mType) + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.durations = append(m.durations, duration) +} + +// RecordDuration implements stats.Measurement +func (m *Measurement) RecordDuration() func() { + if m.mType != stats.TimerType { + panic("operation RecordDuration not supported for measurement type:" + m.mType) + } + + start := m.now() + return func() { + m.Since(start) + } +} + +type Opts func(*Store) + +func WithNow(nowFn func() time.Time) Opts { + return func(s *Store) { + s.now = nowFn + } +} + +func New(opts ...Opts) *Store { + s := &Store{ + byKey: make(map[string]*Measurement), + now: time.Now, + } + + for _, opt := range opts { + opt(s) + } + + return s +} + +// NewStat implements stats.Stats +func (ms *Store) NewStat(name, statType string) (m stats.Measurement) { + return ms.NewTaggedStat(name, statType, nil) +} + +// NewTaggedStat implements stats.Stats +func (ms *Store) NewTaggedStat(name, statType string, tags stats.Tags) stats.Measurement { + return ms.NewSampledTaggedStat(name, statType, tags) +} + +// NewSampledTaggedStat implements stats.Stats +func (ms *Store) NewSampledTaggedStat(name, statType string, tags stats.Tags) stats.Measurement { + ms.mu.Lock() + defer ms.mu.Unlock() + + m := &Measurement{ + name: name, + tags: tags, + mType: statType, + + now: ms.now, + } + + ms.byKey[ms.getKey(name, tags)] = m + return m +} + +// Get the stored measurement with the name and tags. +// If no measurement is found, nil is returned. +func (ms *Store) Get(name string, tags stats.Tags) *Measurement { + ms.mu.Lock() + defer ms.mu.Unlock() + + return ms.byKey[ms.getKey(name, tags)] +} + +// Start implements stats.Stats +func (*Store) Start(_ context.Context, _ stats.GoRoutineFactory) error { return nil } + +// Stop implements stats.Stats +func (*Store) Stop() {} + +// getKey maps name and tags, to a store lookup key. +func (*Store) getKey(name string, tags stats.Tags) string { + return name + tags.String() +} diff --git a/stats/memstats/stats_test.go b/stats/memstats/stats_test.go new file mode 100644 index 00000000..5cfa1c07 --- /dev/null +++ b/stats/memstats/stats_test.go @@ -0,0 +1,142 @@ +package memstats_test + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/rudderlabs/rudder-go-kit/stats" + "github.com/rudderlabs/rudder-go-kit/stats/memstats" +) + +func TestStats(t *testing.T) { + now := time.Now() + + store := memstats.New( + memstats.WithNow(func() time.Time { + return now + }), + ) + + commonTags := stats.Tags{"tag1": "value1"} + + t.Run("test Count", func(t *testing.T) { + name := "testCount" + + m := store.NewTaggedStat(name, stats.CountType, commonTags) + + m.Increment() + + require.Equal(t, 1.0, store.Get(name, commonTags).LastValue()) + require.Equal(t, []float64{1.0}, store.Get(name, commonTags).Values()) + + m.Count(2) + + require.Equal(t, 3.0, store.Get(name, commonTags).LastValue()) + require.Equal(t, []float64{1.0, 3.0}, store.Get(name, commonTags).Values()) + }) + + t.Run("test Gauge", func(t *testing.T) { + name := "testGauge" + m := store.NewTaggedStat(name, stats.GaugeType, commonTags) + + m.Gauge(1.0) + + require.Equal(t, 1.0, store.Get(name, commonTags).LastValue()) + require.Equal(t, []float64{1.0}, store.Get(name, commonTags).Values()) + + m.Gauge(2.0) + + require.Equal(t, 2.0, store.Get(name, commonTags).LastValue()) + require.Equal(t, []float64{1.0, 2.0}, store.Get(name, commonTags).Values()) + }) + + t.Run("test Histogram", func(t *testing.T) { + name := "testHistogram" + m := store.NewTaggedStat(name, stats.HistogramType, commonTags) + + m.Observe(1.0) + + require.Equal(t, 1.0, store.Get(name, commonTags).LastValue()) + require.Equal(t, []float64{1.0}, store.Get(name, commonTags).Values()) + + m.Observe(2.0) + + require.Equal(t, 2.0, store.Get(name, commonTags).LastValue()) + require.Equal(t, []float64{1.0, 2.0}, store.Get(name, commonTags).Values()) + }) + + t.Run("test Timer", func(t *testing.T) { + name := "testTimer" + + m := store.NewTaggedStat(name, stats.TimerType, commonTags) + + m.SendTiming(time.Second) + require.Equal(t, time.Second, store.Get(name, commonTags).LastDuration()) + require.Equal(t, []time.Duration{time.Second}, store.Get(name, commonTags).Durations()) + + m.SendTiming(time.Minute) + require.Equal(t, time.Minute, store.Get(name, commonTags).LastDuration()) + require.Equal(t, + []time.Duration{time.Second, time.Minute}, + store.Get(name, commonTags).Durations(), + ) + + func() { + defer m.RecordDuration()() + now = now.Add(time.Second) + }() + require.Equal(t, time.Second, store.Get(name, commonTags).LastDuration()) + require.Equal(t, + []time.Duration{time.Second, time.Minute, time.Second}, + store.Get(name, commonTags).Durations(), + ) + + m.Since(now.Add(-time.Minute)) + require.Equal(t, time.Minute, store.Get(name, commonTags).LastDuration()) + require.Equal(t, + []time.Duration{time.Second, time.Minute, time.Second, time.Minute}, + store.Get(name, commonTags).Durations(), + ) + }) + + t.Run("invalid operations", func(t *testing.T) { + require.PanicsWithValue(t, "operation Count not supported for measurement type:gauge", func() { + store.NewTaggedStat("invalid_count", stats.GaugeType, commonTags).Count(1) + }) + require.PanicsWithValue(t, "operation Increment not supported for measurement type:gauge", func() { + store.NewTaggedStat("invalid_inc", stats.GaugeType, commonTags).Increment() + }) + require.PanicsWithValue(t, "operation Gauge not supported for measurement type:count", func() { + store.NewTaggedStat("invalid_gauge", stats.CountType, commonTags).Gauge(1) + }) + require.PanicsWithValue(t, "operation SendTiming not supported for measurement type:histogram", func() { + store.NewTaggedStat("invalid_send_timing", stats.HistogramType, commonTags).SendTiming(time.Second) + }) + require.PanicsWithValue(t, "operation RecordDuration not supported for measurement type:histogram", func() { + store.NewTaggedStat("invalid_record_duration", stats.HistogramType, commonTags).RecordDuration() + }) + require.PanicsWithValue(t, "operation Since not supported for measurement type:histogram", func() { + store.NewTaggedStat("invalid_since", stats.HistogramType, commonTags).Since(time.Now()) + }) + require.PanicsWithValue(t, "operation Observe not supported for measurement type:timer", func() { + store.NewTaggedStat("invalid_observe", stats.TimerType, commonTags).Observe(1) + }) + }) + + t.Run("no op", func(t *testing.T) { + require.NoError(t, store.Start(context.Background(), stats.DefaultGoRoutineFactory)) + store.Stop() + }) + + t.Run("no tags", func(t *testing.T) { + name := "no_tags" + m := store.NewStat(name, stats.CountType) + + m.Increment() + + require.Equal(t, 1.0, store.Get(name, nil).LastValue()) + }) +} diff --git a/stats/metric/counter.go b/stats/metric/counter.go new file mode 100644 index 00000000..4d0fdf9a --- /dev/null +++ b/stats/metric/counter.go @@ -0,0 +1,64 @@ +package metric + +import ( + "errors" + "math" + "sync/atomic" +) + +// Counter counts monotonically increasing values +type Counter interface { + // Inc increments the counter by 1. Use Add to increment it by arbitrary + // non-negative values. + Inc() + // Add adds the given value to the counter. It panics if the value is < + // 0. + Add(float64) + // Value gets the current value of the counter. + Value() float64 +} + +// NewCounter creates a new counter +func NewCounter() Counter { + result := &counter{} + return result +} + +type counter struct { + // valBits contains the bits of the represented float64 value, while + // valInt stores values that are exact integers. Both have to go first + // in the struct to guarantee alignment for atomic operations. + // http://golang.org/pkg/sync/atomic/#pkg-note-BUG + valBits uint64 + valInt uint64 +} + +func (c *counter) Add(v float64) { + if v < 0 { + panic(errors.New("counter cannot decrease in value")) + } + + ival := uint64(v) + if float64(ival) == v { + atomic.AddUint64(&c.valInt, ival) + return + } + + for { + oldBits := atomic.LoadUint64(&c.valBits) + newBits := math.Float64bits(math.Float64frombits(oldBits) + v) + if atomic.CompareAndSwapUint64(&c.valBits, oldBits, newBits) { + return + } + } +} + +func (c *counter) Inc() { + atomic.AddUint64(&c.valInt, 1) +} + +func (c *counter) Value() float64 { + fval := math.Float64frombits(atomic.LoadUint64(&c.valBits)) + ival := atomic.LoadUint64(&c.valInt) + return fval + float64(ival) +} diff --git a/stats/metric/counter_test.go b/stats/metric/counter_test.go new file mode 100644 index 00000000..7c5ba7da --- /dev/null +++ b/stats/metric/counter_test.go @@ -0,0 +1,55 @@ +package metric + +import ( + "fmt" + "sync" + "testing" +) + +func TestCounterAdd(t *testing.T) { + counter := NewCounter() + counter.Inc() + if expected, got := 1.0, counter.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + counter.Add(42) + if expected, got := 43.0, counter.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + counter.Add(24.42) + if expected, got := 67.42, counter.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + if expected, got := "counter cannot decrease in value", decreaseCounter(counter).Error(); expected != got { + t.Errorf("Expected error %q, got %q.", expected, got) + } +} + +func TestCounterAddConcurrently(t *testing.T) { + const concurrency = 1000 + counter := NewCounter() + var wg sync.WaitGroup + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func() { + counter.Inc() + wg.Done() + }() + } + wg.Wait() + if expected, got := float64(concurrency), counter.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } +} + +func decreaseCounter(c Counter) (err error) { + defer func() { + if e := recover(); e != nil { + err = fmt.Errorf("%v", e) + } + }() + c.Add(-1) + return nil +} diff --git a/stats/metric/ewma.go b/stats/metric/ewma.go new file mode 100644 index 00000000..cc2da598 --- /dev/null +++ b/stats/metric/ewma.go @@ -0,0 +1,138 @@ +package metric + +import "sync" + +const ( + // AVG_METRIC_AGE By default, we average over a one-minute period, which means the average + // age of the metrics in the period is 30 seconds. + AVG_METRIC_AGE float64 = 30.0 + + // DECAY The formula for computing the decay factor from the average age comes + // from "Production and Operations Analysis" by Steven Nahmias. + DECAY = 2 / (AVG_METRIC_AGE + 1) + + // WARMUP_SAMPLES For best results, the moving average should not be initialized to the + // samples it sees immediately. The book "Production and Operations + // Analysis" by Steven Nahmias suggests initializing the moving average to + // the mean of the first 10 samples. Until the VariableEwma has seen this + // many samples, it is not "ready" to be queried for the value of the + // moving average. This adds some memory cost. + WARMUP_SAMPLES uint8 = 10 +) + +var threadSafeMutex sync.RWMutex + +// MovingAverage is the interface that computes a moving average over a time- +// series stream of numbers. The average may be over a window or exponentially +// decaying. +type MovingAverage interface { + Add(float64) + Value() float64 + Set(float64) +} + +// NewMovingAverage constructs a MovingAverage that computes an average with the +// desired characteristics in the moving window or exponential decay. If no +// age is given, it constructs a default exponentially weighted implementation +// that consumes minimal memory. The age is related to the decay factor alpha +// by the formula given for the DECAY constant. It signifies the average age +// of the samples as time goes to infinity. +func NewMovingAverage(age ...float64) MovingAverage { + if len(age) == 0 { + return new(SimpleEWMA) + } + return &VariableEWMA{ + decay: 2 / (age[0] + 1), + } +} + +// A SimpleEWMA represents the exponentially weighted moving average of a +// series of numbers. It WILL have different behavior than the VariableEWMA +// for multiple reasons. It has no warm-up period and it uses a constant +// decay. These properties let it use less memory. It will also behave +// differently when it's equal to zero, which is assumed to mean +// uninitialized, so if a value is likely to actually become zero over time, +// then any non-zero value will cause a sharp jump instead of a small change. +// However, note that this takes a long time, and the value may just +// decays to a stable value that's close to zero, but which won't be mistaken +// for uninitialized. See http://play.golang.org/p/litxBDr_RC for example. +type SimpleEWMA struct { + // The current value of the average. After adding with Add(), this is + // updated to reflect the average of all values seen thus far. + value float64 +} + +// Add adds a value to the series and updates the moving average. +func (e *SimpleEWMA) Add(value float64) { + threadSafeMutex.Lock() + defer threadSafeMutex.Unlock() + if e.value == 0 { // this is a proxy for "uninitialized" + e.value = value + } else { + e.value = (value * DECAY) + (e.value * (1 - DECAY)) + } +} + +// Value returns the current value of the moving average. +func (e *SimpleEWMA) Value() float64 { + threadSafeMutex.RLock() + defer threadSafeMutex.RUnlock() + return e.value +} + +// Set sets the EWMA's value. +func (e *SimpleEWMA) Set(value float64) { + threadSafeMutex.Lock() + defer threadSafeMutex.Unlock() + e.value = value +} + +// VariableEWMA represents the exponentially weighted moving average of a series of +// numbers. Unlike SimpleEWMA, it supports a custom age, and thus uses more memory. +type VariableEWMA struct { + // The multiplier factor by which the previous samples decay. + decay float64 + // The current value of the average. + value float64 + // The number of samples added to this instance. + count uint8 +} + +// Add adds a value to the series and updates the moving average. +func (e *VariableEWMA) Add(value float64) { + threadSafeMutex.Lock() + defer threadSafeMutex.Unlock() + switch { + case e.count < WARMUP_SAMPLES: + e.count++ + e.value += value + case e.count == WARMUP_SAMPLES: + e.count++ + e.value = e.value / float64(WARMUP_SAMPLES) + e.value = (value * e.decay) + (e.value * (1 - e.decay)) + default: + e.value = (value * e.decay) + (e.value * (1 - e.decay)) + } +} + +// Value returns the current value of the average, or 0.0 if the series hasn't +// warmed up yet. +func (e *VariableEWMA) Value() float64 { + threadSafeMutex.RLock() + defer threadSafeMutex.RUnlock() + if e.count <= WARMUP_SAMPLES { + return 0.0 + } + + return e.value +} + +// Set sets the EWMA's value. +func (e *VariableEWMA) Set(value float64) { + threadSafeMutex.Lock() + defer threadSafeMutex.Unlock() + e.value = value + if e.count <= WARMUP_SAMPLES { + e.count = WARMUP_SAMPLES + 1 + } +} diff --git a/stats/metric/gauge.go b/stats/metric/gauge.go new file mode 100644 index 00000000..1f15c55a --- /dev/null +++ b/stats/metric/gauge.go @@ -0,0 +1,86 @@ +package metric + +import ( + "math" + "sync/atomic" + "time" +) + +// Gauge keeps track of increasing/decreasing or generally arbitrary values. +// You can even use a gauge to keep track of time, e.g. when was the last time +// an event happened! +type Gauge interface { + // Set sets the given value to the gauge. + Set(float64) + // Inc increments the gauge by 1. Use Add to increment it by arbitrary + // values. + Inc() + // Dec decrements the gauge by 1. Use Sub to decrement it by arbitrary + // values. + Dec() + // Add adds the given value to the counter. + Add(val float64) + // Sub subtracts the given value from the counter. + Sub(float64) + // SetToCurrentTime sets the current UNIX time as the gauge's value + SetToCurrentTime() + // Value gets the current value of the counter. + Value() float64 + // IntValue gets the current value of the counter as an int. + IntValue() int + // ValueAsTime gets the current value of the counter as time. + ValueAsTime() time.Time +} + +func NewGauge() Gauge { + result := &gauge{now: time.Now} + return result +} + +type gauge struct { + valBits uint64 + + now func() time.Time // To mock out time.Now() for testing. +} + +func (g *gauge) Set(val float64) { + atomic.StoreUint64(&g.valBits, math.Float64bits(val)) +} + +func (g *gauge) SetToCurrentTime() { + g.Set(float64(g.now().UnixNano()) / 1e9) +} + +func (g *gauge) Inc() { + g.Add(1) +} + +func (g *gauge) Dec() { + g.Add(-1) +} + +func (g *gauge) Add(val float64) { + for { + oldBits := atomic.LoadUint64(&g.valBits) + newBits := math.Float64bits(math.Float64frombits(oldBits) + val) + if atomic.CompareAndSwapUint64(&g.valBits, oldBits, newBits) { + return + } + } +} + +func (g *gauge) Sub(val float64) { + g.Add(val * -1) +} + +func (g *gauge) Value() float64 { + return math.Float64frombits(atomic.LoadUint64(&g.valBits)) +} + +func (g *gauge) IntValue() int { + return int(g.Value()) +} + +func (g *gauge) ValueAsTime() time.Time { + return time.Unix(0, int64(g.Value()*1e9)) +} diff --git a/stats/metric/gauge_test.go b/stats/metric/gauge_test.go new file mode 100644 index 00000000..5ce2d4c2 --- /dev/null +++ b/stats/metric/gauge_test.go @@ -0,0 +1,74 @@ +package metric + +import ( + "sync" + "testing" + "time" +) + +func TestGaugeAddSub(t *testing.T) { + gauge := NewGauge() + gauge.Inc() + if expected, got := 1.0, gauge.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + gauge.Add(42) + if expected, got := 43.0, gauge.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + gauge.Add(24.42) + if expected, got := 67.42, gauge.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + gauge.Dec() + if expected, got := 66.42, gauge.Value(); expected != got { + t.Errorf("Expected error %f, got %f.", expected, got) + } + + gauge.Sub(24.42) + if expected, got := 42.0, gauge.Value(); expected != got { + t.Errorf("Expected error %f, got %f.", expected, got) + } +} + +func TestGaugeSetGetTime(t *testing.T) { + gauge := NewGauge().(*gauge) + now := time.Now() + f := func() time.Time { return now } + gauge.now = f + + gauge.SetToCurrentTime() + if expected, got := now.Round(1*time.Millisecond), gauge.ValueAsTime().Round(1*time.Millisecond); expected != got { + t.Errorf("Expected error %s, got %s.", expected, got) + } +} + +func TestGaugeAddSubConcurrently(t *testing.T) { + const concurrency = 1000 + const addAmt = 10 + const subAmt = 2 + gauge := NewGauge() + var wg sync.WaitGroup + wg.Add(concurrency) + + for i := 0; i < concurrency/2; i++ { + go func() { + gauge.Add(addAmt) + gauge.Dec() // for every dec we do an equivalent sub below (*) + wg.Done() + }() + } + for i := 0; i < concurrency/2; i++ { + go func() { + gauge.Inc() + gauge.Sub(subAmt) // (*) + wg.Done() + }() + } + wg.Wait() + if expected, got := float64(addAmt*(concurrency/2)-subAmt*(concurrency/2)), gauge.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } +} diff --git a/stats/metric/manager.go b/stats/metric/manager.go new file mode 100644 index 00000000..3673b778 --- /dev/null +++ b/stats/metric/manager.go @@ -0,0 +1,40 @@ +/* +Package metric implements an abstraction for safely managing metrics in concurrent environments. +*/ +package metric + +const ( + PublishedMetrics string = "published_metrics" +) + +func NewManager() Manager { + return &manager{ + registries: map[string]Registry{ + PublishedMetrics: NewRegistry(), + }, + } +} + +var Instance Manager = NewManager() + +// Manager is the entry-point for retrieving metric registries +type Manager interface { + // GetRegistry gets a registry by its key + GetRegistry(key string) Registry + // Reset cleans all registries + Reset() +} + +type manager struct { + registries map[string]Registry +} + +func (r *manager) GetRegistry(key string) Registry { + return r.registries[key] +} + +func (r *manager) Reset() { + for key := range r.registries { + r.registries[key] = NewRegistry() + } +} diff --git a/stats/metric/measurement.go b/stats/metric/measurement.go new file mode 100644 index 00000000..bce18d21 --- /dev/null +++ b/stats/metric/measurement.go @@ -0,0 +1,9 @@ +package metric + +// Measurement acts as a key in a Registry. +type Measurement interface { + // GetName gets the name of the measurement + GetName() string + // GetTags gets the tags of the measurement + GetTags() map[string]string +} diff --git a/stats/metric/registry.go b/stats/metric/registry.go new file mode 100644 index 00000000..6ea89298 --- /dev/null +++ b/stats/metric/registry.go @@ -0,0 +1,249 @@ +package metric + +import ( + "fmt" + "sync" +) + +type ( + Tags map[string]string + TagsWithValue struct { + Tags Tags + Value interface{} + } +) + +// Registry is a safe way to capture metrics in a highly concurrent environment. +// The registry is responsible for creating and storing the various measurements and +// guarantees consistency when competing goroutines try to update the same measurement +// at the same time. +// +// E.g. +// assuming that you already have created a new registry +// +// registry := NewRegistry() +// +// the following is guaranteed to be executed atomically: +// +// registry.MustGetCounter("key").Inc() +type Registry interface { + // GetCounter gets a counter by key. If a value for this key + // already exists but corresponds to another measurement type, + // e.g. a Gauge, an error is returned + GetCounter(Measurement) (Counter, error) + + // MustGetCounter gets a counter by key. If a value for this key + // already exists but corresponds to another measurement type, + // e.g. a Gauge, it panics + MustGetCounter(Measurement) Counter + + // GetGauge gets a gauge by key. If a value for this key + // already exists but corresponds to another measurement + // type, e.g. a Counter, an error is returned + GetGauge(Measurement) (Gauge, error) + + // MustGetGauge gets a gauge by key. If a value for this key + // already exists but corresponds to another measurement type, + // e.g. a Counter, it panics + MustGetGauge(Measurement) Gauge + + // GetSimpleMovingAvg gets a moving average by key. If a value for this key + // already exists but corresponds to another measurement + // type, e.g. a Counter, an error is returned + GetSimpleMovingAvg(Measurement) (MovingAverage, error) + + // MustGetSimpleMovingAvg gets a moving average by key. If a value for this key + // already exists but corresponds to another measurement type, + // e.g. a Counter, it panics + MustGetSimpleMovingAvg(Measurement) MovingAverage + + // GetVarMovingAvg gets a moving average by key. If a value for this key + // already exists but corresponds to another measurement + // type, e.g. a Counter, an error is returned + GetVarMovingAvg(m Measurement, age float64) (MovingAverage, error) + + // MustGetVarMovingAvg gets a moving average by key. If a value for this key + // already exists but corresponds to another measurement type, + // e.g. a Counter, it panics + MustGetVarMovingAvg(m Measurement, age float64) MovingAverage + + // Range scans across all metrics + Range(f func(key, value interface{}) bool) + + // GetMetricsByName gets all metrics with this name + GetMetricsByName(name string) []TagsWithValue +} + +// mutexWithMap bundles a lock along with the map it is protecting +type mutexWithMap struct { + lock *sync.RWMutex + value map[Measurement]TagsWithValue +} + +func NewRegistry() Registry { + counterGenerator := func() interface{} { + return NewCounter() + } + gaugeGenerator := func() interface{} { + return NewGauge() + } + varEwmaGenerator := func() interface{} { + return &VariableEWMA{} + } + simpleEwmaGenerator := func() interface{} { + return &SimpleEWMA{} + } + indexGenerator := func() interface{} { + var lock sync.RWMutex + v := &mutexWithMap{&lock, map[Measurement]TagsWithValue{}} + return v + } + return ®istry{ + counters: sync.Pool{New: counterGenerator}, + gauges: sync.Pool{New: gaugeGenerator}, + simpleEwmas: sync.Pool{New: simpleEwmaGenerator}, + varEwmas: sync.Pool{New: varEwmaGenerator}, + sets: sync.Pool{New: indexGenerator}, + } +} + +type registry struct { + store sync.Map + nameIndex sync.Map + counters sync.Pool + gauges sync.Pool + simpleEwmas sync.Pool + varEwmas sync.Pool + sets sync.Pool +} + +func (r *registry) GetCounter(m Measurement) (Counter, error) { + res := r.get(m, &r.counters) + c, ok := res.(Counter) + if !ok { + return nil, fmt.Errorf("a different type of metric exists in the registry with the same key [%+v]: %T", m, res) + } + return c, nil +} + +func (r *registry) MustGetCounter(m Measurement) Counter { + c, err := r.GetCounter(m) + if err != nil { + panic(err) + } + return c +} + +func (r *registry) GetGauge(m Measurement) (Gauge, error) { + res := r.get(m, &r.gauges) + g, ok := res.(Gauge) + if !ok { + return nil, fmt.Errorf("a different type of metric exists in the registry with the same key [%+v]: %T", m, res) + } + return g, nil +} + +func (r *registry) MustGetGauge(m Measurement) Gauge { + c, err := r.GetGauge(m) + if err != nil { + panic(err) + } + return c +} + +func (r *registry) GetSimpleMovingAvg(m Measurement) (MovingAverage, error) { + res := r.get(m, &r.simpleEwmas) + ma, ok := res.(MovingAverage) + if !ok { + return nil, fmt.Errorf("a different type of metric exists in the registry with the same key [%+v]: %T", m, res) + } + return ma, nil +} + +func (r *registry) MustGetSimpleMovingAvg(m Measurement) MovingAverage { + ma, err := r.GetSimpleMovingAvg(m) + if err != nil { + panic(err) + } + return ma +} + +func (r *registry) GetVarMovingAvg(m Measurement, age float64) (MovingAverage, error) { + decay := 2 / (age + 1) + newEwma := r.varEwmas.Get() + newEwma.(*VariableEWMA).decay = decay + res, ok := r.store.Load(m) + if !ok { + res, ok = r.store.LoadOrStore(m, newEwma) + if ok { + r.varEwmas.Put(newEwma) + } else { + r.updateIndex(m, res) + } + } + ma, ok := res.(*VariableEWMA) + if !ok { + return nil, fmt.Errorf("a different type of metric exists in the registry with the same key [%+v]: %T", m, res) + } + if ma.decay != decay { + currentAge := 2/ma.decay + 1 + return nil, fmt.Errorf("another moving average with age %f instead of %f exists in the registry with the same key [%+v]: %T", currentAge, age, m, res) + } + return ma, nil +} + +func (r *registry) MustGetVarMovingAvg(m Measurement, age float64) MovingAverage { + ma, err := r.GetVarMovingAvg(m, age) + if err != nil { + panic(err) + } + return ma +} + +func (r *registry) Range(f func(key, value interface{}) bool) { + r.store.Range(f) +} + +func (r *registry) GetMetricsByName(name string) []TagsWithValue { + metricsSet, ok := r.nameIndex.Load(name) + if !ok { + return nil + } + + var values []TagsWithValue + lock := metricsSet.(*mutexWithMap).lock + lock.RLock() + for _, value := range metricsSet.(*mutexWithMap).value { + values = append(values, value) + } + lock.RUnlock() + return values +} + +func (r *registry) updateIndex(m Measurement, metric interface{}) { + name := m.GetName() + newSet := r.sets.Get() + res, putBack := r.nameIndex.LoadOrStore(name, newSet) + if putBack { + r.sets.Put(newSet) + } + + lock := res.(*mutexWithMap).lock + lock.Lock() + res.(*mutexWithMap).value[m] = TagsWithValue{m.GetTags(), metric} + lock.Unlock() +} + +func (r *registry) get(m Measurement, pool *sync.Pool) interface{} { + res, ok := r.store.Load(m) + if !ok { + newValue := pool.Get() + res, ok = r.store.LoadOrStore(m, newValue) + if ok { + pool.Put(newValue) + } else { + r.updateIndex(m, res) + } + } + return res +} diff --git a/stats/metric/registry_test.go b/stats/metric/registry_test.go new file mode 100644 index 00000000..0da76b7e --- /dev/null +++ b/stats/metric/registry_test.go @@ -0,0 +1,209 @@ +package metric + +import ( + "errors" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +type testMeasurement struct { + name string + tag string +} + +func (r testMeasurement) GetName() string { + return r.name +} + +func (r testMeasurement) GetTags() map[string]string { + return map[string]string{"tag": r.tag} +} + +func TestRegistryGet(t *testing.T) { + registry := NewRegistry() + + counterKey := testMeasurement{name: "key1"} + counter := registry.MustGetCounter(counterKey) + counter.Inc() + otherCounter := registry.MustGetCounter(counterKey) + // otherCounter should be the same counter, since we are using the same key + if expected, got := counter.Value(), otherCounter.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + gaugeKey := testMeasurement{name: "key2"} + gauge := registry.MustGetCounter(gaugeKey) + gauge.Inc() + otherGauge := registry.MustGetCounter(gaugeKey) + // otherGauge should be the same gauge, since we are using the same key + if expected, got := gauge.Value(), otherGauge.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + // trying to get a gauge using the counter's key should result in an error + if g, err := registry.GetGauge(counterKey); err == nil { + t.Errorf("Expected an error, got %T", g) + } + + if expected, got := `a different type of metric exists in the registry with the same key [{name:key1 tag:}]: *metric.counter`, mustGetGauge(registry, counterKey).Error(); expected != got { + t.Errorf("Expected error %q, got %q.", expected, got) + } + + smaKey := testMeasurement{name: "key3"} + sma := registry.MustGetSimpleMovingAvg(smaKey) + sma.Add(1) + otherSma := registry.MustGetSimpleMovingAvg(smaKey) + // otherSma should be the same ma, since we are using the same key + if expected, got := sma.Value(), otherSma.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + vma10Key := testMeasurement{name: "key4"} + vma10 := registry.MustGetVarMovingAvg(vma10Key, 10) + vma10.Add(1) + otherVma10 := registry.MustGetVarMovingAvg(vma10Key, 10) + // otherVma10 should be the same vma10, since we are using the same key + if expected, got := vma10.Value(), otherVma10.Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } + + // trying to get a vma of different age using a key that corresponds to another age should result in an error + if g, err := registry.GetVarMovingAvg(vma10Key, 20); err == nil { + t.Errorf("Expected an error, got %T", g) + } + + if expected, got := `another moving average with age 12.000000 instead of 20.000000 exists in the registry with the same key [{name:key4 tag:}]: *metric.VariableEWMA`, mustGetVMA(registry, vma10Key, 20).Error(); expected != got { + t.Errorf("Expected error %q, got %q.", expected, got) + } +} + +func TestRegistryNameIndex(t *testing.T) { + registry := NewRegistry() + m1 := testMeasurement{name: "key1", tag: "tag1"} + m2 := testMeasurement{name: "key1", tag: "tag2"} + m3 := testMeasurement{name: "key2", tag: "tag1"} + registry.MustGetCounter(m1).Inc() + registry.MustGetCounter(m2).Add(2) + registry.MustGetCounter(m3).Add(3) + + metrics := registry.GetMetricsByName("key1") + + require.Equal(t, 2, len(metrics), "should receive 2 metrics") + res := map[string]float64{} + res[metrics[0].Tags["tag"]] = metrics[0].Value.(Counter).Value() + res[metrics[1].Tags["tag"]] = metrics[1].Value.(Counter).Value() + + require.Equal(t, res, map[string]float64{"tag1": 1.0, "tag2": 2.0}) +} + +func TestRegistryGetConcurrently(t *testing.T) { + const concurrency = 1000 + registry := NewRegistry() + key := testMeasurement{name: "key"} + var wg sync.WaitGroup + wg.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func() { + registry.MustGetCounter(key).Inc() + wg.Done() + }() + } + wg.Wait() + if expected, got := float64(concurrency), registry.MustGetCounter(key).Value(); expected != got { + t.Errorf("Expected %f, got %f.", expected, got) + } +} + +func mustGetGauge(registry Registry, key Measurement) (err error) { + defer func() { + if e := recover(); e != nil { + err = e.(error) + } + }() + registry.MustGetGauge(key) + return errors.New("") +} + +func mustGetVMA(registry Registry, key Measurement, age float64) (err error) { + defer func() { + if e := recover(); e != nil { + err = e.(error) + } + }() + registry.MustGetVarMovingAvg(key, age) + return errors.New("") +} + +func BenchmarkRegistryGetCounterAndInc1(b *testing.B) { + benchmarkRegistryGetCounterAndInc(b, 1) +} + +func BenchmarkRegistryGetCounterAndInc10(b *testing.B) { + benchmarkRegistryGetCounterAndInc(b, 10) +} + +func BenchmarkRegistryGetCounterAndInc100(b *testing.B) { + benchmarkRegistryGetCounterAndInc(b, 100) +} + +func benchmarkRegistryGetCounterAndInc(b *testing.B, concurrency int) { + b.StopTimer() + var start, end sync.WaitGroup + start.Add(1) + n := b.N / concurrency + registry := NewRegistry() + key := testMeasurement{name: "key"} + end.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func() { + start.Wait() + for i := 0; i < n; i++ { + counter := registry.MustGetCounter(key) + counter.Inc() + } + end.Done() + }() + } + + b.StartTimer() + start.Done() + end.Wait() +} + +func BenchmarkMutexMapGetIntAndInc1(b *testing.B) { + benchmarkMutexMapGetIntAndInc(b, 1) +} + +func BenchmarkMutexMapGetIntAndInc10(b *testing.B) { + benchmarkMutexMapGetIntAndInc(b, 10) +} + +func BenchmarkMutexMapGetIntAndInc100(b *testing.B) { + benchmarkMutexMapGetIntAndInc(b, 100) +} + +func benchmarkMutexMapGetIntAndInc(b *testing.B, concurrency int) { + b.StopTimer() + var mutex sync.RWMutex + var start, end sync.WaitGroup + start.Add(1) + n := b.N / concurrency + registry := map[string]int{"key": 0} + end.Add(concurrency) + for i := 0; i < concurrency; i++ { + go func() { + for i := 0; i < n; i++ { + mutex.Lock() + registry["key"] += 1 + mutex.Unlock() + } + end.Done() + }() + } + + b.StartTimer() + start.Done() + end.Wait() +} diff --git a/stats/mock_stats/mock_stats.go b/stats/mock_stats/mock_stats.go new file mode 100644 index 00000000..de0f5379 --- /dev/null +++ b/stats/mock_stats/mock_stats.go @@ -0,0 +1,214 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/rudderlabs/rudder-go-kit/stats (interfaces: Stats,Measurement) + +// Package mock_stats is a generated GoMock package. +package mock_stats + +import ( + context "context" + reflect "reflect" + time "time" + + gomock "github.com/golang/mock/gomock" + stats "github.com/rudderlabs/rudder-go-kit/stats" +) + +// MockStats is a mock of Stats interface. +type MockStats struct { + ctrl *gomock.Controller + recorder *MockStatsMockRecorder +} + +// MockStatsMockRecorder is the mock recorder for MockStats. +type MockStatsMockRecorder struct { + mock *MockStats +} + +// NewMockStats creates a new mock instance. +func NewMockStats(ctrl *gomock.Controller) *MockStats { + mock := &MockStats{ctrl: ctrl} + mock.recorder = &MockStatsMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockStats) EXPECT() *MockStatsMockRecorder { + return m.recorder +} + +// NewSampledTaggedStat mocks base method. +func (m *MockStats) NewSampledTaggedStat(arg0, arg1 string, arg2 stats.Tags) stats.Measurement { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewSampledTaggedStat", arg0, arg1, arg2) + ret0, _ := ret[0].(stats.Measurement) + return ret0 +} + +// NewSampledTaggedStat indicates an expected call of NewSampledTaggedStat. +func (mr *MockStatsMockRecorder) NewSampledTaggedStat(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewSampledTaggedStat", reflect.TypeOf((*MockStats)(nil).NewSampledTaggedStat), arg0, arg1, arg2) +} + +// NewStat mocks base method. +func (m *MockStats) NewStat(arg0, arg1 string) stats.Measurement { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewStat", arg0, arg1) + ret0, _ := ret[0].(stats.Measurement) + return ret0 +} + +// NewStat indicates an expected call of NewStat. +func (mr *MockStatsMockRecorder) NewStat(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewStat", reflect.TypeOf((*MockStats)(nil).NewStat), arg0, arg1) +} + +// NewTaggedStat mocks base method. +func (m *MockStats) NewTaggedStat(arg0, arg1 string, arg2 stats.Tags) stats.Measurement { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "NewTaggedStat", arg0, arg1, arg2) + ret0, _ := ret[0].(stats.Measurement) + return ret0 +} + +// NewTaggedStat indicates an expected call of NewTaggedStat. +func (mr *MockStatsMockRecorder) NewTaggedStat(arg0, arg1, arg2 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NewTaggedStat", reflect.TypeOf((*MockStats)(nil).NewTaggedStat), arg0, arg1, arg2) +} + +// Start mocks base method. +func (m *MockStats) Start(arg0 context.Context, arg1 stats.GoRoutineFactory) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Start", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// Start indicates an expected call of Start. +func (mr *MockStatsMockRecorder) Start(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Start", reflect.TypeOf((*MockStats)(nil).Start), arg0, arg1) +} + +// Stop mocks base method. +func (m *MockStats) Stop() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Stop") +} + +// Stop indicates an expected call of Stop. +func (mr *MockStatsMockRecorder) Stop() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockStats)(nil).Stop)) +} + +// MockMeasurement is a mock of Measurement interface. +type MockMeasurement struct { + ctrl *gomock.Controller + recorder *MockMeasurementMockRecorder +} + +// MockMeasurementMockRecorder is the mock recorder for MockMeasurement. +type MockMeasurementMockRecorder struct { + mock *MockMeasurement +} + +// NewMockMeasurement creates a new mock instance. +func NewMockMeasurement(ctrl *gomock.Controller) *MockMeasurement { + mock := &MockMeasurement{ctrl: ctrl} + mock.recorder = &MockMeasurementMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockMeasurement) EXPECT() *MockMeasurementMockRecorder { + return m.recorder +} + +// Count mocks base method. +func (m *MockMeasurement) Count(arg0 int) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Count", arg0) +} + +// Count indicates an expected call of Count. +func (mr *MockMeasurementMockRecorder) Count(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Count", reflect.TypeOf((*MockMeasurement)(nil).Count), arg0) +} + +// Gauge mocks base method. +func (m *MockMeasurement) Gauge(arg0 interface{}) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Gauge", arg0) +} + +// Gauge indicates an expected call of Gauge. +func (mr *MockMeasurementMockRecorder) Gauge(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Gauge", reflect.TypeOf((*MockMeasurement)(nil).Gauge), arg0) +} + +// Increment mocks base method. +func (m *MockMeasurement) Increment() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Increment") +} + +// Increment indicates an expected call of Increment. +func (mr *MockMeasurementMockRecorder) Increment() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Increment", reflect.TypeOf((*MockMeasurement)(nil).Increment)) +} + +// Observe mocks base method. +func (m *MockMeasurement) Observe(arg0 float64) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Observe", arg0) +} + +// Observe indicates an expected call of Observe. +func (mr *MockMeasurementMockRecorder) Observe(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Observe", reflect.TypeOf((*MockMeasurement)(nil).Observe), arg0) +} + +// RecordDuration mocks base method. +func (m *MockMeasurement) RecordDuration() func() { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "RecordDuration") + ret0, _ := ret[0].(func()) + return ret0 +} + +// RecordDuration indicates an expected call of RecordDuration. +func (mr *MockMeasurementMockRecorder) RecordDuration() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RecordDuration", reflect.TypeOf((*MockMeasurement)(nil).RecordDuration)) +} + +// SendTiming mocks base method. +func (m *MockMeasurement) SendTiming(arg0 time.Duration) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "SendTiming", arg0) +} + +// SendTiming indicates an expected call of SendTiming. +func (mr *MockMeasurementMockRecorder) SendTiming(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SendTiming", reflect.TypeOf((*MockMeasurement)(nil).SendTiming), arg0) +} + +// Since mocks base method. +func (m *MockMeasurement) Since(arg0 time.Time) { + m.ctrl.T.Helper() + m.ctrl.Call(m, "Since", arg0) +} + +// Since indicates an expected call of Since. +func (mr *MockMeasurementMockRecorder) Since(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Since", reflect.TypeOf((*MockMeasurement)(nil).Since), arg0) +} diff --git a/stats/options.go b/stats/options.go new file mode 100644 index 00000000..1e62e699 --- /dev/null +++ b/stats/options.go @@ -0,0 +1,32 @@ +package stats + +import ( + "sync/atomic" +) + +type statsConfig struct { + enabled *atomic.Bool + serviceName string + serviceVersion string + instanceName string + namespaceIdentifier string + excludedTags map[string]struct{} + + periodicStatsConfig periodicStatsConfig +} + +type Option func(*statsConfig) + +// WithServiceName sets the service name for the stats service. +func WithServiceName(name string) Option { + return func(c *statsConfig) { + c.serviceName = name + } +} + +// WithServiceVersion sets the service version for the stats service. +func WithServiceVersion(version string) Option { + return func(c *statsConfig) { + c.serviceVersion = version + } +} diff --git a/stats/otel.go b/stats/otel.go new file mode 100644 index 00000000..6f25cbb4 --- /dev/null +++ b/stats/otel.go @@ -0,0 +1,301 @@ +package stats + +import ( + "context" + "fmt" + "runtime" + "strings" + "sync" + "time" + + "github.com/spf13/cast" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/instrument" + "go.opentelemetry.io/otel/metric/instrument/syncfloat64" + "go.opentelemetry.io/otel/metric/instrument/syncint64" + "go.opentelemetry.io/otel/metric/unit" + + "github.com/rudderlabs/rudder-go-kit/logger" + "github.com/rudderlabs/rudder-go-kit/stats/internal/otel" +) + +// otelStats is an OTel-specific adapter that follows the Stats contract +type otelStats struct { + config statsConfig + otelConfig otelStatsConfig + + meter metric.Meter + counters map[string]syncint64.Counter + countersMu sync.Mutex + gauges map[string]*otelGauge + gaugesMu sync.Mutex + timers map[string]syncint64.Histogram + timersMu sync.Mutex + histograms map[string]syncfloat64.Histogram + histogramsMu sync.Mutex + + otelManager otel.Manager + runtimeStatsCollector runtimeStatsCollector + metricsStatsCollector metricStatsCollector + stopBackgroundCollection func() + logger logger.Logger +} + +func (s *otelStats) Start(ctx context.Context, goFactory GoRoutineFactory) error { + if !s.config.enabled.Load() { + return nil + } + + // Starting OpenTelemetry setup + var attrs []attribute.KeyValue + if s.config.instanceName != "" { + attrs = append(attrs, attribute.String("instanceName", s.config.instanceName)) + } + if s.config.namespaceIdentifier != "" { + attrs = append(attrs, attribute.String("namespace", s.config.namespaceIdentifier)) + } + res, err := otel.NewResource(s.config.serviceName, s.config.instanceName, s.config.serviceVersion, attrs...) + if err != nil { + return fmt.Errorf("failed to create open telemetry resource: %w", err) + } + + options := []otel.Option{otel.WithInsecure()} // @TODO: could make this configurable + if s.otelConfig.tracesEndpoint != "" { + options = append(options, otel.WithTracerProvider( + s.otelConfig.tracesEndpoint, + s.otelConfig.tracingSamplingRate, + )) + } + if s.otelConfig.metricsEndpoint != "" { + options = append(options, otel.WithMeterProvider( + s.otelConfig.metricsEndpoint, + otel.WithMeterProviderExportsInterval(s.otelConfig.metricsExportInterval), + )) + } + _, mp, err := s.otelManager.Setup(ctx, res, options...) + if err != nil { + return fmt.Errorf("failed to setup open telemetry: %w", err) + } + + s.meter = mp.Meter("") + + // Starting background collection + var backgroundCollectionCtx context.Context + backgroundCollectionCtx, s.stopBackgroundCollection = context.WithCancel(context.Background()) + + gaugeFunc := func(key string, val uint64) { + s.getMeasurement("runtime_"+key, GaugeType, nil).Gauge(val) + } + s.metricsStatsCollector = newMetricStatsCollector(s, s.config.periodicStatsConfig.metricManager) + goFactory.Go(func() { + s.metricsStatsCollector.run(backgroundCollectionCtx) + }) + + if s.config.periodicStatsConfig.enabled { + s.runtimeStatsCollector = newRuntimeStatsCollector(gaugeFunc) + s.runtimeStatsCollector.PauseDur = time.Duration(s.config.periodicStatsConfig.statsCollectionInterval) * time.Second + s.runtimeStatsCollector.EnableCPU = s.config.periodicStatsConfig.enableCPUStats + s.runtimeStatsCollector.EnableMem = s.config.periodicStatsConfig.enableMemStats + s.runtimeStatsCollector.EnableGC = s.config.periodicStatsConfig.enableGCStats + goFactory.Go(func() { + s.runtimeStatsCollector.run(backgroundCollectionCtx) + }) + } + + s.logger.Infof("Stats started successfully in mode %q with metrics endpoint %q and traces endpoint %q", + "OpenTelemetry", s.otelConfig.metricsEndpoint, s.otelConfig.tracesEndpoint, + ) + + return nil +} + +func (s *otelStats) Stop() { + if !s.config.enabled.Load() { + return + } + + ctx, cancel := context.WithTimeout(context.TODO(), 10*time.Second) + defer cancel() + + if err := s.otelManager.Shutdown(ctx); err != nil { + s.logger.Errorf("failed to shutdown open telemetry: %v", err) + } + + s.stopBackgroundCollection() + if s.metricsStatsCollector.done != nil { + <-s.metricsStatsCollector.done + } + if s.config.periodicStatsConfig.enabled && s.runtimeStatsCollector.done != nil { + <-s.runtimeStatsCollector.done + } +} + +// NewStat creates a new Measurement with provided Name and Type +func (s *otelStats) NewStat(name, statType string) (m Measurement) { + return s.getMeasurement(name, statType, nil) +} + +// NewTaggedStat creates a new Measurement with provided Name, Type and Tags +func (s *otelStats) NewTaggedStat(name, statType string, tags Tags) (m Measurement) { + return s.getMeasurement(name, statType, tags) +} + +// NewSampledTaggedStat creates a new Measurement with provided Name, Type and Tags +// Deprecated: use NewTaggedStat instead +func (s *otelStats) NewSampledTaggedStat(name, statType string, tags Tags) (m Measurement) { + return s.NewTaggedStat(name, statType, tags) +} + +func (*otelStats) getNoOpMeasurement(statType string) Measurement { + om := &otelMeasurement{ + genericMeasurement: genericMeasurement{statType: statType}, + disabled: true, + } + switch statType { + case CountType: + return &otelCounter{otelMeasurement: om} + case GaugeType: + return &otelGauge{otelMeasurement: om} + case TimerType: + return &otelTimer{otelMeasurement: om} + case HistogramType: + return &otelHistogram{otelMeasurement: om} + } + panic(fmt.Errorf("unsupported measurement type %s", statType)) +} + +func (s *otelStats) getMeasurement(name, statType string, tags Tags) Measurement { + if !s.config.enabled.Load() { + return s.getNoOpMeasurement(statType) + } + + if strings.Trim(name, " ") == "" { + byteArr := make([]byte, 2048) + n := runtime.Stack(byteArr, false) + stackTrace := string(byteArr[:n]) + s.logger.Warnf("detected missing stat measurement name, using 'novalue':\n%v", stackTrace) + name = "novalue" + } + + // Clean up tags based on deployment type. No need to send workspace id tag for free tier customers. + for k, v := range tags { + if strings.Trim(k, " ") == "" { + s.logger.Warnf("removing empty tag key with value %s for measurement %s", v, name) + delete(tags, k) + } + if _, ok := s.config.excludedTags[k]; ok { + delete(tags, k) + } + } + if tags == nil { + tags = make(Tags) + } + + om := &otelMeasurement{ + genericMeasurement: genericMeasurement{statType: statType}, + attributes: tags.otelAttributes(), + } + + switch statType { + case CountType: + instr := buildOTelInstrument(s.meter, name, s.counters, &s.countersMu) + return &otelCounter{counter: instr, otelMeasurement: om} + case GaugeType: + return s.getGauge(s.meter, name, om.attributes, tags.String()) + case TimerType: + instr := buildOTelInstrument(s.meter, name, s.timers, &s.timersMu, instrument.WithUnit(unit.Milliseconds)) + return &otelTimer{timer: instr, otelMeasurement: om} + case HistogramType: + instr := buildOTelInstrument(s.meter, name, s.histograms, &s.histogramsMu) + return &otelHistogram{histogram: instr, otelMeasurement: om} + default: + panic(fmt.Errorf("unsupported measurement type %s", statType)) + } +} + +func (s *otelStats) getGauge(meter metric.Meter, name string, attributes []attribute.KeyValue, tagsKey string) *otelGauge { + var ( + ok bool + og *otelGauge + mapKey = name + "|" + tagsKey + ) + + s.gaugesMu.Lock() + defer s.gaugesMu.Unlock() + + if s.gauges == nil { + s.gauges = make(map[string]*otelGauge) + } else { + og, ok = s.gauges[mapKey] + } + + if !ok { + g, err := meter.AsyncFloat64().Gauge(name) + if err != nil { + panic(fmt.Errorf("failed to create gauge %s: %w", name, err)) + } + og = &otelGauge{otelMeasurement: &otelMeasurement{ + genericMeasurement: genericMeasurement{statType: GaugeType}, + attributes: attributes, + }} + err = meter.RegisterCallback([]instrument.Asynchronous{g}, func(ctx context.Context) { + if value := og.getValue(); value != nil { + g.Observe(ctx, cast.ToFloat64(value), og.attributes...) + } + }) + if err != nil { + panic(fmt.Errorf("failed to register callback for gauge %s: %w", name, err)) + } + s.gauges[mapKey] = og + } + + return og +} + +func buildOTelInstrument[T any]( + meter metric.Meter, name string, m map[string]T, mu *sync.Mutex, opts ...instrument.Option, +) T { + var ( + ok bool + instr T + ) + + mu.Lock() + defer mu.Unlock() + + if m == nil { + m = make(map[string]T) + } else { + instr, ok = m[name] + } + + if !ok { + var err error + var value interface{} + switch any(m).(type) { + case map[string]syncint64.Counter: + value, err = meter.SyncInt64().Counter(name, opts...) + case map[string]syncint64.Histogram: + value, err = meter.SyncInt64().Histogram(name, opts...) + case map[string]syncfloat64.Histogram: + value, err = meter.SyncFloat64().Histogram(name, opts...) + default: + panic(fmt.Errorf("unknown instrument type %T", instr)) + } + if err != nil { + panic(fmt.Errorf("failed to create instrument %T(%s): %w", instr, name, err)) + } + instr = value.(T) + m[name] = instr + } + + return instr +} + +type otelStatsConfig struct { + tracesEndpoint string + tracingSamplingRate float64 + metricsEndpoint string + metricsExportInterval time.Duration +} diff --git a/stats/otel_measurement.go b/stats/otel_measurement.go new file mode 100644 index 00000000..220a67af --- /dev/null +++ b/stats/otel_measurement.go @@ -0,0 +1,117 @@ +package stats + +import ( + "context" + "sync" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/instrument/syncfloat64" + "go.opentelemetry.io/otel/metric/instrument/syncint64" +) + +// otelMeasurement is the statsd-specific implementation of Measurement +type otelMeasurement struct { + genericMeasurement + disabled bool + attributes []attribute.KeyValue +} + +// otelCounter represents a counter stat +type otelCounter struct { + *otelMeasurement + counter syncint64.Counter +} + +func (c *otelCounter) Count(n int) { + if !c.disabled { + c.counter.Add(context.TODO(), int64(n), c.attributes...) + } +} + +// Increment increases the stat by 1. Is the Equivalent of Count(1). Only applies to CountType stats +func (c *otelCounter) Increment() { + if !c.disabled { + c.counter.Add(context.TODO(), 1, c.attributes...) + } +} + +// otelGauge represents a gauge stat +type otelGauge struct { + *otelMeasurement + value interface{} + valueMu sync.Mutex +} + +// Gauge records an absolute value for this stat. Only applies to GaugeType stats +func (g *otelGauge) Gauge(value interface{}) { + if g.disabled { + return + } + g.valueMu.Lock() + g.value = value + g.valueMu.Unlock() +} + +func (g *otelGauge) getValue() interface{} { + if g.disabled { + return nil + } + g.valueMu.Lock() + v := g.value + g.value = nil + g.valueMu.Unlock() + return v +} + +// otelTimer represents a timer stat +type otelTimer struct { + *otelMeasurement + now func() time.Time + timer syncint64.Histogram +} + +// Since sends the time elapsed since duration start. Only applies to TimerType stats +func (t *otelTimer) Since(start time.Time) { + if !t.disabled { + t.SendTiming(time.Since(start)) + } +} + +// SendTiming sends a timing for this stat. Only applies to TimerType stats +func (t *otelTimer) SendTiming(duration time.Duration) { + if !t.disabled { + t.timer.Record(context.TODO(), duration.Milliseconds(), t.attributes...) + } +} + +// RecordDuration records the duration of time between +// the call to this function and the execution of the function it returns. +// Only applies to TimerType stats +func (t *otelTimer) RecordDuration() func() { + if t.disabled { + return func() {} + } + var start time.Time + if t.now == nil { + start = time.Now() + } else { + start = t.now() + } + return func() { + t.Since(start) + } +} + +// otelHistogram represents a histogram stat +type otelHistogram struct { + *otelMeasurement + histogram syncfloat64.Histogram +} + +// Observe sends an observation +func (h *otelHistogram) Observe(value float64) { + if !h.disabled { + h.histogram.Record(context.TODO(), value, h.attributes...) + } +} diff --git a/stats/otel_test.go b/stats/otel_test.go new file mode 100644 index 00000000..5c88ea10 --- /dev/null +++ b/stats/otel_test.go @@ -0,0 +1,598 @@ +package stats + +import ( + "context" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "sync/atomic" + "testing" + "time" + + promClient "github.com/prometheus/client_model/go" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + otelMetric "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/metric/global" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/resource" + semconv "go.opentelemetry.io/otel/semconv/v1.4.0" + + "github.com/rudderlabs/rudder-go-kit/config" + "github.com/rudderlabs/rudder-go-kit/httputil" + "github.com/rudderlabs/rudder-go-kit/logger" + "github.com/rudderlabs/rudder-go-kit/stats/metric" + statsTest "github.com/rudderlabs/rudder-go-kit/stats/testhelper" + "github.com/rudderlabs/rudder-go-kit/testhelper/docker" +) + +const ( + metricsPort = "8889" +) + +func TestOTelMeasurementInvalidOperations(t *testing.T) { + s := &otelStats{meter: global.MeterProvider().Meter(t.Name())} + + t.Run("counter invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", CountType).Gauge(1) + }) + require.Panics(t, func() { + s.NewStat("test", CountType).Observe(1.2) + }) + require.Panics(t, func() { + s.NewStat("test", CountType).RecordDuration() + }) + require.Panics(t, func() { + s.NewStat("test", CountType).SendTiming(1) + }) + require.Panics(t, func() { + s.NewStat("test", CountType).Since(time.Now()) + }) + }) + + t.Run("gauge invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", GaugeType).Increment() + }) + require.Panics(t, func() { + s.NewStat("test", GaugeType).Count(1) + }) + require.Panics(t, func() { + s.NewStat("test", GaugeType).Observe(1.2) + }) + require.Panics(t, func() { + s.NewStat("test", GaugeType).RecordDuration() + }) + require.Panics(t, func() { + s.NewStat("test", GaugeType).SendTiming(1) + }) + require.Panics(t, func() { + s.NewStat("test", GaugeType).Since(time.Now()) + }) + }) + + t.Run("histogram invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", HistogramType).Increment() + }) + require.Panics(t, func() { + s.NewStat("test", HistogramType).Count(1) + }) + require.Panics(t, func() { + s.NewStat("test", HistogramType).Gauge(1) + }) + require.Panics(t, func() { + s.NewStat("test", HistogramType).RecordDuration() + }) + require.Panics(t, func() { + s.NewStat("test", HistogramType).SendTiming(1) + }) + require.Panics(t, func() { + s.NewStat("test", HistogramType).Since(time.Now()) + }) + }) + + t.Run("timer invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", TimerType).Increment() + }) + require.Panics(t, func() { + s.NewStat("test", TimerType).Count(1) + }) + require.Panics(t, func() { + s.NewStat("test", TimerType).Gauge(1) + }) + require.Panics(t, func() { + s.NewStat("test", TimerType).Observe(1.2) + }) + }) +} + +func TestOTelMeasurementOperations(t *testing.T) { + ctx := context.Background() + + t.Run("counter increment", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("test-counter", CountType).Increment() + md := getDataPoint[metricdata.Sum[int64]](ctx, t, r, "test-counter", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1, md.DataPoints[0].Value) + }) + + t.Run("counter count", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("test-counter", CountType).Count(10) + md := getDataPoint[metricdata.Sum[int64]](ctx, t, r, "test-counter", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 10, md.DataPoints[0].Value) + }) + + t.Run("gauge", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("test-gauge", GaugeType).Gauge(1234) + md := getDataPoint[metricdata.Gauge[float64]](ctx, t, r, "test-gauge", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1234, md.DataPoints[0].Value) + }) + + t.Run("tagged gauges", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewTaggedStat("test-tagged-gauge", GaugeType, Tags{"a": "b"}).Gauge(111) + s.NewTaggedStat("test-tagged-gauge", GaugeType, Tags{"c": "d"}).Gauge(222) + md := getDataPoint[metricdata.Gauge[float64]](ctx, t, r, "test-tagged-gauge", 0) + require.Len(t, md.DataPoints, 2) + // sorting data points by value since the collected time is the same + sortDataPointsByValue(md.DataPoints) + require.EqualValues(t, 111, md.DataPoints[0].Value) + expectedAttrs1 := attribute.NewSet(attribute.String("a", "b")) + require.True(t, expectedAttrs1.Equals(&md.DataPoints[0].Attributes)) + require.EqualValues(t, 222, md.DataPoints[1].Value) + expectedAttrs2 := attribute.NewSet(attribute.String("c", "d")) + require.True(t, expectedAttrs2.Equals(&md.DataPoints[1].Attributes)) + }) + + t.Run("timer send timing", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("test-timer-1", TimerType).SendTiming(10 * time.Second) + md := getDataPoint[metricdata.Histogram](ctx, t, r, "test-timer-1", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1, md.DataPoints[0].Count) + require.EqualValues(t, (10*time.Second)/time.Millisecond, md.DataPoints[0].Sum) + }) + + t.Run("timer since", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("test-timer-2", TimerType).Since(time.Now().Add(-time.Second)) + md := getDataPoint[metricdata.Histogram](ctx, t, r, "test-timer-2", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1, md.DataPoints[0].Count) + require.EqualValues(t, time.Second.Milliseconds(), md.DataPoints[0].Sum) + }) + + t.Run("timer RecordDuration", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + ot := s.NewStat("test-timer-3", TimerType) + ot.(*otelTimer).now = func() time.Time { + return time.Now().Add(-time.Second) + } + ot.RecordDuration()() + md := getDataPoint[metricdata.Histogram](ctx, t, r, "test-timer-3", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1, md.DataPoints[0].Count) + require.InDelta(t, time.Second.Milliseconds(), md.DataPoints[0].Sum, 10) + }) + + t.Run("histogram", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("test-hist-1", HistogramType).Observe(1.2) + md := getDataPoint[metricdata.Histogram](ctx, t, r, "test-hist-1", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1, md.DataPoints[0].Count) + require.EqualValues(t, 1.2, md.DataPoints[0].Sum) + }) + + t.Run("tagged stats", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewTaggedStat("test-tagged", CountType, Tags{"key": "value"}).Increment() + md1 := getDataPoint[metricdata.Sum[int64]](ctx, t, r, "test-tagged", 0) + require.Len(t, md1.DataPoints, 1) + require.EqualValues(t, 1, md1.DataPoints[0].Value) + expectedAttrs := attribute.NewSet(attribute.String("key", "value")) + require.True(t, expectedAttrs.Equals(&md1.DataPoints[0].Attributes)) + + // same measurement name, different measurement type + s.NewTaggedStat("test-tagged", GaugeType, Tags{"key": "value"}).Gauge(1234) + md2 := getDataPoint[metricdata.Gauge[float64]](ctx, t, r, "test-tagged", 1) + require.Len(t, md2.DataPoints, 1) + require.EqualValues(t, 1234, md2.DataPoints[0].Value) + require.True(t, expectedAttrs.Equals(&md2.DataPoints[0].Attributes)) + }) + + t.Run("measurement with empty name", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, logger: logger.NOP, config: statsConfig{enabled: atomicBool(true)}} + s.NewStat("", CountType).Increment() + md := getDataPoint[metricdata.Sum[int64]](ctx, t, r, "novalue", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 1, md.DataPoints[0].Value) + require.True(t, md.DataPoints[0].Attributes.Equals(newAttributesSet(t))) + }) + + t.Run("measurement with empty name and empty tag key", func(t *testing.T) { + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, logger: logger.NOP, config: statsConfig{enabled: atomicBool(true)}} + s.NewTaggedStat(" ", GaugeType, Tags{"key": "value", "": "value2", " ": "value3"}).Gauge(22) + md := getDataPoint[metricdata.Gauge[float64]](ctx, t, r, "novalue", 0) + require.Len(t, md.DataPoints, 1) + require.EqualValues(t, 22, md.DataPoints[0].Value) + require.True(t, md.DataPoints[0].Attributes.Equals(newAttributesSet(t, + attribute.String("key", "value"), + ))) + }) +} + +func TestOTelTaggedGauges(t *testing.T) { + ctx := context.Background() + r, m := newReaderWithMeter(t) + s := &otelStats{meter: m, config: statsConfig{enabled: atomicBool(true)}} + s.NewTaggedStat("test-gauge", GaugeType, Tags{"a": "b"}).Gauge(1) + s.NewStat("test-gauge", GaugeType).Gauge(2) + s.NewTaggedStat("test-gauge", GaugeType, Tags{"c": "d"}).Gauge(3) + + rm, err := r.Collect(ctx) + require.NoError(t, err) + + var dp []metricdata.DataPoint[float64] + for _, sm := range rm.ScopeMetrics { + for _, m := range sm.Metrics { + dp = append(dp, m.Data.(metricdata.Gauge[float64]).DataPoints...) + } + } + sortDataPointsByValue(dp) + + require.Len(t, dp, 3) + + require.EqualValues(t, 1, dp[0].Value) + expectedAttrs := attribute.NewSet(attribute.String("a", "b")) + require.True(t, expectedAttrs.Equals(&dp[0].Attributes)) + + require.EqualValues(t, 2, dp[1].Value) + expectedAttrs = attribute.NewSet() + require.True(t, expectedAttrs.Equals(&dp[1].Attributes)) + + require.EqualValues(t, 3, dp[2].Value) + expectedAttrs = attribute.NewSet(attribute.String("c", "d")) + require.True(t, expectedAttrs.Equals(&dp[2].Attributes)) +} + +func TestOTelPeriodicStats(t *testing.T) { + type expectation struct { + name string + tags []*promClient.LabelPair + } + + cwd, err := os.Getwd() + require.NoError(t, err) + + runTest := func(t *testing.T, prepareFunc func(c *config.Config, m metric.Manager), expected []expectation) { + container, grpcEndpoint := statsTest.StartOTelCollector(t, metricsPort, + filepath.Join(cwd, "testdata", "otel-collector-config.yaml"), + ) + + c := config.New() + c.Set("INSTANCE_ID", "my-instance-id") + c.Set("OpenTelemetry.enabled", true) + c.Set("OpenTelemetry.metrics.endpoint", grpcEndpoint) + c.Set("OpenTelemetry.metrics.exportInterval", time.Millisecond) + m := metric.NewManager() + prepareFunc(c, m) + + l := logger.NewFactory(c) + s := NewStats(c, l, m, WithServiceName("TestOTelPeriodicStats")) + + // start stats + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + require.NoError(t, s.Start(ctx, DefaultGoRoutineFactory)) + defer s.Stop() + + var ( + resp *http.Response + metrics map[string]*promClient.MetricFamily + metricsEndpoint = fmt.Sprintf("http://localhost:%d/metrics", docker.GetHostPort(t, metricsPort, container)) + ) + + require.Eventuallyf(t, func() bool { + resp, err = http.Get(metricsEndpoint) + if err != nil { + return false + } + defer func() { httputil.CloseResponse(resp) }() + metrics, err = statsTest.ParsePrometheusMetrics(resp.Body) + if err != nil { + return false + } + for _, exp := range expected { + expectedMetricName := strings.ReplaceAll(exp.name, ".", "_") + if _, ok := metrics[expectedMetricName]; !ok { + return false + } + } + return true + }, 10*time.Second, 100*time.Millisecond, "err: %v, metrics: %+v", err, metrics) + + for _, exp := range expected { + metricName := strings.ReplaceAll(exp.name, ".", "_") + require.EqualValues(t, &metricName, metrics[metricName].Name) + require.EqualValues(t, ptr(promClient.MetricType_GAUGE), metrics[metricName].Type) + require.Len(t, metrics[metricName].Metric, 1) + + expectedLabels := []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("job"), Value: ptr("TestOTelPeriodicStats")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + } + if exp.tags != nil { + expectedLabels = append(expectedLabels, exp.tags...) + } + require.ElementsMatchf(t, expectedLabels, metrics[metricName].Metric[0].Label, + "Got %+v", metrics[metricName].Metric[0].Label, + ) + } + } + + t.Run("CPU stats", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", true) + c.Set("RuntimeStats.enabledMemStats", false) + c.Set("RuntimeStats.enableGCStats", false) + }, []expectation{ + {name: "runtime_cpu.goroutines"}, + {name: "runtime_cpu.cgo_calls"}, + }) + }) + + t.Run("Mem stats", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", false) + c.Set("RuntimeStats.enabledMemStats", true) + c.Set("RuntimeStats.enableGCStats", false) + }, []expectation{ + {name: "runtime_mem.alloc"}, + {name: "runtime_mem.total"}, + {name: "runtime_mem.sys"}, + {name: "runtime_mem.lookups"}, + {name: "runtime_mem.malloc"}, + {name: "runtime_mem.frees"}, + {name: "runtime_mem.heap.alloc"}, + {name: "runtime_mem.heap.sys"}, + {name: "runtime_mem.heap.idle"}, + {name: "runtime_mem.heap.inuse"}, + {name: "runtime_mem.heap.released"}, + {name: "runtime_mem.heap.objects"}, + {name: "runtime_mem.stack.inuse"}, + {name: "runtime_mem.stack.sys"}, + {name: "runtime_mem.stack.mspan_inuse"}, + {name: "runtime_mem.stack.mspan_sys"}, + {name: "runtime_mem.stack.mcache_inuse"}, + {name: "runtime_mem.stack.mcache_sys"}, + {name: "runtime_mem.othersys"}, + }) + }) + + t.Run("MemGC stats", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", false) + c.Set("RuntimeStats.enabledMemStats", true) + c.Set("RuntimeStats.enableGCStats", true) + }, []expectation{ + {name: "runtime_mem.alloc"}, + {name: "runtime_mem.total"}, + {name: "runtime_mem.sys"}, + {name: "runtime_mem.lookups"}, + {name: "runtime_mem.malloc"}, + {name: "runtime_mem.frees"}, + {name: "runtime_mem.heap.alloc"}, + {name: "runtime_mem.heap.sys"}, + {name: "runtime_mem.heap.idle"}, + {name: "runtime_mem.heap.inuse"}, + {name: "runtime_mem.heap.released"}, + {name: "runtime_mem.heap.objects"}, + {name: "runtime_mem.stack.inuse"}, + {name: "runtime_mem.stack.sys"}, + {name: "runtime_mem.stack.mspan_inuse"}, + {name: "runtime_mem.stack.mspan_sys"}, + {name: "runtime_mem.stack.mcache_inuse"}, + {name: "runtime_mem.stack.mcache_sys"}, + {name: "runtime_mem.othersys"}, + {name: "runtime_mem.gc.sys"}, + {name: "runtime_mem.gc.next"}, + {name: "runtime_mem.gc.last"}, + {name: "runtime_mem.gc.pause_total"}, + {name: "runtime_mem.gc.pause"}, + {name: "runtime_mem.gc.count"}, + {name: "runtime_mem.gc.cpu_percent"}, + }) + }) + + t.Run("Pending events", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", false) + c.Set("RuntimeStats.enabledMemStats", false) + c.Set("RuntimeStats.enableGCStats", false) + m.GetRegistry(metric.PublishedMetrics).MustGetGauge(TestMeasurement{tablePrefix: "table", workspace: "workspace", destType: "destType"}).Set(1.0) + }, []expectation{ + {name: "test_measurement_table", tags: []*promClient.LabelPair{ + {Name: ptr("destType"), Value: ptr("destType")}, + {Name: ptr("workspaceId"), Value: ptr("workspace")}, + }}, + }) + }) +} + +func TestOTelExcludedTags(t *testing.T) { + cwd, err := os.Getwd() + require.NoError(t, err) + container, grpcEndpoint := statsTest.StartOTelCollector(t, metricsPort, + filepath.Join(cwd, "testdata", "otel-collector-config.yaml"), + ) + + c := config.New() + c.Set("INSTANCE_ID", "my-instance-id") + c.Set("OpenTelemetry.enabled", true) + c.Set("OpenTelemetry.metrics.endpoint", grpcEndpoint) + c.Set("OpenTelemetry.metrics.exportInterval", time.Millisecond) + c.Set("RuntimeStats.enabled", false) + c.Set("statsExcludedTags", []string{"workspaceId"}) + l := logger.NewFactory(c) + m := metric.NewManager() + s := NewStats(c, l, m, WithServiceName(t.Name())) + + // start stats + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + require.NoError(t, s.Start(ctx, DefaultGoRoutineFactory)) + defer s.Stop() + + metricName := "test_workspaceId" + s.NewTaggedStat(metricName, CountType, Tags{ + "workspaceId": "nice-value", + "should_not_be_filtered": "fancy-value", + }).Increment() + + var ( + resp *http.Response + metrics map[string]*promClient.MetricFamily + metricsEndpoint = fmt.Sprintf("http://localhost:%d/metrics", docker.GetHostPort(t, metricsPort, container)) + ) + + require.Eventuallyf(t, func() bool { + resp, err = http.Get(metricsEndpoint) + if err != nil { + return false + } + defer func() { httputil.CloseResponse(resp) }() + metrics, err = statsTest.ParsePrometheusMetrics(resp.Body) + if err != nil { + return false + } + if _, ok := metrics[metricName]; !ok { + return false + } + return true + }, 10*time.Second, 100*time.Millisecond, "err: %v, metrics: %+v", err, metrics) + + require.EqualValues(t, &metricName, metrics[metricName].Name) + require.EqualValues(t, ptr(promClient.MetricType_COUNTER), metrics[metricName].Type) + require.Len(t, metrics[metricName].Metric, 1) + require.EqualValues(t, &promClient.Counter{Value: ptr(1.0)}, metrics[metricName].Metric[0].Counter) + require.ElementsMatchf(t, []*promClient.LabelPair{ + // the label1=value1 is coming from the otel-collector-config.yaml (see const_labels) + {Name: ptr("label1"), Value: ptr("value1")}, + {Name: ptr("should_not_be_filtered"), Value: ptr("fancy-value")}, + {Name: ptr("job"), Value: ptr("TestOTelExcludedTags")}, + {Name: ptr("instance"), Value: ptr("my-instance-id")}, + }, metrics[metricName].Metric[0].Label, "Got %+v", metrics[metricName].Metric[0].Label) +} + +func TestOTelStartStopError(t *testing.T) { + c := config.New() + c.Set("OpenTelemetry.enabled", true) + l := logger.NewFactory(c) + m := metric.NewManager() + s := NewStats(c, l, m) + + ctx := context.Background() + require.Error(t, s.Start(ctx, DefaultGoRoutineFactory), "we should error if no endpoint is provided but stats are enabled") + + done := make(chan struct{}) + go func() { + s.Stop() // this should not panic/block even if we couldn't start + close(done) + }() + + select { + case <-done: + case <-time.After(1 * time.Second): + t.Fatal("timeout waiting for Stop()") + } +} + +func getDataPoint[T any](ctx context.Context, t *testing.T, rdr sdkmetric.Reader, name string, idx int) (zero T) { + t.Helper() + rm, err := rdr.Collect(ctx) + require.NoError(t, err) + require.GreaterOrEqual(t, len(rm.ScopeMetrics), 1) + require.GreaterOrEqual(t, len(rm.ScopeMetrics[0].Metrics), idx+1) + require.Equal(t, name, rm.ScopeMetrics[0].Metrics[idx].Name) + md, ok := rm.ScopeMetrics[0].Metrics[idx].Data.(T) + require.Truef(t, ok, "Metric data is not of type %T but %T", zero, rm.ScopeMetrics[0].Metrics[idx].Data) + return md +} + +func sortDataPointsByValue[N int64 | float64](dp []metricdata.DataPoint[N]) { + sort.Slice(dp, func(i, j int) bool { + return dp[i].Value < dp[j].Value + }) +} + +func newAttributesSet(t *testing.T, attrs ...attribute.KeyValue) *attribute.Set { + t.Helper() + set := attribute.NewSet(attrs...) + return &set +} + +func newReaderWithMeter(t *testing.T) (sdkmetric.Reader, otelMetric.Meter) { + t.Helper() + manualRdr := sdkmetric.NewManualReader() + meterProvider := sdkmetric.NewMeterProvider( + sdkmetric.WithResource(resource.NewSchemaless(semconv.ServiceNameKey.String(t.Name()))), + sdkmetric.WithReader(manualRdr), + ) + t.Cleanup(func() { + _ = meterProvider.Shutdown(context.Background()) + }) + return manualRdr, meterProvider.Meter(t.Name()) +} + +func ptr[T any](v T) *T { + return &v +} + +func atomicBool(b bool) *atomic.Bool { // nolint:unparam + a := atomic.Bool{} + a.Store(b) + return &a +} + +type TestMeasurement struct { + tablePrefix string + workspace string + destType string +} + +func (r TestMeasurement) GetName() string { + return fmt.Sprintf("test_measurement_%s", r.tablePrefix) +} + +func (r TestMeasurement) GetTags() map[string]string { + return map[string]string{ + "workspaceId": r.workspace, + "destType": r.destType, + } +} diff --git a/stats/periodic.go b/stats/periodic.go new file mode 100644 index 00000000..bd2faa02 --- /dev/null +++ b/stats/periodic.go @@ -0,0 +1,232 @@ +package stats + +import ( + "context" + "runtime" + "time" + + "github.com/rudderlabs/rudder-go-kit/stats/metric" +) + +// GaugeFunc is an interface that implements the setting of a gauge value +// in a stats system. It should be expected that key will contain multiple +// parts separated by the '.' character in the form used by statsd (e.x. +// "mem.heap.alloc") +type gaugeFunc func(key string, val uint64) + +// periodicStatsConfig is the configuration for the periodic stats collection +type periodicStatsConfig struct { + enabled bool + statsCollectionInterval int64 + enableCPUStats bool + enableMemStats bool + enableGCStats bool + metricManager metric.Manager +} + +// runtimeStatsCollector implements the periodic grabbing of informational data from the +// runtime package and outputting the values to a GaugeFunc. +type runtimeStatsCollector struct { + // PauseDur represents the interval in between each set of stats output. + // Defaults to 10 seconds. + PauseDur time.Duration + + // EnableCPU determines whether CPU statistics will be output. Defaults to true. + EnableCPU bool + + // EnableMem determines whether memory statistics will be output. Defaults to true. + EnableMem bool + + // EnableGC determines whether garbage collection statistics will be output. EnableMem + // must also be set to true for this to take affect. Defaults to true. + EnableGC bool + + // done, when closed, is used to signal runtimeStatsCollector that is should stop collecting + // statistics and the Run function should return. If done is set, upon shutdown + // all gauges will be sent a final zero value to reset their values to 0. + done chan struct{} + + gaugeFunc gaugeFunc +} + +// New creates a new runtimeStatsCollector that will periodically output statistics to gaugeFunc. It +// will also set the values of the exported fields to the described defaults. The values +// of the exported defaults can be changed at any point before Run is called. +func newRuntimeStatsCollector(gaugeFunc gaugeFunc) runtimeStatsCollector { + return runtimeStatsCollector{ + PauseDur: 10 * time.Second, + EnableCPU: true, + EnableMem: true, + EnableGC: true, + gaugeFunc: gaugeFunc, + done: make(chan struct{}), + } +} + +// Run gathers statistics from package runtime and outputs them to the configured GaugeFunc every +// PauseDur. This function will not return until Done has been closed (or never if Done is nil), +// therefore it should be called in its own goroutine. +func (c runtimeStatsCollector) run(ctx context.Context) { + defer close(c.done) + defer c.zeroStats() + c.outputStats() + + // Gauges are a 'snapshot' rather than a histogram. Pausing for some interval + // aims to get a 'recent' snapshot out before statsd flushes metrics. + tick := time.NewTicker(c.PauseDur) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + c.outputStats() + } + } +} + +type cpuStats struct { + NumGoroutine uint64 + NumCgoCall uint64 +} + +// zeroStats sets all the stat guages to zero. On shutdown we want to zero them out so they don't persist +// at their last value until we start back up. +func (c runtimeStatsCollector) zeroStats() { + if c.EnableCPU { + cStats := cpuStats{} + c.outputCPUStats(&cStats) + } + if c.EnableMem { + mStats := runtime.MemStats{} + c.outputMemStats(&mStats) + if c.EnableGC { + c.outputGCStats(&mStats) + } + } +} + +func (c runtimeStatsCollector) outputStats() { + if c.EnableCPU { + cStats := cpuStats{ + NumGoroutine: uint64(runtime.NumGoroutine()), + NumCgoCall: uint64(runtime.NumCgoCall()), + } + c.outputCPUStats(&cStats) + } + if c.EnableMem { + m := &runtime.MemStats{} + runtime.ReadMemStats(m) + c.outputMemStats(m) + if c.EnableGC { + c.outputGCStats(m) + } + } +} + +func (c runtimeStatsCollector) outputCPUStats(s *cpuStats) { + c.gaugeFunc("cpu.goroutines", s.NumGoroutine) + c.gaugeFunc("cpu.cgo_calls", s.NumCgoCall) +} + +func (c runtimeStatsCollector) outputMemStats(m *runtime.MemStats) { + // General + c.gaugeFunc("mem.alloc", m.Alloc) + c.gaugeFunc("mem.total", m.TotalAlloc) + c.gaugeFunc("mem.sys", m.Sys) + c.gaugeFunc("mem.lookups", m.Lookups) + c.gaugeFunc("mem.malloc", m.Mallocs) + c.gaugeFunc("mem.frees", m.Frees) + + // Heap + c.gaugeFunc("mem.heap.alloc", m.HeapAlloc) + c.gaugeFunc("mem.heap.sys", m.HeapSys) + c.gaugeFunc("mem.heap.idle", m.HeapIdle) + c.gaugeFunc("mem.heap.inuse", m.HeapInuse) + c.gaugeFunc("mem.heap.released", m.HeapReleased) + c.gaugeFunc("mem.heap.objects", m.HeapObjects) + + // Stack + c.gaugeFunc("mem.stack.inuse", m.StackInuse) + c.gaugeFunc("mem.stack.sys", m.StackSys) + c.gaugeFunc("mem.stack.mspan_inuse", m.MSpanInuse) + c.gaugeFunc("mem.stack.mspan_sys", m.MSpanSys) + c.gaugeFunc("mem.stack.mcache_inuse", m.MCacheInuse) + c.gaugeFunc("mem.stack.mcache_sys", m.MCacheSys) + + c.gaugeFunc("mem.othersys", m.OtherSys) +} + +func (c runtimeStatsCollector) outputGCStats(m *runtime.MemStats) { + c.gaugeFunc("mem.gc.sys", m.GCSys) + c.gaugeFunc("mem.gc.next", m.NextGC) + c.gaugeFunc("mem.gc.last", m.LastGC) + c.gaugeFunc("mem.gc.pause_total", m.PauseTotalNs) + c.gaugeFunc("mem.gc.pause", m.PauseNs[(m.NumGC+255)%256]) + c.gaugeFunc("mem.gc.count", uint64(m.NumGC)) + c.gaugeFunc("mem.gc.cpu_percent", uint64(100*m.GCCPUFraction)) +} + +// metricStatsCollector implements the periodic grabbing of informational data from the +// metric package and outputting the values as stats +type metricStatsCollector struct { + stats Stats + metricManager metric.Manager + // PauseDur represents the interval in between each set of stats output. + // Defaults to 60 seconds. + pauseDur time.Duration + + // Done, when closed, is used to signal metricStatsCollector that is should stop collecting + // statistics and the run function should return. + done chan struct{} +} + +// newMetricStatsCollector creates a new metricStatsCollector. +func newMetricStatsCollector(stats Stats, metricManager metric.Manager) metricStatsCollector { + return metricStatsCollector{ + stats: stats, + metricManager: metricManager, + pauseDur: 60 * time.Second, + done: make(chan struct{}), + } +} + +// run gathers statistics from package metric and outputs them as +func (c metricStatsCollector) run(ctx context.Context) { + defer close(c.done) + c.outputStats() + + // Gauges are a 'snapshot' rather than a histogram. Pausing for some interval + // aims to get a 'recent' snapshot out before statsd flushes metrics. + tick := time.NewTicker(c.pauseDur) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + return + case <-tick.C: + c.outputStats() + } + } +} + +func (c metricStatsCollector) outputStats() { + if c.metricManager == nil { + return + } + c.metricManager.GetRegistry(metric.PublishedMetrics).Range(func(key, value interface{}) bool { + m := key.(metric.Measurement) + switch value := value.(type) { + case metric.Gauge: + c.stats.NewTaggedStat(m.GetName(), GaugeType, m.GetTags()). + Gauge(value.Value()) + case metric.Counter: + c.stats.NewTaggedStat(m.GetName(), CountType, m.GetTags()). + Count(int(value.Value())) + case metric.MovingAverage: + c.stats.NewTaggedStat(m.GetName(), GaugeType, m.GetTags()). + Gauge(value.Value()) + } + return true + }) +} diff --git a/stats/stats.go b/stats/stats.go new file mode 100644 index 00000000..5a1e61a2 --- /dev/null +++ b/stats/stats.go @@ -0,0 +1,129 @@ +//go:generate mockgen -destination=mock_stats/mock_stats.go -package mock_stats github.com/rudderlabs/rudder-go-kit/stats Stats,Measurement +package stats + +import ( + "context" + "os" + "sync/atomic" + "time" + + "go.opentelemetry.io/otel/metric/global" + + "github.com/rudderlabs/rudder-go-kit/config" + "github.com/rudderlabs/rudder-go-kit/logger" + svcMetric "github.com/rudderlabs/rudder-go-kit/stats/metric" +) + +const ( + CountType = "count" + TimerType = "timer" + GaugeType = "gauge" + HistogramType = "histogram" +) + +func init() { + // TODO once we drop statsd support we can do + // Default = &otelStats{config: statsConfig{enabled: false}} + Default = NewStats(config.Default, logger.Default, svcMetric.Instance) +} + +// Default is the default (singleton) Stats instance +var Default Stats + +type GoRoutineFactory interface { + Go(function func()) +} + +// Stats manages stat Measurements +type Stats interface { + // NewStat creates a new Measurement with provided Name and Type + NewStat(name, statType string) (m Measurement) + + // NewTaggedStat creates a new Measurement with provided Name, Type and Tags + NewTaggedStat(name, statType string, tags Tags) Measurement + + // NewSampledTaggedStat creates a new Measurement with provided Name, Type and Tags + // Deprecated: use NewTaggedStat instead + NewSampledTaggedStat(name, statType string, tags Tags) Measurement + + // Start starts the stats service and the collection of periodic stats. + Start(ctx context.Context, goFactory GoRoutineFactory) error + + // Stop stops the service and the collection of periodic stats. + Stop() +} + +// NewStats create a new Stats instance using the provided config, logger factory and metric manager as dependencies +func NewStats( + config *config.Config, loggerFactory *logger.Factory, metricManager svcMetric.Manager, opts ...Option, +) Stats { + excludedTags := make(map[string]struct{}) + excludedTagsSlice := config.GetStringSlice("statsExcludedTags", nil) + for _, tag := range excludedTagsSlice { + excludedTags[tag] = struct{}{} + } + + enabled := atomic.Bool{} + enabled.Store(config.GetBool("enableStats", true)) + statsConfig := statsConfig{ + excludedTags: excludedTags, + enabled: &enabled, + instanceName: config.GetString("INSTANCE_ID", ""), + namespaceIdentifier: os.Getenv("KUBE_NAMESPACE"), + periodicStatsConfig: periodicStatsConfig{ + enabled: config.GetBool("RuntimeStats.enabled", true), + statsCollectionInterval: config.GetInt64("RuntimeStats.statsCollectionInterval", 10), + enableCPUStats: config.GetBool("RuntimeStats.enableCPUStats", true), + enableMemStats: config.GetBool("RuntimeStats.enabledMemStats", true), + enableGCStats: config.GetBool("RuntimeStats.enableGCStats", true), + metricManager: metricManager, + }, + } + for _, opt := range opts { + opt(&statsConfig) + } + + if config.GetBool("OpenTelemetry.enabled", false) { + return &otelStats{ + config: statsConfig, + stopBackgroundCollection: func() {}, + meter: global.MeterProvider().Meter(""), + logger: loggerFactory.NewLogger().Child("stats"), + otelConfig: otelStatsConfig{ + tracesEndpoint: config.GetString("OpenTelemetry.traces.endpoint", ""), + tracingSamplingRate: config.GetFloat64("OpenTelemetry.traces.samplingRate", 0.1), + metricsEndpoint: config.GetString("OpenTelemetry.metrics.endpoint", ""), + metricsExportInterval: config.GetDuration("OpenTelemetry.metrics.exportInterval", 5, time.Second), + }, + } + } + + backgroundCollectionCtx, backgroundCollectionCancel := context.WithCancel(context.Background()) + + return &statsdStats{ + config: statsConfig, + logger: loggerFactory.NewLogger().Child("stats"), + backgroundCollectionCtx: backgroundCollectionCtx, + backgroundCollectionCancel: backgroundCollectionCancel, + statsdConfig: statsdConfig{ + tagsFormat: config.GetString("statsTagsFormat", "influxdb"), + statsdServerURL: config.GetString("STATSD_SERVER_URL", "localhost:8125"), + samplingRate: float32(config.GetFloat64("statsSamplingRate", 1)), + instanceName: statsConfig.instanceName, + namespaceIdentifier: statsConfig.namespaceIdentifier, + }, + state: &statsdState{ + client: &statsdClient{}, + clients: make(map[string]*statsdClient), + pendingClients: make(map[string]*statsdClient), + }, + } +} + +var DefaultGoRoutineFactory = defaultGoRoutineFactory{} + +type defaultGoRoutineFactory struct{} + +func (defaultGoRoutineFactory) Go(function func()) { + go function() +} diff --git a/stats/stats_test.go b/stats/stats_test.go new file mode 100644 index 00000000..c23a38d0 --- /dev/null +++ b/stats/stats_test.go @@ -0,0 +1,38 @@ +package stats + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTagsType(t *testing.T) { + tags := Tags{ + "b": "value1", + "a": "value2", + } + + t.Run("strings method", func(t *testing.T) { + for i := 0; i < 100; i++ { // just making sure we are not just lucky with the order + require.Equal(t, []string{"a", "value2", "b", "value1"}, tags.Strings()) + } + }) + + t.Run("string method", func(t *testing.T) { + require.Equal(t, "a,value2,b,value1", tags.String()) + }) + + t.Run("special character replacement", func(t *testing.T) { + specialTags := Tags{ + "b:1": "value1:1", + "a:1": "value2:2", + } + require.Equal(t, []string{"a-1", "value2-2", "b-1", "value1-1"}, specialTags.Strings()) + }) + + t.Run("empty tags", func(t *testing.T) { + emptyTags := Tags{} + require.Nil(t, emptyTags.Strings()) + require.Equal(t, "", emptyTags.String()) + }) +} diff --git a/stats/statsd.go b/stats/statsd.go new file mode 100644 index 00000000..5ea6eb21 --- /dev/null +++ b/stats/statsd.go @@ -0,0 +1,278 @@ +package stats + +import ( + "context" + "fmt" + "runtime" + "strings" + "sync" + "time" + + "github.com/cenkalti/backoff/v4" + "gopkg.in/alexcesaro/statsd.v2" + + "github.com/rudderlabs/rudder-go-kit/logger" +) + +// statsdStats is the statsd-specific implementation of Stats +type statsdStats struct { + config statsConfig + statsdConfig statsdConfig + state *statsdState + logger logger.Logger + backgroundCollectionCtx context.Context + backgroundCollectionCancel func() +} + +func (s *statsdStats) Start(ctx context.Context, goFactory GoRoutineFactory) (err error) { + if !s.config.enabled.Load() { + return nil + } + + s.state.conn = statsd.Address(s.statsdConfig.statsdServerURL) + // since, we don't want setup to be a blocking call, creating a separate `go routine` for retry to get statsd client. + + // NOTE: this is to get at least a dummy client, even if there is a failure. + // So, that nil pointer error is not received when client is called. + s.state.client.statsd, err = statsd.New(s.state.conn, s.statsdConfig.statsdTagsFormat(), s.statsdConfig.statsdDefaultTags()) + if err == nil { + s.state.clientsLock.Lock() + s.state.connEstablished = true + s.state.clientsLock.Unlock() + } + + goFactory.Go(func() { + if err != nil { + s.state.client.statsd, err = s.getNewStatsdClientWithExpoBackoff(ctx, s.state.conn, s.statsdConfig.statsdTagsFormat(), s.statsdConfig.statsdDefaultTags()) + if err != nil { + s.config.enabled.Store(false) + s.logger.Errorf("error while creating new statsd client: %v", err) + } else { + s.state.clientsLock.Lock() + for _, client := range s.state.pendingClients { + client.statsd = s.state.client.statsd.Clone(s.state.conn, s.statsdConfig.statsdTagsFormat(), s.statsdConfig.statsdDefaultTags(), statsd.Tags(client.tags...), statsd.SampleRate(client.samplingRate)) + } + + s.logger.Info("statsd client setup succeeded.") + s.state.connEstablished = true + s.state.pendingClients = nil + s.state.clientsLock.Unlock() + } + } + if err == nil && ctx.Err() == nil { + s.collectPeriodicStats(goFactory) + } + }) + + s.logger.Infof("Stats started successfully in mode %q with address %q", "StatsD", s.statsdConfig.statsdServerURL) + + return nil +} + +func (s *statsdStats) getNewStatsdClientWithExpoBackoff(ctx context.Context, opts ...statsd.Option) (*statsd.Client, error) { + bo := backoff.NewExponentialBackOff() + bo.MaxInterval = time.Minute + bo.MaxElapsedTime = 0 + boCtx := backoff.WithContext(bo, ctx) + var err error + var c *statsd.Client + op := func() error { + c, err = statsd.New(opts...) + if err != nil { + s.logger.Errorf("error while setting statsd client: %v", err) + } + return err + } + + err = backoff.Retry(op, boCtx) + return c, err +} + +func (s *statsdStats) collectPeriodicStats(goFactory GoRoutineFactory) { + gaugeFunc := func(key string, val uint64) { + s.state.client.statsd.Gauge("runtime_"+key, val) + } + s.state.rc = newRuntimeStatsCollector(gaugeFunc) + s.state.rc.PauseDur = time.Duration(s.config.periodicStatsConfig.statsCollectionInterval) * time.Second + s.state.rc.EnableCPU = s.config.periodicStatsConfig.enableCPUStats + s.state.rc.EnableMem = s.config.periodicStatsConfig.enableMemStats + s.state.rc.EnableGC = s.config.periodicStatsConfig.enableGCStats + + s.state.mc = newMetricStatsCollector(s, s.config.periodicStatsConfig.metricManager) + if s.config.periodicStatsConfig.enabled { + var wg sync.WaitGroup + wg.Add(2) + goFactory.Go(func() { + defer wg.Done() + s.state.rc.run(s.backgroundCollectionCtx) + }) + goFactory.Go(func() { + defer wg.Done() + s.state.mc.run(s.backgroundCollectionCtx) + }) + wg.Wait() + } +} + +// Stop stops periodic collection of stats. +func (s *statsdStats) Stop() { + s.state.clientsLock.RLock() + defer s.state.clientsLock.RUnlock() + + if !s.config.enabled.Load() || !s.state.connEstablished { + return + } + + s.backgroundCollectionCancel() + if !s.config.periodicStatsConfig.enabled { + return + } + + if s.state.rc.done != nil { + <-s.state.rc.done + } + if s.state.mc.done != nil { + <-s.state.mc.done + } +} + +// NewStat creates a new Measurement with provided Name and Type +func (s *statsdStats) NewStat(name, statType string) (m Measurement) { + return s.newStatsdMeasurement(name, statType, s.state.client) +} + +func (s *statsdStats) NewTaggedStat(Name, StatType string, tags Tags) (m Measurement) { + return s.internalNewTaggedStat(Name, StatType, tags, 1) +} + +func (s *statsdStats) NewSampledTaggedStat(Name, StatType string, tags Tags) (m Measurement) { + return s.internalNewTaggedStat(Name, StatType, tags, s.statsdConfig.samplingRate) +} + +func (s *statsdStats) internalNewTaggedStat(name, statType string, tags Tags, samplingRate float32) (m Measurement) { + // If stats is not enabled, returning a dummy struct + if !s.config.enabled.Load() { + return s.newStatsdMeasurement(name, statType, &statsdClient{}) + } + + // Clean up tags based on deployment type. No need to send workspace id tag for free tier customers. + for excludedTag := range s.config.excludedTags { + delete(tags, excludedTag) + } + if tags == nil { + tags = make(Tags) + } + if v, ok := tags[""]; ok { + s.logger.Warnf("removing empty tag key with value %s for measurement %s", v, name) + delete(tags, "") + } + // key comprises of the measurement type plus all tag-value pairs + taggedClientKey := tags.String() + fmt.Sprintf("%f", samplingRate) + + s.state.clientsLock.RLock() + taggedClient, found := s.state.clients[taggedClientKey] + s.state.clientsLock.RUnlock() + + if !found { + s.state.clientsLock.Lock() + if taggedClient, found = s.state.clients[taggedClientKey]; !found { // double check for race + tagVals := tags.Strings() + taggedClient = &statsdClient{samplingRate: samplingRate, tags: tagVals} + if s.state.connEstablished { + taggedClient.statsd = s.state.client.statsd.Clone(s.state.conn, s.statsdConfig.statsdTagsFormat(), s.statsdConfig.statsdDefaultTags(), statsd.Tags(tagVals...), statsd.SampleRate(samplingRate)) + } else { + // new statsd clients will be created when connection is established for all pending clients + s.state.pendingClients[taggedClientKey] = taggedClient + } + s.state.clients[taggedClientKey] = taggedClient + } + s.state.clientsLock.Unlock() + } + + return s.newStatsdMeasurement(name, statType, taggedClient) +} + +// newStatsdMeasurement creates a new measurement of the specific type +func (s *statsdStats) newStatsdMeasurement(name, statType string, client *statsdClient) Measurement { + if strings.Trim(name, " ") == "" { + byteArr := make([]byte, 2048) + n := runtime.Stack(byteArr, false) + stackTrace := string(byteArr[:n]) + s.logger.Warnf("detected missing stat measurement name, using 'novalue':\n%v", stackTrace) + name = "novalue" + } + baseMeasurement := &statsdMeasurement{ + enabled: s.config.enabled.Load(), + name: name, + client: client, + genericMeasurement: genericMeasurement{statType: statType}, + } + switch statType { + case CountType: + return &statsdCounter{baseMeasurement} + case GaugeType: + return &statsdGauge{baseMeasurement} + case TimerType: + return &statsdTimer{statsdMeasurement: baseMeasurement} + case HistogramType: + return &statsdHistogram{baseMeasurement} + default: + panic(fmt.Errorf("unsupported measurement type %s", statType)) + } +} + +type statsdConfig struct { + tagsFormat string + statsdServerURL string + samplingRate float32 + instanceName string + namespaceIdentifier string +} + +// statsdDefaultTags returns the default tags to use for statsd +func (c *statsdConfig) statsdDefaultTags() statsd.Option { + var tags []string + if c.instanceName != "" { + tags = append(tags, "instanceName", c.instanceName) + } + if c.namespaceIdentifier != "" { + tags = append(tags, "namespace", c.namespaceIdentifier) + } + return statsd.Tags(tags...) +} + +// statsdTagsFormat returns the tags format to use for statsd +func (c *statsdConfig) statsdTagsFormat() statsd.Option { + switch c.tagsFormat { + case "datadog": + return statsd.TagsFormat(statsd.Datadog) + default: + return statsd.TagsFormat(statsd.InfluxDB) + } +} + +type statsdState struct { + conn statsd.Option + client *statsdClient + connEstablished bool + rc runtimeStatsCollector + mc metricStatsCollector + + clientsLock sync.RWMutex + clients map[string]*statsdClient + pendingClients map[string]*statsdClient +} + +// statsdClient is a wrapper around statsd.Client. +// We use this wrapper to allow for filling the actual statsd client at a later stage, +// in case a connection cannot be established immediately at startup. +type statsdClient struct { + samplingRate float32 + tags []string + statsd *statsd.Client +} + +// ready returns true if the statsd client is ready to be used (not nil). +func (sc *statsdClient) ready() bool { + return sc.statsd != nil +} diff --git a/stats/statsd_measurement.go b/stats/statsd_measurement.go new file mode 100644 index 00000000..ea344a54 --- /dev/null +++ b/stats/statsd_measurement.go @@ -0,0 +1,114 @@ +package stats + +import ( + "time" + + "gopkg.in/alexcesaro/statsd.v2" +) + +// statsdMeasurement is the statsd-specific implementation of Measurement +type statsdMeasurement struct { + genericMeasurement + enabled bool + name string + client *statsdClient +} + +// skip returns true if the stat should be skipped (stats disabled or client not ready) +func (m *statsdMeasurement) skip() bool { + return !m.enabled || !m.client.ready() +} + +// statsdCounter represents a counter stat +type statsdCounter struct { + *statsdMeasurement +} + +func (c *statsdCounter) Count(n int) { + if c.skip() { + return + } + c.client.statsd.Count(c.name, n) +} + +// Increment increases the stat by 1. Is the Equivalent of Count(1). Only applies to CountType stats +func (c *statsdCounter) Increment() { + if c.skip() { + return + } + c.client.statsd.Increment(c.name) +} + +// statsdGauge represents a gauge stat +type statsdGauge struct { + *statsdMeasurement +} + +// Gauge records an absolute value for this stat. Only applies to GaugeType stats +func (g *statsdGauge) Gauge(value interface{}) { + if g.skip() { + return + } + g.client.statsd.Gauge(g.name, value) +} + +// statsdTimer represents a timer stat +type statsdTimer struct { + *statsdMeasurement + timing *statsd.Timing +} + +// Start starts a new timing for this stat. Only applies to TimerType stats +// Deprecated: Use concurrent safe SendTiming() instead +func (t *statsdTimer) Start() { + if t.skip() { + return + } + timing := t.client.statsd.NewTiming() + t.timing = &timing +} + +// End send the time elapsed since the Start() call of this stat. Only applies to TimerType stats +// Deprecated: Use concurrent safe SendTiming() instead +func (t *statsdTimer) End() { + if t.skip() || t.timing == nil { + return + } + t.timing.Send(t.name) +} + +// Since sends the time elapsed since duration start. Only applies to TimerType stats +func (t *statsdTimer) Since(start time.Time) { + t.SendTiming(time.Since(start)) +} + +// SendTiming sends a timing for this stat. Only applies to TimerType stats +func (t *statsdTimer) SendTiming(duration time.Duration) { + if t.skip() { + return + } + t.client.statsd.Timing(t.name, int(duration/time.Millisecond)) +} + +// RecordDuration records the duration of time between +// the call to this function and the execution of the function it returns. +// Only applies to TimerType stats +func (t *statsdTimer) RecordDuration() func() { + start := time.Now() + return func() { + t.Since(start) + } +} + +// statsdHistogram represents a histogram stat +type statsdHistogram struct { + *statsdMeasurement +} + +// Observe sends an observation +func (h *statsdHistogram) Observe(value float64) { + if h.skip() { + return + } + h.client.statsd.Histogram(h.name, value) +} diff --git a/stats/statsd_test.go b/stats/statsd_test.go new file mode 100644 index 00000000..19515c7b --- /dev/null +++ b/stats/statsd_test.go @@ -0,0 +1,449 @@ +package stats_test + +import ( + "context" + "fmt" + "io" + "net" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/rudderlabs/rudder-go-kit/config" + "github.com/rudderlabs/rudder-go-kit/logger" + "github.com/rudderlabs/rudder-go-kit/stats" + "github.com/rudderlabs/rudder-go-kit/stats/metric" + "github.com/rudderlabs/rudder-go-kit/testhelper" +) + +func TestStatsdMeasurementInvalidOperations(t *testing.T) { + c := config.New() + l := logger.NewFactory(c) + m := metric.NewManager() + s := stats.NewStats(c, l, m) + + t.Run("counter invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", stats.CountType).Gauge(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.CountType).Observe(1.2) + }) + require.Panics(t, func() { + s.NewStat("test", stats.CountType).RecordDuration() + }) + require.Panics(t, func() { + s.NewStat("test", stats.CountType).SendTiming(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.CountType).Since(time.Now()) + }) + }) + + t.Run("gauge invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", stats.GaugeType).Increment() + }) + require.Panics(t, func() { + s.NewStat("test", stats.GaugeType).Count(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.GaugeType).Observe(1.2) + }) + require.Panics(t, func() { + s.NewStat("test", stats.GaugeType).RecordDuration() + }) + require.Panics(t, func() { + s.NewStat("test", stats.GaugeType).SendTiming(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.GaugeType).Since(time.Now()) + }) + }) + + t.Run("histogram invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", stats.HistogramType).Increment() + }) + require.Panics(t, func() { + s.NewStat("test", stats.HistogramType).Count(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.HistogramType).Gauge(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.HistogramType).RecordDuration() + }) + require.Panics(t, func() { + s.NewStat("test", stats.HistogramType).SendTiming(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.HistogramType).Since(time.Now()) + }) + }) + + t.Run("timer invalid operations", func(t *testing.T) { + require.Panics(t, func() { + s.NewStat("test", stats.TimerType).Increment() + }) + require.Panics(t, func() { + s.NewStat("test", stats.TimerType).Count(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.TimerType).Gauge(1) + }) + require.Panics(t, func() { + s.NewStat("test", stats.TimerType).Observe(1.2) + }) + }) +} + +func TestStatsdMeasurementOperations(t *testing.T) { + var lastReceived string + server := newStatsdServer(t, func(s string) { lastReceived = s }) + defer server.Close() + + c := config.New() + c.Set("STATSD_SERVER_URL", server.addr) + c.Set("INSTANCE_ID", "test") + c.Set("RuntimeStats.enabled", false) + c.Set("statsSamplingRate", 0.5) + + l := logger.NewFactory(c) + m := metric.NewManager() + s := stats.NewStats(c, l, m) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // start stats + require.NoError(t, s.Start(ctx, stats.DefaultGoRoutineFactory)) + defer s.Stop() + + t.Run("counter increment", func(t *testing.T) { + s.NewStat("test-counter", stats.CountType).Increment() + + require.Eventually(t, func() bool { + return lastReceived == "test-counter,instanceName=test:1|c" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("counter count", func(t *testing.T) { + s.NewStat("test-counter", stats.CountType).Count(10) + + require.Eventually(t, func() bool { + return lastReceived == "test-counter,instanceName=test:10|c" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("gauge", func(t *testing.T) { + s.NewStat("test-gauge", stats.GaugeType).Gauge(1234) + + require.Eventually(t, func() bool { + return lastReceived == "test-gauge,instanceName=test:1234|g" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("timer send timing", func(t *testing.T) { + s.NewStat("test-timer-1", stats.TimerType).SendTiming(10 * time.Second) + + require.Eventually(t, func() bool { + return lastReceived == "test-timer-1,instanceName=test:10000|ms" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("timer since", func(t *testing.T) { + s.NewStat("test-timer-2", stats.TimerType).Since(time.Now()) + + require.Eventually(t, func() bool { + return lastReceived == "test-timer-2,instanceName=test:0|ms" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("timer RecordDuration", func(t *testing.T) { + func() { + defer s.NewStat("test-timer-4", stats.TimerType).RecordDuration()() + }() + + require.Eventually(t, func() bool { + return lastReceived == "test-timer-4,instanceName=test:0|ms" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("histogram", func(t *testing.T) { + s.NewStat("test-hist-1", stats.HistogramType).Observe(1.2) + require.Eventually(t, func() bool { + return lastReceived == "test-hist-1,instanceName=test:1.2|h" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("tagged stats", func(t *testing.T) { + s.NewTaggedStat("test-tagged", stats.CountType, stats.Tags{"key": "value"}).Increment() + require.Eventually(t, func() bool { + return lastReceived == "test-tagged,instanceName=test,key=value:1|c" + }, 2*time.Second, time.Millisecond) + + // same measurement name, different measurement type + s.NewTaggedStat("test-tagged", stats.GaugeType, stats.Tags{"key": "value"}).Gauge(22) + require.Eventually(t, func() bool { + return lastReceived == "test-tagged,instanceName=test,key=value:22|g" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("sampled stats", func(t *testing.T) { + lastReceived = "" + // use the same, non-sampled counter first to make sure we don't get it from cache when we request the sampled one + counter := s.NewTaggedStat("test-tagged-sampled", stats.CountType, stats.Tags{"key": "value"}) + counter.Increment() + + require.Eventually(t, func() bool { + return lastReceived == "test-tagged-sampled,instanceName=test,key=value:1|c" + }, 2*time.Second, time.Millisecond) + + counterSampled := s.NewSampledTaggedStat("test-tagged-sampled", stats.CountType, stats.Tags{"key": "value"}) + counterSampled.Increment() + require.Eventually(t, func() bool { + if lastReceived == "test-tagged-sampled,instanceName=test,key=value:1|c|@0.5" { + return true + } + // playing with probabilities, we might or might not get the sample (0.5 -> 50% chance) + counterSampled.Increment() + return false + }, 2*time.Second, time.Millisecond) + }) + + t.Run("measurement with empty name", func(t *testing.T) { + s.NewStat("", stats.CountType).Increment() + + require.Eventually(t, func() bool { + return lastReceived == "novalue,instanceName=test:1|c" + }, 2*time.Second, time.Millisecond) + }) + + t.Run("measurement with empty name and empty tag key", func(t *testing.T) { + s.NewTaggedStat(" ", stats.GaugeType, stats.Tags{"key": "value", "": "value2"}).Gauge(22) + + require.Eventually(t, func() bool { + return lastReceived == "novalue,instanceName=test,key=value:22|g" + }, 2*time.Second, time.Millisecond) + }) +} + +func TestStatsdPeriodicStats(t *testing.T) { + runTest := func(t *testing.T, prepareFunc func(c *config.Config, m metric.Manager), expected []string) { + var received []string + server := newStatsdServer(t, func(s string) { + if i := strings.Index(s, ":"); i > 0 { + s = s[:i] + } + received = append(received, s) + }) + defer server.Close() + + c := config.New() + m := metric.NewManager() + t.Setenv("KUBE_NAMESPACE", "my-namespace") + c.Set("STATSD_SERVER_URL", server.addr) + c.Set("INSTANCE_ID", "test") + c.Set("RuntimeStats.enabled", true) + c.Set("RuntimeStats.statsCollectionInterval", 60) + prepareFunc(c, m) + + l := logger.NewFactory(c) + s := stats.NewStats(c, l, m) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // start stats + require.NoError(t, s.Start(ctx, stats.DefaultGoRoutineFactory)) + defer s.Stop() + + require.Eventually(t, func() bool { + if len(received) != len(expected) { + return false + } + return reflect.DeepEqual(received, expected) + }, 10*time.Second, time.Millisecond) + } + + t.Run("CPU stats", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", true) + c.Set("RuntimeStats.enabledMemStats", false) + c.Set("RuntimeStats.enableGCStats", false) + }, []string{ + "runtime_cpu.goroutines,instanceName=test,namespace=my-namespace", + "runtime_cpu.cgo_calls,instanceName=test,namespace=my-namespace", + }) + }) + + t.Run("Mem stats", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", false) + c.Set("RuntimeStats.enabledMemStats", true) + c.Set("RuntimeStats.enableGCStats", false) + }, []string{ + "runtime_mem.alloc,instanceName=test,namespace=my-namespace", + "runtime_mem.total,instanceName=test,namespace=my-namespace", + "runtime_mem.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.lookups,instanceName=test,namespace=my-namespace", + "runtime_mem.malloc,instanceName=test,namespace=my-namespace", + "runtime_mem.frees,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.alloc,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.idle,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.released,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.objects,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mspan_inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mspan_sys,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mcache_inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mcache_sys,instanceName=test,namespace=my-namespace", + "runtime_mem.othersys,instanceName=test,namespace=my-namespace", + }) + }) + + t.Run("MemGC stats", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", false) + c.Set("RuntimeStats.enabledMemStats", true) + c.Set("RuntimeStats.enableGCStats", true) + }, []string{ + "runtime_mem.alloc,instanceName=test,namespace=my-namespace", + "runtime_mem.total,instanceName=test,namespace=my-namespace", + "runtime_mem.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.lookups,instanceName=test,namespace=my-namespace", + "runtime_mem.malloc,instanceName=test,namespace=my-namespace", + "runtime_mem.frees,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.alloc,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.idle,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.released,instanceName=test,namespace=my-namespace", + "runtime_mem.heap.objects,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mspan_inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mspan_sys,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mcache_inuse,instanceName=test,namespace=my-namespace", + "runtime_mem.stack.mcache_sys,instanceName=test,namespace=my-namespace", + "runtime_mem.othersys,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.sys,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.next,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.last,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.pause_total,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.pause,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.count,instanceName=test,namespace=my-namespace", + "runtime_mem.gc.cpu_percent,instanceName=test,namespace=my-namespace", + }) + }) + + t.Run("Pending events", func(t *testing.T) { + runTest(t, func(c *config.Config, m metric.Manager) { + c.Set("RuntimeStats.enableCPUStats", false) + c.Set("RuntimeStats.enabledMemStats", false) + c.Set("RuntimeStats.enableGCStats", false) + m.GetRegistry(metric.PublishedMetrics).MustGetGauge(TestMeasurement{tablePrefix: "table", workspace: "workspace", destType: "destType"}).Set(1.0) + }, []string{ + "test_measurement_table,instanceName=test,namespace=my-namespace,destType=destType,workspaceId=workspace", + }) + }) +} + +func TestStatsdExcludedTags(t *testing.T) { + var lastReceived string + server := newStatsdServer(t, func(s string) { lastReceived = s }) + defer server.Close() + + c := config.New() + c.Set("STATSD_SERVER_URL", server.addr) + c.Set("statsExcludedTags", []string{"workspaceId"}) + c.Set("INSTANCE_ID", "test") + c.Set("RuntimeStats.enabled", false) + + l := logger.NewFactory(c) + m := metric.NewManager() + s := stats.NewStats(c, l, m) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // start stats + require.NoError(t, s.Start(ctx, stats.DefaultGoRoutineFactory)) + defer s.Stop() + + c.Set("statsExcludedTags", []string{"workspaceId"}) + s.NewTaggedStat("test-workspaceId", stats.CountType, stats.Tags{"workspaceId": "value"}).Increment() + require.Eventually(t, func() bool { + fmt.Println(lastReceived) + return lastReceived == "test-workspaceId,instanceName=test:1|c" + }, 2*time.Second, time.Millisecond) +} + +type statsdServer struct { + t *testing.T + addr string + closer io.Closer + closed chan bool +} + +func newStatsdServer(t *testing.T, f func(string)) *statsdServer { + port, err := testhelper.GetFreePort() + require.NoError(t, err) + addr := net.JoinHostPort("localhost", strconv.Itoa(port)) + s := &statsdServer{t: t, closed: make(chan bool)} + laddr, err := net.ResolveUDPAddr("udp", addr) + require.NoError(t, err) + conn, err := net.ListenUDP("udp", laddr) + require.NoError(t, err) + s.closer = conn + s.addr = conn.LocalAddr().String() + go func() { + buf := make([]byte, 4096) + for { + n, err := conn.Read(buf) + if err != nil { + s.closed <- true + return + } + s := string(buf[:n]) + lines := strings.Split(s, "\n") + if n > 0 { + for _, line := range lines { + f(line) + } + } + } + }() + + return s +} + +func (s *statsdServer) Close() { + require.NoError(s.t, s.closer.Close()) + <-s.closed +} + +type TestMeasurement struct { + tablePrefix string + workspace string + destType string +} + +func (r TestMeasurement) GetName() string { + return fmt.Sprintf("test_measurement_%s", r.tablePrefix) +} + +func (r TestMeasurement) GetTags() map[string]string { + return map[string]string{ + "workspaceId": r.workspace, + "destType": r.destType, + } +} diff --git a/stats/tags.go b/stats/tags.go new file mode 100644 index 00000000..2f3e0896 --- /dev/null +++ b/stats/tags.go @@ -0,0 +1,44 @@ +package stats + +import ( + "sort" + "strings" + + "go.opentelemetry.io/otel/attribute" +) + +// Tags is a map of key value pairs +type Tags map[string]string + +// Strings returns all key value pairs as an ordered list of strings, sorted by increasing key order +func (t Tags) Strings() []string { + if len(t) == 0 { + return nil + } + res := make([]string, 0, len(t)*2) + // sorted by tag name (!important for consistent map iteration order) + tagNames := make([]string, 0, len(t)) + for n := range t { + tagNames = append(tagNames, n) + } + sort.Strings(tagNames) + for _, tagName := range tagNames { + tagVal := t[tagName] + res = append(res, strings.ReplaceAll(tagName, ":", "-"), strings.ReplaceAll(tagVal, ":", "-")) + } + return res +} + +// String returns all key value pairs as a single string, separated by commas, sorted by increasing key order +func (t Tags) String() string { + return strings.Join(t.Strings(), ",") +} + +// otelAttributes returns all key value pairs as a list of OpenTelemetry attributes +func (t Tags) otelAttributes() []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, len(t)) + for k, v := range t { + attrs = append(attrs, attribute.String(k, v)) + } + return attrs +} diff --git a/stats/testdata/otel-collector-config.yaml b/stats/testdata/otel-collector-config.yaml new file mode 100644 index 00000000..9bad23b5 --- /dev/null +++ b/stats/testdata/otel-collector-config.yaml @@ -0,0 +1,24 @@ +receivers: + otlp: + protocols: + grpc: + +exporters: + prometheus: + endpoint: "0.0.0.0:8889" + const_labels: + label1: value1 + +processors: + batch: + +extensions: + health_check: + +service: + extensions: [health_check] + pipelines: + metrics: + receivers: [otlp] + processors: [batch] + exporters: [prometheus] diff --git a/stats/testhelper/otel.go b/stats/testhelper/otel.go new file mode 100644 index 00000000..f4905788 --- /dev/null +++ b/stats/testhelper/otel.go @@ -0,0 +1,83 @@ +package testhelper + +import ( + "fmt" + "net/http" + "strconv" + "testing" + "time" + + "github.com/ory/dockertest/v3" + "github.com/ory/dockertest/v3/docker" + "github.com/stretchr/testify/require" + + "github.com/rudderlabs/rudder-go-kit/httputil" + "github.com/rudderlabs/rudder-go-kit/testhelper" + dt "github.com/rudderlabs/rudder-go-kit/testhelper/docker" +) + +const healthPort = "13133" + +type StartOTelCollectorOpt func(*startOTelCollectorConf) + +// WithStartCollectorPort allows to specify the port on which the collector will be listening for gRPC requests. +func WithStartCollectorPort(port int) StartOTelCollectorOpt { + return func(c *startOTelCollectorConf) { + c.port = port + } +} + +func StartOTelCollector(t *testing.T, metricsPort, configPath string, opts ...StartOTelCollectorOpt) ( + container *docker.Container, + grpcEndpoint string, +) { + t.Helper() + + conf := &startOTelCollectorConf{} + for _, opt := range opts { + opt(conf) + } + + if conf.port == 0 { + var err error + conf.port, err = testhelper.GetFreePort() + require.NoError(t, err) + } + + pool, err := dockertest.NewPool("") + require.NoError(t, err) + + collector, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "otel/opentelemetry-collector", + Tag: "0.67.0", + ExposedPorts: []string{healthPort, metricsPort}, + PortBindings: map[docker.Port][]docker.PortBinding{ + "4317/tcp": {{HostPort: strconv.Itoa(conf.port)}}, + }, + Mounts: []string{configPath + ":/etc/otelcol/config.yaml"}, + }) + require.NoError(t, err) + t.Cleanup(func() { + if err := pool.Purge(collector); err != nil { + t.Logf("Could not purge resource: %v", err) + } + }) + + healthEndpoint := fmt.Sprintf("http://localhost:%d", dt.GetHostPort(t, healthPort, collector.Container)) + require.Eventually(t, func() bool { + resp, err := http.Get(healthEndpoint) + if err != nil { + return false + } + defer func() { httputil.CloseResponse(resp) }() + return resp.StatusCode == http.StatusOK + }, 10*time.Second, 100*time.Millisecond, "Collector was not ready on health port") + + t.Log("Container is healthy") + + return collector.Container, "localhost:" + strconv.Itoa(conf.port) +} + +type startOTelCollectorConf struct { + port int +} diff --git a/stats/testhelper/prometheus.go b/stats/testhelper/prometheus.go new file mode 100644 index 00000000..ed624639 --- /dev/null +++ b/stats/testhelper/prometheus.go @@ -0,0 +1,18 @@ +package testhelper + +import ( + "io" + + promClient "github.com/prometheus/client_model/go" + promParser "github.com/prometheus/common/expfmt" +) + +// ParsePrometheusMetrics parses the given Prometheus metrics and returns a map of metric name to metric family. +func ParsePrometheusMetrics(rdr io.Reader) (map[string]*promClient.MetricFamily, error) { + var parser promParser.TextParser + mf, err := parser.TextToMetricFamilies(rdr) + if err != nil { + return nil, err + } + return mf, nil +} diff --git a/testhelper/docker/docker.go b/testhelper/docker/docker.go new file mode 100644 index 00000000..a0104bb5 --- /dev/null +++ b/testhelper/docker/docker.go @@ -0,0 +1,22 @@ +package docker + +import ( + "strconv" + "testing" + + "github.com/ory/dockertest/v3/docker" + "github.com/stretchr/testify/require" +) + +// GetHostPort returns the desired port mapping +func GetHostPort(t *testing.T, port string, container *docker.Container) int { + t.Helper() + for p, bindings := range container.NetworkSettings.Ports { + if p.Port() == port { + pi, err := strconv.Atoi(bindings[0].HostPort) + require.NoError(t, err) + return pi + } + } + return 0 +} diff --git a/testhelper/docker/resource/postgres.go b/testhelper/docker/resource/postgres.go new file mode 100644 index 00000000..d183af95 --- /dev/null +++ b/testhelper/docker/resource/postgres.go @@ -0,0 +1,86 @@ +package resource + +import ( + "database/sql" + _ "encoding/json" + "fmt" + + _ "github.com/lib/pq" + "github.com/ory/dockertest/v3" + "github.com/rudderlabs/rudder-go-kit/testhelper/docker/resource/postgres" +) + +const ( + postgresDefaultDB = "jobsdb" + postgresDefaultUser = "rudder" + postgresDefaultPassword = "password" +) + +type PostgresResource struct { + DB *sql.DB + DBDsn string + Database string + Password string + User string + Host string + Port string +} + +func SetupPostgres(pool *dockertest.Pool, d cleaner, opts ...func(*postgres.Config)) (*PostgresResource, error) { + c := &postgres.Config{ + Tag: "15-alpine", + } + for _, opt := range opts { + opt(c) + } + + cmd := []string{"postgres"} + for _, opt := range c.Options { + cmd = append(cmd, "-c", opt) + } + // pulls an image, creates a container based on it and runs it + postgresContainer, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: "postgres", + Tag: c.Tag, + Env: []string{ + "POSTGRES_PASSWORD=" + postgresDefaultPassword, + "POSTGRES_DB=" + postgresDefaultDB, + "POSTGRES_USER=" + postgresDefaultUser, + }, + Cmd: cmd, + }) + if err != nil { + return nil, err + } + + d.Cleanup(func() { + if err := pool.Purge(postgresContainer); err != nil { + d.Log("Could not purge resource:", err) + } + }) + + dbDSN := fmt.Sprintf( + "postgres://%s:%s@localhost:%s/%s?sslmode=disable", + postgresDefaultUser, postgresDefaultPassword, postgresContainer.GetPort("5432/tcp"), postgresDefaultDB, + ) + var db *sql.DB + // exponential backoff-retry, because the application in the container might not be ready to accept connections yet + err = pool.Retry(func() (err error) { + if db, err = sql.Open("postgres", dbDSN); err != nil { + return err + } + return db.Ping() + }) + if err != nil { + return nil, err + } + return &PostgresResource{ + DB: db, + DBDsn: dbDSN, + Database: postgresDefaultDB, + User: postgresDefaultUser, + Password: postgresDefaultPassword, + Host: "localhost", + Port: postgresContainer.GetPort("5432/tcp"), + }, nil +} diff --git a/testhelper/docker/resource/postgres/config.go b/testhelper/docker/resource/postgres/config.go new file mode 100644 index 00000000..d1e8d12f --- /dev/null +++ b/testhelper/docker/resource/postgres/config.go @@ -0,0 +1,20 @@ +package postgres + +type Opt func(*Config) + +func WithTag(tag string) Opt { + return func(c *Config) { + c.Tag = tag + } +} + +func WithOptions(options ...string) Opt { + return func(c *Config) { + c.Options = options + } +} + +type Config struct { + Tag string + Options []string +} diff --git a/testhelper/docker/resource/pulsar.go b/testhelper/docker/resource/pulsar.go new file mode 100644 index 00000000..780d6547 --- /dev/null +++ b/testhelper/docker/resource/pulsar.go @@ -0,0 +1,74 @@ +package resource + +import ( + "bytes" + "fmt" + "runtime" + "strings" + + "github.com/ory/dockertest/v3" + "github.com/rudderlabs/rudder-go-kit/testhelper/docker/resource/pulsar" +) + +type PulsarResource struct { + URL string + AdminURL string +} + +func SetupPulsar(pool *dockertest.Pool, d cleaner, opts ...pulsar.Opt) (*PulsarResource, error) { + c := &pulsar.Config{ + Tag: "2.11.0", + } + for _, opt := range opts { + opt(c) + } + cmd := []string{"bin/pulsar", "standalone"} + + repository := "apachepulsar/pulsar" + tag := c.Tag + if runtime.GOARCH == "arm64" { // TODO: use original image when multi-arch images are supported by pulsar + repository = "atzoum/pulsar" + tag = "latest" + } + pulsarContainer, err := pool.RunWithOptions(&dockertest.RunOptions{ + Repository: repository, + Tag: tag, + Env: []string{}, + ExposedPorts: []string{"6650", "8080"}, + Cmd: cmd, + }) + if err != nil { + return nil, err + } + + d.Cleanup(func() { + if err := pool.Purge(pulsarContainer); err != nil { + d.Log("Could not purge resource:", err) + } + }) + + url := fmt.Sprintf("pulsar://localhost:%s", pulsarContainer.GetPort("6650/tcp")) + adminURL := fmt.Sprintf("http://localhost:%s", pulsarContainer.GetPort("8080/tcp")) + + if err := pool.Retry(func() (err error) { + var w bytes.Buffer + code, err := pulsarContainer.Exec([]string{"bin/pulsar-admin", "brokers", "healthcheck"}, dockertest.ExecOptions{StdOut: &w, StdErr: &w}) + if err != nil { + return err + } + if code != 0 { + return fmt.Errorf("pulsar healthcheck failed") + } + out := strings.ReplaceAll(w.String(), "\n", "") + if !strings.Contains(out, "ok") { + return fmt.Errorf("pulsar healthcheck failed") + } + return nil + }); err != nil { + return nil, err + } + return &PulsarResource{ + URL: url, + AdminURL: adminURL, + }, nil +} diff --git a/testhelper/docker/resource/pulsar/config.go b/testhelper/docker/resource/pulsar/config.go new file mode 100644 index 00000000..c5c73f0e --- /dev/null +++ b/testhelper/docker/resource/pulsar/config.go @@ -0,0 +1,13 @@ +package pulsar + +type Opt func(*Config) + +func WithTag(tag string) Opt { + return func(c *Config) { + c.Tag = tag + } +} + +type Config struct { + Tag string +} diff --git a/testhelper/docker/resource/types.go b/testhelper/docker/resource/types.go new file mode 100644 index 00000000..eec2e2e1 --- /dev/null +++ b/testhelper/docker/resource/types.go @@ -0,0 +1,10 @@ +package resource + +type logger interface { + Log(...interface{}) +} + +type cleaner interface { + Cleanup(func()) + logger +} diff --git a/testhelper/freeport.go b/testhelper/freeport.go new file mode 100644 index 00000000..13c42017 --- /dev/null +++ b/testhelper/freeport.go @@ -0,0 +1,32 @@ +package testhelper + +import ( + "sync" + + "github.com/phayes/freeport" +) + +var ( + usedPorts map[int]struct{} + usedPortsMu sync.Mutex +) + +func GetFreePort() (int, error) { + usedPortsMu.Lock() + defer usedPortsMu.Unlock() + for { + port, err := freeport.GetFreePort() + if err != nil { + return 0, err + } + + if usedPorts == nil { + usedPorts = make(map[int]struct{}) + } + if _, used := usedPorts[port]; used { + continue + } + usedPorts[port] = struct{}{} + return port, nil + } +} diff --git a/testhelper/rand/rand.go b/testhelper/rand/rand.go new file mode 100644 index 00000000..23357113 --- /dev/null +++ b/testhelper/rand/rand.go @@ -0,0 +1,57 @@ +package rand + +import ( + "math/rand" + "sync" + "time" + "unsafe" +) + +const ( + letterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + letterIdxBits = 6 // 6 bits to represent a letter index + letterIdxMask = 1<= 0; { + if remain == 0 { + cache, remain = src.Int63(), letterIdxMax + } + if idx := int(cache & letterIdxMask); idx < len(letterBytes) { + b[i] = letterBytes[idx] + i-- + } + cache >>= letterIdxBits + remain-- + } + + return *(*string)(unsafe.Pointer(&b)) // skipcq: GSC-G103 +} + +// UniqueString returns a random string that is unique +func UniqueString(n int) string { + str := String(n) + + uniqueRandomStringsMu.Lock() + defer uniqueRandomStringsMu.Unlock() + + for { + if _, ok := uniqueRandomStrings[str]; !ok { + uniqueRandomStrings[str] = struct{}{} + return str + } + str = String(n) + } +}