openclaw-observability 2026.4.1 → 2026.4.21

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 (112) hide show
  1. package/README.md +4 -4
  2. package/dist/cloud/api-key-auth.d.ts.map +1 -1
  3. package/dist/cloud/api-key-auth.js +4 -9
  4. package/dist/cloud/api-key-auth.js.map +1 -1
  5. package/dist/cloud/types.d.ts +2 -3
  6. package/dist/cloud/types.d.ts.map +1 -1
  7. package/dist/config.d.ts +34 -5
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +35 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/gateway/register-observability-gateway.d.ts +6 -4
  12. package/dist/gateway/register-observability-gateway.d.ts.map +1 -1
  13. package/dist/gateway/register-observability-gateway.js +105 -2
  14. package/dist/gateway/register-observability-gateway.js.map +1 -1
  15. package/dist/hooks/messages.d.ts +4 -3
  16. package/dist/hooks/messages.d.ts.map +1 -1
  17. package/dist/hooks/messages.js +23 -1
  18. package/dist/hooks/messages.js.map +1 -1
  19. package/dist/hooks/session.d.ts +4 -3
  20. package/dist/hooks/session.d.ts.map +1 -1
  21. package/dist/hooks/session.js +9 -4
  22. package/dist/hooks/session.js.map +1 -1
  23. package/dist/hooks/subagent.d.ts +4 -3
  24. package/dist/hooks/subagent.d.ts.map +1 -1
  25. package/dist/hooks/subagent.js +4 -1
  26. package/dist/hooks/subagent.js.map +1 -1
  27. package/dist/hooks/tools.d.ts +3 -3
  28. package/dist/hooks/tools.d.ts.map +1 -1
  29. package/dist/hooks/tools.js +122 -4
  30. package/dist/hooks/tools.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +472 -118
  34. package/dist/index.js.map +1 -1
  35. package/dist/llm/replay-runtime.d.ts +16 -0
  36. package/dist/llm/replay-runtime.d.ts.map +1 -0
  37. package/dist/llm/replay-runtime.js +596 -0
  38. package/dist/llm/replay-runtime.js.map +1 -0
  39. package/dist/llm/replay.d.ts +3 -0
  40. package/dist/llm/replay.d.ts.map +1 -1
  41. package/dist/llm/replay.js.map +1 -1
  42. package/dist/redaction.d.ts +1 -1
  43. package/dist/redaction.js +1 -1
  44. package/dist/runtime/index.d.ts +1 -1
  45. package/dist/runtime/index.d.ts.map +1 -1
  46. package/dist/runtime/index.js +3 -1
  47. package/dist/runtime/index.js.map +1 -1
  48. package/dist/runtime/session-context.d.ts +4 -3
  49. package/dist/runtime/session-context.d.ts.map +1 -1
  50. package/dist/runtime/session-context.js +37 -17
  51. package/dist/runtime/session-context.js.map +1 -1
  52. package/dist/security/chain-detector.d.ts +4 -4
  53. package/dist/security/chain-detector.d.ts.map +1 -1
  54. package/dist/security/chain-detector.js.map +1 -1
  55. package/dist/security/rules.d.ts +2 -2
  56. package/dist/security/rules.d.ts.map +1 -1
  57. package/dist/security/rules.js +9 -2
  58. package/dist/security/rules.js.map +1 -1
  59. package/dist/security/scanner.d.ts +8 -3
  60. package/dist/security/scanner.d.ts.map +1 -1
  61. package/dist/security/scanner.js +85 -7
  62. package/dist/security/scanner.js.map +1 -1
  63. package/dist/security/types.d.ts +3 -0
  64. package/dist/security/types.d.ts.map +1 -1
  65. package/dist/storage/buffer.d.ts +7 -7
  66. package/dist/storage/buffer.d.ts.map +1 -1
  67. package/dist/storage/buffer.js +2 -2
  68. package/dist/storage/buffer.js.map +1 -1
  69. package/dist/storage/cloud-export-writer.d.ts +23 -0
  70. package/dist/storage/cloud-export-writer.d.ts.map +1 -0
  71. package/dist/storage/cloud-export-writer.js +202 -0
  72. package/dist/storage/cloud-export-writer.js.map +1 -0
  73. package/dist/storage/duckdb-local-writer.d.ts +19 -3
  74. package/dist/storage/duckdb-local-writer.d.ts.map +1 -1
  75. package/dist/storage/duckdb-local-writer.js +261 -81
  76. package/dist/storage/duckdb-local-writer.js.map +1 -1
  77. package/dist/storage/duckdb-observability-forwarder.d.ts +16 -0
  78. package/dist/storage/duckdb-observability-forwarder.d.ts.map +1 -0
  79. package/dist/storage/duckdb-observability-forwarder.js +289 -0
  80. package/dist/storage/duckdb-observability-forwarder.js.map +1 -0
  81. package/dist/storage/mysql-writer.d.ts +35 -6
  82. package/dist/storage/mysql-writer.d.ts.map +1 -1
  83. package/dist/storage/mysql-writer.js +251 -32
  84. package/dist/storage/mysql-writer.js.map +1 -1
  85. package/dist/storage/schema.d.ts +2 -2
  86. package/dist/storage/schema.d.ts.map +1 -1
  87. package/dist/storage/schema.js +181 -53
  88. package/dist/storage/schema.js.map +1 -1
  89. package/dist/storage/structured-model.d.ts +11 -2
  90. package/dist/storage/structured-model.d.ts.map +1 -1
  91. package/dist/storage/structured-model.js +183 -5
  92. package/dist/storage/structured-model.js.map +1 -1
  93. package/dist/storage/writer.d.ts +14 -2
  94. package/dist/storage/writer.d.ts.map +1 -1
  95. package/dist/types.d.ts +28 -4
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/types.js +3 -1
  98. package/dist/types.js.map +1 -1
  99. package/dist/web/api.d.ts +80 -2
  100. package/dist/web/api.d.ts.map +1 -1
  101. package/dist/web/api.js +917 -113
  102. package/dist/web/api.js.map +1 -1
  103. package/dist/web/routes.d.ts +22 -2
  104. package/dist/web/routes.d.ts.map +1 -1
  105. package/dist/web/routes.js +264 -21
  106. package/dist/web/routes.js.map +1 -1
  107. package/dist/web/ui.d.ts +3 -1
  108. package/dist/web/ui.d.ts.map +1 -1
  109. package/dist/web/ui.js +2678 -633
  110. package/dist/web/ui.js.map +1 -1
  111. package/openclaw.plugin.json +145 -4
  112. package/package.json +1 -1
