@dropout-ai/runtime 0.3.3 → 0.3.5

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +228 -299
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dropout-ai/runtime",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Invisible Node.js runtime for capturing AI interactions.",
5
5
  "main": "src/index.js",
6
6
  "scripts": {
package/src/index.js CHANGED
@@ -1,8 +1,7 @@
1
1
  /**
2
2
  * @dropout-ai/runtime
3
3
  * Universal AI Interaction Capture for Node.js & Next.js
4
- * Role: Passive observer.
5
- * Behavior: Fire-and-forget raw JSON to Supabase.
4
+ * Patches: fetch, http.request, https.request
6
5
  * Capability: Captures Genkit, OpenAI, LangChain, Axios, and standard fetch.
7
6
  */
8
7
 
@@ -19,355 +18,285 @@ const KNOWN_AI_DOMAINS = [
19
18
  'aiplatform.googleapis.com', 'api.groq.com', 'api.mistral.ai', 'api.cohere.ai'
20
19
  ];
21
20
 
22
- // Global State
23
- const GLOBAL_OBJ = typeof window !== 'undefined' ? window : global;
24
- let activeConfig = {
25
- projectId: null,
26
- apiKey: null,
27
- captureEndpoint: SUPABASE_FUNCTION_URL,
28
- maxOutputBytes: 32768,
29
- privacyMode: (typeof process !== 'undefined' && process.env.DROPOUT_PRIVACY_MODE) || 'full',
30
- debug: false // Added debug flag
31
- };
32
-
33
- let isPatched = false;
34
- let turnIndex = 0;
35
- let lastTurnConfirmed = true;
36
- let lastPromptHash = null;
37
- let lastResponseHash = null;
38
- let instance = null;
39
-
40
- // --- UTILS ---
41
-
42
- function log(msg, ...args) {
43
- if (activeConfig.debug) console.log(`[Dropout] ${msg}`, ...args);
44
- }
21
+ class Dropout {
22
+ constructor(config = {}) {
23
+ // 🚨 BROWSER GUARD 🚨
24
+ // If this runs in the browser (Client Component), do nothing.
25
+ // This prevents the "listener" error and protects your API keys.
26
+ if (typeof window !== 'undefined') {
27
+ console.warn("[Dropout] âš ī¸ Initialization Skipped: This SDK is for Node.js/Server environments only.");
28
+ return;
29
+ }
45
30
 
46
- function generateSessionId() {
47
- try {
48
- return crypto.randomUUID();
49
- } catch (e) {
50
- return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
51
- }
52
- }
31
+ // 1. Validation
32
+ if (!config.apiKey || !config.projectId) {
33
+ console.warn("[Dropout] âš ī¸ Initialization Skipped: Missing apiKey or projectId.");
34
+ return;
35
+ }
53
36
 
54
- function hash(text) {
55
- if (!text) return null;
56
- try {
57
- return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
58
- } catch (e) { return 'hash_err'; }
59
- }
37
+ // 2. Config Setup
38
+ this.config = config;
39
+ this.projectId = config.projectId;
40
+ this.apiKey = config.apiKey;
41
+ this.debug = config.debug || false;
42
+ this.privacy = config.privacy || 'full';
43
+ this.captureEndpoint = SUPABASE_FUNCTION_URL;
44
+
45
+ // 3. Singleton Guard
46
+ if (global.__dropout_initialized__) {
47
+ if (this.debug) console.log("[Dropout] â„šī¸ Already initialized. Skipping patch.");
48
+ return;
49
+ }
50
+ global.__dropout_initialized__ = true;
60
51
 
61
- function normalize(url, body) {
62
- let provider = 'custom';
63
- let model = 'unknown';
64
-
65
- if (url) {
66
- const u = url.toLowerCase();
67
- if (u.includes('openai')) provider = 'openai';
68
- else if (u.includes('anthropic')) provider = 'anthropic';
69
- else if (u.includes('google') || u.includes('generative') || u.includes('aiplatform')) provider = 'google';
70
- else if (u.includes('groq')) provider = 'groq';
71
- else if (u.includes('mistral')) provider = 'mistral';
72
- else if (u.includes('cohere')) provider = 'cohere';
73
- else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
74
- }
52
+ // 4. Initialize Identity
53
+ if (!global.__dropout_session_id__) {
54
+ global.__dropout_session_id__ = this.generateSessionId();
55
+ }
75
56
 
76
- if (body) {
77
- try {
78
- const parsed = typeof body === 'string' ? JSON.parse(body) : body;
79
- if (parsed.model) model = parsed.model;
80
- } catch (e) { }
81
- }
82
- return { provider, model };
83
- }
57
+ // 5. Start the Wiretap
58
+ if (this.debug) console.log(`[Dropout] đŸŸĸ Initialized for Project: ${this.projectId}`);
84
59
 
85
- function isAiRequest(url, bodyString) {
86
- if (!url) return false;
87
- // Guard: Infinite Loop Prevention
88
- if (url.includes(activeConfig.captureEndpoint)) return false;
60
+ // Bind methods
61
+ this.log = this.log.bind(this);
62
+ this.isAiRequest = this.isAiRequest.bind(this);
63
+ this.patchNetwork();
64
+ }
89
65
 
90
- // 1. Check Known Domains
91
- if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
66
+ // --- UTILS ---
67
+ log(msg, ...args) {
68
+ if (this.debug) console.log(`[Dropout] ${msg}`, ...args);
69
+ }
92
70
 
93
- // 2. Heuristic Check (for custom endpoints)
94
- if (bodyString) {
95
- return (bodyString.includes('"model"') || bodyString.includes('"messages"')) &&
96
- (bodyString.includes('"user"') || bodyString.includes('"prompt"'));
71
+ generateSessionId() {
72
+ try {
73
+ return crypto.randomUUID();
74
+ } catch (e) {
75
+ return 'sess_' + Math.random().toString(36).substring(2, 12) + Date.now().toString(36);
76
+ }
97
77
  }
98
- return false;
99
- }
100
78
 
101
- // --- EMITTER (Authenticated) ---
79
+ hash(text) {
80
+ if (!text) return null;
81
+ try {
82
+ return crypto.createHash('sha256').update(text.toLowerCase().trim()).digest('hex');
83
+ } catch (e) { return 'hash_err'; }
84
+ }
102
85
 
103
- function emit(payload) {
104
- // 1. Guard: Do not emit if not initialized
105
- if (!activeConfig.apiKey || !activeConfig.projectId) return;
86
+ normalize(url, body) {
87
+ let provider = 'custom';
88
+ let model = 'unknown';
89
+
90
+ if (url) {
91
+ const u = url.toLowerCase();
92
+ if (u.includes('openai')) provider = 'openai';
93
+ else if (u.includes('anthropic')) provider = 'anthropic';
94
+ else if (u.includes('google') || u.includes('gemini') || u.includes('aiplatform')) provider = 'google';
95
+ else if (u.includes('groq')) provider = 'groq';
96
+ else if (u.includes('mistral')) provider = 'mistral';
97
+ else if (u.includes('cohere')) provider = 'cohere';
98
+ else if (u.includes('localhost') || u.includes('127.0.0.1')) provider = 'local';
99
+ }
106
100
 
107
- // 2. Construct Final Payload
108
- const finalPayload = {
109
- ...payload,
110
- project_id: activeConfig.projectId,
101
+ if (body) {
102
+ try {
103
+ const parsed = typeof body === 'string' ? JSON.parse(body) : body;
104
+ if (parsed.model) model = parsed.model;
105
+ } catch (e) { }
106
+ }
107
+ return { provider, model };
108
+ }
111
109
 
112
- // ✅ Ensure these columns are always populated for the DB
113
- content_blob: payload.content, // Map content to blob
114
- received_at: new Date().toISOString()
115
- };
110
+ isAiRequest(url, bodyString) {
111
+ if (!url) return false;
112
+ if (url.includes(this.captureEndpoint)) return false;
116
113
 
117
- log(`🚀 Sending Capture (${payload.turn_role})`);
114
+ if (KNOWN_AI_DOMAINS.some(d => url.includes(d))) return true;
118
115
 
119
- // 3. Fire and Forget using HTTPS (Bypassing our own patches)
120
- const req = https.request(activeConfig.captureEndpoint, {
121
- method: 'POST',
122
- headers: {
123
- 'Content-Type': 'application/json',
124
- 'x-dropout-key': activeConfig.apiKey
116
+ if (bodyString) {
117
+ return (bodyString.includes('"model"') || bodyString.includes('"messages"')) &&
118
+ (bodyString.includes('"user"') || bodyString.includes('"prompt"'));
125
119
  }
126
- });
127
-
128
- req.on('error', (e) => log("❌ Upload Failed", e.message));
129
- req.write(JSON.stringify(finalPayload));
130
- req.end();
131
- }
120
+ return false;
121
+ }
132
122
 
133
- // --- MONKEY PATCH ---
123
+ // --- EMITTER ---
124
+ emit(payload) {
125
+ if (!this.apiKey || !this.projectId) return;
134
126
 
135
- function applyPatch() {
136
- if (isPatched) return;
127
+ const finalPayload = {
128
+ ...payload,
129
+ project_id: this.projectId,
130
+ //content_blob: payload.content,
131
+ content: payload.content,
132
+ received_at: new Date().toISOString()
133
+ };
137
134
 
138
- // --- A. PATCH FETCH (Next.js / OpenAI) ---
139
- if (GLOBAL_OBJ.fetch) {
140
- GLOBAL_OBJ.__dropout_original_fetch__ = GLOBAL_OBJ.fetch;
141
- GLOBAL_OBJ.fetch = async function (input, init) {
142
- const url = typeof input === 'string' ? input : (input && input.url);
135
+ this.log(`🚀 Sending Capture (${payload.turn_role})`);
143
136
 
144
- // Check body string safely
145
- let bodyStr = "";
146
- if (init && init.body) {
147
- try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
137
+ const req = https.request(this.captureEndpoint, {
138
+ method: 'POST',
139
+ headers: {
140
+ 'Content-Type': 'application/json',
141
+ 'x-dropout-key': this.apiKey
148
142
  }
143
+ });
149
144
 
150
- // Guard
151
- if (!isAiRequest(url, bodyStr)) {
152
- return GLOBAL_OBJ.__dropout_original_fetch__(input, init);
153
- }
154
-
155
- log(`⚡ [FETCH] Intercepting: ${url}`);
145
+ req.on('error', (e) => this.log("❌ Upload Failed", e.message));
146
+ req.write(JSON.stringify(finalPayload));
147
+ req.end();
148
+ }
156
149
 
157
- const start = Date.now();
150
+ // --- CORE PATCHING ---
151
+ patchNetwork() {
152
+ const _this = this;
158
153
 
159
- // Calculate Turn
160
- let activeTurn;
161
- if (lastTurnConfirmed) {
162
- activeTurn = turnIndex++;
163
- lastTurnConfirmed = false;
164
- } else {
165
- activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
166
- }
154
+ // --- A. PATCH FETCH ---
155
+ if (global.fetch) {
156
+ const originalFetch = global.fetch;
157
+ global.fetch = async function (input, init) {
158
+ const url = typeof input === 'string' ? input : input?.url;
167
159
 
168
- const { provider, model } = normalize(url, bodyStr);
169
- const pHash = hash(bodyStr);
170
-
171
- // Emit Request (User)
172
- emit({
173
- session_id: GLOBAL_OBJ.__dropout_session_id__,
174
- turn_index: activeTurn,
175
- direction: 'user_to_ai',
176
- turn_role: 'user',
177
- provider,
178
- model,
179
- content: activeConfig.privacyMode === 'full' ? bodyStr : null,
180
- content_hash: pHash,
181
- metadata_flags: {
182
- retry_like: pHash === lastPromptHash ? 1 : 0,
183
- method: 'FETCH',
184
- url: url
160
+ let bodyStr = "";
161
+ if (init && init.body) {
162
+ try { bodyStr = typeof init.body === 'string' ? init.body : JSON.stringify(init.body); } catch (e) { }
185
163
  }
186
- });
187
- lastPromptHash = pHash;
188
164
 
189
- // Actual Call
190
- let response;
191
- try {
192
- response = await GLOBAL_OBJ.__dropout_original_fetch__(input, init);
193
- } catch (err) { throw err; }
165
+ if (!_this.isAiRequest(url, bodyStr)) {
166
+ return originalFetch.apply(this, arguments);
167
+ }
194
168
 
195
- const latency = Date.now() - start;
169
+ _this.log(`⚡ [FETCH] Intercepting: ${url}`);
170
+ const start = Date.now();
196
171
 
197
- // Emit Response (Assistant)
198
- try {
199
- const cloned = response.clone();
200
- const oText = await cloned.text();
201
- const oHash = hash(oText);
202
-
203
- emit({
204
- session_id: GLOBAL_OBJ.__dropout_session_id__,
205
- turn_index: activeTurn,
206
- direction: 'ai_to_user',
207
- turn_role: 'assistant',
208
- latency_ms: latency,
172
+ const { provider, model } = _this.normalize(url, bodyStr);
173
+ _this.emit({
174
+ session_id: global.__dropout_session_id__,
175
+ turn_index: 0,
176
+ turn_role: 'user',
209
177
  provider,
210
178
  model,
211
- content: activeConfig.privacyMode === 'full' ? oText : null,
212
- content_hash: oHash,
213
- metadata_flags: {
214
- non_adaptive_response: oHash === lastResponseHash ? 1 : 0,
215
- turn_boundary_confirmed: 1,
216
- status: response.status
217
- }
179
+ content: _this.privacy === 'full' ? bodyStr : null,
180
+ metadata_flags: { method: 'FETCH', url: url }
218
181
  });
219
- lastResponseHash = oHash;
220
- } catch (e) { log("âš ī¸ Failed to read response body"); }
221
-
222
- lastTurnConfirmed = true;
223
- return response;
224
- };
225
- log("✅ Patch Applied: global.fetch");
226
- }
227
182
 
228
- // --- B. PATCH HTTP/HTTPS (Axios / Genkit / Node SDKs) ---
229
- const patchNodeRequest = (module, moduleName) => {
230
- const originalRequest = module.request;
231
- module.request = function (...args) {
232
- // Resolve URL
233
- let url;
234
- let options;
235
- if (typeof args[0] === 'string') {
236
- url = args[0];
237
- options = args[1] || {};
238
- } else {
239
- options = args[0] || {};
240
- const protocol = options.protocol || 'https:';
241
- const host = options.hostname || options.host || 'localhost';
242
- const path = options.path || '/';
243
- url = `${protocol}//${host}${path}`;
244
- }
183
+ let response;
184
+ try {
185
+ response = await originalFetch.apply(this, arguments);
186
+ } catch (err) { throw err; }
245
187
 
246
- // We can't check body here yet, so we rely on URL
247
- if (!isAiRequest(url, null)) {
248
- return originalRequest.apply(this, args);
249
- }
188
+ const latency = Date.now() - start;
250
189
 
251
- log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
252
-
253
- const start = Date.now();
254
- const clientRequest = originalRequest.apply(this, args);
255
-
256
- // Capture Request Body
257
- const reqChunks = [];
258
- const originalWrite = clientRequest.write;
259
- const originalEnd = clientRequest.end;
190
+ try {
191
+ const cloned = response.clone();
192
+ const oText = await cloned.text();
193
+ _this.emit({
194
+ session_id: global.__dropout_session_id__,
195
+ turn_index: 0,
196
+ turn_role: 'assistant',
197
+ latency_ms: latency,
198
+ provider,
199
+ model,
200
+ content: _this.privacy === 'full' ? oText : null,
201
+ metadata_flags: { status: response.status }
202
+ });
203
+ } catch (e) { _this.log("âš ī¸ Failed to read response body"); }
260
204
 
261
- clientRequest.write = function (...writeArgs) {
262
- if (writeArgs[0]) reqChunks.push(Buffer.from(writeArgs[0]));
263
- return originalWrite.apply(this, writeArgs);
205
+ return response;
264
206
  };
207
+ this.log("✅ Patch Applied: global.fetch");
208
+ }
265
209
 
266
- clientRequest.end = function (...endArgs) {
267
- if (endArgs[0]) reqChunks.push(Buffer.from(endArgs[0]));
268
-
269
- // Request Complete - Emit User Turn
270
- const reqBody = Buffer.concat(reqChunks).toString('utf8');
271
- const { provider, model } = normalize(url, reqBody);
272
- const pHash = hash(reqBody);
273
-
274
- let activeTurn;
275
- if (lastTurnConfirmed) {
276
- activeTurn = turnIndex++;
277
- lastTurnConfirmed = false;
210
+ // --- B. PATCH NODE HTTP/HTTPS ---
211
+ const patchNodeRequest = (module, moduleName) => {
212
+ const originalRequest = module.request;
213
+ module.request = function (...args) {
214
+ let url;
215
+ if (typeof args[0] === 'string') {
216
+ url = args[0];
278
217
  } else {
279
- activeTurn = turnIndex > 0 ? turnIndex - 1 : 0;
218
+ const opts = args[0] || {};
219
+ const protocol = opts.protocol || (moduleName === 'https' ? 'https:' : 'http:');
220
+ const host = opts.hostname || opts.host || 'localhost';
221
+ const path = opts.path || '/';
222
+ url = `${protocol}//${host}${path}`;
280
223
  }
281
224
 
282
- // Save state on request object for response handler
283
- clientRequest._dropout_turn = activeTurn;
284
- clientRequest._dropout_provider = provider;
285
- clientRequest._dropout_model = model;
225
+ if (!_this.isAiRequest(url, null)) {
226
+ return originalRequest.apply(this, args);
227
+ }
286
228
 
287
- emit({
288
- session_id: GLOBAL_OBJ.__dropout_session_id__,
289
- turn_index: activeTurn,
290
- direction: 'user_to_ai',
291
- turn_role: 'user',
292
- provider,
293
- model,
294
- content: activeConfig.privacyMode === 'full' ? reqBody : null,
295
- content_hash: pHash,
296
- metadata_flags: { method: moduleName.toUpperCase(), url: url }
297
- });
298
- lastPromptHash = pHash;
229
+ _this.log(`⚡ [${moduleName.toUpperCase()}] Intercepting: ${url}`);
230
+ const start = Date.now();
299
231
 
300
- return originalEnd.apply(this, endArgs);
301
- };
232
+ const clientRequest = originalRequest.apply(this, args);
233
+ const reqChunks = [];
234
+ const originalWrite = clientRequest.write;
235
+ const originalEnd = clientRequest.end;
302
236
 
303
- // Capture Response
304
- clientRequest.on('response', (res) => {
305
- const resChunks = [];
306
- res.on('data', (chunk) => resChunks.push(chunk));
307
- res.on('end', () => {
308
- const resBody = Buffer.concat(resChunks).toString('utf8');
309
- const latency = Date.now() - start;
310
- const oHash = hash(resBody);
311
-
312
- emit({
313
- session_id: GLOBAL_OBJ.__dropout_session_id__,
314
- turn_index: clientRequest._dropout_turn || 0,
315
- direction: 'ai_to_user',
316
- turn_role: 'assistant',
317
- latency_ms: latency,
318
- provider: clientRequest._dropout_provider,
319
- model: clientRequest._dropout_model,
320
- content: activeConfig.privacyMode === 'full' ? resBody : null,
321
- content_hash: oHash,
322
- metadata_flags: { status: res.statusCode }
237
+ clientRequest.write = function (...writeArgs) {
238
+ const chunk = writeArgs[0];
239
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
240
+ reqChunks.push(Buffer.from(chunk));
241
+ }
242
+ return originalWrite.apply(this, writeArgs);
243
+ };
244
+
245
+ clientRequest.end = function (...endArgs) {
246
+ const chunk = endArgs[0];
247
+ if (chunk && (typeof chunk === 'string' || Buffer.isBuffer(chunk))) {
248
+ reqChunks.push(Buffer.from(chunk));
249
+ }
250
+
251
+ const reqBody = Buffer.concat(reqChunks).toString('utf8');
252
+ const { provider, model } = _this.normalize(url, reqBody);
253
+ clientRequest._dropout_meta = { provider, model };
254
+
255
+ _this.emit({
256
+ session_id: global.__dropout_session_id__,
257
+ turn_index: 0,
258
+ turn_role: 'user',
259
+ provider,
260
+ model,
261
+ content: _this.privacy === 'full' ? reqBody : null,
262
+ metadata_flags: { method: moduleName.toUpperCase(), url: url }
323
263
  });
324
264
 
325
- lastResponseHash = oHash;
326
- lastTurnConfirmed = true;
265
+ return originalEnd.apply(this, endArgs);
266
+ };
267
+
268
+ clientRequest.on('response', (res) => {
269
+ const resChunks = [];
270
+ res.on('data', (chunk) => resChunks.push(chunk));
271
+ res.on('end', () => {
272
+ const resBody = Buffer.concat(resChunks).toString('utf8');
273
+ const latency = Date.now() - start;
274
+ const meta = clientRequest._dropout_meta || {};
275
+
276
+ _this.emit({
277
+ session_id: global.__dropout_session_id__,
278
+ turn_index: 0,
279
+ turn_role: 'assistant',
280
+ latency_ms: latency,
281
+ provider: meta.provider,
282
+ model: meta.model,
283
+ content: _this.privacy === 'full' ? resBody : null,
284
+ metadata_flags: { status: res.statusCode }
285
+ });
286
+ });
327
287
  });
328
- });
329
288
 
330
- return clientRequest;
289
+ return clientRequest;
290
+ };
331
291
  };
332
- };
333
-
334
- patchNodeRequest(https, 'https');
335
- patchNodeRequest(http, 'http');
336
- log("✅ Patch Applied: http/https");
337
-
338
- isPatched = true;
339
- }
340
-
341
- // --- MAIN CLASS EXPORT ---
342
-
343
- class Dropout {
344
- constructor(config = {}) {
345
- if (instance) return instance;
346
-
347
- if (!config.apiKey || !config.projectId) {
348
- console.warn("[Dropout] Missing apiKey or projectId. Tracking disabled.");
349
- return;
350
- }
351
-
352
- // Update Global Configuration
353
- activeConfig.apiKey = config.apiKey;
354
- activeConfig.projectId = config.projectId;
355
- if (config.privacyMode) activeConfig.privacyMode = config.privacyMode;
356
- activeConfig.debug = config.debug || false;
357
-
358
- // Initialize Identity
359
- if (!GLOBAL_OBJ.__dropout_session_id__) {
360
- GLOBAL_OBJ.__dropout_session_id__ = generateSessionId();
361
- }
362
292
 
363
- // Apply the patch
364
- if (activeConfig.debug) console.log(`[Dropout] đŸŸĸ Initialized for Project: ${config.projectId}`);
365
- applyPatch();
366
- instance = this;
293
+ patchNodeRequest(https, 'https');
294
+ patchNodeRequest(http, 'http');
295
+ this.log("✅ Patch Applied: http/https");
367
296
  }
368
297
  }
369
298
 
370
- // Auto-Start
299
+ // Auto-Start (Node Preload)
371
300
  if (process.env.DROPOUT_PROJECT_ID && process.env.DROPOUT_API_KEY) {
372
301
  new Dropout({
373
302
  projectId: process.env.DROPOUT_PROJECT_ID,