From 1cba9a4879b727fbc9ad1d3bcb9991a65825c63c Mon Sep 17 00:00:00 2001 From: Abeeujah Date: Thu, 7 Nov 2024 19:47:12 +0100 Subject: [PATCH] feat: Azure Blob Storage Exporter --- README.md | 57 ++++---- cmd/relayproxy/config/exporter.go | 10 +- cmd/relayproxy/config/exporter_test.go | 9 ++ cmd/relayproxy/service/gofeatureflag.go | 10 ++ cmd/relayproxy/service/gofeatureflag_test.go | 24 ++++ exporter/azureexporter/exporter.go | 130 ++++++++++++++++++ exporter/azureexporter/exporter_test.go | 74 ++++++++++ go.mod | 7 + go.sum | 8 ++ .../configure_flag/export_flags_usage.mdx | 9 ++ .../data_collection/azure_blob_storage.md | 47 +++++++ .../docs/relay_proxy/configure_relay_proxy.md | 19 ++- website/src/components/home/features/index.js | 6 + website/static/docs/collectors/azblob.png | Bin 0 -> 3425 bytes 14 files changed, 380 insertions(+), 30 deletions(-) create mode 100644 exporter/azureexporter/exporter.go create mode 100644 exporter/azureexporter/exporter_test.go create mode 100644 website/docs/go_module/data_collection/azure_blob_storage.md create mode 100644 website/static/docs/collectors/azblob.png diff --git a/README.md b/README.md index 1dec8bfe2bd3..9958c0b531b5 100644 --- a/README.md +++ b/README.md @@ -19,9 +19,9 @@ Sponsords

