diff --git a/apps/docs/app/[lang]/home/[code]/page.tsx b/apps/docs/app/[lang]/home/[code]/page.tsx index 2f592787a..07e0e247b 100644 --- a/apps/docs/app/[lang]/home/[code]/page.tsx +++ b/apps/docs/app/[lang]/home/[code]/page.tsx @@ -13,6 +13,7 @@ import { FlagValues } from 'flags/react'; import type { Metadata } from 'next'; import Link from 'next/link'; import { + checkoutFlag, enableBannerFlag, enableDitheredHeroFlag, enableHeroTextFlag, @@ -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 = [ @@ -101,6 +103,8 @@ export default async function HomePage({ installAudienceFlag(code, rootFlags), ]); + const checkoutFlagValue = await checkoutFlag(code, rootFlags); + return (
{ditheredHeroFlag ? : null} +
+
+ Checkout flag: {checkoutFlagValue ? 'treatment' : 'control'} +
+ +
+
Try the Flags SDK
diff --git a/apps/docs/app/[lang]/home/[code]/purchase-button.tsx b/apps/docs/app/[lang]/home/[code]/purchase-button.tsx new file mode 100644 index 000000000..168feb00a --- /dev/null +++ b/apps/docs/app/[lang]/home/[code]/purchase-button.tsx @@ -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 ( + + ); +} diff --git a/apps/docs/components/geistdocs/provider.tsx b/apps/docs/components/geistdocs/provider.tsx index c33a8705c..cc7cfe459 100644 --- a/apps/docs/components/geistdocs/provider.tsx +++ b/apps/docs/components/geistdocs/provider.tsx @@ -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, @@ -24,7 +32,7 @@ export const GeistdocsProvider = ({ return ( <> - + ); diff --git a/apps/docs/flags.ts b/apps/docs/flags.ts index 13b099b9f..1cef3f822 100644 --- a/apps/docs/flags.ts +++ b/apps/docs/flags.ts @@ -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({ + key: 'checkout-flag-vincent', + description: 'Experiment-owned flag', + defaultValue: false, + options: [false, true], + identify, + adapter: vercelAdapter, +}); export const enableBannerFlag = flag({ key: 'enable-banner-flag', @@ -49,6 +60,7 @@ export const installAudienceFlag = flag<'humans' | 'agents'>({ }); export const rootFlags = [ + checkoutFlag, enableBannerFlag, enableHeroTextFlag, enableDitheredHeroFlag, diff --git a/apps/docs/lib/flags/constants.ts b/apps/docs/lib/flags/constants.ts new file mode 100644 index 000000000..65f0a2352 --- /dev/null +++ b/apps/docs/lib/flags/constants.ts @@ -0,0 +1,2 @@ +export const USER_ID_COOKIE = 'userId'; +export const USER_ID_HEADER = 'x-user-id'; diff --git a/apps/docs/lib/flags/identify.ts b/apps/docs/lib/flags/identify.ts new file mode 100644 index 000000000..4ab354a11 --- /dev/null +++ b/apps/docs/lib/flags/identify.ts @@ -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; + +const generateUserId = dedupe(async () => crypto.randomUUID()); + +export async function getOrGenerateUserId( + cookies: CookieReader, + headers: HeaderReader, +): Promise { + 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 => { + return { user: { id: await getOrGenerateUserId(cookies, headers) } }; + }, +); diff --git a/apps/docs/lib/flags/report-exposures.ts b/apps/docs/lib/flags/report-exposures.ts new file mode 100644 index 000000000..d5cf7d067 --- /dev/null +++ b/apps/docs/lib/flags/report-exposures.ts @@ -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, +): Promise { + 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, + ); + }), + ); +} diff --git a/apps/docs/lib/flags/vercel-adapter.ts b/apps/docs/lib/flags/vercel-adapter.ts new file mode 100644 index 000000000..53dd4ca7a --- /dev/null +++ b/apps/docs/lib/flags/vercel-adapter.ts @@ -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({ + experimental_reportExposures: reportExposures, +}); + +export const vercelAdapter = createVercelAdapter(flagsClient); diff --git a/apps/docs/next.config.ts b/apps/docs/next.config.ts index 21a20a3c5..6406bfc78 100644 --- a/apps/docs/next.config.ts +++ b/apps/docs/next.config.ts @@ -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 diff --git a/apps/docs/package.json b/apps/docs/package.json index 7dbece98b..3e8902a92 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -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", diff --git a/apps/docs/proxy.ts b/apps/docs/proxy.ts index cb2ca7dc9..82181e93e 100644 --- a/apps/docs/proxy.ts +++ b/apps/docs/proxy.ts @@ -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"; @@ -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).*)", ], }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c180ac9a..d59c43d68 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,9 +116,15 @@ importers: apps/docs: dependencies: + '@flags-sdk/vercel': + specifier: workspace:* + version: link:../../packages/adapter-vercel '@vercel/analytics': - specifier: ^1.6.1 - version: 1.6.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.7(@typescript-eslint/types@8.46.1)) + specifier: 2.1.0-canary.5 + version: 2.1.0-canary.5(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.7(@typescript-eslint/types@8.46.1)) + '@vercel/flags-core': + specifier: workspace:* + version: link:../../packages/vercel-flags-core '@vercel/geistdocs': specifier: 2.9.2 version: 2.9.2(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(@svta/cml-cta@1.0.1(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1))(@svta/cml-structured-field-values@1.0.1(@svta/cml-utils@1.0.1))(@svta/cml-utils@1.0.1)(@types/mdast@4.0.4)(@types/react-dom@19.2.3(@types/react@19.2.14))(@vercel/functions@3.4.3)(lucide-react@0.555.0(react@19.2.4))(micromark-util-types@2.0.2)(micromark@4.0.2)(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(tailwindcss@4.1.18)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) @@ -127,7 +133,7 @@ importers: version: 1.3.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.7(@typescript-eslint/types@8.46.1)) '@vercel/toolbar': specifier: 0.2.7 - version: 0.2.7(2b0acf032569d1902c13370752c13288) + version: 0.2.7(cfa22b4c3d3279d362133cf479461783) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -5606,6 +5612,35 @@ packages: vue-router: optional: true + '@vercel/analytics@2.1.0-canary.5': + resolution: {integrity: sha512-zBkf+H2iDlcxgkHYuH3bTeWS5WOad1gWpdbOp0EbNcmY+gnFNpRLKkDj8E1Al15nF/ovge0t/1q6hbj9lftfPw==} + peerDependencies: + '@remix-run/react': ^2 + '@sveltejs/kit': ^2.70.2 + next: '>= 13' + nuxt: '>= 3' + react: ^18 || ^19 || ^19.0.0-rc + svelte: '>= 4' + vue: ^3 + vue-router: ^4 || ^5 + peerDependenciesMeta: + '@remix-run/react': + optional: true + '@sveltejs/kit': + optional: true + next: + optional: true + nuxt: + optional: true + react: + optional: true + svelte: + optional: true + vue: + optional: true + vue-router: + optional: true + '@vercel/edge-config-fs@0.1.0': resolution: {integrity: sha512-NRIBwfcS0bUoUbRWlNGetqjvLSwgYH/BqKqDN7vK1g32p7dN96k0712COgaz6VFizAm9b0g6IG6hR6+hc0KCPg==} deprecated: This package has been renamed to @vercel/global-config-fs. Install @vercel/global-config-fs instead. @@ -14165,6 +14200,14 @@ snapshots: next: 16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 svelte: 5.56.7(@typescript-eslint/types@8.46.1) + optional: true + + '@vercel/analytics@2.1.0-canary.5(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.7(@typescript-eslint/types@8.46.1))': + optionalDependencies: + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + next: 16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + svelte: 5.56.7(@typescript-eslint/types@8.46.1) '@vercel/edge-config-fs@0.1.0': {} @@ -14346,6 +14389,31 @@ snapshots: transitivePeerDependencies: - debug + '@vercel/microfrontends@2.4.0(cfa22b4c3d3279d362133cf479461783)': + dependencies: + '@next/env': 16.0.10 + '@types/md5': 2.3.6 + ajv: 8.20.0 + commander: 12.1.0 + cookie: 1.0.2 + fast-glob: 3.3.3 + http-proxy: 1.18.1 + jsonc-parser: 3.3.1 + md5: 2.3.0 + nanoid: 3.3.18 + path-to-regexp: 6.3.0 + semver: 7.7.4 + optionalDependencies: + '@sveltejs/kit': 2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)) + '@vercel/analytics': 2.1.0-canary.5(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.7(@typescript-eslint/types@8.46.1)) + '@vercel/speed-insights': 1.3.1(@sveltejs/kit@2.70.2(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@7.2.0(svelte@5.56.7(@typescript-eslint/types@8.46.1))(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(svelte@5.56.7(@typescript-eslint/types@8.46.1))(typescript@5.9.3)(vite@8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0)))(next@16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)(svelte@5.56.7(@typescript-eslint/types@8.46.1)) + next: 16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + vite: 8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + transitivePeerDependencies: + - debug + '@vercel/nft@1.10.2(rollup@4.59.0)': dependencies: '@mapbox/node-pre-gyp': 2.0.0 @@ -14452,6 +14520,26 @@ snapshots: - debug - react-dom + '@vercel/toolbar@0.2.7(cfa22b4c3d3279d362133cf479461783)': + dependencies: + '@tinyhttp/app': 1.3.0 + '@vercel/microfrontends': 2.4.0(cfa22b4c3d3279d362133cf479461783) + chokidar: 3.6.0 + execa: 5.1.1 + find-up: 5.0.0 + get-port: 5.1.1 + strip-ansi: 6.0.1 + optionalDependencies: + next: 16.3.8(@opentelemetry/api@1.9.0)(@playwright/test@1.58.1)(@types/node@24.10.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + react: 19.2.4 + vite: 8.1.5(@types/node@24.10.13)(esbuild@0.28.1)(jiti@2.7.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@sveltejs/kit' + - '@vercel/analytics' + - '@vercel/speed-insights' + - debug + - react-dom + '@vimeo/player@2.29.0': dependencies: native-promise-only: 0.8.1