@scoutline-ai/middleware 0.1.0 → 0.3.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Scoutline
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
@@ -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,68 @@ 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
+ **Device details** come from the browser's Client Hints and, where your page runs the script, its major OS version, device model, whether it has a touch screen, and a screen size group such as `small`. Exact screen sizes and full versions are not sent, and these details are never combined into an identifier. Safari and Firefox share less, so some fields stay empty rather than guessed.
150
+
151
+ **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.
152
+
153
+ 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
154
 
85
155
  ## Options
86
156
 
@@ -90,3 +160,5 @@ A matching user agent is recorded as `declared`, because any client can send one
90
160
  | `endpoint` | `SCOUTLINE_ENDPOINT`, then `https://ingest.scoutline.ai` | Collector URL |
91
161
  | `sampleUnknown` | `1` | Share of unclassified traffic to send, from 0 to 1 |
92
162
  | `ignore` | none | `(pathname) => boolean` to skip a path |
163
+ | `sendClientIp` | `true` | Send the visitor's IP for the country and network lookup. It is never stored |
164
+ | `context` | none | `(request) => ({ taskId, sessionId, resourceId, contentVersion })` adds your own ids |
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Served from the customer's own domain at /_scoutline/s. It stays plain ES5 and
3
3
  * dependency free so it can load on any page. It collects no text, field values
4
- * or query strings, and stores nothing, so no cookie or consent is involved.
5
- * Everything it sends is an unverified claim and is revalidated in `browser.ts`.
4
+ * or query strings, and stores nothing. Device details stay coarse, such as a
5
+ * screen size group, and are never combined into an identifier. Everything it
6
+ * sends is an unverified claim and is revalidated in `browser.ts`.
6
7
  */
