Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(browser-sdk,react-sdk): extend the web SDKs to support the new config option. #285

Merged
merged 32 commits into from
Jan 29, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4f6ccb6
Add support for feature-specific config payloads
pavkam Jan 10, 2025
7b3a4af
Refactor flag evaluation logic and update SDK version.
pavkam Jan 13, 2025
70f2393
Add feature configuration support and enhance fallback features
pavkam Jan 13, 2025
2e30def
Merge remote-tracking branch 'origin/main' into buc-3198-extend-the-w…
pavkam Jan 13, 2025
d2e9de6
Bump version to 0.4.0
pavkam Jan 13, 2025
5af7fd3
Merge branch 'main' into buc-3198-extend-the-web-sdks-to-expose-the-c…
pavkam Jan 22, 2025
340fc9c
feat(browser-sdk): add format script, improve README formatting, and …
pavkam Jan 22, 2025
a6c6678
feat(browser-sdk): update feature configuration handling and improve …
pavkam Jan 22, 2025
f5f250e
feat(react-sdk): enhance feature handling in useFeature hook
pavkam Jan 22, 2025
66f661a
feat(browser-sdk, react-sdk, openfeature-browser-sdk): enhance featur…
pavkam Jan 22, 2025
38975e1
feat(openfeature-browser-provider): revert changes to openfeature ada…
pavkam Jan 24, 2025
f2ba839
chore(browser-sdk, react-sdk): Respond to comments
pavkam Jan 24, 2025
f1383e6
fix(browser-sdk): update README to reflect changes in feature configu…
pavkam Jan 24, 2025
5f02bb8
Merge branch 'main' into buc-3198-extend-the-web-sdks-to-expose-the-c…
pavkam Jan 27, 2025
295f05d
fix(browser-sdk): update feature remote config type definition
pavkam Jan 27, 2025
0dd5757
chore(react-sdk): remove unused import from index.tsx
pavkam Jan 27, 2025
7173423
feat(browser-sdk): enhance fallback feature configuration handling
pavkam Jan 27, 2025
862df25
test(browser-sdk): remove .only from features test
pavkam Jan 27, 2025
e22475b
test(browser-sdk): update feature test expectations
pavkam Jan 27, 2025
2e62475
refactor(react-sdk): simplify feature config access and reduce payload
pavkam Jan 27, 2025
2f74252
Merge branch 'main' into buc-3198-extend-the-web-sdks-to-expose-the-c…
pavkam Jan 28, 2025
fa735d8
docs(browser-sdk,react-sdk): clarify remote config documentation
pavkam Jan 28, 2025
a6eb451
Merge remote-tracking branch 'origin/main' into buc-3198-extend-the-w…
pavkam Jan 28, 2025
ff3e85a
Merge remote-tracking branch 'origin/buc-3198-extend-the-web-sdks-to-…
pavkam Jan 28, 2025
f986feb
feat(browser-sdk): export FallbackFeatureOverride type
pavkam Jan 28, 2025
7c6c47c
chore(release): bump browser-sdk and react-sdk to 3.0.0-alpha.1
pavkam Jan 28, 2025
352a462
Merge branch 'main' into buc-3198-extend-the-web-sdks-to-expose-the-c…
pavkam Jan 28, 2025
5a8fffe
Merge branch 'main' into buc-3198-extend-the-web-sdks-to-expose-the-c…
pavkam Jan 29, 2025
f4b6643
Merge branch 'main' into buc-3198-extend-the-web-sdks-to-expose-the-c…
pavkam Jan 29, 2025
dde88b5
fix: ensure feature config always has key and payload properties
pavkam Jan 29, 2025
bb506b4
refactor: simplify feature config type definition
pavkam Jan 29, 2025
564332a
Merge remote-tracking branch 'origin/buc-3198-extend-the-web-sdks-to-…
pavkam Jan 29, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 43 additions & 4 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,24 @@ const bucketClient = new BucketClient({ publishableKey, user, company });

await bucketClient.initialize();

