@katrinalaszlo/track 0.0.0-stage → 0.4.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 +21 -0
- package/README.md +49 -2
- package/index.d.ts +41 -0
- package/package.json +38 -4
- package/skills/agent-traffic/agent-traffic.cjs +256 -0
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,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Agent traffic: install skill
|
|
2
2
|
|
|
3
|
-
This
|
|
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, 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
|
+
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.
|
|
37
|
+
|
|
38
|
+
What each event contains, and the raw HTTP API for other stacks: [alg.katrinalaszlo.com/docs](https://alg.katrinalaszlo.com/docs).
|
|
39
|
+
|
|
40
|
+
## Tests
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
npm test
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Node 18 or later. No dependencies.
|
|
47
|
+
|
|
48
|
+
## License
|
|
49
|
+
|
|
50
|
+
MIT
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
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
|
+
|
|
18
|
+
export interface OutcomeInput {
|
|
19
|
+
/** The same customer id accountIdOf returns for this customer's requests. Hashed with the salt before it is sent. */
|
|
20
|
+
accountId: string | number;
|
|
21
|
+
name: "signup" | "key_created" | "activation" | "paid" | "custom";
|
|
22
|
+
/** Whole minor units: 1999 for $19.99. Required for paid. */
|
|
23
|
+
value?: number;
|
|
24
|
+
/** ISO 4217 code such as "USD". Required when value is set. */
|
|
25
|
+
currency?: string;
|
|
26
|
+
/** Your payment event id or signup id: a retried webhook with the same key is counted once. */
|
|
27
|
+
idempotencyKey: string;
|
|
28
|
+
/** When it happened; defaults to now. */
|
|
29
|
+
occurredAt?: Date | string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AgentTrafficMiddleware {
|
|
33
|
+
(req: any, res: any, next: (err?: unknown) => void): void;
|
|
34
|
+
/** Sends one outcome at once. Throws synchronously on bad input; the returned promise settles when the send finished. */
|
|
35
|
+
recordOutcome(outcome: OutcomeInput): Promise<void>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function agentTraffic(options?: AgentTrafficOptions): AgentTrafficMiddleware;
|
|
39
|
+
|
|
40
|
+
/** 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. */
|
|
41
|
+
export function eventName(req: any): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,40 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@katrinalaszlo/track",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.4.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,256 @@
|
|
|
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.
|
|
18
|
+
//
|
|
19
|
+
// Register it right after creating the app, before auth and routes. It never blocks or fails a request: events are
|
|
20
|
+
// queued, sent in batches of up to 25 once a second, and a failed batch is logged and retried with the same keys.
|
|
21
|
+
const { createHmac, randomUUID } = require("node:crypto");
|
|
22
|
+
const { existsSync, readFileSync } = require("node:fs");
|
|
23
|
+
const { dirname, join } = require("node:path");
|
|
24
|
+
|
|
25
|
+
const BATCH_MAX = 25;
|
|
26
|
+
const OUTCOME_NAMES = ["signup", "key_created", "activation", "paid", "custom"];
|
|
27
|
+
const FLUSH_MS = 1000;
|
|
28
|
+
const RETRIES_MAX = 3;
|
|
29
|
+
const ERROR_MAX = 200;
|
|
30
|
+
// On serverless hosts the process can freeze as soon as the response is sent, so a queued batch might never go out.
|
|
31
|
+
// There each event is sent at once, and on Vercel the send is handed to waitUntil so the function stays up until it
|
|
32
|
+
// is done (the request context @vercel/functions reads, without depending on it).
|
|
33
|
+
const SERVERLESS = Boolean(process.env.VERCEL || process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.NETLIFY);
|
|
34
|
+
const VERCEL_CONTEXT = Symbol.for("@vercel/request-context");
|
|
35
|
+
|
|
36
|
+
// The event name is the route pattern, not the URL, so /v1/users/42 and /v1/users/43 are one step. A leading version
|
|
37
|
+
// segment (v1) and "api" are dropped, and the method is appended for anything but GET:
|
|
38
|
+
// GET /v1/docs/quickstart -> docs_quickstart POST /api/v1/api-keys -> api_keys_post GET /v1/users/:id -> users_id
|
|
39
|
+
// A request no route matched is "unmatched". Names fit the ingest rule: ^[a-z][a-z0-9_]{0,63}$.
|
|
40
|
+
function eventName(req) {
|
|
41
|
+
if (!req.route) return "unmatched";
|
|
42
|
+
// Express baseUrl contains matched values, not the mount pattern: /customers/:id can become /customers/customer_123.
|
|
43
|
+
// Keep only the route pattern so mount parameters never enter event names.
|
|
44
|
+
const pattern = typeof req.route.path === "string" ? req.route.path : "";
|
|
45
|
+
const segments = pattern.toLowerCase().split("/").filter((seg) => seg !== "api" && !/^v\d+$/.test(seg));
|
|
46
|
+
const words = segments.flatMap((seg) => seg.split(/[^a-z0-9]+/)).filter((w) => w !== "");
|
|
47
|
+
if (req.method !== "GET" && req.method !== "HEAD") words.push(req.method.toLowerCase());
|
|
48
|
+
const name = words.join("_").replace(/^[^a-z]+/, "");
|
|
49
|
+
return (name === "" ? "root" : name).slice(0, 64).replace(/_+$/, "");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// The .env holding AGENT_TRAFFIC_KEY: in the working directory, else in this file's folder or the nearest one above it, so
|
|
53
|
+
// it can sit at an app's root or a monorepo's root whichever directory the server starts from.
|
|
54
|
+
function envFile() {
|
|
55
|
+
const candidates = [join(process.cwd(), ".env")];
|
|
56
|
+
for (let dir = __dirname; ; dir = dirname(dir)) {
|
|
57
|
+
candidates.push(join(dir, ".env"));
|
|
58
|
+
if (dirname(dir) === dir) break;
|
|
59
|
+
}
|
|
60
|
+
return candidates.find((file) => existsSync(file) && /^AGENT_TRAFFIC_KEY=/m.test(readFileSync(file, "utf8")));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// AGENT_TRAFFIC_ variables from the environment, else from the .env envFile finds.
|
|
64
|
+
function agentTrafficEnv() {
|
|
65
|
+
const values = {};
|
|
66
|
+
const file = envFile();
|
|
67
|
+
if (file) {
|
|
68
|
+
for (const match of readFileSync(file, "utf8").matchAll(/^(AGENT_TRAFFIC_[A-Z_]+)=(.*)$/gm)) values[match[1]] = match[2].trim();
|
|
69
|
+
}
|
|
70
|
+
for (const name of Object.keys(process.env)) if (name.startsWith("AGENT_TRAFFIC_")) values[name] = process.env[name];
|
|
71
|
+
return values;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Web Bot Auth: when an agent signed its request (Signature and Signature-Input headers), the event carries what ingest
|
|
75
|
+
// needs to check the signature: those two headers, the method and absolute URL, and only the other headers the
|
|
76
|
+
// signature covers. Never a credential header, whatever the signer covered. An unsigned request adds nothing.
|
|
77
|
+
const SIGNATURE_HEADERS = ["signature", "signature-input", "signature-agent"];
|
|
78
|
+
const FORBIDDEN_COVERED = ["authorization", "proxy-authorization", "cookie", "set-cookie"];
|
|
79
|
+
|
|
80
|
+
// The header names a Signature-Input covers: the quoted strings inside its parentheses that are not @-derived components.
|
|
81
|
+
function coveredHeaderNames(signatureInput) {
|
|
82
|
+
const names = [];
|
|
83
|
+
for (const group of signatureInput.matchAll(/\(([^)]*)\)/g)) {
|
|
84
|
+
for (const item of group[1].matchAll(/"([^"]+)"/g)) if (!item[1].startsWith("@")) names.push(item[1].toLowerCase());
|
|
85
|
+
}
|
|
86
|
+
return names;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function signatureMaterial(req) {
|
|
90
|
+
const signature = req.headers.signature;
|
|
91
|
+
const signatureInput = req.headers["signature-input"];
|
|
92
|
+
if (typeof signature !== "string" || typeof signatureInput !== "string") return {};
|
|
93
|
+
const covered = {};
|
|
94
|
+
for (const name of coveredHeaderNames(signatureInput)) {
|
|
95
|
+
if (SIGNATURE_HEADERS.includes(name) || FORBIDDEN_COVERED.includes(name) || req.headers[name] === undefined) continue;
|
|
96
|
+
covered[name] = Array.isArray(req.headers[name]) ? req.headers[name].join(", ") : String(req.headers[name]);
|
|
97
|
+
}
|
|
98
|
+
// req.protocol honours Express's trust proxy setting, so behind a proxy the URL is the one the agent signed.
|
|
99
|
+
const host = req.headers.host;
|
|
100
|
+
const url = typeof host === "string" && host !== "" ? `${req.protocol ?? "https"}://${host}${req.originalUrl ?? req.url ?? "/"}` : undefined;
|
|
101
|
+
return {
|
|
102
|
+
signature,
|
|
103
|
+
signature_input: signatureInput,
|
|
104
|
+
method: String(req.method ?? "GET").toUpperCase(),
|
|
105
|
+
...(url ? { url } : {}),
|
|
106
|
+
...(Object.keys(covered).length > 0 ? { covered_headers: covered } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } = {}) {
|
|
111
|
+
const env = agentTrafficEnv();
|
|
112
|
+
key ??= env.AGENT_TRAFFIC_KEY;
|
|
113
|
+
projectId ??= env.AGENT_TRAFFIC_PROJECT_ID;
|
|
114
|
+
salt ??= env.AGENT_TRAFFIC_ACCOUNT_SALT;
|
|
115
|
+
endpoint ??= env.AGENT_TRAFFIC_ENDPOINT ?? "https://alg.katrinalaszlo.com";
|
|
116
|
+
if (!key || !projectId) throw new Error("agentTraffic needs AGENT_TRAFFIC_KEY and AGENT_TRAFFIC_PROJECT_ID, in the environment or in .env.");
|
|
117
|
+
if (accountIdOf && !salt) throw new Error("agentTraffic needs AGENT_TRAFFIC_ACCOUNT_SALT, in the environment or in .env, to hash what accountIdOf returns.");
|
|
118
|
+
const url = `${endpoint}/v1/events?project_id=${encodeURIComponent(projectId)}`;
|
|
119
|
+
let queue = [];
|
|
120
|
+
let timer = null;
|
|
121
|
+
const warned = new Set();
|
|
122
|
+
|
|
123
|
+
async function send(batch, attempt) {
|
|
124
|
+
let failure = null;
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(url, {
|
|
127
|
+
method: "POST",
|
|
128
|
+
headers: { authorization: `Bearer ${key}`, "content-type": "application/json" },
|
|
129
|
+
body: JSON.stringify(batch),
|
|
130
|
+
});
|
|
131
|
+
const body = await res.json();
|
|
132
|
+
if (res.status === 202) {
|
|
133
|
+
// A key inside its rotation grace window: said once per process, not once per batch.
|
|
134
|
+
for (const warning of body.warnings ?? []) {
|
|
135
|
+
if (!warned.has(warning)) {
|
|
136
|
+
warned.add(warning);
|
|
137
|
+
console.error(`agent-traffic: ${warning}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// One line per reason per batch, not one per event. An unknown field means this host is older than this
|
|
141
|
+
// package: say which field and what to do, instead of a uuid per event.
|
|
142
|
+
const byReason = new Map();
|
|
143
|
+
for (const entry of body.rejected) {
|
|
144
|
+
if (entry.reason === "duplicate") continue;
|
|
145
|
+
const label = entry.reason === "unknown_field" ? `unknown_field (${entry.field ?? "?"})` : entry.reason;
|
|
146
|
+
byReason.set(label, (byReason.get(label) ?? 0) + 1);
|
|
147
|
+
}
|
|
148
|
+
for (const [label, n] of byReason) {
|
|
149
|
+
const hint = label.startsWith("unknown_field") ? ". The agent traffic host does not know this field yet: it is older than this package (0.3.0). Upgrade the host, or pin @katrinalaszlo/track to 0.2.x. https://alg.katrinalaszlo.com/docs#event-fields" : "";
|
|
150
|
+
console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
|
|
151
|
+
}
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
failure = `${res.status} ${body.error}: ${body.message}`;
|
|
155
|
+
// A 4xx other than 429 will fail the same way again, so it is not retried.
|
|
156
|
+
if (res.status < 500 && res.status !== 429) {
|
|
157
|
+
console.error(`agent-traffic: batch of ${batch.length} dropped: ${failure}`);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
} catch (err) {
|
|
161
|
+
failure = err.message;
|
|
162
|
+
}
|
|
163
|
+
if (attempt >= RETRIES_MAX) {
|
|
164
|
+
console.error(`agent-traffic: batch of ${batch.length} dropped after ${attempt} attempts: ${failure}`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
console.error(`agent-traffic: batch of ${batch.length} failed (${failure}), retrying`);
|
|
168
|
+
setTimeout(() => send(batch, attempt + 1), FLUSH_MS * 2 ** attempt).unref();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function flush() {
|
|
172
|
+
timer = null;
|
|
173
|
+
while (queue.length > 0) send(queue.splice(0, BATCH_MAX), 1);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const hashAccountId = (accountId) => createHmac("sha256", salt).update(String(accountId)).digest("hex");
|
|
177
|
+
|
|
178
|
+
// An outcome is sent at once, not batched: there are few of them and each one is money or a signup. Returns a
|
|
179
|
+
// promise that settles when the send has finished (sent, or logged and dropped); nothing it does can throw into
|
|
180
|
+
// your handler after validation. Bad input throws synchronously, at your call site, with the field named: that is
|
|
181
|
+
// your bug to see now, not a rejection to find in a log later.
|
|
182
|
+
function recordOutcome({ accountId, name, value, currency, idempotencyKey, occurredAt } = {}) {
|
|
183
|
+
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");
|
|
184
|
+
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");
|
|
185
|
+
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`);
|
|
186
|
+
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");
|
|
187
|
+
const hasValue = value !== undefined && value !== null;
|
|
188
|
+
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`);
|
|
189
|
+
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`);
|
|
190
|
+
if (name === "paid" && !hasValue) throw new TypeError("recordOutcome: a paid outcome needs value (minor units) and currency. docs: https://alg.katrinalaszlo.com/docs#outcomes");
|
|
191
|
+
const at = occurredAt === undefined ? new Date() : new Date(occurredAt);
|
|
192
|
+
if (Number.isNaN(at.getTime())) throw new TypeError(`recordOutcome: occurredAt is not a date: ${JSON.stringify(occurredAt)}`);
|
|
193
|
+
const event = {
|
|
194
|
+
idempotency_key: idempotencyKey,
|
|
195
|
+
name,
|
|
196
|
+
occurred_at: at.toISOString(),
|
|
197
|
+
source: "sdk",
|
|
198
|
+
account_id: hashAccountId(accountId),
|
|
199
|
+
outcome: { name, ...(hasValue ? { value, currency } : {}) },
|
|
200
|
+
properties: {},
|
|
201
|
+
};
|
|
202
|
+
const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
|
|
203
|
+
const sending = send([event], 1);
|
|
204
|
+
vercel?.waitUntil?.(sending);
|
|
205
|
+
return sending;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function agentTrafficMiddleware(req, res, next) {
|
|
209
|
+
const occurredAt = new Date().toISOString();
|
|
210
|
+
// Read while the request is current: the context is per request.
|
|
211
|
+
const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
|
|
212
|
+
res.on("finish", () => {
|
|
213
|
+
// An exception here would be thrown outside the request, so it is logged instead: the event is lost, the
|
|
214
|
+
// server is not.
|
|
215
|
+
let event;
|
|
216
|
+
try {
|
|
217
|
+
event = eventOf(req, res, occurredAt);
|
|
218
|
+
} catch (err) {
|
|
219
|
+
console.error(`agent-traffic: event not recorded for ${req.method} ${req.originalUrl ?? req.url}: ${err.stack}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (SERVERLESS || vercel?.waitUntil) {
|
|
223
|
+
const sending = send([event], 1);
|
|
224
|
+
vercel?.waitUntil?.(sending);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
queue.push(event);
|
|
228
|
+
if (queue.length >= BATCH_MAX) flush();
|
|
229
|
+
else if (!timer) timer = setTimeout(flush, FLUSH_MS).unref();
|
|
230
|
+
});
|
|
231
|
+
next();
|
|
232
|
+
}
|
|
233
|
+
agentTrafficMiddleware.recordOutcome = recordOutcome;
|
|
234
|
+
return agentTrafficMiddleware;
|
|
235
|
+
|
|
236
|
+
function eventOf(req, res, occurredAt) {
|
|
237
|
+
const accountId = accountIdOf ? accountIdOf(req, res) : undefined;
|
|
238
|
+
// Only for failed responses: the text that tells one 402 from another in Fix next. Capped so a long message cannot
|
|
239
|
+
// crowd a batch.
|
|
240
|
+
const error = errorOf && res.statusCode >= 400 ? errorOf(req, res) : undefined;
|
|
241
|
+
return {
|
|
242
|
+
idempotency_key: randomUUID(),
|
|
243
|
+
name: eventName(req),
|
|
244
|
+
occurred_at: occurredAt,
|
|
245
|
+
source: "sdk",
|
|
246
|
+
account_id: accountId ? hashAccountId(accountId) : undefined,
|
|
247
|
+
user_agent: req.headers["user-agent"],
|
|
248
|
+
accept_language: req.headers["accept-language"],
|
|
249
|
+
signature_agent: req.headers["signature-agent"],
|
|
250
|
+
...signatureMaterial(req),
|
|
251
|
+
properties: { status_code: res.statusCode, method: req.method, ...(typeof error === "string" && error !== "" ? { error: error.slice(0, ERROR_MAX) } : {}) },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
module.exports = { eventName, agentTraffic, signatureMaterial };
|