-> :pray: If you are using **GO Feature Flag** please consider to add yourself in the [adopters](./ADOPTERS.md) list. +> :pray: If you are using **GO Feature Flag** please consider to add yourself in the [adopters](./ADOPTERS.md) list. > This simple act significantly boosts the project's visibility and credibility, making a substantial contribution to its advancement. -> +> > If you want to support me and GO Feature Flag, you can also [become a sponsor](https://github.com/sponsors/thomaspoignant). ## Table of Contents @@ -82,11 +82,11 @@ _The code of this demo is available in [`examples/demo`](examples/demo) reposito > [!IMPORTANT] > Before starting to use **GO Feature Flag** you should decide > if you want to use Open Feature SDKs or if you want to use GO Feature Flag as a GO Module. -> +> > We recommend using the relay-proxy for a central flag management and evaluation solution, -> it enables the multi-languages support, and it integrates seamlessly with the Open Feature SDKs. +> it enables the multi-languages support, and it integrates seamlessly with the Open Feature SDKs. > This is the best way to get full potential of GO Feature Flag. -> +> > If your project is exclusively in GO, the GO module is an option. It will perform the flag evaluation directly in your GO code. @@ -128,7 +128,7 @@ exporter: ### Install the relay proxy -And we will run the **relay proxy** locally to make the API available. +And we will run the **relay proxy** locally to make the API available. The default port will be `1031`. ```shell @@ -239,8 +239,8 @@ defer ffclient.Close() *This example will load a file from your local computer and will refresh the flags every 3 seconds (if you omit the PollingInterval, the default value is 60 seconds).* -> ℹ info -This is a basic configuration to test locally, in production it is better to use a remote place to store your feature flag configuration file. +> ℹ info +This is a basic configuration to test locally, in production it is better to use a remote place to store your feature flag configuration file. Look at the list of available options in the [**Store your feature flag file** page](https://gofeatureflag.org/docs/go_module/store_file/). ### Evaluate your flags @@ -255,18 +255,18 @@ if hasFlag { // flag "test-flag" is false for the user } ``` -The full documentation is available on https://docs.gofeatureflag.org +The full documentation is available on https://docs.gofeatureflag.org You can find more examples in the [examples/](https://github.com/thomaspoignant/go-feature-flag/tree/main/examples) directory. ## Can I use GO Feature Flag with any language? -Originally GO Feature Flag was built to be a GOlang only library, but it limits the ecosystem too much. +Originally GO Feature Flag was built to be a GOlang only library, but it limits the ecosystem too much. To be compatible with more languages we have implemented the [GO Feature Flag Relay Proxy](cmd/relayproxy/). It is a service you can host that provides an API to evaluate your flags, you can call it using HTTP to get your variation. -Since we believe in standardization we are also implementing [OpenFeature](https://github.com/open-feature) providers to interact with this API in the language of your choice. +Since we believe in standardization we are also implementing [OpenFeature](https://github.com/open-feature) providers to interact with this API in the language of your choice. _(OpenFeature is still at an early stage, so not all languages are supported and expect some changes in the future)_ For now, we have providers for: @@ -275,7 +275,7 @@ For now, we have providers for: |--------------------------------|-------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Go | [Go Provider](https://github.com/open-feature/go-sdk-contrib/tree/main/providers/go-feature-flag) | [![version](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fproxy.golang.org%2Fgithub.com%2Fopen-feature%2Fgo-sdk-contrib%2Fproviders%2Fgo-feature-flag%2F%40latest&query=%24.Version&label=GO&color=blue&style=flat-square&logo=golang)](https://github.com/open-feature/go-sdk-contrib/tree/main/providers/go-feature-flag) | | Java / Kotlin (server) | [Java Provider](https://github.com/open-feature/java-sdk-contrib/tree/main/providers/go-feature-flag) | [![version](https://img.shields.io/maven-central/v/dev.openfeature.contrib.providers/go-feature-flag?color=blue&style=flat-square&logo=java)](https://central.sonatype.com/artifact/dev.openfeature.contrib.providers/go-feature-flag) | -| Android / Kotlin (client) | [Kotlin Provider](openfeature/providers/kotlin-provider) | [![version](https://img.shields.io/maven-central/v/org.gofeatureflag.openfeature/gofeatureflag-kotlin-provider?color=blue&style=flat-square&logo=android)](https://central.sonatype.com/artifact/org.gofeatureflag.openfeature/gofeatureflag-kotlin-provider) | +| Android / Kotlin (client) | [Kotlin Provider](openfeature/providers/kotlin-provider) | [![version](https://img.shields.io/maven-central/v/org.gofeatureflag.openfeature/gofeatureflag-kotlin-provider?color=blue&style=flat-square&logo=android)](https://central.sonatype.com/artifact/org.gofeatureflag.openfeature/gofeatureflag-kotlin-provider) | | Javascript/Typescript (server) | [Server Provider](https://github.com/open-feature/js-sdk-contrib/tree/main/libs/providers/go-feature-flag) | [![version](https://img.shields.io/npm/v/%40openfeature%2Fgo-feature-flag-provider?color=blue&style=flat-square&logo=npm)](https://www.npmjs.com/package/@openfeature/go-feature-flag-provider) | | Javascript/Typescript (client) | [Client Provider](https://github.com/open-feature/js-sdk-contrib/tree/main/libs/providers/go-feature-flag-web) | [![version](https://img.shields.io/npm/v/%40openfeature%2Fgo-feature-flag-web-provider?color=blue&style=flat-square&logo=npm)](https://www.npmjs.com/package/@openfeature/go-feature-flag-web-provider) | | Python | [Python Provider](openfeature/providers/python-provider) | [![version](https://img.shields.io/pypi/v/gofeatureflag-python-provider?color=blue&style=flat-square&logo=pypi)](https://pypi.org/project/gofeatureflag-python-provider/) | @@ -283,11 +283,11 @@ For now, we have providers for: | Ruby | [Ruby Provider](https://github.com/open-feature/ruby-sdk-contrib/tree/main/providers/openfeature-go-feature-flag-provider) | [![version](https://img.shields.io/gem/v/openfeature-go-feature-flag-provider?color=blue&style=flat-square&logo=ruby)](https://rubygems.org/gems/openfeature-go-feature-flag-provider) | | Swift | [Swift Provider](https://github.com/go-feature-flag/openfeature-swift-provider) | [![version](https://img.shields.io/github/v/release/go-feature-flag/openfeature-swift-provider?label=Swift&display_name=tag&style=flat-square&logo=Swift)](https://github.com/go-feature-flag/openfeature-swift-provider) | | PHP | [PHP Provider](https://github.com/open-feature/php-sdk-contrib/tree/main/providers/GoFeatureFlag) | [![version](https://img.shields.io/packagist/v/open-feature/go-feature-flag-provider?logo=php&color=blue&style=flat-square)](https://packagist.org/packages/open-feature/go-feature-flag-provider) | - + ## Where do I store my flags file? -The module supports different ways of retrieving the flag file. +The module supports different ways of retrieving the flag file. The available retrievers are: - **GitHub** - **GitLab** @@ -307,7 +307,7 @@ _[See the full list and more information.](https://gofeatureflag.org/docs/config Your file should be a `YAML`, `JSON` or `TOML` file with a list of flags *(examples: [`YAML`](testdata/flag-config.yaml), [`JSON`](testdata/flag-config.json), [`TOML`](testdata/flag-config.toml))*. -The easiest way to create your configuration file is to use **GO Feature Flag Editor** available at https://editor.gofeatureflag.org. +The easiest way to create your configuration file is to use **GO Feature Flag Editor** available at https://editor.gofeatureflag.org. If you prefer to do it manually please follow the instruction below. **A flag configuration looks like this:** @@ -453,20 +453,20 @@ For detailed information on the fields required to create a flag, please refer t The query format is based on the [`nikunjy/rules`](https://github.com/nikunjy/rules) library. -All the operations can be written in capitalized or lowercase (ex: `eq` or `EQ` can be used). +All the operations can be written in capitalized or lowercase (ex: `eq` or `EQ` can be used). Logical Operations supported are `AND` `OR`. Compare Expression and their definitions (`a|b` means you can use either one of the two `a` or `b`): ``` -eq|==: equals to +eq|==: equals to ne|!=: not equals to -lt|<: less than +lt|<: less than gt|>: greater than le|<=: less than equal to -ge|>=: greater than equal to -co: contains -sw: starts with +ge|>=: greater than equal to +co: contains +sw: starts with ew: ends with in: in a list pr: present @@ -485,8 +485,8 @@ When using GO Feature Flag, it's often necessary to personalize the experience f For instance, GO Feature Flag ensures that in cases where a feature is being rolled out to a percentage of users, based on the targeting key, they will see the same variation each time they encounter the feature flag. -The targeting key is a fundamental part of the evaluation context because it directly affects the determination of which feature variant is served to a particular user, and it maintains that continuity over time. To do so GO Feature Flag to do a hash to define if the flag can apply to this evaluation context or not. -**We recommend using a hash if possible.** +The targeting key is a fundamental part of the evaluation context because it directly affects the determination of which feature variant is served to a particular user, and it maintains that continuity over time. To do so GO Feature Flag to do a hash to define if the flag can apply to this evaluation context or not. +**We recommend using a hash if possible.** Feature flag targeting and rollouts are all determined by the user you pass to your evaluation calls. @@ -497,7 +497,7 @@ In some cases, you might need to _bucket_ users based on a different key, e.g. a This can be achieved by defining the `bucketingKey` field in the flag configuration. When present, the value corresponding to the `bucketingKey` will be extracted from the attributes, and that value used for hashing and determining the outcome in place of the `targetingKey`. ## Variations -Variations are the different values possible for a feature flag. +Variations are the different values possible for a feature flag. GO Feature Flag can manage more than just `boolean` values; the value of your flag can be any of the following types: - `bool` - `int` @@ -517,7 +517,7 @@ Variation methods take the feature **flag key**, an **evaluation context**, and **Why do we need a default value?** If we have any error during the evaluation of the flag, we will return the default value, you will always get a value return from the function and we will never throw an error. -In the example, if the flag `your.feature.key` does not exist, the result will be `false`. +In the example, if the flag `your.feature.key` does not exist, the result will be `false`. Note that the result will always provide a usable value. ## Rollout @@ -548,7 +548,7 @@ Available notifiers are: - **Discord** ## Export data -**GO Feature Flag** allows you to export data about the usage of your flags. +**GO Feature Flag** allows you to export data about the usage of your flags. It collects all variation events and can save these events in several locations: - **Local file** *- create local files with the variation usages.* @@ -559,8 +559,9 @@ It collects all variation events and can save these events in several locations: - **Webhook** *- export your variation usages by calling a webhook.* - **AWS SQS** *- export your variation usages by sending events to SQS.* - **Google PubSub** *- export your variation usages by publishing events to PubSub topic.* +- **AzureBlobStorage** *- export your variation usages to Azure Blob Storage.* -Currently, we are supporting only feature events. +Currently, we are supporting only feature events. It represents individual flag evaluations and is considered "full fidelity" events. **An example feature event below:** @@ -588,7 +589,7 @@ A command line tool is available to help you lint your configuration file: [go-f This project welcomes contributions from the community. If you're interested in contributing, see the [contributors' guide](CONTRIBUTING.md) for some helpful tips. ## Community meetings -Since everyone's voice is important we want to hear back from the community. +Since everyone's voice is important we want to hear back from the community. For this reason, we are launching a community meeting every 2 weeks and it is the perfect place to discuss the future of GO Feature Flag and help you use it at full potential. | Name | Meeting Time | Meeting Notes | Discussions | diff --git a/cmd/relayproxy/config/exporter.go b/cmd/relayproxy/config/exporter.go index e62421903e63..3505372ac667 100644 --- a/cmd/relayproxy/config/exporter.go +++ b/cmd/relayproxy/config/exporter.go @@ -30,6 +30,9 @@ type ExporterConf struct { Topic string `mapstructure:"topic" koanf:"topic"` StreamArn string `mapstructure:"streamArn" koanf:"streamarn"` StreamName string `mapstructure:"streamName" koanf:"streamname"` + AccountName string `mapstructure:"accountName" koanf:"accountname"` + AccountKey string `mapstructure:"accountKey" koanf:"accountkey"` + Container string `mapstructure:"container" koanf:"container"` } func (c *ExporterConf) IsValid() error { @@ -65,6 +68,10 @@ func (c *ExporterConf) IsValid() error { return fmt.Errorf("invalid exporter: \"projectID\" and \"topic\" are required for kind \"%s\"", c.Kind) } + if c.Kind == AzureExporter && c.Container == "" { + return fmt.Errorf("invalid exporter: no \"container\" property found for kind \"%s\"", c.Kind) + } + return nil } @@ -80,13 +87,14 @@ const ( SQSExporter ExporterKind = "sqs" KafkaExporter ExporterKind = "kafka" PubSubExporter ExporterKind = "pubsub" + AzureExporter ExporterKind = "azureBlobStorage" ) // IsValid is checking if the value is part of the enum func (r ExporterKind) IsValid() error { switch r { case FileExporter, WebhookExporter, LogExporter, S3Exporter, GoogleStorageExporter, SQSExporter, KafkaExporter, - PubSubExporter, KinesisExporter: + PubSubExporter, KinesisExporter, AzureExporter: return nil } return fmt.Errorf("invalid exporter: kind \"%s\" is not supported", r) diff --git a/cmd/relayproxy/config/exporter_test.go b/cmd/relayproxy/config/exporter_test.go index 91e77094c727..6c17a33e44d8 100644 --- a/cmd/relayproxy/config/exporter_test.go +++ b/cmd/relayproxy/config/exporter_test.go @@ -24,6 +24,7 @@ func TestExporterConf_IsValid(t *testing.T) { ProjectID string Topic string StreamName string + Container string } tests := []struct { name string @@ -77,6 +78,14 @@ func TestExporterConf_IsValid(t *testing.T) { wantErr: true, errValue: "invalid exporter: no \"bucket\" property found for kind \"googleStorage\"", }, + { + name: "kind azureBlobStorage without container", + fields: fields{ + Kind: "azureBlobStorage", + }, + wantErr: true, + errValue: "invalid exporter: no \"container\" property found for kind \"azureBlobStorage\"", + }, { name: "kind webhook without bucket", fields: fields{ diff --git a/cmd/relayproxy/service/gofeatureflag.go b/cmd/relayproxy/service/gofeatureflag.go index e527351e1206..e8efef0026d4 100644 --- a/cmd/relayproxy/service/gofeatureflag.go +++ b/cmd/relayproxy/service/gofeatureflag.go @@ -10,6 +10,7 @@ import ( ffclient "github.com/thomaspoignant/go-feature-flag" "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config" "github.com/thomaspoignant/go-feature-flag/exporter" + "github.com/thomaspoignant/go-feature-flag/exporter/azureexporter" "github.com/thomaspoignant/go-feature-flag/exporter/fileexporter" "github.com/thomaspoignant/go-feature-flag/exporter/gcstorageexporter" "github.com/thomaspoignant/go-feature-flag/exporter/kafkaexporter" @@ -303,6 +304,15 @@ func createExporter(c *config.ExporterConf) (exporter.CommonExporter, error) { ProjectID: c.ProjectID, Topic: c.Topic, }, nil + case config.AzureExporter: + return &azureexporter.Exporter{ + Container: c.Container, + Format: format, + Path: c.Path, + Filename: filename, + CsvTemplate: csvTemplate, + ParquetCompressionCodec: parquetCompressionCodec, + }, nil default: return nil, fmt.Errorf("invalid exporter: kind \"%s\" is not supported", c.Kind) } diff --git a/cmd/relayproxy/service/gofeatureflag_test.go b/cmd/relayproxy/service/gofeatureflag_test.go index e9de5df3cccf..0460c932989d 100644 --- a/cmd/relayproxy/service/gofeatureflag_test.go +++ b/cmd/relayproxy/service/gofeatureflag_test.go @@ -10,6 +10,7 @@ import ( ffclient "github.com/thomaspoignant/go-feature-flag" "github.com/thomaspoignant/go-feature-flag/cmd/relayproxy/config" "github.com/thomaspoignant/go-feature-flag/exporter" + "github.com/thomaspoignant/go-feature-flag/exporter/azureexporter" "github.com/thomaspoignant/go-feature-flag/exporter/fileexporter" "github.com/thomaspoignant/go-feature-flag/exporter/gcstorageexporter" "github.com/thomaspoignant/go-feature-flag/exporter/kafkaexporter" @@ -393,6 +394,29 @@ func Test_initExporter(t *testing.T) { wantType: &kinesisexporter.Exporter{}, skipCompleteValidation: true, }, + { + name: "Azure Blob Storage Exporter", + wantErr: assert.NoError, + conf: &config.ExporterConf{ + Kind: "azureBlobStorage", + Container: "my-container", + Path: "/my-path/", + MaxEventInMemory: 1990, + }, + want: ffclient.DataExporter{ + FlushInterval: config.DefaultExporter.FlushInterval, + MaxEventInMemory: 1990, + Exporter: &azureexporter.Exporter{ + Container: "my-container", + Format: config.DefaultExporter.Format, + Path: "/my-path/", + Filename: config.DefaultExporter.FileName, + CsvTemplate: config.DefaultExporter.CsvFormat, + ParquetCompressionCodec: config.DefaultExporter.ParquetCompressionCodec, + }, + }, + wantType: &azureexporter.Exporter{}, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/exporter/azureexporter/exporter.go b/exporter/azureexporter/exporter.go new file mode 100644 index 000000000000..5e145926f780 --- /dev/null +++ b/exporter/azureexporter/exporter.go @@ -0,0 +1,130 @@ +package azureexporter + +import ( + "context" + "fmt" + "log/slog" + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/azidentity" + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/thomaspoignant/go-feature-flag/exporter" + "github.com/thomaspoignant/go-feature-flag/exporter/fileexporter" + "github.com/thomaspoignant/go-feature-flag/utils/fflog" +) + +type Exporter struct { + // Container is the name of your Azure Blob Storage Container similar to Buckets in S3. + Container string + + // Storage Account Name and Key + AccountName string + AccountKey string + + // Format is the output format you want in your exported file. + // Available format are JSON, CSV, and Parquet. + // Default: JSON + Format string + + // Path allows you to specify in which directory you want to export your data. + Path string + + // Filename is the name of your output file + // You can use a templated config to define the name of your export files. + // Available replacement are {{ .Hostname}}, {{ .Timestamp}} and {{ .Format}} + // Default: "flag-variation-{{ .Hostname}}-{{ .Timestamp}}.{{ .Format}}" + Filename string + + // CsvTemplate is used if your output format is CSV. + // This field will be ignored if you are using another format than CSV. + // You can decide which fields you want in your CSV line with a go-template syntax, + // please check exporter/feature_event.go to see what are the fields available. + // Default: + // {{ .Kind}};{{ .ContextKind}};{{ .UserKey}};{{ .CreationDate}};{{ .Key}};{{ .Variation}};{{ .Value}};{{ .Default}}\n + CsvTemplate string + + // ParquetCompressionCodec is the parquet compression codec for better space efficiency. + // Available options https://github.com/apache/parquet-format/blob/master/Compression.md + // Default: SNAPPY + ParquetCompressionCodec string +} + +func (f *Exporter) initializeAzureClient() (*azblob.Client, error) { + url := fmt.Sprintf("https://%s.blob.core.windows.net/", f.AccountName) + + if f.AccountKey == "" { + cred, err := azidentity.NewDefaultAzureCredential(nil) + if err != nil { + return nil, err + } + return azblob.NewClient(url, cred, nil) + } + cred, err := azblob.NewSharedKeyCredential(f.AccountName, f.AccountKey) + if err != nil { + return nil, err + } + return azblob.NewClientWithSharedKeyCredential(url, cred, nil) +} + +func (f *Exporter) Export(ctx context.Context, logger *fflog.FFLogger, featureEvents []exporter.FeatureEvent) error { + client, err := f.initializeAzureClient() + if err != nil { + return err + } + + if f.Container == "" { + return fmt.Errorf("you should specify a container. %v is invalid", f.Container) + } + + outputDir, err := os.MkdirTemp("", "go_feature_flag_AzureBlobStorage_export") + if err != nil { + return err + } + defer func() { _ = os.Remove(outputDir) }() + + fileExporter := fileexporter.Exporter{ + Format: f.Format, + OutputDir: outputDir, + Filename: f.Filename, + CsvTemplate: f.CsvTemplate, + ParquetCompressionCodec: f.ParquetCompressionCodec, + } + err = fileExporter.Export(ctx, logger, featureEvents) + if err != nil { + return err + } + + files, err := os.ReadDir(outputDir) + if err != nil { + return err + } + + for _, file := range files { + fileName := file.Name() + of, err := os.Open(outputDir + fileName) + if err != nil { + logger.Error("[Azure Exporter] impossible to open file", slog.String("path", outputDir+"/"+fileName)) + continue + } + defer func() { _ = of.Close() }() + + // prepend the path + source := fileName + if f.Path != "" { + source = f.Path + "/" + fileName + } + + _, err = client.UploadFile(context.Background(), f.Container, source, of, nil) + if err != nil { + logger.Error("[Azure Exporter] failed to upload file", slog.String("path", outputDir+"/"+fileName)) + return err + } + + logger.Info("[Azure Exporter] file uploaded.", slog.String("location", f.Container+"/"+fileName)) + } + return nil +} + +func (f *Exporter) IsBulk() bool { + return true +} diff --git a/exporter/azureexporter/exporter_test.go b/exporter/azureexporter/exporter_test.go new file mode 100644 index 000000000000..eaa58c2abf6d --- /dev/null +++ b/exporter/azureexporter/exporter_test.go @@ -0,0 +1,74 @@ +package azureexporter_test + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/thomaspoignant/go-feature-flag/exporter" + "github.com/thomaspoignant/go-feature-flag/exporter/azureexporter" + "github.com/thomaspoignant/go-feature-flag/utils/fflog" +) + +func TestAzureBlobStorage_Export(t *testing.T) { + hostname, _ := os.Hostname() + type fields struct { + Container string + AccountName string + AccountKey string + Format string + Path string + Filename string + CsvTemplate string + } + + tests := []struct { + name string + fields fields + events []exporter.FeatureEvent + wantErr bool + expectedName string + }{ + { + name: "All default test", + fields: fields{ + Container: "test", + }, + events: []exporter.FeatureEvent{ + { + Kind: "feature", ContextKind: "anonymousUser", UserKey: "ABCD", CreationDate: 1617970547, Key: "random-key", + Variation: "Default", Value: "YO", Default: false, + }, + }, + expectedName: "^flag-variation-" + hostname + "-[0-9]*\\.json$", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + f := azureexporter.Exporter{ + Container: tt.fields.Container, + AccountName: tt.fields.AccountName, + AccountKey: tt.fields.AccountKey, + Format: tt.fields.Format, + Path: tt.fields.Path, + Filename: tt.fields.Filename, + CsvTemplate: tt.fields.CsvTemplate, + } + + err := f.Export(context.Background(), &fflog.FFLogger{LeveledLogger: slog.Default()}, tt.events) + if tt.wantErr { + assert.Error(t, err, "Export should error") + return + } + assert.NoError(t, err, "Export should not error") + }) + } +} + +func TestAzureBlobStorage_IsBulk(t *testing.T) { + exporter := azureexporter.Exporter{} + assert.True(t, exporter.IsBulk(), "exporter is a bulk exporter") +} diff --git a/go.mod b/go.mod index 91d4ee089ce2..06dfd51dc7bd 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.22.8 require ( cloud.google.com/go/pubsub v1.45.1 cloud.google.com/go/storage v1.46.0 + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 + github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 github.com/BurntSushi/toml v1.4.0 github.com/IBM/sarama v1.43.3 github.com/aws/aws-lambda-go v1.47.0 @@ -79,7 +81,10 @@ require ( cloud.google.com/go/iam v1.2.1 // indirect cloud.google.com/go/monitoring v1.21.1 // indirect dario.cat/mergo v1.0.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 // indirect github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.24.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.48.1 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.48.1 // indirect @@ -139,6 +144,7 @@ require ( github.com/go-viper/mapstructure/v2 v2.0.0-alpha.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt v3.2.2+incompatible // indirect + github.com/golang-jwt/jwt/v4 v4.5.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/golang/snappy v0.0.4 // indirect @@ -187,6 +193,7 @@ require ( github.com/opencontainers/image-spec v1.1.0 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect + github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pkg/xattr v0.4.10 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect diff --git a/go.sum b/go.sum index ab25f5577bcd..e800bc781d63 100644 --- a/go.sum +++ b/go.sum @@ -101,19 +101,24 @@ github.com/Azure/azure-amqp-common-go/v3 v3.2.1/go.mod h1:O6X1iYHP7s2x7NjUKsXVhk github.com/Azure/azure-amqp-common-go/v3 v3.2.2/go.mod h1:O6X1iYHP7s2x7NjUKsXVhkwWrQhxrd+d8/3rRadj4CI= github.com/Azure/azure-pipeline-go v0.2.3/go.mod h1:x841ezTBIMG6O3lAcl8ATHnsOPVl2bqk7S3ta6S6u4k= github.com/Azure/azure-sdk-for-go v51.1.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go v59.3.0+incompatible h1:dPIm0BO4jsMXFcCI/sLTPkBtE7mk8WMuRHA0JeWhlcQ= github.com/Azure/azure-sdk-for-go v59.3.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= github.com/Azure/azure-sdk-for-go/sdk/azcore v0.19.0/go.mod h1:h6H6c8enJmmocHUbLiiGY6sx7f9i+X3m1CHdd5c6Rdw= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.0.0/go.mod h1:uGG2W01BaETf0Ozp+QxxKJdMBNRWPdstHG0Fmdwn1/U= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0 h1:8kDqDngH+DmVBiCtIjCFTGa7MBnsIOkF9IccInFEbjk= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.6.0/go.mod h1:bjGvMhVMb+EEm3VRNQawDMUyMMjo+S5ewNjflkep/0Q= github.com/Azure/azure-sdk-for-go/sdk/azidentity v0.11.0/go.mod h1:HcM1YX14R7CJcghJGOYCgdezslRSVzqwLf/q+4Y2r/0= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.0.0/go.mod h1:+6sju8gk8FRmSajX3Oz4G5Gm7P+mbqE9FVaXXFYTkCM= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0 h1:vcYCAze6p19qBW7MhZybIsqD8sMV8js0NyQM8JDnVtg= github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.3.0/go.mod h1:OQeznEEkTZ9OrhHJoDD8ZDq51FHgXjqtP9z6bEwBq9U= github.com/Azure/azure-sdk-for-go/sdk/internal v0.7.0/go.mod h1:yqy467j36fJxcRV2TzfVZ1pCb5vxm4BtZPUdYWe/Xo8= github.com/Azure/azure-sdk-for-go/sdk/internal v1.0.0/go.mod h1:eWRD7oawr1Mu1sLCawqVc0CUiF43ia3qQMxLscsKQ9w= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0 h1:sXr+ck84g/ZlZUOZiNELInmMgOsuGwdjjVkEIde0OtY= github.com/Azure/azure-sdk-for-go/sdk/internal v1.3.0/go.mod h1:okt5dMMTOFjX/aovMlrjvvXoPMBVSPzk9185BT0+eZM= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/internal v1.0.0/go.mod h1:ceIuwmxDWptoW3eCqSXlnPsZFKh4X+R38dWPv7GS9Vs= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.0.0/go.mod h1:s1tW/At+xHqjNFvWU4G0c0Qv33KOhvbGNj0RCTQDV8s= github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.2.0/go.mod h1:c+Lifp3EDEamAkPVzMooRNOK6CZjNSdEnf1A7jsI9u4= +github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0 h1:nVocQV40OQne5613EeLayJiRAJuKlBGy+m22qWG+WRg= github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.1.0/go.mod h1:7QJP7dr2wznCMeqIrhMgWGf7XpAQnVrJqDm9nvV3Cu4= github.com/Azure/azure-service-bus-go v0.11.5/go.mod h1:MI6ge2CuQWBVq+ly456MY7XqNLJip5LO1iSFodbNLbU= github.com/Azure/azure-storage-blob-go v0.14.0/go.mod h1:SMqIBi+SuiQH32bvyjngEewEeXoPfKMgWlBDaYf6fck= @@ -138,6 +143,7 @@ github.com/Azure/go-autorest/autorest/validation v0.3.1/go.mod h1:yhLgjC0Wda5DYX github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= github.com/AzureAD/microsoft-authentication-library-for-go v0.4.0/go.mod h1:Vt9sXTKwMyGcOxSmLDMnGPgqsUg7m8pe215qMLrDXw4= +github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0 h1:OBhqkivkhkMqLPymWEppkm7vgPQY2XsHoEkaMQ0AdZY= github.com/AzureAD/microsoft-authentication-library-for-go v1.0.0/go.mod h1:kgDmCTgBzIEPFElEF+FK0SdjAor06dRq2Go927dnQ6o= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0= @@ -409,6 +415,7 @@ github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzw github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang-sql/sqlexp v0.0.0-20170517235910-f1bb20e5a188/go.mod h1:vXjM/+wXQnTPR4KqTKDgJukSZ6amVRtWMPEjE6sQoK8= @@ -763,6 +770,7 @@ 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/pkg/browser v0.0.0-20180916011732-0a3d74bf9ce4/go.mod h1:4OwLy04Bl9Ef3GJJCoec+30X3LQs/0/m4HFRt/2LUSA= github.com/pkg/browser v0.0.0-20210115035449-ce105d075bb4/go.mod h1:N6UoU20jOqggOuDwUaBQpluzLNDqif3kq9z2wpdYEfQ= +github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU= github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= diff --git a/website/docs/configure_flag/export_flags_usage.mdx b/website/docs/configure_flag/export_flags_usage.mdx index 15ca944ed7ba..37791a6d6b80 100644 --- a/website/docs/configure_flag/export_flags_usage.mdx +++ b/website/docs/configure_flag/export_flags_usage.mdx @@ -19,6 +19,7 @@ import sqslogo from '@site/static/docs/collectors/sqs.png'; import kafkalogo from '@site/static/docs/collectors/kafka.png'; import pubsublogo from '@site/static/docs/collectors/pubsub.png'; import kinesislogo from '@site/static/docs/collectors/kinesis.png'; +import azbloblogo from '@site/static/docs/collectors/azblob.png'; # How to export evaluation data @@ -106,6 +107,14 @@ To use, simply configure and use the feature flag as normal, and analyze the col goModuleLink={'../go_module/data_collection/kinesis'} /> }, + { + logoImg: azbloblogo, + title: "Azure Blob Storage", + content: + }, { logoImg: customlogo, title: "Custom ...", diff --git a/website/docs/go_module/data_collection/azure_blob_storage.md b/website/docs/go_module/data_collection/azure_blob_storage.md new file mode 100644 index 000000000000..08670f095a76 --- /dev/null +++ b/website/docs/go_module/data_collection/azure_blob_storage.md @@ -0,0 +1,47 @@ +--- +sidebar_position: 2 +--- + +# Azure Blob Storage Exporter + +The **Azure Blob Storage exporter** will collect the data and create a new file in a specific folder everytime we send the data. + +Everytime the `FlushInterval` or `MaxEventInMemory` is reached, a new file will be added to Google Cloud Storage. + +:::info +If for some reason the Azure Blob Storage upload failed, we will keep the data in memory and retry to add it the next time we reach `FlushInterval` or `MaxEventInMemory`. +::: + +Check this [complete example](https://github.com/thomaspoignant/go-feature-flag/tree/main/examples/data_export_azureblobstorage) to see how to export the data in S3. + +## Configuration example +```go showLineNumbers +ffclient.Config{ + // ... + DataExporter: ffclient.DataExporter{ + // ... + Exporter: &azureexporter.Exporter{ + Container: "test-goff", + Format: "json", + Path: "yourPath", + Filename: "flag-variation-{{ .Timestamp}}.{{ .Format}}", + AccountName: "goff", + AccountKey: "XXXX", + }, + }, + // ... +} +``` + +## Configuration fields +| Field | Description | +|---------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `Container ` | Name of your Azure Blob Storage Container. | +| `CsvTemplate` | *(optional)* CsvTemplate is used if your output format is CSV. This field will be ignored if you are using format other than CSV. You can decide which fields you want in your CSV line with a go-template syntax, please check [internal/exporter/feature_event.go](https://github.com/thomaspoignant/go-feature-flag/blob/main/internal/exporter/feature_event.go) to see what are the fields available.
**Default:** `{{ .Kind}};{{ .ContextKind}};{{ .UserKey}};{{ .CreationDate}};{{ .Key}};{{ .Variation}};{{ .Value}};{{ .Default}};{{ .Source}}\n` | +| `Filename` | *(optional)* Filename is the name of your output file. You can use a templated config to define the name of your exported files.
Available replacements are `{{ .Hostname}}`, `{{ .Timestamp}`} and `{{ .Format}}`
Default: `flag-variation-{{ .Hostname}}-{{ .Timestamp}}.{{ .Format}}` | +| `Format` | *(optional)* Format is the output format you want in your exported file. Available formats are **`JSON`**, **`CSV`**, **`Parquet`**. *(Default: `JSON`)* | +| `Options` | *(optional)* An instance of `option.ClientOption` that configures your access to Google Cloud.
Check [this documentation for more info](https://cloud.google.com/docs/authentication). | +| `Path ` | *(optional)* The location of the directory in your container. | +| `ParquetCompressionCodec` | *(optional)* ParquetCompressionCodec is the parquet compression codec for better space efficiency. [Available options](https://github.com/apache/parquet-format/blob/master/Compression.md) *(Default: `SNAPPY`)* |` + +Check the [godoc for full details](https://pkg.go.dev/github.com/thomaspoignant/go-feature-flag/exporter/azureexporter). diff --git a/website/docs/relay_proxy/configure_relay_proxy.md b/website/docs/relay_proxy/configure_relay_proxy.md index dc4e17a067e0..a62bea197cfe 100644 --- a/website/docs/relay_proxy/configure_relay_proxy.md +++ b/website/docs/relay_proxy/configure_relay_proxy.md @@ -51,7 +51,7 @@ ex: `AUTHORIZEDKEYS_EVALUATION=my-first-key,my-second-key`)_. | `authorizedKeys` | [authorizedKeys](#type-authorizedkeys) | **none** | List of authorized API keys. | | `evaluationContextEnrichment` | object | **none** | It is a free field that will be merged with the evaluation context sent during the evaluation. It is useful to add common attributes to all the evaluations, such as a server version, environment, etc.

These fields will be included in the custom attributes of the evaluation context.

If in the evaluation context you have a field with the same name, it will be override by the `evaluationContextEnrichment`. | | `openTelemetryOtlpEndpoint` | string | **none** | Endpoint of your OpenTelemetry OTLP collector, used to send traces to it and you will be able to forward them to your OpenTelemetry solution with the appropriate provider. | -| `kafka` | object | **none** | Settings for the Kafka exporter. Mandatory when using the 'kafka' exporter type, and ignored otherwise. | +| `kafka` | object | **none** | Settings for the Kafka exporter. Mandatory when using the 'kafka' exporter type, and ignored otherwise. | | `projectID` | string | **none** | ID of GCP project. Mandatory when using PubSub exporter. | | `topic` | string | **none** | Name of PubSub topic on which messages will be published. Mandatory when using PubSub exporter. | | `persistentFlagConfigurationFile` | string | **none** | If set GO Feature Flag will store the flags configuration in this file to be able to serve the flags even if none of the retrievers is available during starting time.
By default, the flag configuration is not persisted and stays on the retriever system. By setting a file here, you ensure that GO Feature Flag will always start with a configuration but which can be out-dated.

_(example: `/tmp/goff_persist_conf.yaml`)_ | @@ -305,3 +305,20 @@ the [doc](../go_module/store_file/redis#expected-format) available._ | `secret` | string | **none** | Secret used to sign your request body and fill the `X-Hub-Signature-256` header.
See [signature section](https://thomaspoignant.github.io/go-feature-flag/latest/data_collection/webhook/#signature) for more details. | | `meta` | map[string]string | **none** | Add all the information you want to see in your request. | | `headers` | map[string][]string | **none** | Add all the headers you want to add while calling the endpoint | + +### Azure Blob Storage + +If you are using the Azure blob storage, the easiest way to provide credentials is to set environment variables. +It will be used by GO Feature Flag to identify to your Azure blob storage container. + +```shell +export AZURE_STORAGE_ACCOUNT=xxxx +export AZURE_STORAGE_KEY=xxx +export AWS_DEFAULT_REGION=eu-west-1 +``` + +| Field name | Type | Default | Description | +|------------|--------|----------|----------------------------------------------------------------------------------------------------------------------| +| `kind` | string | **none** | **(mandatory)** Value should be **`azureBlobStorage`**.
_This field is mandatory and describes which retriever you are using._ | +| `container` | string | **none** | **(mandatory)** This is the name of your azure blob storage container _(ex: `my-featureflag-container`)_. | +| `item` | string | **none** | **(mandatory)** Path to the file inside the container _(ex: `config/flag/my-flags.yaml`)_. | diff --git a/website/src/components/home/features/index.js b/website/src/components/home/features/index.js index f1821dbedd07..facb157eebe5 100644 --- a/website/src/components/home/features/index.js +++ b/website/src/components/home/features/index.js @@ -8,6 +8,7 @@ import pubsublogo from '@site/static/docs/collectors/pubsub.png'; import s3logo from '@site/static/docs/collectors/s3.png'; import kinesislogo from '@site/static/docs/collectors/kinesis.png'; import webhooklogo from '@site/static/docs/collectors/webhook.png'; +import azbloblogo from '@site/static/docs/collectors/azblob.png'; import {Headline} from '../headline'; SocialIcon.propTypes = { @@ -319,6 +320,11 @@ function Integration() { img={kinesislogo} tooltipText="AWS Kinesis" /> + diff --git a/website/static/docs/collectors/azblob.png b/website/static/docs/collectors/azblob.png new file mode 100644 index 0000000000000000000000000000000000000000..b2d72677d87ac90a8d02dc4c94b10a5f3c8edaa9 GIT binary patch literal 3425 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 D@j>Jo literal 0 HcmV?d00001