diff --git a/cmd/flipt/migrate.go b/cmd/flipt/migrate.go index 881c071da3..827e662887 100644 --- a/cmd/flipt/migrate.go +++ b/cmd/flipt/migrate.go @@ -4,9 +4,45 @@ import ( "fmt" "github.com/spf13/cobra" + "go.flipt.io/flipt/internal/config" "go.flipt.io/flipt/internal/storage/sql" + "go.uber.org/zap" ) +const ( + defaultConfig = "default" + analytics = "analytics" +) + +var database string + +func runMigrations(cfg *config.Config, logger *zap.Logger, database string) error { + var ( + migrator *sql.Migrator + err error + ) + + if database == analytics { + migrator, err = sql.NewAnalyticsMigrator(*cfg, logger) + if err != nil { + return err + } + } else { + migrator, err = sql.NewMigrator(*cfg, logger) + if err != nil { + return err + } + } + + defer migrator.Close() + + if err := migrator.Up(true); err != nil { + return fmt.Errorf("running migrator %w", err) + } + + return nil +} + func newMigrateCommand() *cobra.Command { cmd := &cobra.Command{ Use: "migrate", @@ -21,15 +57,16 @@ func newMigrateCommand() *cobra.Command { _ = logger.Sync() }() - migrator, err := sql.NewMigrator(*cfg, logger) - if err != nil { - return fmt.Errorf("initializing migrator %w", err) + // Run the OLTP and OLAP database migrations sequentially because of + // potential danger in DB migrations in general. + if err := runMigrations(cfg, logger, defaultConfig); err != nil { + return err } - defer migrator.Close() - - if err := migrator.Up(true); err != nil { - return fmt.Errorf("running migrator %w", err) + if database == analytics { + if err := runMigrations(cfg, logger, analytics); err != nil { + return err + } } return nil @@ -37,5 +74,6 @@ func newMigrateCommand() *cobra.Command { } cmd.Flags().StringVar(&providedConfigFile, "config", "", "path to config file") + cmd.Flags().StringVar(&database, "database", "default", "string to denote which database type to migrate") return cmd } diff --git a/config/flipt.schema.cue b/config/flipt.schema.cue index 66387eac5c..b21b6bea0d 100644 --- a/config/flipt.schema.cue +++ b/config/flipt.schema.cue @@ -10,6 +10,7 @@ import "strings" @jsonschema(schema="http://json-schema.org/draft/2019-09/schema#") version?: "1.0" | *"1.0" experimental?: #experimental + analytics: #analytics audit?: #audit authentication?: #authentication cache?: #cache @@ -82,7 +83,7 @@ import "strings" issuer?: string audiences?: [...string] } - jwks_url?: string + jwks_url?: string public_key_file?: string } } @@ -129,16 +130,16 @@ import "strings" } #cors: { - enabled?: bool | *false + enabled?: bool | *false allowed_origins?: [...] | string | *["*"] allowed_headers?: [...string] | string | *[ - "Accept", - "Authorization", - "Content-Type", - "X-CSRF-Token", - "X-Fern-Language", - "X-Fern-SDK-Name", - "X-Fern-SDK-Version", + "Accept", + "Authorization", + "Content-Type", + "X-CSRF-Token", + "X-Fern-Language", + "X-Fern-SDK-Name", + "X-Fern-SDK-Version", ] } @@ -228,7 +229,7 @@ import "strings" }) _#lower: ["debug", "error", "fatal", "info", "panic", "trace", "warn"] - _#all: _#lower + [for x in _#lower {strings.ToUpper(x)}] + _#all: _#lower + [ for x in _#lower {strings.ToUpper(x)}] #log: { file?: string encoding?: *"console" | "json" @@ -250,15 +251,15 @@ import "strings" } #server: { - protocol?: *"http" | "https" - host?: string | *"0.0.0.0" - https_port?: int | *443 - http_port?: int | *8080 - grpc_port?: int | *9000 - cert_file?: string - cert_key?: string + protocol?: *"http" | "https" + host?: string | *"0.0.0.0" + https_port?: int | *443 + http_port?: int | *8080 + grpc_port?: int | *9000 + cert_file?: string + cert_key?: string grpc_conn_max_idle_time?: =~#duration - grpc_conn_max_age?: =~#duration + grpc_conn_max_age?: =~#duration grpc_conn_max_age_grace?: =~#duration } @@ -312,6 +313,19 @@ import "strings" events?: [...string] | *["*:*"] } + #analytics: { + storage?: { + clickhouse?: { + enabled?: bool | *false + url?: string | *"" + } + } + buffer?: { + capacity?: int + flush_period?: string | *"2m" + } + } + #experimental: {} #duration: "^([0-9]+(ns|us|µs|ms|s|m|h))+$" diff --git a/config/flipt.schema.json b/config/flipt.schema.json index 783f7df0fe..766833e84c 100644 --- a/config/flipt.schema.json +++ b/config/flipt.schema.json @@ -1076,6 +1076,48 @@ } }, "title": "Audit" + }, + "analytics": { + "type": "object", + "additionalProperties": false, + "properties": { + "storage": { + "type": "object", + "additionalProperties": false, + "properties": { + "clickhouse": { + "type": "object", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "additionalProperties": false, + "default": false + }, + "url": { + "type": "string", + "default": "" + } + }, + "title": "Clickhouse" + } + } + }, + "buffer": { + "type": "object", + "additionalProperties": false, + "properties": { + "capacity": { + "type": "integer" + }, + "flush_period": { + "type": "string", + "default": "10s" + } + } + } + }, + "title": "Analytics" } } } diff --git a/config/migrations/clickhouse/0_initial_clickhouse.up.sql b/config/migrations/clickhouse/0_initial_clickhouse.up.sql new file mode 100644 index 0000000000..decbc90c19 --- /dev/null +++ b/config/migrations/clickhouse/0_initial_clickhouse.up.sql @@ -0,0 +1,5 @@ +CREATE TABLE IF NOT EXISTS flipt_counter_analytics ( + `timestamp` DateTime('UTC'), `analytic_name` String, `namespace_key` String, `flag_key` String, `flag_type` Enum('VARIANT_FLAG_TYPE' = 1, 'BOOLEAN_FLAG_TYPE' = 2), `reason` Enum('UNKNOWN_EVALUATION_REASON' = 1, 'FLAG_DISABLED_EVALUATION_REASON' = 2, 'MATCH_EVALUATION_REASON' = 3, 'DEFAULT_EVALUATION_REASON' = 4), `match` Nullable(Bool), `evaluation_value` Nullable(String), `value` UInt32 +) Engine = MergeTree +ORDER BY timestamp +TTL timestamp + INTERVAL 1 WEEK; diff --git a/go.mod b/go.mod index 0af293300a..783b09ba84 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( cuelang.org/go v0.7.0 github.com/AlecAivazis/survey/v2 v2.3.7 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.2.1 + github.com/ClickHouse/clickhouse-go/v2 v2.17.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/Masterminds/squirrel v1.5.4 github.com/XSAM/otelsql v0.27.0 @@ -99,9 +100,11 @@ require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect github.com/Azure/go-autorest/autorest/to v0.4.0 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 // indirect + github.com/ClickHouse/ch-go v0.61.2 // indirect github.com/Microsoft/go-winio v0.6.1 // indirect github.com/Microsoft/hcsshim v0.11.4 // indirect github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 // indirect + github.com/andybalholm/brotli v1.1.0 // indirect github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 // indirect github.com/aws/aws-sdk-go v1.49.6 // indirect github.com/aws/aws-sdk-go-v2 v1.24.1 // indirect @@ -139,6 +142,8 @@ require ( github.com/docker/go-units v0.5.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect + github.com/go-faster/city v1.0.1 // indirect + github.com/go-faster/errors v0.7.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-logr/logr v1.4.1 // indirect github.com/go-logr/stdr v1.2.2 // indirect @@ -149,7 +154,7 @@ require ( github.com/golang/protobuf v1.5.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/s2a-go v0.1.7 // indirect - github.com/google/uuid v1.5.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.5.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect github.com/googleapis/gax-go/v2 v2.12.0 // indirect @@ -167,7 +172,7 @@ require ( github.com/jmespath/go-jmespath v0.4.0 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/klauspost/compress v1.17.0 // indirect + github.com/klauspost/compress v1.17.5 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect @@ -185,7 +190,9 @@ require ( github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect github.com/opencontainers/runc v1.1.12 // indirect github.com/openzipkin/zipkin-go v0.4.2 // indirect + github.com/paulmach/orb v0.11.1 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect + github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pjbgf/sha1cd v0.3.0 // indirect github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -197,10 +204,12 @@ require ( github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sagikazarmark/locafero v0.4.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect + github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/backo-go v1.0.0 // indirect github.com/sergi/go-diff v1.3.1 // indirect github.com/shirou/gopsutil/v3 v3.23.11 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/shopspring/decimal v1.3.1 // indirect github.com/sirupsen/logrus v1.9.3 // indirect github.com/skeema/knownhosts v1.2.1 // indirect github.com/sourcegraph/conc v0.3.0 // indirect diff --git a/go.sum b/go.sum index b7adaeb9d8..46e7437a78 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,12 @@ github.com/Azure/go-autorest/autorest/to v0.4.0/go.mod h1:fE8iZBn7LQR7zH/9XU2NcP github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0 h1:hVeq+yCyUi+MsoO/CU95yqCIcdzra5ovzk8Q2BBpV2M= github.com/AzureAD/microsoft-authentication-library-for-go v1.2.0/go.mod h1:wP83P5OoQ5p6ip3ScPr0BAq0BvuPAvacpEuSzyouqAI= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/ClickHouse/ch-go v0.61.2 h1:8+8eKO2VgxoRa0yLJpWwkqJxi/jrtP5Z+J6eZdPfwdc= +github.com/ClickHouse/ch-go v0.61.2/go.mod h1:ZSVIE1A7mGJNcJeBvVF1v5bo12n0Wmnw30RhnPCpLzg= +github.com/ClickHouse/clickhouse-go v1.5.4 h1:cKjXeYLNWVJIx2J1K6H2CqyRmfwVJVY1OV1coaaFcI0= +github.com/ClickHouse/clickhouse-go v1.5.4/go.mod h1:EaI/sW7Azgz9UATzd5ZdZHRUhHgv5+JMS9NSr2smCJI= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1 h1:ZCmAYWpu75IyEi7+Yrs/uaAjiCGY5wfW5kXo64exkX4= +github.com/ClickHouse/clickhouse-go/v2 v2.17.1/go.mod h1:rkGTvFDTLqLIm0ma+13xmcCfr/08Gvs7KmFt1tgiWHQ= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= @@ -54,6 +60,8 @@ github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371 h1:kkhsdkhsCv github.com/ProtonMail/go-crypto v0.0.0-20230828082145-3c4c8a2d2371/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/XSAM/otelsql v0.27.0 h1:i9xtxtdcqXV768a5C6SoT/RkG+ue3JTOgkYInzlTOqs= github.com/XSAM/otelsql v0.27.0/go.mod h1:0mFB3TvLa7NCuhm/2nU7/b2wEtsczkj8Rey8ygO7V+A= +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -126,6 +134,8 @@ github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDk github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= +github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg= +github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101 h1:7To3pQ+pZo0i3dsWEbinPNFs5gPSBOsJtx3wTT94VBY= github.com/cncf/xds/go v0.0.0-20231109132714-523115ebc101/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= @@ -203,6 +213,10 @@ github.com/go-chi/chi/v5 v5.0.11 h1:BnpYbFZ3T3S1WMpD79r7R5ThWX40TaFB7L31Y8xqSwA= github.com/go-chi/chi/v5 v5.0.11/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= github.com/go-chi/cors v1.2.1 h1:xEC8UT3Rlp2QuWNEr4Fs/c2EAGVKBwy/1vHx3bppil4= github.com/go-chi/cors v1.2.1/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= +github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= +github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw= +github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg= +github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= @@ -277,6 +291,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 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= @@ -310,8 +325,8 @@ github.com/google/s2a-go v0.1.7 h1:60BLSyTrOV4/haCDW4zb1guZItoSq8foHCXrAnjBo/o= github.com/google/s2a-go v0.1.7/go.mod h1:50CgR4k1jNlWBu4UfS4AcfhVe1r6pdZPygJ3R8F0Qdw= github.com/google/subcommands v1.0.1/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= -github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.5.0 h1:I7ELFeVBr3yfPIcc8+MWvrjk+3VjbcSzoXm3JVa+jD8= github.com/google/wire v0.5.0/go.mod h1:ngWDr9Qvq3yZA10YrxfyGELY/AFWGVpy9c1LTRi1EoU= github.com/googleapis/enterprise-certificate-proxy v0.3.2 h1:Vie5ybvEvT75RniqhfFxPRy3Bf7vr3h0cechB90XaQs= @@ -423,8 +438,8 @@ github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.10.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= -github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.5 h1:d4vBd+7CHydUqpFBgUEKkSdtSugf9YFmSkvUYPquI5E= +github.com/klauspost/compress v1.17.5/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -499,6 +514,7 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto= @@ -543,8 +559,13 @@ github.com/openzipkin/zipkin-go v0.4.2 h1:zjqfqHjUpPmB3c1GlCvvgsM1G4LkvqQbBDueDO github.com/openzipkin/zipkin-go v0.4.2/go.mod h1:ZeVkFjuuBiSy13y8vpSDCjMi9GoI3hPpCJSBx/EYFhY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= +github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU= +github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= +github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4= github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc= +github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= +github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= @@ -588,6 +609,8 @@ github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWR github.com/santhosh-tekuri/jsonschema/v5 v5.3.1 h1:lZUw3E0/J3roVtGQ+SCrUrg3ON6NgVqpn3+iol9aGu4= github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/backo-go v1.0.0 h1:kbOAtGJY2DqOR0jfRkYEorx/b18RgtepGtY3+Cpe6qA= github.com/segmentio/backo-go v1.0.0/go.mod h1:kJ9mm9YmoWSkk+oQ+5Cj8DEoRCX2JT6As4kEtIIOp1M= github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= @@ -601,6 +624,8 @@ github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnj github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= +github.com/shopspring/decimal v1.3.1 h1:2Usl1nmF/WZucqkFZhnfFYxxxu8LG21F6nPQBE5gKV8= +github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= @@ -642,6 +667,7 @@ github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/testcontainers/testcontainers-go v0.27.0 h1:IeIrJN4twonTDuMuBNQdKZ+K97yd7VrmNGu+lDpYcDk= github.com/testcontainers/testcontainers-go v0.27.0/go.mod h1:+HgYZcd17GshBUZv9b+jKFJ198heWPQq3KQIp2+N+7U= +github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= @@ -658,6 +684,9 @@ github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAh github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= +github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= @@ -669,6 +698,7 @@ github.com/xo/dburl v0.20.2 h1:59zqIzahtfQ/X9E6fyp1ziHwjYEFy65opjpyQPmZC7w= github.com/xo/dburl v0.20.2/go.mod h1:B7/G9FGungw6ighV8xJNwWYQPMfn3gsi2sn5SE8Bzco= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c h1:3lbZUMbMiGUW/LMkfsEABsc5zNT9+b1CvsJx47JzJ8g= github.com/xtgo/uuid v0.0.0-20140804021211-a0b114877d4c/go.mod h1:UrdRz5enIKZ63MEE3IF9l2/ebyx59GyGgPi+tICQdmM= +github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yquansah/libsql-client-go v0.0.0-20231017144447-34b2f2f84292 h1:LzG7bJfKeUfQWZZ5OjDVCR5q1Pq4iuPtsMPMF3OeyYs= github.com/yquansah/libsql-client-go v0.0.0-20231017144447-34b2f2f84292/go.mod h1:T+1lRvREkstNW7bmF1PTiDhV6hji0mrlfZkZuk/UPhw= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -678,6 +708,7 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw= github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q= +go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.47.0 h1:UNQQKPfTDe1J81ViolILjTKPr9WetKW6uei2hFgJmFs= @@ -951,6 +982,7 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD 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.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= diff --git a/go.work.sum b/go.work.sum index 3aad77a0c7..d5c8744011 100644 --- a/go.work.sum +++ b/go.work.sum @@ -586,8 +586,6 @@ github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6D github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj7ug5D7I/orNUA= github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= -github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58 h1:F1EaeKL/ta07PY/k9Os/UFtwERei2/XzGemhpGnBKNg= -github.com/cloudflare/golz4 v0.0.0-20150217214814-ef862a3cdc58/go.mod h1:EOBUe0h4xcZ5GoxqC5SDxFQ8gwyZPKQoEzownBlhI80= github.com/cncf/udpa/go v0.0.0-20210930031921-04548b0d99d4/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe/go.mod h1:6pvJx4me5XPnfI9Z40ddWsdw2W/uZgQLFXToKeRcDiI= @@ -596,6 +594,7 @@ github.com/cncf/xds/go v0.0.0-20210805033703-aa0b78936158/go.mod h1:eXthEFrGJvWH github.com/cncf/xds/go v0.0.0-20210922020428-25de7278fc84/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211001041855-01bcc9b48dfe/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cncf/xds/go v0.0.0-20211011173535-cb28da3451f1/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= +github.com/cncf/xds/go v0.0.0-20230607035331-e9ce68804cb4/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/apd/v2 v2.0.2 h1:weh8u7Cneje73dDh+2tEVLUvyBc89iwepWCD8b8034E= github.com/cockroachdb/apd/v2 v2.0.2/go.mod h1:DDxRlzC2lo3/vSlmSoS7JkqbbrARPuFOGr0B9pvN3Gw= @@ -820,6 +819,7 @@ github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= github.com/distribution/distribution/v3 v3.0.0-20220526142353-ffbd94cbe269 h1:hbCT8ZPPMqefiAWD2ZKjn7ypokIGViTvBBg/ExLSdCk= github.com/distribution/distribution/v3 v3.0.0-20220526142353-ffbd94cbe269/go.mod h1:28YO/VJk9/64+sTGNuYaBjWxrXTPrj0C0XmgTIOjxX4= +github.com/dmarkham/enumer v1.5.9/go.mod h1:e4VILe2b1nYK3JKJpRmNdl5xbDQvELc6tQ8b+GsGk6E= github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E= github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v20.10.17+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= @@ -851,6 +851,7 @@ github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3 github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v1.0.0 h1:VSnTsYCnlFHaM2/igO1h6X3HA71jcobQuxemgkq4zYo= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dvsekhvalnov/jose2go v1.5.0 h1:3j8ya4Z4kMCwT5nXIKFSV84YS+HdqSSO0VsTQxaLAeM= github.com/dvsekhvalnov/jose2go v1.5.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/eapache/go-resiliency v1.3.0 h1:RRL0nge+cWGlxXbUzJ7yMcq6w2XBEr19dCN6HECGaT0= @@ -1158,6 +1159,7 @@ github.com/jackc/pgx/v4 v4.18.1/go.mod h1:FydWkUyadDmdNH/mHnGob881GawxeEm7TcMCzk github.com/jackc/pgx/v5 v5.3.1 h1:Fcr8QJ1ZeLi5zsPZqQeUZhNhxfkkKBOgJuYkJHoBOtU= github.com/jackc/pgx/v5 v5.3.1/go.mod h1:t3JDKnCBlYIc0ewLF0Q7B8MXmoIaBOZj/ic7iHozM/8= github.com/jackc/puddle v1.1.3 h1:JnPg/5Q9xVJGfjsO5CPUOjnJps1JaRUm8I9FXVCFK94= +github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs= github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo= @@ -1202,6 +1204,7 @@ github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYs github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs= github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= github.com/klauspost/compress v1.16.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.0.4/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= @@ -1300,6 +1303,7 @@ github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:F github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ= github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f/go.mod h1:OkQIRizQZAeMln+1tSwduZz7+Af5oFlKirV/MSYes2A= +github.com/mkevac/debugcharts v0.0.0-20191222103121-ae1c48aa8615/go.mod h1:Ad7oeElCZqA1Ufj0U9/liOF4BtVepxRcTvr2ey7zTvM= github.com/mmcloughlin/avo v0.5.0 h1:nAco9/aI9Lg2kiuROBY6BhCI/z0t5jEvJfjWbL8qXLU= github.com/mmcloughlin/avo v0.5.0/go.mod h1:ChHFdoV7ql95Wi7vuq2YT1bwCJqiWdZrQ1im3VujLYM= github.com/mndrix/tap-go v0.0.0-20171203230836-629fa407e90b h1:Ga1nclDSe8gOw37MVLMhfu2QKWtD6gvtQ298zsKVh8g= @@ -1423,6 +1427,7 @@ github.com/package-url/packageurl-go v0.1.1-0.20220428063043-89078438f170/go.mod github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= +github.com/pascaldekloe/name v1.0.1/go.mod h1:Z//MfYJnH4jVpQ9wkclwu2I2MkHmXTlT9wR5UZScttM= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrapLU/GW4pbc= github.com/pelletier/go-toml v1.9.3/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= @@ -1538,6 +1543,8 @@ github.com/serialx/hashring v0.0.0-20190422032157-8b2912629002 h1:ka9QPuQg2u4LGi github.com/serialx/hashring v0.0.0-20190422032157-8b2912629002/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e h1:MZM7FHLqUHYI0Y/mQAt3d2aYa0SiNms/hFqC9qJYolM= github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041 h1:llrF3Fs4018ePo4+G/HV/uQUqEI1HMDjCeOf2V6puPc= @@ -1659,11 +1666,8 @@ github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcY github.com/xanzy/go-gitlab v0.15.0 h1:rWtwKTgEnXyNUGrOArN7yyc3THRkpYcKXIXia9abywQ= github.com/xanzy/go-gitlab v0.15.0/go.mod h1:8zdQa/ri1dfn8eS3Ir1SyfvOKlw7WBJ8DVThkpGiXrs= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= -github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.1.1 h1:VOMT+81stJgXW3CpHyqHN3AXDYIMsx56mEFrB37Mb/E= -github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g= github.com/xdg-go/stringprep v1.0.3 h1:kdwGpVNwPFtjs98xCGkHjQtGKh86rDcRZN17QEMCOIs= -github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8= github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf86TLmAcydyeJq5YvxkGPE2fm/u4myDekKRoLuqhs= github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc= github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= @@ -1680,7 +1684,6 @@ github.com/yashtewari/glob-intersection v0.1.0/go.mod h1:LK7pIC3piUjovexikBbJ26Y github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945 h1:6Ju8pZBYFTN9FaV/JvNBiIHcsgEmP4z4laciqjfjY8E= github.com/yhat/scrape v0.0.0-20161128144610-24b7890b0945/go.mod h1:4vRFPPNYllgCacoj+0FoKOjTW68rUhEfqPLiEJaK2w8= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d h1:splanxYIlg+5LfHAM6xpdFEAYOk8iySO56hMFq6uLyA= -github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43 h1:+lm10QQTNSBd8DVTNGHx7o/IKu9HYDvLMffDhbyLccI= github.com/yvasiyarov/go-metrics v0.0.0-20140926110328-57bccd1ccd43/go.mod h1:aX5oPXxHm3bOH+xeAttToC8pqch2ScQN/JoXYupl6xs= @@ -1976,6 +1979,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20230822172742-b8732ec3820d/go. google.golang.org/genproto/googleapis/api v0.0.0-20231002182017-d307bd883b97/go.mod h1:iargEX0SFPm3xcfMI0d1domjg0ZF4Aa0p2awqyxhvF0= google.golang.org/genproto/googleapis/api v0.0.0-20231016165738-49dd2c1f3d0b/go.mod h1:IBQ646DjkDkvUIsVq/cc03FUFQ9wbZu7yE396YcL870= google.golang.org/genproto/googleapis/api v0.0.0-20231211222908-989df2bf70f3/go.mod h1:k2dtGpRrbsSyKcNPKKI5sstZkrNCZwpU/ns96JoHbGg= +google.golang.org/genproto/googleapis/api v0.0.0-20240102182953-50ed04b92917/go.mod h1:CmlNWB9lSezaYELKS5Ym1r44VrrbPUa7JTvw+6MbpJ0= google.golang.org/genproto/googleapis/bytestream v0.0.0-20231120223509-83a465c0220f h1:hL+1ptbhFoeL1HcROQ8OGXaqH0jYRRibgWQWco0/Ugc= google.golang.org/genproto/googleapis/bytestream v0.0.0-20231120223509-83a465c0220f/go.mod h1:iIgEblxoG4klcXsG0d9cpoxJ4xndv6+1FkDROCHhPRI= google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= @@ -1986,6 +1990,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20231030173426-d783a09b4405/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20231120223509-83a465c0220f/go.mod h1:L9KNLi232K1/xB6f7AlSX692koaRnKaWSR0stBki0Yc= google.golang.org/genproto/googleapis/rpc v0.0.0-20231211222908-989df2bf70f3/go.mod h1:eJVxU6o+4G1PSczBr85xmyvSNYAKvAYgkub40YGomFM= google.golang.org/genproto/googleapis/rpc v0.0.0-20231212172506-995d672761c0/go.mod h1:FUoWkonphQm3RhTS+kOEhF8h0iDpm4tdXolVCeZ9KKA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240102182953-50ed04b92917/go.mod h1:xtjpI3tXFPP051KaWnhvxkiubL/6dJ18vLVf7q2pTOU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240116215550-a9fa1716bcac/go.mod h1:daQN87bsDqDoe316QbbvX60nMoJQa4r6Ds0ZuoAe5yA= google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.21.0/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= diff --git a/internal/cmd/grpc.go b/internal/cmd/grpc.go index a672513796..04e4dbd8a2 100644 --- a/internal/cmd/grpc.go +++ b/internal/cmd/grpc.go @@ -20,6 +20,8 @@ import ( "go.flipt.io/flipt/internal/containers" "go.flipt.io/flipt/internal/info" fliptserver "go.flipt.io/flipt/internal/server" + analytics "go.flipt.io/flipt/internal/server/analytics" + "go.flipt.io/flipt/internal/server/analytics/clickhouse" "go.flipt.io/flipt/internal/server/audit" "go.flipt.io/flipt/internal/server/audit/logfile" "go.flipt.io/flipt/internal/server/audit/template" @@ -265,6 +267,29 @@ func NewGRPCServer( server.onShutdown(authShutdown) + if cfg.Analytics.Enabled() { + client, err := clickhouse.New(logger, cfg, forceMigrate) + if err != nil { + return nil, fmt.Errorf("connecting to clickhouse: %w", err) + } + + analyticssrv := analytics.New(logger, client) + register.Add(analyticssrv) + + analyticsExporter := analytics.NewAnalyticsSinkSpanExporter(logger, client) + tracingProvider.RegisterSpanProcessor( + tracesdk.NewBatchSpanProcessor( + analyticsExporter, + tracesdk.WithBatchTimeout(cfg.Analytics.Buffer.FlushPeriod)), + ) + + logger.Debug("analytics enabled", zap.String("database", client.String()), zap.String("flush_period", cfg.Analytics.Buffer.FlushPeriod.String())) + + server.onShutdown(func(ctx context.Context) error { + return analyticsExporter.Shutdown(ctx) + }) + } + // initialize servers register.Add(fliptsrv) register.Add(metasrv) @@ -284,7 +309,7 @@ func NewGRPCServer( append(authInterceptors, middlewaregrpc.ErrorUnaryInterceptor, middlewaregrpc.ValidationUnaryInterceptor, - middlewaregrpc.EvaluationUnaryInterceptor, + middlewaregrpc.EvaluationUnaryInterceptor(cfg.Analytics.Enabled()), )..., ) diff --git a/internal/cmd/http.go b/internal/cmd/http.go index 682d489b51..67f6154e66 100644 --- a/internal/cmd/http.go +++ b/internal/cmd/http.go @@ -21,6 +21,7 @@ import ( "go.flipt.io/flipt/internal/gateway" "go.flipt.io/flipt/internal/info" "go.flipt.io/flipt/rpc/flipt" + "go.flipt.io/flipt/rpc/flipt/analytics" "go.flipt.io/flipt/rpc/flipt/evaluation" "go.flipt.io/flipt/rpc/flipt/meta" "go.flipt.io/flipt/ui" @@ -60,6 +61,7 @@ func NewHTTPServer( api = gateway.NewGatewayServeMux(logger) evaluateAPI = gateway.NewGatewayServeMux(logger) evaluateDataAPI = gateway.NewGatewayServeMux(logger) + analyticsAPI = gateway.NewGatewayServeMux(logger) httpPort = cfg.Server.HTTPPort ) @@ -79,6 +81,10 @@ func NewHTTPServer( return nil, fmt.Errorf("registering grpc gateway: %w", err) } + if err := analytics.RegisterAnalyticsServiceHandler(ctx, analyticsAPI, conn); err != nil { + return nil, fmt.Errorf("registering grpc gateway: %w", err) + } + if cfg.Cors.Enabled { cors := cors.New(cors.Options{ AllowedOrigins: cfg.Cors.AllowedOrigins, @@ -145,6 +151,7 @@ func NewHTTPServer( r.Mount("/api/v1", api) r.Mount("/evaluate/v1", evaluateAPI) + r.Mount("/internal/v1/analytics", analyticsAPI) r.Mount("/internal/v1", evaluateDataAPI) // mount all authentication related HTTP components diff --git a/internal/config/analytics.go b/internal/config/analytics.go new file mode 100644 index 0000000000..baba99fc2c --- /dev/null +++ b/internal/config/analytics.go @@ -0,0 +1,70 @@ +package config + +import ( + "errors" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/spf13/viper" +) + +// AnalyticsConfig defines the configuration for various mechanisms for +// reporting and querying analytical data for Flipt. +type AnalyticsConfig struct { + Storage AnalyticsStorageConfig `json:"storage,omitempty" mapstructure:"storage" yaml:"storage,omitempty"` + Buffer BufferConfig `json:"buffer,omitempty" mapstructure:"buffer" yaml:"buffer,omitempty"` +} + +// AnalyticsStorageConfig is a collection of configuration option for storage backends. +type AnalyticsStorageConfig struct { + Clickhouse ClickhouseConfig `json:"clickhouse,omitempty" mapstructure:"clickhouse" yaml:"clickhouse,omitempty"` +} + +// ClickhouseConfig defines the connection details for connecting Flipt to Clickhouse. +type ClickhouseConfig struct { + Enabled bool `json:"enabled,omitempty" mapstructure:"enabled" yaml:"enabled,omitempty"` + URL string `json:"-" mapstructure:"url" yaml:"url,omitempty"` +} + +func (a *AnalyticsConfig) Enabled() bool { + return a.Storage.Clickhouse.Enabled +} + +// Options returns the connection option details for Clickhouse. +func (c *ClickhouseConfig) Options() (*clickhouse.Options, error) { + options, err := clickhouse.ParseDSN(c.URL) + if err != nil { + return nil, err + } + + return options, nil +} + +//nolint:unparam +func (a *AnalyticsConfig) setDefaults(v *viper.Viper) error { + v.SetDefault("analytics", map[string]any{ + "storage": map[string]any{ + "clickhouse": map[string]any{ + "enabled": "false", + "url": "", + }, + }, + "buffer": map[string]any{ + "flush_period": "10s", + }, + }) + + return nil +} + +func (a *AnalyticsConfig) validate() error { + if a.Storage.Clickhouse.Enabled && a.Storage.Clickhouse.URL == "" { + return errors.New("clickhouse url not provided") + } + + if a.Buffer.FlushPeriod < time.Second*10 { + return errors.New("flush period below 10 seconds") + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 2dbb101a05..4e7edaab9f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -52,6 +52,7 @@ type Config struct { Experimental ExperimentalConfig `json:"experimental,omitempty" mapstructure:"experimental" yaml:"experimental,omitempty"` Log LogConfig `json:"log,omitempty" mapstructure:"log" yaml:"log,omitempty"` Meta MetaConfig `json:"meta,omitempty" mapstructure:"meta" yaml:"meta,omitempty"` + Analytics AnalyticsConfig `json:"analytics,omitempty" mapstructure:"analytics" yaml:"analytics,omitempty"` Server ServerConfig `json:"server,omitempty" mapstructure:"server" yaml:"server,omitempty"` Storage StorageConfig `json:"storage,omitempty" mapstructure:"storage" yaml:"storage,omitempty"` Tracing TracingConfig `json:"tracing,omitempty" mapstructure:"tracing" yaml:"tracing,omitempty"` @@ -554,5 +555,11 @@ func Default() *Config { }, Events: []string{"*:*"}, }, + + Analytics: AnalyticsConfig{ + Buffer: BufferConfig{ + FlushPeriod: 10 * time.Second, + }, + }, } } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 962cf6c20a..d38a01f9a5 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -911,6 +911,16 @@ func TestLoad(t *testing.T) { return cfg }, }, + { + name: "clickhouse enabled but no URL set", + path: "./testdata/analytics/invalid_clickhouse_configuration_empty_url.yml", + wantErr: errors.New("clickhouse url not provided"), + }, + { + name: "analytics flush period too low", + path: "./testdata/analytics/invalid_buffer_configuration_flush_period.yml", + wantErr: errors.New("flush period below 10 seconds"), + }, } for _, tt := range tests { diff --git a/internal/config/testdata/analytics/invalid_buffer_configuration_flush_period.yml b/internal/config/testdata/analytics/invalid_buffer_configuration_flush_period.yml new file mode 100644 index 0000000000..2150562391 --- /dev/null +++ b/internal/config/testdata/analytics/invalid_buffer_configuration_flush_period.yml @@ -0,0 +1,3 @@ +analytics: + buffer: + flush_period: "5s" diff --git a/internal/config/testdata/analytics/invalid_clickhouse_configuration_empty_url.yml b/internal/config/testdata/analytics/invalid_clickhouse_configuration_empty_url.yml new file mode 100644 index 0000000000..374d2619fa --- /dev/null +++ b/internal/config/testdata/analytics/invalid_clickhouse_configuration_empty_url.yml @@ -0,0 +1,4 @@ +analytics: + storage: + clickhouse: + enabled: true diff --git a/internal/config/testdata/marshal/yaml/default.yml b/internal/config/testdata/marshal/yaml/default.yml index 0332cef4b8..88663b26ec 100644 --- a/internal/config/testdata/marshal/yaml/default.yml +++ b/internal/config/testdata/marshal/yaml/default.yml @@ -4,6 +4,9 @@ log: grpc_level: ERROR ui: default_theme: system +analytics: + buffer: + flush_period: "10s" cors: enabled: false allowed_origins: diff --git a/internal/server/analytics/analytics.go b/internal/server/analytics/analytics.go new file mode 100644 index 0000000000..bfd41fa21b --- /dev/null +++ b/internal/server/analytics/analytics.go @@ -0,0 +1,20 @@ +package analytics + +import ( + "context" + + "go.flipt.io/flipt/rpc/flipt/analytics" +) + +// GetFlagEvaluationsCount is the implemented RPC method that will return aggregated flag evaluation counts. +func (s *Server) GetFlagEvaluationsCount(ctx context.Context, req *analytics.GetFlagEvaluationsCountRequest) (*analytics.GetFlagEvaluationsCountResponse, error) { + timestamps, values, err := s.client.GetFlagEvaluationsCount(ctx, req) + if err != nil { + return nil, err + } + + return &analytics.GetFlagEvaluationsCountResponse{ + Timestamps: timestamps, + Values: values, + }, nil +} diff --git a/internal/server/analytics/analytics_test.go b/internal/server/analytics/analytics_test.go new file mode 100644 index 0000000000..04addc28b8 --- /dev/null +++ b/internal/server/analytics/analytics_test.go @@ -0,0 +1,99 @@ +package analytics_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + panalytics "go.flipt.io/flipt/internal/server/analytics" + "go.flipt.io/flipt/internal/server/analytics/clickhouse" + analyticstesting "go.flipt.io/flipt/internal/server/analytics/testing" + "go.flipt.io/flipt/rpc/flipt/analytics" +) + +type AnalyticsDBTestSuite struct { + suite.Suite + client *clickhouse.Client +} + +func TestAnalyticsDBTestSuite(t *testing.T) { + if os.Getenv("FLIPT_ANALYTICS_DATABASE_PROTOCOL") == "" { + t.Skip("please provide an analytics database protocol to run tests") + } + suite.Run(t, new(AnalyticsDBTestSuite)) +} + +func (a *AnalyticsDBTestSuite) SetupSuite() { + setup := func() error { + db, err := analyticstesting.Open() + if err != nil { + return err + } + + c := &clickhouse.Client{ + Conn: db.DB, + } + + a.client = c + + return nil + } + + a.Require().NoError(setup()) +} + +func (a *AnalyticsDBTestSuite) TestAnalyticsMutationAndQuery() { + t := a.T() + + now := time.Now().UTC() + for i := 0; i < 5; i++ { + err := a.client.IncrementFlagEvaluationCounts(context.TODO(), []*panalytics.EvaluationResponse{ + { + NamespaceKey: "default", + FlagKey: "flag1", + Reason: "MATCH_EVALUATION_REASON", + Timestamp: now, + }, + { + NamespaceKey: "default", + FlagKey: "flag1", + Reason: "MATCH_EVALUATION_REASON", + Timestamp: now, + }, + { + NamespaceKey: "default", + FlagKey: "flag1", + Reason: "MATCH_EVALUATION_REASON", + Timestamp: now, + }, + { + NamespaceKey: "default", + FlagKey: "flag1", + Reason: "MATCH_EVALUATION_REASON", + Timestamp: now, + }, + { + NamespaceKey: "default", + FlagKey: "flag1", + Reason: "MATCH_EVALUATION_REASON", + Timestamp: now, + }, + }) + require.Nil(t, err) + } + + _, values, err := a.client.GetFlagEvaluationsCount(context.TODO(), &analytics.GetFlagEvaluationsCountRequest{ + NamespaceKey: "default", + FlagKey: "flag1", + From: now.Add(-time.Hour).Format(time.DateTime), + To: now.Format(time.DateTime), + }) + require.Nil(t, err) + + assert.Len(t, values, 1) + assert.Equal(t, values[0], float32(25)) +} diff --git a/internal/server/analytics/clickhouse/client.go b/internal/server/analytics/clickhouse/client.go new file mode 100644 index 0000000000..1251515796 --- /dev/null +++ b/internal/server/analytics/clickhouse/client.go @@ -0,0 +1,186 @@ +package clickhouse + +import ( + "context" + "database/sql" + "fmt" + "sync" + "time" + + "github.com/ClickHouse/clickhouse-go/v2" + "go.flipt.io/flipt/internal/config" + fliptsql "go.flipt.io/flipt/internal/storage/sql" + "go.flipt.io/flipt/rpc/flipt/analytics" + "go.uber.org/zap" +) + +// Step defines the value and interval name of the time windows +// for the Clickhouse query. +type Step struct { + intervalValue int + intervalStep string +} + +var dbOnce sync.Once + +const ( + counterAnalyticsTable = "flipt_counter_analytics" + counterAnalyticsName = "flag_evaluation_count" +) + +type Client struct { + Conn *sql.DB +} + +// New constructs a new clickhouse client that conforms to the analytics.Client contract. +func New(logger *zap.Logger, cfg *config.Config, forceMigrate bool) (*Client, error) { + var ( + conn *sql.DB + clickhouseErr error + ) + + dbOnce.Do(func() { + err := runMigrations(logger, cfg, forceMigrate) + if err != nil { + clickhouseErr = err + return + } + + connection, err := connect(cfg.Analytics.Storage.Clickhouse) + if err != nil { + clickhouseErr = err + return + } + + conn = connection + }) + + if clickhouseErr != nil { + return nil, clickhouseErr + } + + return &Client{Conn: conn}, nil +} + +// runMigrations will run migrations for clickhouse if enabled from the client. +func runMigrations(logger *zap.Logger, cfg *config.Config, forceMigrate bool) error { + m, err := fliptsql.NewAnalyticsMigrator(*cfg, logger) + if err != nil { + return err + } + + if err := m.Up(forceMigrate); err != nil { + return err + } + + return nil +} + +func connect(clickhouseConfig config.ClickhouseConfig) (*sql.DB, error) { + options, err := clickhouseConfig.Options() + if err != nil { + return nil, err + } + + conn := clickhouse.OpenDB(options) + + if err := conn.Ping(); err != nil { + return nil, err + } + + return conn, nil +} + +func (c *Client) GetFlagEvaluationsCount(ctx context.Context, req *analytics.GetFlagEvaluationsCountRequest) ([]string, []float32, error) { + fromTime, err := time.Parse(time.DateTime, req.From) + if err != nil { + return nil, nil, err + } + + toTime, err := time.Parse(time.DateTime, req.To) + if err != nil { + return nil, nil, err + } + + duration := toTime.Sub(fromTime) + + step := getStepFromDuration(duration) + + rows, err := c.Conn.QueryContext(ctx, fmt.Sprintf(` + SELECT + sum(value) AS value, + toStartOfInterval(timestamp, INTERVAL %d %s) AS timestamp + FROM %s WHERE namespace_key = ? AND flag_key = ? AND timestamp >= toDateTime('%s', 'UTC') AND timestamp < toDateTime('%s', 'UTC') + GROUP BY timestamp + ORDER BY timestamp ASC WITH FILL FROM toDateTime('%s', 'UTC') TO toDateTime('%s', 'UTC') STEP INTERVAL %d %s + `, + step.intervalValue, + step.intervalStep, + counterAnalyticsTable, + fromTime.UTC().Format(time.DateTime), + toTime.UTC().Format(time.DateTime), + fromTime.UTC().Format(time.DateTime), + time.Now().UTC().Format(time.DateTime), + step.intervalValue, + step.intervalStep, + ), + req.NamespaceKey, + req.FlagKey, + ) + if err != nil { + return nil, nil, err + } + + defer func() { + _ = rows.Close() + }() + + var ( + timestamps = make([]string, 0) + values = make([]float32, 0) + ) + for rows.Next() { + var ( + timestamp string + value int + ) + + if err := rows.Scan(&value, ×tamp); err != nil { + return nil, nil, err + } + + timestamps = append(timestamps, timestamp) + values = append(values, float32(value)) + } + + if err := rows.Err(); err != nil { + return nil, nil, err + } + + return timestamps, values, nil +} + +// Close will close the DB connection. +func (c *Client) Close() error { + return c.Conn.Close() +} + +// getStepFromDuration is a utility function that translates the duration passed in from the client +// to determine the interval steps we should use for the Clickhouse query. +func getStepFromDuration(from time.Duration) *Step { + if from <= 4*time.Hour { + return &Step{ + intervalValue: 1, + intervalStep: "MINUTE", + } + } + + return &Step{ + intervalValue: 15, + intervalStep: "MINUTE", + } +} + +func (c *Client) String() string { + return "clickhouse" +} diff --git a/internal/server/analytics/clickhouse/client_test.go b/internal/server/analytics/clickhouse/client_test.go new file mode 100644 index 0000000000..bd56caa889 --- /dev/null +++ b/internal/server/analytics/clickhouse/client_test.go @@ -0,0 +1,49 @@ +package clickhouse + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestGetStepFromDuration(t *testing.T) { + cases := []struct { + name string + duration time.Duration + want *Step + }{ + { + name: "1 hour duration", + duration: time.Hour, + want: &Step{ + intervalValue: 1, + intervalStep: "MINUTE", + }, + }, + { + name: "3 hour duration", + duration: 3 * time.Hour, + want: &Step{ + intervalValue: 1, + intervalStep: "MINUTE", + }, + }, + { + name: "24 hour duration", + duration: 24 * time.Hour, + want: &Step{ + intervalValue: 15, + intervalStep: "MINUTE", + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + step := getStepFromDuration(tt.duration) + assert.Equal(t, step.intervalStep, tt.want.intervalStep) + assert.Equal(t, step.intervalValue, tt.want.intervalValue) + }) + } +} diff --git a/internal/server/analytics/clickhouse/mutation.go b/internal/server/analytics/clickhouse/mutation.go new file mode 100644 index 0000000000..98db6d08db --- /dev/null +++ b/internal/server/analytics/clickhouse/mutation.go @@ -0,0 +1,39 @@ +package clickhouse + +import ( + "context" + "fmt" + "strings" + "time" + + "go.flipt.io/flipt/internal/server/analytics" +) + +// IncrementFlagEvaluationCounts does a batch insert of flag values from evaluations. +func (c *Client) IncrementFlagEvaluationCounts(ctx context.Context, responses []*analytics.EvaluationResponse) error { + if len(responses) < 1 { + return nil + } + + valuePlaceHolders := make([]string, 0, len(responses)) + valueArgs := make([]interface{}, 0, len(responses)*9) + + for _, response := range responses { + valuePlaceHolders = append(valuePlaceHolders, "(toDateTime(?, 'UTC'),?,?,?,?,?,?,?,?)") + valueArgs = append(valueArgs, response.Timestamp.Format(time.DateTime)) + valueArgs = append(valueArgs, counterAnalyticsName) + valueArgs = append(valueArgs, response.NamespaceKey) + valueArgs = append(valueArgs, response.FlagKey) + valueArgs = append(valueArgs, response.FlagType) + valueArgs = append(valueArgs, response.Reason) + valueArgs = append(valueArgs, response.Match) + valueArgs = append(valueArgs, response.EvaluationValue) + valueArgs = append(valueArgs, 1) + } + + //nolint:gosec + stmt := fmt.Sprintf("INSERT INTO %s VALUES %s", counterAnalyticsTable, strings.Join(valuePlaceHolders, ",")) + + _, err := c.Conn.ExecContext(ctx, stmt, valueArgs...) + return err +} diff --git a/internal/server/analytics/server.go b/internal/server/analytics/server.go new file mode 100644 index 0000000000..00ed6d9f58 --- /dev/null +++ b/internal/server/analytics/server.go @@ -0,0 +1,36 @@ +package analytics + +import ( + "context" + "fmt" + + "go.flipt.io/flipt/rpc/flipt/analytics" + "go.uber.org/zap" + "google.golang.org/grpc" +) + +// Client is a contract that each analytics store needs to conform to for +// getting analytics from the implemented store. +type Client interface { + GetFlagEvaluationsCount(ctx context.Context, req *analytics.GetFlagEvaluationsCountRequest) ([]string, []float32, error) + fmt.Stringer +} + +// Server is a grpc server for Flipt analytics. +type Server struct { + logger *zap.Logger + client Client + analytics.UnimplementedAnalyticsServiceServer +} + +// New constructs a new server for Flipt analytics. +func New(logger *zap.Logger, client Client) *Server { + return &Server{ + logger: logger, + client: client, + } +} + +func (s *Server) RegisterGRPC(server *grpc.Server) { + analytics.RegisterAnalyticsServiceServer(server, s) +} diff --git a/internal/server/analytics/server_test.go b/internal/server/analytics/server_test.go new file mode 100644 index 0000000000..ac776f87bd --- /dev/null +++ b/internal/server/analytics/server_test.go @@ -0,0 +1,36 @@ +package analytics + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.flipt.io/flipt/rpc/flipt/analytics" + "go.uber.org/zap" +) + +type testClient struct{} + +func (t *testClient) GetFlagEvaluationsCount(ctx context.Context, req *analytics.GetFlagEvaluationsCountRequest) ([]string, []float32, error) { + return []string{"2000-01-01 00:00:00", "2000-01-01 00:01:00", "2000-01-01 00:02:00"}, []float32{20.0, 30.0, 40.0}, nil +} + +func (t *testClient) String() string { + return "test_client" +} + +func TestServer(t *testing.T) { + server := New(zap.NewNop(), &testClient{}) + + res, err := server.GetFlagEvaluationsCount(context.TODO(), &analytics.GetFlagEvaluationsCountRequest{ + NamespaceKey: "default", + FlagKey: "flag1", + From: "2024-01-01 00:00:00", + To: "2024-01-01 00:00:00", + }) + require.Nil(t, err) + + assert.Equal(t, res.Timestamps, []string{"2000-01-01 00:00:00", "2000-01-01 00:01:00", "2000-01-01 00:02:00"}) + assert.Equal(t, res.Values, []float32{20.0, 30.0, 40.0}) +} diff --git a/internal/server/analytics/sink.go b/internal/server/analytics/sink.go new file mode 100644 index 0000000000..22fdfdba86 --- /dev/null +++ b/internal/server/analytics/sink.go @@ -0,0 +1,84 @@ +package analytics + +import ( + "context" + "encoding/json" + "errors" + "time" + + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.uber.org/zap" +) + +var errNotTransformable = errors.New("event not transformable into evaluation response") + +type AnalyticsStoreMutator interface { + IncrementFlagEvaluationCounts(ctx context.Context, responses []*EvaluationResponse) error + Close() error +} + +type EvaluationResponse struct { + FlagKey string `json:"flagKey,omitempty"` + FlagType string `json:"flagType,omitempty"` + NamespaceKey string `json:"namespaceKey,omitempty"` + Reason string `json:"reason,omitempty"` + Match *bool `json:"match,omitempty"` + EvaluationValue *string `json:"evaluationValue,omitempty"` + Timestamp time.Time `json:"timestamp,omitempty"` +} + +// AnalyticsSinkSpanExporter implements SpanExporter. +type AnalyticsSinkSpanExporter struct { + logger *zap.Logger + analyticsStoreMutator AnalyticsStoreMutator +} + +// NewAnalyticsSinkSpanExporter is the constructor function for an AnalyticsSpanExporter. +func NewAnalyticsSinkSpanExporter(logger *zap.Logger, analyticsStoreMutator AnalyticsStoreMutator) *AnalyticsSinkSpanExporter { + return &AnalyticsSinkSpanExporter{ + logger: logger, + analyticsStoreMutator: analyticsStoreMutator, + } +} + +// transformSpanEventToEvaluationResponses is a convenience function to transform a span event into an []*EvaluationResponse. +func transformSpanEventToEvaluationResponses(event sdktrace.Event) ([]*EvaluationResponse, error) { + for _, attr := range event.Attributes { + if string(attr.Key) == "flipt.evaluation.response" { + evaluationResponseBytes := []byte(attr.Value.AsString()) + var evaluationResponse []*EvaluationResponse + + if err := json.Unmarshal(evaluationResponseBytes, &evaluationResponse); err != nil { + return nil, err + } + + return evaluationResponse, nil + } + } + + return nil, errNotTransformable +} + +// ExportSpans transforms the spans into []*EvaluationResponse which the mutator takes to store into an analytics store. +func (a *AnalyticsSinkSpanExporter) ExportSpans(ctx context.Context, spans []sdktrace.ReadOnlySpan) error { + evaluationResponses := make([]*EvaluationResponse, 0) + + for _, span := range spans { + for _, event := range span.Events() { + evaluationResponsesFromSpan, err := transformSpanEventToEvaluationResponses(event) + if err != nil && !errors.Is(err, errNotTransformable) { + a.logger.Error("event not decodable into evaluation response", zap.Error(err)) + continue + } + + evaluationResponses = append(evaluationResponses, evaluationResponsesFromSpan...) + } + } + + return a.analyticsStoreMutator.IncrementFlagEvaluationCounts(ctx, evaluationResponses) +} + +// Shutdown closes resources for an AnalyticsStoreMutator. +func (a *AnalyticsSinkSpanExporter) Shutdown(_ context.Context) error { + return a.analyticsStoreMutator.Close() +} diff --git a/internal/server/analytics/sink_test.go b/internal/server/analytics/sink_test.go new file mode 100644 index 0000000000..f4ec92657b --- /dev/null +++ b/internal/server/analytics/sink_test.go @@ -0,0 +1,79 @@ +package analytics + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/attribute" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +type sampleSink struct { + ch chan []*EvaluationResponse +} + +func (s *sampleSink) IncrementFlagEvaluationCounts(ctx context.Context, responses []*EvaluationResponse) error { + s.ch <- responses + return nil +} + +func (s *sampleSink) Close() error { return nil } + +func TestSinkSpanExporter(t *testing.T) { + ss := &sampleSink{ + ch: make(chan []*EvaluationResponse, 1), + } + analyticsSinkSpanExporter := NewAnalyticsSinkSpanExporter(zap.NewNop(), ss) + + tp := sdktrace.NewTracerProvider(sdktrace.WithSampler(sdktrace.AlwaysSample())) + tp.RegisterSpanProcessor(sdktrace.NewSimpleSpanProcessor(analyticsSinkSpanExporter)) + + tr := tp.Tracer("SpanProcesser") + + _, span := tr.Start(context.Background(), "OnStart") + + b := true + evaluationResponses := []*EvaluationResponse{ + { + FlagKey: "hello", + NamespaceKey: "default", + Reason: "MATCH_EVALUATION_REASON", + Match: &b, + Timestamp: time.Now().UTC(), + }, + } + + evaluationResponsesBytes, err := json.Marshal(evaluationResponses) + require.NoError(t, err) + + attrs := []attribute.KeyValue{ + { + Key: "flipt.evaluation.response", + Value: attribute.StringValue(string(evaluationResponsesBytes)), + }, + } + span.AddEvent("evaluation_response", trace.WithAttributes(attrs...)) + span.End() + + timeoutCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + select { + case e := <-ss.ch: + assert.Len(t, e, 1) + evaluationResponseActual := e[0] + assert.Equal(t, evaluationResponses[0].FlagKey, evaluationResponseActual.FlagKey) + assert.Equal(t, evaluationResponses[0].NamespaceKey, evaluationResponseActual.NamespaceKey) + assert.Equal(t, evaluationResponses[0].Reason, evaluationResponseActual.Reason) + assert.Equal(t, *evaluationResponses[0].Match, *evaluationResponseActual.Match) + assert.Equal(t, evaluationResponses[0].Timestamp, evaluationResponseActual.Timestamp) + case <-timeoutCtx.Done(): + require.Fail(t, "message should have been sent on the channel") + } +} diff --git a/internal/server/analytics/testing/testing.go b/internal/server/analytics/testing/testing.go new file mode 100644 index 0000000000..52892b05f6 --- /dev/null +++ b/internal/server/analytics/testing/testing.go @@ -0,0 +1,115 @@ +package testing + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/ClickHouse/clickhouse-go/v2" + "github.com/docker/go-connections/nat" + "github.com/golang-migrate/migrate/v4" + clickhouseMigrate "github.com/golang-migrate/migrate/v4/database/clickhouse" + "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "go.flipt.io/flipt/config/migrations" + fliptsql "go.flipt.io/flipt/internal/storage/sql" +) + +type Database struct { + Container testcontainers.Container + DB *sql.DB + Driver fliptsql.Driver +} + +func Open() (*Database, error) { + container, hostIP, port, err := NewAnalyticsDBContainer(context.Background()) + if err != nil { + return nil, err + } + + db := clickhouse.OpenDB(&clickhouse.Options{ + Addr: []string{fmt.Sprintf("%s:%d", hostIP, port)}, + }) + + mm, err := newMigrator(db, fliptsql.Clickhouse) + if err != nil { + return nil, err + } + + // ensure we start with a clean slate + if err := mm.Drop(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return nil, err + } + + mm, err = newMigrator(db, fliptsql.Clickhouse) + if err != nil { + return nil, err + } + + if err := mm.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) { + return nil, err + } + + return &Database{ + Container: container, + DB: db, + Driver: fliptsql.Clickhouse, + }, nil +} + +func newMigrator(db *sql.DB, driver fliptsql.Driver) (*migrate.Migrate, error) { + dr, err := clickhouseMigrate.WithInstance(db, &clickhouseMigrate.Config{ + MigrationsTableEngine: "MergeTree", + }) + if err != nil { + return nil, err + } + + sourceDriver, err := iofs.New(migrations.FS, fliptsql.Clickhouse.Migrations()) + if err != nil { + return nil, fmt.Errorf("constructing migration source driver (db driver %q): %w", driver.Migrations(), err) + } + + mm, err := migrate.NewWithInstance("iofs", sourceDriver, fliptsql.Clickhouse.Migrations(), dr) + if err != nil { + return nil, err + } + + return mm, nil +} + +func NewAnalyticsDBContainer(ctx context.Context) (testcontainers.Container, string, int, error) { + port := nat.Port("9000/tcp") + + req := testcontainers.ContainerRequest{ + Image: "clickhouse/clickhouse-server:24.1-alpine", + ExposedPorts: []string{"9000/tcp"}, + WaitingFor: wait.ForListeningPort(port), + } + + container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ + ContainerRequest: req, + Started: true, + }) + if err != nil { + return nil, "", 0, err + } + + if err := container.StartLogProducer(ctx); err != nil { + return nil, "", 0, err + } + + mappedPort, err := container.MappedPort(ctx, port) + if err != nil { + return nil, "", 0, err + } + + hostIP, err := container.Host(ctx) + if err != nil { + return nil, "", 0, err + } + + return container, hostIP, mappedPort.Int(), nil +} diff --git a/internal/server/audit/audit.go b/internal/server/audit/audit.go index 76269421bc..23cb410b26 100644 --- a/internal/server/audit/audit.go +++ b/internal/server/audit/audit.go @@ -211,8 +211,7 @@ func (s *SinkSpanExporter) ExportSpans(ctx context.Context, spans []sdktrace.Rea es := make([]Event, 0) for _, span := range spans { - events := span.Events() - for _, e := range events { + for _, e := range span.Events() { e, err := decodeToEvent(e.Attributes) if err != nil { if !errors.Is(err, errEventNotValid) { diff --git a/internal/server/audit/audit_test.go b/internal/server/audit/audit_test.go index 3539e60abb..b21a6ed35a 100644 --- a/internal/server/audit/audit_test.go +++ b/internal/server/audit/audit_test.go @@ -80,7 +80,7 @@ func TestSinkSpanExporter(t *testing.T) { Enabled: false, }) - span.AddEvent("auditEvent", trace.WithAttributes(e.DecodeToAttributes()...)) + span.AddEvent("event", trace.WithAttributes(e.DecodeToAttributes()...)) span.End() timeoutCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second) diff --git a/internal/server/middleware/grpc/middleware.go b/internal/server/middleware/grpc/middleware.go index 6c9c745f5c..6d101f03cd 100644 --- a/internal/server/middleware/grpc/middleware.go +++ b/internal/server/middleware/grpc/middleware.go @@ -10,12 +10,14 @@ import ( "github.com/gofrs/uuid" errs "go.flipt.io/flipt/errors" "go.flipt.io/flipt/internal/cache" + "go.flipt.io/flipt/internal/server/analytics" "go.flipt.io/flipt/internal/server/audit" "go.flipt.io/flipt/internal/server/auth" "go.flipt.io/flipt/internal/server/metrics" flipt "go.flipt.io/flipt/rpc/flipt" fauth "go.flipt.io/flipt/rpc/flipt/auth" "go.flipt.io/flipt/rpc/flipt/evaluation" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" "go.uber.org/zap" "google.golang.org/grpc" @@ -88,33 +90,143 @@ type ResponseDurationRecordable interface { // EvaluationUnaryInterceptor sets required request/response fields. // Note: this should be added before any caching interceptor to ensure the request id/response fields are unique. -func EvaluationUnaryInterceptor(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { - startTime := time.Now().UTC() +func EvaluationUnaryInterceptor(analyticsEnabled bool) grpc.UnaryServerInterceptor { + return func(ctx context.Context, req interface{}, _ *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) { + startTime := time.Now().UTC() - // set request ID if not present - requestID := uuid.Must(uuid.NewV4()).String() - if r, ok := req.(RequestIdentifiable); ok { - requestID = r.SetRequestIDIfNotBlank(requestID) + // set request ID if not present + requestID := uuid.Must(uuid.NewV4()).String() + if r, ok := req.(RequestIdentifiable); ok { + requestID = r.SetRequestIDIfNotBlank(requestID) - resp, err = handler(ctx, req) - if err != nil { - return resp, err - } + resp, err = handler(ctx, req) + if err != nil { + return resp, err + } - // set request ID on response - if r, ok := resp.(RequestIdentifiable); ok { - _ = r.SetRequestIDIfNotBlank(requestID) - } + // set request ID on response + if r, ok := resp.(RequestIdentifiable); ok { + _ = r.SetRequestIDIfNotBlank(requestID) + } - // record start, end, duration on response types - if r, ok := resp.(ResponseDurationRecordable); ok { - r.SetTimestamps(startTime, time.Now().UTC()) + // record start, end, duration on response types + if r, ok := resp.(ResponseDurationRecordable); ok { + r.SetTimestamps(startTime, time.Now().UTC()) + } + + if analyticsEnabled { + span := trace.SpanFromContext(ctx) + + switch r := resp.(type) { + case *evaluation.VariantEvaluationResponse: + // This "should" always be an evalution request under these circumstances. + if evaluationRequest, ok := req.(*evaluation.EvaluationRequest); ok { + var variantKey *string = nil + if r.GetVariantKey() != "" { + variantKey = &r.VariantKey + } + + evaluationResponses := []*analytics.EvaluationResponse{ + { + NamespaceKey: evaluationRequest.GetNamespaceKey(), + FlagKey: r.GetFlagKey(), + FlagType: evaluation.EvaluationFlagType_VARIANT_FLAG_TYPE.String(), + Match: &r.Match, + Reason: r.GetReason().String(), + Timestamp: r.GetTimestamp().AsTime(), + EvaluationValue: variantKey, + }, + } + + if evaluationResponsesBytes, err := json.Marshal(evaluationResponses); err == nil { + keyValue := []attribute.KeyValue{ + { + Key: "flipt.evaluation.response", + Value: attribute.StringValue(string(evaluationResponsesBytes)), + }, + } + span.AddEvent("evaluation_response", trace.WithAttributes(keyValue...)) + } + } + case *evaluation.BooleanEvaluationResponse: + if evaluationRequest, ok := req.(*evaluation.EvaluationRequest); ok { + evaluationValue := fmt.Sprint(r.GetEnabled()) + evaluationResponses := []*analytics.EvaluationResponse{ + { + NamespaceKey: evaluationRequest.GetNamespaceKey(), + FlagKey: r.GetFlagKey(), + FlagType: evaluation.EvaluationFlagType_BOOLEAN_FLAG_TYPE.String(), + Reason: r.GetReason().String(), + Timestamp: r.GetTimestamp().AsTime(), + Match: nil, + EvaluationValue: &evaluationValue, + }, + } + + if evaluationResponsesBytes, err := json.Marshal(evaluationResponses); err == nil { + keyValue := []attribute.KeyValue{ + { + Key: "flipt.evaluation.response", + Value: attribute.StringValue(string(evaluationResponsesBytes)), + }, + } + span.AddEvent("evaluation_response", trace.WithAttributes(keyValue...)) + } + } + case *evaluation.BatchEvaluationResponse: + if batchEvaluationRequest, ok := req.(*evaluation.BatchEvaluationRequest); ok { + evaluationResponses := make([]*analytics.EvaluationResponse, 0, len(r.GetResponses())) + for idx, response := range r.GetResponses() { + switch response.GetType() { + case evaluation.EvaluationResponseType_VARIANT_EVALUATION_RESPONSE_TYPE: + variantResponse := response.GetVariantResponse() + var variantKey *string = nil + if variantResponse.GetVariantKey() != "" { + variantKey = &variantResponse.VariantKey + } + + evaluationResponses = append(evaluationResponses, &analytics.EvaluationResponse{ + NamespaceKey: batchEvaluationRequest.Requests[idx].GetNamespaceKey(), + FlagKey: variantResponse.GetFlagKey(), + FlagType: evaluation.EvaluationFlagType_VARIANT_FLAG_TYPE.String(), + Match: &variantResponse.Match, + Reason: variantResponse.GetReason().String(), + Timestamp: variantResponse.Timestamp.AsTime(), + EvaluationValue: variantKey, + }) + case evaluation.EvaluationResponseType_BOOLEAN_EVALUATION_RESPONSE_TYPE: + booleanResponse := response.GetBooleanResponse() + evaluationValue := fmt.Sprint(booleanResponse.GetEnabled()) + evaluationResponses = append(evaluationResponses, &analytics.EvaluationResponse{ + NamespaceKey: batchEvaluationRequest.Requests[idx].GetNamespaceKey(), + FlagKey: booleanResponse.GetFlagKey(), + FlagType: evaluation.EvaluationFlagType_BOOLEAN_FLAG_TYPE.String(), + Reason: booleanResponse.GetReason().String(), + Timestamp: booleanResponse.Timestamp.AsTime(), + Match: nil, + EvaluationValue: &evaluationValue, + }) + } + } + + if evaluationResponsesBytes, err := json.Marshal(evaluationResponses); err == nil { + keyValue := []attribute.KeyValue{ + { + Key: "flipt.evaluation.response", + Value: attribute.StringValue(string(evaluationResponsesBytes)), + }, + } + span.AddEvent("evaluation_response", trace.WithAttributes(keyValue...)) + } + } + } + } + + return resp, nil } - return resp, nil + return handler(ctx, req) } - - return handler(ctx, req) } var ( diff --git a/internal/server/middleware/grpc/middleware_test.go b/internal/server/middleware/grpc/middleware_test.go index b35714ee11..8d09d88f2c 100644 --- a/internal/server/middleware/grpc/middleware_test.go +++ b/internal/server/middleware/grpc/middleware_test.go @@ -176,7 +176,7 @@ func TestEvaluationUnaryInterceptor_Noop(t *testing.T) { } ) - got, err := EvaluationUnaryInterceptor(context.Background(), req, info, handler) + got, err := EvaluationUnaryInterceptor(false)(context.Background(), req, info, handler) require.NoError(t, err) assert.NotNil(t, got) @@ -273,7 +273,7 @@ func TestEvaluationUnaryInterceptor_Evaluation(t *testing.T) { } ) - got, err := EvaluationUnaryInterceptor(context.Background(), test.req, info, handler) + got, err := EvaluationUnaryInterceptor(true)(context.Background(), test.req, info, handler) require.NoError(t, err) assert.NotNil(t, got) @@ -322,7 +322,7 @@ func TestEvaluationUnaryInterceptor_BatchEvaluation(t *testing.T) { } ) - got, err := EvaluationUnaryInterceptor(context.Background(), req, info, handler) + got, err := EvaluationUnaryInterceptor(false)(context.Background(), req, info, handler) require.NoError(t, err) assert.NotNil(t, got) @@ -345,7 +345,7 @@ func TestEvaluationUnaryInterceptor_BatchEvaluation(t *testing.T) { }, } - got, err = EvaluationUnaryInterceptor(context.Background(), req, info, handler) + got, err = EvaluationUnaryInterceptor(false)(context.Background(), req, info, handler) require.NoError(t, err) assert.NotNil(t, got) diff --git a/internal/storage/sql/db.go b/internal/storage/sql/db.go index 016357bae3..b4bac676f9 100644 --- a/internal/storage/sql/db.go +++ b/internal/storage/sql/db.go @@ -8,6 +8,7 @@ import ( "io/fs" "net/url" + "github.com/ClickHouse/clickhouse-go/v2" sq "github.com/Masterminds/squirrel" "github.com/XSAM/otelsql" "github.com/go-sql-driver/mysql" @@ -172,6 +173,23 @@ func open(cfg config.Config, opts Options) (*sql.DB, Driver, error) { return db, d, nil } +// openAnalytics is a convenience function of providing a database.sql instance for +// an analytics database. +func openAnalytics(cfg config.Config) (*sql.DB, Driver, error) { + if cfg.Analytics.Storage.Clickhouse.Enabled { + clickhouseOptions, err := cfg.Analytics.Storage.Clickhouse.Options() + if err != nil { + return nil, 0, err + } + + db := clickhouse.OpenDB(clickhouseOptions) + + return db, Clickhouse, nil + } + + return nil, 0, errors.New("no analytics db provided") +} + var ( driverToString = map[Driver]string{ SQLite: "sqlite3", @@ -179,6 +197,7 @@ var ( Postgres: "postgres", MySQL: "mysql", CockroachDB: "cockroachdb", + Clickhouse: "clickhouse", } stringToDriver = map[string]Driver{ @@ -187,6 +206,7 @@ var ( "postgres": Postgres, "mysql": MySQL, "cockroachdb": CockroachDB, + "clickhouse": Clickhouse, } ) @@ -214,8 +234,10 @@ const ( MySQL // CockroachDB ... CockroachDB - // LibSQL... + // LibSQL ... LibSQL + // Clickhouse ... + Clickhouse ) func parse(cfg config.Config, opts Options) (Driver, *dburl.URL, error) { diff --git a/internal/storage/sql/migrator.go b/internal/storage/sql/migrator.go index e238a76b30..11b012b9d2 100644 --- a/internal/storage/sql/migrator.go +++ b/internal/storage/sql/migrator.go @@ -7,6 +7,7 @@ import ( "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database" + clickhouseMigrate "github.com/golang-migrate/migrate/v4/database/clickhouse" "github.com/golang-migrate/migrate/v4/database/cockroachdb" "github.com/golang-migrate/migrate/v4/database/mysql" "github.com/golang-migrate/migrate/v4/database/postgres" @@ -23,6 +24,7 @@ var expectedVersions = map[Driver]uint{ Postgres: 12, MySQL: 11, CockroachDB: 9, + Clickhouse: 0, } // Migrator is responsible for migrating the database schema @@ -40,7 +42,9 @@ func NewMigrator(cfg config.Config, logger *zap.Logger) (*Migrator, error) { return nil, fmt.Errorf("opening db: %w", err) } - var dr database.Driver + var ( + dr database.Driver + ) switch driver { case SQLite, LibSQL: @@ -59,6 +63,10 @@ func NewMigrator(cfg config.Config, logger *zap.Logger) (*Migrator, error) { logger.Debug("using driver", zap.String("driver", driver.String())) + return migratorHelper(logger, sql, driver, dr) +} + +func migratorHelper(logger *zap.Logger, db *sql.DB, driver Driver, databaseDriver database.Driver) (*Migrator, error) { // source migrations from embedded config/migrations package // relative to the specific driver sourceDriver, err := iofs.New(migrations.FS, driver.Migrations()) @@ -66,19 +74,48 @@ func NewMigrator(cfg config.Config, logger *zap.Logger) (*Migrator, error) { return nil, err } - mm, err := migrate.NewWithInstance("iofs", sourceDriver, driver.Migrations(), dr) + mm, err := migrate.NewWithInstance("iofs", sourceDriver, driver.Migrations(), databaseDriver) if err != nil { return nil, fmt.Errorf("creating migrate instance: %w", err) } return &Migrator{ - db: sql, + db: db, migrator: mm, logger: logger, driver: driver, }, nil } +// NewAnalyticsMigrator returns a migrator for analytics databases +func NewAnalyticsMigrator(cfg config.Config, logger *zap.Logger) (*Migrator, error) { + sql, driver, err := openAnalytics(cfg) + if err != nil { + return nil, fmt.Errorf("opening db: %w", err) + } + + var dr database.Driver + + if driver == Clickhouse { + options, err := cfg.Analytics.Storage.Clickhouse.Options() + if err != nil { + return nil, err + } + + dr, err = clickhouseMigrate.WithInstance(sql, &clickhouseMigrate.Config{ + DatabaseName: options.Auth.Database, + MigrationsTableEngine: "MergeTree", + }) + if err != nil { + return nil, fmt.Errorf("getting db driver for: %s: %w", driver, err) + } + } + + logger.Debug("using driver", zap.String("driver", driver.String())) + + return migratorHelper(logger, sql, driver, dr) +} + // Close closes the source and db func (m *Migrator) Close() (source, db error) { return m.migrator.Close() diff --git a/rpc/flipt/analytics/analytics.pb.go b/rpc/flipt/analytics/analytics.pb.go new file mode 100644 index 0000000000..77337e549c --- /dev/null +++ b/rpc/flipt/analytics/analytics.pb.go @@ -0,0 +1,260 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.32.0 +// protoc (unknown) +// source: analytics/analytics.proto + +package analytics + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type GetFlagEvaluationsCountRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + NamespaceKey string `protobuf:"bytes,1,opt,name=namespace_key,json=namespaceKey,proto3" json:"namespace_key,omitempty"` + FlagKey string `protobuf:"bytes,2,opt,name=flag_key,json=flagKey,proto3" json:"flag_key,omitempty"` + From string `protobuf:"bytes,3,opt,name=from,proto3" json:"from,omitempty"` + To string `protobuf:"bytes,4,opt,name=to,proto3" json:"to,omitempty"` +} + +func (x *GetFlagEvaluationsCountRequest) Reset() { + *x = GetFlagEvaluationsCountRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_analytics_analytics_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetFlagEvaluationsCountRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFlagEvaluationsCountRequest) ProtoMessage() {} + +func (x *GetFlagEvaluationsCountRequest) ProtoReflect() protoreflect.Message { + mi := &file_analytics_analytics_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFlagEvaluationsCountRequest.ProtoReflect.Descriptor instead. +func (*GetFlagEvaluationsCountRequest) Descriptor() ([]byte, []int) { + return file_analytics_analytics_proto_rawDescGZIP(), []int{0} +} + +func (x *GetFlagEvaluationsCountRequest) GetNamespaceKey() string { + if x != nil { + return x.NamespaceKey + } + return "" +} + +func (x *GetFlagEvaluationsCountRequest) GetFlagKey() string { + if x != nil { + return x.FlagKey + } + return "" +} + +func (x *GetFlagEvaluationsCountRequest) GetFrom() string { + if x != nil { + return x.From + } + return "" +} + +func (x *GetFlagEvaluationsCountRequest) GetTo() string { + if x != nil { + return x.To + } + return "" +} + +type GetFlagEvaluationsCountResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Timestamps []string `protobuf:"bytes,1,rep,name=timestamps,proto3" json:"timestamps,omitempty"` + Values []float32 `protobuf:"fixed32,2,rep,packed,name=values,proto3" json:"values,omitempty"` +} + +func (x *GetFlagEvaluationsCountResponse) Reset() { + *x = GetFlagEvaluationsCountResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_analytics_analytics_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetFlagEvaluationsCountResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetFlagEvaluationsCountResponse) ProtoMessage() {} + +func (x *GetFlagEvaluationsCountResponse) ProtoReflect() protoreflect.Message { + mi := &file_analytics_analytics_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetFlagEvaluationsCountResponse.ProtoReflect.Descriptor instead. +func (*GetFlagEvaluationsCountResponse) Descriptor() ([]byte, []int) { + return file_analytics_analytics_proto_rawDescGZIP(), []int{1} +} + +func (x *GetFlagEvaluationsCountResponse) GetTimestamps() []string { + if x != nil { + return x.Timestamps + } + return nil +} + +func (x *GetFlagEvaluationsCountResponse) GetValues() []float32 { + if x != nil { + return x.Values + } + return nil +} + +var File_analytics_analytics_proto protoreflect.FileDescriptor + +var file_analytics_analytics_proto_rawDesc = []byte{ + 0x0a, 0x19, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x74, 0x69, 0x63, 0x73, 0x2f, 0x61, 0x6e, 0x61, 0x6c, + 0x79, 0x74, 0x69, 0x63, 0x73, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x0f, 0x66, 0x6c, 0x69, + 0x70, 0x74, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x74, 0x69, 0x63, 0x73, 0x22, 0x84, 0x01, 0x0a, + 0x1e, 0x47, 0x65, 0x74, 0x46, 0x6c, 0x61, 0x67, 0x45, 0x76, 0x61, 0x6c, 0x75, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, + 0x23, 0x0a, 0x0d, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x5f, 0x6b, 0x65, 0x79, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x6e, 0x61, 0x6d, 0x65, 0x73, 0x70, 0x61, 0x63, + 0x65, 0x4b, 0x65, 0x79, 0x12, 0x19, 0x0a, 0x08, 0x66, 0x6c, 0x61, 0x67, 0x5f, 0x6b, 0x65, 0x79, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x66, 0x6c, 0x61, 0x67, 0x4b, 0x65, 0x79, 0x12, + 0x12, 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x66, + 0x72, 0x6f, 0x6d, 0x12, 0x0e, 0x0a, 0x02, 0x74, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x02, 0x74, 0x6f, 0x22, 0x59, 0x0a, 0x1f, 0x47, 0x65, 0x74, 0x46, 0x6c, 0x61, 0x67, 0x45, 0x76, + 0x61, 0x6c, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1e, 0x0a, 0x0a, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x73, 0x12, 0x16, 0x0a, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, + 0x18, 0x02, 0x20, 0x03, 0x28, 0x02, 0x52, 0x06, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x73, 0x32, 0x92, + 0x01, 0x0a, 0x10, 0x41, 0x6e, 0x61, 0x6c, 0x79, 0x74, 0x69, 0x63, 0x73, 0x53, 0x65, 0x72, 0x76, + 0x69, 0x63, 0x65, 0x12, 0x7e, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x46, 0x6c, 0x61, 0x67, 0x45, 0x76, + 0x61, 0x6c, 0x75, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x2f, + 0x2e, 0x66, 0x6c, 0x69, 0x70, 0x74, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x74, 0x69, 0x63, 0x73, + 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6c, 0x61, 0x67, 0x45, 0x76, 0x61, 0x6c, 0x75, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x30, 0x2e, 0x66, 0x6c, 0x69, 0x70, 0x74, 0x2e, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x74, 0x69, 0x63, + 0x73, 0x2e, 0x47, 0x65, 0x74, 0x46, 0x6c, 0x61, 0x67, 0x45, 0x76, 0x61, 0x6c, 0x75, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x00, 0x42, 0x27, 0x5a, 0x25, 0x67, 0x6f, 0x2e, 0x66, 0x6c, 0x69, 0x70, 0x74, 0x2e, + 0x69, 0x6f, 0x2f, 0x66, 0x6c, 0x69, 0x70, 0x74, 0x2f, 0x72, 0x70, 0x63, 0x2f, 0x66, 0x6c, 0x69, + 0x70, 0x74, 0x2f, 0x61, 0x6e, 0x61, 0x6c, 0x79, 0x74, 0x69, 0x63, 0x73, 0x62, 0x06, 0x70, 0x72, + 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_analytics_analytics_proto_rawDescOnce sync.Once + file_analytics_analytics_proto_rawDescData = file_analytics_analytics_proto_rawDesc +) + +func file_analytics_analytics_proto_rawDescGZIP() []byte { + file_analytics_analytics_proto_rawDescOnce.Do(func() { + file_analytics_analytics_proto_rawDescData = protoimpl.X.CompressGZIP(file_analytics_analytics_proto_rawDescData) + }) + return file_analytics_analytics_proto_rawDescData +} + +var file_analytics_analytics_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_analytics_analytics_proto_goTypes = []interface{}{ + (*GetFlagEvaluationsCountRequest)(nil), // 0: flipt.analytics.GetFlagEvaluationsCountRequest + (*GetFlagEvaluationsCountResponse)(nil), // 1: flipt.analytics.GetFlagEvaluationsCountResponse +} +var file_analytics_analytics_proto_depIdxs = []int32{ + 0, // 0: flipt.analytics.AnalyticsService.GetFlagEvaluationsCount:input_type -> flipt.analytics.GetFlagEvaluationsCountRequest + 1, // 1: flipt.analytics.AnalyticsService.GetFlagEvaluationsCount:output_type -> flipt.analytics.GetFlagEvaluationsCountResponse + 1, // [1:2] is the sub-list for method output_type + 0, // [0:1] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_analytics_analytics_proto_init() } +func file_analytics_analytics_proto_init() { + if File_analytics_analytics_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_analytics_analytics_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetFlagEvaluationsCountRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_analytics_analytics_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*GetFlagEvaluationsCountResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_analytics_analytics_proto_rawDesc, + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_analytics_analytics_proto_goTypes, + DependencyIndexes: file_analytics_analytics_proto_depIdxs, + MessageInfos: file_analytics_analytics_proto_msgTypes, + }.Build() + File_analytics_analytics_proto = out.File + file_analytics_analytics_proto_rawDesc = nil + file_analytics_analytics_proto_goTypes = nil + file_analytics_analytics_proto_depIdxs = nil +} diff --git a/rpc/flipt/analytics/analytics.pb.gw.go b/rpc/flipt/analytics/analytics.pb.gw.go new file mode 100644 index 0000000000..23d2554410 --- /dev/null +++ b/rpc/flipt/analytics/analytics.pb.gw.go @@ -0,0 +1,227 @@ +// Code generated by protoc-gen-grpc-gateway. DO NOT EDIT. +// source: analytics/analytics.proto + +/* +Package analytics is a reverse proxy. + +It translates gRPC into RESTful JSON APIs. +*/ +package analytics + +import ( + "context" + "io" + "net/http" + + "github.com/grpc-ecosystem/grpc-gateway/v2/runtime" + "github.com/grpc-ecosystem/grpc-gateway/v2/utilities" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/grpclog" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" +) + +// Suppress "imported and not used" errors +var _ codes.Code +var _ io.Reader +var _ status.Status +var _ = runtime.String +var _ = utilities.NewDoubleArray +var _ = metadata.Join + +var ( + filter_AnalyticsService_GetFlagEvaluationsCount_0 = &utilities.DoubleArray{Encoding: map[string]int{"namespace_key": 0, "flag_key": 1}, Base: []int{1, 1, 2, 0, 0}, Check: []int{0, 1, 1, 2, 3}} +) + +func request_AnalyticsService_GetFlagEvaluationsCount_0(ctx context.Context, marshaler runtime.Marshaler, client AnalyticsServiceClient, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq GetFlagEvaluationsCountRequest + var metadata runtime.ServerMetadata + + var ( + val string + ok bool + err error + _ = err + ) + + val, ok = pathParams["namespace_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "namespace_key") + } + + protoReq.NamespaceKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "namespace_key", err) + } + + val, ok = pathParams["flag_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "flag_key") + } + + protoReq.FlagKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "flag_key", err) + } + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AnalyticsService_GetFlagEvaluationsCount_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := client.GetFlagEvaluationsCount(ctx, &protoReq, grpc.Header(&metadata.HeaderMD), grpc.Trailer(&metadata.TrailerMD)) + return msg, metadata, err + +} + +func local_request_AnalyticsService_GetFlagEvaluationsCount_0(ctx context.Context, marshaler runtime.Marshaler, server AnalyticsServiceServer, req *http.Request, pathParams map[string]string) (proto.Message, runtime.ServerMetadata, error) { + var protoReq GetFlagEvaluationsCountRequest + var metadata runtime.ServerMetadata + + var ( + val string + ok bool + err error + _ = err + ) + + val, ok = pathParams["namespace_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "namespace_key") + } + + protoReq.NamespaceKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "namespace_key", err) + } + + val, ok = pathParams["flag_key"] + if !ok { + return nil, metadata, status.Errorf(codes.InvalidArgument, "missing parameter %s", "flag_key") + } + + protoReq.FlagKey, err = runtime.String(val) + if err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "type mismatch, parameter: %s, error: %v", "flag_key", err) + } + + if err := req.ParseForm(); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + if err := runtime.PopulateQueryParameters(&protoReq, req.Form, filter_AnalyticsService_GetFlagEvaluationsCount_0); err != nil { + return nil, metadata, status.Errorf(codes.InvalidArgument, "%v", err) + } + + msg, err := server.GetFlagEvaluationsCount(ctx, &protoReq) + return msg, metadata, err + +} + +// RegisterAnalyticsServiceHandlerServer registers the http handlers for service AnalyticsService to "mux". +// UnaryRPC :call AnalyticsServiceServer directly. +// StreamingRPC :currently unsupported pending https://github.com/grpc/grpc-go/issues/906. +// Note that using this registration option will cause many gRPC library features to stop working. Consider using RegisterAnalyticsServiceHandlerFromEndpoint instead. +func RegisterAnalyticsServiceHandlerServer(ctx context.Context, mux *runtime.ServeMux, server AnalyticsServiceServer) error { + + mux.Handle("GET", pattern_AnalyticsService_GetFlagEvaluationsCount_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + var stream runtime.ServerTransportStream + ctx = grpc.NewContextWithServerTransportStream(ctx, &stream) + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateIncomingContext(ctx, mux, req, "/flipt.analytics.AnalyticsService/GetFlagEvaluationsCount", runtime.WithHTTPPathPattern("/internal/v1/analytics/namespaces/{namespace_key}/flags/{flag_key}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := local_request_AnalyticsService_GetFlagEvaluationsCount_0(annotatedContext, inboundMarshaler, server, req, pathParams) + md.HeaderMD, md.TrailerMD = metadata.Join(md.HeaderMD, stream.Header()), metadata.Join(md.TrailerMD, stream.Trailer()) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AnalyticsService_GetFlagEvaluationsCount_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + return nil +} + +// RegisterAnalyticsServiceHandlerFromEndpoint is same as RegisterAnalyticsServiceHandler but +// automatically dials to "endpoint" and closes the connection when "ctx" gets done. +func RegisterAnalyticsServiceHandlerFromEndpoint(ctx context.Context, mux *runtime.ServeMux, endpoint string, opts []grpc.DialOption) (err error) { + conn, err := grpc.DialContext(ctx, endpoint, opts...) + if err != nil { + return err + } + defer func() { + if err != nil { + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + return + } + go func() { + <-ctx.Done() + if cerr := conn.Close(); cerr != nil { + grpclog.Infof("Failed to close conn to %s: %v", endpoint, cerr) + } + }() + }() + + return RegisterAnalyticsServiceHandler(ctx, mux, conn) +} + +// RegisterAnalyticsServiceHandler registers the http handlers for service AnalyticsService to "mux". +// The handlers forward requests to the grpc endpoint over "conn". +func RegisterAnalyticsServiceHandler(ctx context.Context, mux *runtime.ServeMux, conn *grpc.ClientConn) error { + return RegisterAnalyticsServiceHandlerClient(ctx, mux, NewAnalyticsServiceClient(conn)) +} + +// RegisterAnalyticsServiceHandlerClient registers the http handlers for service AnalyticsService +// to "mux". The handlers forward requests to the grpc endpoint over the given implementation of "AnalyticsServiceClient". +// Note: the gRPC framework executes interceptors within the gRPC handler. If the passed in "AnalyticsServiceClient" +// doesn't go through the normal gRPC flow (creating a gRPC client etc.) then it will be up to the passed in +// "AnalyticsServiceClient" to call the correct interceptors. +func RegisterAnalyticsServiceHandlerClient(ctx context.Context, mux *runtime.ServeMux, client AnalyticsServiceClient) error { + + mux.Handle("GET", pattern_AnalyticsService_GetFlagEvaluationsCount_0, func(w http.ResponseWriter, req *http.Request, pathParams map[string]string) { + ctx, cancel := context.WithCancel(req.Context()) + defer cancel() + inboundMarshaler, outboundMarshaler := runtime.MarshalerForRequest(mux, req) + var err error + var annotatedContext context.Context + annotatedContext, err = runtime.AnnotateContext(ctx, mux, req, "/flipt.analytics.AnalyticsService/GetFlagEvaluationsCount", runtime.WithHTTPPathPattern("/internal/v1/analytics/namespaces/{namespace_key}/flags/{flag_key}")) + if err != nil { + runtime.HTTPError(ctx, mux, outboundMarshaler, w, req, err) + return + } + resp, md, err := request_AnalyticsService_GetFlagEvaluationsCount_0(annotatedContext, inboundMarshaler, client, req, pathParams) + annotatedContext = runtime.NewServerMetadataContext(annotatedContext, md) + if err != nil { + runtime.HTTPError(annotatedContext, mux, outboundMarshaler, w, req, err) + return + } + + forward_AnalyticsService_GetFlagEvaluationsCount_0(annotatedContext, mux, outboundMarshaler, w, req, resp, mux.GetForwardResponseOptions()...) + + }) + + return nil +} + +var ( + pattern_AnalyticsService_GetFlagEvaluationsCount_0 = runtime.MustPattern(runtime.NewPattern(1, []int{2, 0, 2, 1, 2, 2, 2, 3, 1, 0, 4, 1, 5, 4, 2, 5, 1, 0, 4, 1, 5, 6}, []string{"internal", "v1", "analytics", "namespaces", "namespace_key", "flags", "flag_key"}, "")) +) + +var ( + forward_AnalyticsService_GetFlagEvaluationsCount_0 = runtime.ForwardResponseMessage +) diff --git a/rpc/flipt/analytics/analytics.proto b/rpc/flipt/analytics/analytics.proto new file mode 100644 index 0000000000..9560d90a97 --- /dev/null +++ b/rpc/flipt/analytics/analytics.proto @@ -0,0 +1,21 @@ +syntax = "proto3"; + +package flipt.analytics; + +option go_package = "go.flipt.io/flipt/rpc/flipt/analytics"; + +message GetFlagEvaluationsCountRequest { + string namespace_key = 1; + string flag_key = 2; + string from = 3; + string to = 4; +} + +message GetFlagEvaluationsCountResponse { + repeated string timestamps = 1; + repeated float values = 2; +} + +service AnalyticsService { + rpc GetFlagEvaluationsCount(GetFlagEvaluationsCountRequest) returns (GetFlagEvaluationsCountResponse) {} +} diff --git a/rpc/flipt/analytics/analytics_grpc.pb.go b/rpc/flipt/analytics/analytics_grpc.pb.go new file mode 100644 index 0000000000..c98bb4011a --- /dev/null +++ b/rpc/flipt/analytics/analytics_grpc.pb.go @@ -0,0 +1,109 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.3.0 +// - protoc (unknown) +// source: analytics/analytics.proto + +package analytics + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +const ( + AnalyticsService_GetFlagEvaluationsCount_FullMethodName = "/flipt.analytics.AnalyticsService/GetFlagEvaluationsCount" +) + +// AnalyticsServiceClient is the client API for AnalyticsService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type AnalyticsServiceClient interface { + GetFlagEvaluationsCount(ctx context.Context, in *GetFlagEvaluationsCountRequest, opts ...grpc.CallOption) (*GetFlagEvaluationsCountResponse, error) +} + +type analyticsServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewAnalyticsServiceClient(cc grpc.ClientConnInterface) AnalyticsServiceClient { + return &analyticsServiceClient{cc} +} + +func (c *analyticsServiceClient) GetFlagEvaluationsCount(ctx context.Context, in *GetFlagEvaluationsCountRequest, opts ...grpc.CallOption) (*GetFlagEvaluationsCountResponse, error) { + out := new(GetFlagEvaluationsCountResponse) + err := c.cc.Invoke(ctx, AnalyticsService_GetFlagEvaluationsCount_FullMethodName, in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// AnalyticsServiceServer is the server API for AnalyticsService service. +// All implementations must embed UnimplementedAnalyticsServiceServer +// for forward compatibility +type AnalyticsServiceServer interface { + GetFlagEvaluationsCount(context.Context, *GetFlagEvaluationsCountRequest) (*GetFlagEvaluationsCountResponse, error) + mustEmbedUnimplementedAnalyticsServiceServer() +} + +// UnimplementedAnalyticsServiceServer must be embedded to have forward compatible implementations. +type UnimplementedAnalyticsServiceServer struct { +} + +func (UnimplementedAnalyticsServiceServer) GetFlagEvaluationsCount(context.Context, *GetFlagEvaluationsCountRequest) (*GetFlagEvaluationsCountResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetFlagEvaluationsCount not implemented") +} +func (UnimplementedAnalyticsServiceServer) mustEmbedUnimplementedAnalyticsServiceServer() {} + +// UnsafeAnalyticsServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to AnalyticsServiceServer will +// result in compilation errors. +type UnsafeAnalyticsServiceServer interface { + mustEmbedUnimplementedAnalyticsServiceServer() +} + +func RegisterAnalyticsServiceServer(s grpc.ServiceRegistrar, srv AnalyticsServiceServer) { + s.RegisterService(&AnalyticsService_ServiceDesc, srv) +} + +func _AnalyticsService_GetFlagEvaluationsCount_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetFlagEvaluationsCountRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(AnalyticsServiceServer).GetFlagEvaluationsCount(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: AnalyticsService_GetFlagEvaluationsCount_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(AnalyticsServiceServer).GetFlagEvaluationsCount(ctx, req.(*GetFlagEvaluationsCountRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// AnalyticsService_ServiceDesc is the grpc.ServiceDesc for AnalyticsService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var AnalyticsService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "flipt.analytics.AnalyticsService", + HandlerType: (*AnalyticsServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetFlagEvaluationsCount", + Handler: _AnalyticsService_GetFlagEvaluationsCount_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "analytics/analytics.proto", +} diff --git a/rpc/flipt/flipt.yaml b/rpc/flipt/flipt.yaml index 8535049bab..0751558982 100644 --- a/rpc/flipt/flipt.yaml +++ b/rpc/flipt/flipt.yaml @@ -315,3 +315,9 @@ http: # internal routes - selector: flipt.evaluation.DataService.EvaluationSnapshotNamespace get: /internal/v1/evaluation/snapshot/namespace/{key} + + # analytics methods + # + # method: evaluation count + - selector: flipt.analytics.AnalyticsService.GetFlagEvaluationsCount + get: /internal/v1/analytics/namespaces/{namespace_key}/flags/{flag_key} diff --git a/sdk/go/analytics.sdk.gen.go b/sdk/go/analytics.sdk.gen.go new file mode 100644 index 0000000000..135db3adc4 --- /dev/null +++ b/sdk/go/analytics.sdk.gen.go @@ -0,0 +1,21 @@ +// Code generated by protoc-gen-go-flipt-sdk. DO NOT EDIT. + +package sdk + +import ( + context "context" + analytics "go.flipt.io/flipt/rpc/flipt/analytics" +) + +type Analytics struct { + transport analytics.AnalyticsServiceClient + authenticationProvider ClientAuthenticationProvider +} + +func (x *Analytics) GetFlagEvaluationsCount(ctx context.Context, v *analytics.GetFlagEvaluationsCountRequest) (*analytics.GetFlagEvaluationsCountResponse, error) { + ctx, err := authenticate(ctx, x.authenticationProvider) + if err != nil { + return nil, err + } + return x.transport.GetFlagEvaluationsCount(ctx, v) +} diff --git a/sdk/go/grpc/grpc.sdk.gen.go b/sdk/go/grpc/grpc.sdk.gen.go index 96f7cd0644..1f3478ee11 100644 --- a/sdk/go/grpc/grpc.sdk.gen.go +++ b/sdk/go/grpc/grpc.sdk.gen.go @@ -4,6 +4,7 @@ package grpc import ( flipt "go.flipt.io/flipt/rpc/flipt" + analytics "go.flipt.io/flipt/rpc/flipt/analytics" auth "go.flipt.io/flipt/rpc/flipt/auth" evaluation "go.flipt.io/flipt/rpc/flipt/evaluation" meta "go.flipt.io/flipt/rpc/flipt/meta" @@ -21,6 +22,10 @@ func NewTransport(cc grpc.ClientConnInterface) Transport { return Transport{cc: cc} } +func (t Transport) AnalyticsClient() analytics.AnalyticsServiceClient { + return analytics.NewAnalyticsServiceClient(t.cc) +} + type authClient struct { cc grpc.ClientConnInterface } diff --git a/sdk/go/http/analytics.sdk.gen.go b/sdk/go/http/analytics.sdk.gen.go new file mode 100644 index 0000000000..f412e06a0a --- /dev/null +++ b/sdk/go/http/analytics.sdk.gen.go @@ -0,0 +1,52 @@ +// Code generated by protoc-gen-go-flipt-sdk. DO NOT EDIT. + +package http + +import ( + context "context" + fmt "fmt" + analytics "go.flipt.io/flipt/rpc/flipt/analytics" + grpc "google.golang.org/grpc" + protojson "google.golang.org/protobuf/encoding/protojson" + io "io" + http "net/http" + url "net/url" +) + +type AnalyticsServiceClient struct { + client *http.Client + addr string +} + +func (x *AnalyticsServiceClient) GetFlagEvaluationsCount(ctx context.Context, v *analytics.GetFlagEvaluationsCountRequest, _ ...grpc.CallOption) (*analytics.GetFlagEvaluationsCountResponse, error) { + var body io.Reader + values := url.Values{} + values.Set("from", v.From) + values.Set("to", v.To) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, x.addr+fmt.Sprintf("/internal/v1/analytics/namespaces/%v/flags/%v", v.NamespaceKey, v.FlagKey), body) + if err != nil { + return nil, err + } + req.URL.RawQuery = values.Encode() + resp, err := x.client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + var output analytics.GetFlagEvaluationsCountResponse + respData, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if err := checkResponse(resp, respData); err != nil { + return nil, err + } + if err := (protojson.UnmarshalOptions{DiscardUnknown: true}).Unmarshal(respData, &output); err != nil { + return nil, err + } + return &output, nil +} + +func (t Transport) AnalyticsClient() analytics.AnalyticsServiceClient { + return &AnalyticsServiceClient{client: t.client, addr: t.addr} +} diff --git a/sdk/go/sdk.gen.go b/sdk/go/sdk.gen.go index e14e2597f0..20553347d7 100644 --- a/sdk/go/sdk.gen.go +++ b/sdk/go/sdk.gen.go @@ -5,6 +5,7 @@ package sdk import ( context "context" flipt "go.flipt.io/flipt/rpc/flipt" + analytics "go.flipt.io/flipt/rpc/flipt/analytics" auth "go.flipt.io/flipt/rpc/flipt/auth" evaluation "go.flipt.io/flipt/rpc/flipt/evaluation" meta "go.flipt.io/flipt/rpc/flipt/meta" @@ -25,6 +26,7 @@ const ( ) type Transport interface { + AnalyticsClient() analytics.AnalyticsServiceClient AuthClient() AuthClient EvaluationClient() evaluation.EvaluationServiceClient FliptClient() flipt.FliptClient @@ -216,6 +218,13 @@ func New(t Transport, opts ...Option) SDK { return sdk } +func (s SDK) Analytics() *Analytics { + return &Analytics{ + transport: s.transport.AnalyticsClient(), + authenticationProvider: s.authenticationProvider, + } +} + func (s SDK) Auth() *Auth { return &Auth{ transport: s.transport.AuthClient(), diff --git a/ui/package-lock.json b/ui/package-lock.json index c69a9e156c..e328bac931 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -24,11 +24,14 @@ "@uiw/codemirror-theme-tokyo-night": "^4.21.21", "@uiw/react-codemirror": "^4.21.21", "buffer": "^6.0.3", + "chart.js": "^4.4.1", + "chartjs-adapter-date-fns": "^3.0.0", "clsx": "^2.1.0", "date-fns": "^3.3.1", "formik": "^2.4.5", "nightwind": "^1.1.13", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-redux": "^9.1.0", @@ -3596,6 +3599,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "node_modules/@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "node_modules/@lezer/common": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.0.tgz", @@ -5880,6 +5888,26 @@ "node": ">=10" } }, + "node_modules/chart.js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.1.tgz", + "integrity": "sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=7" + } + }, + "node_modules/chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "peerDependencies": { + "chart.js": ">=2.8.0", + "date-fns": ">=2.0.0" + } + }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -11468,6 +11496,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", @@ -15629,6 +15666,11 @@ "@jridgewell/sourcemap-codec": "1.4.14" } }, + "@kurkle/color": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.2.tgz", + "integrity": "sha512-fuscdXJ9G1qb7W8VdHi+IwRqij3lBkosAm4ydQtEmbY58OzHXqQhvlxqEkoz0yssNVn38bcpRWgA9PP+OGoisw==" + }, "@lezer/common": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.0.tgz", @@ -17217,6 +17259,20 @@ "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true }, + "chart.js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.1.tgz", + "integrity": "sha512-C74QN1bxwV1v2PEujhmKjOZ7iUM4w6BWs23Md/6aOZZSlwMzeCIDGuZay++rBgChYru7/+QFeoQW0fQoP534Dg==", + "requires": { + "@kurkle/color": "^0.3.0" + } + }, + "chartjs-adapter-date-fns": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz", + "integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==", + "requires": {} + }, "chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -21155,6 +21211,12 @@ "loose-envify": "^1.1.0" } }, + "react-chartjs-2": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.2.0.tgz", + "integrity": "sha512-98iN5aguJyVSxp5U3CblRLH67J8gkfyGNbiK3c+l1QI/G4irHMPQw44aEPmjVag+YKTyQ260NcF82GTQ3bdscA==", + "requires": {} + }, "react-dom": { "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", diff --git a/ui/package.json b/ui/package.json index 93b74b482f..fe51c4ff14 100644 --- a/ui/package.json +++ b/ui/package.json @@ -29,11 +29,14 @@ "@uiw/codemirror-theme-tokyo-night": "^4.21.21", "@uiw/react-codemirror": "^4.21.21", "buffer": "^6.0.3", + "chart.js": "^4.4.1", + "chartjs-adapter-date-fns": "^3.0.0", "clsx": "^2.1.0", "date-fns": "^3.3.1", "formik": "^2.4.5", "nightwind": "^1.1.13", "react": "^18.2.0", + "react-chartjs-2": "^5.2.0", "react-dom": "^18.2.0", "react-helmet": "^6.1.0", "react-redux": "^9.1.0", diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 2bd13d161b..516ea78c53 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -16,8 +16,11 @@ import SessionProvider from './components/SessionProvider'; import { Theme } from './types/Preferences'; import { store } from './store'; const Flags = loadable(() => import('./app/flags/Flags')); -const Variants = loadable(() => import('./app/flags/variants/Variants')); +const ConditionalFlagRouter = loadable( + () => import('./app/flags/ConditionalFlagRouter') +); const Rules = loadable(() => import('./app/flags/rules/Rules')); +const Analytics = loadable(() => import('./app/flags/analytics/Analytics')); const Segments = loadable(() => import('./app/segments/Segments')); const Console = loadable(() => import('./app/console/Console')); const Login = loadable(() => import('./app/auth/Login')); @@ -57,11 +60,15 @@ const namespacedRoutes = [ children: [ { path: '', - element: + element: }, { path: 'rules', element: + }, + { + path: 'analytics', + element: } ] }, diff --git a/ui/src/app/flags/ConditionalFlagRouter.tsx b/ui/src/app/flags/ConditionalFlagRouter.tsx new file mode 100644 index 0000000000..c9c00457e1 --- /dev/null +++ b/ui/src/app/flags/ConditionalFlagRouter.tsx @@ -0,0 +1,26 @@ +import { useOutletContext } from 'react-router-dom'; +import { FlagType, IFlag } from '~/types/Flag'; +import Rollouts from './rollouts/Rollouts'; +import Variants from './variants/Variants'; + +type ConditionalFlagRouterProps = { + flag: IFlag; +}; + +export default function ConditionalFlagRouter() { + const { flag } = useOutletContext(); + + return ( + <> + {flag.type === FlagType.VARIANT ? ( + <> + + + ) : ( + <> + + + )} + + ); +} diff --git a/ui/src/app/flags/Flag.tsx b/ui/src/app/flags/Flag.tsx index 2aa76a2426..e484c8f1a7 100644 --- a/ui/src/app/flags/Flag.tsx +++ b/ui/src/app/flags/Flag.tsx @@ -3,6 +3,7 @@ import { DocumentDuplicateIcon, TrashIcon } from '@heroicons/react/24/outline'; +import 'chartjs-adapter-date-fns'; import { formatDistanceToNowStrict, parseISO } from 'date-fns'; import { useState } from 'react'; import { useSelector } from 'react-redux'; @@ -29,10 +30,16 @@ import { useDeleteFlagMutation, useGetFlagQuery } from './flagsApi'; -import Rollouts from './rollouts/Rollouts'; -const tabs = [ + +const variantFlagTabs = [ { name: 'Variants', to: '' }, - { name: 'Rules', to: 'rules' } + { name: 'Rules', to: 'rules' }, + { name: 'Analytics', to: 'analytics' } +]; + +const booleanFlagTabs = [ + { name: 'Rollouts', to: '' }, + { name: 'Analytics', to: 'analytics' } ]; export default function Flag() { @@ -193,38 +200,60 @@ export default function Flag() { - - {flag.type === FlagType.VARIANT && ( - <> -
-
- -
+ <> +
+
+
- - - )} - {flag.type === FlagType.BOOLEAN && } +
+ +
); diff --git a/ui/src/app/flags/analytics/Analytics.tsx b/ui/src/app/flags/analytics/Analytics.tsx new file mode 100644 index 0000000000..e106409c42 --- /dev/null +++ b/ui/src/app/flags/analytics/Analytics.tsx @@ -0,0 +1,149 @@ +import { Formik } from 'formik'; +import { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { useOutletContext } from 'react-router-dom'; +import { FetchBaseQueryError } from '@reduxjs/toolkit/query'; +import Combobox from '~/components/forms/Combobox'; +import 'chartjs-adapter-date-fns'; +import { addMinutes, format, parseISO } from 'date-fns'; +import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; +import { IFlag } from '~/types/Flag'; +import { BarGraph } from '~/components/graphs'; +import { IFilterable } from '~/types/Selectable'; +import Well from '~/components/Well'; +import { useGetFlagEvaluationCountQuery } from '~/app/flags/analyticsApi'; + +type AnalyticsProps = { + flag: IFlag; +}; + +const timeFormat = 'yyyy-MM-dd HH:mm:ss'; + +interface IDuration { + value: number; +} + +type FilterableDurations = IDuration & IFilterable; + +const durations: FilterableDurations[] = [ + { + value: 30, + key: '30 minutes', + displayValue: '30 minutes', + filterValue: '30 minutes' + }, + { + value: 60, + key: '1 hour', + displayValue: '1 hour', + filterValue: '1 hour' + }, + { + value: 60 * 4, + key: '4 hours', + displayValue: '4 hours', + filterValue: '4 hours' + }, + { + value: 60 * 12, + key: '12 hours', + displayValue: '12 hours', + filterValue: '12 hours' + }, + { + value: 60 * 24, + key: '1 day', + displayValue: '1 day', + filterValue: '1 day' + } +]; + +export default function Analytics() { + const [selectedDuration, setSelectedDuration] = + useState(durations[0]); + const { flag } = useOutletContext(); + const namespace = useSelector(selectCurrentNamespace); + + const nowISO = parseISO(new Date().toISOString()); + + const getFlagEvaluationCount = useGetFlagEvaluationCountQuery({ + namespaceKey: namespace.key, + flagKey: flag.key, + from: format( + addMinutes( + addMinutes( + nowISO, + selectedDuration?.value ? selectedDuration.value * -1 : -60 + ), + nowISO.getTimezoneOffset() + ), + timeFormat + ), + to: format(addMinutes(nowISO, nowISO.getTimezoneOffset()), timeFormat) + }); + + const flagEvaluationCount = useMemo(() => { + const fetchError = getFlagEvaluationCount.error as FetchBaseQueryError; + return { + timestamps: getFlagEvaluationCount.data?.timestamps, + values: getFlagEvaluationCount.data?.values, + unavailable: fetchError?.status === 501 + }; + }, [getFlagEvaluationCount]); + + const initialValues = { + durationValue: selectedDuration?.key + }; + + return ( +
+ {!flagEvaluationCount.unavailable ? ( + <> + <> + + {() => ( + + id="durationValue" + name="durationValue" + placeholder="Select duration" + className="absolute right-28 z-10" + values={durations} + selected={selectedDuration} + setSelected={setSelectedDuration} + /> + )} + + +
+ +
+ + ) : ( +
+ +

Analytics Disabled

+

+ See the configuration{' '} + + documentation + {' '} + for more information. +

+
+
+ )} +
+ ); +} diff --git a/ui/src/app/flags/analyticsApi.tsx b/ui/src/app/flags/analyticsApi.tsx new file mode 100644 index 0000000000..de11e40e09 --- /dev/null +++ b/ui/src/app/flags/analyticsApi.tsx @@ -0,0 +1,23 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; +import { IFlagEvaluationCount } from '~/types/Analytics'; +import { internalQuery } from '~/utils/redux-rtk'; + +export const analyticsApi = createApi({ + reducerPath: 'analytics', + baseQuery: internalQuery, + tagTypes: ['Analytics'], + endpoints: (builder) => ({ + // get evaluation count + getFlagEvaluationCount: builder.query< + IFlagEvaluationCount, + { namespaceKey: string; flagKey: string; from: string; to: string } + >({ + query: ({ namespaceKey, flagKey, from, to }) => ({ + url: `/analytics/namespaces/${namespaceKey}/flags/${flagKey}`, + params: { from, to } + }) + }) + }) +}); + +export const { useGetFlagEvaluationCountQuery } = analyticsApi; diff --git a/ui/src/app/flags/rollouts/Rollouts.tsx b/ui/src/app/flags/rollouts/Rollouts.tsx index 5e140c5d00..361bcb071f 100644 --- a/ui/src/app/flags/rollouts/Rollouts.tsx +++ b/ui/src/app/flags/rollouts/Rollouts.tsx @@ -235,7 +235,7 @@ export default function Rollouts(props: RolloutsProps) { )} {/* rollouts */} -
+

Rollouts

diff --git a/ui/src/app/flags/variants/Variants.tsx b/ui/src/app/flags/variants/Variants.tsx index 8cfc3b3458..a569d8fc7e 100644 --- a/ui/src/app/flags/variants/Variants.tsx +++ b/ui/src/app/flags/variants/Variants.tsx @@ -1,7 +1,6 @@ import { PlusIcon } from '@heroicons/react/24/outline'; import { useRef, useState } from 'react'; import { useSelector } from 'react-redux'; -import { useOutletContext } from 'react-router-dom'; import { useDeleteVariantMutation } from '~/app/flags/flagsApi'; import { selectReadonly } from '~/app/meta/metaSlice'; import { selectCurrentNamespace } from '~/app/namespaces/namespacesSlice'; @@ -18,9 +17,7 @@ type VariantsProps = { flag: IFlag; }; -export default function Variants() { - const { flag } = useOutletContext(); - +export default function Variants({ flag }: VariantsProps) { const [showVariantForm, setShowVariantForm] = useState(false); const [editingVariant, setEditingVariant] = useState(null); const [showDeleteVariantModal, setShowDeleteVariantModal] = diff --git a/ui/src/components/Header.tsx b/ui/src/components/Header.tsx index 7a57686c3b..2922997bdf 100644 --- a/ui/src/components/Header.tsx +++ b/ui/src/components/Header.tsx @@ -19,7 +19,7 @@ export default function Header(props: HeaderProps) { const { session } = useSession(); return ( -
+
+ )} ); } diff --git a/ui/src/components/graphs/index.tsx b/ui/src/components/graphs/index.tsx new file mode 100644 index 0000000000..839cc46131 --- /dev/null +++ b/ui/src/components/graphs/index.tsx @@ -0,0 +1,94 @@ +import { + Chart as ChartJS, + BarElement, + CategoryScale, + LinearScale, + ArcElement, + TimeScale, + TimeSeriesScale, + Title, + Tooltip, + Legend +} from 'chart.js'; +import { useMemo } from 'react'; +import { Bar } from 'react-chartjs-2'; +import { useSelector } from 'react-redux'; +import { selectTimezone } from '~/app/preferences/preferencesSlice'; +import { useTimezone } from '~/data/hooks/timezone'; +import { Timezone } from '~/types/Preferences'; + +ChartJS.register( + ArcElement, + BarElement, + CategoryScale, + LinearScale, + TimeScale, + TimeSeriesScale, + Title, + Tooltip, + Legend +); + +const timeFormat = 'yyyy-MM-dd HH:mm:ss'; + +type BarGraphProps = { + flagKey: string; + timestamps: string[]; + values: number[]; +}; + +export function BarGraph({ flagKey, timestamps, values }: BarGraphProps) { + const timezone = useSelector(selectTimezone); + const { inTimezone } = useTimezone(); + const formattedTimestamps = timestamps.map((timestamp) => + inTimezone(timestamp).slice(0, -4) + ); + const isUTC = useMemo(() => timezone === Timezone.UTC, [timezone]); + + const xLabel = isUTC ? 'Time (UTC)' : 'Time (Local)'; + + return ( + <> + + + ); +} diff --git a/ui/src/data/api.ts b/ui/src/data/api.ts index 24d7f3dfd1..cac9eae1b4 100644 --- a/ui/src/data/api.ts +++ b/ui/src/data/api.ts @@ -5,6 +5,7 @@ export const apiURL = 'api/v1'; export const authURL = 'auth/v1'; export const evaluateURL = 'evaluate/v1'; export const metaURL = 'meta'; +export const internalURL = 'internal/v1'; const csrfTokenHeaderKey = 'x-csrf-token'; const sessionKey = 'session'; diff --git a/ui/src/store.ts b/ui/src/store.ts index 1827472d78..421d228779 100644 --- a/ui/src/store.ts +++ b/ui/src/store.ts @@ -10,6 +10,7 @@ import { namespaceKey, namespacesSlice } from '~/app/namespaces/namespacesSlice'; +import { analyticsApi } from './app/flags/analyticsApi'; import { flagsApi } from './app/flags/flagsApi'; import { rolloutTag, rolloutsApi } from './app/flags/rolloutsApi'; import { ruleTag, rulesApi } from './app/flags/rulesApi'; @@ -152,7 +153,8 @@ export const store = configureStore({ [rulesApi.reducerPath]: rulesApi.reducer, [rolloutsApi.reducerPath]: rolloutsApi.reducer, [tokensApi.reducerPath]: tokensApi.reducer, - [authProvidersApi.reducerPath]: authProvidersApi.reducer + [authProvidersApi.reducerPath]: authProvidersApi.reducer, + [analyticsApi.reducerPath]: analyticsApi.reducer }, middleware: (getDefaultMiddleware) => getDefaultMiddleware() @@ -164,7 +166,8 @@ export const store = configureStore({ rulesApi.middleware, rolloutsApi.middleware, tokensApi.middleware, - authProvidersApi.middleware + authProvidersApi.middleware, + analyticsApi.middleware ) }); diff --git a/ui/src/types/Analytics.ts b/ui/src/types/Analytics.ts new file mode 100644 index 0000000000..887f3850ab --- /dev/null +++ b/ui/src/types/Analytics.ts @@ -0,0 +1,4 @@ +export interface IFlagEvaluationCount { + timestamps: string[]; + values: number[]; +} diff --git a/ui/src/utils/redux-rtk.ts b/ui/src/utils/redux-rtk.ts index 9b1e01cca1..da3ed402f6 100644 --- a/ui/src/utils/redux-rtk.ts +++ b/ui/src/utils/redux-rtk.ts @@ -4,7 +4,7 @@ import { FetchBaseQueryError, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; -import { apiURL, checkResponse, defaultHeaders } from '~/data/api'; +import { apiURL, checkResponse, defaultHeaders, internalURL } from '~/data/api'; type CustomFetchFn = ( url: RequestInfo, options: RequestInit | undefined @@ -21,6 +21,11 @@ export const customFetchFn: CustomFetchFn = async (url, options) => { return response; }; +export const internalQuery = fetchBaseQuery({ + baseUrl: internalURL, + fetchFn: customFetchFn +}); + export const baseQuery: BaseQueryFn< string | FetchArgs, unknown, diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 276b134e2c..31bd181b04 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -23,6 +23,7 @@ export default defineConfig({ '/api/v1': fliptAddr, '/auth/v1': fliptAddr, '/evaluate/v1': fliptAddr, + '/internal/v1': fliptAddr, '/meta': fliptAddr }, origin: 'http://localhost:5173'