@iorg1/trackking-next 0.4.0 → 0.5.0
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 +55 -6
- package/dist/index.cjs +42 -3
- package/dist/index.d.cts +54 -1
- package/dist/index.d.ts +54 -1
- package/dist/index.js +40 -2
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -117,6 +117,45 @@ server-side). Route templates support `:name` (one path segment) and `*` (the
|
|
|
117
117
|
rest of the path). For full control, pass `resolveEvent: (req) => string | null`
|
|
118
118
|
instead of `eventRoute` — return the event name, or `null` to pass through.
|
|
119
119
|
|
|
120
|
+
### Conversions
|
|
121
|
+
|
|
122
|
+
A **conversion** is an event that gets attributed to the campaign the visitor
|
|
123
|
+
landed with. Page views already carry the landing query string, and the collector
|
|
124
|
+
turns it into a `campaign` label (`utm_campaign`, Google's auto-appended
|
|
125
|
+
`gad_campaignid`, a bare `gclid`, …). A conversion adds the request's client IP +
|
|
126
|
+
User-Agent; the collector hashes them into the same cookieless daily visitor id
|
|
127
|
+
the page view got, discards the IP, and looks up that visitor's landing campaign
|
|
128
|
+
for the day. Nothing is stored on the device, so a click and its conversion are
|
|
129
|
+
linked only when they happen on the same day.
|
|
130
|
+
|
|
131
|
+
Three ways to record one:
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
// 1. From a route handler or server action, where the action actually completes.
|
|
135
|
+
// Use @iorg1/trackking-generic and pass the request headers:
|
|
136
|
+
import { headers } from "next/headers";
|
|
137
|
+
import { trackConversion } from "@iorg1/trackking-generic";
|
|
138
|
+
await trackConversion("report-ordered", { headers: await headers(), path: "/order" });
|
|
139
|
+
|
|
140
|
+
// 2. From your middleware — e.g. when the thank-you page is requested.
|
|
141
|
+
// (A page request repeats on reload, so prefer 1 when you can.)
|
|
142
|
+
import { track, trackConversion } from "@iorg1/trackking-next";
|
|
143
|
+
export function middleware(req: NextRequest, event: NextFetchEvent) {
|
|
144
|
+
if (req.nextUrl.pathname === "/order/confirmed") trackConversion(req, event, "order");
|
|
145
|
+
track(req, event);
|
|
146
|
+
return NextResponse.next();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// 3. From the browser, through the first-party event route:
|
|
150
|
+
navigator.sendBeacon("/data/signup?conversion=1");
|
|
151
|
+
// …or with a body: { conversion: true, props: { plan: "pro" } }
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
If your code already knows the campaign (carried in a hidden form field, say),
|
|
155
|
+
pass `campaign: "…"` and the collector skips the lookup. Plain event beacons stay
|
|
156
|
+
identity-free unless you set `identifyEvents: true`, in which case they, too, are
|
|
157
|
+
attributed to the visitor's campaign.
|
|
158
|
+
|
|
120
159
|
## Options
|
|
121
160
|
|
|
122
161
|
```ts
|
|
@@ -128,6 +167,7 @@ createTrackingMiddleware({
|
|
|
128
167
|
trustedProxyHops: 1, // trust the right x-forwarded-for entry (see below)
|
|
129
168
|
eventRoute: "/data/:event", // first-party client-event route (see above)
|
|
130
169
|
resolveEvent: (req) => …, // …or fully custom event extraction (overrides eventRoute)
|
|
170
|
+
identifyEvents: false, // attach visitor identity to plain event beacons too (see Conversions)
|
|
131
171
|
eventEndpoint: process.env.TRACKKING_EVENT_ENDPOINT,
|
|
132
172
|
debug: process.env.NODE_ENV !== "production", // verbose logging (see below)
|
|
133
173
|
});
|
|
@@ -184,18 +224,27 @@ your server logs.
|
|
|
184
224
|
|
|
185
225
|
## What gets sent
|
|
186
226
|
|
|
187
|
-
**Page views** — a single JSON object: `{ path, host, referrer, userAgent,
|
|
188
|
-
headers }`. The collector hashes `ip` into a daily-rotating visitor id **and
|
|
227
|
+
**Page views** — a single JSON object: `{ path, query, host, referrer, userAgent,
|
|
228
|
+
ip, headers }`. The collector hashes `ip` into a daily-rotating visitor id **and
|
|
189
229
|
discards it** — the raw IP is never stored. The referrer is truncated to its
|
|
190
|
-
host. `
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
230
|
+
host. `query` is the landing query string; the collector derives the campaign
|
|
231
|
+
label from it and stores it with secret-looking parameter values (`token`,
|
|
232
|
+
`code`, `email`, …) and, by default, click-id values (`gclid`, `fbclid`, …)
|
|
233
|
+
redacted. `headers` is a small bag of low-entropy request headers
|
|
234
|
+
(`Accept-Language`, `Accept`, the `Sec-Fetch-*` fetch-metadata set, and
|
|
235
|
+
`Sec-CH-UA*` client hints) that help the collector tell real browsers from bots —
|
|
236
|
+
it is **not** a device fingerprint, and no cookies or client identifiers are
|
|
237
|
+
involved.
|
|
194
238
|
|
|
195
239
|
**Events** (if you use the event route) — `{ name, path, props }`, carrying no
|
|
196
240
|
visitor identity. `props` is an optional small, flat bag of non-PII metadata,
|
|
197
241
|
re-sanitized by the collector.
|
|
198
242
|
|
|
243
|
+
**Conversions** — the same, plus `conversion: true` and the request's `ip` +
|
|
244
|
+
`userAgent`, which the collector hashes into the daily visitor id (IP discarded)
|
|
245
|
+
to attribute the conversion to a campaign. Plain events carry identity only with
|
|
246
|
+
`identifyEvents: true`.
|
|
247
|
+
|
|
199
248
|
No personal identifiers are persisted.
|
|
200
249
|
|
|
201
250
|
## License
|
package/dist/index.cjs
CHANGED
|
@@ -27,7 +27,8 @@ __export(index_exports, {
|
|
|
27
27
|
handleEvent: () => handleEvent,
|
|
28
28
|
matchEventRoute: () => matchEventRoute,
|
|
29
29
|
resolveEventName: () => resolveEventName,
|
|
30
|
-
track: () => track
|
|
30
|
+
track: () => track,
|
|
31
|
+
trackConversion: () => trackConversion
|
|
31
32
|
});
|
|
32
33
|
module.exports = __toCommonJS(index_exports);
|
|
33
34
|
|
|
@@ -76,8 +77,13 @@ function parseEventBody(body) {
|
|
|
76
77
|
if (b.props && typeof b.props === "object" && !Array.isArray(b.props)) {
|
|
77
78
|
out.props = b.props;
|
|
78
79
|
}
|
|
80
|
+
if (b.conversion === true) out.conversion = true;
|
|
81
|
+
if (typeof b.campaign === "string" && b.campaign.trim()) out.campaign = b.campaign.trim();
|
|
79
82
|
return out;
|
|
80
83
|
}
|
|
84
|
+
function isTruthyParam(value) {
|
|
85
|
+
return value !== null && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
|
86
|
+
}
|
|
81
87
|
|
|
82
88
|
// src/middleware.ts
|
|
83
89
|
function trustedProxyHops(config) {
|
|
@@ -136,6 +142,9 @@ function buildEvent(req, config = {}) {
|
|
|
136
142
|
const ipFn = config.getClientIp ?? ((r) => getClientIp(r, trustedProxyHops(config)));
|
|
137
143
|
return {
|
|
138
144
|
path: req.nextUrl.pathname,
|
|
145
|
+
// The landing query string is what campaign attribution is derived from
|
|
146
|
+
// (utm_*, gclid, gad_*). The collector redacts secret-looking values.
|
|
147
|
+
query: req.nextUrl.search ? req.nextUrl.search.slice(1) : null,
|
|
139
148
|
host: req.nextUrl.host || req.headers.get("host"),
|
|
140
149
|
referrer: req.headers.get("referer"),
|
|
141
150
|
userAgent: req.headers.get("user-agent"),
|
|
@@ -206,6 +215,10 @@ function resolveEventName(req, config = {}) {
|
|
|
206
215
|
if (!template) return null;
|
|
207
216
|
return matchEventRoute(req.nextUrl.pathname, template);
|
|
208
217
|
}
|
|
218
|
+
function identityOf(req, config) {
|
|
219
|
+
const ipFn = config.getClientIp ?? ((r) => getClientIp(r, trustedProxyHops(config)));
|
|
220
|
+
return { ip: ipFn(req), userAgent: req.headers.get("user-agent") };
|
|
221
|
+
}
|
|
209
222
|
async function sendClientEvent(payload, endpoint, apiKey, debug) {
|
|
210
223
|
try {
|
|
211
224
|
const res = await fetch(endpoint, {
|
|
@@ -227,7 +240,8 @@ async function handleEvent(req, event, config = {}) {
|
|
|
227
240
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
228
241
|
body = await req.json().catch(() => null);
|
|
229
242
|
}
|
|
230
|
-
const { path, props } = parseEventBody(body);
|
|
243
|
+
const { path, props, conversion: bodyConversion, campaign } = parseEventBody(body);
|
|
244
|
+
const conversion = bodyConversion === true || isTruthyParam(req.nextUrl.searchParams.get("conversion"));
|
|
231
245
|
const referrerPath = (() => {
|
|
232
246
|
const ref = req.headers.get("referer");
|
|
233
247
|
if (!ref) return void 0;
|
|
@@ -238,6 +252,9 @@ async function handleEvent(req, event, config = {}) {
|
|
|
238
252
|
}
|
|
239
253
|
})();
|
|
240
254
|
const payload = { name, path: path ?? referrerPath, props };
|
|
255
|
+
if (conversion) payload.conversion = true;
|
|
256
|
+
if (campaign) payload.campaign = campaign;
|
|
257
|
+
if (conversion || config.identifyEvents) Object.assign(payload, identityOf(req, config));
|
|
241
258
|
const endpoint = eventEndpointFor(config);
|
|
242
259
|
const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
|
|
243
260
|
if (endpoint && apiKey) {
|
|
@@ -248,6 +265,27 @@ async function handleEvent(req, event, config = {}) {
|
|
|
248
265
|
}
|
|
249
266
|
return import_server.NextResponse.json({});
|
|
250
267
|
}
|
|
268
|
+
function trackConversion(req, event, name, options = {}) {
|
|
269
|
+
const { path, props, campaign, ...config } = options;
|
|
270
|
+
const endpoint = eventEndpointFor(config);
|
|
271
|
+
const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
|
|
272
|
+
const debug = isDebug(config);
|
|
273
|
+
const trimmed = name.trim();
|
|
274
|
+
if (!endpoint || !apiKey || !trimmed) {
|
|
275
|
+
if (debug) log("conversion skipped: not configured (endpoint/apiKey) or empty name");
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const payload = {
|
|
279
|
+
name: trimmed,
|
|
280
|
+
path: path ?? req.nextUrl.pathname,
|
|
281
|
+
props,
|
|
282
|
+
conversion: true,
|
|
283
|
+
...identityOf(req, config)
|
|
284
|
+
};
|
|
285
|
+
if (campaign) payload.campaign = campaign;
|
|
286
|
+
if (debug) log("tracking conversion:", payload);
|
|
287
|
+
event.waitUntil(sendClientEvent(payload, endpoint, apiKey, debug));
|
|
288
|
+
}
|
|
251
289
|
function createTrackingMiddleware(config = {}) {
|
|
252
290
|
return async function middleware(req, event) {
|
|
253
291
|
const eventResponse = await handleEvent(req, event, config);
|
|
@@ -265,5 +303,6 @@ function createTrackingMiddleware(config = {}) {
|
|
|
265
303
|
handleEvent,
|
|
266
304
|
matchEventRoute,
|
|
267
305
|
resolveEventName,
|
|
268
|
-
track
|
|
306
|
+
track,
|
|
307
|
+
trackConversion
|
|
269
308
|
});
|
package/dist/index.d.cts
CHANGED
|
@@ -9,6 +9,13 @@ import { NextRequest, NextFetchEvent, NextResponse } from 'next/server';
|
|
|
9
9
|
interface TrackkingEvent {
|
|
10
10
|
/** Pathname only (no query string), e.g. "/en/pricing". */
|
|
11
11
|
path: string;
|
|
12
|
+
/**
|
|
13
|
+
* Raw query string without the leading "?", or null. The collector derives the
|
|
14
|
+
* `campaign` label from it (utm_campaign, Google's gad_campaignid, a gclid, …)
|
|
15
|
+
* and stores it with secret-looking parameter values — and, by default,
|
|
16
|
+
* click-id values — redacted.
|
|
17
|
+
*/
|
|
18
|
+
query?: string | null;
|
|
12
19
|
/** Host of the tracked site, used by the collector to drop self-referrals. */
|
|
13
20
|
host: string | null;
|
|
14
21
|
/** Raw `Referer` header value, or null. Truncated to its host by the collector. */
|
|
@@ -67,6 +74,14 @@ interface TrackkingConfig {
|
|
|
67
74
|
* untouched. Overrides `eventRoute`.
|
|
68
75
|
*/
|
|
69
76
|
resolveEvent?: (req: NextRequest) => string | null;
|
|
77
|
+
/**
|
|
78
|
+
* Attach the visitor identity — the request's client IP and User-Agent, which
|
|
79
|
+
* the collector hashes into the same daily visitor id page views get and then
|
|
80
|
+
* discards — to *every* first-party event beacon, so plain events can be
|
|
81
|
+
* attributed to the campaign the visitor landed with. Default false: events
|
|
82
|
+
* stay identity-free unless they are conversions, which always carry it.
|
|
83
|
+
*/
|
|
84
|
+
identifyEvents?: boolean;
|
|
70
85
|
/** Override how the client IP is extracted from the request. */
|
|
71
86
|
getClientIp?: (req: NextRequest) => string | null;
|
|
72
87
|
/**
|
|
@@ -86,6 +101,19 @@ interface TrackkingConfig {
|
|
|
86
101
|
*/
|
|
87
102
|
debug?: boolean;
|
|
88
103
|
}
|
|
104
|
+
/** What a conversion records besides its name. */
|
|
105
|
+
interface ConversionOptions {
|
|
106
|
+
/** The route the conversion relates to. Defaults to the request's pathname. */
|
|
107
|
+
path?: string;
|
|
108
|
+
/** Small, flat bag of non-PII metadata (re-sanitized by the collector). */
|
|
109
|
+
props?: Record<string, string | number | boolean>;
|
|
110
|
+
/**
|
|
111
|
+
* Campaign label to record instead of letting the collector attribute the
|
|
112
|
+
* conversion from the visitor's landing page view — for a flow that carried
|
|
113
|
+
* the campaign itself, e.g. in a hidden form field.
|
|
114
|
+
*/
|
|
115
|
+
campaign?: string;
|
|
116
|
+
}
|
|
89
117
|
|
|
90
118
|
/**
|
|
91
119
|
* Best-effort client IP. Behind a proxy (Vercel, Caddy, nginx) the real visitor
|
|
@@ -123,6 +151,12 @@ declare function resolveEventName(req: NextRequest, config?: TrackkingConfig): s
|
|
|
123
151
|
* null when the request is not an event beacon — the caller should then proceed
|
|
124
152
|
* with normal page-view tracking.
|
|
125
153
|
*
|
|
154
|
+
* A beacon becomes a *conversion* when its body says `{ conversion: true }` or
|
|
155
|
+
* the URL carries `?conversion=1`. Conversions always travel with the visitor
|
|
156
|
+
* identity (client IP + User-Agent, hashed and discarded by the collector) so
|
|
157
|
+
* they can be attributed to the campaign the visitor landed with; plain events
|
|
158
|
+
* only do when `identifyEvents` is set.
|
|
159
|
+
*
|
|
126
160
|
* export async function middleware(req, event) {
|
|
127
161
|
* const res = await handleEvent(req, event, config);
|
|
128
162
|
* if (res) return res; // it was a /data/<event> beacon
|
|
@@ -131,6 +165,25 @@ declare function resolveEventName(req: NextRequest, config?: TrackkingConfig): s
|
|
|
131
165
|
* }
|
|
132
166
|
*/
|
|
133
167
|
declare function handleEvent(req: NextRequest, event: NextFetchEvent, config?: TrackkingConfig): Promise<NextResponse | null>;
|
|
168
|
+
/**
|
|
169
|
+
* Record a conversion for the current visitor from inside your middleware — for
|
|
170
|
+
* example when the "thank you" page is requested. The collector attributes it
|
|
171
|
+
* to the campaign the visitor landed with today (last click) through the same
|
|
172
|
+
* cookieless daily hash page views use; nothing is stored on the device.
|
|
173
|
+
* Fire-and-forget; never throws.
|
|
174
|
+
*
|
|
175
|
+
* export function middleware(req: NextRequest, event: NextFetchEvent) {
|
|
176
|
+
* if (req.nextUrl.pathname === "/order/confirmed") trackConversion(req, event, "order");
|
|
177
|
+
* track(req, event);
|
|
178
|
+
* return NextResponse.next();
|
|
179
|
+
* }
|
|
180
|
+
*
|
|
181
|
+
* A page request is repeatable (reloads, back button), so prefer recording
|
|
182
|
+
* conversions where the action actually completes — a route handler or server
|
|
183
|
+
* action — with `trackConversion` from @iorg1/trackking-generic, passing the
|
|
184
|
+
* request headers. This middleware variant is for when that isn't possible.
|
|
185
|
+
*/
|
|
186
|
+
declare function trackConversion(req: NextRequest, event: NextFetchEvent, name: string, options?: ConversionOptions & TrackkingConfig): void;
|
|
134
187
|
/**
|
|
135
188
|
* Returns a complete Next.js middleware function for the common case where
|
|
136
189
|
* tracking is the only thing your middleware does. It also serves the
|
|
@@ -155,4 +208,4 @@ declare function matchEventRoute(pathname: string, template: string): string | n
|
|
|
155
208
|
*/
|
|
156
209
|
declare function deriveEventEndpoint(base: string | undefined): string | undefined;
|
|
157
210
|
|
|
158
|
-
export { type TrackkingConfig, type TrackkingEvent, buildEvent, createTrackingMiddleware, deriveEventEndpoint, getClientIp, handleEvent, matchEventRoute, resolveEventName, track };
|
|
211
|
+
export { type ConversionOptions, type TrackkingConfig, type TrackkingEvent, buildEvent, createTrackingMiddleware, deriveEventEndpoint, getClientIp, handleEvent, matchEventRoute, resolveEventName, track, trackConversion };
|
package/dist/index.d.ts
CHANGED
|
@@ -9,6 +9,13 @@ import { NextRequest, NextFetchEvent, NextResponse } from 'next/server';
|
|
|
9
9
|
interface TrackkingEvent {
|
|
10
10
|
/** Pathname only (no query string), e.g. "/en/pricing". */
|
|
11
11
|
path: string;
|
|
12
|
+
/**
|
|
13
|
+
* Raw query string without the leading "?", or null. The collector derives the
|
|
14
|
+
* `campaign` label from it (utm_campaign, Google's gad_campaignid, a gclid, …)
|
|
15
|
+
* and stores it with secret-looking parameter values — and, by default,
|
|
16
|
+
* click-id values — redacted.
|
|
17
|
+
*/
|
|
18
|
+
query?: string | null;
|
|
12
19
|
/** Host of the tracked site, used by the collector to drop self-referrals. */
|
|
13
20
|
host: string | null;
|
|
14
21
|
/** Raw `Referer` header value, or null. Truncated to its host by the collector. */
|
|
@@ -67,6 +74,14 @@ interface TrackkingConfig {
|
|
|
67
74
|
* untouched. Overrides `eventRoute`.
|
|
68
75
|
*/
|
|
69
76
|
resolveEvent?: (req: NextRequest) => string | null;
|
|
77
|
+
/**
|
|
78
|
+
* Attach the visitor identity — the request's client IP and User-Agent, which
|
|
79
|
+
* the collector hashes into the same daily visitor id page views get and then
|
|
80
|
+
* discards — to *every* first-party event beacon, so plain events can be
|
|
81
|
+
* attributed to the campaign the visitor landed with. Default false: events
|
|
82
|
+
* stay identity-free unless they are conversions, which always carry it.
|
|
83
|
+
*/
|
|
84
|
+
identifyEvents?: boolean;
|
|
70
85
|
/** Override how the client IP is extracted from the request. */
|
|
71
86
|
getClientIp?: (req: NextRequest) => string | null;
|
|
72
87
|
/**
|
|
@@ -86,6 +101,19 @@ interface TrackkingConfig {
|
|
|
86
101
|
*/
|
|
87
102
|
debug?: boolean;
|
|
88
103
|
}
|
|
104
|
+
/** What a conversion records besides its name. */
|
|
105
|
+
interface ConversionOptions {
|
|
106
|
+
/** The route the conversion relates to. Defaults to the request's pathname. */
|
|
107
|
+
path?: string;
|
|
108
|
+
/** Small, flat bag of non-PII metadata (re-sanitized by the collector). */
|
|
109
|
+
props?: Record<string, string | number | boolean>;
|
|
110
|
+
/**
|
|
111
|
+
* Campaign label to record instead of letting the collector attribute the
|
|
112
|
+
* conversion from the visitor's landing page view — for a flow that carried
|
|
113
|
+
* the campaign itself, e.g. in a hidden form field.
|
|
114
|
+
*/
|
|
115
|
+
campaign?: string;
|
|
116
|
+
}
|
|
89
117
|
|
|
90
118
|
/**
|
|
91
119
|
* Best-effort client IP. Behind a proxy (Vercel, Caddy, nginx) the real visitor
|
|
@@ -123,6 +151,12 @@ declare function resolveEventName(req: NextRequest, config?: TrackkingConfig): s
|
|
|
123
151
|
* null when the request is not an event beacon — the caller should then proceed
|
|
124
152
|
* with normal page-view tracking.
|
|
125
153
|
*
|
|
154
|
+
* A beacon becomes a *conversion* when its body says `{ conversion: true }` or
|
|
155
|
+
* the URL carries `?conversion=1`. Conversions always travel with the visitor
|
|
156
|
+
* identity (client IP + User-Agent, hashed and discarded by the collector) so
|
|
157
|
+
* they can be attributed to the campaign the visitor landed with; plain events
|
|
158
|
+
* only do when `identifyEvents` is set.
|
|
159
|
+
*
|
|
126
160
|
* export async function middleware(req, event) {
|
|
127
161
|
* const res = await handleEvent(req, event, config);
|
|
128
162
|
* if (res) return res; // it was a /data/<event> beacon
|
|
@@ -131,6 +165,25 @@ declare function resolveEventName(req: NextRequest, config?: TrackkingConfig): s
|
|
|
131
165
|
* }
|
|
132
166
|
*/
|
|
133
167
|
declare function handleEvent(req: NextRequest, event: NextFetchEvent, config?: TrackkingConfig): Promise<NextResponse | null>;
|
|
168
|
+
/**
|
|
169
|
+
* Record a conversion for the current visitor from inside your middleware — for
|
|
170
|
+
* example when the "thank you" page is requested. The collector attributes it
|
|
171
|
+
* to the campaign the visitor landed with today (last click) through the same
|
|
172
|
+
* cookieless daily hash page views use; nothing is stored on the device.
|
|
173
|
+
* Fire-and-forget; never throws.
|
|
174
|
+
*
|
|
175
|
+
* export function middleware(req: NextRequest, event: NextFetchEvent) {
|
|
176
|
+
* if (req.nextUrl.pathname === "/order/confirmed") trackConversion(req, event, "order");
|
|
177
|
+
* track(req, event);
|
|
178
|
+
* return NextResponse.next();
|
|
179
|
+
* }
|
|
180
|
+
*
|
|
181
|
+
* A page request is repeatable (reloads, back button), so prefer recording
|
|
182
|
+
* conversions where the action actually completes — a route handler or server
|
|
183
|
+
* action — with `trackConversion` from @iorg1/trackking-generic, passing the
|
|
184
|
+
* request headers. This middleware variant is for when that isn't possible.
|
|
185
|
+
*/
|
|
186
|
+
declare function trackConversion(req: NextRequest, event: NextFetchEvent, name: string, options?: ConversionOptions & TrackkingConfig): void;
|
|
134
187
|
/**
|
|
135
188
|
* Returns a complete Next.js middleware function for the common case where
|
|
136
189
|
* tracking is the only thing your middleware does. It also serves the
|
|
@@ -155,4 +208,4 @@ declare function matchEventRoute(pathname: string, template: string): string | n
|
|
|
155
208
|
*/
|
|
156
209
|
declare function deriveEventEndpoint(base: string | undefined): string | undefined;
|
|
157
210
|
|
|
158
|
-
export { type TrackkingConfig, type TrackkingEvent, buildEvent, createTrackingMiddleware, deriveEventEndpoint, getClientIp, handleEvent, matchEventRoute, resolveEventName, track };
|
|
211
|
+
export { type ConversionOptions, type TrackkingConfig, type TrackkingEvent, buildEvent, createTrackingMiddleware, deriveEventEndpoint, getClientIp, handleEvent, matchEventRoute, resolveEventName, track, trackConversion };
|
package/dist/index.js
CHANGED
|
@@ -43,8 +43,13 @@ function parseEventBody(body) {
|
|
|
43
43
|
if (b.props && typeof b.props === "object" && !Array.isArray(b.props)) {
|
|
44
44
|
out.props = b.props;
|
|
45
45
|
}
|
|
46
|
+
if (b.conversion === true) out.conversion = true;
|
|
47
|
+
if (typeof b.campaign === "string" && b.campaign.trim()) out.campaign = b.campaign.trim();
|
|
46
48
|
return out;
|
|
47
49
|
}
|
|
50
|
+
function isTruthyParam(value) {
|
|
51
|
+
return value !== null && value !== "" && value !== "0" && value.toLowerCase() !== "false";
|
|
52
|
+
}
|
|
48
53
|
|
|
49
54
|
// src/middleware.ts
|
|
50
55
|
function trustedProxyHops(config) {
|
|
@@ -103,6 +108,9 @@ function buildEvent(req, config = {}) {
|
|
|
103
108
|
const ipFn = config.getClientIp ?? ((r) => getClientIp(r, trustedProxyHops(config)));
|
|
104
109
|
return {
|
|
105
110
|
path: req.nextUrl.pathname,
|
|
111
|
+
// The landing query string is what campaign attribution is derived from
|
|
112
|
+
// (utm_*, gclid, gad_*). The collector redacts secret-looking values.
|
|
113
|
+
query: req.nextUrl.search ? req.nextUrl.search.slice(1) : null,
|
|
106
114
|
host: req.nextUrl.host || req.headers.get("host"),
|
|
107
115
|
referrer: req.headers.get("referer"),
|
|
108
116
|
userAgent: req.headers.get("user-agent"),
|
|
@@ -173,6 +181,10 @@ function resolveEventName(req, config = {}) {
|
|
|
173
181
|
if (!template) return null;
|
|
174
182
|
return matchEventRoute(req.nextUrl.pathname, template);
|
|
175
183
|
}
|
|
184
|
+
function identityOf(req, config) {
|
|
185
|
+
const ipFn = config.getClientIp ?? ((r) => getClientIp(r, trustedProxyHops(config)));
|
|
186
|
+
return { ip: ipFn(req), userAgent: req.headers.get("user-agent") };
|
|
187
|
+
}
|
|
176
188
|
async function sendClientEvent(payload, endpoint, apiKey, debug) {
|
|
177
189
|
try {
|
|
178
190
|
const res = await fetch(endpoint, {
|
|
@@ -194,7 +206,8 @@ async function handleEvent(req, event, config = {}) {
|
|
|
194
206
|
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
195
207
|
body = await req.json().catch(() => null);
|
|
196
208
|
}
|
|
197
|
-
const { path, props } = parseEventBody(body);
|
|
209
|
+
const { path, props, conversion: bodyConversion, campaign } = parseEventBody(body);
|
|
210
|
+
const conversion = bodyConversion === true || isTruthyParam(req.nextUrl.searchParams.get("conversion"));
|
|
198
211
|
const referrerPath = (() => {
|
|
199
212
|
const ref = req.headers.get("referer");
|
|
200
213
|
if (!ref) return void 0;
|
|
@@ -205,6 +218,9 @@ async function handleEvent(req, event, config = {}) {
|
|
|
205
218
|
}
|
|
206
219
|
})();
|
|
207
220
|
const payload = { name, path: path ?? referrerPath, props };
|
|
221
|
+
if (conversion) payload.conversion = true;
|
|
222
|
+
if (campaign) payload.campaign = campaign;
|
|
223
|
+
if (conversion || config.identifyEvents) Object.assign(payload, identityOf(req, config));
|
|
208
224
|
const endpoint = eventEndpointFor(config);
|
|
209
225
|
const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
|
|
210
226
|
if (endpoint && apiKey) {
|
|
@@ -215,6 +231,27 @@ async function handleEvent(req, event, config = {}) {
|
|
|
215
231
|
}
|
|
216
232
|
return NextResponse.json({});
|
|
217
233
|
}
|
|
234
|
+
function trackConversion(req, event, name, options = {}) {
|
|
235
|
+
const { path, props, campaign, ...config } = options;
|
|
236
|
+
const endpoint = eventEndpointFor(config);
|
|
237
|
+
const apiKey = config.apiKey ?? process.env.TRACKKING_API_KEY;
|
|
238
|
+
const debug = isDebug(config);
|
|
239
|
+
const trimmed = name.trim();
|
|
240
|
+
if (!endpoint || !apiKey || !trimmed) {
|
|
241
|
+
if (debug) log("conversion skipped: not configured (endpoint/apiKey) or empty name");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const payload = {
|
|
245
|
+
name: trimmed,
|
|
246
|
+
path: path ?? req.nextUrl.pathname,
|
|
247
|
+
props,
|
|
248
|
+
conversion: true,
|
|
249
|
+
...identityOf(req, config)
|
|
250
|
+
};
|
|
251
|
+
if (campaign) payload.campaign = campaign;
|
|
252
|
+
if (debug) log("tracking conversion:", payload);
|
|
253
|
+
event.waitUntil(sendClientEvent(payload, endpoint, apiKey, debug));
|
|
254
|
+
}
|
|
218
255
|
function createTrackingMiddleware(config = {}) {
|
|
219
256
|
return async function middleware(req, event) {
|
|
220
257
|
const eventResponse = await handleEvent(req, event, config);
|
|
@@ -231,5 +268,6 @@ export {
|
|
|
231
268
|
handleEvent,
|
|
232
269
|
matchEventRoute,
|
|
233
270
|
resolveEventName,
|
|
234
|
-
track
|
|
271
|
+
track,
|
|
272
|
+
trackConversion
|
|
235
273
|
};
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iorg1/trackking-next",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/iorg1/trackking.git",
|
|
7
7
|
"directory": "packages/middleware"
|
|
8
8
|
},
|
|
9
|
-
"description": "Cookieless, server-side page-view tracking for Next.js. Drop-in middleware that beacons visits to a Trackking collector
|
|
9
|
+
"description": "Cookieless, server-side page-view tracking for Next.js. Drop-in middleware that beacons visits to a Trackking collector \u2014 no cookies, no client-side script.",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"type": "module",
|
|
12
12
|
"main": "./dist/index.cjs",
|