Skip to content
Closed

wip #559

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
13 changes: 13 additions & 0 deletions apps/docs/app/[lang]/home/[code]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { FlagValues } from 'flags/react';
import type { Metadata } from 'next';
import Link from 'next/link';
import {
checkoutFlag,
enableBannerFlag,
enableDitheredHeroFlag,
enableHeroTextFlag,
Expand All @@ -26,6 +27,7 @@ import { Adaptable, Effortless, Flexible } from './components/illustrations';
import Testimonials from './components/testimonials';
import { HighlightedCode } from './highlighted-code';
import { InstallCommand } from './install-command';
import { PurchaseButton } from './purchase-button';
import { FlagSelect, FlagToggle } from './toggles';

const FEATURES = [
Expand Down Expand Up @@ -101,6 +103,8 @@ export default async function HomePage({
installAudienceFlag(code, rootFlags),
]);

const checkoutFlagValue = await checkoutFlag(code, rootFlags);

return (
<div className="mx-auto w-full max-w-[1448px] px-4 sm:px-6">
<FlagValues
Expand Down Expand Up @@ -131,6 +135,15 @@ export default async function HomePage({
<div className="relative col-span-12 lg:col-span-5 p-4">
{ditheredHeroFlag ? <HeroImage /> : null}

<div className="mb-6 flex flex-col items-start gap-3">
<div className="text-heading-20">
Checkout flag: {checkoutFlagValue ? 'treatment' : 'control'}
</div>
<PurchaseButton
label={checkoutFlagValue ? 'Buy now' : 'Proceed to checkout'}
/>
</div>

<div className="relative rounded-xl bg-background-100 p-4 md:p-6 shadow-(--ds-shadow-menu)">
<div className="flex flex-col gap-y-1 px-2">
<div className="mb-0.5 text-heading-20">Try the Flags SDK</div>
Expand Down
36 changes: 36 additions & 0 deletions apps/docs/app/[lang]/home/[code]/purchase-button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
'use client';
import { track } from '@vercel/analytics';
import { Button } from '@vercel/geistdocs/components/button';
import { useToasts } from '@vercel/geistdocs/components/toasts';
import { USER_ID_COOKIE } from '@/lib/flags/constants';

function readUserId(): string | undefined {
const match = document.cookie
.split('; ')
.find((cookie) => cookie.startsWith(`${USER_ID_COOKIE}=`));
return match?.slice(USER_ID_COOKIE.length + 1);
}

export function PurchaseButton({ label }: { label: string }) {
const { success } = useToasts();

return (
<Button
className="rounded-full"
onClick={() => {
const userId = readUserId();
if (!userId) {
success({ text: 'No userId cookie, purchase not tracked.' });
return;
}
// Exposures are reported with `unitKey: 'event_data.userId'`, so the
// event must carry the same id as a property to be joined to them.
track('purchase', { userId });
success({ text: `Tracked a purchase for ${userId}.` });
}}
size="large"
>
{label}
</Button>
);
}
10 changes: 9 additions & 1 deletion apps/docs/components/geistdocs/provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ type GeistdocsProviderProps = Omit<
lang?: string;
};

// Set NEXT_PUBLIC_WEB_ANALYTICS_MODE=production in .env.local to send events
// from `next dev` to the production project. The matching rewrite in
// next.config.ts proxies /_vercel/insights to production.
const analyticsMode =
process.env.NEXT_PUBLIC_WEB_ANALYTICS_MODE === "production"
? "production"
: undefined;

export const GeistdocsProvider = ({
basePath: _basePath,
className: _className,
Expand All @@ -24,7 +32,7 @@ export const GeistdocsProvider = ({
return (
<>
<PackageProvider config={config} lang={lang} {...props} />
<Analytics />
<Analytics mode={analyticsMode} />
<SpeedInsights />
</>
);
Expand Down
12 changes: 12 additions & 0 deletions apps/docs/flags.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { flag } from 'flags/next';
import { type Entities, identify } from './lib/flags/identify';
import { vercelAdapter } from './lib/flags/vercel-adapter';

export const checkoutFlag = flag<boolean, Entities>({
key: 'checkout-flag-vincent',
description: 'Experiment-owned flag',
defaultValue: false,
options: [false, true],
identify,
adapter: vercelAdapter,
});

export const enableBannerFlag = flag({
key: 'enable-banner-flag',
Expand Down Expand Up @@ -49,6 +60,7 @@ export const installAudienceFlag = flag<'humans' | 'agents'>({
});

export const rootFlags = [
checkoutFlag,
enableBannerFlag,
enableHeroTextFlag,
enableDitheredHeroFlag,
Expand Down
2 changes: 2 additions & 0 deletions apps/docs/lib/flags/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const USER_ID_COOKIE = 'userId';
export const USER_ID_HEADER = 'x-user-id';
43 changes: 43 additions & 0 deletions apps/docs/lib/flags/identify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { dedupe } from 'flags/next';
import { USER_ID_COOKIE, USER_ID_HEADER } from './constants';

export interface Entities {
user?: { id: string };
}

// `baseUrl: "."` in tsconfig makes a bare `flags` import resolve to the local
// `flags.ts`, so the cookie and header shapes are typed structurally instead
// of importing `ReadonlyRequestCookies` and `ReadonlyHeaders` from `flags`.
type CookieReader = { get(name: string): { value: string } | undefined };
type HeaderReader = Pick<Headers, 'get'>;

const generateUserId = dedupe(async () => crypto.randomUUID());

export async function getOrGenerateUserId(
cookies: CookieReader,
headers: HeaderReader,
): Promise<string> {
const cookieUserId = cookies.get(USER_ID_COOKIE)?.value;
if (cookieUserId) {
return cookieUserId;
}

const headerUserId = headers.get(USER_ID_HEADER);
if (headerUserId) {
return headerUserId;
}

return generateUserId();
}

export const identify = dedupe(
async ({
cookies,
headers,
}: {
cookies: CookieReader;
headers: HeaderReader;
}): Promise<Entities> => {
return { user: { id: await getOrGenerateUserId(cookies, headers) } };
},
);
40 changes: 40 additions & 0 deletions apps/docs/lib/flags/report-exposures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { trackExposure } from '@vercel/analytics/server';
import type { experimental_Exposure as Exposure } from '@vercel/flags-core';
import type { Entities } from './identify';

/**
* Reports Vercel Experiments exposures to Web Analytics.
*
* Pass it as `experimental_reportExposures` to `createClient` from
* `@vercel/flags-core`. The exposure unit is resolved from the `identify()`
* entity using the experiment's base path, so conversion events only need to
* carry the same unit (for example `visitorId`) to be joined to exposures.
*/
// On Vercel the SDK reads request headers from the Vercel request context.
// `next dev` has no such context, so it needs explicit headers to send at all.
const localOptions =
process.env.NODE_ENV === 'development' ? { headers: {} } : undefined;

export async function reportExposures(
exposures: readonly Exposure[],
entity: Readonly<Entities>,
): Promise<void> {
await Promise.all(
exposures.map((exposure) => {
const unitValue = entity.user?.id ?? '';

return trackExposure(
{
experimentId: exposure.experimentId,
variantId: exposure.variantId ?? 'override',
unitKey: 'event_data.userId',
unitValue,
assignmentReason: exposure.assignmentReason,
rampId: exposure.rampId,
rampPercentage: exposure.rampPercentage,
},
localOptions,
);
}),
);
}
10 changes: 10 additions & 0 deletions apps/docs/lib/flags/vercel-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createVercelAdapter } from '@flags-sdk/vercel';
import { createClient } from '@vercel/flags-core';
import type { Entities } from './identify';
import { reportExposures } from './report-exposures';

export const flagsClient = createClient<Entities>({
experimental_reportExposures: reportExposures,
});

export const vercelAdapter = createVercelAdapter(flagsClient);
20 changes: 20 additions & 0 deletions apps/docs/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,26 @@ const config: NextConfig = {
],
},

// The production insights endpoint has no CORS headers, so a browser on
// localhost cannot post to it directly. Proxy it instead when local
// analytics testing is enabled through NEXT_PUBLIC_WEB_ANALYTICS_MODE.
rewrites: async () => {
const analyticsOrigin = process.env.VERCEL_WEB_ANALYTICS_ENDPOINT;
if (
process.env.NODE_ENV !== "development" ||
process.env.NEXT_PUBLIC_WEB_ANALYTICS_MODE !== "production" ||
!analyticsOrigin
) {
return [];
}
return [
{
source: "/_vercel/insights/:path*",
destination: `${analyticsOrigin}/_vercel/insights/:path*`,
},
];
},

redirects: async () => {
/**
* At first all docs pages were at /docs/*, but we moved them to the root
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
"translate": "npx @vercel/geistdocs translate"
},
"dependencies": {
"@vercel/analytics": "^1.6.1",
"@flags-sdk/vercel": "workspace:*",
"@vercel/analytics": "2.1.0-canary.5",
"@vercel/flags-core": "workspace:*",
"@vercel/geistdocs": "2.9.2",
"@vercel/speed-insights": "^1.3.1",
"@vercel/toolbar": "0.2.7",
Expand Down
17 changes: 14 additions & 3 deletions apps/docs/proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { createProxy } from "@vercel/geistdocs/proxy";
import { precompute } from "flags/next";
import { NextResponse } from "next/server";
import { rootFlags } from "@/flags";
import { USER_ID_COOKIE, USER_ID_HEADER } from "@/lib/flags/constants";
import { getOrGenerateUserId } from "@/lib/flags/identify";
import { config as geistdocsConfig } from "@/lib/geistdocs/config";
import { trackMdRequest } from "@/lib/geistdocs/md-tracking";

Expand All @@ -15,17 +17,26 @@ const proxy = createProxy({
return null;
}

const userId = await getOrGenerateUserId(
request.cookies,
request.headers
);
const code = await precompute(rootFlags);
return NextResponse.rewrite(
new URL(`/${defaultLanguage}/home/${code}`, request.url)
const requestHeaders = new Headers(request.headers);
requestHeaders.set(USER_ID_HEADER, userId);
const response = NextResponse.rewrite(
new URL(`/${defaultLanguage}/home/${code}`, request.url),
{ request: { headers: requestHeaders } }
);
response.cookies.set(USER_ID_COOKIE, userId, { path: "/" });
return response;
},
});

export const config = {
// Matcher ignoring `/_next/`, `/api/`, static assets, favicon, sitemap, robots, etc.
matcher: [
"/((?!api(?:/|$)|_next/static|_next/image|favicon.ico|sitemap.xml|robots.txt|\\.well-known/vercel/flags).*)",
"/((?!api(?:/|$)|_next/static|_next/image|_vercel/insights|favicon.ico|sitemap.xml|robots.txt|\\.well-known/vercel/flags).*)",
],
};

Expand Down
Loading
Loading