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
package/dist/web/api.js CHANGED
@@ -1,9 +1,45 @@
1
1
  "use strict";
2
2
  /**
3
- * Audit Web API — query database and return structured data
3
+ * Observation Web API — query database and return structured data
4
4
  * Compatible with both MySQL and DuckDB backends
5
5
  */
6
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
7
+ if (k2 === undefined) k2 = k;
8
+ var desc = Object.getOwnPropertyDescriptor(m, k);
9
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
10
+ desc = { enumerable: true, get: function() { return m[k]; } };
11
+ }
12
+ Object.defineProperty(o, k2, desc);
13
+ }) : (function(o, m, k, k2) {
14
+ if (k2 === undefined) k2 = k;
15
+ o[k2] = m[k];
16
+ }));
17
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
18
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
19
+ }) : function(o, v) {
20
+ o["default"] = v;
21
+ });
22
+ var __importStar = (this && this.__importStar) || (function () {
23
+ var ownKeys = function(o) {
24
+ ownKeys = Object.getOwnPropertyNames || function (o) {
25
+ var ar = [];
26
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
27
+ return ar;
28
+ };
29
+ return ownKeys(o);
30
+ };
31
+ return function (mod) {
32
+ if (mod && mod.__esModule) return mod;
33
+ var result = {};
34
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
35
+ __setModuleDefault(result, mod);
36
+ return result;
37
+ };
38
+ })();
6
39
  Object.defineProperty(exports, "__esModule", { value: true });
40
+ exports.getTenants = getTenants;
41
+ exports.getSkillsOverview = getSkillsOverview;
42
+ exports.getSkillDetail = getSkillDetail;
7
43
  exports.getStats = getStats;
8
44
  exports.getSessions = getSessions;
9
45
  exports.getSessionActions = getSessionActions;
@@ -16,6 +52,12 @@ exports.getAnalytics = getAnalytics;
16
52
  exports.getMetricsOverview = getMetricsOverview;
17
53
  exports.getMetricSeries = getMetricSeries;
18
54
  exports.getTraceObservationTree = getTraceObservationTree;
55
+ const fs = __importStar(require("node:fs"));
56
+ const os = __importStar(require("node:os"));
57
+ const path = __importStar(require("node:path"));
58
+ const REPLAY_SESSION_PREFIX = 'replay-';
59
+ const SKILL_CATALOG_CACHE_TTL_MS = 60_000;
60
+ const skillCatalogCache = new Map();
19
61
  /* ------------------------------------------------------------------ */
20
62
  /* API functions */
21
63
  /* ------------------------------------------------------------------ */
@@ -39,6 +81,564 @@ function parseTimeMs(value) {
39
81
  const ms = Date.parse(trimmed);
40
82
  return Number.isFinite(ms) ? ms : undefined;
41
83
  }
