trickle-observe 0.2.105 → 0.2.107
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/dist/db-observer.d.ts +17 -0
- package/dist/db-observer.js +190 -0
- package/dist/observe-register.js +27 -0
- package/dist/transport.js +67 -1
- package/package.json +1 -1
- package/src/db-observer.ts +201 -0
- package/src/observe-register.ts +27 -0
- package/src/transport.ts +67 -1
package/dist/db-observer.d.ts
CHANGED
|
@@ -26,6 +26,23 @@ export declare function patchBetterSqlite3(dbConstructor: any, debug: boolean):
|
|
|
26
26
|
* Called from observe-register when ioredis is required.
|
|
27
27
|
*/
|
|
28
28
|
export declare function patchIoredis(ioredisModule: any, debug: boolean): void;
|
|
29
|
+
/**
|
|
30
|
+
* Patch @prisma/client to capture queries.
|
|
31
|
+
* Prisma has its own query engine (Rust binary) so patching pg/mysql2 won't work.
|
|
32
|
+
* Instead, we hook into Prisma's $on('query') event and $use() middleware.
|
|
33
|
+
*/
|
|
34
|
+
export declare function patchPrisma(prismaModule: any, debug: boolean): void;
|
|
35
|
+
/**
|
|
36
|
+
* Patch Drizzle ORM to capture queries.
|
|
37
|
+
* Drizzle exposes a logger option in its constructor.
|
|
38
|
+
* We patch the db object's execute method to capture queries.
|
|
39
|
+
*/
|
|
40
|
+
export declare function patchDrizzle(drizzleModule: any, debug: boolean): void;
|
|
41
|
+
/**
|
|
42
|
+
* Patch Knex to capture queries.
|
|
43
|
+
* Knex emits 'query' and 'query-response' events on the knex instance.
|
|
44
|
+
*/
|
|
45
|
+
export declare function patchKnex(knexModule: any, debug: boolean): void;
|
|
29
46
|
/**
|
|
30
47
|
* Patch mongoose to capture MongoDB operations.
|
|
31
48
|
* Called from observe-register when mongoose is required.
|
package/dist/db-observer.js
CHANGED
|
@@ -47,6 +47,9 @@ exports.patchPg = patchPg;
|
|
|
47
47
|
exports.patchMysql2 = patchMysql2;
|
|
48
48
|
exports.patchBetterSqlite3 = patchBetterSqlite3;
|
|
49
49
|
exports.patchIoredis = patchIoredis;
|
|
50
|
+
exports.patchPrisma = patchPrisma;
|
|
51
|
+
exports.patchDrizzle = patchDrizzle;
|
|
52
|
+
exports.patchKnex = patchKnex;
|
|
50
53
|
exports.patchMongoose = patchMongoose;
|
|
51
54
|
const fs = __importStar(require("fs"));
|
|
52
55
|
const path = __importStar(require("path"));
|
|
@@ -333,6 +336,193 @@ function patchIoredis(ioredisModule, debug) {
|
|
|
333
336
|
if (debug)
|
|
334
337
|
console.log('[trickle/db] Redis (ioredis) query tracing enabled');
|
|
335
338
|
}
|
|
339
|
+
/**
|
|
340
|
+
* Patch @prisma/client to capture queries.
|
|
341
|
+
* Prisma has its own query engine (Rust binary) so patching pg/mysql2 won't work.
|
|
342
|
+
* Instead, we hook into Prisma's $on('query') event and $use() middleware.
|
|
343
|
+
*/
|
|
344
|
+
function patchPrisma(prismaModule, debug) {
|
|
345
|
+
debugMode = debug;
|
|
346
|
+
const PrismaClient = prismaModule.PrismaClient;
|
|
347
|
+
if (!PrismaClient || PrismaClient.__trickle_patched)
|
|
348
|
+
return;
|
|
349
|
+
const originalConstructor = PrismaClient;
|
|
350
|
+
const OriginalPrototype = PrismaClient.prototype;
|
|
351
|
+
// Wrap the constructor to inject query logging
|
|
352
|
+
const patchedConstructor = function (opts = {}) {
|
|
353
|
+
// Enable query logging in Prisma
|
|
354
|
+
if (!opts.log)
|
|
355
|
+
opts.log = [];
|
|
356
|
+
const logEntries = Array.isArray(opts.log) ? opts.log : [];
|
|
357
|
+
// Add query event if not already present
|
|
358
|
+
const hasQueryLog = logEntries.some((entry) => (typeof entry === 'string' && entry === 'query') ||
|
|
359
|
+
(typeof entry === 'object' && entry.emit === 'event' && entry.level === 'query'));
|
|
360
|
+
if (!hasQueryLog) {
|
|
361
|
+
logEntries.push({ emit: 'event', level: 'query' });
|
|
362
|
+
}
|
|
363
|
+
opts.log = logEntries;
|
|
364
|
+
// Call original constructor
|
|
365
|
+
const instance = new originalConstructor(opts);
|
|
366
|
+
// Subscribe to query events
|
|
367
|
+
try {
|
|
368
|
+
instance.$on('query', (e) => {
|
|
369
|
+
const queryText = (e.query || '').substring(0, MAX_QUERY_LENGTH);
|
|
370
|
+
const params = e.params ? (() => { try {
|
|
371
|
+
return JSON.parse(e.params).slice(0, 5);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
return undefined;
|
|
375
|
+
} })() : undefined;
|
|
376
|
+
writeQuery({
|
|
377
|
+
kind: 'query',
|
|
378
|
+
query: queryText,
|
|
379
|
+
params,
|
|
380
|
+
durationMs: e.duration || 0,
|
|
381
|
+
rowCount: 0, // Prisma query events don't include row count
|
|
382
|
+
timestamp: Date.now(),
|
|
383
|
+
});
|
|
384
|
+
if (debugMode) {
|
|
385
|
+
console.log(`[trickle/db] Prisma: ${queryText.substring(0, 60)}... (${e.duration}ms)`);
|
|
386
|
+
}
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
catch { /* $on may not be available on all Prisma versions */ }
|
|
390
|
+
return instance;
|
|
391
|
+
};
|
|
392
|
+
// Copy prototype chain
|
|
393
|
+
patchedConstructor.prototype = OriginalPrototype;
|
|
394
|
+
Object.setPrototypeOf(patchedConstructor, originalConstructor);
|
|
395
|
+
// Copy static properties
|
|
396
|
+
for (const key of Object.getOwnPropertyNames(originalConstructor)) {
|
|
397
|
+
if (key !== 'prototype' && key !== 'length' && key !== 'name') {
|
|
398
|
+
try {
|
|
399
|
+
Object.defineProperty(patchedConstructor, key, Object.getOwnPropertyDescriptor(originalConstructor, key));
|
|
400
|
+
}
|
|
401
|
+
catch { }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
prismaModule.PrismaClient = patchedConstructor;
|
|
405
|
+
prismaModule.PrismaClient.__trickle_patched = true;
|
|
406
|
+
if (debug)
|
|
407
|
+
console.log('[trickle/db] Prisma query tracing enabled');
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Patch Drizzle ORM to capture queries.
|
|
411
|
+
* Drizzle exposes a logger option in its constructor.
|
|
412
|
+
* We patch the db object's execute method to capture queries.
|
|
413
|
+
*/
|
|
414
|
+
function patchDrizzle(drizzleModule, debug) {
|
|
415
|
+
debugMode = debug;
|
|
416
|
+
// drizzle-orm exports various dialect-specific functions
|
|
417
|
+
// The common pattern is drizzle(client, { logger: true })
|
|
418
|
+
// We patch by wrapping the module's default export or named exports
|
|
419
|
+
for (const key of Object.keys(drizzleModule)) {
|
|
420
|
+
const fn = drizzleModule[key];
|
|
421
|
+
if (typeof fn !== 'function')
|
|
422
|
+
continue;
|
|
423
|
+
if (fn.__trickle_patched)
|
|
424
|
+
continue;
|
|
425
|
+
// Only patch functions that look like drizzle constructors
|
|
426
|
+
// (they take a client + config object)
|
|
427
|
+
drizzleModule[key] = function patchedDrizzle(...args) {
|
|
428
|
+
// Inject a custom logger into the config
|
|
429
|
+
const config = args[1] && typeof args[1] === 'object' ? { ...args[1] } : (args.length > 1 ? args[1] : {});
|
|
430
|
+
if (typeof config === 'object' && config !== null) {
|
|
431
|
+
const origLogger = config.logger;
|
|
432
|
+
config.logger = {
|
|
433
|
+
logQuery(query, params) {
|
|
434
|
+
const startTime = performance.now();
|
|
435
|
+
writeQuery({
|
|
436
|
+
kind: 'query',
|
|
437
|
+
query: query.substring(0, MAX_QUERY_LENGTH),
|
|
438
|
+
params: Array.isArray(params) ? params.slice(0, 5) : undefined,
|
|
439
|
+
durationMs: 0, // Drizzle logger doesn't provide timing
|
|
440
|
+
rowCount: 0,
|
|
441
|
+
timestamp: Date.now(),
|
|
442
|
+
});
|
|
443
|
+
// Call original logger if present
|
|
444
|
+
if (origLogger && typeof origLogger === 'object' && 'logQuery' in origLogger) {
|
|
445
|
+
origLogger.logQuery(query, params);
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
args[1] = config;
|
|
450
|
+
}
|
|
451
|
+
return fn.apply(this, args);
|
|
452
|
+
};
|
|
453
|
+
drizzleModule[key].__trickle_patched = true;
|
|
454
|
+
}
|
|
455
|
+
if (debug)
|
|
456
|
+
console.log('[trickle/db] Drizzle ORM query tracing enabled');
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Patch Knex to capture queries.
|
|
460
|
+
* Knex emits 'query' and 'query-response' events on the knex instance.
|
|
461
|
+
*/
|
|
462
|
+
function patchKnex(knexModule, debug) {
|
|
463
|
+
debugMode = debug;
|
|
464
|
+
const origKnex = knexModule.default || knexModule;
|
|
465
|
+
if (typeof origKnex !== 'function' || origKnex.__trickle_patched)
|
|
466
|
+
return;
|
|
467
|
+
const wrapped = function patchedKnex(...args) {
|
|
468
|
+
const instance = origKnex.apply(this, args);
|
|
469
|
+
// Track query start times
|
|
470
|
+
const queryTimers = new Map();
|
|
471
|
+
try {
|
|
472
|
+
instance.on('query', (data) => {
|
|
473
|
+
queryTimers.set(data.__knexQueryUid || '', performance.now());
|
|
474
|
+
});
|
|
475
|
+
instance.on('query-response', (_response, data) => {
|
|
476
|
+
const startTime = queryTimers.get(data.__knexQueryUid || '');
|
|
477
|
+
const durationMs = startTime ? Math.round((performance.now() - startTime) * 100) / 100 : 0;
|
|
478
|
+
queryTimers.delete(data.__knexQueryUid || '');
|
|
479
|
+
writeQuery({
|
|
480
|
+
kind: 'query',
|
|
481
|
+
query: (data.sql || '').substring(0, MAX_QUERY_LENGTH),
|
|
482
|
+
params: Array.isArray(data.bindings) ? data.bindings.slice(0, 5) : undefined,
|
|
483
|
+
durationMs,
|
|
484
|
+
rowCount: 0,
|
|
485
|
+
timestamp: Date.now(),
|
|
486
|
+
});
|
|
487
|
+
});
|
|
488
|
+
instance.on('query-error', (error, data) => {
|
|
489
|
+
const startTime = queryTimers.get(data.__knexQueryUid || '');
|
|
490
|
+
const durationMs = startTime ? Math.round((performance.now() - startTime) * 100) / 100 : 0;
|
|
491
|
+
queryTimers.delete(data.__knexQueryUid || '');
|
|
492
|
+
writeQuery({
|
|
493
|
+
kind: 'query',
|
|
494
|
+
query: (data.sql || '').substring(0, MAX_QUERY_LENGTH),
|
|
495
|
+
durationMs,
|
|
496
|
+
rowCount: 0,
|
|
497
|
+
error: error?.message?.substring(0, 200),
|
|
498
|
+
timestamp: Date.now(),
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
catch { /* query events not available */ }
|
|
503
|
+
return instance;
|
|
504
|
+
};
|
|
505
|
+
// Copy properties
|
|
506
|
+
Object.setPrototypeOf(wrapped, origKnex);
|
|
507
|
+
for (const key of Object.getOwnPropertyNames(origKnex)) {
|
|
508
|
+
if (key !== 'length' && key !== 'name') {
|
|
509
|
+
try {
|
|
510
|
+
Object.defineProperty(wrapped, key, Object.getOwnPropertyDescriptor(origKnex, key));
|
|
511
|
+
}
|
|
512
|
+
catch { }
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
if (knexModule.default) {
|
|
516
|
+
knexModule.default = wrapped;
|
|
517
|
+
}
|
|
518
|
+
else {
|
|
519
|
+
// knex exports the function directly via module.exports
|
|
520
|
+
// We can't replace module.exports from here, but observe-register handles that
|
|
521
|
+
}
|
|
522
|
+
wrapped.__trickle_patched = true;
|
|
523
|
+
if (debug)
|
|
524
|
+
console.log('[trickle/db] Knex query tracing enabled');
|
|
525
|
+
}
|
|
336
526
|
/**
|
|
337
527
|
* Patch mongoose to capture MongoDB operations.
|
|
338
528
|
* Called from observe-register when mongoose is required.
|
package/dist/observe-register.js
CHANGED
|
@@ -1203,6 +1203,33 @@ if (enabled) {
|
|
|
1203
1203
|
}
|
|
1204
1204
|
catch { /* not critical */ }
|
|
1205
1205
|
}
|
|
1206
|
+
// Prisma ORM
|
|
1207
|
+
if (request === '@prisma/client' && !expressPatched.has('@prisma/client')) {
|
|
1208
|
+
expressPatched.add('@prisma/client');
|
|
1209
|
+
try {
|
|
1210
|
+
const { patchPrisma } = require(path_1.default.join(__dirname, 'db-observer.js'));
|
|
1211
|
+
patchPrisma(exports, debug);
|
|
1212
|
+
}
|
|
1213
|
+
catch { /* not critical */ }
|
|
1214
|
+
}
|
|
1215
|
+
// Drizzle ORM
|
|
1216
|
+
if (request.startsWith('drizzle-orm') && !expressPatched.has('drizzle-orm')) {
|
|
1217
|
+
expressPatched.add('drizzle-orm');
|
|
1218
|
+
try {
|
|
1219
|
+
const { patchDrizzle } = require(path_1.default.join(__dirname, 'db-observer.js'));
|
|
1220
|
+
patchDrizzle(exports, debug);
|
|
1221
|
+
}
|
|
1222
|
+
catch { /* not critical */ }
|
|
1223
|
+
}
|
|
1224
|
+
// Knex query builder
|
|
1225
|
+
if (request === 'knex' && !expressPatched.has('knex')) {
|
|
1226
|
+
expressPatched.add('knex');
|
|
1227
|
+
try {
|
|
1228
|
+
const { patchKnex } = require(path_1.default.join(__dirname, 'db-observer.js'));
|
|
1229
|
+
patchKnex(exports, debug);
|
|
1230
|
+
}
|
|
1231
|
+
catch { /* not critical */ }
|
|
1232
|
+
}
|
|
1206
1233
|
// Redis (ioredis)
|
|
1207
1234
|
if (request === 'ioredis' && !expressPatched.has('ioredis')) {
|
|
1208
1235
|
expressPatched.add('ioredis');
|
package/dist/transport.js
CHANGED
|
@@ -54,6 +54,13 @@ let localFilePath = '';
|
|
|
54
54
|
let queue = [];
|
|
55
55
|
let flushTimer = null;
|
|
56
56
|
let isFlushing = false;
|
|
57
|
+
// Cloud streaming: when TRICKLE_CLOUD_URL + TRICKLE_CLOUD_TOKEN are set,
|
|
58
|
+
// observations are also streamed to the cloud backend in real-time.
|
|
59
|
+
let cloudUrl = process.env.TRICKLE_CLOUD_URL || '';
|
|
60
|
+
let cloudToken = process.env.TRICKLE_CLOUD_TOKEN || '';
|
|
61
|
+
let cloudProject = '';
|
|
62
|
+
let cloudBuffer = [];
|
|
63
|
+
let cloudFlushTimer = null;
|
|
57
64
|
/**
|
|
58
65
|
* Configure the transport layer with global options.
|
|
59
66
|
*/
|
|
@@ -63,6 +70,34 @@ function configure(opts) {
|
|
|
63
70
|
maxBatchSize = opts.maxBatchSize || DEFAULT_MAX_BATCH_SIZE;
|
|
64
71
|
enabled = opts.enabled !== false;
|
|
65
72
|
debug = opts.debug === true;
|
|
73
|
+
// Load cloud config from ~/.trickle/cloud.json if env vars not set
|
|
74
|
+
if (!cloudUrl || !cloudToken) {
|
|
75
|
+
try {
|
|
76
|
+
const configPath = pathMod.join(process.env.HOME || '~', '.trickle', 'cloud.json');
|
|
77
|
+
if (fs.existsSync(configPath)) {
|
|
78
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
79
|
+
if (!cloudUrl && config.url)
|
|
80
|
+
cloudUrl = config.url;
|
|
81
|
+
if (!cloudToken && config.token)
|
|
82
|
+
cloudToken = config.token;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
catch { }
|
|
86
|
+
}
|
|
87
|
+
cloudProject = process.env.TRICKLE_CLOUD_PROJECT || pathMod.basename(process.cwd());
|
|
88
|
+
// Start cloud streaming if configured
|
|
89
|
+
if (cloudUrl && cloudToken && !cloudFlushTimer) {
|
|
90
|
+
cloudFlushTimer = setInterval(() => {
|
|
91
|
+
flushCloud().catch(() => { });
|
|
92
|
+
}, 5000); // Flush to cloud every 5 seconds
|
|
93
|
+
if (cloudFlushTimer.unref)
|
|
94
|
+
cloudFlushTimer.unref();
|
|
95
|
+
if (debug) {
|
|
96
|
+
console.log(`[trickle] Cloud streaming enabled → ${cloudUrl}`);
|
|
97
|
+
}
|
|
98
|
+
// Flush cloud buffer on exit
|
|
99
|
+
process.on('beforeExit', () => { flushCloud().catch(() => { }); });
|
|
100
|
+
}
|
|
66
101
|
// Check for local/file-based mode
|
|
67
102
|
if (process.env.TRICKLE_LOCAL === '1') {
|
|
68
103
|
localMode = true;
|
|
@@ -93,7 +128,12 @@ function enqueue(payload) {
|
|
|
93
128
|
// Local file mode: append directly to JSONL file
|
|
94
129
|
if (localMode && localFilePath) {
|
|
95
130
|
try {
|
|
96
|
-
|
|
131
|
+
const line = JSON.stringify(payload) + '\n';
|
|
132
|
+
fs.appendFileSync(localFilePath, line);
|
|
133
|
+
// Also buffer for cloud if configured
|
|
134
|
+
if (cloudUrl && cloudToken) {
|
|
135
|
+
cloudBuffer.push(line);
|
|
136
|
+
}
|
|
97
137
|
}
|
|
98
138
|
catch {
|
|
99
139
|
// Never crash user's app
|
|
@@ -200,6 +240,32 @@ function stopTimer() {
|
|
|
200
240
|
function sleep(ms) {
|
|
201
241
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
202
242
|
}
|
|
243
|
+
/**
|
|
244
|
+
* Flush buffered observations to the cloud backend.
|
|
245
|
+
*/
|
|
246
|
+
async function flushCloud() {
|
|
247
|
+
if (cloudBuffer.length === 0 || !cloudUrl || !cloudToken)
|
|
248
|
+
return;
|
|
249
|
+
const lines = cloudBuffer.splice(0);
|
|
250
|
+
try {
|
|
251
|
+
await fetch(`${cloudUrl}/api/v1/ingest`, {
|
|
252
|
+
method: 'POST',
|
|
253
|
+
headers: {
|
|
254
|
+
'Content-Type': 'application/json',
|
|
255
|
+
'Authorization': `Bearer ${cloudToken}`,
|
|
256
|
+
},
|
|
257
|
+
body: JSON.stringify({
|
|
258
|
+
project: cloudProject,
|
|
259
|
+
file: 'observations.jsonl',
|
|
260
|
+
lines: lines.join(''),
|
|
261
|
+
}),
|
|
262
|
+
signal: AbortSignal.timeout(10000),
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
// Silent — never crash user's app. Data is still saved locally.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
203
269
|
function silentError() {
|
|
204
270
|
// Intentionally empty — never crash user's app
|
|
205
271
|
}
|
package/package.json
CHANGED
package/src/db-observer.ts
CHANGED
|
@@ -332,6 +332,207 @@ export function patchIoredis(ioredisModule: any, debug: boolean): void {
|
|
|
332
332
|
if (debug) console.log('[trickle/db] Redis (ioredis) query tracing enabled');
|
|
333
333
|
}
|
|
334
334
|
|
|
335
|
+
/**
|
|
336
|
+
* Patch @prisma/client to capture queries.
|
|
337
|
+
* Prisma has its own query engine (Rust binary) so patching pg/mysql2 won't work.
|
|
338
|
+
* Instead, we hook into Prisma's $on('query') event and $use() middleware.
|
|
339
|
+
*/
|
|
340
|
+
export function patchPrisma(prismaModule: any, debug: boolean): void {
|
|
341
|
+
debugMode = debug;
|
|
342
|
+
|
|
343
|
+
const PrismaClient = prismaModule.PrismaClient;
|
|
344
|
+
if (!PrismaClient || (PrismaClient as any).__trickle_patched) return;
|
|
345
|
+
|
|
346
|
+
const originalConstructor = PrismaClient;
|
|
347
|
+
const OriginalPrototype = PrismaClient.prototype;
|
|
348
|
+
|
|
349
|
+
// Wrap the constructor to inject query logging
|
|
350
|
+
const patchedConstructor = function (this: any, opts: any = {}) {
|
|
351
|
+
// Enable query logging in Prisma
|
|
352
|
+
if (!opts.log) opts.log = [];
|
|
353
|
+
const logEntries = Array.isArray(opts.log) ? opts.log : [];
|
|
354
|
+
|
|
355
|
+
// Add query event if not already present
|
|
356
|
+
const hasQueryLog = logEntries.some((entry: any) =>
|
|
357
|
+
(typeof entry === 'string' && entry === 'query') ||
|
|
358
|
+
(typeof entry === 'object' && entry.emit === 'event' && entry.level === 'query')
|
|
359
|
+
);
|
|
360
|
+
if (!hasQueryLog) {
|
|
361
|
+
logEntries.push({ emit: 'event', level: 'query' });
|
|
362
|
+
}
|
|
363
|
+
opts.log = logEntries;
|
|
364
|
+
|
|
365
|
+
// Call original constructor
|
|
366
|
+
const instance = new originalConstructor(opts);
|
|
367
|
+
|
|
368
|
+
// Subscribe to query events
|
|
369
|
+
try {
|
|
370
|
+
instance.$on('query', (e: any) => {
|
|
371
|
+
const queryText = (e.query || '').substring(0, MAX_QUERY_LENGTH);
|
|
372
|
+
const params = e.params ? (() => { try { return JSON.parse(e.params).slice(0, 5); } catch { return undefined; } })() : undefined;
|
|
373
|
+
writeQuery({
|
|
374
|
+
kind: 'query',
|
|
375
|
+
query: queryText,
|
|
376
|
+
params,
|
|
377
|
+
durationMs: e.duration || 0,
|
|
378
|
+
rowCount: 0, // Prisma query events don't include row count
|
|
379
|
+
timestamp: Date.now(),
|
|
380
|
+
});
|
|
381
|
+
if (debugMode) {
|
|
382
|
+
console.log(`[trickle/db] Prisma: ${queryText.substring(0, 60)}... (${e.duration}ms)`);
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
} catch { /* $on may not be available on all Prisma versions */ }
|
|
386
|
+
|
|
387
|
+
return instance;
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
// Copy prototype chain
|
|
391
|
+
patchedConstructor.prototype = OriginalPrototype;
|
|
392
|
+
Object.setPrototypeOf(patchedConstructor, originalConstructor);
|
|
393
|
+
|
|
394
|
+
// Copy static properties
|
|
395
|
+
for (const key of Object.getOwnPropertyNames(originalConstructor)) {
|
|
396
|
+
if (key !== 'prototype' && key !== 'length' && key !== 'name') {
|
|
397
|
+
try {
|
|
398
|
+
Object.defineProperty(patchedConstructor, key, Object.getOwnPropertyDescriptor(originalConstructor, key)!);
|
|
399
|
+
} catch {}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
prismaModule.PrismaClient = patchedConstructor;
|
|
404
|
+
(prismaModule.PrismaClient as any).__trickle_patched = true;
|
|
405
|
+
|
|
406
|
+
if (debug) console.log('[trickle/db] Prisma query tracing enabled');
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Patch Drizzle ORM to capture queries.
|
|
411
|
+
* Drizzle exposes a logger option in its constructor.
|
|
412
|
+
* We patch the db object's execute method to capture queries.
|
|
413
|
+
*/
|
|
414
|
+
export function patchDrizzle(drizzleModule: any, debug: boolean): void {
|
|
415
|
+
debugMode = debug;
|
|
416
|
+
|
|
417
|
+
// drizzle-orm exports various dialect-specific functions
|
|
418
|
+
// The common pattern is drizzle(client, { logger: true })
|
|
419
|
+
// We patch by wrapping the module's default export or named exports
|
|
420
|
+
|
|
421
|
+
for (const key of Object.keys(drizzleModule)) {
|
|
422
|
+
const fn = drizzleModule[key];
|
|
423
|
+
if (typeof fn !== 'function') continue;
|
|
424
|
+
if ((fn as any).__trickle_patched) continue;
|
|
425
|
+
|
|
426
|
+
// Only patch functions that look like drizzle constructors
|
|
427
|
+
// (they take a client + config object)
|
|
428
|
+
drizzleModule[key] = function patchedDrizzle(...args: any[]): any {
|
|
429
|
+
// Inject a custom logger into the config
|
|
430
|
+
const config = args[1] && typeof args[1] === 'object' ? { ...args[1] } : (args.length > 1 ? args[1] : {});
|
|
431
|
+
|
|
432
|
+
if (typeof config === 'object' && config !== null) {
|
|
433
|
+
const origLogger = config.logger;
|
|
434
|
+
config.logger = {
|
|
435
|
+
logQuery(query: string, params: unknown[]): void {
|
|
436
|
+
const startTime = performance.now();
|
|
437
|
+
writeQuery({
|
|
438
|
+
kind: 'query',
|
|
439
|
+
query: query.substring(0, MAX_QUERY_LENGTH),
|
|
440
|
+
params: Array.isArray(params) ? params.slice(0, 5) : undefined,
|
|
441
|
+
durationMs: 0, // Drizzle logger doesn't provide timing
|
|
442
|
+
rowCount: 0,
|
|
443
|
+
timestamp: Date.now(),
|
|
444
|
+
});
|
|
445
|
+
// Call original logger if present
|
|
446
|
+
if (origLogger && typeof origLogger === 'object' && 'logQuery' in origLogger) {
|
|
447
|
+
(origLogger as any).logQuery(query, params);
|
|
448
|
+
}
|
|
449
|
+
},
|
|
450
|
+
};
|
|
451
|
+
args[1] = config;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return fn.apply(this, args);
|
|
455
|
+
};
|
|
456
|
+
(drizzleModule[key] as any).__trickle_patched = true;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (debug) console.log('[trickle/db] Drizzle ORM query tracing enabled');
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Patch Knex to capture queries.
|
|
464
|
+
* Knex emits 'query' and 'query-response' events on the knex instance.
|
|
465
|
+
*/
|
|
466
|
+
export function patchKnex(knexModule: any, debug: boolean): void {
|
|
467
|
+
debugMode = debug;
|
|
468
|
+
|
|
469
|
+
const origKnex = knexModule.default || knexModule;
|
|
470
|
+
if (typeof origKnex !== 'function' || (origKnex as any).__trickle_patched) return;
|
|
471
|
+
|
|
472
|
+
const wrapped = function patchedKnex(this: any, ...args: any[]): any {
|
|
473
|
+
const instance = origKnex.apply(this, args);
|
|
474
|
+
|
|
475
|
+
// Track query start times
|
|
476
|
+
const queryTimers = new Map<string, number>();
|
|
477
|
+
|
|
478
|
+
try {
|
|
479
|
+
instance.on('query', (data: any) => {
|
|
480
|
+
queryTimers.set(data.__knexQueryUid || '', performance.now());
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
instance.on('query-response', (_response: any, data: any) => {
|
|
484
|
+
const startTime = queryTimers.get(data.__knexQueryUid || '');
|
|
485
|
+
const durationMs = startTime ? Math.round((performance.now() - startTime) * 100) / 100 : 0;
|
|
486
|
+
queryTimers.delete(data.__knexQueryUid || '');
|
|
487
|
+
|
|
488
|
+
writeQuery({
|
|
489
|
+
kind: 'query',
|
|
490
|
+
query: (data.sql || '').substring(0, MAX_QUERY_LENGTH),
|
|
491
|
+
params: Array.isArray(data.bindings) ? data.bindings.slice(0, 5) : undefined,
|
|
492
|
+
durationMs,
|
|
493
|
+
rowCount: 0,
|
|
494
|
+
timestamp: Date.now(),
|
|
495
|
+
});
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
instance.on('query-error', (error: any, data: any) => {
|
|
499
|
+
const startTime = queryTimers.get(data.__knexQueryUid || '');
|
|
500
|
+
const durationMs = startTime ? Math.round((performance.now() - startTime) * 100) / 100 : 0;
|
|
501
|
+
queryTimers.delete(data.__knexQueryUid || '');
|
|
502
|
+
|
|
503
|
+
writeQuery({
|
|
504
|
+
kind: 'query',
|
|
505
|
+
query: (data.sql || '').substring(0, MAX_QUERY_LENGTH),
|
|
506
|
+
durationMs,
|
|
507
|
+
rowCount: 0,
|
|
508
|
+
error: error?.message?.substring(0, 200),
|
|
509
|
+
timestamp: Date.now(),
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
} catch { /* query events not available */ }
|
|
513
|
+
|
|
514
|
+
return instance;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
// Copy properties
|
|
518
|
+
Object.setPrototypeOf(wrapped, origKnex);
|
|
519
|
+
for (const key of Object.getOwnPropertyNames(origKnex)) {
|
|
520
|
+
if (key !== 'length' && key !== 'name') {
|
|
521
|
+
try { Object.defineProperty(wrapped, key, Object.getOwnPropertyDescriptor(origKnex, key)!); } catch {}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (knexModule.default) {
|
|
526
|
+
knexModule.default = wrapped;
|
|
527
|
+
} else {
|
|
528
|
+
// knex exports the function directly via module.exports
|
|
529
|
+
// We can't replace module.exports from here, but observe-register handles that
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
(wrapped as any).__trickle_patched = true;
|
|
533
|
+
if (debug) console.log('[trickle/db] Knex query tracing enabled');
|
|
534
|
+
}
|
|
535
|
+
|
|
335
536
|
/**
|
|
336
537
|
* Patch mongoose to capture MongoDB operations.
|
|
337
538
|
* Called from observe-register when mongoose is required.
|
package/src/observe-register.ts
CHANGED
|
@@ -1188,6 +1188,33 @@ if (enabled) {
|
|
|
1188
1188
|
} catch { /* not critical */ }
|
|
1189
1189
|
}
|
|
1190
1190
|
|
|
1191
|
+
// Prisma ORM
|
|
1192
|
+
if (request === '@prisma/client' && !expressPatched.has('@prisma/client')) {
|
|
1193
|
+
expressPatched.add('@prisma/client');
|
|
1194
|
+
try {
|
|
1195
|
+
const { patchPrisma } = require(path.join(__dirname, 'db-observer.js'));
|
|
1196
|
+
patchPrisma(exports, debug);
|
|
1197
|
+
} catch { /* not critical */ }
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
// Drizzle ORM
|
|
1201
|
+
if (request.startsWith('drizzle-orm') && !expressPatched.has('drizzle-orm')) {
|
|
1202
|
+
expressPatched.add('drizzle-orm');
|
|
1203
|
+
try {
|
|
1204
|
+
const { patchDrizzle } = require(path.join(__dirname, 'db-observer.js'));
|
|
1205
|
+
patchDrizzle(exports, debug);
|
|
1206
|
+
} catch { /* not critical */ }
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Knex query builder
|
|
1210
|
+
if (request === 'knex' && !expressPatched.has('knex')) {
|
|
1211
|
+
expressPatched.add('knex');
|
|
1212
|
+
try {
|
|
1213
|
+
const { patchKnex } = require(path.join(__dirname, 'db-observer.js'));
|
|
1214
|
+
patchKnex(exports, debug);
|
|
1215
|
+
} catch { /* not critical */ }
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1191
1218
|
// Redis (ioredis)
|
|
1192
1219
|
if (request === 'ioredis' && !expressPatched.has('ioredis')) {
|
|
1193
1220
|
expressPatched.add('ioredis');
|
package/src/transport.ts
CHANGED
|
@@ -19,6 +19,14 @@ let queue: IngestPayload[] = [];
|
|
|
19
19
|
let flushTimer: ReturnType<typeof setInterval> | null = null;
|
|
20
20
|
let isFlushing = false;
|
|
21
21
|
|
|
22
|
+
// Cloud streaming: when TRICKLE_CLOUD_URL + TRICKLE_CLOUD_TOKEN are set,
|
|
23
|
+
// observations are also streamed to the cloud backend in real-time.
|
|
24
|
+
let cloudUrl = process.env.TRICKLE_CLOUD_URL || '';
|
|
25
|
+
let cloudToken = process.env.TRICKLE_CLOUD_TOKEN || '';
|
|
26
|
+
let cloudProject = '';
|
|
27
|
+
let cloudBuffer: string[] = [];
|
|
28
|
+
let cloudFlushTimer: ReturnType<typeof setInterval> | null = null;
|
|
29
|
+
|
|
22
30
|
/**
|
|
23
31
|
* Configure the transport layer with global options.
|
|
24
32
|
*/
|
|
@@ -29,6 +37,33 @@ export function configure(opts: GlobalOpts): void {
|
|
|
29
37
|
enabled = opts.enabled !== false;
|
|
30
38
|
debug = opts.debug === true;
|
|
31
39
|
|
|
40
|
+
// Load cloud config from ~/.trickle/cloud.json if env vars not set
|
|
41
|
+
if (!cloudUrl || !cloudToken) {
|
|
42
|
+
try {
|
|
43
|
+
const configPath = pathMod.join(process.env.HOME || '~', '.trickle', 'cloud.json');
|
|
44
|
+
if (fs.existsSync(configPath)) {
|
|
45
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
46
|
+
if (!cloudUrl && config.url) cloudUrl = config.url;
|
|
47
|
+
if (!cloudToken && config.token) cloudToken = config.token;
|
|
48
|
+
}
|
|
49
|
+
} catch {}
|
|
50
|
+
}
|
|
51
|
+
cloudProject = process.env.TRICKLE_CLOUD_PROJECT || pathMod.basename(process.cwd());
|
|
52
|
+
|
|
53
|
+
// Start cloud streaming if configured
|
|
54
|
+
if (cloudUrl && cloudToken && !cloudFlushTimer) {
|
|
55
|
+
cloudFlushTimer = setInterval(() => {
|
|
56
|
+
flushCloud().catch(() => {});
|
|
57
|
+
}, 5000); // Flush to cloud every 5 seconds
|
|
58
|
+
if (cloudFlushTimer.unref) cloudFlushTimer.unref();
|
|
59
|
+
if (debug) {
|
|
60
|
+
console.log(`[trickle] Cloud streaming enabled → ${cloudUrl}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Flush cloud buffer on exit
|
|
64
|
+
process.on('beforeExit', () => { flushCloud().catch(() => {}); });
|
|
65
|
+
}
|
|
66
|
+
|
|
32
67
|
// Check for local/file-based mode
|
|
33
68
|
if (process.env.TRICKLE_LOCAL === '1') {
|
|
34
69
|
localMode = true;
|
|
@@ -58,7 +93,12 @@ export function enqueue(payload: IngestPayload): void {
|
|
|
58
93
|
// Local file mode: append directly to JSONL file
|
|
59
94
|
if (localMode && localFilePath) {
|
|
60
95
|
try {
|
|
61
|
-
|
|
96
|
+
const line = JSON.stringify(payload) + '\n';
|
|
97
|
+
fs.appendFileSync(localFilePath, line);
|
|
98
|
+
// Also buffer for cloud if configured
|
|
99
|
+
if (cloudUrl && cloudToken) {
|
|
100
|
+
cloudBuffer.push(line);
|
|
101
|
+
}
|
|
62
102
|
} catch {
|
|
63
103
|
// Never crash user's app
|
|
64
104
|
}
|
|
@@ -175,6 +215,32 @@ function sleep(ms: number): Promise<void> {
|
|
|
175
215
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
176
216
|
}
|
|
177
217
|
|
|
218
|
+
/**
|
|
219
|
+
* Flush buffered observations to the cloud backend.
|
|
220
|
+
*/
|
|
221
|
+
async function flushCloud(): Promise<void> {
|
|
222
|
+
if (cloudBuffer.length === 0 || !cloudUrl || !cloudToken) return;
|
|
223
|
+
|
|
224
|
+
const lines = cloudBuffer.splice(0);
|
|
225
|
+
try {
|
|
226
|
+
await fetch(`${cloudUrl}/api/v1/ingest`, {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
headers: {
|
|
229
|
+
'Content-Type': 'application/json',
|
|
230
|
+
'Authorization': `Bearer ${cloudToken}`,
|
|
231
|
+
},
|
|
232
|
+
body: JSON.stringify({
|
|
233
|
+
project: cloudProject,
|
|
234
|
+
file: 'observations.jsonl',
|
|
235
|
+
lines: lines.join(''),
|
|
236
|
+
}),
|
|
237
|
+
signal: AbortSignal.timeout(10000),
|
|
238
|
+
});
|
|
239
|
+
} catch {
|
|
240
|
+
// Silent — never crash user's app. Data is still saved locally.
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
178
244
|
function silentError(): void {
|
|
179
245
|
// Intentionally empty — never crash user's app
|
|
180
246
|
}
|