@@ -38,9 +38,31 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  })();
39
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
40
  exports.DuckDBLocalWriter = void 0;
41
+ exports.buildDuckDBOpenOptions = buildDuckDBOpenOptions;
41
42
  const path = __importStar(require("path"));
42
43
  const fs = __importStar(require("fs"));
43
44
  const structured_model_1 = require("./structured-model");
45
+ const DEFAULT_DUCKDB_MEMORY_LIMIT = '1GB';
46
+ const DEFAULT_DUCKDB_THREADS = 2;
47
+ const MAX_DUCKDB_THREADS = 8;
48
+ const DEFAULT_DUCKDB_TEMP_DIRECTORY = path.join('/tmp', 'openclaw-observability-duckdb');
49
+ function buildDuckDBOpenOptions(config) {
50
+ const memoryLimit = typeof config.memoryLimit === 'string' && config.memoryLimit.trim()
51
+ ? config.memoryLimit.trim()
52
+ : DEFAULT_DUCKDB_MEMORY_LIMIT;
53
+ const rawThreads = Number(config.threads ?? DEFAULT_DUCKDB_THREADS);
54
+ const threads = Number.isFinite(rawThreads)
55
+ ? Math.min(MAX_DUCKDB_THREADS, Math.max(1, Math.floor(rawThreads)))
56
+ : DEFAULT_DUCKDB_THREADS;
57
+ const tempDirectory = typeof config.tempDirectory === 'string' && config.tempDirectory.trim()
58
+ ? config.tempDirectory.trim()
59
+ : DEFAULT_DUCKDB_TEMP_DIRECTORY;
60
+ return {
61
+ memory_limit: memoryLimit,
62
+ threads: String(threads),
63
+ temp_directory: tempDirectory,
64
+ };
65
+ }
44
66
  /* ------------------------------------------------------------------ */
45
67
  /* @duckdb/node-api Promise wrapper -> QueryPool adapter */
46
68
  /* ------------------------------------------------------------------ */