7
- export declare const BROWSER_SCRIPT = "(function(){\n var ENDPOINT=\"/_scoutline/e\",MAX=20,queue=[],path=location.pathname;\n var nav=performance.getEntriesByType?performance.getEntriesByType(\"navigation\")[0]:null;\n var automated=navigator.webdriver===true;\n function serverId(){\n var timing=nav&&nav.serverTiming;\n for(var i=0;timing&&i<timing.length;i++){\n if(timing[i].name===\"scoutline\")return timing[i].description;\n }\n }\n function send(){\n if(!queue.length)return;\n var now=Date.now(),events=[];\n for(var i=0;i<queue.length;i++){\n var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);\n }\n queue=[];\n var body=JSON.stringify({automated:automated,events:events});\n if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){\n fetch(ENDPOINT,{method:\"POST\",body:body,keepalive:true})[\"catch\"](function(){});\n }\n }\n function add(type,extra){\n var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();\n queue.push(e);\n if(queue.length>=MAX)send();\n }\n function firstView(){\n add(\"page_view\",{\n parent:serverId(),\n status:nav&&nav.responseStatus?nav.responseStatus:undefined,\n durationMs:nav&&nav.duration?Math.round(nav.duration):undefined\n });\n }\n function changed(){\n if(location.pathname===path)return;\n path=location.pathname;add(\"page_view\");\n }\n [\"pushState\",\"replaceState\"].forEach(function(name){\n var original=history[name];\n history[name]=function(){var r=original.apply(this,arguments);changed();return r;};\n });\n addEventListener(\"popstate\",changed);\n document.addEventListener(\"click\",function(event){\n var t=event.target&&event.target.closest&&event.target.closest(\"a,button,[role=button],input[type=submit]\");\n if(t)add(\"click\",{element:t.tagName.toLowerCase()});\n },true);\n document.addEventListener(\"submit\",function(){add(\"form_submit\");},true);\n addEventListener(\"error\",function(event){if(event.message)add(\"error\");});\n addEventListener(\"unhandledrejection\",function(){add(\"error\");});\n addEventListener(\"pagehide\",send);\n document.addEventListener(\"visibilitychange\",function(){\n if(document.visibilityState===\"hidden\")send();\n });\n if(document.readyState===\"complete\")setTimeout(firstView);\n else addEventListener(\"load\",function(){setTimeout(firstView);});\n})();";
8
+ export declare const BROWSER_SCRIPT = "(function(){\n var ENDPOINT=\"/_scoutline/e\",MAX=20,queue=[],path=location.pathname;\n var nav=performance.getEntriesByType?performance.getEntriesByType(\"navigation\")[0]:null;\n var automated=navigator.webdriver===true;\n var device={touch:navigator.maxTouchPoints>0};\n var shortSide=window.screen?Math.min(screen.width,screen.height):0;\n if(shortSide)device.screen=shortSide<600?\"small\":shortSide<900?\"medium\":\"large\";\n if(window.matchMedia)device.coarsePointer=matchMedia(\"(pointer:coarse)\").matches;\n var agent=navigator.userAgentData;\n if(agent&&agent.getHighEntropyValues){\n agent.getHighEntropyValues([\"platformVersion\",\"model\"]).then(function(values){\n device.platform=agent.platform;device.mobile=agent.mobile;\n device.platformVersion=values.platformVersion;device.model=values.model;\n })[\"catch\"](function(){});\n }\n function serverId(){\n var timing=nav&&nav.serverTiming;\n for(var i=0;timing&&i<timing.length;i++){\n if(timing[i].name===\"scoutline\")return timing[i].description;\n }\n }\n function send(){\n if(!queue.length)return;\n var now=Date.now(),events=[];\n for(var i=0;i<queue.length;i++){\n var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);\n }\n queue=[];\n var body=JSON.stringify({automated:automated,device:device,events:events});\n if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){\n fetch(ENDPOINT,{method:\"POST\",body:body,keepalive:true})[\"catch\"](function(){});\n }\n }\n function add(type,extra){\n var e=extra||{};e.type=type;e.path=location.pathname;e.at=Date.now();\n queue.push(e);\n if(queue.length>=MAX)send();\n }\n function firstView(){\n add(\"page_view\",{\n parent:serverId(),\n status:nav&&nav.responseStatus?nav.responseStatus:undefined,\n durationMs:nav&&nav.duration?Math.round(nav.duration):undefined\n });\n }\n function changed(){\n if(location.pathname===path)return;\n path=location.pathname;add(\"page_view\");\n }\n [\"pushState\",\"replaceState\"].forEach(function(name){\n var original=history[name];\n history[name]=function(){var r=original.apply(this,arguments);changed();return r;};\n });\n addEventListener(\"popstate\",changed);\n document.addEventListener(\"click\",function(event){\n var t=event.target&&event.target.closest&&event.target.closest(\"a,button,[role=button],input[type=submit]\");\n if(t)add(\"click\",{element:t.tagName.toLowerCase()});\n },true);\n document.addEventListener(\"submit\",function(){add(\"form_submit\");},true);\n addEventListener(\"error\",function(event){if(event.message)add(\"error\");});\n addEventListener(\"unhandledrejection\",function(){add(\"error\");});\n addEventListener(\"pagehide\",send);\n document.addEventListener(\"visibilitychange\",function(){\n if(document.visibilityState===\"hidden\")send();\n });\n if(document.readyState===\"complete\")setTimeout(firstView);\n else addEventListener(\"load\",function(){setTimeout(firstView);});\n})();";
@@ -1,13 +1,25 @@
1
1
  /**
2
2
  * Served from the customer's own domain at /_scoutline/s. It stays plain ES5 and
3
3
  * dependency free so it can load on any page. It collects no text, field values
4
- * or query strings, and stores nothing, so no cookie or consent is involved.
5
- * Everything it sends is an unverified claim and is revalidated in `browser.ts`.
4
+ * or query strings, and stores nothing. Device details stay coarse, such as a
5
+ * screen size group, and are never combined into an identifier. Everything it
6
+ * sends is an unverified claim and is revalidated in `browser.ts`.
6
7
  */
