@katrinalaszlo/track 0.4.0 → 0.6.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
@@ -31,10 +31,28 @@ app.use(agentTraffic({ accountIdOf: (req, res) => req.user?.id }));
31
31
 
32
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
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.
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 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.
35
47
 
36
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.
37
49
 
50
+ ### Path
51
+
52
+ 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.
53
+
54
+ 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.
55
+
38
56
  What each event contains, and the raw HTTP API for other stacks: [alg.katrinalaszlo.com/docs](https://alg.katrinalaszlo.com/docs).
39
57
 
40
58
  ## Tests
package/index.d.ts CHANGED
@@ -13,6 +13,24 @@ export interface AgentTrafficOptions {
13
13
  salt?: string;
14
14
  /** Defaults to AGENT_TRAFFIC_ENDPOINT, else https://alg.katrinalaszlo.com. */
15
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";
16
34
  }
17
35
 
18
36
  export interface OutcomeInput {
@@ -35,6 +53,11 @@ export interface AgentTrafficMiddleware {
35
53
  recordOutcome(outcome: OutcomeInput): Promise<void>;
36
54
  }
37
55
 
56
+ /**
57
+ * Each event the middleware records carries `path`: the request path as the app received it (router mount prefix
58
+ * included, not percent-decoded), cut at the first "?" or "#", so never the domain or the query. Left out when it does
59
+ * not start with "/" or is longer than 2048 characters. Ids in path segments (/v1/users/123) are sent as they are.
60
+ */
38
61
  export function agentTraffic(options?: AgentTrafficOptions): AgentTrafficMiddleware;
39
62
 
40
63
  /** 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.4.0",
3
+ "version": "0.6.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",
@@ -14,19 +14,29 @@
14
14
  // two errors with the same status show as different rows in Fix next. AGENT_TRAFFIC_KEY,
15
15
  // AGENT_TRAFFIC_PROJECT_ID and AGENT_TRAFFIC_ACCOUNT_SALT come from the environment, or from a .env in the working
16
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.
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.
18
19
  //
19
20
  // Register it right after creating the app, before auth and routes. It never blocks or fails a request: events are
20
21
  // 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 { createHmac, randomBytes, randomUUID } = require("node:crypto");
22
23
  const { existsSync, readFileSync } = require("node:fs");
23
24
  const { dirname, join } = require("node:path");
24
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
+
25
34
  const BATCH_MAX = 25;
26
35
  const OUTCOME_NAMES = ["signup", "key_created", "activation", "paid", "custom"];
27
36
  const FLUSH_MS = 1000;
28
37
  const RETRIES_MAX = 3;
29
38
  const ERROR_MAX = 200;
39
+ const PATH_MAX = 2048;
30
40
  // On serverless hosts the process can freeze as soon as the response is sent, so a queued batch might never go out.
31
41
  // There each event is sent at once, and on Vercel the send is handed to waitUntil so the function stays up until it
32
42
  // is done (the request context @vercel/functions reads, without depending on it).
@@ -107,7 +117,54 @@ function signatureMaterial(req) {
107
117
  };
108
118
  }
109
119
 
110
- function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } = {}) {
120
+ // The path the app received, mount prefix included, cut at the first ? or #: never the domain or the query, which
121
+ // can hold an API key or a journey key. Not percent-decoded. Anything that does not start with / or is longer than
122
+ // ingest accepts is left out rather than cut.
123
+ function requestPath(req) {
124
+ const url = req.originalUrl ?? req.url;
125
+ if (typeof url !== "string") return undefined;
126
+ const path = url.split(/[?#]/)[0];
127
+ if (!path.startsWith("/") || path.length > PATH_MAX) return undefined;
128
+ return path;
129
+ }
130
+
131
+ // A journey key ties requests from one visitor together across sessions: the journey header, else ?track_j= in the URL,
132
+ // else the track_j cookie. The first value that fits the ingest rule wins; one that does not is ignored, never sent.
133
+ function journeyKeyOf(req) {
134
+ const header = req.headers[JOURNEY_HEADER.toLowerCase()];
135
+ if (typeof header === "string" && JOURNEY_KEY.test(header)) return { journey_id: header, journey_source: "header" };
136
+
137
+ const rawUrl = req.originalUrl ?? req.url;
138
+ const url = typeof rawUrl === "string" ? rawUrl : "";
139
+ const queryStart = url.indexOf("?");
140
+ if (queryStart !== -1) {
141
+ const fromQuery = new URLSearchParams(url.slice(queryStart + 1)).getAll(JOURNEY_PARAM).find((value) => JOURNEY_KEY.test(value));
142
+ if (fromQuery) return { journey_id: fromQuery, journey_source: "query" };
143
+ }
144
+
145
+ const cookieHeader = req.headers.cookie;
146
+ if (typeof cookieHeader === "string") {
147
+ for (const pair of cookieHeader.split(";")) {
148
+ const eq = pair.indexOf("=");
149
+ if (eq === -1 || pair.slice(0, eq).trim() !== JOURNEY_PARAM) continue;
150
+ const value = pair.slice(eq + 1).trim();
151
+ if (JOURNEY_KEY.test(value)) return { journey_id: value, journey_source: "cookie" };
152
+ }
153
+ }
154
+ return undefined;
155
+ }
156
+
157
+ // Cache-Control: private so a shared cache never hands one visitor's key to another. A Set-Cookie an earlier
158
+ // middleware added is kept.
159
+ function setJourneyKey(res, journeyId) {
160
+ res.setHeader(JOURNEY_HEADER, journeyId);
161
+ const cookie = `${JOURNEY_PARAM}=${journeyId}; ${JOURNEY_COOKIE_ATTRIBUTES}`;
162
+ const existing = res.getHeader("set-cookie");
163
+ res.setHeader("set-cookie", existing === undefined ? cookie : [].concat(existing, cookie));
164
+ res.setHeader("cache-control", "private");
165
+ }
166
+
167
+ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, issueJourneyKeys = false } = {}) {
111
168
  const env = agentTrafficEnv();
112
169
  key ??= env.AGENT_TRAFFIC_KEY;
113
170
  projectId ??= env.AGENT_TRAFFIC_PROJECT_ID;
@@ -146,7 +203,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
146
203
  byReason.set(label, (byReason.get(label) ?? 0) + 1);
147
204
  }
148
205
  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" : "";
206
+ const hint = label.startsWith("unknown_field") ? ". The agent traffic host does not know this field yet: it is older than this package (0.6.0). Upgrade the host, or pin @katrinalaszlo/track to 0.5.x. https://alg.katrinalaszlo.com/docs#event-fields" : "";
150
207
  console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
151
208
  }
152
209
  return;
@@ -209,14 +266,18 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
209
266
  const occurredAt = new Date().toISOString();
210
267
  // Read while the request is current: the context is per request.
211
268
  const vercel = globalThis[VERCEL_CONTEXT]?.get?.();
269
+ let journey = journeyKeyOf(req);
270
+ if (issueJourneyKeys && !journey) journey = { journey_id: `j_${randomBytes(16).toString("hex")}`, journey_source: "issued" };
271
+ if (issueJourneyKeys) setJourneyKey(res, journey.journey_id);
212
272
  res.on("finish", () => {
213
273
  // An exception here would be thrown outside the request, so it is logged instead: the event is lost, the
214
274
  // server is not.
215
275
  let event;
216
276
  try {
217
- event = eventOf(req, res, occurredAt);
277
+ event = eventOf(req, res, occurredAt, journey);
218
278
  } catch (err) {
219
- console.error(`agent-traffic: event not recorded for ${req.method} ${req.originalUrl ?? req.url}: ${err.stack}`);
279
+ // The path only: the query string can hold an API key or a journey key, which never go in a log.
280
+ console.error(`agent-traffic: event not recorded for ${req.method} ${requestPath(req) ?? "(no path)"}: ${err.stack}`);
220
281
  return;
221
282
  }
222
283
  if (SERVERLESS || vercel?.waitUntil) {
@@ -233,7 +294,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
233
294
  agentTrafficMiddleware.recordOutcome = recordOutcome;
234
295
  return agentTrafficMiddleware;
235
296
 
236
- function eventOf(req, res, occurredAt) {
297
+ function eventOf(req, res, occurredAt, journey) {
237
298
  const accountId = accountIdOf ? accountIdOf(req, res) : undefined;
238
299
  // Only for failed responses: the text that tells one 402 from another in Fix next. Capped so a long message cannot
239
300
  // crowd a batch.
@@ -241,6 +302,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
241
302
  return {
242
303
  idempotency_key: randomUUID(),
243
304
  name: eventName(req),
305
+ path: requestPath(req),
244
306
  occurred_at: occurredAt,
245
307
  source: "sdk",
246
308
  account_id: accountId ? hashAccountId(accountId) : undefined,
@@ -248,9 +310,10 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
248
310
  accept_language: req.headers["accept-language"],
249
311
  signature_agent: req.headers["signature-agent"],
250
312
  ...signatureMaterial(req),
313
+ ...journey,
251
314
  properties: { status_code: res.statusCode, method: req.method, ...(typeof error === "string" && error !== "" ? { error: error.slice(0, ERROR_MAX) } : {}) },
252
315
  };
253
316
  }
254
317
  }
255
318
 
256
- module.exports = { eventName, agentTraffic, signatureMaterial };
319
+ module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER };