@katrinalaszlo/track 0.6.0 → 0.7.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 +34 -5
- package/index.d.ts +21 -2
- package/package.json +5 -2
- package/skills/agent-traffic/agent-traffic.cjs +156 -32
package/README.md
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Track: install skill
|
|
2
2
|
|
|
3
|
-
[
|
|
3
|
+
[Track](https://alg.katrinalaszlo.com) shows which AI agents call your API, how their sessions go, and which errors end them. This repo is everything that runs on your side: an agent skill that installs it, the Express middleware it adds, and the script that checks the install.
|
|
4
4
|
|
|
5
5
|
## Install with a coding agent
|
|
6
6
|
|
|
7
7
|
Give your agent (Claude Code, Cursor, Codex) this prompt:
|
|
8
8
|
|
|
9
9
|
```
|
|
10
|
-
Add
|
|
10
|
+
Add Track to this API: read https://alg.katrinalaszlo.com/SKILL.md and follow it.
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Or add the skill to your agent first:
|
|
@@ -29,9 +29,18 @@ 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.
|
|
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. No account yet? This creates a free workspace and appends its key to `.env` without printing it (the first line adds a final newline to a `.env` that lacks one):
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
```sh
|
|
35
|
+
if [ -s .env ] && [ -n "$(tail -c 1 .env)" ]; then echo >> .env; fi
|
|
36
|
+
curl -fsS -X POST "https://alg.katrinalaszlo.com/v1/signup?format=env" -H "Content-Type: application/json" -d '{"label":"my api","scopes":["read","write"]}' >> .env
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Already have an account? Create a key on the Install page of your dashboard, or run `npx agent-led-growth connect`, which opens your browser to sign in and writes the key to `.env` for you. The full install steps are in [SKILL.md](https://github.com/katrinalaszlo/track-skill/blob/main/skills/agent-traffic/SKILL.md).
|
|
40
|
+
|
|
41
|
+
`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.
|
|
42
|
+
|
|
43
|
+
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
44
|
|
|
36
45
|
### Journey keys
|
|
37
46
|
|
|
@@ -45,6 +54,24 @@ app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id, issueJourneyKeys
|
|
|
45
54
|
|
|
46
55
|
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
56
|
|
|
57
|
+
### Handoff links
|
|
58
|
+
|
|
59
|
+
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.
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
const traffic = agentTraffic({ accountIdOf: (req, res) => req.user?.id, issueJourneyKeys: true });
|
|
63
|
+
app.use(traffic);
|
|
64
|
+
|
|
65
|
+
app.post("/v1/checkout-links", async (req, res) => {
|
|
66
|
+
const link = await traffic.handoffUrl("https://example.com/checkout?plan=pro", { req });
|
|
67
|
+
res.json({ approve_at: link }); // https://example.com/checkout?plan=pro&track_h=h_...
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`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.
|
|
72
|
+
|
|
73
|
+
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).
|
|
74
|
+
|
|
48
75
|
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
76
|
|
|
50
77
|
### Path
|
|
@@ -57,6 +84,8 @@ What each event contains, and the raw HTTP API for other stacks: [alg.katrinalas
|
|
|
57
84
|
|
|
58
85
|
## Tests
|
|
59
86
|
|
|
87
|
+
From a clone of [katrinalaszlo/track-skill](https://github.com/katrinalaszlo/track-skill) (the npm package ships only the middleware and its types):
|
|
88
|
+
|
|
60
89
|
```sh
|
|
61
90
|
npm test
|
|
62
91
|
```
|
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,12 +61,21 @@ 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
|
|
|
56
74
|
/**
|
|
57
75
|
* Each event the middleware records carries `path`: the request path as the app received it (router mount prefix
|
|
58
76
|
* included, not percent-decoded), cut at the first "?" or "#", so never the domain or the query. Left out when it does
|
|
59
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.
|
|
60
79
|
*/
|
|
61
80
|
export function agentTraffic(options?: AgentTrafficOptions): AgentTrafficMiddleware;
|
|
62
81
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@katrinalaszlo/track",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.1",
|
|
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",
|
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
"type": "git",
|
|
9
9
|
"url": "git+https://github.com/katrinalaszlo/track-skill.git"
|
|
10
10
|
},
|
|
11
|
-
"homepage": "https://
|
|
11
|
+
"homepage": "https://alg.katrinalaszlo.com/docs",
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/katrinalaszlo/track-skill/issues"
|
|
14
|
+
},
|
|
12
15
|
"keywords": [
|
|
13
16
|
"ai-agents",
|
|
14
17
|
"express",
|
|
@@ -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
|
-
//
|
|
16
|
-
// directory, this file's folder or any folder above it, when the
|
|
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
|
-
//
|
|
21
|
-
//
|
|
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,6 +37,12 @@ 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"];
|
|
@@ -154,6 +167,15 @@ function journeyKeyOf(req) {
|
|
|
154
167
|
return undefined;
|
|
155
168
|
}
|
|
156
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
|
+
|
|
157
179
|
// Cache-Control: private so a shared cache never hands one visitor's key to another. A Set-Cookie an earlier
|
|
158
180
|
// middleware added is kept.
|
|
159
181
|
function setJourneyKey(res, journeyId) {
|
|
@@ -170,9 +192,13 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
|
|
|
170
192
|
projectId ??= env.AGENT_TRAFFIC_PROJECT_ID;
|
|
171
193
|
salt ??= env.AGENT_TRAFFIC_ACCOUNT_SALT;
|
|
172
194
|
endpoint ??= env.AGENT_TRAFFIC_ENDPOINT ?? "https://alg.katrinalaszlo.com";
|
|
173
|
-
if (!key
|
|
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.");
|
|
174
196
|
if (accountIdOf && !salt) throw new Error("agentTraffic needs AGENT_TRAFFIC_ACCOUNT_SALT, in the environment or in .env, to hash what accountIdOf returns.");
|
|
175
|
-
const
|
|
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();
|
|
176
202
|
let queue = [];
|
|
177
203
|
let timer = null;
|
|
178
204
|
const warned = new Set();
|
|
@@ -203,7 +229,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
|
|
|
203
229
|
byReason.set(label, (byReason.get(label) ?? 0) + 1);
|
|
204
230
|
}
|
|
205
231
|
for (const [label, n] of byReason) {
|
|
206
|
-
const hint = label.startsWith("unknown_field") ? ". The agent traffic host does not know this field yet: it is older than this package (0.
|
|
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" : "";
|
|
207
233
|
console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
|
|
208
234
|
}
|
|
209
235
|
return;
|
|
@@ -262,36 +288,116 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
|
|
|
262
288
|
return sending;
|
|
263
289
|
}
|
|
264
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
|
+
|
|
265
356
|
function agentTrafficMiddleware(req, res, next) {
|
|
266
357
|
const occurredAt = new Date().toISOString();
|
|
267
358
|
// Read while the request is current: the context is per request.
|
|
268
359
|
const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
|
|
269
360
|
let journey = journeyKeyOf(req);
|
|
270
361
|
if (issueJourneyKeys && !journey) journey = { journey_id: `j_${randomBytes(16).toString("hex")}`, journey_source: "issued" };
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
if (
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
}
|
|
293
397
|
}
|
|
294
398
|
agentTrafficMiddleware.recordOutcome = recordOutcome;
|
|
399
|
+
agentTrafficMiddleware.handoffUrl = handoffUrl;
|
|
400
|
+
agentTrafficMiddleware.journeyIdOf = journeyIdOf;
|
|
295
401
|
return agentTrafficMiddleware;
|
|
296
402
|
|
|
297
403
|
function eventOf(req, res, occurredAt, journey) {
|
|
@@ -314,6 +420,24 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
|
|
|
314
420
|
properties: { status_code: res.statusCode, method: req.method, ...(typeof error === "string" && error !== "" ? { error: error.slice(0, ERROR_MAX) } : {}) },
|
|
315
421
|
};
|
|
316
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
|
+
}
|
|
317
441
|
}
|
|
318
442
|
|
|
319
|
-
module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER };
|
|
443
|
+
module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER, HANDOFF_PARAM };
|