Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import {render} from '@testing-library/react';
import {draftMode, headers} from 'next/headers';
import {cookies, draftMode, headers} from 'next/headers';

import {Brand, getBrandFromHostname} from '@/config/brand';
import {getGoogleAnalyticsMeasurementId} from '@/config/ga4';
Expand All @@ -12,6 +12,7 @@ import Layout from '../layout';
jest.mock('next/headers', () => ({
headers: jest.fn(),
draftMode: jest.fn(),
cookies: jest.fn(),
}));

jest.mock('@/config/brand', () => ({
Expand Down Expand Up @@ -71,6 +72,9 @@ jest.mock('next/navigation', () => ({
describe('Layout', () => {
beforeEach(() => {
jest.clearAllMocks();
(cookies as jest.Mock).mockReturnValue({
get: jest.fn().mockReturnValue(undefined),
});
});

it('renders the layout with children', async () => {
Expand Down Expand Up @@ -103,6 +107,10 @@ describe('Layout', () => {
expect(
await findByText(`OrganizationJsonLd for ${brand}`),
).toBeInTheDocument();
expect(generateBootstrapValues).toHaveBeenCalledWith({
brand,
stableId: undefined,
});
});

it('does not render GoogleAnalytics if measurement ID is missing', async () => {
Expand Down Expand Up @@ -175,4 +183,32 @@ describe('Layout', () => {

expect(container.querySelector('html')?.getAttribute('dir')).toBe('rtl');
});

it('passes the statsig stable id from cookies to bootstrap generation', async () => {
const brand = Brand.CODE_DOT_ORG;
(cookies as jest.Mock).mockReturnValue({
get: jest.fn().mockReturnValue({value: 'stable-cookie-id'}),
});

(headers as jest.Mock).mockResolvedValue({
get: jest.fn().mockReturnValue('example.com'),
});
(getBrandFromHostname as jest.Mock).mockReturnValue(brand);
(getGoogleAnalyticsMeasurementId as jest.Mock).mockReturnValue('GA-123456');
(getStage as jest.Mock).mockReturnValue('production');
(generateBootstrapValues as jest.Mock).mockResolvedValue({});

await Layout({
children: <div>Child Component</div>,
params: Promise.resolve({
brand: 'code.org' as Brand,
locale: 'en-US' as SupportedLocale,
}),
});

expect(generateBootstrapValues).toHaveBeenCalledWith({
brand,
stableId: 'stable-cookie-id',
});
});
});
11 changes: 9 additions & 2 deletions apps/marketing/src/app/[brand]/[locale]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import {ThemeProvider} from '@mui/material';
import {AppRouterCacheProvider} from '@mui/material-nextjs/v15-appRouter';
import {GoogleAnalytics} from '@next/third-parties/google';
import {draftMode} from 'next/headers';
import {cookies, draftMode} from 'next/headers';

import {getFooter} from '@/components/footer/Footer';
import {getHeader} from '@/components/header/Header';
Expand All @@ -15,6 +15,7 @@ import LocalizeLoader from '@/providers/localize/LocalizeLoader';
import NewRelicLoader from '@/providers/newrelic/NewRelicLoader';
import OneTrustLoader from '@/providers/onetrust/OneTrustLoader';
import OneTrustProvider from '@/providers/onetrust/OneTrustProvider';
import {STATSIG_STABLE_ID_COOKIE_NAME} from '@/providers/statsig/stableId';
import {generateBootstrapValues} from '@/providers/statsig/statsig-backend';
import StatsigProvider from '@/providers/statsig/StatsigProvider';
import {getCriticalFonts, getMuiTheme} from '@/themes';
Expand All @@ -32,8 +33,14 @@ export default async function Layout({
const locale = syncParams.locale as SupportedLocale;

await getCriticalFonts(brand);
const cookieStore = await cookies();
const statsigStableId =
cookieStore.get(STATSIG_STABLE_ID_COOKIE_NAME)?.value;
const googleAnalyticsMeasurementId = getGoogleAnalyticsMeasurementId(brand);
const statsigBootstrapValues = await generateBootstrapValues();
const statsigBootstrapValues = await generateBootstrapValues({
brand,
stableId: statsigStableId,
});
const statsigClientKey = process.env.STATSIG_CLIENT_KEY;
const localeConfig = SUPPORTED_LOCALES_MAP.get(locale);
const theme = getMuiTheme(brand);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Brand} from '@/config/brand';
import statsig from '@/providers/statsig/statsig';

import {generateBootstrapValues} from '../statsig-backend';
Expand All @@ -10,13 +11,35 @@ jest.mock('@/providers/statsig/statsig', () => ({
},
}));

const mockedStatsig = statsig as unknown as {
initialize: jest.Mock;
getClientInitializeResponse: jest.Mock;
};

describe('generateBootstrapValues', () => {
beforeEach(() => {
jest.resetAllMocks();
});

it('should return client initialize response if statsig is initialized', async () => {
await generateBootstrapValues();
expect(statsig!.getClientInitializeResponse).toHaveBeenCalledTimes(1);
await generateBootstrapValues({brand: Brand.CODE_DOT_ORG});
expect(mockedStatsig.getClientInitializeResponse).toHaveBeenCalledTimes(1);
});

it('should forward stable id to statsig when provided', async () => {
const stableId = 'cookie-stable';
await generateBootstrapValues({brand: Brand.CODE_DOT_ORG, stableId});

const callArgs = mockedStatsig.getClientInitializeResponse.mock.calls[0];
const user = callArgs[0];
expect(user.customIDs).toEqual({stableID: stableId});
});

it('should omit stable id for unsupported brands', async () => {
await generateBootstrapValues({brand: Brand.CS_FOR_ALL, stableId: 'ignore'});

const callArgs = mockedStatsig.getClientInitializeResponse.mock.calls[0];
const user = callArgs[0];
expect(user.customIDs).toBeNull();
});
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import {Brand} from '@/config/brand';

import {generateBootstrapValues} from '../statsig-backend';

jest.mock('@/providers/statsig/statsig', () => ({
Expand All @@ -13,7 +15,9 @@ describe('generateBootstrapValues', () => {

describe('undefined statsig client', () => {
it('should return empty string if statsig is not initialized', async () => {
const result = await generateBootstrapValues();
const result = await generateBootstrapValues({
brand: Brand.CODE_DOT_ORG,
});
expect(result).toBe('');
});
});
Expand Down
33 changes: 20 additions & 13 deletions apps/marketing/src/providers/statsig/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,34 @@ import OneTrustContext, {
OneTrustCookieGroup,
} from '@/providers/onetrust/context/OneTrustContext';
import plugins from '@/providers/statsig/plugins';
import {
STATSIG_STABLE_ID_COOKIE_NAME,
STATSIG_STABLE_ID_COOKIE_OPTIONS,
buildStatsigUserIdentifiers,
shouldUseStatsigStableId,
} from '@/providers/statsig/stableId';

function useStatsigStableId(brand: Brand) {
if (!shouldUseStatsigStableId(brand)) {
return undefined;
}

function getStatsigStableId() {
const onetrustContext = useContext(OneTrustContext);

if (!onetrustContext?.allowedCookies.has(OneTrustCookieGroup.Performance)) {
// If the user has not allowed performance cookies, we do not set a stable ID
return undefined;
}

let stableId = getCookie('statsig_stable_id');
let stableId = getCookie(STATSIG_STABLE_ID_COOKIE_NAME) ?? undefined;

if (!stableId) {
stableId = uuidv4();
setCookie('statsig_stable_id', stableId, {
path: '/',
domain: '.code.org',
sameSite: 'lax',
secure: true,
});
setCookie(
STATSIG_STABLE_ID_COOKIE_NAME,
stableId,
STATSIG_STABLE_ID_COOKIE_OPTIONS,
);
}

return stableId;
Expand All @@ -44,11 +53,9 @@ export function getClient(
values: string,
brand: Brand,
) {
// Add stableID only for code.org brand so we can track users across
// studio.code.org and code.org, otherwise fallback to Statsig SDK's default behavior
const stableId =
brand === Brand.CODE_DOT_ORG ? getStatsigStableId() : undefined;
const user: StatsigUser = stableId ? {customIDs: {stableID: stableId}} : {};
const stableId = useStatsigStableId(brand);
const userIdentifiers = buildStatsigUserIdentifiers(stableId);
const user: StatsigUser = {...userIdentifiers};
return useClientBootstrapInit(clientKey, user, values, {
environment: {tier: stage},
plugins: stage === 'production' ? plugins : undefined,
Expand Down
26 changes: 26 additions & 0 deletions apps/marketing/src/providers/statsig/stableId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {Brand} from '@/config/brand';

export const STATSIG_STABLE_ID_COOKIE_NAME = 'statsig_stable_id';

export const STATSIG_STABLE_ID_COOKIE_OPTIONS = {
path: '/',
domain: '.code.org',
sameSite: 'lax' as const,
secure: true,
};

export type StatsigUserIdentifiers = {
customIDs?: {
stableID: string;
};
};

export function buildStatsigUserIdentifiers(
stableId: string | undefined,
): StatsigUserIdentifiers {
return stableId ? {customIDs: {stableID: stableId}} : {};
}

export function shouldUseStatsigStableId(brand: Brand): boolean {
return brand === Brand.CODE_DOT_ORG;
}
23 changes: 21 additions & 2 deletions apps/marketing/src/providers/statsig/statsig-backend.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
import {StatsigUser} from '@statsig/statsig-node-core';

import {Brand} from '@/config/brand';
import {
buildStatsigUserIdentifiers,
shouldUseStatsigStableId,
} from '@/providers/statsig/stableId';
import statsig from '@/providers/statsig/statsig';

const statsigInitializer = statsig ? statsig.initialize() : undefined;

export async function generateBootstrapValues(): Promise<string> {
interface GenerateBootstrapValuesArgs {
brand: Brand;
stableId?: string;
}

export async function generateBootstrapValues({
brand,
stableId,
}: GenerateBootstrapValuesArgs): Promise<string> {
if (!statsig) {
console.debug(
`Missing environment variable STATSIG_SERVER_KEY, Statsig bootstrap will not be provided.`,
);
return Promise.resolve('');
}

const user = new StatsigUser({userID: 'marketing-user', customIDs: {}});
const identifiers = shouldUseStatsigStableId(brand)
? buildStatsigUserIdentifiers(stableId)
: {};
const user = new StatsigUser({
userID: 'marketing-user',
customIDs: identifiers.customIDs,
});
await statsigInitializer;

return statsig.getClientInitializeResponse(user, {
Expand Down
Loading