@scoutline-ai/middleware 0.1.0 → 0.2.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
@@ -20,7 +20,7 @@ export default withScoutline(yourProxy); // or withScoutline() if you have none
20
20
  export const config = {
21
21
  matcher: [
22
22
  {
23
- source: "/((?!_next/static|_next/image).*)",
23
+ source: "/((?!_next/static|_next/image|favicon.ico).*)",
24
24
  missing: [
25
25
  { type: "header", key: "next-router-prefetch" },
26
26
  { type: "header", key: "purpose", value: "prefetch" },
@@ -32,9 +32,24 @@ export const config = {
32
32
 
33
33
  Next.js removes prefetch headers before your proxy runs, so only the matcher can exclude them. The `missing` rule skips your whole proxy for prefetches, so leave it out if your proxy enforces auth. Prefetches then count as `unknown` traffic. They only come from browsers, so crawler and agent counts are unaffected.
34
34
 
35
+ Your local and preview deployments can use the same key. Reports only count traffic on your site's domain and its subdomains.
36
+
35
37
  A proxy runs before the page renders, so normal page loads have no status or duration. Scoutline leaves those empty and only records a status when the proxy itself responds, such as a redirect.
36
38
 
37
- ## Cloudflare Workers and Hono
39
+ ## Cloudflare Workers
40
+
41
+ Pass the key in. Depending on its compatibility date and flags, a Worker may have no `process.env` to read it from.
42
+
43
+ ```ts
44
+ import { env } from "cloudflare:workers";
45
+ import { withScoutline } from "@scoutline-ai/middleware/fetch";
46
+
47
+ export default { fetch: withScoutline(app.fetch, { key: env.SCOUTLINE_KEY }) };
48
+ ```
49
+
50
+ Use `.dev.vars` locally and `npx wrangler secret put SCOUTLINE_KEY` in production. This sees the full response, so it records status, duration and size.
51
+
52
+ ## Hono and other fetch handlers
38
53
 
39
54
  ```ts
40
55
  import { withScoutline } from "@scoutline-ai/middleware/fetch";
@@ -42,7 +57,7 @@ import { withScoutline } from "@scoutline-ai/middleware/fetch";
42
57
  export default { fetch: withScoutline(app.fetch) };
43
58
  ```
44
59
 
45
- This sees the full response, so it records status, duration and size.
60
+ This also records status, duration and size. If Hono runs on Workers, pass the key as shown above.
46
61
 
47
62
  ## Express and Fastify
48
63
 
@@ -74,13 +89,66 @@ It stores nothing and sets no cookies. A page links to its server request throug
74
89
 
75
90
  Browser events can be forged, so they are never `verified` and can't confirm an outcome. Reports keep them out of request counts.
76
91
 
92
+ ## Outcomes
93
+
94
+ Outcomes are what your server confirms really happened, such as an order or a signup. They are never inferred from a 200.
95
+
96
+ Create an outcome key on your Install page. It is separate from your middleware key.
97
+
98
+ ```sh
99
+ SCOUTLINE_OUTCOME_KEY=sl_live_...
100
+ ```
101
+
102
+ ```ts
103
+ import { createOutcomes } from "@scoutline-ai/middleware/outcomes";
104
+
105
+ const outcomes = createOutcomes();
106
+
107
+ // Runs your action, and confirms the outcome only if it succeeds.
108
+ const order = await outcomes.wrap("order.placed", () => placeOrder(cart), (order) => ({
109
+ id: order.id,
110
+ taskId,
111
+ headers: request.headers,
112
+ }));
113
+
114
+ // Or confirm it yourself.
115
+ await outcomes.confirm("signup.completed", { id: user.id, taskId });
116
+
117
+ // Record a step on the way, or where it failed.
118
+ await outcomes.record("checkout_attempted", { taskId, errorCategory: "card_declined" });
119
+ ```
120
+
121
+ - **`id`** makes repeats safe: the same id counts once. It is hashed before sending. Without it, every call counts.
122
+ - **`taskId`** is your id for one piece of work, and puts the event on that Workflow timeline.
123
+ - **`headers`** are the incoming request's headers, which record who acted and the trace id.
124
+
125
+ Nothing here throws, so a Scoutline problem can't fail your action. Each call returns `{ ok: true }` or `{ ok: false, reason }`. Events are sent straight away, at most twice for up to 2 seconds each, so don't await them if your action can't spare that.
126
+
127
+ ## Linking requests to a task
128
+
129
+ Add your own ids to requests with `context`. Only what you return here is sent.
130
+
131
+ ```ts
132
+ withScoutline(yourProxy, {
133
+ context: (request) => ({
134
+ taskId: request.headers.get("x-your-task-id"),
135
+ resourceId: productIdFrom(request),
136
+ contentVersion: process.env.VERCEL_GIT_COMMIT_SHA,
137
+ }),
138
+ });
139
+ ```
140
+
141
+ Requests that share a W3C `traceparent` with an event in the task also appear on its timeline, labelled as linked by trace.
142
+
77
143
  ## What is sent
78
144
 
79
- From the server: the method, pathname, classification from the user agent, and request and trace ids. From the browser: the pathname, event type, element type such as `button`, status, and load time.
145
+ From the server: the method, pathname, user agent, whether a few standard headers were present, and request and trace ids. Images, video, fonts and similar files are recorded for crawlers and agents, and skipped for everyone else, so one page load counts once. From the browser: the pathname, event type, element type such as `button`, status, and load time.
80
146
 
81
147
  Query strings, cookies, auth headers, bodies, page text and field values are never sent.
82
148
 
83
- A matching user agent is recorded as `declared`, because any client can send one. Other automation is `inferred`, and everything else is `unknown`. This package never reports `verified`.
149
+ **The visitor's IP address** is sent to the collector, which uses it to find the country and network, then discards it. It is never stored. Country is where the network is registered, so cloud traffic may not show where the machine really is. Turn this off with `sendClientIp: false`, and country and network stay empty.
150
+
151
+ The collector classifies each request, so new crawlers are recognised without updating this package. A matching user agent is recorded as `declared`, because any client can send one. Other automation is `inferred`, and everything else is `unknown`. A browser claim that doesn't add up is only `inferred` when two different kinds of evidence agree, such as missing browser headers on a hosting network. This package never reports `verified`.
84
152
 
85
153
  ## Options
86
154
 
@@ -90,3 +158,5 @@ A matching user agent is recorded as `declared`, because any client can send one
90
158
  | `endpoint` | `SCOUTLINE_ENDPOINT`, then `https://ingest.scoutline.ai` | Collector URL |
91
159
  | `sampleUnknown` | `1` | Share of unclassified traffic to send, from 0 to 1 |
92
160
  | `ignore` | none | `(pathname) => boolean` to skip a path |
161
+ | `sendClientIp` | `true` | Send the visitor's IP for the country and network lookup. It is never stored |
162
+ | `context` | none | `(request) => ({ taskId, sessionId, resourceId, contentVersion })` adds your own ids |
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Adapted from get-client-ip.ts in OpenPanel's Express SDK. The header order
3
+ * here puts platform-set headers first.
4
+ *
5
+ * MIT License
6
+ *
7
+ * Copyright (c) 2026 OpenPanel
8
+ *
9
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ * of this software and associated documentation files (the "Software"), to deal
11
+ * in the Software without restriction, including without limitation the rights
12
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ * copies of the Software, and to permit persons to whom the Software is
14
+ * furnished to do so, subject to the following conditions:
15
+ *
16
+ * The above copyright notice and this permission notice shall be included in
17
+ * all copies or substantial portions of the Software.
18
+ *
19
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ * SOFTWARE.
26
+ */
27
+ /** Node reports an IPv4 client as `::ffff:1.2.3.4` on a dual-stack socket. */
28
+ export declare function withoutIpv4Prefix(ip: string): string;
29
+ export declare function usableIp(value: string | null | undefined): string | undefined;
30
+ /** The first public address a proxy reported for the visitor. */
31
+ export declare function clientIpFromHeaders(headers: Headers): string | undefined;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Adapted from get-client-ip.ts in OpenPanel's Express SDK. The header order
3
+ * here puts platform-set headers first.
4
+ *
5
+ * MIT License
6
+ *
7
+ * Copyright (c) 2026 OpenPanel
8
+ *
9
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
10
+ * of this software and associated documentation files (the "Software"), to deal
11
+ * in the Software without restriction, including without limitation the rights
12
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
+ * copies of the Software, and to permit persons to whom the Software is
14
+ * furnished to do so, subject to the following conditions:
15
+ *
16
+ * The above copyright notice and this permission notice shall be included in
17
+ * all copies or substantial portions of the Software.
18
+ *
19
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
+ * SOFTWARE.
26
+ */
27
+ const HEADER_ORDER = [
28
+ "cf-connecting-ip",
29
+ "x-vercel-forwarded-for",
30
+ "true-client-ip",
31
+ "x-real-ip",
32
+ "x-forwarded-for",
33
+ "fastly-client-ip",
34
+ "x-cluster-client-ip",
35
+ "x-appengine-user-ip",
36
+ "do-connecting-ip",
37
+ "x-nf-client-connection-ip",
38
+ "forwarded",
39
+ ];
40
+ const IPV4 = /^(\d{1,3}\.){3}\d{1,3}$/;
41
+ const IPV6 = /^([0-9a-fA-F]{0,4}:){2,7}[0-9a-fA-F]{0,4}$/;
42
+ /** Node reports an IPv4 client as `::ffff:1.2.3.4` on a dual-stack socket. */
43
+ export function withoutIpv4Prefix(ip) {
44
+ return ip.startsWith("::ffff:") ? ip.slice("::ffff:".length) : ip;
45
+ }
46
+ function isPublicIp(address) {
47
+ const ip = withoutIpv4Prefix(address);
48
+ if (ip === "::1")
49
+ return false;
50
+ if (/^(fc|fd|fe8|fe9|fea|feb)/i.test(ip) && ip.includes(":"))
51
+ return false;
52
+ if (/^(127|10)\./.test(ip))
53
+ return false;
54
+ if (ip.startsWith("192.168.") || ip.startsWith("169.254."))
55
+ return false;
56
+ if (ip.startsWith("172.")) {
57
+ const second = Number.parseInt(ip.split(".")[1] ?? "0", 10);
58
+ if (second >= 16 && second <= 31)
59
+ return false;
60
+ }
61
+ return true;
62
+ }
63
+ export function usableIp(value) {
64
+ const ip = value ? withoutIpv4Prefix(value.trim()) : "";
65
+ if (!(IPV4.test(ip) || IPV6.test(ip)))
66
+ return undefined;
67
+ return isPublicIp(ip) ? ip : undefined;
68
+ }
69
+ /** The first public address a proxy reported for the visitor. */
70
+ export function clientIpFromHeaders(headers) {
71
+ for (const name of HEADER_ORDER) {
72
+ const value = headers.get(name);
73
+ if (!value)
74
+ continue;
75
+ const candidate = name === "forwarded"
76
+ ? /for="?\[?([^\]",;]+)/i.exec(value)?.[1]
77
+ : value.split(",")[0];
78
+ const ip = usableIp(candidate);
79
+ if (ip)
80
+ return ip;
81
+ }
82
+ return undefined;
83
+ }
@@ -0,0 +1,2 @@
1
+ export declare const DEFAULT_ENDPOINT = "https://ingest.scoutline.ai";
2
+ export declare function env(name: string): string | undefined;
package/dist/config.js ADDED
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_ENDPOINT = "https://ingest.scoutline.ai";
2
+ export function env(name) {
3
+ return typeof process === "undefined" ? undefined : process?.env?.[name];
4
+ }
package/dist/fetch.d.ts CHANGED
@@ -6,5 +6,8 @@ import { type ScoutlineOptions } from "./index.js";
6
6
  * also adds the browser script to HTML pages, so no code change is needed.
7
7
  *
8
8
  * export default { fetch: withScoutline(app.fetch) };
9
+ *
10
+ * Workers should pass `{ key: env.SCOUTLINE_KEY }` from `cloudflare:workers`,
11
+ * because older compatibility dates have no `process.env` to read it from.
9
12
  */
10
13
  export declare function withScoutline<Args extends unknown[]>(handler: (request: Request, ...args: Args) => Response | Promise<Response>, options?: ScoutlineOptions): (request: Request, ...args: Args) => Promise<Response>;
package/dist/fetch.js CHANGED
@@ -31,6 +31,9 @@ function contentLength(response) {
31
31
  * also adds the browser script to HTML pages, so no code change is needed.
32
32
  *
33
33
  * export default { fetch: withScoutline(app.fetch) };
34
+ *
35
+ * Workers should pass `{ key: env.SCOUTLINE_KEY }` from `cloudflare:workers`,
36
+ * because older compatibility dates have no `process.env` to read it from.
34
37
  */
35
38
  export function withScoutline(handler, options) {
36
39
  const scoutline = createScoutline(options);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,16 @@
1
1
  export { classify } from "./classify.js";
2
+ /**
3
+ * What your app knows about a request that Scoutline can't see. Everything is
4
+ * optional, and only what you return here leaves your server.
5
+ */
6
+ export type RequestContext = {
7
+ /** Your id for the piece of work, shared by the requests and outcomes in it. */
8
+ taskId?: string | null;
9
+ sessionId?: string | null;
10
+ /** The business object, such as a product, so its URLs count as one. */
11
+ resourceId?: string | null;
12
+ contentVersion?: string | null;
13
+ };
2
14
  export type ScoutlineOptions = {
3
15
  /** Ingest key. Falls back to the SCOUTLINE_KEY environment variable. */
4
16
  key?: string;
@@ -8,10 +20,19 @@ export type ScoutlineOptions = {
8
20
  sampleUnknown?: number;
9
21
  /** Skip a path entirely. Runs on the pathname only. */
10
22
  ignore?: (pathname: string) => boolean;
23
+ /** Adds your own ids to a request. A failure here only leaves them out. */
24
+ context?: (request: Request) => RequestContext | null | undefined;
25
+ /**
26
+ * Sends the visitor's IP so the collector can find its country and network.
27
+ * The collector never stores it. Default true.
28
+ */
29
+ sendClientIp?: boolean;
11
30
  };
12
31
  export type Observation = {
13
32
  /** Set by adapters that must announce the id before the response finishes. */
14
33
  eventId?: string;
34
+ /** Set by adapters that can see the connection, not just the headers. */
35
+ clientIp?: string;
15
36
  /** Null when the response isn't visible from this position in the stack. */
16
37
  status?: number | null;
17
38
  durationMs?: number | null;
@@ -30,13 +51,34 @@ export type Scoutline = {
30
51
  * Serves the browser script and its events from the site's own domain.
31
52
  * Returns null for every other path. Must run before any auth check.
32
53
  */
33
- handle(request: Request): Promise<Response> | null;
54
+ handle(request: Request, clientIp?: string): Promise<Response> | null;
34
55
  };
35
56
  /** Only full page loads carry a server id for the browser script to read. */
36
57
  export declare function isDocumentRequest(request: Request): boolean;
37
58
  export declare function serverTiming(eventId: string): string;
38
- /** The site's hostname. A proxy may list several, nearest client first. */
59
+ /** A proxy may list several hosts, nearest client first. */
60
+ export declare function hostFromHeaders(headers: Headers): string | null;
61
+ /** The site's hostname. */
39
62
  export declare function hostOf(request: Request): string | undefined;
63
+ /**
64
+ * What the collector needs to classify a request. Only whether these headers
65
+ * exist is sent, never their values, and rules live there so they can change
66
+ * without a new release of this package.
67
+ */
68
+ export declare function requestFacts(headers: Headers, options: {
69
+ sendClientIp?: boolean;
70
+ clientIp?: string;
71
+ }): {
72
+ userAgent: string | undefined;
73
+ clientIp: string | undefined;
74
+ requestShape: {
75
+ clientHints: boolean;
76
+ fetchMetadata: boolean;
77
+ acceptLanguage: boolean;
78
+ };
79
+ };
80
+ /** The W3C trace id from `traceparent`, a claim until something validates it. */
81
+ export declare function traceId(headers: Headers): string | undefined;
40
82
  /**
41
83
  * Failure here must never reach the customer's site. A missing key turns the
42
84
  * client into a no-op, and a full queue drops the oldest events.
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { EVENTS_PATH, isSameOrigin, parseBrowserBody, SCRIPT_PATH, toBrowserEvent, } from "./browser.js";
2
2
  import { BROWSER_SCRIPT } from "./browser-script.js";
3
3
  import { classify, CLASSIFIER_VERSION } from "./classify.js";
4
+ import { clientIpFromHeaders } from "./client-ip.js";
5
+ import { DEFAULT_ENDPOINT, env } from "./config.js";
4
6
  export { classify } from "./classify.js";
5
- const DEFAULT_ENDPOINT = "https://ingest.scoutline.ai";
6
7
  const MAX_QUEUED = 500;
7
8
  const MAX_BATCH = 100;
8
9
  const SEND_TIMEOUT_MS = 2000;
@@ -12,11 +13,17 @@ const REJECTED_BACKOFF_MS = 60 * 1000;
12
13
  const RATE_LIMITED_BACKOFF_MS = 60 * 1000;
13
14
  const MAX_FIELD_CHARS = 128;
14
15
  const MAX_URL_CHARS = 2048;
16
+ const MAX_USER_AGENT_CHARS = 512;
15
17
  /** Caps browser events per server instance, so a page can't flood the queue. */
16
18
  const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
17
19
  const SCRIPT_CACHE_SECONDS = 3600;
18
- /** Prefetches aren't visits, and assets aren't what agents come for. */
19
- const IGNORED_PATH = /^\/(?:_next|_vercel|\.well-known\/vercel)\/|\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf)$/i;
20
+ const FRAMEWORK_PATH = /^\/(?:_next|_vercel|\.well-known\/vercel)\//;
21
+ /**
22
+ * A person's page load fetches these as a side effect, so they would only add
23
+ * noise. A crawler chooses to fetch them, and they can be most of what it costs
24
+ * to serve, so they stay for anyone identified. PDFs always stay.
25
+ */
26
+ const ASSET_PATH = /\.(?:css|js|mjs|map|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|otf|mp4|m4v|webm|mov|ogv|mp3|m4a|aac|flac|wav|ogg|opus)$/i;
20
27
  /** Only full page loads carry a server id for the browser script to read. */
21
28
  export function isDocumentRequest(request) {
22
29
  return (request.method === "GET" &&
@@ -41,9 +48,6 @@ function ownRoute(request) {
41
48
  const { pathname } = new URL(request.url);
42
49
  return pathname === SCRIPT_PATH || pathname === EVENTS_PATH ? pathname : null;
43
50
  }
44
- function env(name) {
45
- return typeof process === "undefined" ? undefined : process?.env?.[name];
46
- }
47
51
  function limited(value, max = MAX_FIELD_CHARS) {
48
52
  return value && value.length <= max ? value : undefined;
49
53
  }
@@ -54,34 +58,65 @@ function interfaceFor(pathname, headers) {
54
58
  return "api";
55
59
  return headers.get("accept")?.includes("application/json") ? "api" : "web";
56
60
  }
57
- /** The site's hostname. A proxy may list several, nearest client first. */
61
+ /** A proxy may list several hosts, nearest client first. */
62
+ export function hostFromHeaders(headers) {
63
+ const forwarded = headers.get("x-forwarded-host")?.split(",")[0];
64
+ return forwarded?.trim() || headers.get("host") || null;
65
+ }
66
+ /** The site's hostname. */
58
67
  export function hostOf(request) {
59
- const forwarded = request.headers.get("x-forwarded-host")?.split(",")[0];
60
- const host = forwarded?.trim() ||
61
- request.headers.get("host") ||
62
- new URL(request.url).host;
68
+ const host = hostFromHeaders(request.headers) ?? new URL(request.url).host;
63
69
  return limited(host.toLowerCase(), 255);
64
70
  }
71
+ /**
72
+ * What the collector needs to classify a request. Only whether these headers
73
+ * exist is sent, never their values, and rules live there so they can change
74
+ * without a new release of this package.
75
+ */
76
+ export function requestFacts(headers, options) {
77
+ return {
78
+ userAgent: limited(headers.get("user-agent"), MAX_USER_AGENT_CHARS),
79
+ clientIp: options.sendClientIp === false
80
+ ? undefined
81
+ : (options.clientIp ?? clientIpFromHeaders(headers)),
82
+ requestShape: {
83
+ clientHints: headers.has("sec-ch-ua"),
84
+ fetchMetadata: headers.has("sec-fetch-mode") || headers.has("sec-fetch-site"),
85
+ acceptLanguage: headers.has("accept-language"),
86
+ },
87
+ };
88
+ }
65
89
  function isPrefetch(headers) {
66
90
  return (headers.has("next-router-prefetch") ||
67
91
  headers.get("purpose") === "prefetch" ||
68
92
  headers.get("sec-purpose")?.includes("prefetch") === true);
69
93
  }
70
94
  /** The W3C trace id from `traceparent`, a claim until something validates it. */
71
- function traceId(headers) {
95
+ export function traceId(headers) {
72
96
  return limited(headers.get("traceparent")?.split("-")[1] ?? null);
73
97
  }
74
- function toEvent(request, observation, sampleUnknown, ignore) {
98
+ function contextOf(request, read) {
99
+ try {
100
+ return read?.(request) ?? {};
101
+ }
102
+ catch {
103
+ return {};
104
+ }
105
+ }
106
+ function toEvent(request, observation, sampleUnknown, options) {
75
107
  const { pathname } = new URL(request.url);
76
- if (IGNORED_PATH.test(pathname) || ignore?.(pathname))
108
+ if (FRAMEWORK_PATH.test(pathname) || options.ignore?.(pathname))
77
109
  return null;
78
110
  const { headers } = request;
79
111
  if (isPrefetch(headers))
80
112
  return null;
81
113
  const actor = classify(headers.get("user-agent"));
114
+ if (actor.class === "unknown" && ASSET_PATH.test(pathname))
115
+ return null;
82
116
  const sampled = actor.class === "unknown" && sampleUnknown < 1;
83
117
  if (sampled && Math.random() >= sampleUnknown)
84
118
  return null;
119
+ const context = contextOf(request, options.context);
85
120
  return {
86
121
  eventId: observation.eventId ?? crypto.randomUUID(),
87
122
  occurredAt: Date.now(),
@@ -91,11 +126,19 @@ function toEvent(request, observation, sampleUnknown, ignore) {
91
126
  // The pathname only: queries, cookies, headers and bodies never leave here.
92
127
  url: pathname.slice(0, MAX_URL_CHARS),
93
128
  host: hostOf(request),
129
+ ...requestFacts(headers, {
130
+ sendClientIp: options.sendClientIp,
131
+ clientIp: observation.clientIp,
132
+ }),
94
133
  interface: interfaceFor(pathname, headers),
95
134
  requestId: limited(headers.get("x-vercel-id") ??
96
135
  headers.get("cf-ray") ??
97
136
  headers.get("x-request-id")),
98
137
  traceId: traceId(headers),
138
+ taskId: limited(context.taskId ?? null),
139
+ sessionId: limited(context.sessionId ?? null),
140
+ resourceId: limited(context.resourceId ?? null),
141
+ contentVersion: limited(context.contentVersion ?? null),
99
142
  status: observation.status ?? undefined,
100
143
  durationMs: observation.durationMs ?? undefined,
101
144
  bytes: observation.bytes ?? undefined,
@@ -212,7 +255,7 @@ export function createScoutline(options = {}) {
212
255
  }
213
256
  function observe(request, observation = {}) {
214
257
  try {
215
- const event = toEvent(request, observation, sampleUnknown, options.ignore);
258
+ const event = toEvent(request, observation, sampleUnknown, options);
216
259
  if (!event)
217
260
  return undefined;
218
261
  enqueue(event);
@@ -240,7 +283,7 @@ export function createScoutline(options = {}) {
240
283
  }
241
284
  return actor;
242
285
  }
243
- async function acceptBrowserEvents(request) {
286
+ async function acceptBrowserEvents(request, clientIp) {
244
287
  try {
245
288
  if (request.method !== "POST")
246
289
  return emptyResponse(405);
@@ -256,10 +299,14 @@ export function createScoutline(options = {}) {
256
299
  if (sampled && Math.random() >= sampleUnknown)
257
300
  return emptyResponse(204);
258
301
  const now = Date.now();
302
+ const facts = requestFacts(request.headers, {
303
+ sendClientIp: options.sendClientIp,
304
+ clientIp,
305
+ });
259
306
  for (const raw of body.events) {
260
307
  const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
261
308
  if (event)
262
- enqueue({ ...event, host: hostOf(request) });
309
+ enqueue({ ...event, ...facts, host: hostOf(request) });
263
310
  }
264
311
  return emptyResponse(204);
265
312
  }
@@ -267,11 +314,11 @@ export function createScoutline(options = {}) {
267
314
  return emptyResponse(204);
268
315
  }
269
316
  }
270
- function handle(request) {
317
+ function handle(request, clientIp) {
271
318
  const route = ownRoute(request);
272
319
  if (route === SCRIPT_PATH)
273
320
  return Promise.resolve(scriptResponse());
274
- return route ? acceptBrowserEvents(request) : null;
321
+ return route ? acceptBrowserEvents(request, clientIp) : null;
275
322
  }
276
323
  return { observe, flush, handle };
277
324
  }
package/dist/node.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { EVENTS_PATH, SCRIPT_PATH } from "./browser.js";
2
+ import { usableIp } from "./client-ip.js";
2
3
  import { createScoutline, isDocumentRequest, serverTiming, } from "./index.js";
3
4
  const MAX_BODY_BYTES = 32 * 1024;
4
5
  function toRequest(request, body) {
@@ -16,6 +17,13 @@ function toRequest(request, body) {
16
17
  body,
17
18
  });
18
19
  }
20
+ /**
21
+ * A public peer is the visitor, and can't fake that the way it can fake a
22
+ * header. A private one is a proxy, so its headers are used instead.
23
+ */
24
+ function connectionIp(request) {
25
+ return usableIp(request.socket?.remoteAddress);
26
+ }
19
27
  /** A body parser may have run first, and a stream can only be read once. */
20
28
  async function readBody(request) {
21
29
  if (typeof request.body === "string")
@@ -34,7 +42,7 @@ async function readBody(request) {
34
42
  }
35
43
  async function serveOwnRoute(scoutline, request, response) {
36
44
  const body = request.method === "POST" ? await readBody(request) : undefined;
37
- const answer = await scoutline.handle(toRequest(request, body));
45
+ const answer = await scoutline.handle(toRequest(request, body), connectionIp(request));
38
46
  const text = (await answer?.text()) ?? "";
39
47
  response.writeHead(answer?.status ?? 204, Object.fromEntries(answer?.headers ?? []));
40
48
  response.end(text);
@@ -63,6 +71,7 @@ function track(scoutline, request, response) {
63
71
  done = true;
64
72
  scoutline.observe(web, {
65
73
  eventId,
74
+ clientIp: connectionIp(request),
66
75
  status: completed ? response.statusCode : null,
67
76
  bytes: completed ? contentLength(response) : null,
68
77
  durationMs: Date.now() - started,
@@ -0,0 +1,56 @@
1
+ export type OutcomeOptions = {
2
+ /** Outcome key. Falls back to the SCOUTLINE_OUTCOME_KEY environment variable. */
3
+ key?: string;
4
+ /** Collector URL, for self-hosting or testing. Falls back to SCOUTLINE_ENDPOINT. */
5
+ endpoint?: string;
6
+ /**
7
+ * Sends the visitor's IP from `headers` so the collector can find its country
8
+ * and network. The collector never stores it. Default true.
9
+ */
10
+ sendClientIp?: boolean;
11
+ };
12
+ export type EventDetails = {
13
+ /**
14
+ * Makes repeats safe: the same id counts once, so use your order or signup
15
+ * id. It is hashed before sending, so the id itself never leaves your server.
16
+ */
17
+ id?: string;
18
+ /** Your id for the piece of work. Shows this event in its Workflow timeline. */
19
+ taskId?: string;
20
+ sessionId?: string;
21
+ resourceId?: string;
22
+ contentVersion?: string;
23
+ /** Your own opaque reference for who the agent acted for. Never an email. */
24
+ principalRef?: string;
25
+ accountRef?: string;
26
+ /** The incoming request's headers, to record who acted and the trace id. */
27
+ headers?: Headers | Record<string, string | string[] | undefined>;
28
+ errorCategory?: string;
29
+ status?: number;
30
+ durationMs?: number;
31
+ };
32
+ /** Something the server knows happened, short of a confirmed outcome. */
33
+ export type ServerEventFamily = "search" | "comparison" | "tool_invocation" | "authentication_required" | "permission_denied" | "quote_created" | "checkout_attempted" | "payment_required" | "payment_settled" | "approval_requested" | "human_takeover" | "workflow_resumed";
34
+ export type SendResult = {
35
+ ok: true;
36
+ } | {
37
+ ok: false;
38
+ reason: string;
39
+ };
40
+ /**
41
+ * For the things only your server can confirm. Outcomes are never inferred from
42
+ * a 200, because a page loading doesn't prove the order went through.
43
+ *
44
+ * Nothing here throws, so a Scoutline problem can't fail your action. Events
45
+ * are sent straight away rather than queued, because a serverless function can
46
+ * stop as soon as it returns. Each one is sent at most twice, up to 2 seconds
47
+ * each. Don't await it if your action can't spare that.
48
+ *
49
+ * const outcomes = createOutcomes();
50
+ * await outcomes.confirm("order.placed", { id: order.id, taskId });
51
+ */
52
+ export declare function createOutcomes(options?: OutcomeOptions): {
53
+ confirm: (outcomeKey: string, details?: EventDetails) => Promise<SendResult>;
54
+ record: (family: ServerEventFamily, details?: EventDetails) => Promise<SendResult>;
55
+ wrap: <Result>(outcomeKey: string, action: () => Promise<Result>, details?: EventDetails | ((result: Result) => EventDetails)) => Promise<Result>;
56
+ };
@@ -0,0 +1,144 @@
1
+ import { classify, CLASSIFIER_VERSION } from "./classify.js";
2
+ import { DEFAULT_ENDPOINT, env } from "./config.js";
3
+ import { hostFromHeaders, requestFacts, traceId } from "./index.js";
4
+ const SEND_TIMEOUT_MS = 2000;
5
+ const MAX_ID_CHARS = 128;
6
+ function toHeaders(headers) {
7
+ if (!headers || headers instanceof Headers)
8
+ return headers ?? new Headers();
9
+ const result = new Headers();
10
+ for (const [name, value] of Object.entries(headers)) {
11
+ if (value !== undefined) {
12
+ result.set(name, Array.isArray(value) ? value.join(", ") : value);
13
+ }
14
+ }
15
+ return result;
16
+ }
17
+ function bounded(value) {
18
+ return value && value.length <= MAX_ID_CHARS ? value : undefined;
19
+ }
20
+ async function stableId(parts) {
21
+ const bytes = new TextEncoder().encode(parts.join("\0"));
22
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
23
+ const hex = [...new Uint8Array(digest)]
24
+ .map((byte) => byte.toString(16).padStart(2, "0"))
25
+ .join("");
26
+ return `sdk_${hex.slice(0, 40)}`;
27
+ }
28
+ /** Retries can't help with these, so only a failed delivery goes again. */
29
+ function shouldRetry(status) {
30
+ return status === undefined || status >= 500;
31
+ }
32
+ /**
33
+ * For the things only your server can confirm. Outcomes are never inferred from
34
+ * a 200, because a page loading doesn't prove the order went through.
35
+ *
36
+ * Nothing here throws, so a Scoutline problem can't fail your action. Events
37
+ * are sent straight away rather than queued, because a serverless function can
38
+ * stop as soon as it returns. Each one is sent at most twice, up to 2 seconds
39
+ * each. Don't await it if your action can't spare that.
40
+ *
41
+ * const outcomes = createOutcomes();
42
+ * await outcomes.confirm("order.placed", { id: order.id, taskId });
43
+ */
44
+ export function createOutcomes(options = {}) {
45
+ const key = options.key ?? env("SCOUTLINE_OUTCOME_KEY");
46
+ const endpoint = (options.endpoint ||
47
+ env("SCOUTLINE_ENDPOINT") ||
48
+ DEFAULT_ENDPOINT).replace(/\/+$/, "");
49
+ if (!key) {
50
+ console.warn("[scoutline] Missing SCOUTLINE_OUTCOME_KEY, so outcomes aren't sent.");
51
+ }
52
+ async function post(event) {
53
+ let status;
54
+ try {
55
+ const response = await fetch(`${endpoint}/v1/events`, {
56
+ method: "POST",
57
+ headers: {
58
+ Authorization: `Bearer ${key}`,
59
+ "Content-Type": "application/json",
60
+ },
61
+ body: JSON.stringify({ events: [event] }),
62
+ signal: AbortSignal.timeout(SEND_TIMEOUT_MS),
63
+ });
64
+ status = response.status;
65
+ if (status !== 202) {
66
+ await response.body?.cancel();
67
+ return { status };
68
+ }
69
+ const body = (await response.json());
70
+ return { status, rejected: body.rejected?.[0]?.reason };
71
+ }
72
+ catch {
73
+ return { status };
74
+ }
75
+ }
76
+ async function send(family, outcomeKey, details) {
77
+ if (!key)
78
+ return { ok: false, reason: "no_key" };
79
+ try {
80
+ const headers = toHeaders(details.headers);
81
+ const userAgent = headers.get("user-agent");
82
+ const event = {
83
+ eventId: details.id
84
+ ? await stableId([family, outcomeKey ?? "", details.id])
85
+ : crypto.randomUUID(),
86
+ occurredAt: Date.now(),
87
+ family,
88
+ outcomeKey,
89
+ actor: classify(userAgent),
90
+ ...requestFacts(headers, { sendClientIp: options.sendClientIp }),
91
+ host: hostFromHeaders(headers)?.toLowerCase() ?? undefined,
92
+ interface: "app",
93
+ traceId: traceId(headers),
94
+ taskId: bounded(details.taskId),
95
+ sessionId: bounded(details.sessionId),
96
+ resourceId: bounded(details.resourceId),
97
+ contentVersion: bounded(details.contentVersion),
98
+ principalRef: bounded(details.principalRef),
99
+ accountRef: bounded(details.accountRef),
100
+ errorCategory: bounded(details.errorCategory),
101
+ status: details.status,
102
+ durationMs: details.durationMs,
103
+ sampleRate: 1,
104
+ classifierVersion: CLASSIFIER_VERSION,
105
+ };
106
+ let result = await post(event);
107
+ if (shouldRetry(result.status))
108
+ result = await post(event);
109
+ if (result.status === 202 && !result.rejected)
110
+ return { ok: true };
111
+ const reason = result.rejected ?? `status_${result.status ?? "none"}`;
112
+ console.warn(`[scoutline] ${family} wasn't accepted: ${reason}`);
113
+ return { ok: false, reason };
114
+ }
115
+ catch {
116
+ return { ok: false, reason: "failed" };
117
+ }
118
+ }
119
+ /** Records that the outcome really happened. */
120
+ function confirm(outcomeKey, details = {}) {
121
+ return send("outcome_completed", outcomeKey, details);
122
+ }
123
+ /** Records a server event, such as a failed checkout, on the timeline. */
124
+ function record(family, details = {}) {
125
+ return send(family, undefined, details);
126
+ }
127
+ /**
128
+ * Runs your action and confirms the outcome only if it succeeds. A failure is
129
+ * rethrown unchanged. Pass a function to take the id from the result.
130
+ */
131
+ async function wrap(outcomeKey, action, details = {}) {
132
+ const result = await action();
133
+ let resolved = {};
134
+ try {
135
+ resolved = typeof details === "function" ? details(result) : details;
136
+ }
137
+ catch {
138
+ // Observation is optional. The customer's action already succeeded.
139
+ }
140
+ await confirm(outcomeKey, resolved);
141
+ return result;
142
+ }
143
+ return { confirm, record, wrap };
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@scoutline-ai/middleware",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "In-path agent observation for Scoutline. Fails open and adds no work to the hot path.",
5
5
  "type": "module",
6
6
  "files": [
@@ -11,6 +11,10 @@
11
11
  "types": "./dist/index.d.ts",
12
12
  "default": "./dist/index.js"
13
13
  },
14
+ "./outcomes": {
15
+ "types": "./dist/outcomes.d.ts",
16
+ "default": "./dist/outcomes.js"
17
+ },
14
18
  "./next": {
15
19
  "types": "./dist/next.d.ts",
16
20
  "default": "./dist/next.js"