From c94c1ef9f0aaca4744564bd7bb24900caf4e3dad Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Sun, 26 Apr 2026 19:42:17 +0000 Subject: [PATCH 1/3] feat(notification): add webhook notification system for flag CRUD operations - Add generic HTTP webhook notifier with retry, jitter, and config validation - Fire notifications on all flag CRUD operations (create/update/delete/restore) - Track component changes (flag/segment/variant/constraint/distribution/tag) - Async dispatch with semaphore limiting (max 100 concurrent) - Privacy-by-default: diffs disabled unless FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true - Payload: operation, flag_id, flag_key, component_type, component_id, component_key, pre_value, post_value, diff, user, timestamp (snake_case JSON with omitempty) - Statsd metrics (notification.sent) tagged by provider, operation, and status - Docs: flagr_notifications.md with payload spec and env var reference --- docs/_sidebar.md | 1 + docs/flagr_notifications.md | 76 ++++++++ go.mod | 58 +++--- go.sum | 125 ++++++------- pkg/config/env.go | 20 +++ pkg/entity/flag_snapshot.go | 48 ++++- pkg/entity/flag_snapshot_test.go | 6 +- pkg/handler/crud.go | 40 +++-- pkg/handler/crud_flag_creation.go | 3 +- pkg/handler/crud_notification_test.go | 192 ++++++++++++++++++++ pkg/handler/data_recorder_pubsub.go | 8 +- pkg/handler/data_recorder_pubsub_test.go | 8 +- pkg/handler/export_test.go | 7 +- pkg/handler/handler.go | 10 +- pkg/notification/dispatch.go | 124 +++++++++++++ pkg/notification/dispatch_test.go | 216 +++++++++++++++++++++++ pkg/notification/notifier.go | 116 ++++++++++++ pkg/notification/notifier_test.go | 133 ++++++++++++++ pkg/notification/retry.go | 78 ++++++++ pkg/notification/retry_test.go | 195 ++++++++++++++++++++ pkg/notification/validate.go | 16 ++ pkg/notification/webhook.go | 78 ++++++++ pkg/notification/webhook_test.go | 111 ++++++++++++ pkg/util/util.go | 24 +++ pkg/util/util_test.go | 87 +++++++++ 25 files changed, 1649 insertions(+), 131 deletions(-) create mode 100644 docs/flagr_notifications.md create mode 100644 pkg/handler/crud_notification_test.go create mode 100644 pkg/notification/dispatch.go create mode 100644 pkg/notification/dispatch_test.go create mode 100644 pkg/notification/notifier.go create mode 100644 pkg/notification/notifier_test.go create mode 100644 pkg/notification/retry.go create mode 100644 pkg/notification/retry_test.go create mode 100644 pkg/notification/validate.go create mode 100644 pkg/notification/webhook.go create mode 100644 pkg/notification/webhook_test.go diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 61133af91..0edab0d8f 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -7,6 +7,7 @@ - [Debug Console](flagr_debugging.md) - Server Configuration - [Env](flagr_env.md) + - [Notifications](flagr_notifications.md) - Client SDKs - [Ruby SDK 🔗](https://github.com/openflagr/rbflagr) - [Go SDK 🔗](https://github.com/openflagr/goflagr) diff --git a/docs/flagr_notifications.md b/docs/flagr_notifications.md new file mode 100644 index 000000000..cebee7846 --- /dev/null +++ b/docs/flagr_notifications.md @@ -0,0 +1,76 @@ +# Notifications + +Flagr provides an integrated notification system that allows you to monitor changes and updates to your operational resources in real-time. You can configure Flagr to send HTTP `POST` webhooks whenever a flag is created, updated, deleted, or restored. + +## Tracked Operations + +Flagr monitors changes to **flags** and their related configuration. All notifications have `EntityType: "flag"` in the payload. + +The following operations trigger notifications: + +| Operation | Description | +|-----------|-------------| +| `create` | A new flag is created | +| `update` | Any change to a flag's metadata, enabled state, or any of its associated entities (segments, variants, constraints, distributions, tags) | +| `delete` | A flag is soft-deleted | +| `restore` | A soft-deleted flag is restored | + +**Note**: Operations such as adding/removing tags, updating segment rollout percentages, modifying constraints, or changing variant attachments all trigger an `update` notification for the parent flag. Enabling or disabling a flag is also considered an update. + +## Configuration + +To enable notifications, set the following environment variables: + +- `FLAGR_NOTIFICATION_WEBHOOK_ENABLED=true` (Default: `false`) — Enable webhook notifications. +- `FLAGR_NOTIFICATION_WEBHOOK_URL=https://api.your-org.com/webhooks/flagr` — HTTP destination endpoint for POST requests. +- `FLAGR_NOTIFICATION_WEBHOOK_HEADERS=Authorization: Bearer secret-token, X-Custom-Header: value` — (Optional) Custom comma-separated HTTP headers, often utilized for securing your webhook receiver with an API token. +- `FLAGR_NOTIFICATION_TIMEOUT=10s` (Default: `10s`) — Configures the timeout window for dialing the webhook endpoint. +- `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true` (Default: `false`) — When enabled, Flagr will embed the precise visual JSON diff of the modified flag within the notification payload. +- `FLAGR_NOTIFICATION_MAX_RETRIES=3` (Default: `3`) — Maximum number of retry attempts for transient HTTP failures (5xx errors). Set to `0` to disable retries. +- `FLAGR_NOTIFICATION_RETRY_BASE=1s` (Default: `1s`) — Base delay for exponential backoff between retries. +- `FLAGR_NOTIFICATION_RETRY_MAX=10s` (Default: `10s`) — Maximum delay between retries. + +### Concurrency & Observability + +- Notifications are sent asynchronously with a default concurrency limit of 100 to prevent resource exhaustion under load. +- Metric `notification.sent` is emitted when statsd is enabled, tagged with `provider`, `operation`, `entity_type`, and `status` (`success`/`failure`). + +### Important Notes + +- **Asynchronous delivery**: Notifications are sent in background goroutines. Failures are logged but **do not affect the API response**. +- **Startup validation**: Flagr validates the notification configuration at startup and logs a warning if `FLAGR_NOTIFICATION_WEBHOOK_URL` is not set while webhooks are enabled. +- **Silent fallback**: If webhooks are enabled but the URL is missing, notifications will be silently dropped. A warning is logged at startup to help diagnose misconfiguration. + +## Webhook Payload Format + +The target endpoint receives a structured JSON payload: + +```json +{ + "operation": "update", + "flag_id": 123, + "flag_key": "my-feature-flag", + "component_type": "segment", + "component_id": 7, + "component_key": "power-users", + "pre_value": "...", + "post_value": "...", + "diff": "--- Previous\n+++ Current\n@@ ...", + "user": "admin@example.com", + "timestamp": "2026-04-26T18:51:03Z" +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `operation` | string | `create`, `update`, `delete`, or `restore` | +| `flag_id` | uint | Database ID of the parent flag | +| `flag_key` | string | Unique key of the parent flag | +| `component_type` | string | What part of the flag changed: `flag`, `segment`, `variant`, `constraint`, `distribution`, or `tag` | +| `component_id` | uint | Database ID of the changed component | +| `component_key` | string | Key/name of the changed component (e.g. variant key, tag value) | +| `pre_value` | string | Previous flag snapshot JSON (only if `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true`) +| `post_value` | string | Current flag snapshot JSON (only if `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true`) +| `diff` | string | Unified diff between previous and current (only if `FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED=true`) +| `user` | string | Identity of the user who made the change | +| `timestamp` | string | UTC timestamp of the change in RFC 3339 format | diff --git a/go.mod b/go.mod index 84946567c..0061c03a5 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/bsm/ratelimit v2.0.0+incompatible github.com/caarlos0/env v3.5.0+incompatible github.com/certifi/gocertifi v0.0.0-20210507211836-431795d63e8d // indirect - github.com/davecgh/go-spew v1.1.1 + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc github.com/dchest/uniuri v1.2.0 github.com/evalphobia/logrus_sentry v0.8.2 github.com/form3tech-oss/jwt-go v3.2.5+incompatible @@ -47,13 +47,12 @@ require ( github.com/zhouzhuojie/conditions v0.2.3 github.com/zhouzhuojie/withtimeout v0.0.0-20190405051827-12b39eb2edd5 golang.org/x/net v0.48.0 - google.golang.org/api v0.247.0 + google.golang.org/api v0.257.0 google.golang.org/grpc v1.79.3 gopkg.in/DataDog/dd-trace-go.v1 v1.46.0 ) require ( - cloud.google.com/go/pubsub v1.49.0 github.com/glebarez/sqlite v1.6.0 github.com/newrelic/go-agent v2.1.0+incompatible gorm.io/driver/mysql v1.4.5 @@ -62,15 +61,17 @@ require ( ) require ( - github.com/aws/aws-sdk-go-v2/config v1.31.20 + cloud.google.com/go/pubsub/v2 v2.0.0 + github.com/aws/aws-sdk-go-v2/config v1.32.5 github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3 + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 ) require ( - cloud.google.com/go/auth v0.16.4 // indirect + cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect - cloud.google.com/go/iam v1.5.2 // indirect + cloud.google.com/go/iam v1.5.3 // indirect github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1 // indirect github.com/DataDog/datadog-agent/pkg/remoteconfig/state v0.42.0-rc.5 // indirect github.com/DataDog/datadog-go/v5 v5.2.0 // indirect @@ -78,19 +79,20 @@ require ( github.com/DataDog/sketches-go v1.4.1 // indirect github.com/Microsoft/go-winio v0.6.0 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/aws/aws-sdk-go-v2 v1.39.6 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.18.24 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.5 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 // indirect - github.com/aws/smithy-go v1.23.2 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect + github.com/aws/smithy-go v1.24.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgraph-io/ristretto v0.1.1 // indirect @@ -122,7 +124,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect @@ -139,26 +141,26 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/klauspost/compress v1.18.0 // indirect - github.com/mattn/go-isatty v0.0.17 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/oklog/ulid v1.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.17 // indirect github.com/pkg/errors v0.9.1 // indirect - github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/common v0.66.1 // indirect github.com/rcrowley/go-metrics v0.0.0-20201227073835-cf1acfcdf475 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect - go.einride.tech/aip v0.68.1 // indirect + github.com/stretchr/objx v0.5.3 // indirect + go.einride.tech/aip v0.73.0 // indirect go.mongodb.org/mongo-driver v1.17.4 // indirect go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect - go.opentelemetry.io/otel v1.41.0 // indirect - go.opentelemetry.io/otel/metric v1.41.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect go.opentelemetry.io/otel/sdk v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect go.uber.org/atomic v1.10.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -170,10 +172,10 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.40.0 // indirect golang.org/x/text v0.32.0 // indirect - golang.org/x/time v0.12.0 // indirect + golang.org/x/time v0.14.0 // indirect golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect - google.golang.org/genproto v0.0.0-20250603155806-513f23925822 // indirect + google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect google.golang.org/protobuf v1.36.10 // indirect diff --git a/go.sum b/go.sum index 0e6a5a795..897306606 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,16 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= -cloud.google.com/go/auth v0.16.4 h1:fXOAIQmkApVvcIn7Pc2+5J8QTMVbUGLscnSVNl11su8= -cloud.google.com/go/auth v0.16.4/go.mod h1:j10ncYwjX/g3cdX7GpEzsdM+d+ZNsXAbb6qXA7p1Y5M= +cloud.google.com/go/auth v0.17.0 h1:74yCm7hCj2rUyyAocqnFzsAYXgJhrG26XCFimrc/Kz4= +cloud.google.com/go/auth v0.17.0/go.mod h1:6wv/t5/6rOPAX4fJiRjKkJCvswLwdet7G8+UGXt7nCQ= cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= -cloud.google.com/go/iam v1.5.2 h1:qgFRAGEmd8z6dJ/qyEchAuL9jpswyODjA2lS+w234g8= -cloud.google.com/go/iam v1.5.2/go.mod h1:SE1vg0N81zQqLzQEwxL2WI6yhetBdbNQuTvIKCSkUHE= -cloud.google.com/go/kms v1.22.0 h1:dBRIj7+GDeeEvatJeTB19oYZNV0aj6wEqSIT/7gLqtk= -cloud.google.com/go/kms v1.22.0/go.mod h1:U7mf8Sva5jpOb4bxYZdtw/9zsbIjrklYwPcvMk34AL8= -cloud.google.com/go/longrunning v0.6.7 h1:IGtfDWHhQCgCjwQjV9iiLnUta9LBCo8R9QmAFsS/PrE= -cloud.google.com/go/longrunning v0.6.7/go.mod h1:EAFV3IZAKmM56TyiE6VAP3VoTzhZzySwI/YI1s/nRsY= -cloud.google.com/go/pubsub v1.49.0 h1:5054IkbslnrMCgA2MAEPcsN3Ky+AyMpEZcii/DoySPo= -cloud.google.com/go/pubsub v1.49.0/go.mod h1:K1FswTWP+C1tI/nfi3HQecoVeFvL4HUOB1tdaNXKhUY= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/pubsub/v2 v2.0.0 h1:0qS6mRJ41gD1lNmM/vdm6bR7DQu6coQcVwD+VPf0Bz0= +cloud.google.com/go/pubsub/v2 v2.0.0/go.mod h1:0aztFxNzVQIRSZ8vUr79uH2bS3jwLebwK6q1sgEub+E= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1 h1:AHZu7lzfW6amjOLkbjioAxT+pKiiwD6KdkR0VfT3pMw= github.com/DataDog/datadog-agent/pkg/obfuscate v0.41.1/go.mod h1:DNHeRExTGWQoMgmOgcDtNENOEHN/tYJIicmAUgW1nXk= @@ -45,36 +41,38 @@ github.com/auth0/go-jwt-middleware v1.0.2-0.20210804140707-b4090e955b98 h1:cH5eD github.com/auth0/go-jwt-middleware v1.0.2-0.20210804140707-b4090e955b98/go.mod h1:YSeUX3z6+TF2H+7padiEqNJ73Zy9vXW72U//IgN0BIM= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= -github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk= -github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE= +github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4= +github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y= -github.com/aws/aws-sdk-go-v2/config v1.31.20 h1:/jWF4Wu90EhKCgjTdy1DGxcbcbNrjfBHvksEL79tfQc= -github.com/aws/aws-sdk-go-v2/config v1.31.20/go.mod h1:95Hh1Tc5VYKL9NJ7tAkDcqeKt+MCXQB1hQZaRdJIZE0= -github.com/aws/aws-sdk-go-v2/credentials v1.18.24 h1:iJ2FmPT35EaIB0+kMa6TnQ+PwG5A1prEdAw+PsMzfHg= -github.com/aws/aws-sdk-go-v2/credentials v1.18.24/go.mod h1:U91+DrfjAiXPDEGYhh/x29o4p0qHX5HDqG7y5VViv64= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M= +github.com/aws/aws-sdk-go-v2/config v1.32.5 h1:pz3duhAfUgnxbtVhIK39PGF/AHYyrzGEyRD9Og0QrE8= +github.com/aws/aws-sdk-go-v2/config v1.32.5/go.mod h1:xmDjzSUs/d0BB7ClzYPAZMmgQdrodNjPPhd6bGASwoE= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5 h1:xMo63RlqP3ZZydpJDMBsH9uJ10hgHYfQFIk1cHDXrR4= +github.com/aws/aws-sdk-go-v2/credentials v1.19.5/go.mod h1:hhbH6oRcou+LpXfA/0vPElh/e0M3aFeOblE1sssAAEk= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o= -github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM= github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3 h1:A2HNxrABEFha5831yAU05G0mYNxaxYH4WG85FV6ZWIQ= github.com/aws/aws-sdk-go-v2/service/kinesis v1.42.3/go.mod h1:jTDNZao/9uv/6JeaeDWEqA4s+l6c8+cqaDeYFpM+818= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.3 h1:NjShtS1t8r5LUfFVtFeI8xLAHQNTa7UI0VawXlrBMFQ= -github.com/aws/aws-sdk-go-v2/service/sso v1.30.3/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7 h1:gTsnx0xXNQ6SBbymoDvcoRHL+q4l/dAFsQuKfDWSaGc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.7/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo= -github.com/aws/aws-sdk-go-v2/service/sts v1.40.2 h1:HK5ON3KmQV2HcAunnx4sKLB9aPf3gKGwVAf7xnx0QT0= -github.com/aws/aws-sdk-go-v2/service/sts v1.40.2/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk= -github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM= -github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7 h1:eYnlt6QxnFINKzwxP5/Ucs1vkG7VT3Iezmvfgc2waUw= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.7/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk= +github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= +github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/brandur/simplebox v0.1.0 h1:6LKvBOuQ/KNDtuNg0e/OTLeS6IDKN1osuXGF67xEynk= @@ -102,8 +100,9 @@ github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/Buvy github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= github.com/dgraph-io/ristretto v0.1.0/go.mod h1:fux0lOrBhrVCJd3lcTHsIJhq1T2rokOu6v9Vcb3Q9ug= @@ -255,8 +254,8 @@ github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/google/uuid v1.3.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/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= +github.com/googleapis/enterprise-certificate-proxy v0.3.7 h1:zrn2Ee/nWmHulBx5sAVrGgAa0f2/R35S4DJwfFaUPFQ= +github.com/googleapis/enterprise-certificate-proxy v0.3.7/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= @@ -327,8 +326,8 @@ github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= -github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= -github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= github.com/meatballhat/negroni-logrus v1.1.1 h1:eDgsDdJYy97gI9kr+YS/uDKCaqK4S6CUQLPG0vNDqZA= github.com/meatballhat/negroni-logrus v1.1.1/go.mod h1:FlwPdXB6PeT8EG/gCd/2766M2LNF7SwZiNGD6t2NRGU= @@ -364,8 +363,9 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo= github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= github.com/prometheus/client_golang v1.20.4 h1:Tgh3Yr67PaOv/uTqloMsCEdeuFTatm5zIq5+qNN23vI= @@ -403,8 +403,8 @@ github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qq github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= -github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -430,28 +430,28 @@ github.com/zhouzhuojie/conditions v0.2.3 h1:TS3X6vA9CVXXteRdeXtpOw3hAar+01f0TI/d github.com/zhouzhuojie/conditions v0.2.3/go.mod h1:Izhy98HD3MkfwGPz+p9ZV2JuqrpbHjaQbUq9iZHh+ZY= github.com/zhouzhuojie/withtimeout v0.0.0-20190405051827-12b39eb2edd5 h1:YuR5otuPvpk6EPrKy9rVXiQKTqgY6OEqSlzko9kcfCI= github.com/zhouzhuojie/withtimeout v0.0.0-20190405051827-12b39eb2edd5/go.mod h1:nhm/3zpPm56iKoXLEeeevuI5V9qEtNhuhLbPZwcrgcs= -go.einride.tech/aip v0.68.1 h1:16/AfSxcQISGN5z9C5lM+0mLYXihrHbQ1onvYTr93aQ= -go.einride.tech/aip v0.68.1/go.mod h1:XaFtaj4HuA3Zwk9xoBtTWgNubZ0ZZXv9BZJCkuKuWbg= +go.einride.tech/aip v0.73.0 h1:bPo4oqBo2ZQeBKo4ZzLb1kxYXTY1ysJhpvQyfuGzvps= +go.einride.tech/aip v0.73.0/go.mod h1:Mj7rFbmXEgw0dq1dqJ7JGMvYCZZVxmGOR3S4ZcV5LvQ= go.mongodb.org/mongo-driver v1.17.4 h1:jUorfmVzljjr0FLzYQsGP8cgN/qzzxlY9Vh0C9KFXVw= go.mongodb.org/mongo-driver v1.17.4/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0 h1:q4XOmH/0opmeuJtPsbFNivyl7bCt7yRBbeEm2sC/XtQ= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.61.0/go.mod h1:snMWehoOh2wsEwnvvwtDyFCxVeDAODenXHtn5vzrKjo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= -go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c= -go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE= -go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ= -go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= -go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -552,6 +552,7 @@ golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20221010170243-090e33056c14/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -566,8 +567,8 @@ golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= -golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= -golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -591,15 +592,15 @@ golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 h1:H2TDz8ibqkAF6YGhCdN3j golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/api v0.247.0 h1:tSd/e0QrUlLsrwMKmkbQhYVa109qIintOls2Wh6bngc= -google.golang.org/api v0.247.0/go.mod h1:r1qZOPmxXffXg6xS5uhx16Fa/UFY8QU/K4bfKrnvovM= +google.golang.org/api v0.257.0 h1:8Y0lzvHlZps53PEaw+G29SsQIkuKrumGWs9puiexNAA= +google.golang.org/api v0.257.0/go.mod h1:4eJrr+vbVaZSqs7vovFd1Jb/A6ml6iw2e6FBYf3GAO4= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822 h1:rHWScKit0gvAPuOnu87KpaYtjK5zBMLcULh7gxkCXu4= -google.golang.org/genproto v0.0.0-20250603155806-513f23925822/go.mod h1:HubltRL7rMh0LfnQPkMH4NPDFEWp0jw3vixw7jEM53s= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= +google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:yJ2HH4EHEDTd3JiLmhds6NkJ17ITVYOdV3m3VKOnws0= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww= @@ -651,8 +652,8 @@ gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.24.2/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= gorm.io/gorm v1.24.3 h1:WL2ifUmzR/SLp85CSURAfybcHnGZ+yLSGSxgYXlFBHg= gorm.io/gorm v1.24.3/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA= -gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= inet.af/netaddr v0.0.0-20220811202034-502d2d690317 h1:U2fwK6P2EqmopP/hFLTOAjWTki0qgd4GMJn5X8wOleU= diff --git a/pkg/config/env.go b/pkg/config/env.go index a6e2b954b..c84505643 100644 --- a/pkg/config/env.go +++ b/pkg/config/env.go @@ -230,6 +230,26 @@ var Config = struct { BasicAuthPrefixWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_WHITELIST_PATHS" envDefault:"/api/v1/health,/api/v1/flags,/api/v1/evaluation" envSeparator:","` BasicAuthExactWhitelistPaths []string `env:"FLAGR_BASIC_AUTH_EXACT_WHITELIST_PATHS" envDefault:"" envSeparator:","` + // ===== Notification - Global Settings ===== + // NotificationDetailedDiffEnabled - notify detailed diff of pre and post values + NotificationDetailedDiffEnabled bool `env:"FLAGR_NOTIFICATION_DETAILED_DIFF_ENABLED" envDefault:"false"` + // NotificationTimeout - timeout for sending notifications + NotificationTimeout time.Duration `env:"FLAGR_NOTIFICATION_TIMEOUT" envDefault:"10s"` + // NotificationMaxRetries - maximum number of retry attempts for HTTP notifications + NotificationMaxRetries int `env:"FLAGR_NOTIFICATION_MAX_RETRIES" envDefault:"3"` + // NotificationRetryBase - base delay for exponential backoff with jitter + NotificationRetryBase time.Duration `env:"FLAGR_NOTIFICATION_RETRY_BASE" envDefault:"1s"` + // NotificationRetryMax - maximum delay between retries + NotificationRetryMax time.Duration `env:"FLAGR_NOTIFICATION_RETRY_MAX" envDefault:"10s"` + + // ===== Notification - Webhook Provider ===== + // NotificationWebhookEnabled - enable generic webhook notifications + NotificationWebhookEnabled bool `env:"FLAGR_NOTIFICATION_WEBHOOK_ENABLED" envDefault:"false"` + // NotificationWebhookURL - Webhook URL for generic notifications + NotificationWebhookURL string `env:"FLAGR_NOTIFICATION_WEBHOOK_URL" envDefault:""` + // NotificationWebhookHeaders - Webhook Headers for generic notifications, e.g. "Authorization: Bearer token,X-Custom-Header: value" + NotificationWebhookHeaders string `env:"FLAGR_NOTIFICATION_WEBHOOK_HEADERS" envDefault:""` + // WebPrefix - base path for web and API // e.g. FLAGR_WEB_PREFIX=/foo // UI path => localhost:18000/foo" diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index b814a1163..231ad1172 100644 --- a/pkg/entity/flag_snapshot.go +++ b/pkg/entity/flag_snapshot.go @@ -1,11 +1,13 @@ package entity import ( + "errors" "fmt" "encoding/json" "github.com/openflagr/flagr/pkg/config" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/pkg/util" "github.com/sirupsen/logrus" "gorm.io/gorm" @@ -20,11 +22,16 @@ type FlagSnapshot struct { Flag []byte `gorm:"type:text"` } -// SaveFlagSnapshot saves the Flag Snapshot -func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { +// SaveFlagSnapshot saves the Flag Snapshot and sends a notification. +func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation notification.Operation, componentType string, componentID uint, componentKey string) { tx := db.Begin() f := &Flag{} - if err := tx.First(f, flagID).Error; err != nil { + // Use Unscoped to include soft-deleted flags. This is necessary for: + // 1. Delete operations: we need to snapshot the flag after it's been soft-deleted + // 2. Restore operations: we need to update the flag that was previously soft-deleted + // This is safe because flagID comes from validated request params and the operation + // is explicitly tracked (create/update/delete/restore). + if err := tx.Unscoped().First(f, flagID).Error; err != nil { logrus.WithFields(logrus.Fields{ "err": err, "flagID": flagID, @@ -55,7 +62,9 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { f.UpdatedBy = updatedBy f.SnapshotID = fs.ID - if err := tx.Save(f).Error; err != nil { + // Use Unscoped to update soft-deleted flags (e.g., after delete operation). + // Without Unscoped(), GORM would add "deleted_at IS NULL" condition and fail. + if err := tx.Unscoped().Save(f).Error; err != nil { logrus.WithFields(logrus.Fields{ "err": err, "flagID": f.Model.ID, @@ -65,11 +74,42 @@ func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string) { return } + preFS := &FlagSnapshot{} + // Find the most recent snapshot before the current one (use Unscoped to include any soft-deleted). + // ErrRecordNotFound is expected for the first snapshot of a flag. + if err := tx.Unscoped().Where("flag_id = ? AND id < ?", flagID, fs.ID).Order("id desc").First(preFS).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + logrus.WithError(err).WithField("flagID", flagID).Warn("failed to find previous flag snapshot") + } + if err := tx.Commit().Error; err != nil { tx.Rollback() + logrus.WithError(err).WithField("flagID", flagID).Error("failed to commit flag snapshot") + return + } + + preValue := "" + postValue := "" + diff := "" + + if config.Config.NotificationDetailedDiffEnabled { + preValue = string(preFS.Flag) + postValue = string(fs.Flag) + diff = notification.CalculateDiff(preValue, postValue) } logFlagSnapshotUpdate(flagID, updatedBy) + notification.SendNotification(notification.Notification{ + Operation: operation, + FlagID: flagID, + FlagKey: f.Key, + ComponentType: componentType, + ComponentID: componentID, + ComponentKey: componentKey, + PreValue: preValue, + PostValue: postValue, + Diff: diff, + User: updatedBy, + }) } var logFlagSnapshotUpdate = func(flagID uint, updatedBy string) { diff --git a/pkg/entity/flag_snapshot_test.go b/pkg/entity/flag_snapshot_test.go index 9293663e3..42bf631a4 100644 --- a/pkg/entity/flag_snapshot_test.go +++ b/pkg/entity/flag_snapshot_test.go @@ -2,6 +2,8 @@ package entity import ( "testing" + + "github.com/openflagr/flagr/pkg/notification" ) func TestSaveFlagSnapshot(t *testing.T) { @@ -16,10 +18,10 @@ func TestSaveFlagSnapshot(t *testing.T) { defer tmpDB.Close() t.Run("happy code path", func(t *testing.T) { - SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) }) t.Run("save on non-existing flag", func(t *testing.T) { - SaveFlagSnapshot(db, uint(999999), "flagr-test@example.com") + SaveFlagSnapshot(db, uint(999999), "flagr-test@example.com", notification.OperationUpdate, "flag", 0, "") }) } diff --git a/pkg/handler/crud.go b/pkg/handler/crud.go index 389b692d6..598914e6b 100644 --- a/pkg/handler/crud.go +++ b/pkg/handler/crud.go @@ -8,6 +8,7 @@ import ( "github.com/openflagr/flagr/pkg/entity" "github.com/openflagr/flagr/pkg/mapper/entity_restapi/e2r" "github.com/openflagr/flagr/pkg/mapper/entity_restapi/r2e" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/pkg/util" "github.com/openflagr/flagr/swagger_gen/restapi/operations/constraint" "github.com/openflagr/flagr/swagger_gen/restapi/operations/distribution" @@ -270,7 +271,7 @@ func (c *crud) PutFlag(params flag.PutFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "flag", util.SafeUint(params.FlagID), f.Key) return resp } @@ -293,7 +294,7 @@ func (c *crud) SetFlagEnabledState(params flag.SetFlagEnabledParams) middleware. } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "flag", util.SafeUint(params.FlagID), f.Key) return resp } @@ -316,14 +317,21 @@ func (c *crud) RestoreFlag(params flag.RestoreFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationRestore, "flag", util.SafeUint(params.FlagID), f.Key) return resp } func (c *crud) DeleteFlag(params flag.DeleteFlagParams) middleware.Responder { + f := &entity.Flag{} + if err := getDB().First(f, params.FlagID).Error; err != nil { + return flag.NewDeleteFlagDefault(404).WithPayload(ErrorMessage("%s", err)) + } + if err := getDB().Delete(&entity.Flag{}, params.FlagID).Error; err != nil { return flag.NewDeleteFlagDefault(500).WithPayload(ErrorMessage("%s", err)) } + + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, "flag", util.SafeUint(params.FlagID), f.Key) return flag.NewDeleteFlagOK() } @@ -337,7 +345,7 @@ func (c *crud) DeleteTag(params tag.DeleteTagParams) middleware.Responder { if err := getDB().Model(s).Association("Tags").Delete(t); err != nil { return tag.NewDeleteTagDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "tag", uint(params.TagID), "") return tag.NewDeleteTagOK() } @@ -399,7 +407,7 @@ func (c *crud) CreateTag(params tag.CreateTagParams) middleware.Responder { resp := tag.NewCreateTagOK() resp.SetPayload(e2r.MapTag(t)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "tag", t.ID, t.Value) return resp } @@ -418,7 +426,7 @@ func (c *crud) CreateSegment(params segment.CreateSegmentParams) middleware.Resp resp := segment.NewCreateSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", s.ID, "") return resp } @@ -461,7 +469,7 @@ func (c *crud) PutSegment(params segment.PutSegmentParams) middleware.Responder resp := segment.NewPutSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", util.SafeUint(params.SegmentID), "") return resp } @@ -485,7 +493,7 @@ func (c *crud) PutSegmentsReorder(params segment.PutSegmentsReorderParams) middl return segment.NewPutSegmentsReorderDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", 0, "") return segment.NewPutSegmentsReorderOK() } @@ -495,7 +503,7 @@ func (c *crud) DeleteSegment(params segment.DeleteSegmentParams) middleware.Resp return segment.NewDeleteSegmentDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", util.SafeUint(params.SegmentID), "") return segment.NewDeleteSegmentOK() } @@ -517,7 +525,7 @@ func (c *crud) CreateConstraint(params constraint.CreateConstraintParams) middle resp := constraint.NewCreateConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "constraint", cons.ID, "") return resp } @@ -555,7 +563,7 @@ func (c *crud) PutConstraint(params constraint.PutConstraintParams) middleware.R resp := constraint.NewPutConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "constraint", util.SafeUint(params.ConstraintID), "") return resp } @@ -566,7 +574,7 @@ func (c *crud) DeleteConstraint(params constraint.DeleteConstraintParams) middle resp := constraint.NewDeleteConstraintOK() - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "constraint", util.SafeUint(params.ConstraintID), "") return resp } @@ -602,7 +610,7 @@ func (c *crud) PutDistributions(params distribution.PutDistributionsParams) midd resp := distribution.NewPutDistributionsOK() resp.SetPayload(e2r.MapDistributions(ds)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "distribution", 0, "") return resp } @@ -644,7 +652,7 @@ func (c *crud) CreateVariant(params variant.CreateVariantParams) middleware.Resp resp := variant.NewCreateVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "variant", v.ID, v.Key) return resp } @@ -695,7 +703,7 @@ func (c *crud) PutVariant(params variant.PutVariantParams) middleware.Responder resp := variant.NewPutVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "variant", util.SafeUint(params.VariantID), v.Key) return resp } @@ -708,6 +716,6 @@ func (c *crud) DeleteVariant(params variant.DeleteVariantParams) middleware.Resp return variant.NewDeleteVariantDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "variant", util.SafeUint(params.VariantID), "") return variant.NewDeleteVariantOK() } diff --git a/pkg/handler/crud_flag_creation.go b/pkg/handler/crud_flag_creation.go index cffd4ce95..ec50a9ab2 100644 --- a/pkg/handler/crud_flag_creation.go +++ b/pkg/handler/crud_flag_creation.go @@ -3,6 +3,7 @@ package handler import ( "github.com/go-openapi/runtime/middleware" "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/pkg/util" "github.com/openflagr/flagr/swagger_gen/restapi/operations/flag" "gorm.io/gorm" @@ -55,7 +56,7 @@ func (c *crud) CreateFlag(params flag.CreateFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest)) + entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, "flag", f.ID, f.Key) return resp } diff --git a/pkg/handler/crud_notification_test.go b/pkg/handler/crud_notification_test.go new file mode 100644 index 000000000..ed564f948 --- /dev/null +++ b/pkg/handler/crud_notification_test.go @@ -0,0 +1,192 @@ +package handler + +import ( + "net/http" + "testing" + "time" + + "github.com/openflagr/flagr/pkg/config" + "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" + "github.com/openflagr/flagr/swagger_gen/models" + "github.com/openflagr/flagr/swagger_gen/restapi/operations/flag" + "github.com/prashantv/gostub" + "github.com/stretchr/testify/assert" +) + +func TestHandlerNotifications(t *testing.T) { + db := entity.NewTestDB() + defer gostub.StubFunc(&getDB, db).Reset() + + mockNotifier := notification.NewMockNotifier() + // Use gostub to set notifiers and reset via defer + stubs := gostub.Stub(¬ification.Notifiers, []notification.Notifier{mockNotifier}) + defer stubs.Reset() + + c := NewCRUD() + + t.Run("CreateFlag sends notification", func(t *testing.T) { + mockNotifier.ClearSent() + params := flag.CreateFlagParams{ + HTTPRequest: &http.Request{}, + Body: &models.CreateFlagRequest{ + Description: new("test flag"), + Key: "test_flag_notif", + }, + } + c.CreateFlag(params) + + // Notifications are sent in a goroutine, so we might need a small wait or check repeatedly + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationCreate, sent[0].Operation) + assert.Equal(t, "test_flag_notif", sent[0].FlagKey) + // Privacy by default + assert.Empty(t, sent[0].PreValue) + assert.Empty(t, sent[0].PostValue) + assert.Empty(t, sent[0].Diff) + }) + + t.Run("PutFlag sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + mockNotifier.ClearSent() + + params := flag.PutFlagParams{ + FlagID: int64(f.ID), + Body: &models.PutFlagRequest{ + Description: new("updated description"), + }, + HTTPRequest: &http.Request{}, + } + c.PutFlag(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationUpdate, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].FlagKey) + // Privacy by default + assert.Empty(t, sent[0].PreValue) + assert.Empty(t, sent[0].PostValue) + assert.Empty(t, sent[0].Diff) + }) + + t.Run("PutFlag with detailed diff enabled", func(t *testing.T) { + stubs := gostub.Stub(&config.Config.NotificationDetailedDiffEnabled, true) + defer stubs.Reset() + + f := entity.GenFixtureFlag() + f.ID = 0 // Allow DB to assign new ID + f.Key = "detailed_diff_flag" + db.Create(&f) + mockNotifier.ClearSent() + + // First update to create first snapshot + params1 := flag.PutFlagParams{ + FlagID: int64(f.ID), + Body: &models.PutFlagRequest{ + Description: new("first update"), + }, + HTTPRequest: &http.Request{}, + } + c.PutFlag(params1) + + // Second update to trigger diff calculation + params2 := flag.PutFlagParams{ + FlagID: int64(f.ID), + Body: &models.PutFlagRequest{ + Description: new("second update"), + }, + HTTPRequest: &http.Request{}, + } + c.PutFlag(params2) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) >= 2 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 2) + // Second notification should have a diff + assert.NotEmpty(t, sent[1].Diff) + assert.Contains(t, sent[1].Diff, "- \"Description\": \"first update\"") + assert.Contains(t, sent[1].Diff, "+ \"Description\": \"second update\"") + }) + + t.Run("DeleteFlag sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + mockNotifier.ClearSent() + + params := flag.DeleteFlagParams{ + FlagID: int64(f.ID), + HTTPRequest: &http.Request{}, + } + c.DeleteFlag(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationDelete, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].FlagKey) + }) + + t.Run("RestoreFlag sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + // Soft delete first + db.Delete(&f) + mockNotifier.ClearSent() + + params := flag.RestoreFlagParams{ + FlagID: int64(f.ID), + HTTPRequest: &http.Request{}, + } + c.RestoreFlag(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationRestore, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].FlagKey) + }) + + t.Run("SetFlagEnabledState sends notification", func(t *testing.T) { + f := entity.GenFixtureFlag() + db.Create(&f) + mockNotifier.ClearSent() + + params := flag.SetFlagEnabledParams{ + FlagID: int64(f.ID), + Body: &models.SetFlagEnabledRequest{ + Enabled: new(false), + }, + HTTPRequest: &http.Request{}, + } + c.SetFlagEnabledState(params) + + assert.Eventually(t, func() bool { + return len(mockNotifier.GetSentNotifications()) > 0 + }, 1*time.Second, 10*time.Millisecond) + + sent := mockNotifier.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, notification.OperationUpdate, sent[0].Operation) + assert.Equal(t, f.Key, sent[0].FlagKey) + assert.Equal(t, f.ID, sent[0].FlagID) // Verify entity ID is set correctly + }) +} diff --git a/pkg/handler/data_recorder_pubsub.go b/pkg/handler/data_recorder_pubsub.go index 6387e072e..17ce4fb13 100644 --- a/pkg/handler/data_recorder_pubsub.go +++ b/pkg/handler/data_recorder_pubsub.go @@ -3,7 +3,7 @@ package handler import ( "context" - "cloud.google.com/go/pubsub" + "cloud.google.com/go/pubsub/v2" "github.com/openflagr/flagr/pkg/config" "github.com/openflagr/flagr/swagger_gen/models" "github.com/sirupsen/logrus" @@ -12,7 +12,7 @@ import ( type pubsubRecorder struct { producer *pubsub.Client - topic *pubsub.Topic + publisher *pubsub.Publisher options DataRecordFrameOptions } @@ -35,7 +35,7 @@ var NewPubsubRecorder = func() DataRecorder { return &pubsubRecorder{ producer: client, - topic: client.Topic(config.Config.RecorderPubsubTopicName), + publisher: client.Publisher(config.Config.RecorderPubsubTopicName), options: DataRecordFrameOptions{ Encrypted: false, // not implemented yet FrameOutputMode: config.Config.RecorderFrameOutputMode, @@ -58,7 +58,7 @@ func (p *pubsubRecorder) AsyncRecord(r models.EvalResult) { return } ctx := context.Background() - res := p.topic.Publish(ctx, &pubsub.Message{Data: output}) + res := p.publisher.Publish(ctx, &pubsub.Message{Data: output}) if config.Config.RecorderPubsubVerbose { go func() { ctx, cancel := context.WithTimeout(ctx, config.Config.RecorderPubsubVerboseCancelTimeout) diff --git a/pkg/handler/data_recorder_pubsub_test.go b/pkg/handler/data_recorder_pubsub_test.go index 5fc994aff..e135170d8 100644 --- a/pkg/handler/data_recorder_pubsub_test.go +++ b/pkg/handler/data_recorder_pubsub_test.go @@ -4,8 +4,8 @@ import ( "context" "testing" - "cloud.google.com/go/pubsub" - "cloud.google.com/go/pubsub/pstest" + "cloud.google.com/go/pubsub/v2" + "cloud.google.com/go/pubsub/v2/pstest" "github.com/openflagr/flagr/swagger_gen/models" "github.com/prashantv/gostub" "github.com/stretchr/testify/assert" @@ -33,11 +33,11 @@ func TestPubsubAsyncRecord(t *testing.T) { t.Run("enabled and valid", func(t *testing.T) { client := mockClient(t) defer client.Close() - topic := client.Topic("test") + publisher := client.Publisher("test") assert.NotPanics(t, func() { pr := &pubsubRecorder{ producer: client, - topic: topic, + publisher: publisher, } pr.AsyncRecord( diff --git a/pkg/handler/export_test.go b/pkg/handler/export_test.go index 2c0cfabe6..b869122bf 100644 --- a/pkg/handler/export_test.go +++ b/pkg/handler/export_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/swagger_gen/restapi/operations/export" "github.com/prashantv/gostub" "github.com/stretchr/testify/assert" @@ -55,7 +56,7 @@ func TestExportFlags(t *testing.T) { func TestExportFlagSnapshots(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { @@ -84,7 +85,7 @@ func TestExportFlagSnapshots(t *testing.T) { func TestExportSQLiteFile(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { @@ -114,7 +115,7 @@ func TestExportSQLiteFile(t *testing.T) { func TestExportSQLiteHandler(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com") + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { diff --git a/pkg/handler/handler.go b/pkg/handler/handler.go index 43bbfbd1c..b35a09a03 100644 --- a/pkg/handler/handler.go +++ b/pkg/handler/handler.go @@ -4,6 +4,7 @@ import ( "github.com/go-openapi/runtime/middleware" "github.com/openflagr/flagr/pkg/config" "github.com/openflagr/flagr/pkg/entity" + "github.com/openflagr/flagr/pkg/notification" "github.com/openflagr/flagr/swagger_gen/models" "github.com/openflagr/flagr/swagger_gen/restapi/operations" "github.com/openflagr/flagr/swagger_gen/restapi/operations/constraint" @@ -21,6 +22,8 @@ var getDB = entity.GetDB // Setup initialize all the handler functions func Setup(api *operations.FlagrAPI) { + notification.ValidateConfig() + if config.Config.EvalOnlyMode { setupHealth(api) setupEvaluation(api) @@ -35,7 +38,6 @@ func Setup(api *operations.FlagrAPI) { func setupCRUD(api *operations.FlagrAPI) { c := NewCRUD() - // flags api.FlagFindFlagsHandler = flag.FindFlagsHandlerFunc(c.FindFlags) api.FlagCreateFlagHandler = flag.CreateFlagHandlerFunc(c.CreateFlag) api.FlagGetFlagHandler = flag.GetFlagHandlerFunc(c.GetFlag) @@ -46,30 +48,25 @@ func setupCRUD(api *operations.FlagrAPI) { api.FlagGetFlagSnapshotsHandler = flag.GetFlagSnapshotsHandlerFunc(c.GetFlagSnapshots) api.FlagGetFlagEntityTypesHandler = flag.GetFlagEntityTypesHandlerFunc(c.GetFlagEntityTypes) - // tags api.TagCreateTagHandler = tag.CreateTagHandlerFunc(c.CreateTag) api.TagDeleteTagHandler = tag.DeleteTagHandlerFunc(c.DeleteTag) api.TagFindTagsHandler = tag.FindTagsHandlerFunc(c.FindTags) api.TagFindAllTagsHandler = tag.FindAllTagsHandlerFunc(c.FindAllTags) - // segments api.SegmentCreateSegmentHandler = segment.CreateSegmentHandlerFunc(c.CreateSegment) api.SegmentFindSegmentsHandler = segment.FindSegmentsHandlerFunc(c.FindSegments) api.SegmentPutSegmentHandler = segment.PutSegmentHandlerFunc(c.PutSegment) api.SegmentDeleteSegmentHandler = segment.DeleteSegmentHandlerFunc(c.DeleteSegment) api.SegmentPutSegmentsReorderHandler = segment.PutSegmentsReorderHandlerFunc(c.PutSegmentsReorder) - // constraints api.ConstraintCreateConstraintHandler = constraint.CreateConstraintHandlerFunc(c.CreateConstraint) api.ConstraintFindConstraintsHandler = constraint.FindConstraintsHandlerFunc(c.FindConstraints) api.ConstraintPutConstraintHandler = constraint.PutConstraintHandlerFunc(c.PutConstraint) api.ConstraintDeleteConstraintHandler = constraint.DeleteConstraintHandlerFunc(c.DeleteConstraint) - // distributions api.DistributionFindDistributionsHandler = distribution.FindDistributionsHandlerFunc(c.FindDistributions) api.DistributionPutDistributionsHandler = distribution.PutDistributionsHandlerFunc(c.PutDistributions) - // variants api.VariantCreateVariantHandler = variant.CreateVariantHandlerFunc(c.CreateVariant) api.VariantFindVariantsHandler = variant.FindVariantsHandlerFunc(c.FindVariants) api.VariantPutVariantHandler = variant.PutVariantHandlerFunc(c.PutVariant) @@ -85,7 +82,6 @@ func setupEvaluation(api *operations.FlagrAPI) { api.EvaluationPostEvaluationBatchHandler = evaluation.PostEvaluationBatchHandlerFunc(e.PostEvaluationBatch) if config.Config.RecorderEnabled { - // Try GetDataRecorder to catch fatal errors before we start the evaluation api GetDataRecorder() } } diff --git a/pkg/notification/dispatch.go b/pkg/notification/dispatch.go new file mode 100644 index 000000000..315fbd3ea --- /dev/null +++ b/pkg/notification/dispatch.go @@ -0,0 +1,124 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "sync" + "time" + + "github.com/openflagr/flagr/pkg/config" + "github.com/pmezard/go-difflib/difflib" + "github.com/sirupsen/logrus" +) + +var ( + // Semaphore to limit concurrent notification sends. Default 100. + notificationSemaphore = make(chan struct{}, 100) +) + +func recordNotificationMetrics(provider string, operation Operation, success bool) { + if config.Global.StatsdClient == nil { + return + } + status := "failure" + if success { + status = "success" + } + tags := []string{ + fmt.Sprintf("provider:%s", provider), + fmt.Sprintf("operation:%s", operation), + fmt.Sprintf("status:%s", status), + } + config.Global.StatsdClient.Incr("notification.sent", tags, 1) +} + +// SendNotification dispatches a notification to all configured notifiers asynchronously. +// Notifications are sent in a background goroutine and failures do not affect the caller. +func SendNotification(n Notification) { + // Capture notifiers BEFORE spawning goroutine to avoid test pollution + // when Notifiers is modified between test runs + notifiers := GetNotifiers() + if len(notifiers) == 0 { + return + } + + // Set timestamp if not already set by caller + if n.Timestamp.IsZero() { + n.Timestamp = time.Now().UTC() + } + + go func() { + // Acquire semaphore slot + notificationSemaphore <- struct{}{} + defer func() { + <-notificationSemaphore + if r := recover(); r != nil { + logrus.WithField("panic", r).Error("panic in SendNotification") + } + }() + + ctx, cancel := context.WithTimeout(context.Background(), config.Config.NotificationTimeout) + defer cancel() + + // Send to all notifiers concurrently, aggregate errors + var ( + wg sync.WaitGroup + mu sync.Mutex + errs []error + ) + + for _, nr := range notifiers { + wg.Add(1) + go func(notifier Notifier) { + defer wg.Done() + err := notifier.Send(ctx, n) + recordNotificationMetrics(notifier.Name(), n.Operation, err == nil) + if err != nil { + mu.Lock() + errs = append(errs, fmt.Errorf("%s: %w", notifier.Name(), err)) + mu.Unlock() + } + }(nr) + } + + wg.Wait() + + if len(errs) > 0 { + logrus.WithFields(logrus.Fields{ + "operation": n.Operation, + "flagID": n.FlagID, + "errors": errs, + }).Warn("failed to send notifications to some providers") + } + }() +} + +func CalculateDiff(pre, post string) string { + if pre == "" || post == "" { + return "" + } + + prePretty := prettyPrintJSON(pre) + postPretty := prettyPrintJSON(post) + + diff := difflib.UnifiedDiff{ + A: difflib.SplitLines(prePretty), + B: difflib.SplitLines(postPretty), + FromFile: "Previous", + ToFile: "Current", + Context: 3, + } + text, _ := difflib.GetUnifiedDiffString(diff) + return text +} + +func prettyPrintJSON(s string) string { + var out bytes.Buffer + err := json.Indent(&out, []byte(s), "", " ") + if err != nil { + return s + } + return out.String() +} diff --git a/pkg/notification/dispatch_test.go b/pkg/notification/dispatch_test.go new file mode 100644 index 000000000..1f37c6d71 --- /dev/null +++ b/pkg/notification/dispatch_test.go @@ -0,0 +1,216 @@ +package notification + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/prashantv/gostub" + "github.com/stretchr/testify/assert" +) + +func TestCalculateDiff(t *testing.T) { + t.Run("empty cases", func(t *testing.T) { + assert.Empty(t, CalculateDiff("", "")) + assert.Empty(t, CalculateDiff("a", "")) + assert.Empty(t, CalculateDiff("", "b")) + }) + + t.Run("simple diff", func(t *testing.T) { + pre := "line1\nline2\n" + post := "line1\nline3\n" + diff := CalculateDiff(pre, post) + assert.NotEmpty(t, diff) + assert.Contains(t, diff, "-line2") + assert.Contains(t, diff, "+line3") + }) + + t.Run("JSON diff visibility", func(t *testing.T) { + pre := `{"id":1,"key":"flag1","enabled":false}` + post := `{"id":1,"key":"flag1","enabled":true}` + diff := CalculateDiff(pre, post) + t.Logf("Pretty JSON Diff:\n%s", diff) + // Pretty JSON diff shows individual field changes + assert.Contains(t, diff, "- \"enabled\": false") + assert.Contains(t, diff, "+ \"enabled\": true") + }) +} + +func TestSendNotification(t *testing.T) { + t.Run("sends to multiple notifiers concurrently", func(t *testing.T) { + mock1 := NewMockNotifier() + mock2 := NewMockNotifier() + mock3 := NewMockNotifier() + + // First reset to nil, then stub to desired value + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock1, mock2, mock3}) + defer stubs.Reset() + + SendNotification(Notification{ + Operation: OperationCreate, + FlagID: 1, + FlagKey: "test-flag", + User: "user", + }) + + // Wait for goroutine to complete + assert.Eventually(t, func() bool { + return len(mock1.GetSentNotifications()) == 1 && + len(mock2.GetSentNotifications()) == 1 && + len(mock3.GetSentNotifications()) == 1 + }, 1*time.Second, 10*time.Millisecond) + + // Verify each notifier received the same notification + for _, mock := range []*MockNotifier{mock1, mock2, mock3} { + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, OperationCreate, sent[0].Operation) + assert.Equal(t, uint(1), sent[0].FlagID) + assert.Equal(t, "test-flag", sent[0].FlagKey) + } + }) + + t.Run("handles errors from some notifiers", func(t *testing.T) { + mock1 := NewMockNotifier() + mock1.SetSendError(errors.New("error from mock1")) + + mock2 := NewMockNotifier() + // mock2 succeeds + + mock3 := NewMockNotifier() + mock3.SetSendError(errors.New("error from mock3")) + + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock1, mock2, mock3}) + defer stubs.Reset() + + SendNotification(Notification{ + Operation: OperationUpdate, + FlagID: 2, + FlagKey: "test-flag-2", + }) + + // Wait for goroutine to complete + assert.Eventually(t, func() bool { + return len(mock1.GetSentNotifications()) == 1 && + len(mock2.GetSentNotifications()) == 1 && + len(mock3.GetSentNotifications()) == 1 + }, 1*time.Second, 10*time.Millisecond) + + // All notifiers should still have been called (fire all) + assert.Len(t, mock1.GetSentNotifications(), 1) + assert.Len(t, mock2.GetSentNotifications(), 1) + assert.Len(t, mock3.GetSentNotifications(), 1) + }) + + t.Run("does nothing when notifiers is empty", func(t *testing.T) { + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier(nil)) + defer stubs.Reset() + + // Should not panic + SendNotification(Notification{ + Operation: OperationCreate, + FlagID: 1, + }) + }) + + t.Run("sends notification with correct entity type and fields", func(t *testing.T) { + mock := NewMockNotifier() + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + defer stubs.Reset() + + SendNotification(Notification{ + Operation: OperationCreate, + FlagID: 42, + FlagKey: "my-flag", + User: "creator", + }) + + assert.Eventually(t, func() bool { + return len(mock.GetSentNotifications()) >= 1 + }, 1*time.Second, 10*time.Millisecond) + + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, OperationCreate, sent[0].Operation) + assert.Equal(t, uint(42), sent[0].FlagID) + assert.Equal(t, "my-flag", sent[0].FlagKey) + assert.Equal(t, "creator", sent[0].User) + }) + + t.Run("sets timestamp when not provided", func(t *testing.T) { + mock := NewMockNotifier() + Notifiers = nil + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + defer stubs.Reset() + + before := time.Now() + SendNotification(Notification{ + Operation: OperationCreate, + FlagID: 1, + }) + + assert.Eventually(t, func() bool { + return len(mock.GetSentNotifications()) >= 1 + }, 1*time.Second, 10*time.Millisecond) + + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.False(t, sent[0].Timestamp.IsZero()) + assert.True(t, sent[0].Timestamp.After(before) || sent[0].Timestamp.Equal(before)) + }) +} + +func TestSendNotificationConcurrency(t *testing.T) { + t.Run("concurrent sends are handled safely", func(t *testing.T) { + mock := NewMockNotifier() + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + defer stubs.Reset() + + var wg sync.WaitGroup + for i := 0; i < 50; i++ { + wg.Add(1) + go func(id uint) { + defer wg.Done() + SendNotification(Notification{ + Operation: OperationCreate, + FlagID: id, + FlagKey: "flag", + }) + }(uint(i)) + } + + wg.Wait() + + // All notifications should eventually be delivered + assert.Eventually(t, func() bool { + return len(mock.GetSentNotifications()) == 50 + }, 2*time.Second, 50*time.Millisecond) + }) +} + +func TestNotifierDirectSend(t *testing.T) { + t.Run("can send to notifier directly with context", func(t *testing.T) { + mock := NewMockNotifier() + + ctx := context.Background() + notif := Notification{ + Operation: OperationCreate, + FlagID: 1, + FlagKey: "direct-test", + User: "tester", + } + + err := mock.Send(ctx, notif) + assert.NoError(t, err) + + sent := mock.GetSentNotifications() + assert.Len(t, sent, 1) + assert.Equal(t, "direct-test", sent[0].FlagKey) + }) +} diff --git a/pkg/notification/notifier.go b/pkg/notification/notifier.go new file mode 100644 index 000000000..367c15717 --- /dev/null +++ b/pkg/notification/notifier.go @@ -0,0 +1,116 @@ +package notification + +import ( + "context" + "sync" + "time" + + "github.com/openflagr/flagr/pkg/config" +) + +type Notifier interface { + Send(ctx context.Context, n Notification) error + Name() string +} + +type Operation string + +const ( + OperationCreate Operation = "create" + OperationUpdate Operation = "update" + OperationDelete Operation = "delete" + OperationRestore Operation = "restore" +) + +type Notification struct { + Operation Operation `json:"operation"` + FlagID uint `json:"flag_id"` + FlagKey string `json:"flag_key"` + ComponentType string `json:"component_type,omitempty"` + ComponentID uint `json:"component_id,omitempty"` + ComponentKey string `json:"component_key,omitempty"` + PreValue string `json:"pre_value,omitempty"` + PostValue string `json:"post_value,omitempty"` + Diff string `json:"diff,omitempty"` + User string `json:"user,omitempty"` + Timestamp time.Time `json:"timestamp"` +} + +var ( + // Notifiers is the list of configured notifiers. Set directly for testing. + Notifiers []Notifier + once sync.Once +) + +// GetNotifiers returns the list of configured notifiers. +// It initializes the notifiers on first call using sync.Once. +// For testing, set Notifiers directly before calling GetNotifiers. +func GetNotifiers() []Notifier { + // If already set (e.g., by tests), return immediately + if len(Notifiers) > 0 { + return Notifiers + } + + once.Do(func() { + if config.Config.NotificationWebhookEnabled { + if wn := NewWebhookNotifier(); wn != nil { + Notifiers = append(Notifiers, wn) + } + } + }) + + return Notifiers +} + +type nullNotifier struct{} + +func (n *nullNotifier) Send(ctx context.Context, notification Notification) error { + return nil +} + +func (n *nullNotifier) Name() string { + return "null" +} + +type MockNotifier struct { + sent []Notification + mu sync.Mutex + sendError error +} + +func NewMockNotifier() *MockNotifier { + return &MockNotifier{ + sent: make([]Notification, 0), + } +} + +func (m *MockNotifier) Send(ctx context.Context, n Notification) error { + m.mu.Lock() + defer m.mu.Unlock() + m.sent = append(m.sent, n) + return m.sendError +} + +func (m *MockNotifier) Name() string { + return "mock" +} + +func (m *MockNotifier) SetSendError(err error) { + m.mu.Lock() + defer m.mu.Unlock() + m.sendError = err +} + +func (m *MockNotifier) GetSentNotifications() []Notification { + m.mu.Lock() + defer m.mu.Unlock() + result := make([]Notification, len(m.sent)) + copy(result, m.sent) + return result +} + +func (m *MockNotifier) ClearSent() { + m.mu.Lock() + defer m.mu.Unlock() + m.sent = make([]Notification, 0) +} diff --git a/pkg/notification/notifier_test.go b/pkg/notification/notifier_test.go new file mode 100644 index 000000000..24f08c3fc --- /dev/null +++ b/pkg/notification/notifier_test.go @@ -0,0 +1,133 @@ +package notification + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/prashantv/gostub" + "github.com/stretchr/testify/assert" +) + +func TestNotification(t *testing.T) { + t.Run("null notifier should not fail", func(t *testing.T) { + n := &nullNotifier{} + ctx := context.Background() + notif := Notification{ + Operation: OperationCreate, + FlagID: 1, + FlagKey: "test-flag", + User: "test@example.com", + } + + err := n.Send(ctx, notif) + assert.NoError(t, err) + }) + + t.Run("null notifier name", func(t *testing.T) { + n := &nullNotifier{} + assert.Equal(t, "null", n.Name()) + }) + + t.Run("mock notifier records sent notifications", func(t *testing.T) { + m := NewMockNotifier() + ctx := context.Background() + + notif1 := Notification{ + Operation: OperationCreate, + FlagID: 1, + FlagKey: "test-flag-1", + User: "user1@example.com", + } + + notif2 := Notification{ + Operation: OperationUpdate, + FlagID: 2, + FlagKey: "test-flag-2", + User: "user2@example.com", + } + + err1 := m.Send(ctx, notif1) + err2 := m.Send(ctx, notif2) + + assert.NoError(t, err1) + assert.NoError(t, err2) + + sent := m.GetSentNotifications() + assert.Len(t, sent, 2) + assert.Equal(t, OperationCreate, sent[0].Operation) + assert.Equal(t, uint(1), sent[0].FlagID) + assert.Equal(t, "test-flag-1", sent[0].FlagKey) + + assert.Equal(t, OperationUpdate, sent[1].Operation) + assert.Equal(t, uint(2), sent[1].FlagID) + assert.Equal(t, "test-flag-2", sent[1].FlagKey) + }) + + t.Run("mock notifier can return errors", func(t *testing.T) { + m := NewMockNotifier() + m.SetSendError(errors.New("test error")) + + ctx := context.Background() + notif := Notification{Operation: Operation("test")} + + err := m.Send(ctx, notif) + assert.Error(t, err) + }) + + t.Run("mock notifier clear works", func(t *testing.T) { + m := NewMockNotifier() + ctx := context.Background() + + m.Send(ctx, Notification{Operation: "test"}) + m.Send(ctx, Notification{Operation: "test"}) + + assert.Len(t, m.GetSentNotifications(), 2) + + m.ClearSent() + assert.Len(t, m.GetSentNotifications(), 0) + }) +} + +func TestGetNotifiers(t *testing.T) { + t.Run("GetNotifiers returns empty when disabled", func(t *testing.T) { + stubs := gostub.Stub(&Notifiers, []Notifier(nil)) + stubs.Stub(&once, sync.Once{}) + defer stubs.Reset() + + n := GetNotifiers() + assert.Empty(t, n) + }) + + t.Run("GetNotifiers returns pre-set notifiers for testing", func(t *testing.T) { + mock := NewMockNotifier() + stubs := gostub.Stub(&Notifiers, []Notifier{mock}) + stubs.Stub(&once, sync.Once{}) + defer stubs.Reset() + + n := GetNotifiers() + assert.Len(t, n, 1) + assert.Equal(t, "mock", n[0].Name()) + }) +} + +func TestNotifierConcurrency(t *testing.T) { + t.Run("MockNotifier is safe for concurrent use", func(t *testing.T) { + mock := NewMockNotifier() + + var wg sync.WaitGroup + for i := 0; i < 100; i++ { + wg.Add(1) + go func() { + defer wg.Done() + mock.Send(context.Background(), Notification{Operation: OperationCreate}) + }() + } + + wg.Wait() + + // All notifications should have been sent + assert.Len(t, mock.GetSentNotifications(), 100) + }) +} \ No newline at end of file diff --git a/pkg/notification/retry.go b/pkg/notification/retry.go new file mode 100644 index 000000000..ed2348649 --- /dev/null +++ b/pkg/notification/retry.go @@ -0,0 +1,78 @@ +package notification + +import ( + "context" + "fmt" + "math/rand/v2" + "net/http" + "time" +) + +func minDuration(a, b time.Duration) time.Duration { + if a < b { + return a + } + return b +} + +// doRequestWithRetry performs an HTTP request with exponential backoff and jitter. +// On success (status < 500), it returns (resp, nil). +// On failure after retries, it returns the last response (if any) and an error. +func doRequestWithRetry(ctx context.Context, client *http.Client, req *http.Request, maxRetries int, baseDelay, maxDelay time.Duration) (*http.Response, error) { + var lastResp *http.Response + var lastErr error + delay := baseDelay + + for attempt := 0; attempt <= maxRetries; attempt++ { + if attempt > 0 { + select { + case <-time.After(delay): + case <-ctx.Done(): + return lastResp, fmt.Errorf("retry canceled: %w", ctx.Err()) + } + } + + resp, err := client.Do(req) + if err != nil { + lastErr = fmt.Errorf("HTTP request failed: %w", err) + if attempt < maxRetries { + delay = nextDelayWithJitter(delay, maxDelay) + continue + } + return nil, lastErr + } + // Don't close body here; caller will handle it if resp is returned + + if resp.StatusCode < 500 { + // Close any previous failed response body before returning success + if lastResp != nil { + lastResp.Body.Close() + } + return resp, nil // Success or client error (4xx) is considered final; no retry on 4xx + } + + // 5xx - retryable + // Close previous lastResp.Body before overwriting to prevent resource leak + if lastResp != nil { + lastResp.Body.Close() + } + lastResp = resp + lastErr = fmt.Errorf("HTTP %d error", resp.StatusCode) + if attempt < maxRetries { + delay = nextDelayWithJitter(delay, maxDelay) + continue + } + // Final attempt failed with 5xx - caller is responsible for closing body + return resp, lastErr + } + + return lastResp, lastErr +} + +// nextDelayWithJitter returns the next retry delay using exponential backoff +// with up to 50% jitter to prevent thundering herd. +func nextDelayWithJitter(prevDelay, maxDelay time.Duration) time.Duration { + next := minDuration(2*prevDelay, maxDelay) + jitter := time.Duration(rand.Int64N(int64(next) / 2)) + return next + jitter +} diff --git a/pkg/notification/retry_test.go b/pkg/notification/retry_test.go new file mode 100644 index 000000000..b4bb82379 --- /dev/null +++ b/pkg/notification/retry_test.go @@ -0,0 +1,195 @@ +package notification + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestDoRequestWithRetry(t *testing.T) { + t.Run("returns immediately on 2xx success", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int32(1), atomic.LoadInt32(&callCount)) + resp.Body.Close() + }) + + t.Run("does not retry on 4xx client error", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusBadRequest) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + assert.Equal(t, int32(1), atomic.LoadInt32(&callCount), "Should not retry on 4xx") + resp.Body.Close() + }) + + t.Run("retries on 5xx server error", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + if count < 3 { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "Should retry on 5xx") + resp.Body.Close() + }) + + t.Run("returns error after max retries exhausted", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 2, 10*time.Millisecond, 100*time.Millisecond) + assert.Error(t, err) + assert.Contains(t, err.Error(), "HTTP 503") + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "Should make maxRetries+1 attempts") + resp.Body.Close() + }) + + t.Run("properly closes response body on retries to prevent leak", func(t *testing.T) { + var bodiesClosed int32 + + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count := atomic.AddInt32(&callCount, 1) + if count < 3 { + w.WriteHeader(http.StatusInternalServerError) + } else { + w.WriteHeader(http.StatusOK) + } + })) + defer ts.Close() + + // Create a custom transport that tracks body closures + originalTransport := http.DefaultTransport + transport := &mockTransport{ + RoundTripFunc: func(req *http.Request) (*http.Response, error) { + resp, err := originalTransport.RoundTrip(req) + if resp != nil && resp.Body != nil { + // Wrap body to track closure + wrapped := &trackingBody{ + ReadCloser: resp.Body, + onClose: func() { + atomic.AddInt32(&bodiesClosed, 1) + }, + } + resp.Body = wrapped + } + return resp, err + }, + } + + client := &http.Client{Transport: transport} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx := context.Background() + + resp, err := doRequestWithRetry(ctx, client, req, 3, 10*time.Millisecond, 100*time.Millisecond) + assert.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + // Close the final response + resp.Body.Close() + + // We made 3 requests, so we expect 3 bodies total, all should be closed + assert.Equal(t, int32(3), atomic.LoadInt32(&callCount), "Should make 3 requests") + assert.Equal(t, int32(3), atomic.LoadInt32(&bodiesClosed), "All 3 response bodies should be closed after retries") + }) + + t.Run("respects context cancellation", func(t *testing.T) { + callCount := int32(0) + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&callCount, 1) + w.WriteHeader(http.StatusInternalServerError) + })) + defer ts.Close() + + client := &http.Client{} + req, _ := http.NewRequest("GET", ts.URL, nil) + ctx, cancel := context.WithCancel(context.Background()) + + // Cancel after first request + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + resp, err := doRequestWithRetry(ctx, client, req, 10, 100*time.Millisecond, 1*time.Second) + assert.Error(t, err) + assert.Contains(t, err.Error(), "canceled") + // Should not have made all retries + assert.LessOrEqual(t, atomic.LoadInt32(&callCount), int32(2)) + if resp != nil { + resp.Body.Close() + } + }) +} + +// mockTransport is a custom http.RoundTripper for testing +type mockTransport struct { + RoundTripFunc func(req *http.Request) (*http.Response, error) +} + +func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + return m.RoundTripFunc(req) +} + +// trackingBody wraps an io.ReadCloser and calls onClose when Close is called +type trackingBody struct { + io.ReadCloser + onClose func() +} + +func (t *trackingBody) Close() error { + if t.onClose != nil { + t.onClose() + } + return t.ReadCloser.Close() +} diff --git a/pkg/notification/validate.go b/pkg/notification/validate.go new file mode 100644 index 000000000..966f5ef88 --- /dev/null +++ b/pkg/notification/validate.go @@ -0,0 +1,16 @@ +package notification + +import ( + "github.com/openflagr/flagr/pkg/config" + "github.com/sirupsen/logrus" +) + +// ValidateConfig checks notification configuration and logs warnings if misconfigured. +// This should be called during application startup. +func ValidateConfig() { + if config.Config.NotificationWebhookEnabled { + if config.Config.NotificationWebhookURL == "" { + logrus.Warn("Webhook notifications are enabled, but FLAGR_NOTIFICATION_WEBHOOK_URL is not set. Webhook notifications will be silently dropped.") + } + } +} diff --git a/pkg/notification/webhook.go b/pkg/notification/webhook.go new file mode 100644 index 000000000..dc7131409 --- /dev/null +++ b/pkg/notification/webhook.go @@ -0,0 +1,78 @@ +package notification + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/openflagr/flagr/pkg/config" + "github.com/openflagr/flagr/pkg/util" + "github.com/sirupsen/logrus" +) + +type webhookNotifier struct { + httpClient *http.Client +} + +// maxErrorBodyBytes limits how much of a webhook error response body is read +// to prevent memory exhaustion from a misconfigured or malicious endpoint. +const maxErrorBodyBytes = 4096 + +func NewWebhookNotifier() Notifier { + if config.Config.NotificationWebhookURL == "" { + logrus.Warn("NotificationWebhookURL is empty, using null notifier") + return &nullNotifier{} + } + + return &webhookNotifier{ + httpClient: &http.Client{}, + } +} + +func (w *webhookNotifier) Send(ctx context.Context, n Notification) error { + jsonPayload, err := json.Marshal(n) + if err != nil { + return fmt.Errorf("failed to marshal webhook payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", config.Config.NotificationWebhookURL, bytes.NewReader(jsonPayload)) + if err != nil { + return fmt.Errorf("failed to create webhook request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + for k, v := range util.ParseHeaders(config.Config.NotificationWebhookHeaders) { + req.Header.Set(k, v) + } + + // Execute request with retry + resp, err := doRequestWithRetry(ctx, w.httpClient, req, config.Config.NotificationMaxRetries, config.Config.NotificationRetryBase, config.Config.NotificationRetryMax) + if err != nil { + if resp != nil { + resp.Body.Close() + } + return fmt.Errorf("failed to send webhook: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes)) + return fmt.Errorf("webhook service returned error: %d - %s", resp.StatusCode, string(body)) + } + + logrus.WithFields(logrus.Fields{ + "status": resp.StatusCode, + "operation": n.Operation, + "flagID": n.FlagID, + }).Info("webhook notification sent successfully") + + return nil +} + +func (w *webhookNotifier) Name() string { + return "webhook" +} diff --git a/pkg/notification/webhook_test.go b/pkg/notification/webhook_test.go new file mode 100644 index 000000000..8b1b65a46 --- /dev/null +++ b/pkg/notification/webhook_test.go @@ -0,0 +1,111 @@ +package notification + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/openflagr/flagr/pkg/config" + "github.com/prashantv/gostub" + "github.com/stretchr/testify/assert" +) + +func TestWebhookNotifier(t *testing.T) { + t.Run("returns null notifier when no webhook URL", func(t *testing.T) { + wn := NewWebhookNotifier() + ctx := context.Background() + notif := Notification{ + Operation: "create", + FlagID: 1, + FlagKey: "test-flag", + User: "test@example.com", + } + + err := wn.Send(ctx, notif) + assert.NoError(t, err) + }) + + t.Run("sends custom headers", func(t *testing.T) { + + var receivedAuth string + var receivedCustomHeader string + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedAuth = r.Header.Get("Authorization") + receivedCustomHeader = r.Header.Get("X-Custom-Header") + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + stubs := gostub.Stub(&config.Config.NotificationWebhookURL, ts.URL) + defer stubs.Reset() + + stubs.Stub(&config.Config.NotificationWebhookHeaders, "Authorization: Bearer secret-token, X-Custom-Header: custom-value ") + + wn := NewWebhookNotifier() + ctx := context.Background() + notif := Notification{ + Operation: "create", + } + + err := wn.Send(ctx, notif) + assert.NoError(t, err) + + assert.Equal(t, "Bearer secret-token", receivedAuth) + assert.Equal(t, "custom-value", receivedCustomHeader) + }) + + t.Run("sends correctly formatted JSON payload", func(t *testing.T) { + var receivedBody []byte + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedBody, _ = io.ReadAll(r.Body) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + w.WriteHeader(http.StatusOK) + })) + defer ts.Close() + + stubs := gostub.Stub(&config.Config.NotificationWebhookURL, ts.URL) + defer stubs.Reset() + + wn := NewWebhookNotifier() + ctx := context.Background() + now := time.Date(2025, 1, 15, 12, 0, 0, 0, time.UTC) + notif := Notification{ + Operation: OperationUpdate, + FlagID: 42, + FlagKey: "my-feature", + ComponentType: "segment", + ComponentID: 7, + ComponentKey: "power-users", + Diff: "-old\n+new", + User: "admin@example.com", + Timestamp: now, + } + + err := wn.Send(ctx, notif) + assert.NoError(t, err) + + var parsed map[string]any + assert.NoError(t, json.Unmarshal(receivedBody, &parsed)) + + // Verify snake_case JSON keys + assert.Equal(t, "update", parsed["operation"]) + assert.Equal(t, float64(42), parsed["flag_id"]) + assert.Equal(t, "my-feature", parsed["flag_key"]) + assert.Equal(t, "segment", parsed["component_type"]) + assert.Equal(t, float64(7), parsed["component_id"]) + assert.Equal(t, "power-users", parsed["component_key"]) + assert.Equal(t, "-old\n+new", parsed["diff"]) + assert.Equal(t, "admin@example.com", parsed["user"]) + assert.Equal(t, "2025-01-15T12:00:00Z", parsed["timestamp"]) + + // Fields not present + assert.NotContains(t, parsed, "entity_type") // old name, should be gone + assert.NotContains(t, parsed, "object") // removed for simplicity + assert.NotContains(t, parsed, "pre_value") + assert.NotContains(t, parsed, "post_value") + }) +} diff --git a/pkg/util/util.go b/pkg/util/util.go index 4990323e9..7c499401b 100644 --- a/pkg/util/util.go +++ b/pkg/util/util.go @@ -97,3 +97,27 @@ func Round(f float64) int { func TimeNow() string { return time.Now().UTC().Format(time.RFC3339) } + +// ParseHeaders converts a comma-separated list of key-value pairs separated by colons into a map of strings. +// It gracefully handles edge cases such as empty headers, missing values, spaces around keys and values, +// and malformed chunks by filtering them out. +// Example: "Authorization: Bearer token, X-Custom-Header: value" will be parsed correctly. +func ParseHeaders(headerStr string) map[string]string { + headers := make(map[string]string) + if headerStr == "" { + return headers + } + + pairs := strings.Split(headerStr, ",") + for _, pair := range pairs { + parts := strings.SplitN(pair, ":", 2) + if len(parts) == 2 { + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + if key != "" { + headers[key] = val + } + } + } + return headers +} diff --git a/pkg/util/util_test.go b/pkg/util/util_test.go index 3cc3a8f75..937cb798b 100644 --- a/pkg/util/util_test.go +++ b/pkg/util/util_test.go @@ -284,3 +284,90 @@ func TestHasSafePrefix(t *testing.T) { }) } } + +func TestParseHeaders(t *testing.T) { + tests := []struct { + name string + input string + expected map[string]string + }{ + { + name: "empty string", + input: "", + expected: map[string]string{}, + }, + { + name: "single valid header", + input: "Authorization: Bearer token", + expected: map[string]string{ + "Authorization": "Bearer token", + }, + }, + { + name: "multiple valid headers", + input: "Authorization: Bearer token, X-Custom-Header: value", + expected: map[string]string{ + "Authorization": "Bearer token", + "X-Custom-Header": "value", + }, + }, + { + name: "messy spacing around colons and commas", + input: " Auth : Token , Another : Value ", + expected: map[string]string{ + "Auth": "Token", + "Another": "Value", + }, + }, + { + name: "missing value formatting", + input: "Authorization:,", + expected: map[string]string{ + "Authorization": "", + }, + }, + { + name: "missing colon format is ignored", + input: "InvalidFormat", + expected: map[string]string{}, + }, + { + name: "extra colons in the value are kept", + input: "Trace-Id: 123:456:789", + expected: map[string]string{ + "Trace-Id": "123:456:789", + }, + }, + { + name: "spaces only", + input: " ", + expected: map[string]string{}, + }, + { + name: "colons only", + input: ":::", + expected: map[string]string{}, + }, + { + name: "trailing and leading commas", + input: ",Authorization: Bearer token,", + expected: map[string]string{ + "Authorization": "Bearer token", + }, + }, + { + name: "valid headers mixed with invalid garbage", + input: "InvalidFormat, Authorization: Bearer token, , :valueOnly", + expected: map[string]string{ + "Authorization": "Bearer token", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := ParseHeaders(tt.input) + assert.Equal(t, tt.expected, result) + }) + } +} From 68e46565b89facd3a145c57cb7a640b2a973961f Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Sun, 26 Apr 2026 19:46:50 +0000 Subject: [PATCH 2/3] refactor(notification): extract component type strings to named constants Add ComponentType type with constants (ComponentFlag, ComponentSegment, ComponentVariant, ComponentConstraint, ComponentDistribution, ComponentTag) to replace magic string literals across handler call sites. --- pkg/entity/flag_snapshot.go | 2 +- pkg/entity/flag_snapshot_test.go | 4 ++-- pkg/handler/crud.go | 34 +++++++++++++++---------------- pkg/handler/crud_flag_creation.go | 2 +- pkg/handler/export_test.go | 6 +++--- pkg/notification/notifier.go | 14 ++++++++++++- pkg/notification/webhook_test.go | 4 ++-- 7 files changed, 39 insertions(+), 27 deletions(-) diff --git a/pkg/entity/flag_snapshot.go b/pkg/entity/flag_snapshot.go index 231ad1172..7ff7624c4 100644 --- a/pkg/entity/flag_snapshot.go +++ b/pkg/entity/flag_snapshot.go @@ -23,7 +23,7 @@ type FlagSnapshot struct { } // SaveFlagSnapshot saves the Flag Snapshot and sends a notification. -func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation notification.Operation, componentType string, componentID uint, componentKey string) { +func SaveFlagSnapshot(db *gorm.DB, flagID uint, updatedBy string, operation notification.Operation, componentType notification.ComponentType, componentID uint, componentKey string) { tx := db.Begin() f := &Flag{} // Use Unscoped to include soft-deleted flags. This is necessary for: diff --git a/pkg/entity/flag_snapshot_test.go b/pkg/entity/flag_snapshot_test.go index 42bf631a4..345cf2b41 100644 --- a/pkg/entity/flag_snapshot_test.go +++ b/pkg/entity/flag_snapshot_test.go @@ -18,10 +18,10 @@ func TestSaveFlagSnapshot(t *testing.T) { defer tmpDB.Close() t.Run("happy code path", func(t *testing.T) { - SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) + SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, notification.ComponentFlag, f.ID, f.Key) }) t.Run("save on non-existing flag", func(t *testing.T) { - SaveFlagSnapshot(db, uint(999999), "flagr-test@example.com", notification.OperationUpdate, "flag", 0, "") + SaveFlagSnapshot(db, uint(999999), "flagr-test@example.com", notification.OperationUpdate, notification.ComponentFlag, 0, "") }) } diff --git a/pkg/handler/crud.go b/pkg/handler/crud.go index 598914e6b..816e9caaf 100644 --- a/pkg/handler/crud.go +++ b/pkg/handler/crud.go @@ -271,7 +271,7 @@ func (c *crud) PutFlag(params flag.PutFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "flag", util.SafeUint(params.FlagID), f.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentFlag, util.SafeUint(params.FlagID), f.Key) return resp } @@ -294,7 +294,7 @@ func (c *crud) SetFlagEnabledState(params flag.SetFlagEnabledParams) middleware. } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "flag", util.SafeUint(params.FlagID), f.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentFlag, util.SafeUint(params.FlagID), f.Key) return resp } @@ -317,7 +317,7 @@ func (c *crud) RestoreFlag(params flag.RestoreFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationRestore, "flag", util.SafeUint(params.FlagID), f.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationRestore, notification.ComponentFlag, util.SafeUint(params.FlagID), f.Key) return resp } @@ -331,7 +331,7 @@ func (c *crud) DeleteFlag(params flag.DeleteFlagParams) middleware.Responder { return flag.NewDeleteFlagDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, "flag", util.SafeUint(params.FlagID), f.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, notification.ComponentFlag, util.SafeUint(params.FlagID), f.Key) return flag.NewDeleteFlagOK() } @@ -345,7 +345,7 @@ func (c *crud) DeleteTag(params tag.DeleteTagParams) middleware.Responder { if err := getDB().Model(s).Association("Tags").Delete(t); err != nil { return tag.NewDeleteTagDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "tag", uint(params.TagID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentTag, uint(params.TagID), "") return tag.NewDeleteTagOK() } @@ -407,7 +407,7 @@ func (c *crud) CreateTag(params tag.CreateTagParams) middleware.Responder { resp := tag.NewCreateTagOK() resp.SetPayload(e2r.MapTag(t)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "tag", t.ID, t.Value) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentTag, t.ID, t.Value) return resp } @@ -426,7 +426,7 @@ func (c *crud) CreateSegment(params segment.CreateSegmentParams) middleware.Resp resp := segment.NewCreateSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", s.ID, "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentSegment, s.ID, "") return resp } @@ -469,7 +469,7 @@ func (c *crud) PutSegment(params segment.PutSegmentParams) middleware.Responder resp := segment.NewPutSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", util.SafeUint(params.SegmentID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentSegment, util.SafeUint(params.SegmentID), "") return resp } @@ -493,7 +493,7 @@ func (c *crud) PutSegmentsReorder(params segment.PutSegmentsReorderParams) middl return segment.NewPutSegmentsReorderDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", 0, "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentSegment, 0, "") return segment.NewPutSegmentsReorderOK() } @@ -503,7 +503,7 @@ func (c *crud) DeleteSegment(params segment.DeleteSegmentParams) middleware.Resp return segment.NewDeleteSegmentDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "segment", util.SafeUint(params.SegmentID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentSegment, util.SafeUint(params.SegmentID), "") return segment.NewDeleteSegmentOK() } @@ -525,7 +525,7 @@ func (c *crud) CreateConstraint(params constraint.CreateConstraintParams) middle resp := constraint.NewCreateConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "constraint", cons.ID, "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentConstraint, cons.ID, "") return resp } @@ -563,7 +563,7 @@ func (c *crud) PutConstraint(params constraint.PutConstraintParams) middleware.R resp := constraint.NewPutConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "constraint", util.SafeUint(params.ConstraintID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentConstraint, util.SafeUint(params.ConstraintID), "") return resp } @@ -574,7 +574,7 @@ func (c *crud) DeleteConstraint(params constraint.DeleteConstraintParams) middle resp := constraint.NewDeleteConstraintOK() - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "constraint", util.SafeUint(params.ConstraintID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentConstraint, util.SafeUint(params.ConstraintID), "") return resp } @@ -610,7 +610,7 @@ func (c *crud) PutDistributions(params distribution.PutDistributionsParams) midd resp := distribution.NewPutDistributionsOK() resp.SetPayload(e2r.MapDistributions(ds)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "distribution", 0, "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentDistribution, 0, "") return resp } @@ -652,7 +652,7 @@ func (c *crud) CreateVariant(params variant.CreateVariantParams) middleware.Resp resp := variant.NewCreateVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "variant", v.ID, v.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentVariant, v.ID, v.Key) return resp } @@ -703,7 +703,7 @@ func (c *crud) PutVariant(params variant.PutVariantParams) middleware.Responder resp := variant.NewPutVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "variant", util.SafeUint(params.VariantID), v.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentVariant, util.SafeUint(params.VariantID), v.Key) return resp } @@ -716,6 +716,6 @@ func (c *crud) DeleteVariant(params variant.DeleteVariantParams) middleware.Resp return variant.NewDeleteVariantDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, "variant", util.SafeUint(params.VariantID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentVariant, util.SafeUint(params.VariantID), "") return variant.NewDeleteVariantOK() } diff --git a/pkg/handler/crud_flag_creation.go b/pkg/handler/crud_flag_creation.go index ec50a9ab2..ab3f8ca05 100644 --- a/pkg/handler/crud_flag_creation.go +++ b/pkg/handler/crud_flag_creation.go @@ -56,7 +56,7 @@ func (c *crud) CreateFlag(params flag.CreateFlagParams) middleware.Responder { } resp.SetPayload(payload) - entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, "flag", f.ID, f.Key) + entity.SaveFlagSnapshot(getDB(), f.ID, getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, notification.ComponentFlag, f.ID, f.Key) return resp } diff --git a/pkg/handler/export_test.go b/pkg/handler/export_test.go index b869122bf..760c50bc3 100644 --- a/pkg/handler/export_test.go +++ b/pkg/handler/export_test.go @@ -56,7 +56,7 @@ func TestExportFlags(t *testing.T) { func TestExportFlagSnapshots(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, notification.ComponentFlag, f.ID, f.Key) tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { @@ -85,7 +85,7 @@ func TestExportFlagSnapshots(t *testing.T) { func TestExportSQLiteFile(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, notification.ComponentFlag, f.ID, f.Key) tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { @@ -115,7 +115,7 @@ func TestExportSQLiteFile(t *testing.T) { func TestExportSQLiteHandler(t *testing.T) { f := entity.GenFixtureFlag() db := entity.PopulateTestDB(f) - entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, "flag", f.ID, f.Key) + entity.SaveFlagSnapshot(db, f.ID, "flagr-test@example.com", notification.OperationUpdate, notification.ComponentFlag, f.ID, f.Key) tmpDB1, dbErr1 := db.DB() if dbErr1 != nil { diff --git a/pkg/notification/notifier.go b/pkg/notification/notifier.go index 367c15717..d85ab9bdb 100644 --- a/pkg/notification/notifier.go +++ b/pkg/notification/notifier.go @@ -22,11 +22,23 @@ const ( OperationRestore Operation = "restore" ) +// ComponentType identifies which part of a flag was modified. +type ComponentType string + +const ( + ComponentFlag ComponentType = "flag" + ComponentSegment ComponentType = "segment" + ComponentVariant ComponentType = "variant" + ComponentConstraint ComponentType = "constraint" + ComponentDistribution ComponentType = "distribution" + ComponentTag ComponentType = "tag" +) + type Notification struct { Operation Operation `json:"operation"` FlagID uint `json:"flag_id"` FlagKey string `json:"flag_key"` - ComponentType string `json:"component_type,omitempty"` + ComponentType ComponentType `json:"component_type,omitempty"` ComponentID uint `json:"component_id,omitempty"` ComponentKey string `json:"component_key,omitempty"` PreValue string `json:"pre_value,omitempty"` diff --git a/pkg/notification/webhook_test.go b/pkg/notification/webhook_test.go index 8b1b65a46..4ec240edd 100644 --- a/pkg/notification/webhook_test.go +++ b/pkg/notification/webhook_test.go @@ -78,8 +78,8 @@ func TestWebhookNotifier(t *testing.T) { FlagID: 42, FlagKey: "my-feature", ComponentType: "segment", - ComponentID: 7, - ComponentKey: "power-users", + ComponentID: 7, + ComponentKey: "power-users", Diff: "-old\n+new", User: "admin@example.com", Timestamp: now, From e0e6995f78e4cab595abe72a88e7d91923af8ec8 Mon Sep 17 00:00:00 2001 From: zhouzhuojie Date: Tue, 5 May 2026 22:48:57 +0000 Subject: [PATCH 3/3] fix(notification): use correct operation for sub-resource create/delete events Sub-resource handlers (CreateTag, CreateSegment, CreateConstraint, CreateVariant) were using OperationUpdate instead of OperationCreate. Similarly, DeleteTag, DeleteSegment, DeleteConstraint, DeleteVariant were using OperationUpdate instead of OperationDelete. Fix them so the operation field accurately reflects what happened to the component. --- pkg/handler/crud.go | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/handler/crud.go b/pkg/handler/crud.go index 816e9caaf..3fac2af6c 100644 --- a/pkg/handler/crud.go +++ b/pkg/handler/crud.go @@ -345,7 +345,7 @@ func (c *crud) DeleteTag(params tag.DeleteTagParams) middleware.Responder { if err := getDB().Model(s).Association("Tags").Delete(t); err != nil { return tag.NewDeleteTagDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentTag, uint(params.TagID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, notification.ComponentTag, uint(params.TagID), "") return tag.NewDeleteTagOK() } @@ -407,7 +407,7 @@ func (c *crud) CreateTag(params tag.CreateTagParams) middleware.Responder { resp := tag.NewCreateTagOK() resp.SetPayload(e2r.MapTag(t)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentTag, t.ID, t.Value) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, notification.ComponentTag, t.ID, t.Value) return resp } @@ -426,7 +426,7 @@ func (c *crud) CreateSegment(params segment.CreateSegmentParams) middleware.Resp resp := segment.NewCreateSegmentOK() resp.SetPayload(e2r.MapSegment(s)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentSegment, s.ID, "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, notification.ComponentSegment, s.ID, "") return resp } @@ -503,7 +503,7 @@ func (c *crud) DeleteSegment(params segment.DeleteSegmentParams) middleware.Resp return segment.NewDeleteSegmentDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentSegment, util.SafeUint(params.SegmentID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, notification.ComponentSegment, util.SafeUint(params.SegmentID), "") return segment.NewDeleteSegmentOK() } @@ -525,7 +525,7 @@ func (c *crud) CreateConstraint(params constraint.CreateConstraintParams) middle resp := constraint.NewCreateConstraintOK() resp.SetPayload(e2r.MapConstraint(cons)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentConstraint, cons.ID, "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, notification.ComponentConstraint, cons.ID, "") return resp } @@ -574,7 +574,7 @@ func (c *crud) DeleteConstraint(params constraint.DeleteConstraintParams) middle resp := constraint.NewDeleteConstraintOK() - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentConstraint, util.SafeUint(params.ConstraintID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, notification.ComponentConstraint, util.SafeUint(params.ConstraintID), "") return resp } @@ -652,7 +652,7 @@ func (c *crud) CreateVariant(params variant.CreateVariantParams) middleware.Resp resp := variant.NewCreateVariantOK() resp.SetPayload(e2r.MapVariant(v)) - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentVariant, v.ID, v.Key) + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationCreate, notification.ComponentVariant, v.ID, v.Key) return resp } @@ -716,6 +716,6 @@ func (c *crud) DeleteVariant(params variant.DeleteVariantParams) middleware.Resp return variant.NewDeleteVariantDefault(500).WithPayload(ErrorMessage("%s", err)) } - entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationUpdate, notification.ComponentVariant, util.SafeUint(params.VariantID), "") + entity.SaveFlagSnapshot(getDB(), util.SafeUint(params.FlagID), getSubjectFromRequest(params.HTTPRequest), notification.OperationDelete, notification.ComponentVariant, util.SafeUint(params.VariantID), "") return variant.NewDeleteVariantOK() }