@caravela-studio/glancy-next 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +161 -2
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/middleware.d.ts +36 -0
- package/dist/middleware.js +116 -0
- package/dist/react.d.ts +14 -0
- package/dist/react.js +48 -0
- package/dist/server.d.ts +70 -0
- package/dist/server.js +77 -0
- package/package.json +51 -4
package/README.md
CHANGED
|
@@ -1,3 +1,162 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @caravela-studio/glancy-next
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Send product analytics from a Next.js app straight to Glancy — no Segment in between.
|
|
4
|
+
|
|
5
|
+
- **Browser:** `<GlancyProvider>` sends a page view on every route change, `useGlancy()` gives you
|
|
6
|
+
`track` / `identify` / `group` / `alias` / `reset`. Messages are batched (20 messages or 2 s) and flushed with
|
|
7
|
+
`fetch(keepalive)` when the tab is hidden or closed. Works with the App Router and the Pages Router.
|
|
8
|
+
- **Middleware:** `withGlancy()` from `@caravela-studio/glancy-next/middleware` sends page views for page loads
|
|
9
|
+
(shared with the browser, so each is stored once) and `API Request` events from the server — see
|
|
10
|
+
[below](#middleware).
|
|
11
|
+
- **Server:** `glancy()` from `@caravela-studio/glancy-next/server` for route handlers, server actions and middleware.
|
|
12
|
+
- **Session replay & heatmaps (opt-in):** record sessions to watch them back, and collect clicks and scroll depth
|
|
13
|
+
per page — see [below](#session-replay-and-heatmaps).
|
|
14
|
+
- **One visitor across both:** the browser's anonymous id lives in the `glancy_anon_id` cookie, so server events
|
|
15
|
+
can be tied to it with `getAnonymousId()`. `identify` merges the anonymous history into the user.
|
|
16
|
+
|
|
17
|
+
Messages follow the Segment spec, so they look exactly like the events Glancy already receives from Segment.
|
|
18
|
+
|
|
19
|
+
> Not on Next.js? See [all the SDKs](../README.md): `@caravela-studio/glancy-web` for any web app, `glancy.js` for any site,
|
|
20
|
+
> React Native, Swift and Kotlin. This package is built on [`@caravela-studio/glancy-web`](../web).
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
Public on npm, no token needed:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npm install @caravela-studio/glancy-next # brings @caravela-studio/glancy-web with it
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`.env.local` of your app (the write key is on the bucket's **Setup** page):
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
NEXT_PUBLIC_GLANCY_HOST=https://analytics.example.com
|
|
34
|
+
NEXT_PUBLIC_GLANCY_WRITE_KEY=glc_prod_…
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
The write key can only add events to one bucket, never read them, so it's fine to ship it to the browser.
|
|
38
|
+
|
|
39
|
+
## Browser
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
// app/layout.tsx
|
|
43
|
+
import { GlancyProvider } from "@caravela-studio/glancy-next";
|
|
44
|
+
|
|
45
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
46
|
+
return (
|
|
47
|
+
<html lang="en">
|
|
48
|
+
<body>
|
|
49
|
+
<GlancyProvider>{children}</GlancyProvider>
|
|
50
|
+
</body>
|
|
51
|
+
</html>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
```tsx
|
|
57
|
+
"use client";
|
|
58
|
+
import { useGlancy } from "@caravela-studio/glancy-next";
|
|
59
|
+
|
|
60
|
+
export function BuyButton() {
|
|
61
|
+
const analytics = useGlancy();
|
|
62
|
+
return <button onClick={() => analytics.track("Order Completed", { revenue: 42, currency: "EUR" })}>Buy</button>;
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
| Method | What it sends |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `track(event, properties?)` | A named event |
|
|
69
|
+
| `page(name?, properties?)` | `Page Viewed` with `path`, `url`, `title`, `referrer`, `search` (automatic with the provider) |
|
|
70
|
+
| `identify(userId, traits?)` | Links this browser to a user and stores traits on the profile (call after sign in) |
|
|
71
|
+
| `group(groupId, traits?)` | Associates the user with a company / team |
|
|
72
|
+
| `alias(userId, previousId?)` | Merges a previous id into `userId` |
|
|
73
|
+
| `reset()` | Forgets the user, starts a new anonymous id and a new session (call on sign out) |
|
|
74
|
+
| `flush()` | Sends the queue now |
|
|
75
|
+
| `stopRecording()` | Stops session replay and heatmaps (e.g. consent withdrawn); events keep working |
|
|
76
|
+
| `getSessionId()` | The current session id (also sent as `context.sessionId` on every event) |
|
|
77
|
+
|
|
78
|
+
Provider props: `writeKey`, `host` (default to the env vars above), `trackPageViews` (default `true`),
|
|
79
|
+
`flushAt` (20), `flushInterval` (2000 ms), `debug` (logs every message), `sessionReplay`, `heatmaps`.
|
|
80
|
+
|
|
81
|
+
Every message carries `context.page`, `userAgent`, `os.name`, `locale`, `timezone` and `screen`, so breakdowns
|
|
82
|
+
like *by OS* work out of the box.
|
|
83
|
+
|
|
84
|
+
## Session replay and heatmaps
|
|
85
|
+
|
|
86
|
+
Both are off by default and turned on with a prop:
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
<GlancyProvider sessionReplay heatmaps>{children}</GlancyProvider>
|
|
90
|
+
|
|
91
|
+
// or with options
|
|
92
|
+
<GlancyProvider sessionReplay={{ sampleRate: 0.25, maskAllText: false }} heatmaps>
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
**Session replay** records the DOM with [rrweb](https://github.com/rrweb-io/rrweb) and sends it in compressed
|
|
96
|
+
chunks every few seconds. Watch sessions under **Replays** in Glancy (with the session's events on a
|
|
97
|
+
timeline), or from a person's profile. The recorder is loaded on demand, so it isn't in your bundle when replay is
|
|
98
|
+
off.
|
|
99
|
+
|
|
100
|
+
- A session is one browser tab; it ends after 30 minutes of inactivity or on `reset()`.
|
|
101
|
+
- Privacy defaults: everything typed in inputs is masked (passwords always are). Add `data-glancy-mask` (or class
|
|
102
|
+
`glancy-mask`) to mask an element's text, `data-glancy-block` (or `glancy-block`) to leave it out entirely.
|
|
103
|
+
|
|
104
|
+
| `sessionReplay` option | Default | |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| `sampleRate` | `1` | Share of sessions recorded (decided once per session) |
|
|
107
|
+
| `maskAllInputs` | `true` | Mask what people type |
|
|
108
|
+
| `maskAllText` | `false` | Mask every piece of text on the page |
|
|
109
|
+
| `maskTextSelector` | — | More elements to mask |
|
|
110
|
+
| `blockSelector` | — | More elements to leave out |
|
|
111
|
+
|
|
112
|
+
**Heatmaps** collect every click (where on the page, and which element) and the deepest scroll of each page view.
|
|
113
|
+
Glancy draws them over your live page in a frame, per page and device class (desktop / tablet / mobile), and
|
|
114
|
+
lists the most clicked elements. Give important elements a `data-testid` or `data-glancy-name` to get stable names.
|
|
115
|
+
For the page to show, your site must allow being framed by the dashboard's origin (no `X-Frame-Options: DENY` /
|
|
116
|
+
restrictive `frame-ancestors`); the SDK doesn't track inside that frame.
|
|
117
|
+
|
|
118
|
+
## Middleware
|
|
119
|
+
|
|
120
|
+
```ts
|
|
121
|
+
// middleware.ts
|
|
122
|
+
import { withGlancy } from "@caravela-studio/glancy-next/middleware";
|
|
123
|
+
|
|
124
|
+
export default withGlancy({ exclude: ["/api/health"], userId: (req) => req.cookies.get("uid")?.value });
|
|
125
|
+
export const config = { matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"] };
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
- A page view for every page load (`Sec-Fetch-Dest: document`), with a fresh `messageId` that it hands to the
|
|
129
|
+
browser in the short-lived `glancy_pv` cookie. The browser SDK's first page view of that page reuses it, and
|
|
130
|
+
ingestion stores one row, filling in what the server's copy lacked (title, screen, the browser session).
|
|
131
|
+
Client-side navigations stay with `<GlancyProvider>`: Next.js strips the RSC / prefetch headers before middleware,
|
|
132
|
+
so a navigation can't be told from a prefetch there.
|
|
133
|
+
- An `API Request` event (method, path) for paths matching `api` (default `["/api/"]`).
|
|
134
|
+
- Sets `glancy_anon_id` on the first request (readable by the browser SDK), so both sides share the visitor.
|
|
135
|
+
- Skips static files, prefetches, redirects and `exclude`; sends with `event.waitUntil`.
|
|
136
|
+
- `withGlancy(options, yourMiddleware)` runs yours first and keeps its response.
|
|
137
|
+
|
|
138
|
+
## Server
|
|
139
|
+
|
|
140
|
+
Set `GLANCY_HOST` and `GLANCY_WRITE_KEY` (the `NEXT_PUBLIC_` ones are used as a fallback).
|
|
141
|
+
|
|
142
|
+
```ts
|
|
143
|
+
// app/api/checkout/route.ts
|
|
144
|
+
import { glancy, getAnonymousId } from "@caravela-studio/glancy-next/server";
|
|
145
|
+
import { cookies } from "next/headers";
|
|
146
|
+
import { after } from "next/server";
|
|
147
|
+
|
|
148
|
+
export async function POST() {
|
|
149
|
+
const anonymousId = getAnonymousId(await cookies());
|
|
150
|
+
after(() => glancy().track({ userId: user.id, anonymousId, event: "Order Completed", properties: { revenue: 42 } }));
|
|
151
|
+
return Response.json({ ok: true });
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Server calls (`track`, `identify`, `page`, `group`, `alias`, `batch`) need a `userId` or an `anonymousId`. Each
|
|
156
|
+
one is sent right away and resolves once it's stored, so serverless functions don't drop events; `after()` keeps
|
|
157
|
+
it off the response time. Sending never throws — failures go to `onError` (default `console.error`).
|
|
158
|
+
`new ServerClient({ writeKey, host })` creates a client with explicit config.
|
|
159
|
+
|
|
160
|
+
## Example
|
|
161
|
+
|
|
162
|
+
`examples/nextjs` is a small app using both sides: `npm install && npm run dev` (port 3048).
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { NextResponse, type NextFetchEvent, type NextRequest } from "next/server";
|
|
2
|
+
import { type ServerOptions } from "./server.js";
|
|
3
|
+
/** A path prefix ("/api/"), a regular expression, or a test of the whole request. */
|
|
4
|
+
export type PathMatcher = string | RegExp | ((req: NextRequest) => boolean);
|
|
5
|
+
export interface GlancyMiddlewareOptions extends ServerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Send a page view from the server for every page load. The browser SDK (`<GlancyProvider>`) reuses
|
|
8
|
+
* its id, so each page view is stored once, and it still counts when the browser script is blocked.
|
|
9
|
+
* Client-side navigations are tracked by the browser. Default true.
|
|
10
|
+
*/
|
|
11
|
+
pageViews?: boolean;
|
|
12
|
+
/** Requests sent as an "API Request" event (method, path). Default ["/api/"]; false for none. */
|
|
13
|
+
api?: PathMatcher[] | false;
|
|
14
|
+
/** Requests never tracked, e.g. health checks. */
|
|
15
|
+
exclude?: PathMatcher[];
|
|
16
|
+
/** The signed-in user, e.g. from your session cookie, so events land on their profile. */
|
|
17
|
+
userId?: (req: NextRequest) => string | number | null | undefined | Promise<string | number | null | undefined>;
|
|
18
|
+
}
|
|
19
|
+
type Middleware = (req: NextRequest, event: NextFetchEvent) => Response | NextResponse | null | undefined | void | Promise<Response | NextResponse | null | undefined | void>;
|
|
20
|
+
/**
|
|
21
|
+
* A page load: a document request. Client-side navigations aren't counted here: Next.js hides the
|
|
22
|
+
* headers that tell a navigation from a prefetch from middleware, so the browser SDK tracks those.
|
|
23
|
+
*/
|
|
24
|
+
export declare function isPageRequest(req: NextRequest): boolean;
|
|
25
|
+
/**
|
|
26
|
+
* Next.js middleware that tracks server-side: page views (shared with the browser SDK, so nothing is
|
|
27
|
+
* counted twice) and API requests. It also sets the anonymous id cookie on the first request, so
|
|
28
|
+
* server and browser events belong to the same visitor from the start.
|
|
29
|
+
*
|
|
30
|
+
* // middleware.ts
|
|
31
|
+
* export default withGlancy({ userId: (req) => req.cookies.get("uid")?.value });
|
|
32
|
+
*
|
|
33
|
+
* Pass your own middleware as the second argument to run it first.
|
|
34
|
+
*/
|
|
35
|
+
export declare function withGlancy(options?: GlancyMiddlewareOptions, middleware?: Middleware): (req: NextRequest, event: NextFetchEvent) => Promise<Response>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import { ANONYMOUS_ID_COOKIE, DEFAULT_REDACTED_PARAMS, PAGE_VIEW_COOKIE, encodePageView, redactUrl, uuid } from "@caravela-studio/glancy-web/core";
|
|
3
|
+
import { ServerClient } from "./server.js";
|
|
4
|
+
const YEAR = 365 * 24 * 3600;
|
|
5
|
+
/** Static files and Next.js internals: never page views or API requests. */
|
|
6
|
+
const ASSET = /^\/(_next|__nextjs)|\.[a-z0-9]{2,5}$/i;
|
|
7
|
+
const matches = (req, list) => list.some((m) => (typeof m === "string" ? req.nextUrl.pathname.startsWith(m) : m instanceof RegExp ? m.test(req.nextUrl.pathname) : m(req)));
|
|
8
|
+
/** A browser prefetch (speculation rules, <link rel=prefetch>): nobody has seen the page yet. */
|
|
9
|
+
const isPrefetch = (h) => h.get("purpose") === "prefetch" || /prefetch|prerender/.test(h.get("sec-purpose") ?? "");
|
|
10
|
+
/**
|
|
11
|
+
* A page load: a document request. Client-side navigations aren't counted here: Next.js hides the
|
|
12
|
+
* headers that tell a navigation from a prefetch from middleware, so the browser SDK tracks those.
|
|
13
|
+
*/
|
|
14
|
+
export function isPageRequest(req) {
|
|
15
|
+
const h = req.headers;
|
|
16
|
+
if (req.method !== "GET" || isPrefetch(h) || ASSET.test(req.nextUrl.pathname))
|
|
17
|
+
return false;
|
|
18
|
+
const dest = h.get("sec-fetch-dest");
|
|
19
|
+
if (dest)
|
|
20
|
+
return dest === "document";
|
|
21
|
+
return !h.has("next-url") && (h.get("accept") ?? "").includes("text/html");
|
|
22
|
+
}
|
|
23
|
+
/** Country, region and city from Vercel's geolocation headers, when the app runs on Vercel. */
|
|
24
|
+
function location(h) {
|
|
25
|
+
const country = h.get("x-vercel-ip-country");
|
|
26
|
+
if (!country)
|
|
27
|
+
return undefined;
|
|
28
|
+
const decode = (v) => (v ? decodeURIComponent(v) : undefined);
|
|
29
|
+
const lat = Number(h.get("x-vercel-ip-latitude"));
|
|
30
|
+
const lon = Number(h.get("x-vercel-ip-longitude"));
|
|
31
|
+
return {
|
|
32
|
+
country,
|
|
33
|
+
region: decode(h.get("x-vercel-ip-country-region")),
|
|
34
|
+
city: decode(h.get("x-vercel-ip-city")),
|
|
35
|
+
...(Number.isFinite(lat) && Number.isFinite(lon) && h.get("x-vercel-ip-latitude") ? { latitude: lat, longitude: lon } : {}),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function cookie(name, value, maxAge, secure) {
|
|
39
|
+
return `${name}=${encodeURIComponent(value)}; Path=/; Max-Age=${maxAge}; SameSite=Lax${secure ? "; Secure" : ""}`;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Next.js middleware that tracks server-side: page views (shared with the browser SDK, so nothing is
|
|
43
|
+
* counted twice) and API requests. It also sets the anonymous id cookie on the first request, so
|
|
44
|
+
* server and browser events belong to the same visitor from the start.
|
|
45
|
+
*
|
|
46
|
+
* // middleware.ts
|
|
47
|
+
* export default withGlancy({ userId: (req) => req.cookies.get("uid")?.value });
|
|
48
|
+
*
|
|
49
|
+
* Pass your own middleware as the second argument to run it first.
|
|
50
|
+
*/
|
|
51
|
+
export function withGlancy(options = {}, middleware) {
|
|
52
|
+
const { pageViews = true, api = ["/api/"], exclude = [], userId, ...serverOptions } = options;
|
|
53
|
+
let client = null;
|
|
54
|
+
return async function glancyMiddleware(req, event) {
|
|
55
|
+
const response = (await middleware?.(req, event)) ?? NextResponse.next();
|
|
56
|
+
if (ASSET.test(req.nextUrl.pathname) || matches(req, exclude))
|
|
57
|
+
return response;
|
|
58
|
+
const page = pageViews && isPageRequest(req) && !(response.status >= 300 && response.status < 400);
|
|
59
|
+
const apiRequest = !!api && !page && matches(req, api) && !isPrefetch(req.headers);
|
|
60
|
+
if (!page && !apiRequest)
|
|
61
|
+
return response;
|
|
62
|
+
const secure = req.nextUrl.protocol === "https:";
|
|
63
|
+
const cookies = [];
|
|
64
|
+
let anonymousId = req.cookies.get(ANONYMOUS_ID_COOKIE)?.value;
|
|
65
|
+
anonymousId = anonymousId ? decodeURIComponent(anonymousId) : undefined;
|
|
66
|
+
if (!anonymousId) {
|
|
67
|
+
anonymousId = uuid();
|
|
68
|
+
cookies.push(cookie(ANONYMOUS_ID_COOKIE, anonymousId, YEAR, secure));
|
|
69
|
+
}
|
|
70
|
+
// Secret query parameters (tokens, codes) never leave the server, like in the browser SDK.
|
|
71
|
+
const url = req.nextUrl;
|
|
72
|
+
const redact = (u) => redactUrl(u, DEFAULT_REDACTED_PARAMS);
|
|
73
|
+
const h = req.headers;
|
|
74
|
+
const info = {
|
|
75
|
+
path: url.pathname,
|
|
76
|
+
url: redact(url.href),
|
|
77
|
+
search: redact(url.search ? `/${url.search}` : "").slice(1),
|
|
78
|
+
referrer: redact(h.get("referer") ?? ""),
|
|
79
|
+
};
|
|
80
|
+
const context = {
|
|
81
|
+
userAgent: h.get("user-agent") ?? undefined,
|
|
82
|
+
locale: h.get("accept-language")?.split(",")[0] || undefined,
|
|
83
|
+
location: location(h),
|
|
84
|
+
page: info,
|
|
85
|
+
};
|
|
86
|
+
const properties = page ? info : { method: req.method, path: url.pathname };
|
|
87
|
+
const messageId = uuid();
|
|
88
|
+
if (page)
|
|
89
|
+
cookies.push(cookie(PAGE_VIEW_COOKIE, encodePageView(messageId, redact(url.pathname + url.search)), 60, secure));
|
|
90
|
+
const send = async () => {
|
|
91
|
+
try {
|
|
92
|
+
client ?? (client = new ServerClient(serverOptions));
|
|
93
|
+
const identity = { anonymousId: anonymousId, userId: (userId ? await userId(req) : null) ?? null };
|
|
94
|
+
await (page
|
|
95
|
+
? client.page({ ...identity, messageId, context, properties })
|
|
96
|
+
: client.track({ ...identity, messageId, event: "API Request", context, properties }));
|
|
97
|
+
}
|
|
98
|
+
catch (err) {
|
|
99
|
+
(serverOptions.onError ?? console.error)(err);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
// Off the response's critical path: Next.js keeps the function alive until it's stored.
|
|
103
|
+
const sending = send();
|
|
104
|
+
if (typeof event?.waitUntil === "function")
|
|
105
|
+
event.waitUntil(sending);
|
|
106
|
+
for (const c of cookies) {
|
|
107
|
+
try {
|
|
108
|
+
response.headers.append("Set-Cookie", c);
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
// Immutable headers (e.g. Response.redirect): no cookies, the browser SDK makes its own ids.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return response;
|
|
115
|
+
};
|
|
116
|
+
}
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import { BrowserClient, type BrowserOptions } from "@caravela-studio/glancy-web";
|
|
3
|
+
export interface GlancyProviderProps extends Partial<BrowserOptions> {
|
|
4
|
+
children: ReactNode;
|
|
5
|
+
/** Send a page view on every route change (pathname or search params). Default true. */
|
|
6
|
+
trackPageViews?: boolean;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Put this in the root layout. `writeKey` and `host` default to NEXT_PUBLIC_GLANCY_WRITE_KEY and
|
|
10
|
+
* NEXT_PUBLIC_GLANCY_HOST. Add `sessionReplay` and / or `heatmaps` to turn those on.
|
|
11
|
+
*/
|
|
12
|
+
export declare function GlancyProvider({ children, trackPageViews, ...options }: GlancyProviderProps): import("react").JSX.Element;
|
|
13
|
+
/** The client, for `track` / `identify` / `reset` in client components. */
|
|
14
|
+
export declare function useGlancy(): BrowserClient;
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { usePathname, useSearchParams } from "next/navigation";
|
|
4
|
+
import { createContext, Suspense, useContext, useEffect, useRef, useState } from "react";
|
|
5
|
+
import { BrowserClient, LIBRARY } from "@caravela-studio/glancy-web";
|
|
6
|
+
const Context = createContext(null);
|
|
7
|
+
/**
|
|
8
|
+
* Put this in the root layout. `writeKey` and `host` default to NEXT_PUBLIC_GLANCY_WRITE_KEY and
|
|
9
|
+
* NEXT_PUBLIC_GLANCY_HOST. Add `sessionReplay` and / or `heatmaps` to turn those on.
|
|
10
|
+
*/
|
|
11
|
+
export function GlancyProvider({ children, trackPageViews = true, ...options }) {
|
|
12
|
+
const [client] = useState(() => new BrowserClient({
|
|
13
|
+
...options,
|
|
14
|
+
writeKey: options.writeKey ?? process.env.NEXT_PUBLIC_GLANCY_WRITE_KEY ?? "",
|
|
15
|
+
host: options.host ?? process.env.NEXT_PUBLIC_GLANCY_HOST ?? "",
|
|
16
|
+
manualStart: true,
|
|
17
|
+
library: { name: "@glancy/next", version: LIBRARY.version },
|
|
18
|
+
}));
|
|
19
|
+
// In an effect (with cleanup) so React strict mode's double render doesn't record twice.
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
client.startRecording();
|
|
22
|
+
return () => client.stopRecording();
|
|
23
|
+
}, [client]);
|
|
24
|
+
return (_jsxs(Context.Provider, { value: client, children: [trackPageViews && (
|
|
25
|
+
// useSearchParams needs a Suspense boundary, otherwise it opts the whole page out of static rendering.
|
|
26
|
+
_jsx(Suspense, { fallback: null, children: _jsx(PageViews, { client: client }) })), children] }));
|
|
27
|
+
}
|
|
28
|
+
function PageViews({ client }) {
|
|
29
|
+
const pathname = usePathname();
|
|
30
|
+
const search = useSearchParams()?.toString() ?? "";
|
|
31
|
+
const last = useRef(null);
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const key = `${pathname}?${search}`;
|
|
34
|
+
// Strict mode runs effects twice in development; one page view per URL.
|
|
35
|
+
if (last.current === key)
|
|
36
|
+
return;
|
|
37
|
+
last.current = key;
|
|
38
|
+
client.page();
|
|
39
|
+
}, [client, pathname, search]);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
/** The client, for `track` / `identify` / `reset` in client components. */
|
|
43
|
+
export function useGlancy() {
|
|
44
|
+
const client = useContext(Context);
|
|
45
|
+
if (!client)
|
|
46
|
+
throw new Error("[glancy] useGlancy() must be used inside <GlancyProvider>");
|
|
47
|
+
return client;
|
|
48
|
+
}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { ANONYMOUS_ID_COOKIE, type Message, type Properties, type Traits } from "@caravela-studio/glancy-web/core";
|
|
2
|
+
export { ANONYMOUS_ID_COOKIE };
|
|
3
|
+
export type { Message, Properties, Traits };
|
|
4
|
+
export interface ServerOptions {
|
|
5
|
+
/** Defaults to GLANCY_WRITE_KEY (or NEXT_PUBLIC_GLANCY_WRITE_KEY). */
|
|
6
|
+
writeKey?: string;
|
|
7
|
+
/** Defaults to GLANCY_HOST (or NEXT_PUBLIC_GLANCY_HOST). */
|
|
8
|
+
host?: string;
|
|
9
|
+
/** Called when a send fails. Default: console.error. Sending never throws. */
|
|
10
|
+
onError?: (err: unknown) => void;
|
|
11
|
+
}
|
|
12
|
+
/** Who the event is about: a signed-in user, the browser's anonymous id, or both (links them). */
|
|
13
|
+
export type Identity = {
|
|
14
|
+
userId: string | number;
|
|
15
|
+
anonymousId?: string | null;
|
|
16
|
+
} | {
|
|
17
|
+
userId?: string | number | null;
|
|
18
|
+
anonymousId: string;
|
|
19
|
+
};
|
|
20
|
+
type Common = Identity & {
|
|
21
|
+
context?: Record<string, unknown>;
|
|
22
|
+
timestamp?: Date | string;
|
|
23
|
+
/** A unique id per message; Glancy stores one message per id (default: a new uuid). */
|
|
24
|
+
messageId?: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Server client for route handlers, server actions and middleware. Each call is sent right away and the
|
|
28
|
+
* returned promise resolves when it's stored, so nothing is lost when a serverless function ends. Wrap
|
|
29
|
+
* calls in `after()` (next/server) to keep them off the response's critical path.
|
|
30
|
+
*/
|
|
31
|
+
export declare class ServerClient {
|
|
32
|
+
private readonly options;
|
|
33
|
+
private readonly writeKey;
|
|
34
|
+
private readonly url;
|
|
35
|
+
constructor(options?: ServerOptions);
|
|
36
|
+
track(input: Common & {
|
|
37
|
+
event: string;
|
|
38
|
+
properties?: Properties;
|
|
39
|
+
}): Promise<void>;
|
|
40
|
+
identify(input: Common & {
|
|
41
|
+
traits?: Traits;
|
|
42
|
+
}): Promise<void>;
|
|
43
|
+
page(input: Common & {
|
|
44
|
+
name?: string;
|
|
45
|
+
properties?: Properties;
|
|
46
|
+
}): Promise<void>;
|
|
47
|
+
group(input: Common & {
|
|
48
|
+
groupId: string | number;
|
|
49
|
+
traits?: Traits;
|
|
50
|
+
}): Promise<void>;
|
|
51
|
+
alias(input: {
|
|
52
|
+
userId: string | number;
|
|
53
|
+
previousId: string;
|
|
54
|
+
context?: Record<string, unknown>;
|
|
55
|
+
}): Promise<void>;
|
|
56
|
+
/** Send several messages in one request (e.g. a backfill). */
|
|
57
|
+
batch(messages: Message[]): Promise<void>;
|
|
58
|
+
private message;
|
|
59
|
+
private send;
|
|
60
|
+
}
|
|
61
|
+
export declare function glancy(options?: ServerOptions): ServerClient;
|
|
62
|
+
/**
|
|
63
|
+
* The anonymous id the browser SDK stored in a cookie, so server events land on the same visitor:
|
|
64
|
+
* `getAnonymousId(await cookies())` or `getAnonymousId(request.cookies)`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function getAnonymousId(cookies: {
|
|
67
|
+
get(name: string): {
|
|
68
|
+
value: string;
|
|
69
|
+
} | undefined;
|
|
70
|
+
}): string | undefined;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { ANONYMOUS_ID_COOKIE, LIBRARY, ingestUrl, stamp } from "@caravela-studio/glancy-web/core";
|
|
2
|
+
const library = { name: "@glancy/next", version: LIBRARY.version };
|
|
3
|
+
export { ANONYMOUS_ID_COOKIE };
|
|
4
|
+
/**
|
|
5
|
+
* Server client for route handlers, server actions and middleware. Each call is sent right away and the
|
|
6
|
+
* returned promise resolves when it's stored, so nothing is lost when a serverless function ends. Wrap
|
|
7
|
+
* calls in `after()` (next/server) to keep them off the response's critical path.
|
|
8
|
+
*/
|
|
9
|
+
export class ServerClient {
|
|
10
|
+
constructor(options = {}) {
|
|
11
|
+
this.options = options;
|
|
12
|
+
const env = typeof process !== "undefined" ? process.env : {};
|
|
13
|
+
this.writeKey = options.writeKey ?? env.GLANCY_WRITE_KEY ?? env.NEXT_PUBLIC_GLANCY_WRITE_KEY ?? "";
|
|
14
|
+
if (!this.writeKey)
|
|
15
|
+
throw new Error("[glancy] `writeKey` is required (or set GLANCY_WRITE_KEY)");
|
|
16
|
+
this.url = ingestUrl(options.host ?? env.GLANCY_HOST ?? env.NEXT_PUBLIC_GLANCY_HOST ?? "");
|
|
17
|
+
}
|
|
18
|
+
track(input) {
|
|
19
|
+
return this.send([this.message({ ...input, type: "track" })]);
|
|
20
|
+
}
|
|
21
|
+
identify(input) {
|
|
22
|
+
return this.send([this.message({ ...input, type: "identify" })]);
|
|
23
|
+
}
|
|
24
|
+
page(input) {
|
|
25
|
+
return this.send([this.message({ ...input, type: "page" })]);
|
|
26
|
+
}
|
|
27
|
+
group(input) {
|
|
28
|
+
return this.send([this.message({ ...input, type: "group", groupId: String(input.groupId) })]);
|
|
29
|
+
}
|
|
30
|
+
alias(input) {
|
|
31
|
+
return this.send([this.message({ ...input, type: "alias" })]);
|
|
32
|
+
}
|
|
33
|
+
/** Send several messages in one request (e.g. a backfill). */
|
|
34
|
+
async batch(messages) {
|
|
35
|
+
if (messages.length)
|
|
36
|
+
await this.send(messages.map((m) => stamp({ ...m, context: { source: "server", ...m.context } }, library)));
|
|
37
|
+
}
|
|
38
|
+
message(input) {
|
|
39
|
+
const { timestamp, userId, context, ...rest } = input;
|
|
40
|
+
return stamp({
|
|
41
|
+
...rest,
|
|
42
|
+
context: { source: "server", ...context },
|
|
43
|
+
userId: userId === undefined || userId === null ? null : String(userId),
|
|
44
|
+
...(timestamp ? { timestamp: new Date(timestamp).toISOString() } : {}),
|
|
45
|
+
}, library);
|
|
46
|
+
}
|
|
47
|
+
async send(batch) {
|
|
48
|
+
try {
|
|
49
|
+
const res = await fetch(this.url, {
|
|
50
|
+
method: "POST",
|
|
51
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.writeKey}` },
|
|
52
|
+
body: JSON.stringify({ batch, sentAt: new Date().toISOString() }),
|
|
53
|
+
cache: "no-store",
|
|
54
|
+
});
|
|
55
|
+
if (!res.ok)
|
|
56
|
+
throw new Error(`[glancy] HTTP ${res.status}: ${await res.text()}`);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
(this.options.onError ?? console.error)(err);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** One client per server process; reads its config from the environment. */
|
|
64
|
+
let shared = null;
|
|
65
|
+
export function glancy(options) {
|
|
66
|
+
if (options)
|
|
67
|
+
return new ServerClient(options);
|
|
68
|
+
return (shared ?? (shared = new ServerClient()));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The anonymous id the browser SDK stored in a cookie, so server events land on the same visitor:
|
|
72
|
+
* `getAnonymousId(await cookies())` or `getAnonymousId(request.cookies)`.
|
|
73
|
+
*/
|
|
74
|
+
export function getAnonymousId(cookies) {
|
|
75
|
+
const v = cookies.get(ANONYMOUS_ID_COOKIE)?.value;
|
|
76
|
+
return v ? decodeURIComponent(v) : undefined;
|
|
77
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,53 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@caravela-studio/glancy-next",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Send product analytics from Next.js straight to Glancy",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/caravela-labs/glancy.git",
|
|
9
|
+
"directory": "sdks/next"
|
|
10
|
+
},
|
|
11
|
+
"publishConfig": {
|
|
12
|
+
"registry": "https://registry.npmjs.org",
|
|
13
|
+
"access": "public"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"sideEffects": false,
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md"
|
|
20
|
+
],
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/index.d.ts",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./server": {
|
|
27
|
+
"types": "./dist/server.d.ts",
|
|
28
|
+
"default": "./dist/server.js"
|
|
29
|
+
},
|
|
30
|
+
"./middleware": {
|
|
31
|
+
"types": "./dist/middleware.d.ts",
|
|
32
|
+
"default": "./dist/middleware.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "tsc -p tsconfig.json",
|
|
37
|
+
"prepack": "npm run build"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"next": ">=13.4",
|
|
41
|
+
"react": ">=18"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^22.0.0",
|
|
45
|
+
"@types/react": "^19.0.0",
|
|
46
|
+
"next": "^15.5.0",
|
|
47
|
+
"react": "^19.0.0",
|
|
48
|
+
"typescript": "^5.9.0"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@caravela-studio/glancy-web": "^0.1.1"
|
|
52
|
+
}
|
|
53
|
+
}
|