@katrinalaszlo/track 0.5.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,7 +31,7 @@ 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, 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.
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
35
 
36
36
  ### Journey keys
37
37
 
@@ -47,6 +47,12 @@ Then every response carries the visitor's key: the one the request sent, or a ne
47
47
 
48
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
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
+
50
56
  What each event contains, and the raw HTTP API for other stacks: [alg.katrinalaszlo.com/docs](https://alg.katrinalaszlo.com/docs).
51
57
 
52
58
  ## Tests
package/index.d.ts CHANGED
@@ -53,6 +53,11 @@ export interface AgentTrafficMiddleware {
53
53
  recordOutcome(outcome: OutcomeInput): Promise<void>;
54
54
  }
55
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
+ */
56
61
  export function agentTraffic(options?: AgentTrafficOptions): AgentTrafficMiddleware;
57
62
 
58
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.5.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",
@@ -36,6 +36,7 @@ const OUTCOME_NAMES = ["signup", "key_created", "activation", "paid", "custom"];
36
36
  const FLUSH_MS = 1000;
37
37
  const RETRIES_MAX = 3;
38
38
  const ERROR_MAX = 200;
39
+ const PATH_MAX = 2048;
39
40
  // On serverless hosts the process can freeze as soon as the response is sent, so a queued batch might never go out.
40
41
  // There each event is sent at once, and on Vercel the send is handed to waitUntil so the function stays up until it
41
42
  // is done (the request context @vercel/functions reads, without depending on it).
@@ -116,13 +117,25 @@ function signatureMaterial(req) {
116
117
  };
117
118
  }
118
119
 
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
+
119
131
  // A journey key ties requests from one visitor together across sessions: the journey header, else ?track_j= in the URL,
120
132
  // else the track_j cookie. The first value that fits the ingest rule wins; one that does not is ignored, never sent.
121
133
  function journeyKeyOf(req) {
122
134
  const header = req.headers[JOURNEY_HEADER.toLowerCase()];
123
135
  if (typeof header === "string" && JOURNEY_KEY.test(header)) return { journey_id: header, journey_source: "header" };
124
136
 
125
- const url = req.originalUrl ?? req.url ?? "";
137
+ const rawUrl = req.originalUrl ?? req.url;
138
+ const url = typeof rawUrl === "string" ? rawUrl : "";
126
139
  const queryStart = url.indexOf("?");
127
140
  if (queryStart !== -1) {
128
141
  const fromQuery = new URLSearchParams(url.slice(queryStart + 1)).getAll(JOURNEY_PARAM).find((value) => JOURNEY_KEY.test(value));
@@ -190,7 +203,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
190
203
  byReason.set(label, (byReason.get(label) ?? 0) + 1);
191
204
  }
192
205
  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" : "";
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" : "";
194
207
  console.error(`agent-traffic: ${n} of ${batch.length} events rejected: ${label}${hint}`);
195
208
  }
196
209
  return;
@@ -263,8 +276,8 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
263
276
  try {
264
277
  event = eventOf(req, res, occurredAt, journey);
265
278
  } 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}`);
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}`);
268
281
  return;
269
282
  }
270
283
  if (SERVERLESS || vercel?.waitUntil) {
@@ -289,6 +302,7 @@ function agentTraffic({ accountIdOf, errorOf, key, projectId, salt, endpoint, is
289
302
  return {
290
303
  idempotency_key: randomUUID(),
291
304
  name: eventName(req),
305
+ path: requestPath(req),
292
306
  occurred_at: occurredAt,
293
307
  source: "sdk",
294
308
  account_id: accountId ? hashAccountId(accountId) : undefined,