const { isEnabled, track, requestFeedback } = bucketClient.getFeature("huddle");
const { isEnabled, config, track, requestFeedback } = bucketClient.getFeature("huddle");

if (isEnabled) {
// show feature. When retrieving `isEnabled` the client automatically
// Show feature. When retrieving `isEnabled` the client automatically
// sends a "check" event for the "huddle" feature which is shown in the
// Bucket UI.

// On usage, call `track` to let Bucket know that a user interacted with the feature
track();

// The `config` is a user-supplied value in Bucket that can be dynamically evaluated
// with respect to the current context. Here, it is assumed that one could either get
// a config value that maches the context or not.
const question = config?.question ?? "Tell us what you think of Huddles"

// Use `requestFeedback` to create "Send feedback" buttons easily for specific
// features. This is not related to `track` and you can call them individually.
requestFeedback({ title: "Tell us what you think of Huddles" });
requestFeedback({ title: question });
}

// `track` just calls `bucketClient.track(<featureKey>)` to send an event using the same feature key
Expand Down Expand Up @@ -127,6 +132,7 @@ To retrieve features along with their targeting information, use `getFeature(key
const huddle = bucketClient.getFeature("huddle");
// {
// isEnabled: true,
// config: any,
// track: () => Promise<Response>
// requestFeedback: (options: RequestFeedbackData) => void
// }
Expand All @@ -140,6 +146,7 @@ const features = bucketClient.getFeatures();
// huddle: {
// isEnabled: true,
// targetingVersion: 42,
// config: ...
// }
// }
```
Expand All @@ -148,7 +155,39 @@ const features = bucketClient.getFeatures();
by down-stream clients, like the React SDK.