84
+ function normalizeScopeId(value) {
85
+ const v = (value || '').trim();
86
+ return v || 'local';
87
+ }
88
+ function safeParseJsonObject(value) {
89
+ if (typeof value !== 'string' || !value.trim())
90
+ return null;
91
+ try {
92
+ const parsed = JSON.parse(value);
93
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
94
+ return parsed;
95
+ }
96
+ }
97
+ catch {
98
+ // ignore invalid json
99
+ }
100
+ return null;
101
+ }
102
+ function decodeXmlEntities(input) {
103
+ return input
104
+ .replace(/&quot;/g, '"')
105
+ .replace(/&apos;/g, '\'')
106
+ .replace(/&lt;/g, '<')
107
+ .replace(/&gt;/g, '>')
108
+ .replace(/&amp;/g, '&');
109
+ }
110
+ function readSkillCatalogFromSystemPrompt(systemPrompt) {
111
+ if (!systemPrompt || typeof systemPrompt !== 'string')
112
+ return [];
113
+ const out = [];
114
+ const seen = new Set();
115
+ const xmlRe = /<skill>\s*<name>([\s\S]*?)<\/name>[\s\S]*?<description>([\s\S]*?)<\/description>[\s\S]*?<location>([\s\S]*?)<\/location>[\s\S]*?<\/skill>/gi;
116
+ let m;
117
+ while ((m = xmlRe.exec(systemPrompt)) !== null) {
118
+ const skill = decodeXmlEntities(String(m[1] || '')).trim();
119
+ if (!skill || seen.has(skill))
120
+ continue;
121
+ seen.add(skill);
122
+ const description = decodeXmlEntities(String(m[2] || '')).trim();
123
+ const location = decodeXmlEntities(String(m[3] || '')).trim();
124
+ out.push({
125
+ skill,
126
+ description: description || undefined,
127
+ location: location || undefined,
128
+ });
129
+ }
130
+ return out;
131
+ }
132
+ function extractSystemPrompt(inputObj) {
133
+ const sp = typeof inputObj.systemPrompt === 'string'
134
+ ? inputObj.systemPrompt
135
+ : (typeof inputObj.extraSystemPrompt === 'string' ? inputObj.extraSystemPrompt : '');
136
+ return String(sp || '');
137
+ }
138
+ function parseSkillCallInput(input) {
139
+ const obj = safeParseJsonObject(input);
140
+ if (!obj)
141
+ return null;
142
+ const skill = typeof obj.skill === 'string' ? obj.skill.trim() : '';
143
+ if (!skill)
144
+ return null;
145
+ const toolCallId = typeof obj.toolCallId === 'string' ? obj.toolCallId.trim() : '';
146
+ const skillPath = typeof obj.skillPath === 'string' ? obj.skillPath.trim() : '';
147
+ const skillSource = typeof obj.skillSource === 'string' ? obj.skillSource.trim() : '';
148
+ return {
149
+ skill,
150
+ toolCallId: toolCallId || undefined,
151
+ skillPath: skillPath || undefined,
152
+ skillSource: skillSource || undefined,
153
+ };
154
+ }
155
+ function resolveSkillLocation(rawLocation) {
156
+ const value = String(rawLocation || '').trim();
157
+ if (!value)
158
+ return '';
159
+ if (value === '~')
160
+ return os.homedir();
161
+ if (value.startsWith('~/'))
162
+ return path.join(os.homedir(), value.slice(2));
163
+ if (value.startsWith('$HOME/'))
164
+ return path.join(os.homedir(), value.slice(6));
165
+ return value;
166
+ }
167
+ function inferSkillSourceFromLocation(rawLocation) {
168
+ const resolved = resolveSkillLocation(rawLocation);
169
+ const normalized = String(resolved || '').replace(/\\/g, '/');
170
+ if (!normalized)
171
+ return '';
172
+ const home = os.homedir().replace(/\\/g, '/');
173
+ const stateDir = (String(process.env.OPENCLAW_STATE_DIR || '').trim() || path.join(home, '.openclaw'))
174
+ .replace(/\\/g, '/');
175
+ const bundledOverride = String(process.env.OPENCLAW_BUNDLED_SKILLS_DIR || '').trim().replace(/\\/g, '/');
176
+ const bundledSibling = path.join(path.dirname(process.execPath || ''), 'skills').replace(/\\/g, '/');
177
+ if (normalized.includes('/node_modules/openclaw/skills/'))
178
+ return 'openclaw-bundled';
179
+ if (bundledOverride && normalized.startsWith(`${bundledOverride}/`))
180
+ return 'openclaw-bundled';
181
+ if (bundledSibling && normalized.startsWith(`${bundledSibling}/`))
182
+ return 'openclaw-bundled';
183
+ if (normalized.startsWith(`${stateDir}/skills/`))
184
+ return 'openclaw-managed';
185
+ if (normalized.startsWith(`${stateDir}/workspace/skills/`))
186
+ return 'openclaw-workspace';
187
+ if (normalized.startsWith(`${home}/.agents/skills/`))
188
+ return 'agents-skills-personal';
189
+ if (normalized.includes('/.agents/skills/'))
190
+ return 'agents-skills-project';
191
+ if (normalized.includes('/workspace/skills/'))
192
+ return 'openclaw-workspace';
193
+ return 'openclaw-extra';
194
+ }
195
+ function getSkillSearchDirs() {
196
+ const dirs = [];
197
+ const home = os.homedir();
198
+ const stateDir = String(process.env.OPENCLAW_STATE_DIR || '').trim() || path.join(home, '.openclaw');
199
+ const envHomes = [
200
+ process.env.OPENCLAW_HOME,
201
+ process.env.OPENCLAW_STATE_DIR,
202
+ ].map((v) => String(v || '').trim()).filter(Boolean);
203
+ const bundledOverride = String(process.env.OPENCLAW_BUNDLED_SKILLS_DIR || '').trim();
204
+ const bundledSibling = path.join(path.dirname(process.execPath || ''), 'skills');
205
+ const nodeInstallRoot = path.dirname(path.dirname(process.execPath || ''));
206
+ const nodeGlobalOpenClawSkills = path.join(nodeInstallRoot, 'lib', 'node_modules', 'openclaw', 'skills');
207
+ dirs.push(path.join(stateDir, 'skills'));
208
+ envHomes.forEach((h) => dirs.push(path.join(h, 'workspace', 'skills')));
209
+ dirs.push(path.join(stateDir, 'workspace', 'skills'));
210
+ dirs.push(path.join(home, '.agents', 'skills'));
211
+ dirs.push(path.join(process.cwd(), '.agents', 'skills'));
212
+ dirs.push(path.join(process.cwd(), 'skills'));
213
+ dirs.push(path.join(home, '.openclaw', 'workspace', 'skills'));
214
+ dirs.push(path.join(process.cwd(), 'workspace', 'skills'));
215
+ dirs.push(nodeGlobalOpenClawSkills);
216
+ if (bundledOverride)
217
+ dirs.push(bundledOverride);
218
+ if (bundledSibling)
219
+ dirs.push(bundledSibling);
220
+ // de-dup while preserving order
221
+ return Array.from(new Set(dirs.map((d) => path.resolve(d))));
222
+ }
223
+ function inferSkillDescriptionFromMarkdown(content) {
224
+ const text = String(content || '');
225
+ const lines = text.split(/\r?\n/).map((x) => x.trim());
226
+ for (const line of lines) {
227
+ if (!line)
228
+ continue;
229
+ if (line.startsWith('#'))
230
+ continue;
231
+ if (line.startsWith('```'))
232
+ continue;
233
+ return line.length > 220 ? `${line.slice(0, 217)}...` : line;
234
+ }
235
+ return '';
236
+ }
237
+ function discoverLocalSkills() {
238
+ const out = new Map();
239
+ const roots = getSkillSearchDirs();
240
+ for (const root of roots) {
241
+ let entries = [];
242
+ try {
243
+ if (!fs.existsSync(root))
244
+ continue;
245
+ entries = fs.readdirSync(root, { withFileTypes: true });
246
+ }
247
+ catch {
248
+ continue;
249
+ }
250
+ for (const ent of entries) {
251
+ if (!ent.isDirectory())
252
+ continue;
253
+ const skill = String(ent.name || '').trim();
254
+ if (!skill || out.has(skill))
255
+ continue;
256
+ const skillFile = path.join(root, skill, 'SKILL.md');
257
+ if (!fs.existsSync(skillFile))
258
+ continue;
259
+ let description = '';
260
+ try {
261
+ description = inferSkillDescriptionFromMarkdown(fs.readFileSync(skillFile, 'utf8'));
262
+ }
263
+ catch {
264
+ description = '';
265
+ }
266
+ out.set(skill, {
267
+ description: description || undefined,
268
+ location: skillFile,
269
+ });
270
+ }
271
+ }
272
+ return out;
273
+ }
274
+ function normalizeAgentScope(value) {
275
+ const raw = String(value || 'main').trim().toLowerCase();
276
+ if (raw === 'subagent' || raw === 'replay' || raw === 'system' || raw === 'all')
277
+ return raw;
278
+ return 'main';
279
+ }
280
+ function applyAgentScopeSessionFilter(where, values, scopeId, agentScope) {
281
+ const subagentPromptClause = `session_id IN (
282
+ SELECT DISTINCT session_id FROM observation_actions
283
+ WHERE scope_id = ?
284
+ AND action_type IN ('prompt_build', 'message')
285
+ AND (
286
+ input_params LIKE '%[Subagent Context]%'
287
+ OR input_params LIKE '%"source":"subagent"%'
288
+ )
289
+ )`;
290
+ const llmSessionClause = `session_id IN (
291
+ SELECT DISTINCT session_id FROM observation_actions
292
+ WHERE scope_id = ?
293
+ AND action_type = 'message'
294
+ AND action_name LIKE 'llm_call:%'
295
+ )`;
296
+ const replaySessionClause = `session_id IN (
297
+ SELECT DISTINCT session_id FROM observation_actions
298
+ WHERE scope_id = ?
299
+ AND action_type = 'replay'
300
+ )`;
301
+ const systemSessionClause = `(
302
+ LOWER(COALESCE(user_id,'')) = 'system'
303
+ OR LOWER(COALESCE(session_id,'')) = 'system'
304
+ OR LOWER(COALESCE(channel_id,'')) LIKE '%heartbeat%'
305
+ OR LOWER(COALESCE(user_id,'')) LIKE '%heartbeat%'
306
+ OR LOWER(COALESCE(session_id,'')) LIKE '%heartbeat%'
307
+ )`;
308
+ if (agentScope === 'replay') {
309
+ where += ' AND session_id LIKE ?';
310
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
311
+ where += ` AND ${replaySessionClause}`;
312
+ values.push(scopeId);
313
+ return { where, values };
314
+ }
315
+ where += ' AND session_id NOT LIKE ?';
316
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
317
+ if (agentScope === 'system') {
318
+ where += ` AND ${systemSessionClause}`;
319
+ return { where, values };
320
+ }
321
+ where += " AND LOWER(COALESCE(user_id,'')) NOT IN ('unknown','unkown')";
322
+ where += " AND LOWER(COALESCE(session_id,'')) NOT IN ('unknown','unkown','-')";
323
+ if (agentScope === 'all') {
324
+ where += ` AND (${systemSessionClause} OR ${llmSessionClause})`;
325
+ values.push(scopeId);
326
+ return { where, values };
327
+ }
328
+ where += ` AND ${llmSessionClause}`;
329
+ values.push(scopeId);
330
+ if (agentScope === 'subagent') {
331
+ where += ` AND (COALESCE(TRIM(parent_session_id), '') <> '' OR ${subagentPromptClause})`;
332
+ values.push(scopeId);
333
+ return { where, values };
334
+ }
335
+ where += ` AND NOT (${systemSessionClause})`;
336
+ where += ` AND (COALESCE(TRIM(parent_session_id), '') = '' AND NOT (${subagentPromptClause}))`;
337
+ values.push(scopeId);
338
+ return { where, values };
339
+ }
340
+ /** List available scope ids */
341
+ async function getTenants(pool) {
342
+ const [rows] = await pool.query(`SELECT
343
+ COALESCE(NULLIF(TRIM(scope_id), ''), 'local') AS scope_id
344
+ FROM observation_sessions
345
+ WHERE session_id NOT LIKE ?
346
+ GROUP BY 1
347
+ ORDER BY CASE WHEN LOWER(COALESCE(NULLIF(TRIM(scope_id), ''), 'local')) = 'local' THEN 0 ELSE 1 END, scope_id ASC`, [`${REPLAY_SESSION_PREFIX}%`]);
348
+ return { tenants: rows };
349
+ }
350
+ async function getSkillCatalog(pool, scopeId) {
351
+ const now = Date.now();
352
+ const hit = skillCatalogCache.get(scopeId);
353
+ if (hit && (now - hit.cachedAt) < SKILL_CATALOG_CACHE_TTL_MS) {
354
+ return new Map(hit.catalog);
355
+ }
356
+ const baseSql = `SELECT input_params
357
+ FROM observation_actions
358
+ WHERE scope_id = ?
359
+ AND action_type IN ('message', 'replay')
360
+ AND action_name LIKE 'llm_call:%'
361
+ AND input_params IS NOT NULL
362
+ ORDER BY created_at DESC
363
+ LIMIT ?`;
364
+ const [hotRows] = await pool.query(baseSql, [scopeId, 8]);
365
+ let rows = hotRows;
366
+ if (!rows.length) {
367
+ const [allRows] = await pool.query(baseSql, [scopeId, 80]);
368
+ rows = allRows;
369
+ }
370
+ const catalog = new Map();
371
+ const seenPromptHashes = new Set();
372
+ let staleRounds = 0;
373
+ for (const row of rows) {
374
+ const inputObj = safeParseJsonObject(row.input_params);
375
+ if (!inputObj)
376
+ continue;
377
+ const promptHash = typeof inputObj.systemPromptHash === 'string'
378
+ ? inputObj.systemPromptHash.trim()
379
+ : '';
380
+ if (promptHash && seenPromptHashes.has(promptHash))
381
+ continue;
382
+ if (promptHash)
383
+ seenPromptHashes.add(promptHash);
384
+ const systemPrompt = extractSystemPrompt(inputObj);
385
+ if (!systemPrompt)
386
+ continue;
387
+ const before = catalog.size;
388
+ const items = readSkillCatalogFromSystemPrompt(systemPrompt);
389
+ for (const item of items) {
390
+ if (!catalog.has(item.skill)) {
391
+ catalog.set(item.skill, {
392
+ description: item.description,
393
+ location: item.location,
394
+ });
395
+ }
396
+ }
397
+ if (catalog.size === before) {
398
+ staleRounds += 1;
399
+ if (catalog.size > 0 && staleRounds >= 8)
400
+ break;
401
+ }
402
+ else {
403
+ staleRounds = 0;
404
+ }
405
+ }
406
+ if (!catalog.size && rows.length < 20) {
407
+ const [fallbackRows] = await pool.query(baseSql, [scopeId, 80]);
408
+ for (const row of fallbackRows) {
409
+ const inputObj = safeParseJsonObject(row.input_params);
410
+ if (!inputObj)
411
+ continue;
412
+ const promptHash = typeof inputObj.systemPromptHash === 'string'
413
+ ? inputObj.systemPromptHash.trim()
414
+ : '';
415
+ if (promptHash && seenPromptHashes.has(promptHash))
416
+ continue;
417
+ if (promptHash)
418
+ seenPromptHashes.add(promptHash);
419
+ const systemPrompt = extractSystemPrompt(inputObj);
420
+ if (!systemPrompt)
421
+ continue;
422
+ const items = readSkillCatalogFromSystemPrompt(systemPrompt);
423
+ for (const item of items) {
424
+ if (!catalog.has(item.skill)) {
425
+ catalog.set(item.skill, {
426
+ description: item.description,
427
+ location: item.location,
428
+ });
429
+ }
430
+ }
431
+ if (catalog.size > 0)
432
+ break;
433
+ }
434
+ }
435
+ // Keep local file-system discovery scoped to local tenant only.
436
+ if (scopeId === 'local') {
437
+ const localSkills = discoverLocalSkills();
438
+ for (const [skill, meta] of localSkills.entries()) {
439
+ if (!catalog.has(skill)) {
440
+ catalog.set(skill, meta);
441
+ }
442
+ else {
443
+ const existing = catalog.get(skill) || {};
444
+ catalog.set(skill, {
445
+ description: existing.description || meta.description,
446
+ location: existing.location || meta.location,
447
+ });
448
+ }
449
+ }
450
+ }
451
+ skillCatalogCache.set(scopeId, { cachedAt: now, catalog: new Map(catalog) });
452
+ return catalog;
453
+ }
454
+ /** Skills overview aggregated from realtime skill_call events */
455
+ async function getSkillsOverview(pool, params) {
456
+ const scopeId = normalizeScopeId(params?.scopeId);
457
+ const limit = Math.max(1, Math.min(500, normalizePositiveInt(params?.limit, 200)));
458
+ const fromMs = parseTimeMs(params?.timeFrom);
459
+ const toMs = parseTimeMs(params?.timeTo);
460
+ let where = `scope_id = ? AND action_type = 'skill_call'`;
461
+ const values = [scopeId];
462
+ if (fromMs != null) {
463
+ where += ' AND created_at >= FROM_UNIXTIME(? / 1000)';
464
+ values.push(fromMs);
465
+ }
466
+ if (toMs != null) {
467
+ where += ' AND created_at <= FROM_UNIXTIME(? / 1000)';
468
+ values.push(toMs);
469
+ }
470
+ const [rows] = await pool.query(`SELECT session_id, run_id, action_name, input_params, created_at
471
+ FROM observation_actions
472
+ WHERE ${where}
473
+ ORDER BY created_at DESC
474
+ LIMIT 20000`, values);
475
+ const perSkill = new Map();
476
+ for (const row of rows) {
477
+ const parsed = parseSkillCallInput(row.input_params);
478
+ const fromName = String(row.action_name || '').startsWith('skill_call:')
479
+ ? String(row.action_name || '').slice('skill_call:'.length).trim()
480
+ : '';
481
+ const skillName = parsed?.skill || fromName;
482
+ if (!skillName)
483
+ continue;
484
+ const sessionId = String(row.session_id || '').trim();
485
+ const runId = String(row.run_id || '').trim();
486
+ const ts = Date.parse(String(row.created_at || '')) || 0;
487
+ let rec = perSkill.get(skillName);
488
+ if (!rec) {
489
+ rec = { callCount: 0, sessions: new Set(), runs: new Set(), lastSeen: 0, sourceCounts: new Map() };
490
+ perSkill.set(skillName, rec);
491
+ }
492
+ rec.callCount += 1;
493
+ if (sessionId)
494
+ rec.sessions.add(sessionId);
495
+ if (runId)
496
+ rec.runs.add(runId);
497
+ if (ts > rec.lastSeen)
498
+ rec.lastSeen = ts;
499
+ if (parsed?.skillSource) {
500
+ rec.sourceCounts.set(parsed.skillSource, Number(rec.sourceCounts.get(parsed.skillSource) || 0) + 1);
501
+ }
502
+ }
503
+ const catalog = await getSkillCatalog(pool, scopeId);
504
+ for (const skill of catalog.keys()) {
505
+ if (!perSkill.has(skill)) {
506
+ perSkill.set(skill, { callCount: 0, sessions: new Set(), runs: new Set(), lastSeen: 0, sourceCounts: new Map() });
507
+ }
508
+ }
509
+ const items = Array.from(perSkill.entries())
510
+ .map(([skill, rec]) => {
511
+ const cat = catalog.get(skill);
512
+ let source = '';
513
+ let sourceCount = -1;
514
+ rec.sourceCounts.forEach((cnt, name) => {
515
+ if (cnt > sourceCount) {
516
+ source = name;
517
+ sourceCount = cnt;
518
+ }
519
+ });
520
+ if (!source) {
521
+ source = inferSkillSourceFromLocation(cat?.location || '');
522
+ }
523
+ return {
524
+ skill,
525
+ description: cat?.description,
526
+ location: cat?.location,
527
+ source: source || undefined,
528
+ callCount: rec.callCount,
529
+ sessionCount: rec.sessions.size,
530
+ runCount: rec.runs.size,
531
+ lastSeen: rec.lastSeen > 0 ? new Date(rec.lastSeen).toISOString() : '',
532
+ };
533
+ })
534
+ .sort((a, b) => (b.callCount - a.callCount) || a.skill.localeCompare(b.skill))
535
+ .slice(0, limit);
536
+ return {
537
+ totalSkills: perSkill.size,
538
+ totalCalls: Array.from(perSkill.values()).reduce((sum, it) => sum + it.callCount, 0),
539
+ items,
540
+ };
541
+ }
542
+ async function getSkillDetail(pool, params) {
543
+ const scopeId = normalizeScopeId(params.scopeId);
544
+ const skill = String(params.skill || '').trim();
545
+ if (!skill) {
546
+ throw new Error('skill is required');
547
+ }
548
+ const limit = Math.max(1, Math.min(200, normalizePositiveInt(params.limit, 50)));
549
+ const fromMs = parseTimeMs(params.timeFrom);
550
+ const toMs = parseTimeMs(params.timeTo);
551
+ let where = `scope_id = ? AND action_type = 'skill_call' AND action_name = ?`;
552
+ const values = [scopeId, `skill_call:${skill}`];
553
+ if (fromMs != null) {
554
+ where += ' AND created_at >= FROM_UNIXTIME(? / 1000)';
555
+ values.push(fromMs);
556
+ }
557
+ if (toMs != null) {
558
+ where += ' AND created_at <= FROM_UNIXTIME(? / 1000)';
559
+ values.push(toMs);
560
+ }
561
+ const [rows] = await pool.query(`SELECT session_id, run_id, input_params, output_result, created_at
562
+ FROM observation_actions
563
+ WHERE ${where}
564
+ ORDER BY created_at DESC
565
+ LIMIT 20000`, values);
566
+ const sessions = new Set();
567
+ const runs = new Set();
568
+ let callCount = 0;
569
+ let lastSeenMs = 0;
570
+ const recentCalls = [];
571
+ const seenToolCallIds = new Set();
572
+ let latestPath = '';
573
+ let latestSource = '';
574
+ for (const row of rows) {
575
+ callCount += 1;
576
+ const sessionId = String(row.session_id || '').trim();
577
+ const runId = String(row.run_id || '').trim();
578
+ if (sessionId)
579
+ sessions.add(sessionId);
580
+ if (runId)
581
+ runs.add(runId);
582
+ const ts = Date.parse(String(row.created_at || '')) || 0;
583
+ if (ts > lastSeenMs)
584
+ lastSeenMs = ts;
585
+ const inp = parseSkillCallInput(row.input_params);
586
+ const out = safeParseJsonObject(row.output_result);
587
+ const ok = typeof out?.ok === 'boolean' ? out.ok : undefined;
588
+ const error = typeof out?.error === 'string' ? out.error : undefined;
589
+ if (inp?.skillPath && !latestPath)
590
+ latestPath = inp.skillPath;
591
+ if (inp?.skillSource && !latestSource)
592
+ latestSource = inp.skillSource;
593
+ if (recentCalls.length < limit) {
594
+ const dedupeKey = `${sessionId}|${runId}|${inp?.toolCallId || ''}|${String(row.created_at || '')}`;
595
+ if (!seenToolCallIds.has(dedupeKey)) {
596
+ seenToolCallIds.add(dedupeKey);
597
+ recentCalls.push({
598
+ sessionId,
599
+ runId,
600
+ createdAt: String(row.created_at || ''),
601
+ toolCallId: inp?.toolCallId,
602
+ skillPath: inp?.skillPath,
603
+ skillSource: inp?.skillSource,
604
+ ok,
605
+ error,
606
+ });
607
+ }
608
+ }
609
+ }
610
+ const catalog = await getSkillCatalog(pool, scopeId);
611
+ const cat = catalog.get(skill);
612
+ const locationRaw = cat?.location || latestPath || '';
613
+ const inferredSource = inferSkillSourceFromLocation(locationRaw);
614
+ const location = resolveSkillLocation(locationRaw);
615
+ let content = '';
616
+ let contentLoaded = false;
617
+ if (location) {
618
+ try {
619
+ if (fs.existsSync(location)) {
620
+ content = fs.readFileSync(location, 'utf8');
621
+ contentLoaded = true;
622
+ }
623
+ }
624
+ catch {
625
+ contentLoaded = false;
626
+ }
627
+ }
628
+ return {
629
+ skill,
630
+ description: cat?.description,
631
+ location: locationRaw || undefined,
632
+ source: latestSource || inferredSource || undefined,
633
+ content,
634
+ contentLoaded,
635
+ callCount,
636
+ sessionCount: sessions.size,
637
+ runCount: runs.size,
638
+ lastSeen: lastSeenMs > 0 ? new Date(lastSeenMs).toISOString() : '',
639
+ recentCalls,
640
+ };
641
+ }
42
642
  function resolveMetricsWindow(params) {
43
643
  const now = Date.now();
44
644
  const defaultMinutes = Math.min(Math.max(normalizePositiveInt(params.minutes, 60), 1), 24 * 60);
@@ -60,8 +660,11 @@ function resolveMetricsWindow(params) {
60
660
  }
61
661
  /** Get summary statistics */
62
662
  async function getStats(pool, params) {
63
- let sessionWhere = '1=1';
64
- const sessionVals = [];
663
+ const scopeId = normalizeScopeId(params?.scopeId);
664
+ const agentScope = normalizeAgentScope(params?.agentType);
665
+ let sessionWhere = 'scope_id = ?';
666
+ let sessionVals = [scopeId];
667
+ ({ where: sessionWhere, values: sessionVals } = applyAgentScopeSessionFilter(sessionWhere, sessionVals, scopeId, agentScope));
65
668
  if (params?.timeFrom) {
66
669
  sessionWhere += ' AND COALESCE(end_time, start_time) >= ?';
67
670
  sessionVals.push(params.timeFrom);
@@ -70,8 +673,9 @@ async function getStats(pool, params) {
70
673
  sessionWhere += ' AND COALESCE(end_time, start_time) <= ?';
71
674
  sessionVals.push(params.timeTo);
72
675
  }
73
- let actionWhere = '1=1';
74
- const actionVals = [];
676
+ let actionWhere = 'scope_id = ? AND session_id IN (SELECT session_id FROM observation_sessions WHERE ' + sessionWhere + ')';
677
+ const actionVals = [scopeId];
678
+ actionVals.push(...sessionVals);
75
679
  if (params?.timeFrom) {
76
680
  actionWhere += ' AND created_at >= ?';
77
681
  actionVals.push(params.timeFrom);
@@ -80,16 +684,16 @@ async function getStats(pool, params) {
80
684
  actionWhere += ' AND created_at <= ?';
81
685
  actionVals.push(params.timeTo);
82
686
  }
83
- const [sessionRows] = await pool.query('SELECT COUNT(*) as cnt FROM audit_sessions WHERE ' + sessionWhere, sessionVals);
687
+ const [sessionRows] = await pool.query('SELECT COUNT(*) as cnt FROM observation_sessions WHERE ' + sessionWhere, sessionVals);
84
688
  const [actionRows] = await pool.query(`SELECT COUNT(*) as cnt,
85
689
  COALESCE(SUM(COALESCE(prompt_tokens,0) + COALESCE(completion_tokens,0)), 0) as tokens,
86
690
  COALESCE(AVG(NULLIF(duration_ms, 0)), 0) as avg_latency,
87
691
  COALESCE(SUM(CASE WHEN action_type = 'agent_end' THEN 1 ELSE 0 END), 0) as total_end,
88
692
  COALESCE(SUM(CASE WHEN action_type = 'agent_end' AND output_result LIKE '%"success":true%' THEN 1 ELSE 0 END), 0) as success_end
89
- FROM audit_actions
693
+ FROM observation_actions
90
694
  WHERE ` + actionWhere, actionVals);
91
695
  // Count by action_type
92
- const [typeCounts] = await pool.query('SELECT action_type, COUNT(*) as cnt FROM audit_actions WHERE ' + actionWhere + ' GROUP BY action_type ORDER BY cnt DESC', actionVals);
696
+ const [typeCounts] = await pool.query('SELECT action_type, COUNT(*) as cnt FROM observation_actions WHERE ' + actionWhere + ' GROUP BY action_type ORDER BY cnt DESC', actionVals);
93
697
  const actionTypeCounts = {};
94
698
  for (const row of typeCounts) {
95
699
  actionTypeCounts[row.action_type] = Number(row.cnt);
@@ -109,11 +713,14 @@ async function getStats(pool, params) {
109
713
  }
110
714
  /** Get session list */
111
715
  async function getSessions(pool, params) {
716
+ const scopeId = normalizeScopeId(params.scopeId);
112
717
  const page = normalizePositiveInt(params.page, 1);
113
718
  const limit = Math.min(normalizePositiveInt(params.limit, 20), 100);
114
719
  const offset = (page - 1) * limit;
115
- let where = '1=1';
116
- const values = [];
720
+ const agentScope = normalizeAgentScope(params.agentType);
721
+ let where = 'scope_id = ?';
722
+ let values = [scopeId];
723
+ ({ where, values } = applyAgentScopeSessionFilter(where, values, scopeId, agentScope));
117
724
  if (params.sessionId) {
118
725
  where += ' AND session_id = ?';
119
726
  values.push(params.sessionId);
@@ -132,11 +739,11 @@ async function getSessions(pool, params) {
132
739
  where += ` AND (
133
740
  session_id LIKE ? OR user_id LIKE ? OR model_name LIKE ?
134
741
  OR session_id IN (
135
- SELECT DISTINCT session_id FROM audit_actions
136
- WHERE input_params LIKE ? OR output_result LIKE ? OR action_name LIKE ?
742
+ SELECT DISTINCT session_id FROM observation_actions
743
+ WHERE scope_id = ? AND (input_params LIKE ? OR output_result LIKE ? OR action_name LIKE ?)
137
744
  )
138
745
  )`;
139
- values.push(like, like, like, like, like, like);
746
+ values.push(like, like, like, scopeId, like, like, like);
140
747
  }
141
748
  // Time range filter: use "last active time" (end_time fallback start_time)
142
749
  // so "recent 24h" reflects recently updated sessions, not only newly started sessions.
@@ -148,23 +755,49 @@ async function getSessions(pool, params) {
148
755
  where += ' AND COALESCE(end_time, start_time) <= ?';
149
756
  values.push(params.timeTo);
150
757
  }
151
- // Hide noisy pseudo rows from trace list
152
- where += " AND LOWER(COALESCE(user_id,'')) NOT IN ('unknown','unkown')";
153
- where += " AND LOWER(COALESCE(session_id,'')) NOT IN ('unknown','unkown','-')";
154
- const [countRows] = await pool.query('SELECT COUNT(*) as cnt FROM audit_sessions WHERE ' + where, values);
155
- const [rows] = await pool.query(`SELECT * FROM audit_sessions WHERE ${where}
758
+ const [countRows] = await pool.query('SELECT COUNT(*) as cnt FROM observation_sessions WHERE ' + where, values);
759
+ const [rows] = await pool.query(`SELECT * FROM observation_sessions WHERE ${where}
156
760
  ORDER BY
157
761
  CASE WHEN LOWER(COALESCE(user_id,'')) = 'system' THEN 1 ELSE 0 END ASC,
158
762
  COALESCE(end_time, start_time) DESC,
159
763
  start_time DESC
160
764
  LIMIT ? OFFSET ?`, [...values, limit, offset]);
765
+ const sessions = rows;
766
+ const sessionIds = sessions.map((s) => String(s.session_id || '')).filter(Boolean);
767
+ const subagentBySession = new Set();
768
+ if (sessionIds.length > 0) {
769
+ const placeholders = sessionIds.map(() => '?').join(',');
770
+ const [tagRows] = await pool.query(`SELECT DISTINCT session_id
771
+ FROM observation_actions
772
+ WHERE scope_id = ?
773
+ AND session_id IN (${placeholders})
774
+ AND action_type IN ('prompt_build', 'message')
775
+ AND (
776
+ input_params LIKE '%[Subagent Context]%'
777
+ OR input_params LIKE '%"source":"subagent"%'
778
+ )`, [scopeId, ...sessionIds]);
779
+ tagRows.forEach((r) => {
780
+ if (r && r.session_id)
781
+ subagentBySession.add(String(r.session_id));
782
+ });
783
+ }
784
+ const normalizedSessions = sessions.map((s) => {
785
+ const parent = String(s.parent_session_id || '').trim();
786
+ const sid = String(s.session_id || '');
787
+ const inferred = parent.length > 0 || subagentBySession.has(sid);
788
+ return {
789
+ ...s,
790
+ is_subagent: inferred ? 1 : 0,
791
+ };
792
+ });
161
793
  return {
162
- sessions: rows,
794
+ sessions: normalizedSessions,
163
795
  total: Number(countRows[0]?.cnt ?? 0),
164
796
  };
165
797
  }
166
798
  /** Get actions for a session (supports field selection and limit) */
167
799
  async function getSessionActions(pool, sessionId, options) {
800
+ const scopeId = normalizeScopeId(options?.scopeId);
168
801
  // If fields=action_type, return only minimal fields (for mini trace)
169
802
  const selectCols = options?.fields === 'action_type'
170
803
  ? 'action_type, created_at'
@@ -173,8 +806,12 @@ async function getSessionActions(pool, sessionId, options) {
173
806
  ? Math.min(normalizePositiveInt(options.limit, 1000), 1000)
174
807
  : undefined;
175
808
  const limitClause = limitedRows ? ` LIMIT ${limitedRows}` : '';
176
- let where = 'session_id = ?';
177
- const values = [sessionId];
809
+ let where = 'scope_id = ? AND session_id = ?';
810
+ const values = [scopeId, sessionId];
811
+ if (!String(sessionId || '').startsWith(REPLAY_SESSION_PREFIX)) {
812
+ where += ' AND session_id NOT LIKE ?';
813
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
814
+ }
178
815
  if (options?.timeFrom) {
179
816
  where += ' AND created_at >= ?';
180
817
  values.push(options.timeFrom);
@@ -183,16 +820,19 @@ async function getSessionActions(pool, sessionId, options) {
183
820
  where += ' AND created_at <= ?';
184
821
  values.push(options.timeTo);
185
822
  }
186
- const [rows] = await pool.query(`SELECT ${selectCols} FROM audit_actions WHERE ${where} ORDER BY created_at ASC, id ASC${limitClause}`, values);
823
+ const [rows] = await pool.query(`SELECT ${selectCols} FROM observation_actions WHERE ${where} ORDER BY created_at ASC, id ASC${limitClause}`, values);
187
824
  return rows;
188
825
  }
189
826
  /** Get action list */
190
827
  async function getActions(pool, params) {
828
+ const scopeId = normalizeScopeId(params.scopeId);
191
829
  const page = normalizePositiveInt(params.page, 1);
192
830
  const limit = Math.min(normalizePositiveInt(params.limit, 50), 200);
193
831
  const offset = (page - 1) * limit;
194
- let where = '1=1';
195
- const values = [];
832
+ let where = 'scope_id = ?';
833
+ const values = [scopeId];
834
+ where += ' AND session_id NOT LIKE ?';
835
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
196
836
  if (params.actionType) {
197
837
  where += ' AND action_type = ?';
198
838
  values.push(params.actionType);
@@ -209,8 +849,8 @@ async function getActions(pool, params) {
209
849
  where += ' AND created_at <= ?';
210
850
  values.push(params.timeTo);
211
851
  }
212
- const [countRows] = await pool.query('SELECT COUNT(*) as cnt FROM audit_actions WHERE ' + where, values);
213
- const [rows] = await pool.query('SELECT * FROM audit_actions WHERE ' + where + ' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?', [...values, limit, offset]);
852
+ const [countRows] = await pool.query('SELECT COUNT(*) as cnt FROM observation_actions WHERE ' + where, values);
853
+ const [rows] = await pool.query('SELECT * FROM observation_actions WHERE ' + where + ' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?', [...values, limit, offset]);
214
854
  return {
215
855
  actions: rows,
216
856
  total: Number(countRows[0]?.cnt ?? 0),
@@ -221,11 +861,14 @@ function normalizeAlertStatus(status) {
221
861
  }
222
862
  /** Get security alert list */
223
863
  async function getAlerts(pool, params) {
864
+ const scopeId = normalizeScopeId(params.scopeId);
224
865
  const page = normalizePositiveInt(params.page, 1);
225
866
  const limit = Math.min(normalizePositiveInt(params.limit, 20), 100);
226
867
  const offset = (page - 1) * limit;
227
- let where = '1=1';
228
- const values = [];
868
+ let where = 'scope_id = ?';
869
+ const values = [scopeId];
870
+ where += ' AND session_id NOT LIKE ?';
871
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
229
872
  if (params.severity) {
230
873
  where += ' AND severity = ?';
231
874
  values.push(params.severity);
@@ -269,8 +912,8 @@ async function getAlerts(pool, params) {
269
912
  where += ' AND created_at <= ?';
270
913
  values.push(params.timeTo);
271
914
  }
272
- const [countRows] = await pool.query('SELECT COUNT(*) as cnt FROM audit_alerts WHERE ' + where, values);
273
- const [rows] = await pool.query(`SELECT * FROM audit_alerts WHERE ${where}
915
+ const [countRows] = await pool.query('SELECT COUNT(*) as cnt FROM observation_alerts WHERE ' + where, values);
916
+ const [rows] = await pool.query(`SELECT * FROM observation_alerts WHERE ${where}
274
917
  ORDER BY
275
918
  CASE severity
276
919
  WHEN 'critical' THEN 0
@@ -291,8 +934,11 @@ async function getAlerts(pool, params) {
291
934
  }
292
935
  /** Get security alert statistics */
293
936
  async function getAlertStats(pool, params) {
294
- let where = '1=1';
295
- const values = [];
937
+ const scopeId = normalizeScopeId(params?.scopeId);
938
+ let where = 'scope_id = ?';
939
+ const values = [scopeId];
940
+ where += ' AND session_id NOT LIKE ?';
941
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
296
942
  if (params?.timeFrom) {
297
943
  where += ' AND created_at >= ?';
298
944
  values.push(params.timeFrom);
@@ -304,20 +950,20 @@ async function getAlertStats(pool, params) {
304
950
  const [summaryRows] = await pool.query(`SELECT
305
951
  COUNT(*) as total_cnt,
306
952
  COALESCE(SUM(CASE WHEN created_at >= (NOW() - INTERVAL 24 HOUR) THEN 1 ELSE 0 END), 0) as recent_24h
307
- FROM audit_alerts
953
+ FROM observation_alerts
308
954
  WHERE ${where}`, values);
309
955
  const [groupRows] = await pool.query(`SELECT 'severity' as grp, severity as k, COUNT(*) as cnt
310
- FROM audit_alerts
956
+ FROM observation_alerts
311
957
  WHERE ${where}
312
958
  GROUP BY severity
313
959
  UNION ALL
314
960
  SELECT 'category' as grp, category as k, COUNT(*) as cnt
315
- FROM audit_alerts
961
+ FROM observation_alerts
316
962
  WHERE ${where}
317
963
  GROUP BY category
318
964
  UNION ALL
319
965
  SELECT 'status' as grp, status as k, COUNT(*) as cnt
320
- FROM audit_alerts
966
+ FROM observation_alerts
321
967
  WHERE ${where}
322
968
  GROUP BY status`, [...values, ...values, ...values]);
323
969
  const bySeverity = {};
@@ -347,22 +993,28 @@ async function getAlertStats(pool, params) {
347
993
  };
348
994
  }
349
995
  /** Update alert status */
350
- async function updateAlertStatus(pool, alertId, status, resolvedBy) {
996
+ async function updateAlertStatus(pool, alertId, status, resolvedBy, scopeId) {
997
+ const scopedScopeId = normalizeScopeId(scopeId);
351
998
  const validStatuses = ['open', 'acknowledged', 'resolved'];
352
999
  if (!validStatuses.includes(status))
353
1000
  return false;
354
1001
  const resolvedAt = status === 'resolved'
355
1002
  ? new Date().toISOString().replace('T', ' ').slice(0, 19) // compatible with MySQL & DuckDB
356
1003
  : null;
357
- await pool.query(`UPDATE audit_alerts SET status = ?, resolved_by = ?, resolved_at = ? WHERE alert_id = ?`, [status, resolvedBy || null, resolvedAt, alertId]);
1004
+ await pool.query(`UPDATE observation_alerts SET status = ?, resolved_by = ?, resolved_at = ? WHERE scope_id = ? AND alert_id = ?`, [status, resolvedBy || null, resolvedAt, scopedScopeId, alertId]);
358
1005
  // Verify update result (compatible with MySQL and DuckDB, not relying on affectedRows)
359
- const [rows] = await pool.query('SELECT 1 FROM audit_alerts WHERE alert_id = ? AND status = ?', [alertId, status]);
1006
+ const [rows] = await pool.query('SELECT 1 FROM observation_alerts WHERE scope_id = ? AND alert_id = ? AND status = ?', [scopedScopeId, alertId, status]);
360
1007
  return rows.length > 0;
361
1008
  }
362
1009
  /** Get alerts for a session */
363
1010
  async function getSessionAlerts(pool, sessionId, params) {
364
- let where = 'session_id = ?';
365
- const values = [sessionId];
1011
+ const scopeId = normalizeScopeId(params?.scopeId);
1012
+ let where = 'scope_id = ? AND session_id = ?';
1013
+ const values = [scopeId, sessionId];
1014
+ if (!String(sessionId || '').startsWith(REPLAY_SESSION_PREFIX)) {
1015
+ where += ' AND session_id NOT LIKE ?';
1016
+ values.push(`${REPLAY_SESSION_PREFIX}%`);
1017
+ }
366
1018
  if (params?.timeFrom) {
367
1019
  where += ' AND created_at >= ?';
368
1020
  values.push(params.timeFrom);
@@ -371,7 +1023,7 @@ async function getSessionAlerts(pool, sessionId, params) {
371
1023
  where += ' AND created_at <= ?';
372
1024
  values.push(params.timeTo);
373
1025
  }
374
- const [rows] = await pool.query(`SELECT * FROM audit_alerts WHERE ${where} ORDER BY created_at ASC`, values);
1026
+ const [rows] = await pool.query(`SELECT * FROM observation_alerts WHERE ${where} ORDER BY created_at ASC`, values);
375
1027
  return rows.map((r) => ({
376
1028
  ...r,
377
1029
  status: normalizeAlertStatus(String(r.status || '')),
@@ -379,10 +1031,15 @@ async function getSessionAlerts(pool, sessionId, params) {
379
1031
  }
380
1032
  /** Get comprehensive analytics data */
381
1033
  async function getAnalytics(pool, params) {
382
- let timeWhere = '1=1';
383
- let sessTimeWhere = '1=1';
384
- const timeVals = [];
385
- const sessTimeVals = [];
1034
+ const scopeId = normalizeScopeId(params.scopeId);
1035
+ let timeWhere = 'scope_id = ?';
1036
+ let sessTimeWhere = 'scope_id = ?';
1037
+ const timeVals = [scopeId];
1038
+ const sessTimeVals = [scopeId];
1039
+ timeWhere += ' AND session_id NOT LIKE ?';
1040
+ sessTimeWhere += ' AND session_id NOT LIKE ?';
1041
+ timeVals.push(`${REPLAY_SESSION_PREFIX}%`);
1042
+ sessTimeVals.push(`${REPLAY_SESSION_PREFIX}%`);
386
1043
  if (params.timeFrom) {
387
1044
  timeWhere += ' AND created_at >= ?';
388
1045
  sessTimeWhere += ' AND start_time >= ?';
@@ -396,17 +1053,17 @@ async function getAnalytics(pool, params) {
396
1053
  sessTimeVals.push(params.timeTo);
397
1054
  }
398
1055
  // 1. Overview KPIs
399
- const [sessCount] = await pool.query('SELECT COUNT(*) as cnt FROM audit_sessions WHERE ' + sessTimeWhere, sessTimeVals);
1056
+ const [sessCount] = await pool.query('SELECT COUNT(*) as cnt FROM observation_sessions WHERE ' + sessTimeWhere, sessTimeVals);
400
1057
  const [actAgg] = await pool.query(`SELECT COUNT(*) as cnt,
401
1058
  COALESCE(SUM(COALESCE(prompt_tokens,0)), 0) as inp,
402
1059
  COALESCE(SUM(COALESCE(completion_tokens,0)), 0) as outp,
403
1060
  COALESCE(AVG(NULLIF(duration_ms, 0)), 0) as avg_lat
404
- FROM audit_actions WHERE ` + timeWhere, timeVals);
405
- const [modelCount] = await pool.query(`SELECT COUNT(DISTINCT model_name) as cnt FROM audit_actions
1061
+ FROM observation_actions WHERE ` + timeWhere, timeVals);
1062
+ const [modelCount] = await pool.query(`SELECT COUNT(DISTINCT model_name) as cnt FROM observation_actions
406
1063
  WHERE model_name != '' AND ` + timeWhere, timeVals);
407
1064
  let secAlerts = 0;
408
1065
  try {
409
- const [alertCount] = await pool.query('SELECT COUNT(*) as cnt FROM audit_alerts WHERE ' + timeWhere, timeVals);
1066
+ const [alertCount] = await pool.query('SELECT COUNT(*) as cnt FROM observation_alerts WHERE ' + timeWhere, timeVals);
410
1067
  secAlerts = Number(alertCount[0]?.cnt ?? 0);
411
1068
  }
412
1069
  catch { /* alerts table may not exist */ }
@@ -430,16 +1087,17 @@ async function getAnalytics(pool, params) {
430
1087
  if (spanMs <= 48 * 3600 * 1000)
431
1088
  granularity = 'hour';
432
1089
  }
433
- // Build GROUP BY expressions compatible with both MySQL and DuckDB
1090
+ // Build GROUP BY expressions compatible with both MySQL and DuckDB-like backends.
1091
+ // Avoid CAST(... AS VARCHAR) because some MySQL-compatible engines reject it.
434
1092
  const sessGroupExpr = granularity === 'hour'
435
- ? `CAST(start_time AS VARCHAR)` // full timestamp string, we'll truncate in JS
436
- : `CAST(start_time AS DATE)`;
1093
+ ? `start_time` // full timestamp, truncated in JS
1094
+ : `DATE(start_time)`;
437
1095
  const actGroupExpr = granularity === 'hour'
438
- ? `CAST(created_at AS VARCHAR)`
439
- : `CAST(created_at AS DATE)`;
1096
+ ? `created_at`
1097
+ : `DATE(created_at)`;
440
1098
  const [tsRows] = await pool.query(`SELECT ${sessGroupExpr} as bucket,
441
1099
  COUNT(*) as sessions
442
- FROM audit_sessions
1100
+ FROM observation_sessions
443
1101
  WHERE ` + sessTimeWhere + `
444
1102
  GROUP BY ${sessGroupExpr}
445
1103
  ORDER BY bucket ASC`, sessTimeVals);
@@ -447,7 +1105,7 @@ async function getAnalytics(pool, params) {
447
1105
  COUNT(*) as actions,
448
1106
  COALESCE(SUM(COALESCE(prompt_tokens,0)),0) as inp,
449
1107
  COALESCE(SUM(COALESCE(completion_tokens,0)),0) as outp
450
- FROM audit_actions
1108
+ FROM observation_actions
451
1109
  WHERE ` + timeWhere + `
452
1110
  GROUP BY ${actGroupExpr}
453
1111
  ORDER BY bucket ASC`, timeVals);
@@ -519,7 +1177,7 @@ async function getAnalytics(pool, params) {
519
1177
  COALESCE(SUM(COALESCE(prompt_tokens,0)),0) as inp,
520
1178
  COALESCE(SUM(COALESCE(completion_tokens,0)),0) as outp,
521
1179
  COALESCE(AVG(NULLIF(duration_ms,0)),0) as avg_lat
522
- FROM audit_actions
1180
+ FROM observation_actions
523
1181
  WHERE action_type = 'message' AND model_name != '' AND ` + timeWhere + `
524
1182
  GROUP BY model_name
525
1183
  ORDER BY inp + outp DESC
@@ -537,7 +1195,7 @@ async function getAnalytics(pool, params) {
537
1195
  output: Number(r.outp),
538
1196
  }));
539
1197
  // 4. Action distribution
540
- const [actTypeRows] = await pool.query(`SELECT action_type, COUNT(*) as cnt FROM audit_actions
1198
+ const [actTypeRows] = await pool.query(`SELECT action_type, COUNT(*) as cnt FROM observation_actions
541
1199
  WHERE ` + timeWhere + `
542
1200
  GROUP BY action_type ORDER BY cnt DESC`, timeVals);
543
1201
  const actionDistribution = {};
@@ -548,7 +1206,7 @@ async function getAnalytics(pool, params) {
548
1206
  const [topAgentRows] = await pool.query(`SELECT b.user_id, b.sessions, b.tokens, COALESCE(COUNT(a.id), 0) as actions
549
1207
  FROM (
550
1208
  SELECT user_id, COUNT(DISTINCT session_id) as sessions, COALESCE(SUM(total_tokens), 0) as tokens
551
- FROM audit_sessions
1209
+ FROM observation_sessions
552
1210
  WHERE user_id != ''
553
1211
  AND LOWER(user_id) NOT IN ('unknown','unkown')
554
1212
  AND ` + sessTimeWhere + `
@@ -558,7 +1216,7 @@ async function getAnalytics(pool, params) {
558
1216
  sessions DESC
559
1217
  LIMIT 10
560
1218
  ) b
561
- LEFT JOIN audit_actions a
1219
+ LEFT JOIN observation_actions a
562
1220
  ON a.user_id = b.user_id
563
1221
  AND ` + timeWhere + `
564
1222
  GROUP BY b.user_id, b.sessions, b.tokens
@@ -573,7 +1231,134 @@ async function getAnalytics(pool, params) {
573
1231
  }));
574
1232
  return { overview, timeSeries, granularity, modelUsage, actionDistribution, topAgents, tokensByModel };
575
1233
  }
1234
+ function buildBucketTimeline(fromMs, toMs, stepMs) {
1235
+ if (!Number.isFinite(fromMs) || !Number.isFinite(toMs) || !Number.isFinite(stepMs) || stepMs <= 0)
1236
+ return [];
1237
+ const start = Math.floor(fromMs / stepMs) * stepMs;
1238
+ const end = Math.floor(toMs / stepMs) * stepMs;
1239
+ const out = [];
1240
+ for (let ts = start; ts <= end; ts += stepMs)
1241
+ out.push(ts);
1242
+ return out;
1243
+ }
1244
+ function inferFillMode(temporality) {
1245
+ const t = String(temporality || '').trim().toLowerCase();
1246
+ return t === 'delta' ? 'zero' : 'carry';
1247
+ }
1248
+ function densifySeriesPoints(points, timeline, fillMode, seedValue) {
1249
+ if (!Array.isArray(points) || !points.length || !Array.isArray(timeline) || !timeline.length)
1250
+ return [];
1251
+ const byTs = new Map();
1252
+ for (const p of points) {
1253
+ const ts = Number(p.timestampMs || 0);
1254
+ const v = Number(p.value || 0);
1255
+ if (!Number.isFinite(ts) || !Number.isFinite(v))
1256
+ continue;
1257
+ byTs.set(ts, v);
1258
+ }
1259
+ let lastKnown = Number.isFinite(seedValue) ? Number(seedValue) : null;
1260
+ if (fillMode === 'carry' && lastKnown == null) {
1261
+ let firstTs = Infinity;
1262
+ let firstVal = null;
1263
+ for (const [ts, v] of byTs.entries()) {
1264
+ if (ts < firstTs) {
1265
+ firstTs = ts;
1266
+ firstVal = v;
1267
+ }
1268
+ }
1269
+ if (Number.isFinite(firstTs) && firstVal != null && Number.isFinite(firstVal)) {
1270
+ lastKnown = Number(firstVal);
1271
+ }
1272
+ }
1273
+ const out = [];
1274
+ for (const ts of timeline) {
1275
+ if (byTs.has(ts)) {
1276
+ const v = Number(byTs.get(ts) || 0);
1277
+ lastKnown = v;
1278
+ out.push({ timestampMs: ts, value: v });
1279
+ continue;
1280
+ }
1281
+ if (fillMode === 'zero') {
1282
+ out.push({ timestampMs: ts, value: 0 });
1283
+ continue;
1284
+ }
1285
+ if (lastKnown != null) {
1286
+ out.push({ timestampMs: ts, value: lastKnown });
1287
+ }
1288
+ }
1289
+ return out;
1290
+ }
1291
+ function inPlaceholders(n) {
1292
+ return new Array(Math.max(0, n)).fill('?').join(', ');
1293
+ }
1294
+ async function fetchCarrySeedValuesV2(pool, params) {
1295
+ const ids = (params.seriesIds || []).filter(Boolean);
1296
+ if (!ids.length)
1297
+ return new Map();
1298
+ const placeholders = inPlaceholders(ids.length);
1299
+ const sql = `
1300
+ SELECT t.series_id AS series_id, t.v AS v
1301
+ FROM (
1302
+ SELECT
1303
+ q.series_id AS series_id,
1304
+ q.v AS v,
1305
+ q.ts_ms AS ts_ms,
1306
+ ROW_NUMBER() OVER (PARTITION BY q.series_id ORDER BY q.ts_ms DESC) AS rn
1307
+ FROM (
1308
+ SELECT s.series_id AS series_id, n.ts_ms AS ts_ms, n.value AS v
1309
+ FROM oc_metric_points_number n
1310
+ INNER JOIN oc_metric_series s ON s.series_id = n.series_id
1311
+ WHERE s.scope_id = ? AND n.scope_id = ? AND s.metric_name = ? AND n.ts_ms < ? AND s.series_id IN (${placeholders})
1312
+ UNION ALL
1313
+ SELECT s2.series_id AS series_id, h.ts_ms AS ts_ms, CAST(h.count AS DOUBLE) AS v
1314
+ FROM oc_metric_points_histogram h
1315
+ INNER JOIN oc_metric_series s2 ON s2.series_id = h.series_id
1316
+ WHERE s2.scope_id = ? AND h.scope_id = ? AND s2.metric_name = ? AND h.ts_ms < ? AND s2.series_id IN (${placeholders})
1317
+ ) q
1318
+ ) t
1319
+ WHERE t.rn = 1
1320
+ `;
1321
+ const bind = [
1322
+ params.scopeId, params.scopeId, params.metricName, params.beforeMs, ...ids,
1323
+ params.scopeId, params.scopeId, params.metricName, params.beforeMs, ...ids,
1324
+ ];
1325
+ const [rows] = await pool.query(sql, bind);
1326
+ const out = new Map();
1327
+ for (const r of rows) {
1328
+ const sid = String(r.series_id || '');
1329
+ const v = Number(r.v || 0);
1330
+ if (!sid || !Number.isFinite(v))
1331
+ continue;
1332
+ out.set(sid, v);
1333
+ }
1334
+ return out;
1335
+ }
1336
+ function aggregateSeriesPoints(series, timeline) {
1337
+ if (!Array.isArray(series) || !series.length || !Array.isArray(timeline) || !timeline.length)
1338
+ return [];
1339
+ const seriesMaps = series.map((s) => {
1340
+ const m = new Map();
1341
+ for (const p of s.points || [])
1342
+ m.set(Number(p.timestampMs || 0), Number(p.value || 0));
1343
+ return m;
1344
+ });
1345
+ const out = [];
1346
+ for (const ts of timeline) {
1347
+ let sum = 0;
1348
+ let hasAny = false;
1349
+ for (const m of seriesMaps) {
1350
+ if (!m.has(ts))
1351
+ continue;
1352
+ sum += Number(m.get(ts) || 0);
1353
+ hasAny = true;
1354
+ }
1355
+ if (hasAny)
1356
+ out.push({ timestampMs: ts, value: sum });
1357
+ }
1358
+ return out;
1359
+ }
576
1360
  async function getMetricsOverview(pool, params) {
1361
+ const scopeId = normalizeScopeId(params?.scopeId);
577
1362
  const minutes = Math.min(Math.max(normalizePositiveInt(params?.minutes, 60), 1), 24 * 60);
578
1363
  const limit = Math.min(Math.max(normalizePositiveInt(params?.limit, 100), 1), 500);
579
1364
  const window = resolveMetricsWindow({
@@ -587,11 +1372,11 @@ async function getMetricsOverview(pool, params) {
587
1372
  const [summaryRows] = await pool.query(`SELECT
588
1373
  COUNT(DISTINCT s.metric_name) AS metric_cnt,
589
1374
  (
590
- COALESCE((SELECT COUNT(*) FROM oc_metric_points_number pn WHERE pn.ts_ms >= ? AND pn.ts_ms <= ?), 0) +
591
- COALESCE((SELECT COUNT(*) FROM oc_metric_points_histogram ph WHERE ph.ts_ms >= ? AND ph.ts_ms <= ?), 0)
1375
+ COALESCE((SELECT COUNT(*) FROM oc_metric_points_number pn WHERE pn.scope_id = ? AND pn.ts_ms >= ? AND pn.ts_ms <= ?), 0) +
1376
+ COALESCE((SELECT COUNT(*) FROM oc_metric_points_histogram ph WHERE ph.scope_id = ? AND ph.ts_ms >= ? AND ph.ts_ms <= ?), 0)
592
1377
  ) AS cnt
593
1378
  FROM oc_metric_series s
594
- WHERE s.last_seen_ms >= ? AND s.last_seen_ms <= ?`, [fromMs, toMs, fromMs, toMs, fromMs, toMs]);
1379
+ WHERE s.scope_id = ? AND s.last_seen_ms >= ? AND s.last_seen_ms <= ?`, [scopeId, fromMs, toMs, scopeId, fromMs, toMs, scopeId, fromMs, toMs]);
595
1380
  const [catalogRows] = await pool.query(`SELECT
596
1381
  s.metric_name AS metric_name,
597
1382
  MAX(s.metric_type) AS metric_type,
@@ -602,9 +1387,9 @@ async function getMetricsOverview(pool, params) {
602
1387
  LEFT JOIN (
603
1388
  SELECT series_id, COUNT(*) AS samples, MAX(ts_ms) AS latest_ts
604
1389
  FROM (
605
- SELECT series_id, ts_ms FROM oc_metric_points_number WHERE ts_ms >= ? AND ts_ms <= ?
1390
+ SELECT series_id, ts_ms FROM oc_metric_points_number WHERE scope_id = ? AND ts_ms >= ? AND ts_ms <= ?
606
1391
  UNION ALL
607
- SELECT series_id, ts_ms FROM oc_metric_points_histogram WHERE ts_ms >= ? AND ts_ms <= ?
1392
+ SELECT series_id, ts_ms FROM oc_metric_points_histogram WHERE scope_id = ? AND ts_ms >= ? AND ts_ms <= ?
608
1393
  ) t
609
1394
  GROUP BY series_id
610
1395
  ) a ON a.series_id = s.series_id
@@ -612,10 +1397,10 @@ async function getMetricsOverview(pool, params) {
612
1397
  ON n.series_id = s.series_id AND n.ts_ms = a.latest_ts
613
1398
  LEFT JOIN oc_metric_points_histogram h
614
1399
  ON h.series_id = s.series_id AND h.ts_ms = a.latest_ts
615
- WHERE s.last_seen_ms >= ? AND s.last_seen_ms <= ?
1400
+ WHERE s.scope_id = ? AND s.last_seen_ms >= ? AND s.last_seen_ms <= ?
616
1401
  GROUP BY s.metric_name, a.samples, a.latest_ts
617
1402
  ORDER BY s.metric_name ASC
618
- LIMIT ?`, [fromMs, toMs, fromMs, toMs, fromMs, toMs, limit]);
1403
+ LIMIT ?`, [scopeId, fromMs, toMs, scopeId, fromMs, toMs, scopeId, fromMs, toMs, limit]);
619
1404
  const items = catalogRows.map((r) => ({
620
1405
  metricName: String(r.metric_name || ''),
621
1406
  metricType: String(r.metric_type || 'untyped'),
@@ -623,18 +1408,25 @@ async function getMetricsOverview(pool, params) {
623
1408
  latestTimestampMs: Number(r.latest_ts || 0),
624
1409
  latestValue: Number(r.latest_value || 0),
625
1410
  }));
626
- return {
1411
+ const result = {
627
1412
  totalMetrics: Number(summaryRows[0]?.metric_cnt ?? 0),
628
1413
  totalSamples: Number(summaryRows[0]?.cnt ?? 0),
629
1414
  rangeMinutes: window.rangeMinutes,
630
1415
  items,
631
1416
  };
1417
+ // Some deployments still persist only legacy snapshots.
1418
+ // If new-series tables return empty, fall back to legacy query path.
1419
+ if (result.totalMetrics <= 0 && result.totalSamples <= 0 && items.length === 0) {
1420
+ return getMetricsOverviewLegacy(pool, window.rangeMinutes, limit, fromMs, toMs);
1421
+ }
1422
+ return result;
632
1423
  }
633
1424
  catch {
634
1425
  return getMetricsOverviewLegacy(pool, window.rangeMinutes, limit, fromMs, toMs);
635
1426
  }
636
1427
  }
637
1428
  async function getMetricSeries(pool, params) {
1429
+ const scopeId = normalizeScopeId(params.scopeId);
638
1430
  const metricName = params.metricName?.trim();
639
1431
  if (!metricName) {
640
1432
  return {
@@ -656,27 +1448,28 @@ async function getMetricSeries(pool, params) {
656
1448
  try {
657
1449
  const [typeRows] = await pool.query(`SELECT metric_type
658
1450
  FROM oc_metric_series
659
- WHERE metric_name = ?
1451
+ WHERE scope_id = ? AND metric_name = ?
660
1452
  ORDER BY last_seen_ms DESC
661
- LIMIT 1`, [metricName]);
1453
+ LIMIT 1`, [scopeId, metricName]);
662
1454
  const [rows] = await pool.query(`SELECT
663
- CAST(FLOOR(q.ts_ms / ?) * ? AS BIGINT) AS bucket_ms,
1455
+ FLOOR(q.ts_ms / ?) * ? AS bucket_ms,
664
1456
  q.series_id AS series_id,
665
1457
  q.labels_json AS labels_json,
1458
+ q.temporality AS temporality,
666
1459
  MAX(q.v) AS series_bucket_max
667
1460
  FROM (
668
- SELECT n.ts_ms AS ts_ms, n.value AS v, s.series_id AS series_id, s.labels_json AS labels_json
1461
+ SELECT n.ts_ms AS ts_ms, n.value AS v, s.series_id AS series_id, s.labels_json AS labels_json, s.temporality AS temporality
669
1462
  FROM oc_metric_points_number n
670
1463
  INNER JOIN oc_metric_series s ON s.series_id = n.series_id
671
- WHERE s.metric_name = ? AND n.ts_ms >= ? AND n.ts_ms <= ?
1464
+ WHERE s.scope_id = ? AND n.scope_id = ? AND s.metric_name = ? AND n.ts_ms >= ? AND n.ts_ms <= ?
672
1465
  UNION ALL
673
- SELECT h.ts_ms AS ts_ms, CAST(h.count AS DOUBLE) AS v, s2.series_id AS series_id, s2.labels_json AS labels_json
1466
+ SELECT h.ts_ms AS ts_ms, CAST(h.count AS DOUBLE) AS v, s2.series_id AS series_id, s2.labels_json AS labels_json, s2.temporality AS temporality
674
1467
  FROM oc_metric_points_histogram h
675
1468
  INNER JOIN oc_metric_series s2 ON s2.series_id = h.series_id
676
- WHERE s2.metric_name = ? AND h.ts_ms >= ? AND h.ts_ms <= ?
1469
+ WHERE s2.scope_id = ? AND h.scope_id = ? AND s2.metric_name = ? AND h.ts_ms >= ? AND h.ts_ms <= ?
677
1470
  ) q
678
- GROUP BY bucket_ms, q.series_id, q.labels_json
679
- ORDER BY bucket_ms ASC, q.series_id ASC`, [stepMs, stepMs, metricName, fromMs, toMs, metricName, fromMs, toMs]);
1471
+ GROUP BY bucket_ms, q.series_id, q.labels_json, q.temporality
1472
+ ORDER BY bucket_ms ASC, q.series_id ASC`, [stepMs, stepMs, scopeId, scopeId, metricName, fromMs, toMs, scopeId, scopeId, metricName, fromMs, toMs]);
680
1473
  const seriesMap = new Map();
681
1474
  for (const r of rows) {
682
1475
  const seriesId = String(r.series_id || '');
@@ -689,6 +1482,7 @@ async function getMetricSeries(pool, params) {
689
1482
  entry = {
690
1483
  seriesId,
691
1484
  labels: parseMetricLabelsJson(r.labels_json),
1485
+ temporality: String(r.temporality || ''),
692
1486
  points: [],
693
1487
  latestTimestampMs: 0,
694
1488
  samples: 0,
@@ -700,23 +1494,31 @@ async function getMetricSeries(pool, params) {
700
1494
  if (timestampMs > entry.latestTimestampMs)
701
1495
  entry.latestTimestampMs = timestampMs;
702
1496
  }
703
- const series = Array.from(seriesMap.values());
1497
+ const timeline = buildBucketTimeline(fromMs, toMs, stepMs);
1498
+ const rawSeries = Array.from(seriesMap.values());
1499
+ const carrySeriesIds = rawSeries
1500
+ .filter((s) => inferFillMode(s.temporality) === 'carry')
1501
+ .map((s) => s.seriesId);
1502
+ const carrySeeds = carrySeriesIds.length
1503
+ ? await fetchCarrySeedValuesV2(pool, { scopeId, metricName, beforeMs: fromMs, seriesIds: carrySeriesIds })
1504
+ : new Map();
1505
+ const series = rawSeries.map((s) => {
1506
+ const mode = inferFillMode(s.temporality);
1507
+ const seed = mode === 'carry' ? carrySeeds.get(s.seriesId) : undefined;
1508
+ const densified = densifySeriesPoints(s.points, timeline, mode, seed);
1509
+ return {
1510
+ ...s,
1511
+ points: densified,
1512
+ };
1513
+ });
704
1514
  let points = [];
705
1515
  if (aggregate) {
706
- const bucketTotals = new Map();
707
- for (const s of series) {
708
- for (const p of s.points) {
709
- bucketTotals.set(p.timestampMs, (bucketTotals.get(p.timestampMs) || 0) + p.value);
710
- }
711
- }
712
- points = Array.from(bucketTotals.entries())
713
- .sort((a, b) => a[0] - b[0])
714
- .map(([timestampMs, value]) => ({ timestampMs, value }));
1516
+ points = aggregateSeriesPoints(series, timeline);
715
1517
  }
716
1518
  else if (series.length === 1) {
717
1519
  points = series[0].points.slice();
718
1520
  }
719
- return {
1521
+ const result = {
720
1522
  metricName,
721
1523
  metricType: String(typeRows[0]?.metric_type || 'untyped'),
722
1524
  rangeMinutes: window.rangeMinutes,
@@ -725,6 +1527,12 @@ async function getMetricSeries(pool, params) {
725
1527
  series,
726
1528
  aggregateApplied: aggregate,
727
1529
  };
1530
+ // Backward compatibility: if v2 metrics tables are present but empty for this metric,
1531
+ // try legacy snapshot table so charts still render.
1532
+ if (result.series.length === 0 && result.points.length === 0) {
1533
+ return getMetricSeriesLegacy(pool, metricName, window.rangeMinutes, stepSec, stepMs, fromMs, toMs, aggregate);
1534
+ }
1535
+ return result;
728
1536
  }
729
1537
  catch {
730
1538
  return getMetricSeriesLegacy(pool, metricName, window.rangeMinutes, stepSec, stepMs, fromMs, toMs, aggregate);
@@ -778,10 +1586,10 @@ async function getMetricSeriesLegacy(pool, metricName, minutes, stepSec, stepMs,
778
1586
  agg.series_bucket_max AS series_bucket_max
779
1587
  FROM (
780
1588
  SELECT
781
- CAST(FLOOR(s.sample_timestamp_ms / ?) * ? AS BIGINT) AS bucket_ms,
1589
+ FLOOR(s.sample_timestamp_ms / ?) * ? AS bucket_ms,
782
1590
  COALESCE(
783
1591
  NULLIF(TRIM(COALESCE(s.label_fingerprint, '')), ''),
784
- CONCAT('id:', CAST(s.id AS CHAR))
1592
+ CONCAT('labels:', COALESCE(s.labels_json, '{}'))
785
1593
  ) AS series_key,
786
1594
  COALESCE(s.labels_json, '{}') AS labels_json,
787
1595
  MAX(s.value) AS series_bucket_max
@@ -789,15 +1597,9 @@ async function getMetricSeriesLegacy(pool, metricName, minutes, stepSec, stepMs,
789
1597
  WHERE s.metric_name = ?
790
1598
  AND s.sample_timestamp_ms >= ?
791
1599
  AND s.sample_timestamp_ms <= ?
792
- GROUP BY
793
- CAST(FLOOR(s.sample_timestamp_ms / ?) * ? AS BIGINT),
794
- COALESCE(
795
- NULLIF(TRIM(COALESCE(s.label_fingerprint, '')), ''),
796
- CONCAT('id:', CAST(s.id AS CHAR))
797
- ),
798
- COALESCE(s.labels_json, '{}')
1600
+ GROUP BY 1, 2, 3
799
1601
  ) agg
800
- ORDER BY agg.bucket_ms ASC, agg.series_key ASC`, [stepMs, stepMs, metricName, fromMs, toMs, stepMs, stepMs]);
1602
+ ORDER BY agg.bucket_ms ASC, agg.series_key ASC`, [stepMs, stepMs, metricName, fromMs, toMs]);
801
1603
  const seriesMap = new Map();
802
1604
  for (const r of rows) {
803
1605
  const seriesId = String(r.series_id || '');
@@ -822,20 +1624,17 @@ async function getMetricSeriesLegacy(pool, metricName, minutes, stepSec, stepMs,
822
1624
  entry.latestTimestampMs = timestampMs;
823
1625
  }
824
1626
  const series = Array.from(seriesMap.values());
1627
+ const timeline = buildBucketTimeline(fromMs, toMs, stepMs);
1628
+ const densifiedSeries = series.map((s) => ({
1629
+ ...s,
1630
+ points: densifySeriesPoints(s.points, timeline, 'carry'),
1631
+ }));
825
1632
  let points = [];
826
1633
  if (aggregate) {
827
- const bucketTotals = new Map();
828
- for (const s of series) {
829
- for (const p of s.points) {
830
- bucketTotals.set(p.timestampMs, (bucketTotals.get(p.timestampMs) || 0) + p.value);
831
- }
832
- }
833
- points = Array.from(bucketTotals.entries())
834
- .sort((a, b) => a[0] - b[0])
835
- .map(([timestampMs, value]) => ({ timestampMs, value }));
1634
+ points = aggregateSeriesPoints(densifiedSeries, timeline);
836
1635
  }
837
- else if (series.length === 1) {
838
- points = series[0].points.slice();
1636
+ else if (densifiedSeries.length === 1) {
1637
+ points = densifiedSeries[0].points.slice();
839
1638
  }
840
1639
  return {
841
1640
  metricName,
@@ -843,7 +1642,7 @@ async function getMetricSeriesLegacy(pool, metricName, minutes, stepSec, stepMs,
843
1642
  rangeMinutes: minutes,
844
1643
  stepSec,
845
1644
  points,
846
- series,
1645
+ series: densifiedSeries,
847
1646
  aggregateApplied: aggregate,
848
1647
  };
849
1648
  }
@@ -869,11 +1668,12 @@ function parseMetricLabelsJson(raw) {
869
1668
  }
870
1669
  }
871
1670
  async function getTraceObservationTree(pool, traceId, params) {
1671
+ const scopeId = normalizeScopeId(params?.scopeId);
872
1672
  const id = String(traceId || '').trim();
873
1673
  if (!id)
874
1674
  return { traceId: '', observations: [] };
875
- let where = 'c.trace_id = ?';
876
- const values = [id];
1675
+ let where = 'c.scope_id = ? AND c.trace_id = ?';
1676
+ const values = [scopeId, id];
877
1677
  if (params?.timeFrom) {
878
1678
  where += ' AND c.start_time >= ?';
879
1679
  values.push(params.timeFrom);
@@ -886,6 +1686,7 @@ async function getTraceObservationTree(pool, traceId, params) {
886
1686
  const [rows] = await pool.query(`SELECT
887
1687
  c.observation_id AS observation_id,
888
1688
  c.parent_observation_id AS parent_observation_id,
1689
+ pcore.run_id AS parent_run_id,
889
1690
  c.root_observation_id AS root_observation_id,
890
1691
  c.trace_id AS trace_id,
891
1692
  c.observation_type AS observation_type,
@@ -912,6 +1713,8 @@ async function getTraceObservationTree(pool, traceId, params) {
912
1713
  p.model_params_json AS model_params_json,
913
1714
  p.error_json AS error_json
914
1715
  FROM oc_observations_core c
1716
+ LEFT JOIN oc_observations_core pcore
1717
+ ON pcore.observation_id = c.parent_observation_id
915
1718
  LEFT JOIN oc_observations_payload p
916
1719
  ON p.observation_id = c.observation_id
917
1720
  WHERE ${where}
@@ -921,6 +1724,7 @@ async function getTraceObservationTree(pool, traceId, params) {
921
1724
  observations: rows.map((r) => ({
922
1725
  observationId: String(r.observation_id || ''),
923
1726
  parentObservationId: r.parent_observation_id ? String(r.parent_observation_id) : null,
1727
+ parentRunId: r.parent_run_id ? String(r.parent_run_id) : null,
924
1728
  rootObservationId: String(r.root_observation_id || ''),
925
1729
  traceId: String(r.trace_id || ''),
926
1730
  type: String(r.observation_type || ''),