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.
@@ -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.
@@ -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.
@@ -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
- fs.appendFileSync(localFilePath, JSON.stringify(payload) + '\n');
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trickle-observe",
3
- "version": "0.2.105",
3
+ "version": "0.2.107",
4
4
  "description": "Runtime type observability for JavaScript applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -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.
@@ -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
- fs.appendFileSync(localFilePath, JSON.stringify(payload) + '\n');
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
  }