Skip to content
Merged
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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: pnpm
- run: pnpm install --frozen-lockfile
- run: pnpm run build
19 changes: 11 additions & 8 deletions packages/app/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ The app package serves as:
- Auto-capture page views on route change
- Manual event tracking (clicks, custom events)
- Feature flag usage in UI
- Session management with A/B testing
- Session management with backend-assigned feature flags

### Backend Integration Examples

Expand Down Expand Up @@ -78,7 +78,14 @@ const client = new AnalyticsBackendClient<CustomEvents>({
});

// app/api/analytics/route.ts
const handler = client.getHandler();
const handler = client.getHandler({
middleware: ({ analyticsRequest }) => {
if (analyticsRequest.requestType !== "createSession") return;
return {
featureFlags: { theme: "dark", newCheckout: "enabled" },
};
},
});
export const POST = handler;
export const GET = handler;
```
Expand All @@ -94,12 +101,8 @@ const client = new AnalyticsClient<CustomEvents, FeatureFlags>({
flushInterval: 2000,
});

const session = await client.createSession({
featureFlags: {
theme: { light: 50, dark: 50 },
newCheckout: { enabled: 30, disabled: 70 },
},
});
// Flags are assigned by backend middleware
const session = await client.createSession();
```

### Feature Flag Usage
Expand Down
25 changes: 24 additions & 1 deletion packages/app/app/api/analytics/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,29 @@
import { getAnalyticsBackendClient } from "@/lib/analytics";

const handler = getAnalyticsBackendClient().getHandler();
const handler = getAnalyticsBackendClient().getHandler({
middleware: ({ analyticsRequest }) => {
if (analyticsRequest.requestType !== "createSession") return;

// Resolve feature flags server-side using weighted distributions
const theme = weightedRandom({ light: 50, dark: 50 });
const newCheckout = weightedRandom({ enabled: 30, disabled: 70 });

return {
featureFlags: { theme, newCheckout },
};
},
});

function weightedRandom(distribution: Record<string, number>): string {
const entries = Object.entries(distribution);
const total = entries.reduce((sum, [, w]) => sum + w, 0);
let rand = Math.random() * total;
for (const [value, weight] of entries) {
rand -= weight;
if (rand <= 0) return value;
}
return entries[entries.length - 1][0];
}

// Single endpoint - user just exports this in their Next.js app
export const POST = handler;
Expand Down
135 changes: 84 additions & 51 deletions packages/app/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,22 +347,17 @@ export const useAnalytics = createAnalyticsHook<CustomEvents, FeatureFlags>({
endpoint: "/api/analytics",
flushInterval: 2000,
maxBatchSize: 20,
featureFlags: {
theme: { light: 50, dark: 50 },
newCheckout: { enabled: 30, disabled: 70 },
},
});`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Config Options</h2>
<p className="text-zinc-600 dark:text-zinc-400">
The factory accepts all <code>ClientConfig</code> options plus <code>featureFlags</code> for
automatic A/B test assignment on session creation.
The factory accepts all <code>ClientConfig</code> options. Feature flags are now
assigned by backend middleware rather than the client.
</p>
<ConfigTable>
<ConfigOption name="endpoint" type="string" defaultValue='"/api/analytics"' description="Base URL of your analytics API route." />
<ConfigOption name="flushInterval" type="number" defaultValue="0" description="Debounce time in ms for batching events. 0 = send immediately." />
<ConfigOption name="maxBatchSize" type="number" defaultValue="20" description="Max events per batch request. Larger queues are chunked automatically." />
<ConfigOption name="featureFlags" type="object" defaultValue="undefined" description="Feature flag distributions for auto-created sessions. Each key maps to a value or weighted distribution." />
</ConfigTable>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Use the Hook</h2>
Expand Down Expand Up @@ -401,7 +396,7 @@ export default function MyPage() {
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Automatic Behavior</h2>
<p className="text-zinc-600 dark:text-zinc-400">The hook automatically handles:</p>
<ul className="list-inside list-disc space-y-2 text-zinc-600 dark:text-zinc-400">
<li><strong>Session creation</strong> — a session is created on first render. Feature flags are resolved using the distributions you provided.</li>
<li><strong>Session creation</strong> — a session is created on first render. Feature flags are resolved by backend middleware.</li>
<li><strong>Pageview tracking</strong> — a <code>pageview</code> event is captured whenever the URL path changes. Duplicate paths are ignored.</li>
<li><strong>Shared state</strong> — all components using the hook share one session and one client. State updates propagate to all consumers.</li>
</ul>
Expand All @@ -416,16 +411,13 @@ export default function MyPage() {

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Manually Creating a Session</h2>
<p className="text-zinc-600 dark:text-zinc-400">
Use <code>createSession</code> to start a new session with explicit flag values. This
replaces the current session for all hook consumers.
Use <code>createSession</code> to start a new session. Feature flags are assigned
by backend middleware. This replaces the current session for all hook consumers.
</p>
<Code>{`const { createSession, featureFlags } = useAnalytics();

