@katrinalaszlo/track 0.5.0 → 0.7.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 CHANGED
@@ -29,9 +29,9 @@ const { agentTraffic } = require("@katrinalaszlo/track"); // or: import { agentT
29
29
  app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id }));
30
30
  ```
31
31
 
32
- Register it right after creating the app, before auth and routes. `accountIdOf` returns your customer's id (hashed before it leaves your server), or undefined. Optional `errorOf(req, res)` returns error text for failed responses (400 and above, up to 200 characters), so two errors with the same status show separately. Keys come from `AGENT_TRAFFIC_KEY`, `AGENT_TRAFFIC_PROJECT_ID` and `AGENT_TRAFFIC_ACCOUNT_SALT` in the environment or `.env`; get them with the signup in [SKILL.md](skills/agent-traffic/SKILL.md).
32
+ Register it right after creating the app, before auth and routes. `accountIdOf` returns your customer's id (hashed before it leaves your server), or undefined. Optional `errorOf(req, res)` returns error text for failed responses (400 and above, up to 200 characters), so two errors with the same status show separately. It needs one variable, `AGENT_TRAFFIC_KEY`, in the environment or `.env`: the key belongs to one project, and events go there. Get it with `npx agent-led-growth connect`, which opens your browser to sign in and writes the key to `.env` for you, or with the signup in [SKILL.md](skills/agent-traffic/SKILL.md). `AGENT_TRAFFIC_ACCOUNT_SALT` is needed when you set `accountIdOf` or call `recordOutcome`. `AGENT_TRAFFIC_PROJECT_ID` is optional: set, it is sent with each batch as before.
33
33
 
34
- Per request it sends the route pattern, method, status code, the `User-Agent`, `Signature-Agent` and `Accept-Language` headers, the time, the hashed customer id, the journey key if the request carried one (see below), and the text `errorOf` returns if you set it. When an agent signed its request (Web Bot Auth, `Signature` and `Signature-Input` headers present) it also sends those two headers, the request's full URL, and the other headers the signature covers, so the signature can be checked; never `Authorization` or `Cookie`, whatever the signer covered. Otherwise no bodies, no other headers, no IPs. To see which agents lead to signups and payments, call `traffic.recordOutcome({ accountId, name: "paid", value: 1999, currency: "USD", idempotencyKey: paymentEvent.id })` from your payment webhook (and `name: "signup"` from your signup handler): the outcome joins the agent session of the same customer. Events go out in batches once a second (at once on Vercel, Lambda and Netlify), and it never blocks or fails a request. No dependencies; TypeScript types included.
34
+ Per request it sends the route pattern, the path (see below), method, status code, the `User-Agent`, `Signature-Agent` and `Accept-Language` headers, the time, the hashed customer id, the journey key if the request carried one (see below), and the text `errorOf` returns if you set it. When an agent signed its request (Web Bot Auth, `Signature` and `Signature-Input` headers present) it also sends those two headers, the request's full URL, and the other headers the signature covers, so the signature can be checked; never `Authorization` or `Cookie`, whatever the signer covered. Otherwise no bodies, no other headers, no IPs. To see which agents lead to signups and payments, call `traffic.recordOutcome({ accountId, name: "paid", value: 1999, currency: "USD", idempotencyKey: paymentEvent.id })` from your payment webhook (and `name: "signup"` from your signup handler): the outcome joins the agent session of the same customer. Events go out in batches once a second (at once on Vercel, Lambda and Netlify), and it never fails a request. The one request it holds up is a person opening a handoff link (below), for at most 2 seconds. No dependencies; TypeScript types included.
35
35
 
36
36
  ### Journey keys
37
37
 
@@ -45,8 +45,32 @@ app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id, issueJourneyKeys
45
45
 
46
46
  Then every response carries the visitor's key: the one the request sent, or a new one (`j_` plus 32 hex characters) when it sent none. It goes out as the `Track-Journey` response header and as `Set-Cookie: track_j=<key>; Path=/; Max-Age=1209600; SameSite=Lax; Secure`, with `Cache-Control: private` so a shared cache never serves one visitor's key to another. It is off by default because it sets a cookie on your responses and changes their caching: that can need a line in your cookie policy or consent banner, and a CDN will stop caching those responses. Both are set before your routes run, so a route that sets its own `Cache-Control` replaces `private` (keep it private on any response that carries a key), and a raw `res.setHeader("Set-Cookie", ...)` replaces the `track_j` cookie (Express's `res.cookie` and express-session add to it instead). The request that receives a new key is sent with that key as `journey_id` and `journey_source: "issued"`, so it is the first step of the journey.
47
47
 
48
+ ### Handoff links
49
+
50
+ When an agent cannot finish on its own (a person has to approve a payment or verify an email) and your server sends that person a link, add a handoff token to the link so the person's actions count toward the agent's journey. Their checkout is then credited to the agent that brought them.
51
+
52
+ ```js
53
+ const traffic = agentTraffic({ accountIdOf: (req, res) => req.user?.id, issueJourneyKeys: true });
54
+ app.use(traffic);
55
+
56
+ app.post("/v1/checkout-links", async (req, res) => {
57
+ const link = await traffic.handoffUrl("https://example.com/checkout?plan=pro", { req });
58
+ res.json({ approve_at: link }); // https://example.com/checkout?plan=pro&track_h=h_...
59
+ });
60
+ ```
61
+
62
+ `handoffUrl(link, { req })` asks Track for a one-time token for the journey of `req` (the agent's request, which must carry a journey key, or have one issued) and resolves with `link` plus `?track_h=<token>`. Pass `{ journeyId }` instead when you have the key without the request. It throws at once when `link` is not an absolute URL or there is no journey key. If Track cannot mint a token, it logs why and resolves with `link` unchanged, so the person still gets a working link that just does not count toward the journey.
63
+
64
+ When the person opens the link, the middleware sees `?track_h=` and, before your routes run, sends a `handoff_opened` event carrying the token and waits up to 2 seconds for Track's answer. If Track linked it, the request's own event goes on the agent's journey (`journey_source: "issued"`), and with `issueJourneyKeys` on the response sets the person's `track_j` cookie to the agent's journey, so their sign-in and checkout land on it too. With `issueJourneyKeys` off no cookie is set: read `traffic.journeyIdOf(req)` and set the `track_j` cookie (or send the `Track-Journey` header) yourself. A token links once and for 24 hours; a used, expired or unknown token, or Track not answering in time, leaves the person on their own journey and logs one line. A token is never written to a log, and `?track_h=` never reaches `path`. Needs a Track host that takes `handoff_token` (an older one logs that it does not).
65
+
48
66
  Event names use the matched route's pattern and exclude router mount prefixes, which can contain raw IDs. A router mounted at `/customers/:customerId` with route `/orders/:orderId` records `orders_orderid` for every customer. Routes with the same pattern under different mounts share an event name.
49
67
 
68
+ ### Path
69
+
70
+ Each event also carries `path`, which Track's Paths and Attribution reports use: the path the app received, router mount prefix included, cut at the first `?` or `#`. `GET https://api.example.com/v1/users?api_key=123` is sent as `/v1/users`: never the domain, and never the query or fragment, so an API key or a `track_j` value in the query is neither sent nor logged. Segments are sent as they are, not percent-decoded, so an id in the path is sent: `/v1/users/123` arrives as `/v1/users/123`. Event names stay route patterns (`users_id`), so reports grouped by event name do not split by id; there is no option yet to leave `path` out of the event. A path that does not start with `/` or is longer than 2048 characters is left out of the event.
71
+
72
+ For a signed request (Web Bot Auth), the signature material still carries the full URL with its query, because the signature covers it and cannot be checked without it. It goes to Track ingest only, never to a log.
73
+
50
74
  What each event contains, and the raw HTTP API for other stacks: [alg.katrinalaszlo.com/docs](https://alg.katrinalaszlo.com/docs).
51
75
 
52
76
  ## Tests
package/index.d.ts CHANGED
@@ -7,7 +7,7 @@ export interface AgentTrafficOptions {
7
7
  errorOf?: (req: any, res: any) => string | null | undefined;
8
8
  /** Defaults to AGENT_TRAFFIC_KEY from the environment or .env. */
9
9
  key?: string;
10
- /** Defaults to AGENT_TRAFFIC_PROJECT_ID from the environment or .env. */
10
+ /** Defaults to AGENT_TRAFFIC_PROJECT_ID from the environment or .env. Optional: without it, events go to the key's own project. */
11
11
  projectId?: string;
12
12
  /** Defaults to AGENT_TRAFFIC_ACCOUNT_SALT from the environment or .env. Required when accountIdOf is set. */
13
13
  salt?: string;
@@ -29,10 +29,20 @@ export const JOURNEY_HEADER: string;
29
29
  export interface JourneyFields {
30
30
  /** 8 to 128 characters of A-Z, a-z, 0-9, _ and -. */
31
31
  journey_id: string;
32
- /** "issued" when issueJourneyKeys minted the key on this request's response. */
32
+ /** "issued" when issueJourneyKeys minted the key on this request's response, or a handoff link gave the request the agent's journey. */
33
33
  journey_source: "header" | "query" | "cookie" | "issued";
34
34
  }
35
35
 
36
+ /** The query parameter a handoff link carries: "track_h". */
37
+ export const HANDOFF_PARAM: string;
38
+
39
+ export interface HandoffOptions {
40
+ /** A request the middleware saw: its journey key (after any handoff) is the agent's journey to hand off. */
41
+ req?: any;
42
+ /** The agent's journey key, when you have it without a request. Wins over req. */
43
+ journeyId?: string;
44
+ }
45
+
36
46
  export interface OutcomeInput {
37
47
  /** The same customer id accountIdOf returns for this customer's requests. Hashed with the salt before it is sent. */
38
48
  accountId: string | number;
@@ -51,8 +61,22 @@ export interface AgentTrafficMiddleware {
51
61
  (req: any, res: any, next: (err?: unknown) => void): void;
52
62
  /** Sends one outcome at once. Throws synchronously on bad input; the returned promise settles when the send finished. */
53
63
  recordOutcome(outcome: OutcomeInput): Promise<void>;
64
+ /**
65
+ * Resolves with `link` plus `?track_h=<token>`, a one-time token (24 hours) that puts the person who opens the link
66
+ * on the agent's journey. Throws synchronously when link is not absolute or there is no valid journey key. When Track
67
+ * cannot mint a token, logs it and resolves with `link` unchanged.
68
+ */
69
+ handoffUrl(link: string, options: HandoffOptions): Promise<string>;
70
+ /** The journey key the middleware recorded this request with (the agent's, when a handoff link linked it), or undefined. */
71
+ journeyIdOf(req: any): string | undefined;
54
72
  }
55
73
 
74
+ /**
75
+ * Each event the middleware records carries `path`: the request path as the app received it (router mount prefix
76
+ * included, not percent-decoded), cut at the first "?" or "#", so never the domain or the query. Left out when it does
77
+ * not start with "/" or is longer than 2048 characters. Ids in path segments (/v1/users/123) are sent as they are.
78
+ * A request with a well-formed ?track_h= waits up to 2 seconds for Track to link it, before your routes run.
79
+ */
56
80
  export function agentTraffic(options?: AgentTrafficOptions): AgentTrafficMiddleware;
57
81
 
58
82
  /** The event name for a request: its route pattern without router mount prefixes, version and "api" segments dropped, the method added for anything but GET. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@katrinalaszlo/track",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Express middleware that shows which AI agents call your API, how their sessions go, and which errors end them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -11,14 +11,21 @@
11
11
  // accountIdOf returns your customer's id for the request (called with req and res after the response, so values your
12
12
  // auth set on either, such as req.user or res.locals.user, are there), or undefined. errorOf, optional, returns the
13
13
  // error text for a failed response (status 400 and above), such as an error code your handler put in res.locals, so
14
- // two errors with the same status show as different rows in Fix next. AGENT_TRAFFIC_KEY,
15
- // AGENT_TRAFFIC_PROJECT_ID and AGENT_TRAFFIC_ACCOUNT_SALT come from the environment, or from a .env in the working
16
- // directory, this file's folder or any folder above it, when the environment lacks them; only AGENT_TRAFFIC_ lines are
17
- // read, nothing else in .env is touched. issueJourneyKeys, optional and off by default, gives each visitor a journey key
14
+ // two errors with the same status show as different rows in Fix next. AGENT_TRAFFIC_KEY (required),
15
+ // AGENT_TRAFFIC_ACCOUNT_SALT and AGENT_TRAFFIC_PROJECT_ID (optional: without it the key's own project is used) come from
16
+ // the environment, or from a .env in the working directory, this file's folder or any folder above it, when the
17
+ // environment lacks them; only AGENT_TRAFFIC_ lines are read, nothing else in .env is touched. issueJourneyKeys, optional and off by default, gives each visitor a journey key
18
18
  // on the response (a header and a track_j cookie) so its later requests join one journey.
19
19
  //
20
- // Register it right after creating the app, before auth and routes. It never blocks or fails a request: events are
21
- // queued, sent in batches of up to 25 once a second, and a failed batch is logged and retried with the same keys.
20
+ // Handoffs: when an agent sends a person a link to finish (approve a payment, verify an email), add a one-time token so
21
+ // the person's actions count toward the agent's journey:
22
+ // const link = await traffic.handoffUrl("https://example.com/checkout?plan=pro", { req }); // adds ?track_h=<token>
23
+ // A request that arrives with ?track_h= waits up to 2 seconds for Track to link it to the agent's journey, records a
24
+ // handoff_opened event, and, with issueJourneyKeys on, sets the person's track_j cookie to the agent's journey.
25
+ //
26
+ // Register it right after creating the app, before auth and routes. It never fails a request, and blocks none but a
27
+ // request carrying ?track_h=: events are queued, sent in batches of up to 25 once a second, and a failed batch is logged
28
+ // and retried with the same keys.
22
29
  const { createHmac, randomBytes, randomUUID } = require("node:crypto");
23
30
  const { existsSync, readFileSync } = require("node:fs");
24
31
  const { dirname, join } = require("node:path");
@@ -30,12 +37,19 @@ const JOURNEY_PARAM = "track_j";
30
37
  const JOURNEY_KEY = /^[A-Za-z0-9_-]{8,128}$/;
31
38
  // 14 days. Secure so the key never travels over plain http; Lax so it comes along when a person follows a link in.
32
39
  const JOURNEY_COOKIE_ATTRIBUTES = "Path=/; Max-Age=1209600; SameSite=Lax; Secure";
40
+ // The query parameter a handoff link carries, and the token shape Track mints. A token is a secret in transit: it goes
41
+ // to Track only, never into a log or an error message.
42
+ const HANDOFF_PARAM = "track_h";
43
+ const HANDOFF_TOKEN = /^h_[A-Za-z0-9_-]{32,64}$/;
44
+ // How long a request with ?track_h= waits for Track before it carries on unlinked.
45
+ const HANDOFF_WAIT_MS = 2000;
33
46
 
34
47
  const BATCH_MAX = 25;
35
48
  const OUTCOME_NAMES = ["signup", "key_created", "activation", "paid", "custom"];
36
49
  const FLUSH_MS = 1000;
37
50
  const RETRIES_MAX = 3;
38
51
  const ERROR_MAX = 200;
52
+ const PATH_MAX = 2048;
39
53
  // On serverless hosts the process can freeze as soon as the response is sent, so a queued batch might never go out.
40
54
  // There each event is sent at once, and on Vercel the send is handed to waitUntil so the function stays up until it
41
55
  // is done (the request context @vercel/functions reads, without depending on it).
@@ -116,13 +130,25 @@ function signatureMaterial(req) {
116
130
  };
117
131
  }
118
132
 
133
+ // The path the app received, mount prefix included, cut at the first ? or #: never the domain or the query, which
134
+ // can hold an API key or a journey key. Not percent-decoded. Anything that does not start with / or is longer than
135
+ // ingest accepts is left out rather than cut.
136
+ function requestPath(req) {
137
+ const url = req.originalUrl ?? req.url;
138
+ if (typeof url !== "string") return undefined;
139
+ const path = url.split(/[?#]/)[0];
140
+ if (!path.startsWith("/") || path.length > PATH_MAX) return undefined;
141
+ return path;
142
+ }
143
+
119
144
  // A journey key ties requests from one visitor together across sessions: the journey header, else ?track_j= in the URL,
120
145
  // else the track_j cookie. The first value that fits the ingest rule wins; one that does not is ignored, never sent.
121
146
  function journeyKeyOf(req) {
122
147
  const header = req.headers[JOURNEY_HEADER.toLowerCase()];
123
148
  if (typeof header === "string" && JOURNEY_KEY.test(header)) return { journey_id: header, journey_source: "header" };
124
149
 
125
- const url = req.originalUrl ?? req.url ?? "";
150
+ const rawUrl = req.originalUrl ?? req.url;
151
+ const url = typeof rawUrl === "string" ? rawUrl : "";
126
152
  const queryStart = url.indexOf("?");
127
153
  if (queryStart !== -1) {
128
154
  const fromQuery = new URLSearchParams(url.slice(queryStart + 1)).getAll(JOURNEY_PARAM).find((value) => JOURNEY_KEY.test(value));
@@ -141,6 +167,15 @@ function journeyKeyOf(req) {
141
167
  return undefined;
142
168
  }
143
169
 
170
+ // The ?track_h= value when it has the token shape, else undefined.
171
+ function handoffTokenOf(req) {
172
+ const rawUrl = req.originalUrl ?? req.url;
173
+ const url = typeof rawUrl === "string" ? rawUrl : "";
174
+ const queryStart = url.indexOf("?");
175
+ if (queryStart === -1) return undefined;
176
+ return new URLSearchParams(url.slice(queryStart + 1)).getAll(HANDOFF_PARAM).find((value) => HANDOFF_TOKEN.test(value));
177
+ }
178
+
144
179
  // Cache-Control: private so a shared cache never hands one visitor's key to another. A Set-Cookie an earlier
145
180
  // middleware added is kept.
146
181
  function setJourneyKey(res, journeyId) {
@@ -157,9 +192,13 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
157
192
  projectId ??= env.AGENT_TRAFFIC_PROJECT_ID;
158
193
  salt ??= env.AGENT_TRAFFIC_ACCOUNT_SALT;
159
194
  endpoint ??= env.AGENT_TRAFFIC_ENDPOINT ?? "https://alg.katrinalaszlo.com";
160
- if (!key || !projectId) throw new Error("agentTraffic needs AGENT_TRAFFIC_KEY and AGENT_TRAFFIC_PROJECT_ID, in the environment or in .env.");
195
+ if (!key) throw new Error("agentTraffic needs AGENT_TRAFFIC_KEY, in the environment or in .env. Run `npx agent-led-growth connect` to get one.");
161
196
  if (accountIdOf && !salt) throw new Error("agentTraffic needs AGENT_TRAFFIC_ACCOUNT_SALT, in the environment or in .env, to hash what accountIdOf returns.");
162
- const url = `${endpoint}/v1/events?project_id=${encodeURIComponent(projectId)}`;
197
+ const projectQuery = projectId ? `?project_id=${encodeURIComponent(projectId)}` : "";
198
+ const url = `${endpoint}/v1/events${projectQuery}`;
199
+ const handoffsUrl = `${endpoint}/v1/handoffs${projectQuery}`;
200
+ // The journey key each request was recorded with, after a handoff link changed it; read by journeyIdOf and handoffUrl.
201
+ const journeys = new WeakMap();
163
202
  let queue = [];
164
203
  let timer = null;
165
204
  const warned = new Set();
@@ -190,7 +229,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
190
229
  byReason.set(label, (byReason.get(label) ?? 0) + 1);
191
230
  }
192
231
  for (const [label, n] of byReason) {
193
- const hint = label.startsWith("unknown_field") ? ". The agent traffic host does not know this field yet: it is older than this package (0.5.0). Upgrade the host, or pin @katrinalaszlo/track to 0.4.x. https://alg.katrinalaszlo.com/docs#event-fields" : "";
232
+ const hint = label.startsWith("unknown_field") ? ". The agent traffic host does not know this field yet: it is older than this package (0.7.0). Upgrade the host, or pin @katrinalaszlo/track to 0.5.x. https://alg.katrinalaszlo.com/docs#event-fields" : "";
194
233
  console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
195
234
  }
196
235
  return;
@@ -249,36 +288,116 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
249
288
  return sending;
250
289
  }
251
290
 
291
+ // The journey key the middleware recorded req with (the agent's journey when a handoff link linked it), or undefined.
292
+ function journeyIdOf(req) {
293
+ return journeys.get(req)?.journey_id;
294
+ }
295
+
296
+ // Resolves with link plus ?track_h=<token> for the agent's journey: journeyId, else the journey of req. Bad input
297
+ // throws synchronously. When Track cannot mint a token the failure is logged and the promise resolves with link
298
+ // unchanged, so the person still gets a working link; it just will not count toward the agent's journey.
299
+ function handoffUrl(link, { req, journeyId } = {}) {
300
+ let target;
301
+ try {
302
+ target = new URL(link);
303
+ } catch {
304
+ throw new TypeError("handoffUrl: link must be an absolute URL, such as https://example.com/checkout, because it is sent to a person.");
305
+ }
306
+ const journey = journeyId ?? (req ? journeyIdOf(req) ?? journeyKeyOf(req)?.journey_id : undefined);
307
+ if (typeof journey !== "string" || !JOURNEY_KEY.test(journey)) throw new TypeError("handoffUrl: no journey to hand off. Pass { req } for a request that carried a journey key (Track-Journey header, ?track_j= or the track_j cookie, or issueJourneyKeys on), or { journeyId }. docs: https://alg.katrinalaszlo.com/docs#handing-off-to-a-person");
308
+ return (async () => {
309
+ try {
310
+ const res = await fetch(handoffsUrl, {
311
+ method: "POST",
312
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
313
+ body: JSON.stringify({ journey_id: journey }),
314
+ });
315
+ const body = await res.json();
316
+ if (res.status !== 201) {
317
+ console.error(`agent-traffic: handoff link sent without a token: ${res.status} ${body.error}: ${body.message}`);
318
+ return link;
319
+ }
320
+ target.searchParams.set(HANDOFF_PARAM, body.token);
321
+ return target.toString();
322
+ } catch (err) {
323
+ console.error(`agent-traffic: handoff link sent without a token: ${err.message}`);
324
+ return link;
325
+ }
326
+ })();
327
+ }
328
+
329
+ // Sends the event of a request that arrived with a handoff token, at once, and resolves with the agent's journey key
330
+ // when Track linked it, else null. Never rejects: a failure is logged (without the token) and the request carries on.
331
+ async function linkHandoff(event) {
332
+ try {
333
+ const res = await fetch(url, {
334
+ method: "POST",
335
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
336
+ body: JSON.stringify([event]),
337
+ signal: AbortSignal.timeout(HANDOFF_WAIT_MS),
338
+ });
339
+ const body = await res.json();
340
+ if (res.status !== 202) {
341
+ console.error(`agent-traffic: handoff not linked: ${res.status} ${body.error}: ${body.message}`);
342
+ return null;
343
+ }
344
+ for (const entry of body.rejected) {
345
+ const hint = entry.reason === "unknown_field" ? ". The agent traffic host is older than this package (0.7.0) and does not take handoff tokens yet." : "";
346
+ console.error(`agent-traffic: handoff not linked: event rejected: ${entry.reason}${hint}`);
347
+ }
348
+ for (const warning of body.event_warnings ?? []) console.error(`agent-traffic: handoff not linked: ${warning.message}`);
349
+ return body.journeys?.[0]?.journey_id ?? null;
350
+ } catch (err) {
351
+ console.error(`agent-traffic: handoff not linked: ${err.message}`);
352
+ return null;
353
+ }
354
+ }
355
+
252
356
  function agentTrafficMiddleware(req, res, next) {
253
357
  const occurredAt = new Date().toISOString();
254
358
  // Read while the request is current: the context is per request.
255
359
  const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
256
360
  let journey = journeyKeyOf(req);
257
361
  if (issueJourneyKeys && !journey) journey = { journey_id: `j_${randomBytes(16).toString("hex")}`, journey_source: "issued" };
258
- if (issueJourneyKeys) setJourneyKey(res, journey.journey_id);
259
- res.on("finish", () => {
260
- // An exception here would be thrown outside the request, so it is logged instead: the event is lost, the
261
- // server is not.
262
- let event;
263
- try {
264
- event = eventOf(req, res, occurredAt, journey);
265
- } catch (err) {
266
- // The path only: the query string can hold a journey key, which never goes in a log.
267
- console.error(`agent-traffic: event not recorded for ${req.method} ${(req.originalUrl ?? req.url ?? "").split("?")[0]}: ${err.stack}`);
268
- return;
269
- }
270
- if (SERVERLESS || vercel?.waitUntil) {
271
- const sending = send([event], 1);
272
- vercel?.waitUntil?.(sending);
273
- return;
274
- }
275
- queue.push(event);
276
- if (queue.length >= BATCH_MAX) flush();
277
- else if (!timer) timer = setTimeout(flush, FLUSH_MS).unref();
278
- });
279
- next();
362
+ const handoffToken = handoffTokenOf(req);
363
+ if (!handoffToken) return record(journey);
364
+ // A handoff link: Track has to answer before the response, so the person's cookie can carry the agent's journey.
365
+ // The request's own event is still sent after the response, on the agent's journey with journey_source "issued":
366
+ // the key was given on this request, not read from it. Ingest takes "handoff" from no caller.
367
+ linkHandoff(handoffEventOf(req, occurredAt, journey, handoffToken)).then((agentJourney) => {
368
+ if (agentJourney) journey = { journey_id: agentJourney, journey_source: "issued" };
369
+ record(journey);
370
+ }).catch(next);
371
+
372
+ function record(journey) {
373
+ journeys.set(req, journey);
374
+ if (issueJourneyKeys) setJourneyKey(res, journey.journey_id);
375
+ res.on("finish", () => {
376
+ // An exception here would be thrown outside the request, so it is logged instead: the event is lost, the
377
+ // server is not.
378
+ let event;
379
+ try {
380
+ event = eventOf(req, res, occurredAt, journey);
381
+ } catch (err) {
382
+ // The path only: the query string can hold an API key, a journey key or a handoff token, which never go in a log.
383
+ console.error(`agent-traffic: event not recorded for ${req.method} ${requestPath(req) ?? "(no path)"}: ${err.stack}`);
384
+ return;
385
+ }
386
+ if (SERVERLESS || vercel?.waitUntil) {
387
+ const sending = send([event], 1);
388
+ vercel?.waitUntil?.(sending);
389
+ return;
390
+ }
391
+ queue.push(event);
392
+ if (queue.length >= BATCH_MAX) flush();
393
+ else if (!timer) timer = setTimeout(flush, FLUSH_MS).unref();
394
+ });
395
+ next();
396
+ }
280
397
  }
281
398
  agentTrafficMiddleware.recordOutcome = recordOutcome;
399
+ agentTrafficMiddleware.handoffUrl = handoffUrl;
400
+ agentTrafficMiddleware.journeyIdOf = journeyIdOf;
282
401
  return agentTrafficMiddleware;
283
402
 
284
403
  function eventOf(req, res, occurredAt, journey) {
@@ -289,6 +408,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
289
408
  return {
290
409
  idempotency_key: randomUUID(),
291
410
  name: eventName(req),
411
+ path: requestPath(req),
292
412
  occurred_at: occurredAt,
293
413
  source: "sdk",
294
414
  account_id: accountId ? hashAccountId(accountId) : undefined,
@@ -300,6 +420,24 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
300
420
  properties: { status_code: res.statusCode, method: req.method, ...(typeof error === "string" && error !== "" ? { error: error.slice(0, ERROR_MAX) } : {}) },
301
421
  };
302
422
  }
423
+
424
+ // The event that carries a handoff token, sent before the response, so it has no status code and no account yet:
425
+ // the person's sign-in comes on their later requests.
426
+ function handoffEventOf(req, occurredAt, journey, handoffToken) {
427
+ return {
428
+ idempotency_key: randomUUID(),
429
+ name: "handoff_opened",
430
+ path: requestPath(req),
431
+ occurred_at: occurredAt,
432
+ source: "sdk",
433
+ user_agent: req.headers["user-agent"],
434
+ accept_language: req.headers["accept-language"],
435
+ signature_agent: req.headers["signature-agent"],
436
+ ...journey,
437
+ handoff_token: handoffToken,
438
+ properties: { method: req.method },
439
+ };
440
+ }
303
441
  }
304
442
 
305
- module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER };
443
+ module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER, HANDOFF_PARAM };