Skip to content

Commit d61f6ae

Browse files
committed
feat(browser-sdk): Event listeners (#325)
Event listeners allow capturing different events that happen in the Browser SDK. They are useful for a number of things like building client side integrations to analytics or error-logging systems.
1 parent 51fffb7 commit d61f6ae

12 files changed

Lines changed: 419 additions & 68 deletions

File tree

packages/browser-sdk/README.md

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ Bucket can assist you with collecting your user's feedback by offering a pre-bui
294294

295295
Feedback can be submitted to Bucket using the SDK:
296296

297-
```js
297+
```ts
298298
bucketClient.feedback({
299299
featureId: "my_feature_id", // String (required), copy from Feature feedback tab
300300
score: 5, // Number: 1-5 (optional)
@@ -308,6 +308,33 @@ If you are not using the Bucket Browser SDK, you can still submit feedback using
308308

309309
See details in [Feedback HTTP API](https://docs.bucket.co/reference/http-tracking-api#feedback)
310310

311+
### Event listeners
312+
313+
Event listeners allow for capturing various events occurring in the `BucketClient`. This is useful to build integrations with other system or for various debugging purposes. There are 5 kinds of events:
314+
315+
- FeaturesUpdated
316+
- User
317+
- Company
318+
- Check
319+
- Track
320+
321+
Use the `on()` method to add an event listener to respond to certain events. See the API reference for details on each hook.
322+
323+
```ts
324+
import { BucketClient, CheckEvent, RawFeatures } from "@bucketco/browser-sdk";
325+
326+
const client = new BucketClient({
327+
// options
328+
});
329+
330+
// or add the hooks after construction:
331+
const unsub = client.on("enabledCheck", (check: CheckEvent) =>
332+
console.log(`Check event ${check}`),
333+
);
334+
// use the returned function to unsubscribe, or call `off()` with the same arguments again
335+
unsub();
336+
```
337+
311338
### Zero PII
312339

313340
The Bucket Browser SDK doesn't collect any metadata and HTTP IP addresses are _not_ being stored.

packages/browser-sdk/example/typescript/app.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
1-
import { BucketClient } from "../../src";
1+
import { BucketClient, CheckEvent, RawFeatures } from "../../src";
22

33
const urlParams = new URLSearchParams(window.location.search);
44
const publishableKey = urlParams.get("publishableKey");
55
const featureKey = urlParams.get("featureKey") ?? "huddles";
66

7-
const featureList = ["huddles"];
8-
97
if (!publishableKey) {
108
throw Error("publishableKey is missing");
119
}
@@ -18,7 +16,6 @@ const bucket = new BucketClient({
1816
show: true,
1917
position: { placement: "bottom-right" },
2018
},
21-
featureList,
2219
});
2320

2421
document
@@ -37,8 +34,8 @@ bucket.initialize().then(() => {
3734
if (loadingElem) loadingElem.style.display = "none";
3835
});
3936

40-
bucket.onFeaturesUpdated(() => {
41-
const { isEnabled } = bucket.getFeature("huddles");
37+
bucket.on("featuresUpdated", (features: RawFeatures) => {
38+
const { isEnabled } = features[featureKey];
4239

4340
const startHuddleElem = document.getElementById("start-huddle");
4441
if (isEnabled) {

packages/browser-sdk/index.html

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@
3636

3737
<script type="module">
3838
import { BucketClient } from "./src/index.ts";
39-
const featureList = ["huddles"];
4039

4140
window.bucket = new BucketClient({
4241
publishableKey,
@@ -48,22 +47,28 @@
4847
placement: "bottom-right",
4948
},
5049
},
51-
featureList,
5250
});
5351

5452
bucket.initialize().then(() => {
5553
console.log("Bucket initialized");
5654
document.getElementById("loading").style.display = "none";
5755
});
5856

59-
bucket.onFeaturesUpdated(() => {
60-
const { isEnabled } = bucket.getFeature("huddles");
61-
if (isEnabled) {
57+
bucket.on("enabledCheck", (check) =>
58+
console.log(`Check event for ${check.key}`),
59+
);
60+
61+
bucket.on("featuresUpdated", (features) => {
62+
console.log("Features updated");
63+
const feature = bucket.getFeature(featureKey);
64+
65+
const startHuddleElem = document.getElementById("start-huddle");
66+
if (feature.isEnabled) {
6267
// show the start-huddle button
63-
document.getElementById("start-huddle").style.display = "block";
68+
if (startHuddleElem) startHuddleElem.style.display = "block";
6469
} else {
6570
// hide the start-huddle button
66-
document.getElementById("start-huddle").style.display = "none";
71+
if (startHuddleElem) startHuddleElem.style.display = "none";
6772
}
6873
});
6974
</script>

packages/browser-sdk/src/client.ts

Lines changed: 55 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import * as feedbackLib from "./feedback/ui";
1616
import { ToolbarPosition } from "./toolbar/Toolbar";
1717
import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./config";
1818
import { BucketContext, CompanyContext, UserContext } from "./context";
19+
import { HookArgs, HooksManager } from "./hooksManager";
1920
import { HttpClient } from "./httpClient";
2021
import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger";
2122
import { showToolbarToggle } from "./toolbar";
@@ -356,6 +357,8 @@ export class BucketClient {
356357

357358
public readonly logger: Logger;
358359

360+
private readonly hooks: HooksManager;
361+
359362
/**
360363
* Create a new BucketClient instance.
361364
*/
@@ -433,6 +436,12 @@ export class BucketClient {
433436
typeof opts.toolbar === "object" ? opts.toolbar.position : undefined,
434437
});
435438
}
439+
440+
// Register hooks
441+
this.hooks = new HooksManager();
442+
this.featuresClient.onUpdated(() => {
443+
this.hooks.trigger("featuresUpdated", this.featuresClient.getFeatures());
444+
});
436445
}
437446

438447
/**
@@ -463,6 +472,31 @@ export class BucketClient {
463472
}
464473
}
465474

475+
/**
476+
* Add a hook to the client.
477+
*
478+
* @param hook Hook to add.
479+
*/
480+
on<THookType extends keyof HookArgs>(
481+
type: THookType,
482+
handler: (args0: HookArgs[THookType]) => void,
483+
) {
484+
this.hooks.addHook(type, handler);
485+
}
486+
487+
/**
488+
* Remove a hook from the client.
489+
*
490+
* @param hook Hook to add.
491+
* @returns A function to remove the hook.
492+
*/
493+
off<THookType extends keyof HookArgs>(
494+
type: THookType,
495+
handler: (args0: HookArgs[THookType]) => void,
496+
) {
497+
return this.hooks.removeHook(type, handler);
498+
}
499+
466500
/**
467501
* Get the current configuration.
468502
*/
@@ -534,18 +568,6 @@ export class BucketClient {
534568
await this.featuresClient.setContext(this.context);
535569
}
536570

537-
/**
538-
* Register a callback to be called when the features are updated.
539-
* Features are not guaranteed to have actually changed when the callback is called.
540-
*
541-
* Calling `client.stop()` will remove all listeners added here.
542-
*
543-
* @param cb The callback to call when the update completes.
544-
*/
545-
onFeaturesUpdated(cb: () => void) {
546-
return this.featuresClient.onUpdated(cb);
547-
}
548-
549571
/**
550572
* Track an event in Bucket.
551573
*
@@ -572,6 +594,13 @@ export class BucketClient {
572594

573595
const res = await this.httpClient.post({ path: `/event`, body: payload });
574596
this.logger.debug(`sent event`, res);
597+
598+
this.hooks.trigger("track", {
599+
eventName,
600+
attributes,
601+
user: this.context.user,
602+
company: this.context.company,
603+
});
575604
return res;
576605
}
577606

@@ -684,7 +713,8 @@ export class BucketClient {
684713
getFeature(key: string): Feature {
685714
const f = this.getFeatures()[key];
686715

687-
const fClient = this.featuresClient;
716+
// eslint-disable-next-line @typescript-eslint/no-this-alias
717+
const self = this;
688718
const value = f?.isEnabledOverride ?? f?.isEnabled ?? false;
689719
const config = f?.config
690720
? {
@@ -695,9 +725,9 @@ export class BucketClient {
695725

696726
return {
697727
get isEnabled() {
698-
fClient
728+
self
699729
.sendCheckEvent({
700-
action: "check",
730+
action: "check-is-enabled",
701731
key,
702732
version: f?.targetingVersion,
703733
ruleEvaluationResults: f?.ruleEvaluationResults,
@@ -710,7 +740,7 @@ export class BucketClient {
710740
return value;
711741
},
712742
get config() {
713-
fClient
743+
self
714744
.sendCheckEvent({
715745
action: "check-config",
716746
key,
@@ -749,7 +779,12 @@ export class BucketClient {
749779
}
750780

751781
sendCheckEvent(checkEvent: CheckEvent) {
752-
return this.featuresClient.sendCheckEvent(checkEvent);
782+
return this.featuresClient.sendCheckEvent(checkEvent, () => {
783+
this.hooks.trigger(
784+
checkEvent.action == "check-config" ? "configCheck" : "enabledCheck",
785+
checkEvent,
786+
);
787+
});
753788
}
754789

755790
/**
@@ -787,6 +822,8 @@ export class BucketClient {
787822
};
788823
const res = await this.httpClient.post({ path: `/user`, body: payload });
789824
this.logger.debug(`sent user`, res);
825+
826+
this.hooks.trigger("user", this.context.user);
790827
return res;
791828
}
792829

@@ -817,6 +854,7 @@ export class BucketClient {
817854

818855
const res = await this.httpClient.post({ path: `/company`, body: payload });
819856
this.logger.debug(`sent company`, res);
857+
this.hooks.trigger("company", this.context.company);
820858
return res;
821859
}
822860
}

packages/browser-sdk/src/feature/features.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export type FetchedFeature = {
6969
};
7070
};
7171

72-
const FEATURES_UPDATED_EVENT = "features-updated";
72+
const FEATURES_UPDATED_EVENT = "featuresUpdated";
7373

7474
export type FetchedFeatures = Record<string, FetchedFeature | undefined>;
7575

@@ -145,7 +145,7 @@ export interface CheckEvent {
145145
/**
146146
* Action to perform.
147147
*/
148-
action: "check" | "check-config";
148+
action: "check-is-enabled" | "check-config";
149149

150150
/**
151151
* Feature key.
@@ -299,20 +299,12 @@ export class FeaturesClient {
299299
* Features are not guaranteed to have actually changed when the callback is called.
300300
*
301301
* @param callback this will be called when the features are updated.
302-
* @param options passed as-is to addEventListener, except the abort signal is not supported.
303302
* @returns a function that can be called to remove the listener
304303
*/
305-
onUpdated(callback: () => void, options?: AddEventListenerOptions | boolean) {
304+
onUpdated(callback: () => void) {
306305
this.eventTarget.addEventListener(FEATURES_UPDATED_EVENT, callback, {
307306
signal: this.abortController.signal,
308307
});
309-
return () => {
310-
this.eventTarget.removeEventListener(
311-
FEATURES_UPDATED_EVENT,
312-
callback,
313-
options,
314-
);
315-
};
316308
}
317309

318310
getFeatures(): RawFeatures {
@@ -365,10 +357,10 @@ export class FeaturesClient {
365357
*
366358
*
367359
* @param checkEvent - The feature to send the event for.
360+
* @param cb - Callback to call after the event is sent. Might be skipped if the event was rate limited.
368361
*/
369-
async sendCheckEvent(checkEvent: CheckEvent) {
362+
async sendCheckEvent(checkEvent: CheckEvent, cb: () => void) {
370363
const rateLimitKey = `check-event:${this.fetchParams().toString()}:${checkEvent.key}:${checkEvent.version}:${checkEvent.value}`;
371-
372364
await this.rateLimiter.rateLimited(rateLimitKey, async () => {
373365
const payload = {
374366
action: checkEvent.action,
@@ -390,6 +382,7 @@ export class FeaturesClient {
390382
});
391383

392384
this.logger.debug(`sent feature event`, payload);
385+
cb();
393386
});
394387

395388
return checkEvent.value;

0 commit comments

Comments
 (0)