@katrinalaszlo/track 0.0.0-stage → 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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kat Laszlo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,62 @@
1
- # Temporary Holding Version
1
+ # Agent traffic: install skill
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [Agent traffic](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
+
5
+ ## Install with a coding agent
6
+
7
+ Give your agent (Claude Code, Cursor, Codex) this prompt:
8
+
9
+ ```
10
+ Add agent traffic to this API: read https://alg.katrinalaszlo.com/SKILL.md and follow it.
11
+ ```
12
+
13
+ Or add the skill to your agent first:
14
+
15
+ ```sh
16
+ npx skills add katrinalaszlo/track-skill
17
+ ```
18
+
19
+ The skill is three steps: a `curl` that appends the key to `.env` without printing it, `npm install @katrinalaszlo/track` plus two lines in your app, and a `curl` to the install check, which fails unless sessions, status codes and distinct event names arrive.
20
+
21
+ ## Install by hand
22
+
23
+ ```sh
24
+ npm install @katrinalaszlo/track
25
+ ```
26
+
27
+ ```js
28
+ const { agentTraffic } = require("@katrinalaszlo/track"); // or: import { agentTraffic } from "@katrinalaszlo/track";
29
+ app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id }));
30
+ ```
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).
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.
35
+
36
+ ### Journey keys
37
+
38
+ A journey key ties one visitor's requests together across sessions, for example an agent that reads your docs today and signs up tomorrow. The middleware reads it from each request in this order: the `Track-Journey` header, the `?track_j=` query parameter, then the `track_j` cookie. The first value of 8 to 128 letters, digits, `_` or `-` wins and is sent as `journey_id`, with `journey_source` set to `header`, `query` or `cookie`. A value that does not fit is ignored: not sent, and never an error for the request. With no key, neither field is sent (unless `issueJourneyKeys` is on, below). A journey key is never written to a log.
39
+
40
+ To hand out keys yourself, turn on `issueJourneyKeys`:
41
+
42
+ ```js
43
+ app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id, issueJourneyKeys: true }));
44
+ ```
45
+
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
+
48
+ 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
+
50
+ What each event contains, and the raw HTTP API for other stacks: [alg.katrinalaszlo.com/docs](https://alg.katrinalaszlo.com/docs).
51
+
52
+ ## Tests
53
+
54
+ ```sh
55
+ npm test
56
+ ```
57
+
58
+ Node 18 or later. No dependencies.
59
+
60
+ ## License
61
+
62
+ MIT
package/index.d.ts ADDED
@@ -0,0 +1,59 @@
1
+ // Types for @katrinalaszlo/track. Request and response are typed loosely so the package needs no @types/express and
2
+ // accountIdOf compiles under strict mode whatever your auth adds to them.
3
+ export interface AgentTrafficOptions {
4
+ /** Your customer's id for this request (called after the response), or undefined when there is none. Hashed before it is sent. */
5
+ accountIdOf?: (req: any, res: any) => string | number | null | undefined;
6
+ /** Error text for a failed response (status 400 and above), or undefined. Sent as properties.error, up to 200 characters, so two errors with the same status show separately in Fix next. */
7
+ errorOf?: (req: any, res: any) => string | null | undefined;
8
+ /** Defaults to AGENT_TRAFFIC_KEY from the environment or .env. */
9
+ key?: string;
10
+ /** Defaults to AGENT_TRAFFIC_PROJECT_ID from the environment or .env. */
11
+ projectId?: string;
12
+ /** Defaults to AGENT_TRAFFIC_ACCOUNT_SALT from the environment or .env. Required when accountIdOf is set. */
13
+ salt?: string;
14
+ /** Defaults to AGENT_TRAFFIC_ENDPOINT, else https://alg.katrinalaszlo.com. */
15
+ endpoint?: string;
16
+ /**
17
+ * Off by default. When true, every response carries the visitor's journey key: the one the request sent (Track-Journey
18
+ * header, ?track_j= query parameter or track_j cookie), else a new "j_" + 32 hex key. It is set as the Track-Journey
19
+ * response header and as `Set-Cookie: track_j=<key>; Path=/; Max-Age=1209600; SameSite=Lax; Secure`, with
20
+ * `Cache-Control: private`. Opt-in because it sets a cookie on your responses and stops shared caches from storing them.
21
+ */
22
+ issueJourneyKeys?: boolean;
23
+ }
24
+
25
+ /** The request and response header that carries a journey key: "Track-Journey". */
26
+ export const JOURNEY_HEADER: string;
27
+
28
+ /** Fields the middleware adds to an event when the request carried a valid journey key; both or neither. */
29
+ export interface JourneyFields {
30
+ /** 8 to 128 characters of A-Z, a-z, 0-9, _ and -. */
31
+ journey_id: string;
32
+ /** "issued" when issueJourneyKeys minted the key on this request's response. */
33
+ journey_source: "header" | "query" | "cookie" | "issued";
34
+ }
35
+
36
+ export interface OutcomeInput {
37
+ /** The same customer id accountIdOf returns for this customer's requests. Hashed with the salt before it is sent. */
38
+ accountId: string | number;
39
+ name: "signup" | "key_created" | "activation" | "paid" | "custom";
40
+ /** Whole minor units: 1999 for $19.99. Required for paid. */
41
+ value?: number;
42
+ /** ISO 4217 code such as "USD". Required when value is set. */
43
+ currency?: string;
44
+ /** Your payment event id or signup id: a retried webhook with the same key is counted once. */
45
+ idempotencyKey: string;
46
+ /** When it happened; defaults to now. */
47
+ occurredAt?: Date | string;
48
+ }
49
+
50
+ export interface AgentTrafficMiddleware {
51
+ (req: any, res: any, next: (err?: unknown) => void): void;
52
+ /** Sends one outcome at once. Throws synchronously on bad input; the returned promise settles when the send finished. */
53
+ recordOutcome(outcome: OutcomeInput): Promise<void>;
54
+ }
55
+
56
+ export function agentTraffic(options?: AgentTrafficOptions): AgentTrafficMiddleware;
57
+
58
+ /** 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. */
59
+ export function eventName(req: any): string;
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "@katrinalaszlo/track",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.5.0",
4
+ "description": "Express middleware that shows which AI agents call your API, how their sessions go, and which errors end them.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/katrinalaszlo/track-skill.git"
10
+ },
11
+ "homepage": "https://traffic.katrinalaszlo.com/docs",
12
+ "keywords": [
13
+ "ai-agents",
14
+ "express",
15
+ "middleware",
16
+ "analytics",
17
+ "api"
18
+ ],
19
+ "main": "./skills/agent-traffic/agent-traffic.cjs",
20
+ "types": "./index.d.ts",
21
+ "exports": {
22
+ ".": {
23
+ "types": "./index.d.ts",
24
+ "default": "./skills/agent-traffic/agent-traffic.cjs"
25
+ }
26
+ },
27
+ "files": [
28
+ "skills/agent-traffic/agent-traffic.cjs",
29
+ "index.d.ts"
30
+ ],
31
+ "scripts": {
32
+ "test": "node --test test/*.test.mjs"
33
+ },
34
+ "engines": {
35
+ "node": ">=18"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ }
40
+ }
@@ -0,0 +1,305 @@
1
+ // Agent traffic: Express middleware that sends one event per request to alg.katrinalaszlo.com, after the
2
+ // response is sent. npm package @katrinalaszlo/track; source https://github.com/katrinalaszlo/track-skill. Node 18+.
3
+ //
4
+ // const { agentTraffic } = require("@katrinalaszlo/track"); // or: import { agentTraffic } from "@katrinalaszlo/track";
5
+ // const traffic = agentTraffic({ accountIdOf: (req, res) => req.user?.id });
6
+ // app.use(traffic);
7
+ // // From your signup handler and your payment webhook: the outcome an agent's session led to.
8
+ // await traffic.recordOutcome({ accountId: user.id, name: "signup", idempotencyKey: user.id });
9
+ // await traffic.recordOutcome({ accountId: customer.id, name: "paid", value: 1999, currency: "USD", idempotencyKey: paymentEvent.id });
10
+ //
11
+ // accountIdOf returns your customer's id for the request (called with req and res after the response, so values your
12
+ // auth set on either, such as req.user or res.locals.user, are there), or undefined. errorOf, optional, returns the
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
18
+ // on the response (a header and a track_j cookie) so its later requests join one journey.
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.
22
+ const { createHmac, randomBytes, randomUUID } = require("node:crypto");
23
+ const { existsSync, readFileSync } = require("node:fs");
24
+ const { dirname, join } = require("node:path");
25
+
26
+ // The request and response header that carries a journey key.
27
+ const JOURNEY_HEADER = "Track-Journey";
28
+ // The cookie name and the query parameter name.
29
+ const JOURNEY_PARAM = "track_j";
30
+ const JOURNEY_KEY = /^[A-Za-z0-9_-]{8,128}$/;
31
+ // 14 days. Secure so the key never travels over plain http; Lax so it comes along when a person follows a link in.
32
+ const JOURNEY_COOKIE_ATTRIBUTES = "Path=/; Max-Age=1209600; SameSite=Lax; Secure";
33
+
34
+ const BATCH_MAX = 25;
35
+ const OUTCOME_NAMES = ["signup", "key_created", "activation", "paid", "custom"];
36
+ const FLUSH_MS = 1000;
37
+ const RETRIES_MAX = 3;
38
+ const ERROR_MAX = 200;
39
+ // On serverless hosts the process can freeze as soon as the response is sent, so a queued batch might never go out.
40
+ // There each event is sent at once, and on Vercel the send is handed to waitUntil so the function stays up until it
41
+ // is done (the request context @vercel/functions reads, without depending on it).
42
+ const SERVERLESS = Boolean(process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.NETLIFY);
43
+ const VERCEL_CONTEXT = Symbol.for("@vercel/request-context");
44
+
45
+ // The event name is the route pattern, not the URL, so /v1/users/42 and /v1/users/43 are one step. A leading version
46
+ // segment (v1) and "api" are dropped, and the method is appended for anything but GET:
47
+ // GET /v1/docs/quickstart -> docs_quickstart POST /api/v1/api-keys -> api_keys_post GET /v1/users/:id -> users_id
48
+ // A request no route matched is "unmatched". Names fit the ingest rule: ^[a-z][a-z0-9_]{0,63}$.
49
+ function eventName(req) {
50
+ if (!req.route) return "unmatched";
51
+ // Express baseUrl contains matched values, not the mount pattern: /customers/:id can become /customers/customer_123.
52
+ // Keep only the route pattern so mount parameters never enter event names.
53
+ const pattern = typeof req.route.path === "string" ? req.route.path : "";
54
+ const segments = pattern.toLowerCase().split("/").filter((seg) => seg !== "api" && !/^v\d+$/.test(seg));
55
+ const words = segments.flatMap((seg) => seg.split(/[^a-z0-9]+/)).filter((w) => w !== "");
56
+ if (req.method !== "GET" && req.method !== "HEAD") words.push(req.method.toLowerCase());
57
+ const name = words.join("_").replace(/^[^a-z]+/, "");
58
+ return (name === "" ? "root" : name).slice(0, 64).replace(/_+$/, "");
59
+ }
60
+
61
+ // The .env holding AGENT_TRAFFIC_KEY: in the working directory, else in this file's folder or the nearest one above it, so
62
+ // it can sit at an app's root or a monorepo's root whichever directory the server starts from.
63
+ function envFile() {
64
+ const candidates = [join(process.cwd(), ".env")];
65
+ for (let dir = __dirname; ; dir = dirname(dir)) {
66
+ candidates.push(join(dir, ".env"));
67
+ if (dirname(dir) === dir) break;
68
+ }
69
+ return candidates.find((file) => existsSync(file) && /^AGENT_TRAFFIC_KEY=/m.test(readFileSync(file, "utf8")));
70
+ }
71
+
72
+ // AGENT_TRAFFIC_ variables from the environment, else from the .env envFile finds.
73
+ function agentTrafficEnv() {
74
+ const values = {};
75
+ const file = envFile();
76
+ if (file) {
77
+ for (const match of readFileSync(file, "utf8").matchAll(/^(AGENT_TRAFFIC_[A-Z_]+)=(.*)$/gm)) values[match[1]] = match[2].trim();
78
+ }
79
+ for (const name of Object.keys(process.env)) if (name.startsWith("AGENT_TRAFFIC_")) values[name] = process.env[name];
80
+ return values;
81
+ }
82
+
83
+ // Web Bot Auth: when an agent signed its request (Signature and Signature-Input headers), the event carries what ingest
84
+ // needs to check the signature: those two headers, the method and absolute URL, and only the other headers the
85
+ // signature covers. Never a credential header, whatever the signer covered. An unsigned request adds nothing.
86
+ const SIGNATURE_HEADERS = ["signature", "signature-input", "signature-agent"];
87
+ const FORBIDDEN_COVERED = ["authorization", "proxy-authorization", "cookie", "set-cookie"];
88
+
89
+ // The header names a Signature-Input covers: the quoted strings inside its parentheses that are not @-derived components.
90
+ function coveredHeaderNames(signatureInput) {
91
+ const names = [];
92
+ for (const group of signatureInput.matchAll(/\(([^)]*)\)/g)) {
93
+ for (const item of group[1].matchAll(/"([^"]+)"/g)) if (!item[1].startsWith("@")) names.push(item[1].toLowerCase());
94
+ }
95
+ return names;
96
+ }
97
+
98
+ function signatureMaterial(req) {
99
+ const signature = req.headers.signature;
100
+ const signatureInput = req.headers["signature-input"];
101
+ if (typeof signature !== "string" || typeof signatureInput !== "string") return {};
102
+ const covered = {};
103
+ for (const name of coveredHeaderNames(signatureInput)) {
104
+ if (SIGNATURE_HEADERS.includes(name) || FORBIDDEN_COVERED.includes(name) || req.headers[name] === undefined) continue;
105
+ covered[name] = Array.isArray(req.headers[name]) ? req.headers[name].join(", ") : String(req.headers[name]);
106
+ }
107
+ // req.protocol honours Express's trust proxy setting, so behind a proxy the URL is the one the agent signed.
108
+ const host = req.headers.host;
109
+ const url = typeof host === "string" && host !== "" ? `${req.protocol ?? "https"}://${host}${req.originalUrl ?? req.url ?? "/"}` : undefined;
110
+ return {
111
+ signature,
112
+ signature_input: signatureInput,
113
+ method: String(req.method ?? "GET").toUpperCase(),
114
+ ...(url ? { url } : {}),
115
+ ...(Object.keys(covered).length > 0 ? { covered_headers: covered } : {}),
116
+ };
117
+ }
118
+
119
+ // A journey key ties requests from one visitor together across sessions: the journey header, else ?track_j= in the URL,
120
+ // else the track_j cookie. The first value that fits the ingest rule wins; one that does not is ignored, never sent.
121
+ function journeyKeyOf(req) {
122
+ const header = req.headers[JOURNEY_HEADER.toLowerCase()];
123
+ if (typeof header === "string" && JOURNEY_KEY.test(header)) return { journey_id: header, journey_source: "header" };
124
+
125
+ const url = req.originalUrl ?? req.url ?? "";
126
+ const queryStart = url.indexOf("?");
127
+ if (queryStart !== -1) {
128
+ const fromQuery = new URLSearchParams(url.slice(queryStart + 1)).getAll(JOURNEY_PARAM).find((value) => JOURNEY_KEY.test(value));
129
+ if (fromQuery) return { journey_id: fromQuery, journey_source: "query" };
130
+ }
131
+
132
+ const cookieHeader = req.headers.cookie;
133
+ if (typeof cookieHeader === "string") {
134
+ for (const pair of cookieHeader.split(";")) {
135
+ const eq = pair.indexOf("=");
136
+ if (eq === -1 || pair.slice(0, eq).trim() !== JOURNEY_PARAM) continue;
137
+ const value = pair.slice(eq + 1).trim();
138
+ if (JOURNEY_KEY.test(value)) return { journey_id: value, journey_source: "cookie" };
139
+ }
140
+ }
141
+ return undefined;
142
+ }
143
+
144
+ // Cache-Control: private so a shared cache never hands one visitor's key to another. A Set-Cookie an earlier
145
+ // middleware added is kept.
146
+ function setJourneyKey(res, journeyId) {
147
+ res.setHeader(JOURNEY_HEADER, journeyId);
148
+ const cookie = `${JOURNEY_PARAM}=${journeyId}; ${JOURNEY_COOKIE_ATTRIBUTES}`;
149
+ const existing = res.getHeader("set-cookie");
150
+ res.setHeader("set-cookie", existing === undefined ? cookie : [].concat(existing, cookie));
151
+ res.setHeader("cache-control", "private");
152
+ }
153
+
154
+ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, issueJourneyKeys = false } = {}) {
155
+ const env = agentTrafficEnv();
156
+ key ??= env.AGENT_TRAFFIC_KEY;
157
+ projectId ??= env.AGENT_TRAFFIC_PROJECT_ID;
158
+ salt ??= env.AGENT_TRAFFIC_ACCOUNT_SALT;
159
+ 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.");
161
+ 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)}`;
163
+ let queue = [];
164
+ let timer = null;
165
+ const warned = new Set();
166
+
167
+ async function send(batch, attempt) {
168
+ let failure = null;
169
+ try {
170
+ const res = await fetch(url, {
171
+ method: "POST",
172
+ headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
173
+ body: JSON.stringify(batch),
174
+ });
175
+ const body = await res.json();
176
+ if (res.status === 202) {
177
+ // A key inside its rotation grace window: said once per process, not once per batch.
178
+ for (const warning of body.warnings ?? []) {
179
+ if (!warned.has(warning)) {
180
+ warned.add(warning);
181
+ console.error(`agent-traffic: ${warning}`);
182
+ }
183
+ }
184
+ // One line per reason per batch, not one per event. An unknown field means this host is older than this
185
+ // package: say which field and what to do, instead of a uuid per event.
186
+ const byReason = new Map();
187
+ for (const entry of body.rejected) {
188
+ if (entry.reason === "duplicate") continue;
189
+ const label = entry.reason === "unknown_field" ? `unknown_field (${entry.field ?? "?"})` : entry.reason;
190
+ byReason.set(label, (byReason.get(label) ?? 0) + 1);
191
+ }
192
+ 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" : "";
194
+ console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
195
+ }
196
+ return;
197
+ }
198
+ failure = `${res.status} ${body.error}: ${body.message}`;
199
+ // A 4xx other than 429 will fail the same way again, so it is not retried.
200
+ if (res.status < 500 && res.status !== 429) {
201
+ console.error(`agent-traffic: batch of ${batch.length} dropped: ${failure}`);
202
+ return;
203
+ }
204
+ } catch (err) {
205
+ failure = err.message;
206
+ }
207
+ if (attempt >= RETRIES_MAX) {
208
+ console.error(`agent-traffic: batch of ${batch.length} dropped after ${attempt} attempts: ${failure}`);
209
+ return;
210
+ }
211
+ console.error(`agent-traffic: batch of ${batch.length} failed (${failure}), retrying`);
212
+ setTimeout(() => send(batch, attempt + 1), FLUSH_MS * 2 ** attempt).unref();
213
+ }
214
+
215
+ function flush() {
216
+ timer = null;
217
+ while (queue.length > 0) send(queue.splice(0, BATCH_MAX), 1);
218
+ }
219
+
220
+ const hashAccountId = (accountId) => createHmac("sha256", salt).update(String(accountId)).digest("hex");
221
+
222
+ // An outcome is sent at once, not batched: there are few of them and each one is money or a signup. Returns a
223
+ // promise that settles when the send has finished (sent, or logged and dropped); nothing it does can throw into
224
+ // your handler after validation. Bad input throws synchronously, at your call site, with the field named: that is
225
+ // your bug to see now, not a rejection to find in a log later.
226
+ function recordOutcome({ accountId, name, value, currency, idempotencyKey, occurredAt } = {}) {
227
+ if (!salt) throw new TypeError("recordOutcome needs AGENT_TRAFFIC_ACCOUNT_SALT (or the salt option) to hash accountId, the same salt the middleware uses. docs: https://alg.katrinalaszlo.com/docs#outcomes");
228
+ if (accountId === undefined || accountId === null || String(accountId) === "") throw new TypeError("recordOutcome: accountId is required: the same customer id accountIdOf returns, so the outcome joins the agent's session. docs: https://alg.katrinalaszlo.com/docs#outcomes");
229
+ if (!OUTCOME_NAMES.includes(name)) throw new TypeError(`recordOutcome: name must be one of ${OUTCOME_NAMES.join(", ")}, got ${JSON.stringify(name)}. docs: https://alg.katrinalaszlo.com/docs#outcomes`);
230
+ if (typeof idempotencyKey !== "string" || idempotencyKey === "" || idempotencyKey.length > 200) throw new TypeError("recordOutcome: idempotencyKey is required (1 to 200 characters): your payment event id or signup id, so a retried webhook never counts twice. docs: https://alg.katrinalaszlo.com/docs#outcomes");
231
+ const hasValue = value !== undefined && value !== null;
232
+ if (hasValue && (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0)) throw new TypeError(`recordOutcome: value must be a whole number of minor units (1999 for $19.99), 0 or more, got ${typeof value === "string" ? `string ${JSON.stringify(value)}` : String(value)}. docs: https://alg.katrinalaszlo.com/docs#outcomes`);
233
+ if (hasValue && !/^[A-Z]{3}$/.test(String(currency))) throw new TypeError(`recordOutcome: currency must be an ISO 4217 code such as USD when value is set, got ${JSON.stringify(currency)}. docs: https://alg.katrinalaszlo.com/docs#outcomes`);
234
+ if (name === "paid" && !hasValue) throw new TypeError("recordOutcome: a paid outcome needs value (minor units) and currency. docs: https://alg.katrinalaszlo.com/docs#outcomes");
235
+ const at = occurredAt === undefined ? new Date() : new Date(occurredAt);
236
+ if (Number.isNaN(at.getTime())) throw new TypeError(`recordOutcome: occurredAt is not a date: ${JSON.stringify(occurredAt)}`);
237
+ const event = {
238
+ idempotency_key: idempotencyKey,
239
+ name,
240
+ occurred_at: at.toISOString(),
241
+ source: "sdk",
242
+ account_id: hashAccountId(accountId),
243
+ outcome: { name, ...(hasValue ? { value, currency } : {}) },
244
+ properties: {},
245
+ };
246
+ const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
247
+ const sending = send([event], 1);
248
+ vercel?.waitUntil?.(sending);
249
+ return sending;
250
+ }
251
+
252
+ function agentTrafficMiddleware(req, res, next) {
253
+ const occurredAt = new Date().toISOString();
254
+ // Read while the request is current: the context is per request.
255
+ const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
256
+ let journey = journeyKeyOf(req);
257
+ 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();
280
+ }
281
+ agentTrafficMiddleware.recordOutcome = recordOutcome;
282
+ return agentTrafficMiddleware;
283
+
284
+ function eventOf(req, res, occurredAt, journey) {
285
+ const accountId = accountIdOf ? accountIdOf(req, res) : undefined;
286
+ // Only for failed responses: the text that tells one 402 from another in Fix next. Capped so a long message cannot
287
+ // crowd a batch.
288
+ const error = errorOf && res.statusCode >= 400 ? errorOf(req, res) : undefined;
289
+ return {
290
+ idempotency_key: randomUUID(),
291
+ name: eventName(req),
292
+ occurred_at: occurredAt,
293
+ source: "sdk",
294
+ account_id: accountId ? hashAccountId(accountId) : undefined,
295
+ user_agent: req.headers["user-agent"],
296
+ accept_language: req.headers["accept-language"],
297
+ signature_agent: req.headers["signature-agent"],
298
+ ...signatureMaterial(req),
299
+ ...journey,
300
+ properties: { status_code: res.statusCode, method: req.method, ...(typeof error === "string" && error !== "" ? { error: error.slice(0, ERROR_MAX) } : {}) },
301
+ };
302
+ }
303
+ }
304
+
305
+ module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER };