From f7f66843245291a21424af6fc33b0e72e1c00259 Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Fri, 17 Jul 2026 17:49:53 +0530 Subject: [PATCH 1/2] feat(analytics): emit group_id on every event (RQ-4675) Stamp the signed-in user's BrowserStack group_id onto every analytics event so BrowserStack Usage Reports can attribute Interceptor usage per group. group_id is a property of the USER (not the workspace), sourced from the user's BS-linked billing team (browserstackGroupId), which maps 1:1 to a BS group via requestly-cloud (browserstack-). - getUserBrowserstackGroupId / pickUserBrowserstackGroupId billing selectors: resolve the group id from the user's billing teams, preferring a team the user is a member of (over domain-matched teams). - useBrowserstackGroupId: keep window.currentlyActiveBrowserstackGroupId in sync (mirrors the existing window.currentlyActiveWorkspace* globals that the non-hook trackEvent choke-point reads), mounted in AppLayout. - trackEvent enrichment stamps newParams.group_id. sub_group_id is intentionally omitted (no sub-group source in Interceptor today). - Unit tests for the pure resolver. Field name group_id (snake_case) matches the BigQuery sink and the API Client envelope. null when the user has no BS-linked billing team. Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/hooks/useBrowserstackGroupId.ts | 28 ++++++++ app/src/layouts/AppLayout/index.tsx | 2 + app/src/modules/analytics/index.js | 7 ++ .../store/features/billing/selectors.test.ts | 69 +++++++++++++++++++ app/src/store/features/billing/selectors.ts | 43 ++++++++++++ 5 files changed, 149 insertions(+) create mode 100644 app/src/hooks/useBrowserstackGroupId.ts create mode 100644 app/src/store/features/billing/selectors.test.ts diff --git a/app/src/hooks/useBrowserstackGroupId.ts b/app/src/hooks/useBrowserstackGroupId.ts new file mode 100644 index 0000000000..d707d2bda7 --- /dev/null +++ b/app/src/hooks/useBrowserstackGroupId.ts @@ -0,0 +1,28 @@ +import { useEffect } from "react"; +import { useSelector } from "react-redux"; +import { getUserBrowserstackGroupId } from "store/features/billing/selectors"; + +declare global { + interface Window { + currentlyActiveBrowserstackGroupId: string | null | undefined; + } +} + +/** + * Exposes the signed-in user's BrowserStack group id on `window`, mirroring + * the existing `window.currentlyActiveWorkspace*` global pattern. The analytics + * enrichment choke-point (`modules/analytics/index.js` → `trackEvent`) is a + * plain function, not a hook, so it reads attribution off `window` globals; + * this hook keeps the group id in sync from the billing store. + * + * Group is a property of the USER (not the workspace) — sourced from the user's + * BS-linked billing team (`browserstackGroupId`). Feeds BrowserStack Usage + * Reports group attribution. See RQ-4675 and the track design doc. + */ +export const useBrowserstackGroupId = () => { + const browserstackGroupId = useSelector(getUserBrowserstackGroupId); + + useEffect(() => { + window.currentlyActiveBrowserstackGroupId = browserstackGroupId ?? null; + }, [browserstackGroupId]); +}; diff --git a/app/src/layouts/AppLayout/index.tsx b/app/src/layouts/AppLayout/index.tsx index 6235ac5bb5..5dc62efac0 100644 --- a/app/src/layouts/AppLayout/index.tsx +++ b/app/src/layouts/AppLayout/index.tsx @@ -23,6 +23,7 @@ import AutomationNotAllowedNotice from "components/misc/notices/AutomationNotAll import { useIsExtensionEnabled } from "hooks"; import { LazyMotion, domMax } from "framer-motion"; import { useBillingTeamsListener } from "backend/billing/hooks/useBillingTeamsListener"; +import { useBrowserstackGroupId } from "hooks/useBrowserstackGroupId"; import ThemeProvider from "lib/design-system-v2/helpers/ThemeProvider"; import { InitImplicitWidgetConfigHandler } from "components/features/rules/TestThisRule"; import APP_CONSTANTS from "config/constants"; @@ -45,6 +46,7 @@ const App: React.FC = () => { useGeoLocation(); useIsExtensionEnabled(); useBillingTeamsListener(); + useBrowserstackGroupId(); useAppLanguageObserver(); // useInitializeNewUserSessionRecordingConfig(); diff --git a/app/src/modules/analytics/index.js b/app/src/modules/analytics/index.js index ec11a30cf6..9c5538514d 100644 --- a/app/src/modules/analytics/index.js +++ b/app/src/modules/analytics/index.js @@ -46,6 +46,13 @@ export const trackEvent = (name, params, config) => { newParams.workspace = getWorkspaceType(); newParams.workspaceId = window.currentlyActiveWorkspaceTeamId ? window.currentlyActiveWorkspaceTeamId : null; newParams.workspaceMembersCount = window.workspaceMembersCount ?? null; + // BrowserStack Usage Reports group attribution (RQ-4675). group_id is the + // signed-in user's BrowserStack group — a property of the USER (sourced from + // their BS-linked billing team), NOT the workspace — kept on window by + // useBrowserstackGroupId. null when the user has no BS-linked billing team. + // NOTE: sub_group_id is intentionally omitted — Interceptor has no sub-group + // source today; revisit if Team-level (sub_group) reports are ever needed. + newParams.group_id = window.currentlyActiveBrowserstackGroupId ?? null; Logger.log(`[analytics.trackEvent] name=${name}`, { params, config }); posthogIntegration.trackEvent(name, newParams); diff --git a/app/src/store/features/billing/selectors.test.ts b/app/src/store/features/billing/selectors.test.ts new file mode 100644 index 0000000000..e3f3cd6fd0 --- /dev/null +++ b/app/src/store/features/billing/selectors.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect } from "vitest"; + +import { BillingTeamDetails, BillingTeamRoles } from "features/settings/components/BillingTeam/types"; +import { pickUserBrowserstackGroupId } from "./selectors"; + +const UID = "user-123"; + +const team = (overrides: Partial): BillingTeamDetails => + ({ + id: "team-1", + name: "Team", + description: "", + owner: "owner", + subscriptionDetails: {}, + members: {}, + seats: 5, + ...overrides, + } as BillingTeamDetails); + +describe("pickUserBrowserstackGroupId", () => { + it("returns the browserstackGroupId of the team the user is a member of", () => { + const teams = [ + team({ + id: "browserstack-777", + browserstackGroupId: "777", + members: { [UID]: { role: BillingTeamRoles.Admin, joiningDate: 0 } }, + }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBe("777"); + }); + + it("prefers the team the user is a MEMBER of over a domain-matched team", () => { + const teams = [ + // domain-matched team the listener also pulls in — user is NOT a member + team({ id: "browserstack-111", browserstackGroupId: "111", members: {} }), + // the user's actual team + team({ + id: "browserstack-222", + browserstackGroupId: "222", + members: { [UID]: { role: BillingTeamRoles.Member, joiningDate: 0 } }, + }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBe("222"); + }); + + it("falls back to any team carrying a browserstackGroupId when the user is not a member of one", () => { + const teams = [ + team({ id: "browserstack-333", browserstackGroupId: "333", members: {} }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBe("333"); + }); + + it("ignores non-BrowserStack billing teams (no browserstackGroupId)", () => { + const teams = [ + team({ id: "stripe-team", members: { [UID]: { role: BillingTeamRoles.Manager, joiningDate: 0 } } }), + ]; + expect(pickUserBrowserstackGroupId(teams, UID)).toBeNull(); + }); + + it("returns null when there are no billing teams", () => { + expect(pickUserBrowserstackGroupId([], UID)).toBeNull(); + expect(pickUserBrowserstackGroupId(undefined, UID)).toBeNull(); + }); + + it("returns null when there is no signed-in uid", () => { + const teams = [team({ browserstackGroupId: "999", members: {} })]; + expect(pickUserBrowserstackGroupId(teams, undefined)).toBeNull(); + }); +}); diff --git a/app/src/store/features/billing/selectors.ts b/app/src/store/features/billing/selectors.ts index 4c5a0ddf54..e9a1daf632 100644 --- a/app/src/store/features/billing/selectors.ts +++ b/app/src/store/features/billing/selectors.ts @@ -1,5 +1,6 @@ import { BillingTeamDetails } from "features/settings/components/BillingTeam/types"; import { ReducerKeys } from "store/constants"; +import { getUserAuthDetails } from "store/slices/global/user/selectors"; import { RootState } from "store/types"; export const getAvailableBillingTeams = (state: RootState): BillingTeamDetails[] => { @@ -32,3 +33,45 @@ export const getBillingTeamMemberById = (billingId: string, memberId: string) => export const getIsBillingTeamsLoading = (state: RootState): boolean => { return state[ReducerKeys.BILLING].isBillingTeamsLoading; }; + +/** + * Pure resolver for the signed-in user's BrowserStack group id from their + * billing teams. A BrowserStack-synced user belongs to exactly one + * `browserstack-` billing team (requestly-cloud userSync guarantees + * this — see the design doc), so we prefer a team the user is an actual + * MEMBER of (over domain-matched teams the billing listener also pulls in), + * falling back to any team carrying a `browserstackGroupId`. Returns `null` + * when the user has no BS-linked billing team (email/Firebase-only or + * unlinked). Extracted from the selector so it can be unit-tested without a + * full RootState. See RQ-4675. + */ +export const pickUserBrowserstackGroupId = ( + billingTeams: BillingTeamDetails[] | undefined, + uid: string | undefined +): string | null => { + if (!uid || !billingTeams?.length) { + return null; + } + + const memberTeam = billingTeams.find( + (team) => Boolean(team.browserstackGroupId) && Boolean(team.members) && uid in team.members + ); + if (memberTeam?.browserstackGroupId) { + return memberTeam.browserstackGroupId; + } + + const anyTeamWithGroup = billingTeams.find((team) => Boolean(team.browserstackGroupId)); + return anyTeamWithGroup?.browserstackGroupId ?? null; +}; + +/** + * The signed-in user's BrowserStack group id (from their BS-linked billing + * team), or `null`. Feeds BrowserStack Usage Reports group attribution + * (RQ-4675) via `useBrowserstackGroupId` → the analytics enrichment choke-point. + */ +export const getUserBrowserstackGroupId = (state: RootState): string | null => { + return pickUserBrowserstackGroupId( + getAvailableBillingTeams(state), + getUserAuthDetails(state)?.details?.profile?.uid + ); +}; From d9c436036f5ce23eff6573ba9ba58a7b045ee664 Mon Sep 17 00:00:00 2001 From: Sahil Gupta Date: Mon, 20 Jul 2026 12:55:08 +0530 Subject: [PATCH 2/2] refactor(analytics): emit group_id as a top-level EDS field, not event_details (RQ-4675) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the patch model (requestly-cloud webapp overwrites modules/analytics/), group_id must flow the same way as browserstack_user_id: a top-level data.* field stamped in the EDS integration (webapp/eds.ts), not in event_details. - Drop the newParams.group_id stamp from this repo's analytics/index.js (it lives in the overwritten module and belongs in event_details, wrong). - Keep the billing selector + useBrowserstackGroupId hook: they expose the group id on window.currentlyActiveBrowserstackGroupId — the interceptor-side source the EDS overlay reads (analogous to the auth handler's browserstackId). The EDS-side stamp lands in requestly-cloud webapp/src/modules/analytics/ integrations/eds.ts (separate PR). Co-Authored-By: Claude Opus 4.8 (1M context) --- app/src/hooks/useBrowserstackGroupId.ts | 11 +++++++---- app/src/modules/analytics/index.js | 7 ------- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/app/src/hooks/useBrowserstackGroupId.ts b/app/src/hooks/useBrowserstackGroupId.ts index d707d2bda7..6db5d2d222 100644 --- a/app/src/hooks/useBrowserstackGroupId.ts +++ b/app/src/hooks/useBrowserstackGroupId.ts @@ -10,10 +10,13 @@ declare global { /** * Exposes the signed-in user's BrowserStack group id on `window`, mirroring - * the existing `window.currentlyActiveWorkspace*` global pattern. The analytics - * enrichment choke-point (`modules/analytics/index.js` → `trackEvent`) is a - * plain function, not a hook, so it reads attribution off `window` globals; - * this hook keeps the group id in sync from the billing store. + * the existing `window.currentlyActiveWorkspace*` global pattern. The BS EDS + * integration (which ships via the requestly-cloud `webapp` analytics overlay, + * not this repo's `modules/analytics`) reads this global and stamps it as a + * top-level `data.group_id` field on every EDS event — flowing the same way as + * `browserstack_user_id`. This hook keeps the value in sync from the billing + * store; it is the interceptor-side source, analogous to how the auth handler + * exposes `browserstackId`. * * Group is a property of the USER (not the workspace) — sourced from the user's * BS-linked billing team (`browserstackGroupId`). Feeds BrowserStack Usage diff --git a/app/src/modules/analytics/index.js b/app/src/modules/analytics/index.js index 9c5538514d..ec11a30cf6 100644 --- a/app/src/modules/analytics/index.js +++ b/app/src/modules/analytics/index.js @@ -46,13 +46,6 @@ export const trackEvent = (name, params, config) => { newParams.workspace = getWorkspaceType(); newParams.workspaceId = window.currentlyActiveWorkspaceTeamId ? window.currentlyActiveWorkspaceTeamId : null; newParams.workspaceMembersCount = window.workspaceMembersCount ?? null; - // BrowserStack Usage Reports group attribution (RQ-4675). group_id is the - // signed-in user's BrowserStack group — a property of the USER (sourced from - // their BS-linked billing team), NOT the workspace — kept on window by - // useBrowserstackGroupId. null when the user has no BS-linked billing team. - // NOTE: sub_group_id is intentionally omitted — Interceptor has no sub-group - // source today; revisit if Team-level (sub_group) reports are ever needed. - newParams.group_id = window.currentlyActiveBrowserstackGroupId ?? null; Logger.log(`[analytics.trackEvent] name=${name}`, { params, config }); posthogIntegration.trackEvent(name, newParams);