@@ -252,9 +274,9 @@ class DuckDBPool {
252
274
  */
253
275
  const DUCKDB_SCHEMA_STATEMENTS = [
254
276
  // Drop legacy sequences that cause WAL write-write conflicts
255
- `DROP SEQUENCE IF EXISTS audit_actions_id_seq`,
256
- `DROP SEQUENCE IF EXISTS audit_alerts_id_seq`,
257
- `CREATE TABLE IF NOT EXISTS audit_actions (
277
+ `DROP SEQUENCE IF EXISTS observation_actions_id_seq`,
278
+ `DROP SEQUENCE IF EXISTS observation_alerts_id_seq`,
279
+ `CREATE TABLE IF NOT EXISTS observation_actions (
258
280
  id BIGINT PRIMARY KEY,
259
281
  session_id VARCHAR(64) NOT NULL,
260
282
  action_type VARCHAR(32) NOT NULL,
@@ -266,18 +288,21 @@ const DUCKDB_SCHEMA_STATEMENTS = [
266
288
  completion_tokens INTEGER DEFAULT NULL,
267
289
  duration_ms INTEGER DEFAULT NULL,
268
290
  user_id VARCHAR(128) DEFAULT '',
291
+ run_id VARCHAR(128) DEFAULT '',
292
+ parent_run_id VARCHAR(128) DEFAULT '',
269
293
  channel_id VARCHAR(128) DEFAULT '',
270
294
  created_at TIMESTAMP DEFAULT current_timestamp
271
295
  )`,
272
- `CREATE INDEX IF NOT EXISTS idx_act_session ON audit_actions(session_id)`,
273
- `CREATE INDEX IF NOT EXISTS idx_act_type ON audit_actions(action_type)`,
274
- `CREATE INDEX IF NOT EXISTS idx_act_created ON audit_actions(created_at)`,
275
- `CREATE INDEX IF NOT EXISTS idx_act_user ON audit_actions(user_id)`,
296
+ `CREATE INDEX IF NOT EXISTS idx_act_session ON observation_actions(session_id)`,
297
+ `CREATE INDEX IF NOT EXISTS idx_act_type ON observation_actions(action_type)`,
298
+ `CREATE INDEX IF NOT EXISTS idx_act_created ON observation_actions(created_at)`,
299
+ `CREATE INDEX IF NOT EXISTS idx_act_user ON observation_actions(user_id)`,
276
300
  // Note: idx_act_channel is created after migration (see migration logic below)
277
301
  // This prevents errors when table exists without channel_id column
278
- `CREATE TABLE IF NOT EXISTS audit_sessions (
302
+ `CREATE TABLE IF NOT EXISTS observation_sessions (
279
303
  session_id VARCHAR(64) PRIMARY KEY,
280
304
  user_id VARCHAR(128) DEFAULT '',
305
+ parent_session_id VARCHAR(128) DEFAULT '',
281
306
  model_name VARCHAR(128) DEFAULT '',
282
307
  channel_id VARCHAR(128) DEFAULT '',
283
308
  start_time TIMESTAMP NOT NULL,
@@ -285,11 +310,11 @@ const DUCKDB_SCHEMA_STATEMENTS = [
285
310
  total_actions INTEGER DEFAULT 0,
286
311
  total_tokens INTEGER DEFAULT 0
287
312
  )`,
288
- `CREATE INDEX IF NOT EXISTS idx_ses_user ON audit_sessions(user_id)`,
289
- `CREATE INDEX IF NOT EXISTS idx_ses_start ON audit_sessions(start_time)`,
290
- // Note: idx_ses_channel is created after migration (see migration logic below)
313
+ `CREATE INDEX IF NOT EXISTS idx_ses_user ON observation_sessions(user_id)`,
314
+ `CREATE INDEX IF NOT EXISTS idx_ses_start ON observation_sessions(start_time)`,
315
+ // Note: idx_ses_parent / idx_ses_channel are created after migration
291
316
  // This prevents errors when table exists without channel_id column
292
- `CREATE TABLE IF NOT EXISTS audit_alerts (
317
+ `CREATE TABLE IF NOT EXISTS observation_alerts (
293
318
  id BIGINT PRIMARY KEY,
294
319
  alert_id VARCHAR(64) NOT NULL,
295
320
  session_id VARCHAR(64) NOT NULL,
@@ -308,12 +333,12 @@ const DUCKDB_SCHEMA_STATEMENTS = [
308
333
  model_name VARCHAR(128) DEFAULT '',
309
334
  created_at TIMESTAMP DEFAULT current_timestamp
310
335
  )`,
311
- `CREATE INDEX IF NOT EXISTS idx_alrt_sev ON audit_alerts(severity)`,
312
- `CREATE INDEX IF NOT EXISTS idx_alrt_cat ON audit_alerts(category)`,
313
- `CREATE INDEX IF NOT EXISTS idx_alrt_sta ON audit_alerts(status)`,
314
- `CREATE INDEX IF NOT EXISTS idx_alrt_ses ON audit_alerts(session_id)`,
315
- `CREATE INDEX IF NOT EXISTS idx_alrt_cre ON audit_alerts(created_at)`,
316
- `CREATE INDEX IF NOT EXISTS idx_alrt_rul ON audit_alerts(rule_id)`,
336
+ `CREATE INDEX IF NOT EXISTS idx_alrt_sev ON observation_alerts(severity)`,
337
+ `CREATE INDEX IF NOT EXISTS idx_alrt_cat ON observation_alerts(category)`,
338
+ `CREATE INDEX IF NOT EXISTS idx_alrt_sta ON observation_alerts(status)`,
339
+ `CREATE INDEX IF NOT EXISTS idx_alrt_ses ON observation_alerts(session_id)`,
340
+ `CREATE INDEX IF NOT EXISTS idx_alrt_cre ON observation_alerts(created_at)`,
341
+ `CREATE INDEX IF NOT EXISTS idx_alrt_rul ON observation_alerts(rule_id)`,
317
342
  `CREATE TABLE IF NOT EXISTS observability_metrics_samples (
318
343
  id BIGINT PRIMARY KEY,
319
344
  metric_name VARCHAR(256) NOT NULL,
@@ -330,6 +355,9 @@ const DUCKDB_SCHEMA_STATEMENTS = [
330
355
  `CREATE INDEX IF NOT EXISTS idx_metrics_fp_ts ON observability_metrics_samples(label_fingerprint, sample_timestamp_ms)`,
331
356
  `CREATE TABLE IF NOT EXISTS oc_traces (
332
357
  trace_id VARCHAR(128) PRIMARY KEY,
358
+ organization_id VARCHAR(64) DEFAULT 'local',
359
+ scope_id VARCHAR(64) DEFAULT 'local',
360
+ environment VARCHAR(32) DEFAULT 'prod',
333
361
  session_id VARCHAR(128) NOT NULL,
334
362
  user_id VARCHAR(128) DEFAULT '',
335
363
  channel_id VARCHAR(128) DEFAULT '',
@@ -347,6 +375,9 @@ const DUCKDB_SCHEMA_STATEMENTS = [
347
375
  `CREATE INDEX IF NOT EXISTS idx_oc_trace_channel ON oc_traces(channel_id)`,
348
376
  `CREATE TABLE IF NOT EXISTS oc_observations_core (
349
377
  observation_id VARCHAR(160) PRIMARY KEY,
378
+ organization_id VARCHAR(64) DEFAULT 'local',
379
+ scope_id VARCHAR(64) DEFAULT 'local',
380
+ environment VARCHAR(32) DEFAULT 'prod',
350
381
  trace_id VARCHAR(128) NOT NULL,
351
382
  parent_observation_id VARCHAR(160),
352
383
  root_observation_id VARCHAR(160) NOT NULL,
@@ -388,6 +419,9 @@ const DUCKDB_SCHEMA_STATEMENTS = [
388
419
  )`,
389
420
  `CREATE TABLE IF NOT EXISTS oc_events_raw (
390
421
  event_id VARCHAR(180) PRIMARY KEY,
422
+ organization_id VARCHAR(64) DEFAULT 'local',
423
+ scope_id VARCHAR(64) DEFAULT 'local',
424
+ environment VARCHAR(32) DEFAULT 'prod',
391
425
  trace_id VARCHAR(128) NOT NULL,
392
426
  observation_id VARCHAR(160) NOT NULL,
393
427
  event_type VARCHAR(64) NOT NULL,
@@ -448,6 +482,8 @@ const DUCKDB_SCHEMA_STATEMENTS = [
448
482
  class DuckDBLocalWriter {
449
483
  pool = null;
450
484
  config;
485
+ dataRetentionDays;
486
+ startupMaintenanceDone = false;
451
487
  initialized = false;
452
488
  /** Promise for an in-progress initialization (allows concurrent callers to await) */
453
489
  _initPromise = null;
@@ -461,8 +497,11 @@ class DuckDBLocalWriter {
461
497
  * This gives ~10,000 unique IDs per millisecond.
462
498
  */
463
499
  _idCounter = 0;
464
- constructor(config) {
500
+ constructor(config, options) {
465
501
  this.config = config;
502
+ this.dataRetentionDays = Number.isFinite(Number(options?.dataRetentionDays))
503
+ ? Math.max(0, Math.floor(Number(options?.dataRetentionDays)))
504
+ : 30;
466
505
  }
467
506
  /** Generate a unique BIGINT ID without DuckDB SEQUENCE (precision-safe) */
468
507
  _nextId() {
@@ -583,6 +622,10 @@ class DuckDBLocalWriter {
583
622
  }
584
623
  // Ensure parent directory exists
585
624
  const dbPath = this.config.path;
625
+ const openOptions = buildDuckDBOpenOptions(this.config);
626
+ if (openOptions.temp_directory && !fs.existsSync(openOptions.temp_directory)) {
627
+ fs.mkdirSync(openOptions.temp_directory, { recursive: true });
628
+ }
586
629
  if (dbPath !== ':memory:') {
587
630
  const dir = path.dirname(dbPath);
588
631
  if (!fs.existsSync(dir)) {
@@ -594,7 +637,7 @@ class DuckDBLocalWriter {
594
637
  if (!fs.existsSync(dbPath)) {
595
638
  // Create empty database file first (this creates the main .duckdb file)
596
639
  // Then we'll open it normally for actual operations
597
- const tempInstance = await duckdb.DuckDBInstance.create(dbPath);
640
+ const tempInstance = await duckdb.DuckDBInstance.create(dbPath, openOptions);
598
641
  const tempConn = await tempInstance.connect();
599
642
  // Immediately checkpoint to ensure main file is created (not just WAL)
600
643
  // Use run() for DDL/commands (query() is for SELECT statements)
@@ -627,48 +670,60 @@ class DuckDBLocalWriter {
627
670
  }
628
671
  }
629
672
  // Open database (main file now guaranteed to exist)
630
- const instance = await duckdb.DuckDBInstance.create(dbPath);
673
+ const instance = await duckdb.DuckDBInstance.create(dbPath, openOptions);
631
674
  const conn = await instance.connect();
632
675
  this.pool = new DuckDBPool(instance, conn);
633
676
  // Create table schema (execute one statement at a time)
634
677
  for (const stmt of DUCKDB_SCHEMA_STATEMENTS) {
635
678
  await this.pool.run(stmt);
636
679
  }
637
- // Migration: add channel_id column if it doesn't exist (for existing databases)
638
- // Silently check if column exists before adding to avoid unnecessary logs
639
- const hasChannelIdInActions = await this._hasColumn('audit_actions', 'channel_id');
640
- if (!hasChannelIdInActions) {
641
- try {
642
- await this.pool.run(`ALTER TABLE audit_actions ADD COLUMN channel_id VARCHAR(128) DEFAULT ''`);
643
- // Only log when actually adding the column (silent if already exists)
644
- }
645
- catch (err) {
646
- const errMsg = err.message || String(err);
647
- if (!errMsg.includes('already exists') && !errMsg.includes('duplicate column')) {
648
- console.warn('[openclaw-observability] Failed to add channel_id to audit_actions:', errMsg);
649
- }
650
- }
651
- }
652
- const hasChannelIdInSessions = await this._hasColumn('audit_sessions', 'channel_id');
653
- if (!hasChannelIdInSessions) {
680
+ // Migration: channel_id for legacy DBs.
681
+ await this._ensureChannelIdColumn('observation_actions');
682
+ await this._ensureChannelIdColumn('observation_sessions');
683
+ // Migration: cloud-tenant columns for legacy local DuckDB databases.
684
+ // New query layer filters by scope_id; old databases created before this
685
+ // column existed would fail with Binder Error.
686
+ await this._ensureTenantColumn('observation_actions', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
687
+ await this._ensureTenantColumn('observation_actions', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
688
+ await this._ensureTenantColumn('observation_actions', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
689
+ await this._ensureActionRunLineageColumns();
690
+ await this._ensureTenantColumn('observation_sessions', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
691
+ await this._ensureTenantColumn('observation_sessions', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
692
+ await this._ensureTenantColumn('observation_sessions', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
693
+ await this._ensureColumn('observation_sessions', 'parent_session_id', `VARCHAR(128) DEFAULT ''`);
694
+ if (!(await this._hasIndex('observation_sessions', 'idx_ses_parent'))) {
654
695
  try {
655
- await this.pool.run(`ALTER TABLE audit_sessions ADD COLUMN channel_id VARCHAR(128) DEFAULT ''`);
656
- // Only log when actually adding the column (silent if already exists)
696
+ await this.pool.run(`CREATE INDEX idx_ses_parent ON observation_sessions(parent_session_id)`);
657
697
  }
658
698
  catch (err) {
659
699
  const errMsg = err.message || String(err);
660
- if (!errMsg.includes('already exists') && !errMsg.includes('duplicate column')) {
661
- console.warn('[openclaw-observability] Failed to add channel_id to audit_sessions:', errMsg);
700
+ if (!errMsg.includes('already exists')) {
701
+ console.warn('[openclaw-observability] Failed to create idx_ses_parent:', errMsg);
662
702
  }
663
703
  }
664
704
  }
705
+ await this._ensureTenantColumn('observation_alerts', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
706
+ await this._ensureTenantColumn('observation_alerts', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
707
+ await this._ensureTenantColumn('observation_alerts', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
708
+ await this._ensureTenantColumn('observability_metrics_samples', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
709
+ await this._ensureTenantColumn('observability_metrics_samples', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
710
+ await this._ensureTenantColumn('observability_metrics_samples', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
711
+ await this._ensureTenantColumn('oc_traces', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
712
+ await this._ensureTenantColumn('oc_traces', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
713
+ await this._ensureTenantColumn('oc_traces', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
714
+ await this._ensureTenantColumn('oc_observations_core', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
715
+ await this._ensureTenantColumn('oc_observations_core', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
716
+ await this._ensureTenantColumn('oc_observations_core', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
717
+ await this._ensureTenantColumn('oc_events_raw', 'organization_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
718
+ await this._ensureTenantColumn('oc_events_raw', 'scope_id', `VARCHAR(64) DEFAULT 'local'`, 'local');
719
+ await this._ensureTenantColumn('oc_events_raw', 'environment', `VARCHAR(32) DEFAULT 'prod'`, 'prod');
665
720
  // Create index if column exists and index doesn't exist (silent check)
666
- const hasChannelIdColumn = await this._hasColumn('audit_actions', 'channel_id');
721
+ const hasChannelIdColumn = await this._hasColumn('observation_actions', 'channel_id');
667
722
  if (hasChannelIdColumn) {
668
- const hasIndex = await this._hasIndex('audit_actions', 'idx_act_channel');
723
+ const hasIndex = await this._hasIndex('observation_actions', 'idx_act_channel');
669
724
  if (!hasIndex) {
670
725
  try {
671
- await this.pool.run(`CREATE INDEX idx_act_channel ON audit_actions(channel_id)`);
726
+ await this.pool.run(`CREATE INDEX idx_act_channel ON observation_actions(channel_id)`);
672
727
  }
673
728
  catch (err) {
674
729
  const errMsg = err.message || String(err);
@@ -678,12 +733,12 @@ class DuckDBLocalWriter {
678
733
  }
679
734
  }
680
735
  }
681
- const hasChannelIdInSessionsCol = await this._hasColumn('audit_sessions', 'channel_id');
736
+ const hasChannelIdInSessionsCol = await this._hasColumn('observation_sessions', 'channel_id');
682
737
  if (hasChannelIdInSessionsCol) {
683
- const hasIndex = await this._hasIndex('audit_sessions', 'idx_ses_channel');
738
+ const hasIndex = await this._hasIndex('observation_sessions', 'idx_ses_channel');
684
739
  if (!hasIndex) {
685
740
  try {
686
- await this.pool.run(`CREATE INDEX idx_ses_channel ON audit_sessions(channel_id)`);
741
+ await this.pool.run(`CREATE INDEX idx_ses_channel ON observation_sessions(channel_id)`);
687
742
  }
688
743
  catch (err) {
689
744
  const errMsg = err.message || String(err);
@@ -712,6 +767,7 @@ class DuckDBLocalWriter {
712
767
  // Still continue, but log the risk
713
768
  }
714
769
  await this._backfillStructuredFromLegacy();
770
+ await this.runStartupMaintenance();
715
771
  this.initialized = true;
716
772
  console.log(`[openclaw-observability] DuckDB writer initialized: ${dbPath}`);
717
773
  }
@@ -742,12 +798,13 @@ class DuckDBLocalWriter {
742
798
  if (!this.pool)
743
799
  return;
744
800
  // Ensure channel_id column exists before writing (migration safety check)
745
- await this._ensureChannelIdColumn('audit_actions');
801
+ await this._ensureChannelIdColumn('observation_actions');
802
+ await this._ensureActionRunLineageColumns();
746
803
  const sql = `
747
- INSERT INTO audit_actions
804
+ INSERT INTO observation_actions
748
805
  (id, session_id, action_type, action_name, model_name, input_params, output_result,
749
- prompt_tokens, completion_tokens, duration_ms, user_id, channel_id, created_at)
750
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
806
+ prompt_tokens, completion_tokens, duration_ms, user_id, run_id, parent_run_id, channel_id, created_at)
807
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
751
808
  `;
752
809
  for (const a of actions) {
753
810
  await this.pool.run(sql, [
@@ -756,7 +813,7 @@ class DuckDBLocalWriter {
756
813
  a.inputParams ? JSON.stringify(a.inputParams) : null,
757
814
  a.outputResult ? JSON.stringify(a.outputResult) : null,
758
815
  a.promptTokens, a.completionTokens, a.durationMs,
759
- a.userId, a.channelId || '',
816
+ a.userId, a.runId || '', a.parentRunId || '', a.channelId || '',
760
817
  this._formatDate(a.createdAt),
761
818
  ]);
762
819
  }
@@ -769,13 +826,13 @@ class DuckDBLocalWriter {
769
826
  for (const s of sessions)
770
827
  deduped.set(s.sessionId, s);
771
828
  for (const session of deduped.values()) {
772
- // Aggregate stats from audit_actions in real-time
829
+ // Aggregate stats from observation_actions in real-time
773
830
  const [statsRows] = await this.pool.query(`SELECT
774
831
  MIN(created_at) AS start_time,
775
832
  MAX(created_at) AS end_time,
776
833
  COUNT(*) AS total_actions,
777
834
  COALESCE(SUM(COALESCE(prompt_tokens, 0) + COALESCE(completion_tokens, 0)), 0) AS total_tokens
778
- FROM audit_actions WHERE session_id = ?`, [session.sessionId]);
835
+ FROM observation_actions WHERE session_id = ?`, [session.sessionId]);
779
836
  const stats = statsRows[0];
780
837
  const startTime = stats?.start_time || this._formatDate(session.startTime);
781
838
  const endTime = stats?.end_time || this._formatDate(session.endTime);
@@ -784,19 +841,20 @@ class DuckDBLocalWriter {
784
841
  // Use INSERT OR REPLACE to handle concurrent inserts atomically
785
842
  // This prevents PRIMARY KEY constraint violations when multiple processes
786
843
  // or concurrent transactions try to insert the same session_id
787
- await this.pool.run(`INSERT INTO audit_sessions
788
- (session_id, user_id, model_name, channel_id, start_time, end_time, total_actions, total_tokens)
789
- VALUES (?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), ?, ?)
844
+ await this.pool.run(`INSERT INTO observation_sessions
845
+ (session_id, user_id, parent_session_id, model_name, channel_id, start_time, end_time, total_actions, total_tokens)
846
+ VALUES (?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), ?, ?)
790
847
  ON CONFLICT (session_id) DO UPDATE SET
791
- user_id = COALESCE(EXCLUDED.user_id, audit_sessions.user_id),
792
- model_name = CASE WHEN EXCLUDED.model_name != '' THEN EXCLUDED.model_name ELSE audit_sessions.model_name END,
793
- channel_id = CASE WHEN EXCLUDED.channel_id != '' THEN EXCLUDED.channel_id ELSE audit_sessions.channel_id END,
794
- start_time = CASE WHEN audit_sessions.start_time < EXCLUDED.start_time THEN audit_sessions.start_time ELSE EXCLUDED.start_time END,
848
+ user_id = COALESCE(EXCLUDED.user_id, observation_sessions.user_id),
849
+ parent_session_id = CASE WHEN EXCLUDED.parent_session_id != '' THEN EXCLUDED.parent_session_id ELSE observation_sessions.parent_session_id END,
850
+ model_name = CASE WHEN EXCLUDED.model_name != '' THEN EXCLUDED.model_name ELSE observation_sessions.model_name END,
851
+ channel_id = CASE WHEN EXCLUDED.channel_id != '' THEN EXCLUDED.channel_id ELSE observation_sessions.channel_id END,
852
+ start_time = CASE WHEN observation_sessions.start_time < EXCLUDED.start_time THEN observation_sessions.start_time ELSE EXCLUDED.start_time END,
795
853
  end_time = EXCLUDED.end_time,
796
854
  total_actions = EXCLUDED.total_actions,
797
855
  total_tokens = EXCLUDED.total_tokens`, [
798
856
  session.sessionId,
799
- session.userId, session.modelName, session.channelId || '',
857
+ session.userId, session.parentSessionId || '', session.modelName, session.channelId || '',
800
858
  this._formatDate(session.startTime), this._formatDate(session.endTime),
801
859
  totalActions, totalTokens,
802
860
  ]);
@@ -808,9 +866,12 @@ class DuckDBLocalWriter {
808
866
  const batch = (0, structured_model_1.buildStructuredWriteBatch)(actions);
809
867
  const traceSql = `
810
868
  INSERT INTO oc_traces
811
- (trace_id, session_id, user_id, channel_id, trace_name, status, tags_json, metadata_json, start_time, end_time, created_at, updated_at)
812
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))
869
+ (trace_id, organization_id, scope_id, environment, session_id, user_id, channel_id, trace_name, status, tags_json, metadata_json, start_time, end_time, created_at, updated_at)
870
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))
813
871
  ON CONFLICT (trace_id) DO UPDATE SET
872
+ organization_id = CASE WHEN EXCLUDED.organization_id != '' THEN EXCLUDED.organization_id ELSE oc_traces.organization_id END,
873
+ scope_id = CASE WHEN EXCLUDED.scope_id != '' THEN EXCLUDED.scope_id ELSE oc_traces.scope_id END,
874
+ environment = CASE WHEN EXCLUDED.environment != '' THEN EXCLUDED.environment ELSE oc_traces.environment END,
814
875
  user_id = CASE WHEN EXCLUDED.user_id != '' THEN EXCLUDED.user_id ELSE oc_traces.user_id END,
815
876
  channel_id = CASE WHEN EXCLUDED.channel_id != '' THEN EXCLUDED.channel_id ELSE oc_traces.channel_id END,
816
877
  trace_name = CASE WHEN EXCLUDED.trace_name != '' THEN EXCLUDED.trace_name ELSE oc_traces.trace_name END,
@@ -828,9 +889,12 @@ class DuckDBLocalWriter {
828
889
  `;
829
890
  const obsSql = `
830
891
  INSERT INTO oc_observations_core
831
- (observation_id, trace_id, parent_observation_id, root_observation_id, observation_type, observation_name, level, status, start_time, end_time, duration_ms, run_id, tool_call_id, model_name, provider, prompt_tokens, completion_tokens, total_tokens, cost_usd, user_id, session_id, channel_id, created_at, updated_at)
832
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))
892
+ (observation_id, organization_id, scope_id, environment, trace_id, parent_observation_id, root_observation_id, observation_type, observation_name, level, status, start_time, end_time, duration_ms, run_id, tool_call_id, model_name, provider, prompt_tokens, completion_tokens, total_tokens, cost_usd, user_id, session_id, channel_id, created_at, updated_at)
893
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), CAST(? AS TIMESTAMP))
833
894
  ON CONFLICT (observation_id) DO UPDATE SET
895
+ organization_id = CASE WHEN EXCLUDED.organization_id != '' THEN EXCLUDED.organization_id ELSE oc_observations_core.organization_id END,
896
+ scope_id = CASE WHEN EXCLUDED.scope_id != '' THEN EXCLUDED.scope_id ELSE oc_observations_core.scope_id END,
897
+ environment = CASE WHEN EXCLUDED.environment != '' THEN EXCLUDED.environment ELSE oc_observations_core.environment END,
834
898
  parent_observation_id = COALESCE(oc_observations_core.parent_observation_id, EXCLUDED.parent_observation_id),
835
899
  root_observation_id = EXCLUDED.root_observation_id,
836
900
  level = EXCLUDED.level,
@@ -871,13 +935,16 @@ class DuckDBLocalWriter {
871
935
  `;
872
936
  const eventSql = `
873
937
  INSERT INTO oc_events_raw
874
- (event_id, trace_id, observation_id, event_type, event_name, event_time, run_id, payload_json, created_at)
875
- VALUES (?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), ?, ?, CAST(? AS TIMESTAMP))
938
+ (event_id, organization_id, scope_id, environment, trace_id, observation_id, event_type, event_name, event_time, run_id, payload_json, created_at)
939
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS TIMESTAMP), ?, ?, CAST(? AS TIMESTAMP))
876
940
  ON CONFLICT (event_id) DO NOTHING
877
941
  `;
878
942
  for (const t of batch.traces) {
879
943
  await this.pool.run(traceSql, [
880
944
  t.traceId,
945
+ t.organizationId || 'local',
946
+ t.scopeId || 'local',
947
+ t.environment || 'prod',
881
948
  t.sessionId,
882
949
  t.userId,
883
950
  t.channelId,
@@ -894,6 +961,9 @@ class DuckDBLocalWriter {
894
961
  for (const o of batch.observations) {
895
962
  await this.pool.run(obsSql, [
896
963
  o.observationId,
964
+ o.organizationId || 'local',
965
+ o.scopeId || 'local',
966
+ o.environment || 'prod',
897
967
  o.traceId,
898
968
  o.parentObservationId,
899
969
  o.rootObservationId,
@@ -934,6 +1004,9 @@ class DuckDBLocalWriter {
934
1004
  for (const e of batch.events) {
935
1005
  await this.pool.run(eventSql, [
936
1006
  e.eventId,
1007
+ e.organizationId || 'local',
1008
+ e.scopeId || 'local',
1009
+ e.environment || 'prod',
937
1010
  e.traceId,
938
1011
  e.observationId,
939
1012
  e.eventType,
@@ -951,7 +1024,7 @@ class DuckDBLocalWriter {
951
1024
  try {
952
1025
  const [legacyRows] = await this.pool.query(`SELECT session_id, action_type, action_name, model_name, input_params, output_result,
953
1026
  prompt_tokens, completion_tokens, duration_ms, user_id, channel_id, created_at
954
- FROM audit_actions a
1027
+ FROM observation_actions a
955
1028
  WHERE NOT EXISTS (
956
1029
  SELECT 1
957
1030
  FROM oc_observations_core c
@@ -992,7 +1065,7 @@ class DuckDBLocalWriter {
992
1065
  };
993
1066
  });
994
1067
  await this.writeStructured(actions);
995
- console.log(`[openclaw-observability] Backfilled structured observations from legacy audit_actions: ${actions.length}`);
1068
+ console.log(`[openclaw-observability] Backfilled structured observations from legacy observation_actions: ${actions.length}`);
996
1069
  }
997
1070
  catch (error) {
998
1071
  console.warn('[openclaw-observability] Structured backfill skipped:', error.message);
@@ -1002,7 +1075,7 @@ class DuckDBLocalWriter {
1002
1075
  if (!this.pool || alerts.length === 0)
1003
1076
  return;
1004
1077
  const sql = `
1005
- INSERT INTO audit_alerts
1078
+ INSERT INTO observation_alerts
1006
1079
  (id, alert_id, session_id, action_type, action_name, rule_id, rule_name,
1007
1080
  category, severity, finding, context, status, user_id, model_name, created_at)
1008
1081
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
@@ -1140,6 +1213,7 @@ class DuckDBLocalWriter {
1140
1213
  await this.pool.close();
1141
1214
  this.pool = null;
1142
1215
  this.initialized = false;
1216
+ this.startupMaintenanceDone = false;
1143
1217
  console.log('[openclaw-observability] DuckDB writer closed (checkpoint done)');
1144
1218
  }
1145
1219
  }
@@ -1202,23 +1276,98 @@ class DuckDBLocalWriter {
1202
1276
  async _ensureChannelIdColumn(tableName) {
1203
1277
  if (!this.pool)
1204
1278
  return;
1205
- const exists = await this._hasColumn(tableName, 'channel_id');
1206
- if (exists)
1207
- return; // Column exists, no action needed
1208
1279
  try {
1209
- await this.pool.run(`ALTER TABLE ${tableName} ADD COLUMN channel_id VARCHAR(128) DEFAULT ''`);
1210
- // Only log when actually adding the column (silent if already exists)
1280
+ // Do not rely on _hasColumn() for old DB variants; make DDL idempotent.
1281
+ await this.pool.run(`ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS channel_id VARCHAR(128) DEFAULT ''`);
1211
1282
  }
1212
1283
  catch (err) {
1213
1284
  const errMsg = err.message || String(err);
1214
- // If column already exists, that's fine (silent success)
1215
- if (errMsg.includes('already exists') || errMsg.includes('duplicate column')) {
1216
- return; // Column exists, no action needed
1217
- }
1218
1285
  // Other errors should be logged
1219
1286
  console.warn(`[openclaw-observability] Failed to ensure channel_id in ${tableName}:`, errMsg);
1220
1287
  }
1221
1288
  }
1289
+ async _ensureActionRunLineageColumns() {
1290
+ if (!this.pool)
1291
+ return;
1292
+ try {
1293
+ await this.pool.run(`ALTER TABLE observation_actions ADD COLUMN IF NOT EXISTS run_id VARCHAR(128) DEFAULT ''`);
1294
+ await this.pool.run(`ALTER TABLE observation_actions ADD COLUMN IF NOT EXISTS parent_run_id VARCHAR(128) DEFAULT ''`);
1295
+ await this.pool.run(`UPDATE observation_actions
1296
+ SET run_id = ''
1297
+ WHERE run_id IS NULL`);
1298
+ await this.pool.run(`UPDATE observation_actions
1299
+ SET parent_run_id = ''
1300
+ WHERE parent_run_id IS NULL`);
1301
+ }
1302
+ catch (err) {
1303
+ const errMsg = err.message || String(err);
1304
+ console.warn('[openclaw-observability] Failed to ensure run lineage columns:', errMsg);
1305
+ }
1306
+ if (!(await this._hasIndex('observation_actions', 'idx_act_run_id'))) {
1307
+ try {
1308
+ await this.pool.run(`CREATE INDEX idx_act_run_id ON observation_actions(run_id)`);
1309
+ }
1310
+ catch (err) {
1311
+ const errMsg = err.message || String(err);
1312
+ if (!errMsg.includes('already exists')) {
1313
+ console.warn('[openclaw-observability] Failed to create idx_act_run_id:', errMsg);
1314
+ }
1315
+ }
1316
+ }
1317
+ if (!(await this._hasIndex('observation_actions', 'idx_act_parent_run_id'))) {
1318
+ try {
1319
+ await this.pool.run(`CREATE INDEX idx_act_parent_run_id ON observation_actions(parent_run_id)`);
1320
+ }
1321
+ catch (err) {
1322
+ const errMsg = err.message || String(err);
1323
+ if (!errMsg.includes('already exists')) {
1324
+ console.warn('[openclaw-observability] Failed to create idx_act_parent_run_id:', errMsg);
1325
+ }
1326
+ }
1327
+ }
1328
+ }
1329
+ async _ensureColumn(tableName, columnName, columnDef) {
1330
+ if (!this.pool)
1331
+ return;
1332
+ try {
1333
+ // Do not rely on _hasColumn() for old DB variants; make DDL idempotent.
1334
+ await this.pool.run(`ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${columnName} ${columnDef}`);
1335
+ }
1336
+ catch (err) {
1337
+ const errMsg = err.message || String(err);
1338
+ if (!errMsg.includes('already exists') && !errMsg.includes('duplicate column')) {
1339
+ console.warn(`[openclaw-observability] Failed to add ${columnName} to ${tableName}:`, errMsg);
1340
+ }
1341
+ }
1342
+ }
1343
+ /**
1344
+ * Ensure a tenant-related column exists and legacy rows are backfilled.
1345
+ * This keeps old local DuckDB files compatible with new project-scoped queries.
1346
+ */
1347
+ async _ensureTenantColumn(tableName, columnName, columnDef, defaultValue) {
1348
+ if (!this.pool)
1349
+ return;
1350
+ try {
1351
+ // Do not rely on _hasColumn() for old DB variants; make DDL idempotent.
1352
+ await this.pool.run(`ALTER TABLE ${tableName} ADD COLUMN IF NOT EXISTS ${columnName} ${columnDef}`);
1353
+ }
1354
+ catch (err) {
1355
+ const errMsg = err.message || String(err);
1356
+ if (!errMsg.includes('already exists') && !errMsg.includes('duplicate column')) {
1357
+ console.warn(`[openclaw-observability] Failed to add ${columnName} to ${tableName}:`, errMsg);
1358
+ return;
1359
+ }
1360
+ }
1361
+ try {
1362
+ await this.pool.run(`UPDATE ${tableName}
1363
+ SET ${columnName} = ?
1364
+ WHERE ${columnName} IS NULL OR CAST(${columnName} AS VARCHAR) = ''`, [defaultValue]);
1365
+ }
1366
+ catch (err) {
1367
+ const errMsg = err.message || String(err);
1368
+ console.warn(`[openclaw-observability] Failed to backfill ${columnName} in ${tableName}:`, errMsg);
1369
+ }
1370
+ }
1222
1371
  _formatDate(d) {
1223
1372
  if (!d)
1224
1373
  return this._formatTimestamp(new Date());
@@ -1233,6 +1382,37 @@ class DuckDBLocalWriter {
1233
1382
  return this._formatTimestamp(d);
1234
1383
  return String(d);
1235
1384
  }
1385
+ async runStartupMaintenance() {
1386
+ if (this.startupMaintenanceDone)
1387
+ return;
1388
+ await this._cleanupExpiredDataOnStartup();
1389
+ this.startupMaintenanceDone = true;
1390
+ }
1391
+ async _cleanupExpiredDataOnStartup() {
1392
+ if (!this.pool || this.dataRetentionDays <= 0)
1393
+ return;
1394
+ const cutoffDate = new Date(Date.now() - this.dataRetentionDays * 24 * 60 * 60 * 1000);
1395
+ const cutoffText = this._formatDate(cutoffDate);
1396
+ const cutoffMs = cutoffDate.getTime();
1397
+ await this.pool.run('DELETE FROM observation_alerts WHERE created_at < ?', [cutoffText]);
1398
+ await this.pool.run('DELETE FROM observation_actions WHERE created_at < ?', [cutoffText]);
1399
+ await this.pool.run('DELETE FROM observation_sessions WHERE COALESCE(end_time, start_time) < ?', [cutoffText]);
1400
+ await this.pool.run('DELETE FROM oc_events_raw WHERE event_time < ?', [cutoffText]);
1401
+ await this.pool.run(`DELETE FROM oc_observations_payload
1402
+ WHERE observation_id IN (
1403
+ SELECT observation_id
1404
+ FROM oc_observations_core
1405
+ WHERE COALESCE(end_time, start_time, created_at) < ?
1406
+ )`, [cutoffText]);
1407
+ await this.pool.run('DELETE FROM oc_observations_core WHERE COALESCE(end_time, start_time, created_at) < ?', [cutoffText]);
1408
+ await this.pool.run('DELETE FROM oc_traces WHERE COALESCE(end_time, start_time, created_at) < ?', [cutoffText]);
1409
+ await this.pool.run('DELETE FROM observability_metrics_samples WHERE sample_timestamp_ms < ?', [cutoffMs]);
1410
+ await this.pool.run('DELETE FROM oc_metric_points_number WHERE ts_ms < ?', [cutoffMs]);
1411
+ await this.pool.run('DELETE FROM oc_metric_points_histogram WHERE ts_ms < ?', [cutoffMs]);
1412
+ await this.pool.run('DELETE FROM oc_metric_series WHERE last_seen_ms < ?', [cutoffMs]);
1413
+ await this.pool.run('DELETE FROM oc_observations_payload WHERE observation_id NOT IN (SELECT observation_id FROM oc_observations_core)');
1414
+ console.log(`[openclaw-observability] Startup data retention cleanup completed for DuckDB (days=${this.dataRetentionDays}, cutoff=${cutoffDate.toISOString()})`);
1415
+ }
1236
1416
  _formatTimestamp(d) {
1237
1417
  return d.toISOString().replace('T', ' ').replace('Z', '');
1238
1418
  }