@scoutline-ai/middleware 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,45 @@
1
1
  {
2
2
  "name": "@scoutline-ai/middleware",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "In-path agent observation for Scoutline. Fails open and adds no work to the hot path.",
5
+ "type": "module",
6
+ "files": [
7
+ "dist"
8
+ ],
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ },
14
+ "./outcomes": {
15
+ "types": "./dist/outcomes.d.ts",
16
+ "default": "./dist/outcomes.js"
17
+ },
18
+ "./next": {
19
+ "types": "./dist/next.d.ts",
20
+ "default": "./dist/next.js"
21
+ },
22
+ "./fetch": {
23
+ "types": "./dist/fetch.d.ts",
24
+ "default": "./dist/fetch.js"
25
+ },
26
+ "./node": {
27
+ "types": "./dist/node.d.ts",
28
+ "default": "./dist/node.js"
29
+ }
30
+ },
31
+ "scripts": {
32
+ "build": "tsc -p tsconfig.json",
33
+ "prepublishOnly": "tsc -p tsconfig.json"
34
+ },
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "keywords": [
39
+ "scoutline",
40
+ "ai agents",
41
+ "analytics",
42
+ "middleware"
43
+ ],
44
+ "homepage": "https://scoutline.ai"
45
+ }