feat: add GetUserAttributeKeys - #1994
Conversation
There was a problem hiding this comment.
Pull Request Overview
This PR adds support for a new GetUserAttributeKeys feature endpoint, including server-side logic, client stubs, caching integration, and tests.
- Introduces new gRPC/HTTP RPC and protobuf messages for retrieving user attribute keys.
- Implements caching and event-based extraction of user attributes in the subscriber.
- Adds API handler, client mocks, JS stubs, Swagger spec, and unit tests for the new endpoint.
Reviewed Changes
Copilot reviewed 13 out of 17 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| ui/web-v2/src/proto/feature/service_pb_service.js | Add JS client method for GetUserAttributeKeys |
| ui/web-v2/src/proto/feature/service_pb.js | Add JS proto definitions for request/response |
| proto/feature/service.proto | Define GetUserAttributeKeys messages and RPC |
| proto/feature/service.pb.gw.go | Generate HTTP handlers for new RPC |
| pkg/subscriber/processor/evaluation_events_evaluation_count_event_persister.go | Add updateUserAttributes to extract/cache attributes |
| pkg/subscriber/cmd/server/server.go | Wire UserAttributesCache into subscriber processor |
| pkg/feature/client/mock/client.go | Mock GetUserAttributeKeys in feature client |
| pkg/feature/api/user_attribute.go | Implement API endpoint logic for GetUserAttributeKeys |
| pkg/feature/api/user_attribute_test.go | Add unit tests for GetUserAttributeKeys |
| pkg/feature/api/api.go | Register UserAttributesCache in FeatureService |
| api-description/web-api.swagger.yaml | Document new endpoint and response schema |
| Makefile | Restrict e2e tests to gateway directory |
Comments suppressed due to low confidence (4)
pkg/subscriber/processor/evaluation_events_evaluation_count_event_persister.go:494
- [nitpick] Consider adding unit tests for
updateUserAttributesto verify correct extraction and caching of user attributes across different event scenarios.
func (p *evaluationCountEventPersister) updateUserAttributes(envEvents environmentEventMap) {
proto/feature/service.proto:1655
- The HTTP GET annotation should include the
environment_idquery parameter (e.g.,"/v1/feature/user-attribute-keys?environmentId={environment_id}") to bind the request field correctly.
get: "/v1/feature/user-attribute-keys"
api-description/web-api.swagger.yaml:5160
- The swagger spec defines a 503 response but the proto HTTP options only specify up to a 500 response. Align status codes between the proto annotations and the swagger documentation.
"503":
Makefile:251
- [nitpick] Restricting the e2e target to only
test/e2e/gatewaymay omit other end-to-end tests. Verify this aligns with intended coverage requirements.
go test -v ./test/e2e/gateway/... -args \
| _, err := s.checkEnvironmentRole( | ||
| ctx, accountproto.AccountV2_Role_Environment_VIEWER, | ||
| req.EnvironmentId, localizer) | ||
| if err != nil { | ||
| s.logger.Error("Failed to get user attribute keys", zap.Error(err)) | ||
| return nil, err | ||
| } | ||
|
|
There was a problem hiding this comment.
The check for empty EnvironmentId happens after permission validation, so missing-ID errors may be masked by permission errors. Consider validating req.EnvironmentId is non-empty before calling checkEnvironmentRole.
| _, err := s.checkEnvironmentRole( | |
| ctx, accountproto.AccountV2_Role_Environment_VIEWER, | |
| req.EnvironmentId, localizer) | |
| if err != nil { | |
| s.logger.Error("Failed to get user attribute keys", zap.Error(err)) | |
| return nil, err | |
| } |
| if req.EnvironmentId == "" { | ||
| dt, err := statusMissingID.WithDetails(&errdetails.LocalizedMessage{ | ||
| Locale: localizer.GetLocale(), | ||
| Message: localizer.MustLocalizeWithTemplate(locale.RequiredFieldTemplate, "environment_id"), | ||
| }) | ||
| if err != nil { | ||
| return nil, statusInternal.Err() | ||
| } | ||
| return nil, dt.Err() | ||
| } |
There was a problem hiding this comment.
Because we have environments with empty IDs, we can't validate them.
| // Increment the evaluation event count in the Redis | ||
| fails := p.incrementEnvEvents(envEvents) | ||
| // Save user attributes to cache | ||
| p.updateUserAttributes(envEvents) |
There was a problem hiding this comment.
Doing this synchronously will delay the data in the pubsub. Since this data is not essential and can be lost, it should be done asynchronously.
Also, as we discussed, we don't need to save all requests in Redis because 90% of the cases would have the same user attributes. This will increase the cost of the Redis Private service connection.
Please check the way we did it to save the last_used_info logic.
| featureproto "github.com/bucketeer-io/bucketeer/proto/feature" | ||
| ) | ||
|
|
||
| func TestGetUserAttributeKeys(t *testing.T) { |
There was a problem hiding this comment.
An e2e test for this API would be nice!
| zap.String("environmentId", userAttributes.EnvironmentId), | ||
| zap.Int("attributeCount", len(userAttributes.UserAttributes)), |
There was a problem hiding this comment.
| zap.String("environmentId", userAttributes.EnvironmentId), | |
| zap.Int("attributeCount", len(userAttributes.UserAttributes)), | |
| zap.String("environmentId", userAttributes.EnvironmentId), | |
| zap.Any("attributes", userAttributes.UserAttributes), | |
| zap.Int("attributeCount", len(userAttributes.UserAttributes)), |
kakcy
left a comment
There was a problem hiding this comment.
@cre8ivejp
Thank you for your review.
The review pointed out have been fixed.
Also, as we discussed, we don't need to save all requests in Redis because 90% of the cases would have the same user attributes. This will increase the cost of the Redis Private service connection.
Please check the way we did it to save the last_used_info logic.
I have modified it so that it caches in-memory based on the last_used_info logic, and then caches asynchronously in Redis periodically.
Please check it out when you have free time.
| } | ||
| } | ||
|
|
||
| func TestGetUserAttributeKeys(t *testing.T) { |
| for _, attr := range userAttributesMap { | ||
| userAttributes.UserAttributes = append(userAttributes.UserAttributes, attr) | ||
| } | ||
| p.userAttributesCache[environmentId] = userAttributes |
There was a problem hiding this comment.
The current implementation overrides the current cache with every request.
If the event is from iOS with 4 attributes and the next one is from Android with 2 attributes, the iOS attributes will not be saved.
| Id: userID, | ||
| Data: data, | ||
| }, | ||
| Reason: &featureproto.Reason{}, |
There was a problem hiding this comment.
Set the reason to CLIENT to avoid errors in the subscriber.
|
|
||
| testUserDataKeySuffix := "testGetUserAttributeKeys-" | ||
| data := map[string]string{ | ||
| testUserDataKeySuffix + uuid: "0.1.0", |
There was a problem hiding this comment.
@add at least 3 attributes, so you ensure that the loop saving the keys in the subscriber is working properly.
| testUserDataKeySuffix + uuid: "0.1.0", | ||
| } | ||
|
|
||
| evaluation, err := ptypes.MarshalAny(&eventproto.EvaluationEvent{ |
There was a problem hiding this comment.
Send at least 2 different Evaluation events using different source IDs and attributes. So, we ensure that is saving it correctly without overriding the attributes from other events.
| FeatureId: featureID, | ||
| FeatureVersion: 1, | ||
| UserId: userID, | ||
| VariationId: "variation-id", |
There was a problem hiding this comment.
Use the get feature API to retrieve the flag you created for testing, and get the correct variation ID and feature version, to avoid errors and buggy states when saving in the subscriber.
| func (p *evaluationCountEventPersister) writeUserAttributes() { | ||
| p.userAttributesCacheMutex.Lock() | ||
| defer p.userAttributesCacheMutex.Unlock() | ||
|
|
||
| for _, cache := range p.userAttributesCache { | ||
| if cache != nil && len(cache.UserAttributes) > 0 { | ||
| if err := p.upsertUserAttributes(cache); err != nil { | ||
| continue | ||
| } | ||
| } | ||
| } | ||
| // Reset the cache | ||
| p.userAttributesCache = make(userAttributesCache) | ||
| } |
There was a problem hiding this comment.
WDYT of the following approach?
If Redis is down or being restarted, if an error happens, it would delete all in-memory cache.
The example below would only delete the data if it succeeds.
| func (p *evaluationCountEventPersister) writeUserAttributes() { | |
| p.userAttributesCacheMutex.Lock() | |
| defer p.userAttributesCacheMutex.Unlock() | |
| for _, cache := range p.userAttributesCache { | |
| if cache != nil && len(cache.UserAttributes) > 0 { | |
| if err := p.upsertUserAttributes(cache); err != nil { | |
| continue | |
| } | |
| } | |
| } | |
| // Reset the cache | |
| p.userAttributesCache = make(userAttributesCache) | |
| } | |
| func (p *evaluationCountEventPersister) writeUserAttributes() { | |
| p.userAttributesCacheMutex.Lock() | |
| defer p.userAttributesCacheMutex.Unlock() | |
| for envID, cache := range p.userAttributesCache { | |
| if cache != nil && len(cache.UserAttributes) > 0 { | |
| if err := p.upsertUserAttributes(cache); err != nil { | |
| p.logger.Error( | |
| "Failed to save user attributes, will retry next cycle", | |
| zap.Error(err), | |
| zap.String("environmentId", envID), | |
| ) | |
| continue | |
| } | |
| // If successful, delete it from the cache. | |
| // The failed items will remain for the next attempt. | |
| delete(p.userAttributesCache, envID) | |
| } | |
| } | |
| } |
|
@cre8ivejp |
This pull request introduces a new feature to manage user attributes across environments, including API endpoint additions, caching enhancements, and related tests. The most important changes include the addition of a new API endpoint to retrieve user attribute keys, integration of a user attributes cache, and logic to process and store user attributes in the cache.
related to #686
API Enhancements:
/v1/feature/user-attribute-keysinweb-api.swagger.yamlto retrieve user attribute keys for a specified environment, including detailed response codes and descriptions.featureGetUserAttributeKeysResponseschema inweb-api.swagger.yamlto structure the API response for the new endpoint.Caching and Service Updates:
UserAttributesCacheinto theFeatureServiceinapi.go, enabling storage and retrieval of user attribute keys. [1] [2]Logic for User Attributes Processing:
GetUserAttributeKeysmethod inuser_attribute.goto retrieve user attribute keys from the cache, with validation and error handling.evaluation_events_evaluation_count_event_persister.goto extract user attributes from events and store them in the cache. [1] [2]Testing:
GetUserAttributeKeysmethod inuser_attribute_test.go, covering various scenarios such as empty environment ID, permission errors, cache errors, and successful responses.api_test.goto mock theUserAttributesCacheand validate its integration. [1] [2] [3]