@bounded-sh/observe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +167 -0
- package/dist/context.d.ts +18 -0
- package/dist/context.js +105 -0
- package/dist/counters.d.ts +34 -0
- package/dist/counters.js +141 -0
- package/dist/denylist.d.ts +7 -0
- package/dist/denylist.js +81 -0
- package/dist/envelope.d.ts +71 -0
- package/dist/envelope.js +20 -0
- package/dist/escort.d.ts +92 -0
- package/dist/escort.js +269 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +274 -0
- package/dist/intercept/fetch.d.ts +2 -0
- package/dist/intercept/fetch.js +534 -0
- package/dist/intercept/http.d.ts +2 -0
- package/dist/intercept/http.js +262 -0
- package/dist/llm-prices.d.ts +136 -0
- package/dist/llm-prices.js +240 -0
- package/dist/manifest.d.ts +168 -0
- package/dist/manifest.js +941 -0
- package/dist/pipeline.d.ts +18 -0
- package/dist/pipeline.js +212 -0
- package/dist/recognize.d.ts +38 -0
- package/dist/recognize.js +185 -0
- package/dist/register.d.ts +9 -0
- package/dist/register.js +138 -0
- package/dist/reporter.d.ts +27 -0
- package/dist/reporter.js +210 -0
- package/dist/shapes.d.ts +15 -0
- package/dist/shapes.js +56 -0
- package/dist/state.d.ts +32 -0
- package/dist/state.js +42 -0
- package/dist/template.d.ts +7 -0
- package/dist/template.js +63 -0
- package/dist/types.d.ts +125 -0
- package/dist/types.js +10 -0
- package/package.json +43 -0
package/dist/reporter.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// reporter.ts -- async batch reporting (SPEC §3.1a).
|
|
4
|
+
//
|
|
5
|
+
// Fire-and-forget POST {events} to <ingestBase>/v1/events with the sensor
|
|
6
|
+
// token; batches of <= 500 (MAX_BATCH_EVENTS), 2s cadence (driven by the
|
|
7
|
+
// shared tick in index.ts) or immediately at 500 queued. Bounded memory:
|
|
8
|
+
// queue overflow drops the newest event and counts it; drops are reported on
|
|
9
|
+
// the first event of the next successful batch via `dropped` (honest counts).
|
|
10
|
+
// One retry for retryable failures (network/timeout/429/5xx); terminal
|
|
11
|
+
// failures (401/400/413) drop + count. Uses the ORIGINAL pre-patch fetch so
|
|
12
|
+
// reporting is never itself captured.
|
|
13
|
+
// ---------------------------------------------------------------------------
|
|
14
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
15
|
+
exports.Reporter = void 0;
|
|
16
|
+
const state_1 = require("./state");
|
|
17
|
+
const MAX_BATCH = 500; // == observe-shared MAX_BATCH_EVENTS (asserted in tests)
|
|
18
|
+
class Reporter {
|
|
19
|
+
maxQueue;
|
|
20
|
+
sendTimeoutMs;
|
|
21
|
+
queue = [];
|
|
22
|
+
retry = null;
|
|
23
|
+
/** In-flight send (single-flight). drain() AWAITS it — never abandons it. */
|
|
24
|
+
inFlight = null;
|
|
25
|
+
immediateScheduled = false;
|
|
26
|
+
/** Drops awaiting report on the next batch's first event. */
|
|
27
|
+
pendingDrops = 0;
|
|
28
|
+
totalDropped = 0;
|
|
29
|
+
sentEvents = 0;
|
|
30
|
+
sentBatches = 0;
|
|
31
|
+
sendFailures = 0;
|
|
32
|
+
constructor(maxQueue, sendTimeoutMs) {
|
|
33
|
+
this.maxQueue = maxQueue;
|
|
34
|
+
this.sendTimeoutMs = sendTimeoutMs;
|
|
35
|
+
}
|
|
36
|
+
get queueDepth() {
|
|
37
|
+
return this.queue.length + (this.retry?.events.length ?? 0);
|
|
38
|
+
}
|
|
39
|
+
countDrop(n = 1) {
|
|
40
|
+
this.pendingDrops += n;
|
|
41
|
+
this.totalDropped += n;
|
|
42
|
+
}
|
|
43
|
+
push(ev) {
|
|
44
|
+
if (this.queue.length >= this.maxQueue) {
|
|
45
|
+
this.countDrop(1);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
this.queue.push(ev);
|
|
49
|
+
try {
|
|
50
|
+
state_1.state.config?.onEvent?.(ev.wire, ev.fields);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
/* debug hook errors never propagate */
|
|
54
|
+
}
|
|
55
|
+
if (this.queue.length >= MAX_BATCH && !this.immediateScheduled) {
|
|
56
|
+
this.immediateScheduled = true;
|
|
57
|
+
const t = setTimeout(() => {
|
|
58
|
+
this.immediateScheduled = false;
|
|
59
|
+
void this.flushOnce();
|
|
60
|
+
}, 0);
|
|
61
|
+
t.unref?.();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/** Send at most one batch. Single-flight; never throws. */
|
|
65
|
+
async flushOnce() {
|
|
66
|
+
if (this.inFlight)
|
|
67
|
+
return;
|
|
68
|
+
if (!state_1.state.enabled)
|
|
69
|
+
return;
|
|
70
|
+
const run = (async () => {
|
|
71
|
+
try {
|
|
72
|
+
await this.sendNext();
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
(0, state_1.noteInternalError)("reporter.flushOnce", err);
|
|
76
|
+
}
|
|
77
|
+
})();
|
|
78
|
+
this.inFlight = run;
|
|
79
|
+
try {
|
|
80
|
+
await run;
|
|
81
|
+
}
|
|
82
|
+
finally {
|
|
83
|
+
this.inFlight = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Drain everything (graceful shutdown / tests). WAITS for any in-flight
|
|
87
|
+
* send first — a shutdown never abandons a batch mid-air. Never throws. */
|
|
88
|
+
async drain() {
|
|
89
|
+
// Wait out in-flight sends (bounded: each send has its own timeout).
|
|
90
|
+
let waitGuard = 8;
|
|
91
|
+
while (this.inFlight && waitGuard-- > 0) {
|
|
92
|
+
try {
|
|
93
|
+
await this.inFlight;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
/* sendNext never throws, belt-and-suspenders */
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const run = (async () => {
|
|
100
|
+
try {
|
|
101
|
+
// Bounded loop: at most queue/MAX_BATCH + retries iterations.
|
|
102
|
+
let guard = Math.ceil(this.maxQueue / MAX_BATCH) + 4;
|
|
103
|
+
while ((this.queue.length > 0 || this.retry) && guard-- > 0) {
|
|
104
|
+
const before = this.queueDepth;
|
|
105
|
+
await this.sendNext();
|
|
106
|
+
if (this.queueDepth >= before)
|
|
107
|
+
break; // no progress (e.g. offline)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
(0, state_1.noteInternalError)("reporter.drain", err);
|
|
112
|
+
}
|
|
113
|
+
})();
|
|
114
|
+
this.inFlight = run;
|
|
115
|
+
try {
|
|
116
|
+
await run;
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
this.inFlight = null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
takeBatch() {
|
|
123
|
+
if (this.retry) {
|
|
124
|
+
const r = this.retry;
|
|
125
|
+
this.retry = null;
|
|
126
|
+
return r;
|
|
127
|
+
}
|
|
128
|
+
if (this.queue.length === 0)
|
|
129
|
+
return null;
|
|
130
|
+
const items = this.queue.splice(0, MAX_BATCH);
|
|
131
|
+
const events = items.map((i) => i.wire);
|
|
132
|
+
// Report accumulated drops on the first event of this batch.
|
|
133
|
+
let claimedDrops = 0;
|
|
134
|
+
if (this.pendingDrops > 0) {
|
|
135
|
+
claimedDrops = this.pendingDrops;
|
|
136
|
+
this.pendingDrops = 0;
|
|
137
|
+
events[0] = { ...events[0], dropped: claimedDrops };
|
|
138
|
+
}
|
|
139
|
+
return { events, attempts: 0, claimedDrops };
|
|
140
|
+
}
|
|
141
|
+
async sendNext() {
|
|
142
|
+
const batch = this.takeBatch();
|
|
143
|
+
if (!batch)
|
|
144
|
+
return;
|
|
145
|
+
const cfg = state_1.state.config;
|
|
146
|
+
const doFetch = state_1.state.originalFetch;
|
|
147
|
+
if (!cfg || !doFetch) {
|
|
148
|
+
this.countDrop(batch.events.length);
|
|
149
|
+
this.pendingDrops += batch.claimedDrops;
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let status = 0;
|
|
153
|
+
try {
|
|
154
|
+
const ac = new AbortController();
|
|
155
|
+
const timer = setTimeout(() => ac.abort(), this.sendTimeoutMs);
|
|
156
|
+
timer.unref?.();
|
|
157
|
+
try {
|
|
158
|
+
const res = await doFetch(`${cfg.ingestBase.replace(/\/$/, "")}/v1/events`, {
|
|
159
|
+
method: "POST",
|
|
160
|
+
headers: {
|
|
161
|
+
"Content-Type": "application/json",
|
|
162
|
+
Authorization: `Bearer ${cfg.token}`,
|
|
163
|
+
},
|
|
164
|
+
body: JSON.stringify({ events: batch.events }),
|
|
165
|
+
signal: ac.signal,
|
|
166
|
+
});
|
|
167
|
+
status = res.status;
|
|
168
|
+
if (res.ok) {
|
|
169
|
+
this.sentEvents += batch.events.length;
|
|
170
|
+
this.sentBatches++;
|
|
171
|
+
(0, state_1.debugLog)(`reported ${batch.events.length} events (${status})`);
|
|
172
|
+
// Drain the body so the socket is reusable; response is small.
|
|
173
|
+
try {
|
|
174
|
+
await res.text();
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
/* ignore */
|
|
178
|
+
}
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
await res.text();
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
/* ignore */
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
finally {
|
|
189
|
+
clearTimeout(timer);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
status = 0; // network error / timeout
|
|
194
|
+
}
|
|
195
|
+
this.sendFailures++;
|
|
196
|
+
const retryable = status === 0 || status === 408 || status === 429 || status >= 500;
|
|
197
|
+
if (retryable && batch.attempts < 1) {
|
|
198
|
+
this.retry = { events: batch.events, attempts: batch.attempts + 1, claimedDrops: batch.claimedDrops };
|
|
199
|
+
(0, state_1.debugLog)(`send failed (${status}); will retry once`);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
// Terminal: drop the batch and report it honestly on the next success.
|
|
203
|
+
// claimedDrops rode this batch's first event and never landed — re-claim
|
|
204
|
+
// them (already counted in totalDropped when first dropped).
|
|
205
|
+
this.countDrop(batch.events.length);
|
|
206
|
+
this.pendingDrops += batch.claimedDrops;
|
|
207
|
+
(0, state_1.debugLog)(`send failed terminally (${status}); dropped ${batch.events.length} events`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
exports.Reporter = Reporter;
|
package/dist/shapes.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare class ShapeDeduper {
|
|
2
|
+
private maxPerMinute;
|
|
3
|
+
private known;
|
|
4
|
+
private windowStart;
|
|
5
|
+
private emittedThisWindow;
|
|
6
|
+
constructor(maxPerMinute: number);
|
|
7
|
+
/** True if this route's shape was already sampled (cheap pre-check). */
|
|
8
|
+
hasSeen(host: string, method: string, pathTemplate: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Returns true exactly once per route (and only under the rate cap) —
|
|
11
|
+
* the caller emits the shape sample on true.
|
|
12
|
+
*/
|
|
13
|
+
shouldEmit(host: string, method: string, pathTemplate: string, now: number): boolean;
|
|
14
|
+
size(): number;
|
|
15
|
+
}
|
package/dist/shapes.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// shapes.ts -- shape-sample dedupe + rate cap (SPEC §3.2).
|
|
4
|
+
//
|
|
5
|
+
// Unrecognized routes emit ONE shape sample per (host, method, pathTemplate)
|
|
6
|
+
// per process (in-memory LRU dedupe), rate-capped per minute. Field NAMES +
|
|
7
|
+
// types only ride the sample; repeat traffic on a known shape emits nothing
|
|
8
|
+
// (high-volume GETs graduate to counter aggregation instead — counters.ts).
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ShapeDeduper = void 0;
|
|
12
|
+
const MAX_KNOWN_SHAPES = 1_024;
|
|
13
|
+
class ShapeDeduper {
|
|
14
|
+
maxPerMinute;
|
|
15
|
+
known = new Map(); // Map preserves insert order => LRU-ish eviction
|
|
16
|
+
windowStart = 0;
|
|
17
|
+
emittedThisWindow = 0;
|
|
18
|
+
constructor(maxPerMinute) {
|
|
19
|
+
this.maxPerMinute = maxPerMinute;
|
|
20
|
+
}
|
|
21
|
+
/** True if this route's shape was already sampled (cheap pre-check). */
|
|
22
|
+
hasSeen(host, method, pathTemplate) {
|
|
23
|
+
return this.known.has(`${host}|${method}|${pathTemplate}`);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Returns true exactly once per route (and only under the rate cap) —
|
|
27
|
+
* the caller emits the shape sample on true.
|
|
28
|
+
*/
|
|
29
|
+
shouldEmit(host, method, pathTemplate, now) {
|
|
30
|
+
const key = `${host}|${method}|${pathTemplate}`;
|
|
31
|
+
if (this.known.has(key))
|
|
32
|
+
return false;
|
|
33
|
+
// Rate cap: max N NEW shapes per minute; overflow shapes are simply not
|
|
34
|
+
// recorded as known, so they get another chance in a later window.
|
|
35
|
+
const win = Math.floor(now / 60_000);
|
|
36
|
+
if (win !== this.windowStart) {
|
|
37
|
+
this.windowStart = win;
|
|
38
|
+
this.emittedThisWindow = 0;
|
|
39
|
+
}
|
|
40
|
+
if (this.emittedThisWindow >= this.maxPerMinute)
|
|
41
|
+
return false;
|
|
42
|
+
this.emittedThisWindow++;
|
|
43
|
+
if (this.known.size >= MAX_KNOWN_SHAPES) {
|
|
44
|
+
// Evict the oldest entry (first key in insertion order).
|
|
45
|
+
const oldest = this.known.keys().next().value;
|
|
46
|
+
if (oldest !== undefined)
|
|
47
|
+
this.known.delete(oldest);
|
|
48
|
+
}
|
|
49
|
+
this.known.set(key, true);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
size() {
|
|
53
|
+
return this.known.size;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
exports.ShapeDeduper = ShapeDeduper;
|
package/dist/state.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ObserveConfig } from "./types";
|
|
2
|
+
import { CompiledManifest } from "./manifest";
|
|
3
|
+
import type { Pipeline } from "./pipeline";
|
|
4
|
+
import type { Reporter } from "./reporter";
|
|
5
|
+
export declare const KILL_SWITCH_ENV = "BOUNDED_OBSERVE_DISABLED";
|
|
6
|
+
export interface ShimStats {
|
|
7
|
+
captured: number;
|
|
8
|
+
internalErrors: number;
|
|
9
|
+
}
|
|
10
|
+
export interface ShimState {
|
|
11
|
+
initialized: boolean;
|
|
12
|
+
/** Master gate consulted by every interceptor on every call. */
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
patched: boolean;
|
|
15
|
+
config?: ObserveConfig;
|
|
16
|
+
/** Host (host[:port]) of the ingest endpoint — self-traffic is never captured. */
|
|
17
|
+
ingestHost?: string;
|
|
18
|
+
/** The pre-patch fetch: used for reporting + manifest polling (no self-loop). */
|
|
19
|
+
originalFetch?: typeof globalThis.fetch;
|
|
20
|
+
manifest: CompiledManifest;
|
|
21
|
+
pipeline?: Pipeline;
|
|
22
|
+
reporter?: Reporter;
|
|
23
|
+
tickTimer?: NodeJS.Timeout;
|
|
24
|
+
manifestTimer?: NodeJS.Timeout;
|
|
25
|
+
stats: ShimStats;
|
|
26
|
+
}
|
|
27
|
+
export declare const state: ShimState;
|
|
28
|
+
export declare function killSwitchActive(): boolean;
|
|
29
|
+
/** Debug logger — never throws, never logs unless debug is on. */
|
|
30
|
+
export declare function debugLog(msg: string, extra?: unknown): void;
|
|
31
|
+
/** Count an internal shim error (never propagates to the app). */
|
|
32
|
+
export declare function noteInternalError(where: string, err: unknown): void;
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// state.ts -- module singleton state for the shim. One shim per process.
|
|
4
|
+
//
|
|
5
|
+
// Kept as a plain mutable object so the interceptors' hot path reads are a
|
|
6
|
+
// property access + boolean check, nothing more.
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.state = exports.KILL_SWITCH_ENV = void 0;
|
|
10
|
+
exports.killSwitchActive = killSwitchActive;
|
|
11
|
+
exports.debugLog = debugLog;
|
|
12
|
+
exports.noteInternalError = noteInternalError;
|
|
13
|
+
const manifest_1 = require("./manifest");
|
|
14
|
+
exports.KILL_SWITCH_ENV = "BOUNDED_OBSERVE_DISABLED";
|
|
15
|
+
exports.state = {
|
|
16
|
+
initialized: false,
|
|
17
|
+
enabled: false,
|
|
18
|
+
patched: false,
|
|
19
|
+
manifest: new manifest_1.CompiledManifest(manifest_1.BUILTIN_MANIFEST, "builtin"),
|
|
20
|
+
stats: { captured: 0, internalErrors: 0 },
|
|
21
|
+
};
|
|
22
|
+
function killSwitchActive() {
|
|
23
|
+
const v = process.env[exports.KILL_SWITCH_ENV];
|
|
24
|
+
return v === "1" || v === "true" || v === "yes";
|
|
25
|
+
}
|
|
26
|
+
/** Debug logger — never throws, never logs unless debug is on. */
|
|
27
|
+
function debugLog(msg, extra) {
|
|
28
|
+
try {
|
|
29
|
+
if (exports.state.config?.debug) {
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
console.error(`[bounded-observe] ${msg}`, extra !== undefined ? extra : "");
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
/* never */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** Count an internal shim error (never propagates to the app). */
|
|
39
|
+
function noteInternalError(where, err) {
|
|
40
|
+
exports.state.stats.internalErrors++;
|
|
41
|
+
debugLog(`internal error in ${where}`, err);
|
|
42
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** True if one path segment looks like an ID and should template to {id}. */
|
|
2
|
+
export declare function isIdSegment(segment: string): boolean;
|
|
3
|
+
/**
|
|
4
|
+
* Template a raw path: ID-looking segments become {id}. The input must
|
|
5
|
+
* already be query-less (interceptors split query before calling).
|
|
6
|
+
*/
|
|
7
|
+
export declare function templatePath(path: string): string;
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// template.ts -- path templating (SPEC §3.2 / L1).
|
|
4
|
+
//
|
|
5
|
+
// IDs are stripped into {id} segments SHIM-SIDE, before anything is reported:
|
|
6
|
+
// ingest never sees raw path IDs. Query strings are dropped entirely by the
|
|
7
|
+
// interceptors (names may ride shape samples; values never leave the process).
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.isIdSegment = isIdSegment;
|
|
11
|
+
exports.templatePath = templatePath;
|
|
12
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
13
|
+
const NUMERIC_RE = /^\d+$/;
|
|
14
|
+
const HEX_RE = /^[0-9a-f]{16,}$/i;
|
|
15
|
+
// Vendor-prefixed opaque ids: ch_3Nq1..., cus_ABC123, pi_..., in_..., acct_...
|
|
16
|
+
// The suffix must contain a digit — API resource words ("payment_intents",
|
|
17
|
+
// "chat_completions") never do, real vendor ids essentially always do.
|
|
18
|
+
const PREFIXED_ID_RE = /^[a-z]{1,8}_(?=[A-Za-z0-9]*\d)[A-Za-z0-9]{6,}$/;
|
|
19
|
+
// Long random tokens (ULIDs, base64url ids, ...): letters+digits, len >= 20.
|
|
20
|
+
const LONG_RANDOM_RE = /^[A-Za-z0-9_-]{20,}$/;
|
|
21
|
+
const HAS_DIGIT_RE = /\d/;
|
|
22
|
+
const HAS_ALPHA_RE = /[A-Za-z]/;
|
|
23
|
+
// Trailing file extension keeps static assets recognizable (/app.3f9c2b.js).
|
|
24
|
+
const MONGO_OBJECTID_RE = /^[0-9a-f]{24}$/i;
|
|
25
|
+
/** True if one path segment looks like an ID and should template to {id}. */
|
|
26
|
+
function isIdSegment(segment) {
|
|
27
|
+
if (segment.length === 0)
|
|
28
|
+
return false;
|
|
29
|
+
if (NUMERIC_RE.test(segment))
|
|
30
|
+
return true;
|
|
31
|
+
if (UUID_RE.test(segment))
|
|
32
|
+
return true;
|
|
33
|
+
if (MONGO_OBJECTID_RE.test(segment))
|
|
34
|
+
return true;
|
|
35
|
+
if (HEX_RE.test(segment))
|
|
36
|
+
return true;
|
|
37
|
+
if (PREFIXED_ID_RE.test(segment))
|
|
38
|
+
return true;
|
|
39
|
+
if (LONG_RANDOM_RE.test(segment) && HAS_DIGIT_RE.test(segment) && HAS_ALPHA_RE.test(segment)) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Template a raw path: ID-looking segments become {id}. The input must
|
|
46
|
+
* already be query-less (interceptors split query before calling).
|
|
47
|
+
*/
|
|
48
|
+
function templatePath(path) {
|
|
49
|
+
if (path === "" || path === "/")
|
|
50
|
+
return "/";
|
|
51
|
+
const parts = path.split("/");
|
|
52
|
+
let changed = false;
|
|
53
|
+
for (let i = 0; i < parts.length; i++) {
|
|
54
|
+
const seg = parts[i];
|
|
55
|
+
if (seg && isIdSegment(seg)) {
|
|
56
|
+
parts[i] = "{id}";
|
|
57
|
+
changed = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const out = changed ? parts.join("/") : path;
|
|
61
|
+
// Envelope caps pathTemplate at 1024 chars.
|
|
62
|
+
return out.length > 1024 ? out.slice(0, 1024) : out;
|
|
63
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { ObserveEventV1, ActorKind, RecFields } from "./envelope";
|
|
2
|
+
import type { EscortPin } from "./manifest";
|
|
3
|
+
/** What the shim actually puts on the wire: envelope v1 minus org/sensor. */
|
|
4
|
+
export type WireEvent = Omit<ObserveEventV1, "org" | "sensor">;
|
|
5
|
+
/** Actor context input for runAs() / middleware mappers. */
|
|
6
|
+
export interface ActorInput {
|
|
7
|
+
actor: string;
|
|
8
|
+
onBehalfOf?: string;
|
|
9
|
+
kind?: ActorKind;
|
|
10
|
+
}
|
|
11
|
+
/** Normalized actor context carried by AsyncLocalStorage. */
|
|
12
|
+
export interface ActorContext {
|
|
13
|
+
id: string;
|
|
14
|
+
kind: ActorKind;
|
|
15
|
+
onBehalfOf?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* One raw capture from an interceptor. Hot-path cheap: refs + numbers only.
|
|
19
|
+
* All heavy work (templating, recognition, body parsing, dedupe, counters)
|
|
20
|
+
* happens later in the pipeline tick, off the request path.
|
|
21
|
+
*/
|
|
22
|
+
export interface RawCapture {
|
|
23
|
+
/** Request start, epoch ms. */
|
|
24
|
+
ts: number;
|
|
25
|
+
/** Effective destination host (after aliasHosts mapping), incl. port. */
|
|
26
|
+
host: string;
|
|
27
|
+
/** Raw URL path WITHOUT query string. */
|
|
28
|
+
path: string;
|
|
29
|
+
/** Query parameter NAMES only (values are never captured). */
|
|
30
|
+
queryNames?: string[];
|
|
31
|
+
/** Uppercase method. */
|
|
32
|
+
method: string;
|
|
33
|
+
/** HTTP status; 0 = network error / never completed. */
|
|
34
|
+
status: number;
|
|
35
|
+
durMs: number;
|
|
36
|
+
bytesIn: number;
|
|
37
|
+
bytesOut: number;
|
|
38
|
+
/** Actor context at call initiation (ALS wins over header fallback). */
|
|
39
|
+
actor?: ActorContext;
|
|
40
|
+
/** Raw request body text (capped) — manifest hosts / shape sampling only. */
|
|
41
|
+
reqBodyText?: string;
|
|
42
|
+
reqContentType?: string;
|
|
43
|
+
/** Parsed response JSON (recognized routes with responseFields only). */
|
|
44
|
+
respJson?: unknown;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* A processed event awaiting report. `fields` holds manifest-recognized SAFE
|
|
48
|
+
* values (amounts in cents, opaque ids, model names, token counts). Envelope
|
|
49
|
+
* v1 has no slot for them, so they are NEVER serialized to the wire — they
|
|
50
|
+
* are exposed via the onEvent debug hook and reserved for envelope v2.
|
|
51
|
+
*/
|
|
52
|
+
export interface InternalEvent {
|
|
53
|
+
wire: WireEvent;
|
|
54
|
+
fields?: RecFields;
|
|
55
|
+
}
|
|
56
|
+
/** Session -> actor mapper for middleware(). Receives the framework-native
|
|
57
|
+
* request object (Express `req` or Hono `Context`). Return null to leave the
|
|
58
|
+
* request unattributed. */
|
|
59
|
+
export type SessionMapper = (req: unknown) => ActorInput | null | undefined;
|
|
60
|
+
export interface ObserveConfig {
|
|
61
|
+
/** Ingest base URL, e.g. https://observe-ingest.buildwithtarobase.workers.dev */
|
|
62
|
+
ingestBase: string;
|
|
63
|
+
/** Sensor token: obs1.<keyId>.<sig> */
|
|
64
|
+
token: string;
|
|
65
|
+
/** Org slug — used ONLY for manifest polling (org/sensor are server-stamped). */
|
|
66
|
+
org?: string;
|
|
67
|
+
/** Informational; the wire sensor identity comes from the token. */
|
|
68
|
+
sensor?: string;
|
|
69
|
+
/** Session -> actor mapper used by middleware() when no explicit mapper given. */
|
|
70
|
+
sessionMapper?: SessionMapper;
|
|
71
|
+
/**
|
|
72
|
+
* Map local/mock hosts to their canonical identity for recognition +
|
|
73
|
+
* reporting (e.g. {"127.0.0.1:4242": "api.stripe.com"}). Dev/demo aid.
|
|
74
|
+
*/
|
|
75
|
+
aliasHosts?: Record<string, string>;
|
|
76
|
+
/** Flush cadence, ms (default 2000). Batches also flush at 500 events. */
|
|
77
|
+
flushIntervalMs?: number;
|
|
78
|
+
/** Per-send timeout, ms (default 5000). */
|
|
79
|
+
sendTimeoutMs?: number;
|
|
80
|
+
/** Bounded report queue (default 5000 events). Overflow -> dropped counter. */
|
|
81
|
+
maxQueueEvents?: number;
|
|
82
|
+
/** Unrecognized-GET calls/min above which a route becomes counter-class (default 60). */
|
|
83
|
+
counterPromoteThreshold?: number;
|
|
84
|
+
/** Max new shape samples emitted per minute (default 30). */
|
|
85
|
+
shapesPerMinute?: number;
|
|
86
|
+
/** Manifest override (tests/dev). Skips the built-in + polling when set. */
|
|
87
|
+
manifest?: unknown;
|
|
88
|
+
/**
|
|
89
|
+
* Env-derived escort pin (set by the register preload from
|
|
90
|
+
* BOUNDED_VERDICT_URL). Applied over the initial manifest AND re-applied
|
|
91
|
+
* over every fetched manifest swap, so a served manifest can never silently
|
|
92
|
+
* drop the pinned enforcement. A fetched escort block on the pinned route
|
|
93
|
+
* wins over the pin (server-side promotion supersedes the boot-time env
|
|
94
|
+
* default).
|
|
95
|
+
*/
|
|
96
|
+
escortPin?: EscortPin;
|
|
97
|
+
/** Disable the 60s manifest poll (tests). */
|
|
98
|
+
disableManifestPoll?: boolean;
|
|
99
|
+
/** Manifest poll interval ms (default 60000). */
|
|
100
|
+
manifestPollMs?: number;
|
|
101
|
+
/** Debug logging to stderr (never throws). */
|
|
102
|
+
debug?: boolean;
|
|
103
|
+
/**
|
|
104
|
+
* Debug/test hook: called for every event as it is queued, with the wire
|
|
105
|
+
* event and the (never-transmitted) recognized fields. Exceptions ignored.
|
|
106
|
+
*/
|
|
107
|
+
onEvent?: (wire: WireEvent, fields?: RecFields) => void;
|
|
108
|
+
}
|
|
109
|
+
export interface Diagnostics {
|
|
110
|
+
enabled: boolean;
|
|
111
|
+
patched: boolean;
|
|
112
|
+
queueDepth: number;
|
|
113
|
+
rawQueueDepth: number;
|
|
114
|
+
/** Drops not yet reported on a wire event. */
|
|
115
|
+
pendingDrops: number;
|
|
116
|
+
totalDropped: number;
|
|
117
|
+
captured: number;
|
|
118
|
+
sentEvents: number;
|
|
119
|
+
sentBatches: number;
|
|
120
|
+
sendFailures: number;
|
|
121
|
+
/** Internal shim errors swallowed (never propagated to the app). */
|
|
122
|
+
internalErrors: number;
|
|
123
|
+
manifestVersion: string;
|
|
124
|
+
manifestSource: "builtin" | "override" | "fetched";
|
|
125
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// ---------------------------------------------------------------------------
|
|
3
|
+
// types.ts -- internal + public types for @bounded-sh/observe.
|
|
4
|
+
//
|
|
5
|
+
// The wire contract is the FROZEN envelope v1 from
|
|
6
|
+
// packages/cdk/cloudflare/observe-shared, mirrored in ./envelope (drift is
|
|
7
|
+
// CI-checked; see envelope.ts header). org/sensor are SERVER-AUTHORITATIVE
|
|
8
|
+
// (stamped from the sensor key), so the shim omits them entirely.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bounded-sh/observe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Bounded observe shim for Node: intercepts fetch + http/https egress, attributes actors, reports metadata-only events to the Bounded observe ingest. Never breaks the app.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"publishConfig": {
|
|
8
|
+
"access": "public"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"require": "./dist/index.js",
|
|
16
|
+
"import": "./dist/index.js"
|
|
17
|
+
},
|
|
18
|
+
"./register": {
|
|
19
|
+
"types": "./dist/register.d.ts",
|
|
20
|
+
"require": "./dist/register.js",
|
|
21
|
+
"import": "./dist/register.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"README.md"
|
|
27
|
+
],
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=18"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "tsc -p tsconfig.json",
|
|
33
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
34
|
+
"test": "vitest run",
|
|
35
|
+
"test:live": "node scripts/live-test.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@types/node": "^22.0.0",
|
|
40
|
+
"typescript": "^5.8.0",
|
|
41
|
+
"vitest": "^3.2.0"
|
|
42
|
+
}
|
|
43
|
+
}
|