// Override flags for this session
await createSession({
theme: "dark",
newCheckout: "enabled",
});`}</Code>
// Create a new session — flags are assigned by backend middleware
await createSession();`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Capturing Events</h2>
<p className="text-zinc-600 dark:text-zinc-400">
Expand All @@ -452,9 +444,7 @@ captureEvent({
sessionId: string | null;
featureFlags: TFeatureFlags;
captureEvent: (input: CaptureEventInput<TCustomEvents>) => void;
createSession: (flags?: {
[K in keyof TFeatureFlags]?: TFeatureFlags[K] | Record<string, number>;
}) => Promise<Session<TFeatureFlags>>;
createSession: () => Promise<Session<TFeatureFlags>>;
};`}</Code>
</div>
);
Expand Down Expand Up @@ -593,16 +583,64 @@ const handler = client.getHandler({
},
});`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Feature Flags via Middleware</h2>
<p className="text-zinc-600 dark:text-zinc-400">
Return <code>{"{ featureFlags }"}</code> from middleware to assign feature flags to the session.
When using multiple middlewares, flags are merged in order. Return <code>{"{ featureFlags: false }"}</code> to
wipe all previously accumulated flags.
</p>
<Code>{`const handler = client.getHandler({
middleware: ({ analyticsRequest }) => {
if (analyticsRequest.requestType !== "createSession") return;

return {
featureFlags: {
theme: Math.random() < 0.5 ? "light" : "dark",
newCheckout: Math.random() < 0.3 ? "enabled" : "disabled",
},
};
},
});`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Multiple Middlewares</h2>
<p className="text-zinc-600 dark:text-zinc-400">
Pass an array of middlewares. They execute in order — sessionMetadata and featureFlags
are merged across all middlewares.
</p>
<Code>{`const handler = client.getHandler({
middleware: [
// Auth middleware
({ request }) => {
const token = request.headers.get("authorization");
if (!token) {
return new Response(
JSON.stringify({ success: false, error: "Unauthorized" }),
{ status: 401 }
);
}
},
// Feature flag middleware
({ analyticsRequest }) => {
if (analyticsRequest.requestType !== "createSession") return;
return {
featureFlags: { theme: "dark" },
};
},
],
});`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">Middleware Signature</h2>
<Code>{`type HandlerOptions<TSessionMetadata> = {
middleware?: (context: {
request: Request;
analyticsRequest: AnalyticsRequest;
}) =>
| Response
| void
| { sessionMetadata: TSessionMetadata }
| Promise<Response | void | { sessionMetadata: TSessionMetadata }>;
<Code>{`type MiddlewareFn<TSessionMetadata> = (context: {
request: Request;
analyticsRequest: AnalyticsRequest;
}) =>
| Response
| void
| { sessionMetadata?: TSessionMetadata; featureFlags?: Record<string, string> | false }
| Promise<Response | void | { sessionMetadata?: TSessionMetadata; featureFlags?: Record<string, string> | false }>;

type HandlerOptions<TSessionMetadata> = {
middleware?: MiddlewareFn<TSessionMetadata> | MiddlewareFn<TSessionMetadata>[];
};`}</Code>
</div>
);
Expand Down Expand Up @@ -708,9 +746,9 @@ function FeatureFlags() {
<div className="space-y-6">
<h1 className="text-3xl font-bold text-zinc-900 dark:text-zinc-50">Feature Flags</h1>
<p className="text-zinc-600 dark:text-zinc-400">
Feature flags are resolved at session creation time and attached to every event.
Both the client and backend accept a generic type parameter for compile-time type safety
on flag names and values.
Feature flags are resolved at session creation time via backend middleware and attached to every event.
The backend has full control over flag assignment. Both the client and backend accept a generic type
parameter for compile-time type safety on flag names and values.
</p>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">1. Define Flags in Backend Config</h2>
Expand Down Expand Up @@ -747,36 +785,32 @@ const client = new AnalyticsBackendClient({
newCheckout: "enabled" | "disabled";
};`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">3. Assign Flags at Session Creation</h2>
<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">3. Assign Flags via Middleware</h2>
<p className="text-zinc-600 dark:text-zinc-400">
Provide explicit values or weighted distributions. Distributions are resolved server-side
using weighted random selection.
Return <code>{"{ featureFlags }"}</code> from your middleware to assign flags at session creation.
The backend has full control over the assignment logic.
</p>
<Code>{`// Explicit assignment
const session = await analytics.createSession({
featureFlags: { theme: "dark", newCheckout: "enabled" },
});
<Code>{`const handler = client.getHandler({
middleware: ({ analyticsRequest }) => {
if (analyticsRequest.requestType !== "createSession") return;

// A/B test with weighted distribution
const session = await analytics.createSession({
featureFlags: {
theme: { light: 50, dark: 50 }, // 50/50 split
newCheckout: { enabled: 30, disabled: 70 }, // 30% enabled
return {
featureFlags: {
theme: Math.random() < 0.5 ? "light" : "dark",
newCheckout: Math.random() < 0.3 ? "enabled" : "disabled",
},
};
},
});`}</Code>

<h2 className="text-xl font-semibold text-zinc-900 dark:text-zinc-50">4. With the React Hook</h2>
<p className="text-zinc-600 dark:text-zinc-400">
Pass feature flag distributions to <code>createAnalyticsHook</code>. Flags are resolved
automatically when the session is created.
The client calls <code>createSession()</code> without any flag arguments. Flags are assigned
by backend middleware and returned in the session response.
</p>
<Code>{`export const useAnalytics = createAnalyticsHook<CustomEvents, FeatureFlags>({
endpoint: "/api/analytics",
flushInterval: 2000,
featureFlags: {
theme: { light: 50, dark: 50 },
newCheckout: { enabled: 30, disabled: 70 },
},
});

// In a component:
Expand All @@ -789,9 +823,8 @@ featureFlags.newCheckout; // "enabled" | "disabled"`}</Code>
endpoint: "/api/analytics",
});

const session = await analytics.createSession({
featureFlags: { theme: { light: 50, dark: 50 } },
});
// Flags are assigned by backend middleware
const session = await analytics.createSession();
session.featureFlags.theme; // "light" | "dark"

// Read a single flag
Expand Down
46 changes: 11 additions & 35 deletions packages/app/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export default function Home() {
</h1>
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
Pageviews are captured automatically on every navigation.
Session is auto-created with A/B test feature flags.
Session is auto-created with feature flags assigned by backend middleware.
</p>
</div>

Expand Down Expand Up @@ -56,25 +56,9 @@ export default function Home() {
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-50">theme</p>
<p className="text-sm text-zinc-500">
Current: <strong>{theme}</strong> — assigned via A/B test with 50/50 weighted distribution
Current: <strong>{theme}</strong> — assigned by backend middleware (50/50 weighted distribution)
</p>
</div>
<div className="flex gap-2">
{(["light", "dark"] as const).map((val) => (
<button
key={val}
onClick={() => sessionId && createSession({ theme: val, newCheckout: featureFlags.newCheckout })}
disabled={!sessionId || theme === val}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
theme === val
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
}`}
>
{val}
</button>
))}
</div>
</div>
<div
className={`mt-3 rounded-md p-3 text-center text-sm ${
Expand All @@ -93,32 +77,24 @@ export default function Home() {
<div>
<p className="font-medium text-zinc-900 dark:text-zinc-50">newCheckout</p>
<p className="text-sm text-zinc-500">
Current: <strong>{featureFlags.newCheckout ?? "disabled"}</strong> — assigned via A/B test with 30/70 weighted distribution (enabled/disabled)
Current: <strong>{featureFlags.newCheckout ?? "disabled"}</strong> — assigned by backend middleware (30/70 weighted distribution)
</p>
</div>
<div className="flex gap-2">
{(["enabled", "disabled"] as const).map((val) => (
<button
key={val}
onClick={() => sessionId && createSession({ theme: featureFlags.theme, newCheckout: val })}
disabled={!sessionId || featureFlags.newCheckout === val}
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
featureFlags.newCheckout === val
? "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900"
: "border border-zinc-200 text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
}`}
>
{val}
</button>
))}
</div>
</div>
{featureFlags.newCheckout === "enabled" && (
<div className="mt-3 rounded-md bg-purple-50 p-3 text-center text-sm text-purple-700 dark:bg-purple-950 dark:text-purple-300">
Express checkout flow is active — visit the <strong>Checkout</strong> page to see it
</div>
)}
</div>

<button
onClick={() => sessionId && createSession()}
disabled={!sessionId}
className="rounded-md border border-zinc-200 px-4 py-2 text-sm font-medium text-zinc-600 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-400 dark:hover:bg-zinc-800"
>
New Session (re-roll flags)
</button>
</section>

{/* Event Tracking */}
Expand Down
4 changes: 0 additions & 4 deletions packages/app/lib/use-analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,4 @@ import type { CustomEvents, FeatureFlags } from "./config";
export const useAnalytics = createAnalyticsHook<CustomEvents, FeatureFlags>({
endpoint: "/api/analytics",
flushInterval: 2000,
featureFlags: {
theme: { light: 50, dark: 50 },
newCheckout: { enabled: 30, disabled: 70 },
},
});
Loading