@katrinalaszlo/track 0.4.0 → 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/README.md CHANGED
@@ -31,7 +31,19 @@ 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, 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
 
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 {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@katrinalaszlo/track",
3
- "version": "0.4.0",
3
+ "version": "0.5.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,14 +14,23 @@
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;
@@ -107,7 +116,42 @@ function signatureMaterial(req) {
107
116
  };
108
117
  }
109
118
 
110
- function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } = {}) {
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 } = {}) {
111
155
  const env = agentTrafficEnv();
112
156
  key ??= env.AGENT_TRAFFIC_KEY;
113
157
  projectId ??= env.AGENT_TRAFFIC_PROJECT_ID;
@@ -146,7 +190,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
146
190
  byReason.set(label, (byReason.get(label) ?? 0) + 1);
147
191
  }
148
192
  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" : "";
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" : "";
150
194
  console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
151
195
  }
152
196
  return;
@@ -209,14 +253,18 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
209
253
  const occurredAt = new Date().toISOString();
210
254
  // Read while the request is current: the context is per request.
211
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);
212
259
  res.on("finish", () => {
213
260
  // An exception here would be thrown outside the request, so it is logged instead: the event is lost, the
214
261
  // server is not.
215
262
  let event;
216
263
  try {
217
- event = eventOf(req, res, occurredAt);
264
+ event = eventOf(req, res, occurredAt, journey);
218
265
  } catch (err) {
219
- console.error(`agent-traffic: event not recorded for ${req.method} ${req.originalUrl ?? req.url}: ${err.stack}`);
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}`);
220
268
  return;
221
269
  }
222
270
  if (SERVERLESS || vercel?.waitUntil) {
@@ -233,7 +281,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
233
281
  agentTrafficMiddleware.recordOutcome = recordOutcome;
234
282
  return agentTrafficMiddleware;
235
283
 
236
- function eventOf(req, res, occurredAt) {
284
+ function eventOf(req, res, occurredAt, journey) {
237
285
  const accountId = accountIdOf ? accountIdOf(req, res) : undefined;
238
286
  // Only for failed responses: the text that tells one 402 from another in Fix next. Capped so a long message cannot
239
287
  // crowd a batch.
@@ -248,9 +296,10 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint } =
248
296
  accept_language: req.headers["accept-language"],
249
297
  signature_agent: req.headers["signature-agent"],
250
298
  ...signatureMaterial(req),
299
+ ...journey,
251
300
  properties: { status_code: res.statusCode, method: req.method, ...(typeof error === "string" && error !== "" ? { error: error.slice(0, ERROR_MAX) } : {}) },
252
301
  };
253
302
  }
254
303
  }
255
304
 
256
- module.exports = { eventName, agentTraffic, signatureMaterial };
305
+ module.exports = { eventName, agentTraffic, signatureMaterial, JOURNEY_HEADER };