runable
This commit is contained in:
112
ingestion/sdk/web/README.md
Normal file
112
ingestion/sdk/web/README.md
Normal file
@@ -0,0 +1,112 @@
|
||||
# CDP Web SDK
|
||||
|
||||
Single-file TypeScript tracker for browsers. No build step, no dependencies.
|
||||
|
||||
## Install
|
||||
|
||||
Copy [`cdp.ts`](./cdp.ts) into your app. A common spot for Next.js:
|
||||
|
||||
```
|
||||
your-app/
|
||||
└── lib/
|
||||
└── cdp.ts ← paste here
|
||||
```
|
||||
|
||||
## Init (Next.js App Router)
|
||||
|
||||
`app/layout.tsx`:
|
||||
|
||||
```tsx
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { cdp } from '@/lib/cdp';
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
cdp.init({
|
||||
writeKey: process.env.NEXT_PUBLIC_CDP_WRITE_KEY!,
|
||||
endpoint: process.env.NEXT_PUBLIC_CDP_ENDPOINT ?? 'http://localhost:3049',
|
||||
autoPage: true, // fire `page` on every SPA route change
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <html><body>{children}</body></html>;
|
||||
}
|
||||
```
|
||||
|
||||
`.env.local`:
|
||||
|
||||
```
|
||||
NEXT_PUBLIC_CDP_WRITE_KEY=cdp_dev_writekey_1234567890
|
||||
NEXT_PUBLIC_CDP_ENDPOINT=http://localhost:3049
|
||||
```
|
||||
|
||||
(The dev key above is the one seeded by `infra/migrations/000002_seed_dev.up.sql`.)
|
||||
|
||||
## Use
|
||||
|
||||
```tsx
|
||||
import { cdp } from '@/lib/cdp';
|
||||
|
||||
// On login
|
||||
cdp.identify(user.id, { email: user.email, plan: user.plan });
|
||||
|
||||
// On a meaningful action
|
||||
cdp.track('Checkout Completed', { revenue: 199, currency: 'USD' });
|
||||
|
||||
// Manual page call (skip if autoPage is on)
|
||||
cdp.page('Pricing');
|
||||
|
||||
// On logout
|
||||
cdp.reset();
|
||||
```
|
||||
|
||||
## Vite / Create React App
|
||||
|
||||
Identical — `cdp.init(...)` in your root component / `main.tsx`.
|
||||
|
||||
## What gets sent
|
||||
|
||||
Every call POSTs to `${endpoint}/v1/<type>` with this envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "track",
|
||||
"messageId": "uuid-v4",
|
||||
"anonymousId": "uuid-v4 from localStorage",
|
||||
"userId": "from identify()",
|
||||
"sentAt": "2026-05-25T03:14:15Z",
|
||||
"context": { "library_name": "cdp-web", "user_agent": "..." },
|
||||
"event": "Checkout Completed",
|
||||
"properties": { "revenue": 199, "currency": "USD" }
|
||||
}
|
||||
```
|
||||
|
||||
Header: `Authorization: Basic base64(<writeKey>:)`.
|
||||
|
||||
Payload is Segment-compatible: if you ever swap endpoints to Segment the same
|
||||
code works.
|
||||
|
||||
## Things to know
|
||||
|
||||
- `anonymousId` is generated once and persisted in `localStorage` under
|
||||
`cdp_anon`. It survives across sessions.
|
||||
- `userId` is persisted in `cdp_uid` until you call `cdp.reset()`.
|
||||
- `fetch` uses `keepalive: true` so events fire even when the page is
|
||||
unloading. No `sendBeacon` because we need the `Authorization` header.
|
||||
- For SSR (Next.js Server Components, Remix loaders) skip the SDK — fire
|
||||
events from your API route or a server-side function instead.
|
||||
|
||||
## CORS
|
||||
|
||||
The ingest service serves `Access-Control-Allow-Origin: *` so any origin
|
||||
works in dev. Lock this down for production (configure a reverse proxy or
|
||||
patch `internal/middleware/middleware.go`).
|
||||
|
||||
## Production checklist
|
||||
|
||||
- [ ] Issue a per-workspace write key from the console (don't ship the dev key)
|
||||
- [ ] Restrict CORS to known origins
|
||||
- [ ] Front the ingest service with HTTPS (browser refuses mixed content)
|
||||
- [ ] Set `NEXT_PUBLIC_CDP_ENDPOINT` to the public URL
|
||||
225
ingestion/sdk/web/cdp.ts
Normal file
225
ingestion/sdk/web/cdp.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
// Lightweight CDP tracker for browsers (React / Next.js / vanilla).
|
||||
// Zero dependencies. Copy this file into your app and import the `cdp` object.
|
||||
//
|
||||
// import { cdp } from '@/lib/cdp';
|
||||
//
|
||||
// // Once, at app startup (e.g. layout.tsx / _app.tsx):
|
||||
// cdp.init({
|
||||
// writeKey: process.env.NEXT_PUBLIC_CDP_WRITE_KEY!,
|
||||
// endpoint: process.env.NEXT_PUBLIC_CDP_ENDPOINT ?? 'http://localhost:3049',
|
||||
// });
|
||||
//
|
||||
// cdp.identify('user_42', { plan: 'pro' });
|
||||
// cdp.track('Button Clicked', { id: 'cta-hero' });
|
||||
// cdp.page(); // pulls path/url from window.location
|
||||
//
|
||||
// Payload shape matches Segment's Track/Identify/Page API, so the same code
|
||||
// works with Segment if you ever swap endpoints.
|
||||
|
||||
type Json = Record<string, unknown>;
|
||||
type EventType = 'track' | 'identify' | 'page' | 'group' | 'alias' | 'screen';
|
||||
|
||||
interface CDPConfig {
|
||||
writeKey: string;
|
||||
/** Base URL of the ingest service. e.g. http://localhost:3049 */
|
||||
endpoint: string;
|
||||
/** localStorage key for the anonymous id. Default: "cdp_anon". */
|
||||
anonymousIdKey?: string;
|
||||
/** localStorage key for the resolved user id. Default: "cdp_uid". */
|
||||
userIdKey?: string;
|
||||
/** Auto-fire `page` on every history change. Default: false. */
|
||||
autoPage?: boolean;
|
||||
}
|
||||
|
||||
interface CommonPayload {
|
||||
type: EventType;
|
||||
messageId: string;
|
||||
anonymousId: string;
|
||||
userId?: string;
|
||||
sentAt: string;
|
||||
context: Json;
|
||||
}
|
||||
|
||||
const DEFAULTS = {
|
||||
anonymousIdKey: 'cdp_anon',
|
||||
userIdKey: 'cdp_uid',
|
||||
autoPage: false,
|
||||
};
|
||||
|
||||
class CDPClient {
|
||||
private cfg: Required<CDPConfig> | null = null;
|
||||
private authHeader = '';
|
||||
|
||||
init(config: CDPConfig) {
|
||||
this.cfg = { ...DEFAULTS, ...config };
|
||||
// Segment-style basic auth: base64(writeKey + ":").
|
||||
this.authHeader = 'Basic ' + b64(`${config.writeKey}:`);
|
||||
this.ensureAnonymousId();
|
||||
if (this.cfg.autoPage && typeof window !== 'undefined') {
|
||||
this.installAutoPage();
|
||||
}
|
||||
}
|
||||
|
||||
identify(userId: string, traits: Json = {}) {
|
||||
this.setUserId(userId);
|
||||
return this.send('identify', { userId, traits });
|
||||
}
|
||||
|
||||
track(event: string, properties: Json = {}) {
|
||||
return this.send('track', { event, properties });
|
||||
}
|
||||
|
||||
page(name?: string, properties: Json = {}) {
|
||||
const loc = typeof window !== 'undefined' ? window.location : null;
|
||||
const merged: Json = {
|
||||
...(loc ? { url: loc.href, path: loc.pathname, referrer: document.referrer } : {}),
|
||||
...properties,
|
||||
};
|
||||
return this.send('page', { name, properties: merged });
|
||||
}
|
||||
|
||||
group(groupId: string, traits: Json = {}) {
|
||||
return this.send('group', { groupId, traits });
|
||||
}
|
||||
|
||||
alias(userId: string, previousId: string) {
|
||||
return this.send('alias', { userId, previousId });
|
||||
}
|
||||
|
||||
/** Forget the user id (e.g. on logout). Anonymous id is preserved. */
|
||||
reset() {
|
||||
if (typeof localStorage === 'undefined' || !this.cfg) return;
|
||||
localStorage.removeItem(this.cfg.userIdKey);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// internals
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private send(type: EventType, body: Json) {
|
||||
if (!this.cfg) {
|
||||
console.warn('[cdp] send() before init(); call cdp.init() at startup');
|
||||
return Promise.resolve();
|
||||
}
|
||||
const payload: CommonPayload & Json = {
|
||||
type,
|
||||
messageId: uuidv4(),
|
||||
anonymousId: this.getAnonymousId(),
|
||||
userId: this.getUserId() ?? undefined,
|
||||
sentAt: new Date().toISOString(),
|
||||
context: this.buildContext(),
|
||||
...body,
|
||||
};
|
||||
const url = `${this.cfg.endpoint}/v1/${type}`;
|
||||
const blob = JSON.stringify(payload);
|
||||
|
||||
// Prefer sendBeacon for unload safety (page-close, route-change).
|
||||
if (typeof navigator !== 'undefined' && 'sendBeacon' in navigator) {
|
||||
// sendBeacon can't set Authorization; fall back to fetch with keepalive
|
||||
// when an auth header is required. We always use fetch.
|
||||
}
|
||||
return fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: this.authHeader,
|
||||
},
|
||||
body: blob,
|
||||
keepalive: true,
|
||||
}).catch((err) => {
|
||||
console.warn('[cdp] send failed', err);
|
||||
});
|
||||
}
|
||||
|
||||
private buildContext(): Json {
|
||||
if (typeof window === 'undefined') return { library_name: 'cdp-web', library_version: '0.1.0' };
|
||||
return {
|
||||
library_name: 'cdp-web',
|
||||
library_version: '0.1.0',
|
||||
user_agent: navigator.userAgent,
|
||||
locale: navigator.language,
|
||||
screen_width: window.screen?.width,
|
||||
screen_height: window.screen?.height,
|
||||
};
|
||||
}
|
||||
|
||||
private ensureAnonymousId() {
|
||||
if (typeof localStorage === 'undefined' || !this.cfg) return;
|
||||
if (!localStorage.getItem(this.cfg.anonymousIdKey)) {
|
||||
localStorage.setItem(this.cfg.anonymousIdKey, uuidv4());
|
||||
}
|
||||
}
|
||||
|
||||
private getAnonymousId(): string {
|
||||
if (!this.cfg || typeof localStorage === 'undefined') return uuidv4();
|
||||
let id = localStorage.getItem(this.cfg.anonymousIdKey);
|
||||
if (!id) {
|
||||
id = uuidv4();
|
||||
localStorage.setItem(this.cfg.anonymousIdKey, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
private setUserId(id: string) {
|
||||
if (!this.cfg || typeof localStorage === 'undefined') return;
|
||||
localStorage.setItem(this.cfg.userIdKey, id);
|
||||
}
|
||||
|
||||
private getUserId(): string | null {
|
||||
if (!this.cfg || typeof localStorage === 'undefined') return null;
|
||||
return localStorage.getItem(this.cfg.userIdKey);
|
||||
}
|
||||
|
||||
private installAutoPage() {
|
||||
let lastPath = location.pathname;
|
||||
const fire = () => {
|
||||
if (location.pathname !== lastPath) {
|
||||
lastPath = location.pathname;
|
||||
this.page();
|
||||
}
|
||||
};
|
||||
// Patch pushState/replaceState so SPA route changes fire `page`.
|
||||
const origPush = history.pushState;
|
||||
const origReplace = history.replaceState;
|
||||
history.pushState = function (...args) {
|
||||
const r = origPush.apply(this, args);
|
||||
fire();
|
||||
return r;
|
||||
};
|
||||
history.replaceState = function (...args) {
|
||||
const r = origReplace.apply(this, args);
|
||||
fire();
|
||||
return r;
|
||||
};
|
||||
window.addEventListener('popstate', fire);
|
||||
// First page load.
|
||||
this.page();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tiny helpers (no deps)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uuidv4(): string {
|
||||
// crypto.randomUUID is available in all evergreen browsers and Node 19+.
|
||||
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
|
||||
return (crypto as Crypto).randomUUID();
|
||||
}
|
||||
// RFC 4122-ish fallback.
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
function b64(input: string): string {
|
||||
if (typeof btoa !== 'undefined') return btoa(input);
|
||||
// Node SSR
|
||||
// @ts-expect-error: Buffer exists in Node.
|
||||
return Buffer.from(input, 'utf-8').toString('base64');
|
||||
}
|
||||
|
||||
export const cdp = new CDPClient();
|
||||
export type { CDPConfig };
|
||||
Reference in New Issue
Block a user