@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 +21 -0
- package/README.md +77 -5
- package/dist/browser-script.d.ts +4 -3
- package/dist/browser-script.js +15 -3
- package/dist/browser.d.ts +9 -0
- package/dist/browser.js +30 -0
- package/dist/client-ip.d.ts +31 -0
- package/dist/client-ip.js +83 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +4 -0
- package/dist/fetch.d.ts +3 -0
- package/dist/fetch.js +3 -0
- package/dist/index.d.ts +50 -2
- package/dist/index.js +81 -20
- package/dist/node.js +10 -1
- package/dist/outcomes.d.ts +56 -0
- package/dist/outcomes.js +144 -0
- package/package.json +6 -1
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 |
|
package/dist/browser-script.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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})();";
|
package/dist/browser-script.js
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
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
|
+
}
|
package/dist/config.d.ts
ADDED
package/dist/config.js
ADDED
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
|
-
/**
|
|
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
|
-
|
|
19
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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
|
+
};
|
package/dist/outcomes.js
ADDED
|
@@ -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.
|
|
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"
|