7
8
  export const BROWSER_SCRIPT = `(function(){
8
9
  var ENDPOINT="/_scoutline/e",MAX=20,queue=[],path=location.pathname;
9
10
  var nav=performance.getEntriesByType?performance.getEntriesByType("navigation")[0]:null;
10
11
  var automated=navigator.webdriver===true;
12
+ var device={touch:navigator.maxTouchPoints>0};
13
+ var shortSide=window.screen?Math.min(screen.width,screen.height):0;
14
+ if(shortSide)device.screen=shortSide<600?"small":shortSide<900?"medium":"large";
15
+ if(window.matchMedia)device.coarsePointer=matchMedia("(pointer:coarse)").matches;
16
+ var agent=navigator.userAgentData;
17
+ if(agent&&agent.getHighEntropyValues){
18
+ agent.getHighEntropyValues(["platformVersion","model"]).then(function(values){
19
+ device.platform=agent.platform;device.mobile=agent.mobile;
20
+ device.platformVersion=values.platformVersion;device.model=values.model;
21
+ })["catch"](function(){});
22
+ }
11
23
  function serverId(){
12
24
  var timing=nav&&nav.serverTiming;
13
25
  for(var i=0;timing&&i<timing.length;i++){
@@ -21,7 +33,7 @@ export const BROWSER_SCRIPT = `(function(){
21
33
  var e=queue[i];e.ageMs=now-e.at;delete e.at;events.push(e);
22
34
  }
23
35
  queue=[];
24
- var body=JSON.stringify({automated:automated,events:events});
36
+ var body=JSON.stringify({automated:automated,device:device,events:events});
25
37
  if(!navigator.sendBeacon||!navigator.sendBeacon(ENDPOINT,body)){
26
38
  fetch(ENDPOINT,{method:"POST",body:body,keepalive:true})["catch"](function(){});
27
39
  }
package/dist/browser.d.ts CHANGED
@@ -29,5 +29,14 @@ export declare function toBrowserEvent(raw: unknown, actor: Actor, now: number,
29
29
  } | null;
30
30
  export declare function parseBrowserBody(text: string): {
31
31
  automated: boolean;
32
+ device: {
33
+ touch: boolean | undefined;
34
+ coarsePointer: boolean | undefined;
35
+ mobile: boolean | undefined;
36
+ screen: string | undefined;
37
+ platform: string | undefined;
38
+ platformVersion: string | undefined;
39
+ model: string | undefined;
40
+ } | undefined;
32
41
  events: unknown[];
33
42
  } | null;
package/dist/browser.js CHANGED
@@ -9,6 +9,10 @@ const MAX_DURATION_MS = 60 * 60 * 1000;
9
9
  const TYPES = new Set(["page_view", "click", "form_submit", "error"]);
10
10
  const ELEMENTS = new Set(["a", "button", "input", "div", "span"]);
11
11
  const SERVER_ID = /^[0-9a-f-]{36}$/;
12
+ const SCREENS = new Set(["small", "medium", "large"]);
13
+ const MAX_MODEL_CHARS = 64;
14
+ const PLATFORM = /^[A-Za-z][A-Za-z0-9 ]{0,31}$/;
15
+ const MODEL = /^[\w .()+-]+$/;
12
16
  /**
13
17
  * Anything in the page can be forged, so only a same-origin browser request is
14
18
  * accepted. This keeps out other sites, not a determined client, which is why
@@ -82,6 +86,31 @@ export function toBrowserEvent(raw, actor, now, sampleRate) {
82
86
  classifierVersion: BROWSER_CLASSIFIER_VERSION,
83
87
  };
84
88
  }
89
+ /** Forged values can only mislabel the page that sent them. */
90
+ function deviceOf(raw) {
91
+ if (typeof raw !== "object" || raw === null)
92
+ return undefined;
93
+ const { coarsePointer, mobile, model, platform, platformVersion, screen, touch } = raw;
94
+ const major = typeof platformVersion === "string"
95
+ ? /^\d{1,4}/.exec(platformVersion)?.[0]
96
+ : undefined;
97
+ return {
98
+ touch: typeof touch === "boolean" ? touch : undefined,
99
+ coarsePointer: typeof coarsePointer === "boolean" ? coarsePointer : undefined,
100
+ mobile: typeof mobile === "boolean" ? mobile : undefined,
101
+ screen: typeof screen === "string" && SCREENS.has(screen) ? screen : undefined,
102
+ platform: typeof platform === "string" && PLATFORM.test(platform)
103
+ ? platform
104
+ : undefined,
105
+ // The major version is enough, and the rest narrows down one machine.
106
+ platformVersion: major,
107
+ model: typeof model === "string" &&
108
+ model.length <= MAX_MODEL_CHARS &&
109
+ MODEL.test(model)
110
+ ? model
111
+ : undefined,
112
+ };
113
+ }
85
114
  export function parseBrowserBody(text) {
86
115
  if (text.length > MAX_BODY_CHARS)
87
116
  return null;
@@ -92,6 +121,7 @@ export function parseBrowserBody(text) {
92
121
  }
93
122
  return {
94
123
  automated: body.automated === true,
124
+ device: deviceOf(body.device),
95
125
  events: body.events,
96
126
  };
97
127
  }
@@ -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,40 @@ 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 and name its device. The
65
+ * three Client Hints are the browser's own low-detail summary. For the others,
66
+ * only whether a header exists is sent. Rules live on the collector, so they can
67
+ * change without a new release of this package.
68
+ */
69
+ export declare function requestFacts(headers: Headers, options: {
70
+ sendClientIp?: boolean;
71
+ clientIp?: string;
72
+ }): {
73
+ userAgent: string | undefined;
74
+ clientIp: string | undefined;
75
+ clientHints: {
76
+ brands: string | undefined;
77
+ mobile: string | undefined;
78
+ platform: string | undefined;
79
+ };
80
+ requestShape: {
81
+ clientHints: boolean;
82
+ fetchMetadata: boolean;
83
+ acceptLanguage: boolean;
84
+ };
85
+ };
86
+ /** The W3C trace id from `traceparent`, a claim until something validates it. */
87
+ export declare function traceId(headers: Headers): string | undefined;
40
88
  /**
41
89
  * Failure here must never reach the customer's site. A missing key turns the
42
90
  * 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,18 @@ 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;
17
+ const MAX_HINT_CHARS = 256;
15
18
  /** Caps browser events per server instance, so a page can't flood the queue. */
16
19
  const MAX_BROWSER_EVENTS_PER_MINUTE = 2000;
17
20
  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;
21
+ const FRAMEWORK_PATH = /^\/(?:_next|_vercel|\.well-known\/vercel)\//;
22
+ /**
23
+ * A person's page load fetches these as a side effect, so they would only add
24
+ * noise. A crawler chooses to fetch them, and they can be most of what it costs
25
+ * to serve, so they stay for anyone identified. PDFs always stay.
26
+ */
27
+ 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
28
  /** Only full page loads carry a server id for the browser script to read. */
21
29
  export function isDocumentRequest(request) {
22
30
  return (request.method === "GET" &&
@@ -41,9 +49,6 @@ function ownRoute(request) {
41
49
  const { pathname } = new URL(request.url);
42
50
  return pathname === SCRIPT_PATH || pathname === EVENTS_PATH ? pathname : null;
43
51
  }
44
- function env(name) {
45
- return typeof process === "undefined" ? undefined : process?.env?.[name];
46
- }
47
52
  function limited(value, max = MAX_FIELD_CHARS) {
48
53
  return value && value.length <= max ? value : undefined;
49
54
  }
@@ -54,34 +59,71 @@ function interfaceFor(pathname, headers) {
54
59
  return "api";
55
60
  return headers.get("accept")?.includes("application/json") ? "api" : "web";
56
61
  }
57
- /** The site's hostname. A proxy may list several, nearest client first. */
62
+ /** A proxy may list several hosts, nearest client first. */
63
+ export function hostFromHeaders(headers) {
64
+ const forwarded = headers.get("x-forwarded-host")?.split(",")[0];
65
+ return forwarded?.trim() || headers.get("host") || null;
66
+ }
67
+ /** The site's hostname. */
58
68
  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;
69
+ const host = hostFromHeaders(request.headers) ?? new URL(request.url).host;
63
70
  return limited(host.toLowerCase(), 255);
64
71
  }
72
+ /**
73
+ * What the collector needs to classify a request and name its device. The
74
+ * three Client Hints are the browser's own low-detail summary. For the others,
75
+ * only whether a header exists is sent. Rules live on the collector, so they can
76
+ * change without a new release of this package.
77
+ */
78
+ export function requestFacts(headers, options) {
79
+ return {
80
+ userAgent: limited(headers.get("user-agent"), MAX_USER_AGENT_CHARS),
81
+ clientIp: options.sendClientIp === false
82
+ ? undefined
83
+ : (options.clientIp ?? clientIpFromHeaders(headers)),
84
+ clientHints: {
85
+ brands: limited(headers.get("sec-ch-ua"), MAX_HINT_CHARS),
86
+ mobile: limited(headers.get("sec-ch-ua-mobile"), MAX_HINT_CHARS),
87
+ platform: limited(headers.get("sec-ch-ua-platform"), MAX_HINT_CHARS),
88
+ },
89
+ requestShape: {
90
+ clientHints: headers.has("sec-ch-ua"),
91
+ fetchMetadata: headers.has("sec-fetch-mode") || headers.has("sec-fetch-site"),
92
+ acceptLanguage: headers.has("accept-language"),
93
+ },
94
+ };
95
+ }
65
96
  function isPrefetch(headers) {
66
97
  return (headers.has("next-router-prefetch") ||
67
98
  headers.get("purpose") === "prefetch" ||
68
99
  headers.get("sec-purpose")?.includes("prefetch") === true);
69
100
  }
70
101
  /** The W3C trace id from `traceparent`, a claim until something validates it. */
71
- function traceId(headers) {
102
+ export function traceId(headers) {
72
103
  return limited(headers.get("traceparent")?.split("-")[1] ?? null);
73
104
  }
74
- function toEvent(request, observation, sampleUnknown, ignore) {
105
+ function contextOf(request, read) {
106
+ try {
107
+ return read?.(request) ?? {};
108
+ }
109
+ catch {
110
+ return {};
111
+ }
112
+ }
113
+ function toEvent(request, observation, sampleUnknown, options) {
75
114
  const { pathname } = new URL(request.url);
76
- if (IGNORED_PATH.test(pathname) || ignore?.(pathname))
115
+ if (FRAMEWORK_PATH.test(pathname) || options.ignore?.(pathname))
77
116
  return null;
78
117
  const { headers } = request;
79
118
  if (isPrefetch(headers))
80
119
  return null;
81
120
  const actor = classify(headers.get("user-agent"));
121
+ if (actor.class === "unknown" && ASSET_PATH.test(pathname))
122
+ return null;
82
123
  const sampled = actor.class === "unknown" && sampleUnknown < 1;
83
124
  if (sampled && Math.random() >= sampleUnknown)
84
125
  return null;
126
+ const context = contextOf(request, options.context);
85
127
  return {
86
128
  eventId: observation.eventId ?? crypto.randomUUID(),
87
129
  occurredAt: Date.now(),
@@ -91,11 +133,19 @@ function toEvent(request, observation, sampleUnknown, ignore) {
91
133
  // The pathname only: queries, cookies, headers and bodies never leave here.
92
134
  url: pathname.slice(0, MAX_URL_CHARS),
93
135
  host: hostOf(request),
136
+ ...requestFacts(headers, {
137
+ sendClientIp: options.sendClientIp,
138
+ clientIp: observation.clientIp,
139
+ }),
94
140
  interface: interfaceFor(pathname, headers),
95
141
  requestId: limited(headers.get("x-vercel-id") ??
96
142
  headers.get("cf-ray") ??
97
143
  headers.get("x-request-id")),
98
144
  traceId: traceId(headers),
145
+ taskId: limited(context.taskId ?? null),
146
+ sessionId: limited(context.sessionId ?? null),
147
+ resourceId: limited(context.resourceId ?? null),
148
+ contentVersion: limited(context.contentVersion ?? null),
99
149
  status: observation.status ?? undefined,
100
150
  durationMs: observation.durationMs ?? undefined,
101
151
  bytes: observation.bytes ?? undefined,
@@ -212,7 +262,7 @@ export function createScoutline(options = {}) {
212
262
  }
213
263
  function observe(request, observation = {}) {
214
264
  try {
215
- const event = toEvent(request, observation, sampleUnknown, options.ignore);
265
+ const event = toEvent(request, observation, sampleUnknown, options);
216
266
  if (!event)
217
267
  return undefined;
218
268
  enqueue(event);
@@ -240,7 +290,7 @@ export function createScoutline(options = {}) {
240
290
  }
241
291
  return actor;
242
292
  }
243
- async function acceptBrowserEvents(request) {
293
+ async function acceptBrowserEvents(request, clientIp) {
244
294
  try {
245
295
  if (request.method !== "POST")
246
296
  return emptyResponse(405);
@@ -256,10 +306,21 @@ export function createScoutline(options = {}) {
256
306
  if (sampled && Math.random() >= sampleUnknown)
257
307
  return emptyResponse(204);
258
308
  const now = Date.now();
309
+ const facts = requestFacts(request.headers, {
310
+ sendClientIp: options.sendClientIp,
311
+ clientIp,
312
+ });
259
313
  for (const raw of body.events) {
260
314
  const event = toBrowserEvent(raw, actor, now, sampled ? sampleUnknown : 1);
261
- if (event)
262
- enqueue({ ...event, host: hostOf(request) });
315
+ if (!event)
316
+ continue;
317
+ const device = event.action === "browser:page_view" ? body.device : null;
318
+ enqueue({
319
+ ...event,
320
+ ...facts,
321
+ host: hostOf(request),
322
+ device: device ?? undefined,
323
+ });
263
324
  }
264
325
  return emptyResponse(204);
265
326
  }
@@ -267,11 +328,11 @@ export function createScoutline(options = {}) {
267
328
  return emptyResponse(204);
268
329
  }
269
330
  }
270
- function handle(request) {
331
+ function handle(request, clientIp) {
271
332
  const route = ownRoute(request);
272
333
  if (route === SCRIPT_PATH)
273
334
  return Promise.resolve(scriptResponse());
274
- return route ? acceptBrowserEvents(request) : null;
335
+ return route ? acceptBrowserEvents(request, clientIp) : null;
275
336
  }
276
337
  return { observe, flush, handle };
277
338
  }
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,7 @@
1
1
  {
2
2
  "name": "@scoutline-ai/middleware",
3
- "version": "0.1.0",
3
+ "version": "0.3.0",
4
+ "license": "MIT",
4
5
  "description": "In-path agent observation for Scoutline. Fails open and adds no work to the hot path.",
5
6
  "type": "module",
6
7
  "files": [
@@ -11,6 +12,10 @@
11
12
  "types": "./dist/index.d.ts",
12
13
  "default": "./dist/index.js"
13
14
  },
15
+ "./outcomes": {
16
+ "types": "./dist/outcomes.d.ts",
17
+ "default": "./dist/outcomes.js"
18
+ },
14
19
  "./next": {
15
20
  "types": "./dist/next.d.ts",
16
21
  "default": "./dist/next.js"