Note that accessing `isEnabled` on the object returned by `getFeatures` does not automatically
generate a `check` event, contrary to the `isEnabled` property on the object return from `getFeature`.
generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFeature`.

### Feature toggles
pavkam marked this conversation as resolved.
Show resolved Hide resolved

Similar to `isEnabled`, each feature has a `config` property. This configuration is set by the user within Bucket. It is
similar to the way access is controlled, using matching rules. Each config-bound rule is given a configuration payload
(a JSON value) that is returned to the SDKs if the requested context matches that specific rule. It is possible to have
multiple rules with different configuration payloads. Whichever rule matches the context, provides the configuration
payload.
pavkam marked this conversation as resolved.
Show resolved Hide resolved


The config is accessible through the same methods as the `isEnabled` property:

```ts
const features = bucketClient.getFeatures();
// {
// huddle: {
// isEnabled: true,
// targetingVersion: 42,
// config?: {
// name: "gpt-3.5",
// version: 2,
pavkam marked this conversation as resolved.
Show resolved Hide resolved
// payload: { maxTokens: 10000, model: "gpt-3.5-beta1" }
// }
// }
// }
```
The `name` is given by the user in Bucket for each configuration variant, and `version` is maintained by Bucket similar
to `targetingVersion`. The `payload` is the actual JSON value supplied by the user and serves as context-based
configuration.

Just as `isEnabled`, accessing `config` on the object returned by `getFeatures` does not automatically
generate a `check` event, contrary to the `config` property on the object returned by `getFeature`.

### Tracking feature usage

Expand Down
2 changes: 1 addition & 1 deletion packages/browser-sdk/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@bucketco/browser-sdk",
"version": "2.5.0",
"version": "2.6.0",
pavkam marked this conversation as resolved.
Show resolved Hide resolved
"packageManager": "yarn@4.1.1",
"license": "MIT",
"repository": {
Expand Down
64 changes: 40 additions & 24 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,9 +180,13 @@ export interface Feature {
*/
isEnabled: boolean;

/*
* Optional user-defined configuration
*/
config: any;

/**
* Function to send analytics events for this feature
*
*/
track: () => Promise<Response | undefined>;
requestFeedback: (
Expand All @@ -194,16 +198,16 @@ export interface Feature {
*
*/
export class BucketClient {
private publishableKey: string;
private context: BucketContext;
private readonly publishableKey: string;
private readonly context: BucketContext;
private config: Config;
private requestFeedbackOptions: Partial<RequestFeedbackOptions>;
private logger: Logger;
private httpClient: HttpClient;
private readonly logger: Logger;
private readonly httpClient: HttpClient;

private autoFeedback: AutoFeedback | undefined;
private readonly autoFeedback: AutoFeedback | undefined;
private autoFeedbackInit: Promise<void> | undefined;
private featuresClient: FeaturesClient;
private readonly featuresClient: FeaturesClient;

/**
* Create a new BucketClient instance.
Expand Down Expand Up @@ -328,7 +332,7 @@ export class BucketClient {
* Performs a shallow merge with the existing company context.
* Attempting to update the company ID will log a warning and be ignored.
*
* @param company
* @param company The company details.
*/
async updateCompany(company: { [key: string]: string | number | undefined }) {
if (company.id && company.id !== this.context.company?.id) {
Expand All @@ -350,6 +354,8 @@ export class BucketClient {
* Update the company context.
* Performs a shallow merge with the existing company context.
* Updates to the company ID will be ignored.
*
* @param otherContext Additional context.
*/
async updateOtherContext(otherContext: {
[key: string]: string | number | undefined;
Expand All @@ -367,7 +373,7 @@ export class BucketClient {
*
* Calling `client.stop()` will remove all listeners added here.
*
* @param cb this will be called when the features are updated.
* @param cb The callback to call when the update completes.
*/
onFeaturesUpdated(cb: () => void) {
return this.featuresClient.onUpdated(cb);
Expand All @@ -376,8 +382,8 @@ export class BucketClient {
/**
* Track an event in Bucket.
*
* @param eventName The name of the event
* @param attributes Any attributes you want to attach to the event
* @param eventName The name of the event.
* @param attributes Any attributes you want to attach to the event.
*/
async track(eventName: string, attributes?: Record<string, any> | null) {
if (!this.context.user) {
Expand Down Expand Up @@ -405,7 +411,8 @@ export class BucketClient {
/**
* Submit user feedback to Bucket. Must include either `score` or `comment`, or both.
*
* @returns
* @param payload The feedback details to submit.
* @returns The server response.
*/
async feedback(payload: Feedback) {
const userId =
Expand Down Expand Up @@ -499,35 +506,44 @@ export class BucketClient {
* Returns a map of enabled features.
* Accessing a feature will *not* send a check event
*
* @returns Map of features
* @returns Map of features.
*/
getFeatures(): RawFeatures {
return this.featuresClient.getFeatures();
}

/**
* Return a feature. Accessing `isEnabled` will automatically send a `check` event.
* @returns A feature
* Return a feature. Accessing `isEnabled` or `config` will automatically send a `check` event.
* @returns A feature.
*/
getFeature(key: string): Feature {
const f = this.getFeatures()[key];

const fClient = this.featuresClient;
const value = f?.isEnabled ?? false;
const config = f?.config?.payload;

function sendCheckEvent() {
fClient
.sendCheckEvent({
key: key,
pavkam marked this conversation as resolved.
Show resolved Hide resolved
version: f?.targetingVersion,
value,
})
.catch(() => {
// ignore
});
}
pavkam marked this conversation as resolved.
Show resolved Hide resolved

return {
get isEnabled() {
fClient
.sendCheckEvent({
key: key,
version: f?.targetingVersion,
value,
})
.catch(() => {
// ignore
});
sendCheckEvent();
return value;
},
get config() {
sendCheckEvent();
return config;
},
track: () => this.track(key),
requestFeedback: (
options: Omit<RequestFeedbackData, "featureKey" | "featureId">,
Expand Down
11 changes: 8 additions & 3 deletions packages/browser-sdk/src/feature/featureCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,19 +22,24 @@ export function parseAPIFeaturesResponse(
const features: RawFeatures = {};
for (const key in featuresInput) {
const feature = featuresInput[key];

if (
typeof feature.isEnabled !== "boolean" ||
feature.key !== key ||
typeof feature.targetingVersion !== "number"
typeof feature.targetingVersion !== "number" ||
(feature.config && typeof feature.config !== "object")
) {
return;
}

features[key] = {
isEnabled: feature.isEnabled,
targetingVersion: feature.targetingVersion,
key,
config: feature.config,
};
}

return features;
}

Expand All @@ -45,8 +50,8 @@ export interface CacheResult {

export class FeatureCache {
private storage: StorageItem;
private staleTimeMs: number;
private expireTimeMs: number;
private readonly staleTimeMs: number;
private readonly expireTimeMs: number;

constructor({
storage,
Expand Down
66 changes: 54 additions & 12 deletions packages/browser-sdk/src/feature/features.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,25 @@ export type RawFeature = {
* Version of targeting rules
*/
targetingVersion?: number;

/**
* Optional user-defined configuration.
*/
config?: {
/**
* The name of the matched configuration variant.
*/
name: string | null;
/**
* The version of the matched configuration variant.
*/
version: number;

/**
* The user-supplied data.
*/
payload: any;
};
};

const FEATURES_UPDATED_EVENT = "features-updated";
Expand All @@ -33,12 +52,14 @@ export type RawFeatures = Record<string, RawFeature | undefined>;
export type FeaturesOptions = {
/**
* Feature keys for which `isEnabled` should fallback to true
* if SDK fails to fetch features from Bucket servers.
* if SDK fails to fetch features from Bucket servers. If a record
* is supplied instead of array, the values of each key represent the
* configuration values and `isEnabled` is assume `true`.
*/
fallbackFeatures?: string[];
fallbackFeatures?: string[] | Record<string, any>;

/**
* Timeout in miliseconds
* Timeout in milliseconds
*/
timeoutMs?: number;

Expand All @@ -52,13 +73,13 @@ export type FeaturesOptions = {
};

type Config = {
fallbackFeatures: string[];
fallbackFeatures: Record<string, any>;
timeoutMs: number;
staleWhileRevalidate: boolean;
};

export const DEFAULT_FEATURES_CONFIG: Config = {
fallbackFeatures: [],
fallbackFeatures: {},
timeoutMs: 5000,
staleWhileRevalidate: false,
};
Expand All @@ -84,7 +105,9 @@ export function validateFeaturesResponse(response: any) {
if (typeof response.success !== "boolean" || !isObject(response.features)) {
return;
}

const features = parseAPIFeaturesResponse(response.features);

if (!features) {
return;
}
Expand Down Expand Up @@ -174,6 +197,20 @@ export class FeaturesClient {
staleTimeMs: options?.staleTimeMs ?? 0,
expireTimeMs: options?.expireTimeMs ?? FEATURES_EXPIRE_MS,
});

if (Array.isArray(options?.fallbackFeatures)) {
options = {
...options,
fallbackFeatures: options.fallbackFeatures.reduce(
(acc, key) => {
acc[key] = null;
return acc;
},
{} as Record<string, any>,
),
};
}

this.config = { ...DEFAULT_FEATURES_CONFIG, ...options };
this.rateLimiter =
options?.rateLimiter ??
Expand Down Expand Up @@ -246,6 +283,7 @@ export class FeaturesClient {
JSON.stringify(errorBody),
);
}

const typeRes = validateFeaturesResponse(await res.json());
if (!typeRes || !typeRes.success) {
throw new Error("unable to validate response");
Expand Down Expand Up @@ -352,12 +390,16 @@ export class FeaturesClient {
}

// fetch failed, nothing cached => return fallbacks
return this.config.fallbackFeatures.reduce((acc, key) => {
acc[key] = {
key,
isEnabled: true,
};
return acc;
}, {} as RawFeatures);
return Object.entries(this.config.fallbackFeatures).reduce(
(acc, [key, config]) => {
acc[key] = {
key,
isEnabled: true,
config,
};
return acc;
},
{} as RawFeatures,
);
}
}
Loading
Loading