@dropout-ai/runtime 0.2.14 → 0.3.1
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/package.json +1 -1
- package/src/index.js +201 -277
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @dropout-ai/runtime
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* Universal AI Interaction Capture for Node.js & Next.js
|
|
4
|
+
* Patches: fetch, http.request, https.request
|
|
5
|
+
* Capability: Captures Genkit, OpenAI, LangChain, Axios, and standard fetch.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
const https = require('https');
|
|
9
|
+
const http = require('http');
|
|
8
10
|
const crypto = require('crypto');
|
|
9
11
|
|
|
10
12
|
// --- CONFIGURATION ---
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
const KNOWN_AI_DOMAINS = [
|
|
14
|
+
'api.openai.com', // OpenAI
|
|
15
|
+
'api.anthropic.com', // Claude
|
|
16
|
+
'generativelanguage.googleapis.com', // Gemini
|
|
17
|
+
'aiplatform.googleapis.com', // Vertex AI (Genkit often uses this)
|
|
18
|
+
'api.groq.com', // Groq
|
|
19
|
+
'api.mistral.ai', // Mistral
|
|
20
|
+
'api.cohere.ai' // Cohere
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
const DROPOUT_INGEST_URL = "https://hipughmjlwmwjxzyxfzs.supabase.co/functions/v1/capture-sealed";
|
|
24
|
+
|
|
25
|
+
// --- UTILS ---
|
|
16
26
|
function generateSessionId() {
|
|
17
27
|
try {
|
|
18
28
|
return crypto.randomUUID();
|
|
@@ -21,295 +31,209 @@ function generateSessionId() {
|
|
|
21
31
|
}
|
|
22
32
|
}
|
|
23
33
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
let lastPromptHash = null;
|
|
31
|
-
let lastResponseHash = null;
|
|
32
|
-
|
|
33
|
-
let config = {
|
|
34
|
-
maxOutputBytes: 32768,
|
|
35
|
-
captureEndpoint: SUPABASE_FUNCTION_URL,
|
|
36
|
-
privacyMode: (typeof process !== 'undefined' && process.env.DROPOUT_PRIVACY_MODE) || 'full'
|
|
37
|
-
};
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Telemetry Emitter (Non-blocking, Fire-and-forget)
|
|
41
|
-
*/
|
|
42
|
-
function emit(payload) {
|
|
43
|
-
const fetchFn = GLOBAL_OBJ.__dropout_original_fetch__ || GLOBAL_OBJ.fetch;
|
|
44
|
-
if (typeof fetchFn !== 'function') return;
|
|
45
|
-
|
|
46
|
-
setTimeout(() => {
|
|
47
|
-
fetchFn(config.captureEndpoint, {
|
|
48
|
-
method: 'POST',
|
|
49
|
-
headers: {
|
|
50
|
-
'Content-Type': 'application/json'
|
|
51
|
-
},
|
|
52
|
-
body: JSON.stringify(payload),
|
|
53
|
-
keepalive: true
|
|
54
|
-
}).catch(() => { });
|
|
55
|
-
}, 0);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
// --- Lifecycle Signals ---
|
|
59
|
-
|
|
60
|
-
function emitSessionEnd(reason) {
|
|
61
|
-
if (GLOBAL_OBJ.__dropout_session_ended__) return;
|
|
62
|
-
GLOBAL_OBJ.__dropout_session_ended__ = true;
|
|
63
|
-
|
|
64
|
-
emit({
|
|
65
|
-
session_id: GLOBAL_OBJ.__dropout_session_id__,
|
|
66
|
-
turn_index: turnIndex,
|
|
67
|
-
direction: 'meta',
|
|
68
|
-
turn_role: 'system',
|
|
69
|
-
metadata_flags: {
|
|
70
|
-
session_end: true,
|
|
71
|
-
end_reason: reason
|
|
34
|
+
class Dropout {
|
|
35
|
+
constructor(config = {}) {
|
|
36
|
+
// 1. Validation
|
|
37
|
+
if (!config.apiKey || !config.projectId) {
|
|
38
|
+
console.warn("[Dropout] ⚠️ Initialization Skipped: Missing apiKey or projectId.");
|
|
39
|
+
return;
|
|
72
40
|
}
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
// Browser: Navigation/Reload
|
|
77
|
-
if (typeof window !== 'undefined' && window.addEventListener) {
|
|
78
|
-
window.addEventListener('beforeunload', () => emitSessionEnd('navigation'));
|
|
79
|
-
}
|
|
80
41
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
} catch (e) {
|
|
95
|
-
return 'hash_err';
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
// --- Provider Normalization ---
|
|
42
|
+
// 2. Config Setup
|
|
43
|
+
this.config = config;
|
|
44
|
+
this.projectId = config.projectId;
|
|
45
|
+
this.apiKey = config.apiKey;
|
|
46
|
+
this.debug = config.debug || false; // Toggle this to see logs
|
|
47
|
+
this.privacy = config.privacy || 'full';
|
|
48
|
+
|
|
49
|
+
// 3. Singleton Guard (Prevent double-patching)
|
|
50
|
+
if (global.__dropout_initialized__) {
|
|
51
|
+
if (this.debug) console.log("[Dropout] ℹ️ Already initialized. Skipping patch.");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
global.__dropout_initialized__ = true;
|
|
100
55
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
56
|
+
// 4. Initialize Identity
|
|
57
|
+
if (!global.__dropout_session_id__) {
|
|
58
|
+
global.__dropout_session_id__ = generateSessionId();
|
|
59
|
+
}
|
|
104
60
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
else if (u.includes('anthropic.com')) provider = 'anthropic';
|
|
109
|
-
else if (u.includes('google.com') || u.includes('generative')) provider = 'google';
|
|
110
|
-
else if (u.includes('groq.com')) provider = 'groq';
|
|
111
|
-
else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
|
|
61
|
+
// 5. Start the Wiretap
|
|
62
|
+
if (this.debug) console.log(`[Dropout] 🟢 Initialized for Project: ${this.projectId}`);
|
|
63
|
+
this.patchNetwork();
|
|
112
64
|
}
|
|
113
65
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
const parsed = typeof body === 'string' ? JSON.parse(body) : body;
|
|
117
|
-
model = parsed.model || model;
|
|
118
|
-
if (provider === 'unknown' && (parsed.messages || parsed.prompt || parsed.input)) {
|
|
119
|
-
provider = 'heuristic';
|
|
120
|
-
}
|
|
121
|
-
} catch (e) { }
|
|
66
|
+
log(msg, ...args) {
|
|
67
|
+
if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
|
|
122
68
|
}
|
|
123
69
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if (typeof GLOBAL_OBJ.fetch === 'function' && !GLOBAL_OBJ.fetch.__dropout_patched__) {
|
|
130
|
-
GLOBAL_OBJ.__dropout_original_fetch__ = GLOBAL_OBJ.fetch;
|
|
131
|
-
|
|
132
|
-
GLOBAL_OBJ.fetch = async function (input, init) {
|
|
133
|
-
const url = typeof input === 'string' ? input : (input && input.url);
|
|
70
|
+
isAiRequest(url) {
|
|
71
|
+
if (!url) return false;
|
|
72
|
+
// Guard: Infinite Loop Prevention (Don't capture our own calls)
|
|
73
|
+
if (url.includes(DROPOUT_INGEST_URL)) return false;
|
|
134
74
|
|
|
135
|
-
//
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
75
|
+
// Check against known AI domains
|
|
76
|
+
return KNOWN_AI_DOMAINS.some(domain => url.includes(domain));
|
|
77
|
+
}
|
|
139
78
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
79
|
+
// --- THE CORE: UNIVERSAL PATCHING ---
|
|
80
|
+
patchNetwork() {
|
|
81
|
+
const _this = this;
|
|
82
|
+
|
|
83
|
+
// A. Patch Global Fetch (Used by OpenAI v4+, Vercel AI SDK, Next.js)
|
|
84
|
+
if (global.fetch) {
|
|
85
|
+
const originalFetch = global.fetch;
|
|
86
|
+
global.fetch = async function (input, init) {
|
|
87
|
+
const url = typeof input === 'string' ? input : input?.url;
|
|
88
|
+
|
|
89
|
+
// PASSTHROUGH: If not AI, ignore
|
|
90
|
+
if (!_this.isAiRequest(url)) {
|
|
91
|
+
return originalFetch.apply(this, arguments);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_this.log(`⚡ [FETCH] Intercepting request to: ${url}`);
|
|
95
|
+
|
|
96
|
+
// Capture Request Body
|
|
97
|
+
let reqBody = "";
|
|
98
|
+
try { if (init && init.body) reqBody = init.body; } catch (e) { }
|
|
99
|
+
|
|
100
|
+
const startTime = Date.now();
|
|
101
|
+
let response;
|
|
102
|
+
|
|
103
|
+
// Execute Original Call
|
|
104
|
+
try {
|
|
105
|
+
response = await originalFetch.apply(this, arguments);
|
|
106
|
+
} catch (e) { throw e; }
|
|
107
|
+
|
|
108
|
+
// Capture Response (Clone response stream)
|
|
109
|
+
try {
|
|
110
|
+
const cloned = response.clone();
|
|
111
|
+
const resBody = await cloned.text();
|
|
112
|
+
|
|
113
|
+
_this.emit({
|
|
114
|
+
url,
|
|
115
|
+
method: 'FETCH',
|
|
116
|
+
request: reqBody,
|
|
117
|
+
response: resBody,
|
|
118
|
+
latency: Date.now() - startTime,
|
|
119
|
+
status: response.status
|
|
120
|
+
});
|
|
121
|
+
} catch (e) { _this.log("⚠️ Error reading response body", e); }
|
|
122
|
+
|
|
123
|
+
return response;
|
|
124
|
+
};
|
|
125
|
+
this.log("✅ Patch Applied: global.fetch");
|
|
159
126
|
}
|
|
160
127
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
128
|
+
// B. Patch Node HTTPS/HTTP (Used by Axios, Google Vertex SDK, Legacy LangChain)
|
|
129
|
+
const patchNodeRequest = (module, moduleName) => {
|
|
130
|
+
const originalRequest = module.request;
|
|
131
|
+
module.request = function (...args) {
|
|
132
|
+
// Resolve URL from varied arguments
|
|
133
|
+
let url;
|
|
134
|
+
let options;
|
|
135
|
+
if (typeof args[0] === 'string') {
|
|
136
|
+
url = args[0];
|
|
137
|
+
options = args[1] || {};
|
|
138
|
+
} else {
|
|
139
|
+
options = args[0] || {};
|
|
140
|
+
const protocol = options.protocol || 'https:';
|
|
141
|
+
const host = options.hostname || options.host || 'localhost';
|
|
142
|
+
const path = options.path || '/';
|
|
143
|
+
url = `${protocol}//${host}${path}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// PASSTHROUGH: If not AI, ignore
|
|
147
|
+
if (!_this.isAiRequest(url)) {
|
|
148
|
+
return originalRequest.apply(this, args);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting request to: ${url}`);
|
|
152
|
+
|
|
153
|
+
const startTime = Date.now();
|
|
154
|
+
const clientRequest = originalRequest.apply(this, args);
|
|
155
|
+
|
|
156
|
+
// Capture Request Body (Chunks)
|
|
157
|
+
const chunks = [];
|
|
158
|
+
const originalWrite = clientRequest.write;
|
|
159
|
+
const originalEnd = clientRequest.end;
|
|
160
|
+
|
|
161
|
+
clientRequest.write = function (...writeArgs) {
|
|
162
|
+
if (writeArgs[0]) chunks.push(Buffer.from(writeArgs[0]));
|
|
163
|
+
return originalWrite.apply(this, writeArgs);
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
clientRequest.end = function (...endArgs) {
|
|
167
|
+
if (endArgs[0]) chunks.push(Buffer.from(endArgs[0]));
|
|
168
|
+
// Store full body on the request object for later
|
|
169
|
+
clientRequest._fullBody = Buffer.concat(chunks).toString('utf8');
|
|
170
|
+
return originalEnd.apply(this, endArgs);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
// Capture Response
|
|
174
|
+
clientRequest.on('response', (res) => {
|
|
175
|
+
const resChunks = [];
|
|
176
|
+
res.on('data', (chunk) => resChunks.push(chunk));
|
|
177
|
+
res.on('end', () => {
|
|
178
|
+
const resBody = Buffer.concat(resChunks).toString('utf8');
|
|
179
|
+
_this.emit({
|
|
180
|
+
url,
|
|
181
|
+
method: moduleName.toUpperCase(),
|
|
182
|
+
request: clientRequest._fullBody,
|
|
183
|
+
response: resBody,
|
|
184
|
+
latency: Date.now() - startTime,
|
|
185
|
+
status: res.statusCode
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
return clientRequest;
|
|
191
|
+
};
|
|
184
192
|
};
|
|
185
193
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
let response;
|
|
191
|
-
let oText = "";
|
|
192
|
-
try {
|
|
193
|
-
response = await GLOBAL_OBJ.__dropout_original_fetch__(input, init);
|
|
194
|
-
} catch (err) {
|
|
195
|
-
throw err;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
const latency = Date.now() - start;
|
|
194
|
+
patchNodeRequest(https, 'https');
|
|
195
|
+
patchNodeRequest(http, 'http');
|
|
196
|
+
this.log("✅ Patch Applied: http/https");
|
|
197
|
+
}
|
|
199
198
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const responseEvent = {
|
|
213
|
-
session_id: GLOBAL_OBJ.__dropout_session_id__,
|
|
214
|
-
turn_index: activeTurn,
|
|
215
|
-
direction: 'ai_to_user',
|
|
216
|
-
turn_role: 'assistant',
|
|
217
|
-
latency_ms: latency,
|
|
218
|
-
provider,
|
|
219
|
-
model,
|
|
220
|
-
// FIX: Renamed 'content_raw' to 'content'
|
|
221
|
-
content: config.privacyMode === 'full' ? oText : null,
|
|
222
|
-
content_hash: oHash,
|
|
199
|
+
// --- EMITTER ---
|
|
200
|
+
emit(data) {
|
|
201
|
+
// 1. Construct Payload
|
|
202
|
+
// We combine Request + Response into a single content blob for simplicity in this universal mode
|
|
203
|
+
const content = `--- REQUEST ---\n${data.request}\n\n--- RESPONSE ---\n${data.response}`;
|
|
204
|
+
|
|
205
|
+
const payload = {
|
|
206
|
+
project_id: this.projectId,
|
|
207
|
+
session_id: global.__dropout_session_id__,
|
|
208
|
+
turn_role: 'assistant', // Default to assistant for system-captured logs
|
|
209
|
+
turn_index: 0, // In universal mode, we capture raw streams
|
|
210
|
+
content_blob: this.privacy === 'full' ? content : null,
|
|
223
211
|
metadata_flags: {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
212
|
+
latency: data.latency,
|
|
213
|
+
url: data.url,
|
|
214
|
+
method: data.method,
|
|
215
|
+
status: data.status,
|
|
216
|
+
captured_via: 'universal_interceptor'
|
|
217
|
+
},
|
|
218
|
+
received_at: new Date().toISOString()
|
|
227
219
|
};
|
|
228
220
|
|
|
229
|
-
|
|
230
|
-
lastResponseHash = oHash;
|
|
231
|
-
lastTurnConfirmed = true;
|
|
232
|
-
|
|
233
|
-
return response;
|
|
234
|
-
};
|
|
235
|
-
|
|
236
|
-
GLOBAL_OBJ.fetch.__dropout_patched__ = true;
|
|
237
|
-
}
|
|
221
|
+
this.log(`🚀 Sending Capture to Supabase (${data.latency}ms)`);
|
|
238
222
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
lastTurnConfirmed = false;
|
|
249
|
-
} else {
|
|
250
|
-
activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
const mode = options.privacy || config.privacyMode;
|
|
254
|
-
|
|
255
|
-
let prompt, output, latency_ms = options.latency || 0;
|
|
223
|
+
// 2. Fire and Forget (Using unpatched fetch mechanism)
|
|
224
|
+
// We use a clean http request to avoid triggering our own hooks if we used global.fetch
|
|
225
|
+
const req = https.request(DROPOUT_INGEST_URL, {
|
|
226
|
+
method: 'POST',
|
|
227
|
+
headers: {
|
|
228
|
+
'Content-Type': 'application/json',
|
|
229
|
+
'x-dropout-key': this.apiKey
|
|
230
|
+
}
|
|
231
|
+
});
|
|
256
232
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
output = typeof result === 'string' ? result : JSON.stringify(result);
|
|
261
|
-
latency_ms = Date.now() - start;
|
|
262
|
-
} else {
|
|
263
|
-
prompt = target.prompt;
|
|
264
|
-
output = target.output;
|
|
233
|
+
req.on('error', (e) => this.log("❌ Upload Failed", e.message));
|
|
234
|
+
req.write(JSON.stringify(payload));
|
|
235
|
+
req.end();
|
|
265
236
|
}
|
|
266
|
-
|
|
267
|
-
const pHash = hash(prompt);
|
|
268
|
-
const oHash = hash(output);
|
|
269
|
-
|
|
270
|
-
// Emit Request
|
|
271
|
-
emit({
|
|
272
|
-
session_id: GLOBAL_OBJ.__dropout_session_id__,
|
|
273
|
-
turn_index: activeTurn,
|
|
274
|
-
direction: 'user_to_ai',
|
|
275
|
-
turn_role: 'user',
|
|
276
|
-
provider: options.provider || 'manual',
|
|
277
|
-
model: options.model || 'unknown',
|
|
278
|
-
content: mode === 'full' ? prompt : null, // FIX: content_raw -> content
|
|
279
|
-
content_hash: pHash,
|
|
280
|
-
metadata_flags: { retry_like: pHash === lastPromptHash ? 1 : 0 }
|
|
281
|
-
});
|
|
282
|
-
|
|
283
|
-
// Emit Response
|
|
284
|
-
emit({
|
|
285
|
-
session_id: GLOBAL_OBJ.__dropout_session_id__,
|
|
286
|
-
turn_index: activeTurn,
|
|
287
|
-
direction: 'ai_to_user',
|
|
288
|
-
turn_role: 'assistant',
|
|
289
|
-
latency_ms,
|
|
290
|
-
provider: options.provider || 'manual',
|
|
291
|
-
model: options.model || 'unknown',
|
|
292
|
-
content: mode === 'full' ? output : null, // FIX: content_raw -> content
|
|
293
|
-
content_hash: oHash,
|
|
294
|
-
metadata_flags: { non_adaptive_response: oHash === lastResponseHash ? 1 : 0, turn_boundary_confirmed: 1 }
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
lastPromptHash = pHash;
|
|
298
|
-
lastResponseHash = oHash;
|
|
299
|
-
lastTurnConfirmed = true;
|
|
300
|
-
|
|
301
|
-
return output;
|
|
302
237
|
}
|
|
303
238
|
|
|
304
|
-
module.exports =
|
|
305
|
-
capture,
|
|
306
|
-
reset: (reason = 'manual_reset') => {
|
|
307
|
-
emitSessionEnd(reason);
|
|
308
|
-
GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
|
|
309
|
-
GLOBAL_OBJ.__dropout_session_ended__ = false;
|
|
310
|
-
turnIndex = 0;
|
|
311
|
-
lastTurnConfirmed = true;
|
|
312
|
-
lastPromptHash = null;
|
|
313
|
-
lastResponseHash = null;
|
|
314
|
-
}
|
|
315
|
-
};
|
|
239
|
+
module.exports = Dropout;
|