@runtimescope/mcp-server 0.6.2 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,6 +5,7 @@ import { execSync as execSync2 } from "child_process";
5
5
  import { existsSync as existsSync2 } from "fs";
6
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { join as join2 } from "path";
8
9
  import {
9
10
  CollectorServer,
10
11
  ProjectManager,
@@ -17,9 +18,12 @@ import {
17
18
  SessionManager,
18
19
  HttpServer,
19
20
  SqliteStore,
21
+ isSqliteAvailable,
20
22
  AuthManager,
21
23
  Redactor,
22
- resolveTlsConfig
24
+ resolveTlsConfig,
25
+ PmStore,
26
+ ProjectDiscovery
23
27
  } from "@runtimescope/collector";
24
28
 
25
29
  // src/tools/network.ts
@@ -224,7 +228,7 @@ function registerIssueTools(server, store, apiDiscovery, processMonitor) {
224
228
  const allIssues = [...detectIssues(events)];
225
229
  if (apiDiscovery) {
226
230
  try {
227
- allIssues.push(...apiDiscovery.detectIssues(events));
231
+ allIssues.push(...apiDiscovery.detectIssues());
228
232
  } catch {
229
233
  }
230
234
  }
@@ -290,7 +294,7 @@ function registerTimelineTools(server, store) {
290
294
  "Get a chronological view of ALL events (network requests, console messages) interleaved by timestamp. Essential for understanding causal chains \u2014 e.g. seeing that an API call failed, then an error was logged, then another retry fired. Events are in chronological order (oldest first).",
291
295
  {
292
296
  since_seconds: z4.number().optional().describe("Only return events from the last N seconds (default: 60)"),
293
- event_types: z4.array(z4.enum(["network", "console", "session", "state", "render", "performance", "dom_snapshot", "database"])).optional().describe("Filter by event types (default: all)"),
297
+ event_types: z4.array(z4.enum(["network", "console", "session", "state", "render", "performance", "dom_snapshot", "database", "custom"])).optional().describe("Filter by event types (default: all)"),
294
298
  limit: z4.number().optional().describe("Max events to return (default: 200, max: 1000)")
295
299
  },
296
300
  async ({ since_seconds, event_types, limit }) => {
@@ -413,6 +417,14 @@ function formatTimelineEvent(event) {
413
417
  error: de.error ?? null
414
418
  };
415
419
  }
420
+ case "custom": {
421
+ const ce = event;
422
+ return {
423
+ ...base,
424
+ name: ce.name,
425
+ properties: ce.properties ?? null
426
+ };
427
+ }
416
428
  default:
417
429
  return base;
418
430
  }
@@ -1963,34 +1975,160 @@ function registerInfraTools(server, infraConnector) {
1963
1975
  // src/tools/session-diff.ts
1964
1976
  import { z as z15 } from "zod";
1965
1977
  import { compareSessions } from "@runtimescope/collector";
1966
- function registerSessionDiffTools(server, sessionManager) {
1978
+ function registerSessionDiffTools(server, sessionManager, collector) {
1979
+ server.tool(
1980
+ "create_session_snapshot",
1981
+ "Capture a point-in-time snapshot of a live or recent session. Use before/after code changes to compare how your app behaves at different moments. Each session can have multiple snapshots.",
1982
+ {
1983
+ session_id: z15.string().optional().describe("Session ID (defaults to first active session)"),
1984
+ label: z15.string().optional().describe('Label for this snapshot (e.g., "before-fix", "baseline", "after-deploy")'),
1985
+ project: z15.string().optional().describe("Project name")
1986
+ },
1987
+ async ({ session_id, label, project }) => {
1988
+ const sessionId = session_id ?? collector.getFirstSessionId();
1989
+ if (!sessionId) {
1990
+ return {
1991
+ content: [{ type: "text", text: JSON.stringify({
1992
+ summary: "No active session found. Connect an SDK first.",
1993
+ data: null,
1994
+ issues: ["No active sessions \u2014 connect an SDK with RuntimeScope.init()"],
1995
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1996
+ }, null, 2) }]
1997
+ };
1998
+ }
1999
+ const projectName = project ?? collector.getProjectForSession(sessionId) ?? "default";
2000
+ const snapshot = sessionManager.createSnapshot(sessionId, projectName, label);
2001
+ const response = {
2002
+ summary: `Snapshot captured for session ${sessionId.slice(0, 8)}${label ? ` (label: "${label}")` : ""}. ${snapshot.metrics.totalEvents} events, ${snapshot.metrics.errorCount} errors.`,
2003
+ data: {
2004
+ sessionId: snapshot.sessionId,
2005
+ project: snapshot.project,
2006
+ label: snapshot.label ?? null,
2007
+ createdAt: new Date(snapshot.createdAt).toISOString(),
2008
+ metrics: {
2009
+ totalEvents: snapshot.metrics.totalEvents,
2010
+ errorCount: snapshot.metrics.errorCount,
2011
+ endpointCount: Object.keys(snapshot.metrics.endpoints).length,
2012
+ componentCount: Object.keys(snapshot.metrics.components).length,
2013
+ storeCount: Object.keys(snapshot.metrics.stores).length,
2014
+ webVitalCount: Object.keys(snapshot.metrics.webVitals).length,
2015
+ queryCount: Object.keys(snapshot.metrics.queries).length
2016
+ }
2017
+ },
2018
+ issues: [],
2019
+ metadata: {
2020
+ timeRange: { from: snapshot.metrics.connectedAt, to: snapshot.metrics.disconnectedAt },
2021
+ eventCount: snapshot.metrics.totalEvents,
2022
+ sessionId: snapshot.sessionId
2023
+ }
2024
+ };
2025
+ return {
2026
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2027
+ };
2028
+ }
2029
+ );
2030
+ server.tool(
2031
+ "get_session_snapshots",
2032
+ "List all snapshots for a session. Use with compare_sessions to track how your app changed over time within a single session.",
2033
+ {
2034
+ session_id: z15.string().describe("Session ID"),
2035
+ project: z15.string().optional().describe("Project name")
2036
+ },
2037
+ async ({ session_id, project }) => {
2038
+ const projectName = project ?? collector.getProjectForSession(session_id) ?? "default";
2039
+ const snapshots = sessionManager.getSessionSnapshots(projectName, session_id);
2040
+ const response = {
2041
+ summary: `${snapshots.length} snapshot(s) for session ${session_id.slice(0, 8)}.`,
2042
+ data: snapshots.map((s) => ({
2043
+ id: s.id,
2044
+ sessionId: s.sessionId,
2045
+ label: s.label ?? null,
2046
+ createdAt: new Date(s.createdAt).toISOString(),
2047
+ totalEvents: s.metrics.totalEvents,
2048
+ errorCount: s.metrics.errorCount,
2049
+ endpointCount: Object.keys(s.metrics.endpoints).length,
2050
+ componentCount: Object.keys(s.metrics.components).length
2051
+ })),
2052
+ issues: [],
2053
+ metadata: {
2054
+ timeRange: snapshots.length > 0 ? { from: snapshots[0].createdAt, to: snapshots[snapshots.length - 1].createdAt } : { from: 0, to: 0 },
2055
+ eventCount: snapshots.length,
2056
+ sessionId: session_id
2057
+ }
2058
+ };
2059
+ return {
2060
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
2061
+ };
2062
+ }
2063
+ );
1967
2064
  server.tool(
1968
2065
  "compare_sessions",
1969
- "Compare two sessions: render counts, API latency, errors, Web Vitals, and query performance. Shows regressions and improvements.",
2066
+ "Compare two sessions or two snapshots: render counts, API latency, errors, Web Vitals, and query performance. Shows regressions and improvements. Use snapshot_a/snapshot_b to compare specific snapshots within or across sessions.",
1970
2067
  {
1971
- session_a: z15.string().describe("First session ID (baseline)"),
1972
- session_b: z15.string().describe("Second session ID (comparison)"),
2068
+ session_a: z15.string().optional().describe("First session ID (baseline) \u2014 used when comparing sessions"),
2069
+ session_b: z15.string().optional().describe("Second session ID (comparison) \u2014 used when comparing sessions"),
2070
+ snapshot_a: z15.number().optional().describe("First snapshot ID (baseline) \u2014 used when comparing snapshots"),
2071
+ snapshot_b: z15.number().optional().describe("Second snapshot ID (comparison) \u2014 used when comparing snapshots"),
1973
2072
  project: z15.string().optional().describe("Project name")
1974
2073
  },
1975
- async ({ session_a, session_b, project }) => {
2074
+ async ({ session_a, session_b, snapshot_a, snapshot_b, project }) => {
1976
2075
  const projectName = project ?? "default";
1977
- const history = sessionManager.getSessionHistory(projectName, 100);
1978
- const snapshotA = history.find((s) => s.sessionId === session_a);
1979
- const snapshotB = history.find((s) => s.sessionId === session_b);
1980
- if (!snapshotA || !snapshotB) {
2076
+ let metricsA = null;
2077
+ let metricsB = null;
2078
+ let labelA = "";
2079
+ let labelB = "";
2080
+ if (snapshot_a != null && snapshot_b != null) {
2081
+ const snapA = sessionManager.getSnapshotById(projectName, snapshot_a);
2082
+ const snapB = sessionManager.getSnapshotById(projectName, snapshot_b);
2083
+ if (!snapA || !snapB) {
2084
+ return {
2085
+ content: [{ type: "text", text: JSON.stringify({
2086
+ summary: "Could not find one or both snapshots.",
2087
+ data: null,
2088
+ issues: [
2089
+ !snapA ? `Snapshot ${snapshot_a} not found` : null,
2090
+ !snapB ? `Snapshot ${snapshot_b} not found` : null
2091
+ ].filter(Boolean),
2092
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
2093
+ }, null, 2) }]
2094
+ };
2095
+ }
2096
+ metricsA = snapA.metrics;
2097
+ metricsB = snapB.metrics;
2098
+ labelA = snapA.label ? ` (${snapA.label})` : ` (snapshot #${snapshot_a})`;
2099
+ labelB = snapB.label ? ` (${snapB.label})` : ` (snapshot #${snapshot_b})`;
2100
+ } else if (session_a && session_b) {
2101
+ const history = sessionManager.getSessionHistory(projectName, 100);
2102
+ const snapshotA = history.find((s) => s.sessionId === session_a);
2103
+ const snapshotB = history.find((s) => s.sessionId === session_b);
2104
+ if (!snapshotA || !snapshotB) {
2105
+ return {
2106
+ content: [{ type: "text", text: JSON.stringify({
2107
+ summary: "Could not find one or both sessions in history.",
2108
+ data: null,
2109
+ issues: [
2110
+ !snapshotA ? `Session ${session_a} not found` : null,
2111
+ !snapshotB ? `Session ${session_b} not found` : null
2112
+ ].filter(Boolean),
2113
+ metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
2114
+ }, null, 2) }]
2115
+ };
2116
+ }
2117
+ metricsA = snapshotA.metrics;
2118
+ metricsB = snapshotB.metrics;
2119
+ labelA = ` (session ${session_a.slice(0, 8)})`;
2120
+ labelB = ` (session ${session_b.slice(0, 8)})`;
2121
+ } else {
1981
2122
  return {
1982
2123
  content: [{ type: "text", text: JSON.stringify({
1983
- summary: "Could not find one or both sessions in history.",
2124
+ summary: "Provide either session_a + session_b or snapshot_a + snapshot_b.",
1984
2125
  data: null,
1985
- issues: [
1986
- !snapshotA ? `Session ${session_a} not found` : null,
1987
- !snapshotB ? `Session ${session_b} not found` : null
1988
- ].filter(Boolean),
2126
+ issues: ["Must provide either two session IDs or two snapshot IDs to compare."],
1989
2127
  metadata: { timeRange: { from: 0, to: 0 }, eventCount: 0, sessionId: null }
1990
2128
  }, null, 2) }]
1991
2129
  };
1992
2130
  }
1993
- const diff = compareSessions(snapshotA.metrics, snapshotB.metrics);
2131
+ const diff = compareSessions(metricsA, metricsB);
1994
2132
  const regressions = [
1995
2133
  ...diff.endpointDeltas.filter((d) => d.classification === "regression"),
1996
2134
  ...diff.componentDeltas.filter((d) => d.classification === "regression"),
@@ -2004,7 +2142,7 @@ function registerSessionDiffTools(server, sessionManager) {
2004
2142
  ...diff.queryDeltas.filter((d) => d.classification === "improvement")
2005
2143
  ];
2006
2144
  const response = {
2007
- summary: `Session comparison: ${regressions.length} regression(s), ${improvements.length} improvement(s). Error delta: ${diff.overallDelta.errorCountDelta >= 0 ? "+" : ""}${diff.overallDelta.errorCountDelta}.`,
2145
+ summary: `Comparison${labelA} vs${labelB}: ${regressions.length} regression(s), ${improvements.length} improvement(s). Error delta: ${diff.overallDelta.errorCountDelta >= 0 ? "+" : ""}${diff.overallDelta.errorCountDelta}.`,
2008
2146
  data: {
2009
2147
  endpointDeltas: diff.endpointDeltas.map((d) => ({
2010
2148
  ...d,
@@ -2030,7 +2168,7 @@ function registerSessionDiffTools(server, sessionManager) {
2030
2168
  },
2031
2169
  issues: regressions.map((r) => `Regression: ${r.key} (${(r.percentChange * 100).toFixed(1)}% worse)`),
2032
2170
  metadata: {
2033
- timeRange: { from: snapshotA.createdAt, to: snapshotB.createdAt },
2171
+ timeRange: { from: metricsA.connectedAt, to: metricsB.disconnectedAt },
2034
2172
  eventCount: 2,
2035
2173
  sessionId: null
2036
2174
  }
@@ -4353,8 +4491,241 @@ RuntimeScope.init({
4353
4491
  );
4354
4492
  }
4355
4493
 
4356
- // src/tools/history.ts
4494
+ // src/tools/custom-events.ts
4357
4495
  import { z as z26 } from "zod";
4496
+ function registerCustomEventTools(server, store) {
4497
+ server.tool(
4498
+ "get_custom_events",
4499
+ "Get custom business/product events tracked via RuntimeScope.track(). Shows event catalog (all unique event names with counts) and recent occurrences. Use this to see what events are being tracked and their frequency.",
4500
+ {
4501
+ name: z26.string().optional().describe("Filter by event name (exact match)"),
4502
+ since_seconds: z26.number().optional().describe("Only events from the last N seconds (default: 300)"),
4503
+ session_id: z26.string().optional().describe("Filter by session ID")
4504
+ },
4505
+ async ({ name, since_seconds, session_id }) => {
4506
+ const sinceSeconds = since_seconds ?? 300;
4507
+ const events = store.getCustomEvents({
4508
+ name,
4509
+ sinceSeconds,
4510
+ sessionId: session_id
4511
+ });
4512
+ const catalog = {};
4513
+ for (const e of events) {
4514
+ if (!catalog[e.name]) {
4515
+ catalog[e.name] = { count: 0, lastSeen: 0, sampleProperties: void 0 };
4516
+ }
4517
+ catalog[e.name].count++;
4518
+ if (e.timestamp > catalog[e.name].lastSeen) {
4519
+ catalog[e.name].lastSeen = e.timestamp;
4520
+ catalog[e.name].sampleProperties = e.properties;
4521
+ }
4522
+ }
4523
+ const catalogList = Object.entries(catalog).sort((a, b) => b[1].lastSeen - a[1].lastSeen).map(([eventName, info]) => ({
4524
+ name: eventName,
4525
+ count: info.count,
4526
+ lastSeen: new Date(info.lastSeen).toISOString(),
4527
+ sampleProperties: info.sampleProperties
4528
+ }));
4529
+ const sessions = store.getSessionInfo();
4530
+ const sessionId = session_id ?? sessions[0]?.sessionId ?? null;
4531
+ const response = {
4532
+ summary: `${events.length} custom event(s) across ${catalogList.length} unique event name(s) in the last ${sinceSeconds}s.${name ? ` Filtered by: "${name}".` : ""}`,
4533
+ data: {
4534
+ catalog: catalogList,
4535
+ recentEvents: events.slice(0, 100).map((e) => ({
4536
+ name: e.name,
4537
+ timestamp: new Date(e.timestamp).toISOString(),
4538
+ properties: e.properties,
4539
+ sessionId: e.sessionId
4540
+ }))
4541
+ },
4542
+ issues: [],
4543
+ metadata: {
4544
+ timeRange: {
4545
+ from: events.length > 0 ? events[events.length - 1].timestamp : 0,
4546
+ to: events.length > 0 ? events[0].timestamp : 0
4547
+ },
4548
+ eventCount: events.length,
4549
+ sessionId
4550
+ }
4551
+ };
4552
+ return {
4553
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4554
+ };
4555
+ }
4556
+ );
4557
+ server.tool(
4558
+ "get_event_flow",
4559
+ "Analyze a user flow as a funnel. Given an ordered list of custom event names (steps), shows how many sessions completed each step, where drop-offs happen, and what errors/failures occurred between steps. Each step includes correlated telemetry (network errors, console errors, failed DB queries) that happened between the previous step and this one \u2014 this is the key to finding WHY a step failed.",
4560
+ {
4561
+ steps: z26.array(z26.string()).min(2).describe('Ordered list of custom event names representing the flow (e.g. ["create_profile", "generate_campaign", "export_ad"])'),
4562
+ since_seconds: z26.number().optional().describe("Only analyze events from the last N seconds (default: 3600)"),
4563
+ session_id: z26.string().optional().describe("Analyze a specific session (default: all sessions)")
4564
+ },
4565
+ async ({ steps, since_seconds, session_id }) => {
4566
+ const sinceSeconds = since_seconds ?? 3600;
4567
+ const allCustom = store.getCustomEvents({ sinceSeconds, sessionId: session_id });
4568
+ const bySession = /* @__PURE__ */ new Map();
4569
+ for (const e of allCustom) {
4570
+ if (!bySession.has(e.sessionId)) bySession.set(e.sessionId, []);
4571
+ bySession.get(e.sessionId).push(e);
4572
+ }
4573
+ for (const events of bySession.values()) {
4574
+ events.sort((a, b) => a.timestamp - b.timestamp);
4575
+ }
4576
+ const networkErrors = store.getNetworkRequests({ sinceSeconds, sessionId: session_id }).filter((e) => e.status >= 400 || e.errorPhase);
4577
+ const consoleErrors = store.getConsoleMessages({ sinceSeconds, sessionId: session_id }).filter((e) => e.level === "error");
4578
+ const dbErrors = store.getDatabaseEvents({ sinceSeconds, sessionId: session_id }).filter((e) => !!e.error);
4579
+ const stepResults = steps.map(() => ({
4580
+ step: "",
4581
+ sessionsReached: 0,
4582
+ sessionsCompleted: 0,
4583
+ avgTimeFromPrevMs: null,
4584
+ correlatedErrors: { networkErrors: [], consoleErrors: [], dbErrors: [] }
4585
+ }));
4586
+ const totalSessions = bySession.size;
4587
+ const completedFlows = [];
4588
+ for (const [sessionId, sessionEvents] of bySession) {
4589
+ let prevStepTime = null;
4590
+ let completedAll = true;
4591
+ for (let i = 0; i < steps.length; i++) {
4592
+ const stepName = steps[i];
4593
+ stepResults[i].step = stepName;
4594
+ const occurrence = sessionEvents.find(
4595
+ (e) => e.name === stepName && (prevStepTime === null || e.timestamp >= prevStepTime)
4596
+ );
4597
+ if (!occurrence) {
4598
+ completedAll = false;
4599
+ if (prevStepTime !== null) {
4600
+ const gapEnd = sessionEvents[sessionEvents.length - 1]?.timestamp ?? Date.now();
4601
+ collectCorrelatedErrors(stepResults[i], sessionId, prevStepTime, gapEnd, networkErrors, consoleErrors, dbErrors);
4602
+ }
4603
+ break;
4604
+ }
4605
+ stepResults[i].sessionsReached++;
4606
+ if (prevStepTime !== null) {
4607
+ const delta = occurrence.timestamp - prevStepTime;
4608
+ if (stepResults[i].avgTimeFromPrevMs === null) {
4609
+ stepResults[i].avgTimeFromPrevMs = delta;
4610
+ } else {
4611
+ stepResults[i].avgTimeFromPrevMs = (stepResults[i].avgTimeFromPrevMs * (stepResults[i].sessionsReached - 1) + delta) / stepResults[i].sessionsReached;
4612
+ }
4613
+ collectCorrelatedErrors(stepResults[i], sessionId, prevStepTime, occurrence.timestamp, networkErrors, consoleErrors, dbErrors);
4614
+ } else {
4615
+ stepResults[i].sessionsReached++;
4616
+ }
4617
+ stepResults[i].sessionsCompleted++;
4618
+ prevStepTime = occurrence.timestamp;
4619
+ }
4620
+ if (completedAll && prevStepTime !== null) {
4621
+ const firstStep = sessionEvents.find((e) => e.name === steps[0]);
4622
+ if (firstStep) {
4623
+ completedFlows.push({ sessionId, totalDurationMs: prevStepTime - firstStep.timestamp });
4624
+ }
4625
+ }
4626
+ }
4627
+ for (let i = 0; i < stepResults.length; i++) {
4628
+ stepResults[i].step = steps[i];
4629
+ }
4630
+ for (const step of stepResults) {
4631
+ step.correlatedErrors.networkErrors = dedup(step.correlatedErrors.networkErrors, 5);
4632
+ step.correlatedErrors.consoleErrors = dedup(step.correlatedErrors.consoleErrors, 5);
4633
+ step.correlatedErrors.dbErrors = dedup(step.correlatedErrors.dbErrors, 5);
4634
+ }
4635
+ const funnelSteps = stepResults.map((s, i) => ({
4636
+ step: s.step,
4637
+ reached: s.sessionsCompleted,
4638
+ conversionRate: i === 0 ? totalSessions > 0 ? `${(s.sessionsCompleted / totalSessions * 100).toFixed(1)}%` : "0%" : stepResults[i - 1].sessionsCompleted > 0 ? `${(s.sessionsCompleted / stepResults[i - 1].sessionsCompleted * 100).toFixed(1)}%` : "0%",
4639
+ avgTimeFromPrev: s.avgTimeFromPrevMs !== null ? `${Math.round(s.avgTimeFromPrevMs)}ms` : null,
4640
+ errorsBetweenSteps: {
4641
+ network: s.correlatedErrors.networkErrors.length,
4642
+ console: s.correlatedErrors.consoleErrors.length,
4643
+ database: s.correlatedErrors.dbErrors.length
4644
+ },
4645
+ correlatedErrors: s.correlatedErrors
4646
+ }));
4647
+ const avgCompletionTime = completedFlows.length > 0 ? Math.round(completedFlows.reduce((sum, f) => sum + f.totalDurationMs, 0) / completedFlows.length) : null;
4648
+ const issues = [];
4649
+ for (let i = 1; i < funnelSteps.length; i++) {
4650
+ const prev = funnelSteps[i - 1].reached;
4651
+ const curr = funnelSteps[i].reached;
4652
+ if (prev > 0 && curr / prev < 0.5) {
4653
+ issues.push(`Major drop-off at "${steps[i]}": only ${(curr / prev * 100).toFixed(0)}% conversion from "${steps[i - 1]}"`);
4654
+ }
4655
+ const totalErrors = funnelSteps[i].errorsBetweenSteps.network + funnelSteps[i].errorsBetweenSteps.console + funnelSteps[i].errorsBetweenSteps.database;
4656
+ if (totalErrors > 0) {
4657
+ issues.push(`${totalErrors} error(s) detected between "${steps[i - 1]}" and "${steps[i]}"`);
4658
+ }
4659
+ }
4660
+ const response = {
4661
+ summary: `Flow analysis: ${steps.length} steps, ${totalSessions} session(s), ${completedFlows.length} completed the full flow.${avgCompletionTime ? ` Avg completion: ${avgCompletionTime}ms.` : ""}`,
4662
+ data: {
4663
+ totalSessions,
4664
+ completedFlows: completedFlows.length,
4665
+ avgCompletionTimeMs: avgCompletionTime,
4666
+ funnel: funnelSteps
4667
+ },
4668
+ issues,
4669
+ metadata: {
4670
+ timeRange: {
4671
+ from: allCustom.length > 0 ? allCustom[allCustom.length - 1].timestamp : 0,
4672
+ to: allCustom.length > 0 ? allCustom[0].timestamp : 0
4673
+ },
4674
+ eventCount: allCustom.length,
4675
+ sessionId: session_id ?? null
4676
+ }
4677
+ };
4678
+ return {
4679
+ content: [{ type: "text", text: JSON.stringify(response, null, 2) }]
4680
+ };
4681
+ }
4682
+ );
4683
+ }
4684
+ function collectCorrelatedErrors(result, sessionId, fromTs, toTs, networkErrors, consoleErrors, dbErrors) {
4685
+ for (const e of networkErrors) {
4686
+ if (e.sessionId === sessionId && e.timestamp >= fromTs && e.timestamp <= toTs) {
4687
+ result.correlatedErrors.networkErrors.push({
4688
+ url: e.url,
4689
+ status: e.status,
4690
+ method: e.method,
4691
+ timestamp: new Date(e.timestamp).toISOString()
4692
+ });
4693
+ }
4694
+ }
4695
+ for (const e of consoleErrors) {
4696
+ if (e.sessionId === sessionId && e.timestamp >= fromTs && e.timestamp <= toTs) {
4697
+ result.correlatedErrors.consoleErrors.push({
4698
+ message: e.message.length > 200 ? e.message.slice(0, 200) + "..." : e.message,
4699
+ timestamp: new Date(e.timestamp).toISOString()
4700
+ });
4701
+ }
4702
+ }
4703
+ for (const e of dbErrors) {
4704
+ if (e.sessionId === sessionId && e.timestamp >= fromTs && e.timestamp <= toTs) {
4705
+ result.correlatedErrors.dbErrors.push({
4706
+ query: e.query.length > 150 ? e.query.slice(0, 150) + "..." : e.query,
4707
+ error: e.error,
4708
+ timestamp: new Date(e.timestamp).toISOString()
4709
+ });
4710
+ }
4711
+ }
4712
+ }
4713
+ function dedup(arr, limit) {
4714
+ const seen = /* @__PURE__ */ new Set();
4715
+ const result = [];
4716
+ for (const item of arr) {
4717
+ const key = JSON.stringify(item);
4718
+ if (!seen.has(key)) {
4719
+ seen.add(key);
4720
+ result.push(item);
4721
+ if (result.length >= limit) break;
4722
+ }
4723
+ }
4724
+ return result;
4725
+ }
4726
+
4727
+ // src/tools/history.ts
4728
+ import { z as z27 } from "zod";
4358
4729
  var EVENT_TYPES = [
4359
4730
  "network",
4360
4731
  "console",
@@ -4393,13 +4764,13 @@ function registerHistoryTools(server, collector, projectManager) {
4393
4764
  "get_historical_events",
4394
4765
  "Query past events from persistent SQLite storage. Use this to access events beyond the in-memory buffer (last 10K events). Events persist across Claude Code restarts. Filter by project, event type, time range, and session.",
4395
4766
  {
4396
- project: z26.string().describe("Project/app name (the appName used in SDK init)"),
4397
- event_types: z26.array(z26.enum(EVENT_TYPES)).optional().describe('Filter by event types (e.g., ["network", "console"])'),
4398
- since: z26.string().optional().describe('Start time \u2014 relative ("2h", "7d", "30m") or ISO date string'),
4399
- until: z26.string().optional().describe("End time \u2014 relative or ISO date string"),
4400
- session_id: z26.string().optional().describe("Filter by specific session ID"),
4401
- limit: z26.number().optional().default(200).describe("Max events to return (default 200, max 1000)"),
4402
- offset: z26.number().optional().default(0).describe("Pagination offset")
4767
+ project: z27.string().describe("Project/app name (the appName used in SDK init)"),
4768
+ event_types: z27.array(z27.enum(EVENT_TYPES)).optional().describe('Filter by event types (e.g., ["network", "console"])'),
4769
+ since: z27.string().optional().describe('Start time \u2014 relative ("2h", "7d", "30m") or ISO date string'),
4770
+ until: z27.string().optional().describe("End time \u2014 relative or ISO date string"),
4771
+ session_id: z27.string().optional().describe("Filter by specific session ID"),
4772
+ limit: z27.number().optional().default(200).describe("Max events to return (default 200, max 1000)"),
4773
+ offset: z27.number().optional().default(0).describe("Pagination offset")
4403
4774
  },
4404
4775
  async ({ project, event_types, since, until, session_id, limit, offset }) => {
4405
4776
  const sqliteStore = collector.getSqliteStore(project);
@@ -4588,36 +4959,74 @@ async function main() {
4588
4959
  const sessionManager = new SessionManager(projectManager, sqliteStores, store);
4589
4960
  collector.onDisconnect((sessionId, projectName) => {
4590
4961
  try {
4591
- sessionManager.createSnapshot(sessionId, projectName);
4962
+ sessionManager.createSnapshot(sessionId, projectName, "auto-disconnect");
4592
4963
  console.error(`[RuntimeScope] Session ${sessionId} metrics saved to SQLite`);
4593
4964
  } catch {
4594
4965
  }
4595
4966
  });
4596
- const RETENTION_DAYS = parseInt(process.env.RUNTIMESCOPE_RETENTION_DAYS ?? "30", 10);
4597
- const cutoffMs = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1e3;
4598
- for (const projectName of projectManager.listProjects()) {
4599
- const dbPath = projectManager.getProjectDbPath(projectName);
4600
- if (existsSync2(dbPath)) {
4967
+ const AUTO_SNAPSHOT_INTERVAL_MS = 5 * 60 * 1e3;
4968
+ let autoSnapshotCount = 0;
4969
+ const autoSnapshotTimer = setInterval(() => {
4970
+ const sessions = collector.getConnectedSessions();
4971
+ if (sessions.length === 0) return;
4972
+ autoSnapshotCount++;
4973
+ const minutes = autoSnapshotCount * 5;
4974
+ for (const { sessionId, projectName } of sessions) {
4601
4975
  try {
4602
- const tempStore = new SqliteStore({ dbPath });
4603
- const deleted = tempStore.deleteOldEvents(cutoffMs);
4604
- if (deleted > 0) {
4605
- console.error(`[RuntimeScope] Pruned ${deleted} events older than ${RETENTION_DAYS}d from "${projectName}"`);
4606
- }
4607
- tempStore.close();
4976
+ sessionManager.createSnapshot(sessionId, projectName, `auto-${minutes}m`);
4608
4977
  } catch {
4609
4978
  }
4610
4979
  }
4980
+ }, AUTO_SNAPSHOT_INTERVAL_MS);
4981
+ if (isSqliteAvailable()) {
4982
+ const RETENTION_DAYS = parseInt(process.env.RUNTIMESCOPE_RETENTION_DAYS ?? "30", 10);
4983
+ const cutoffMs = Date.now() - RETENTION_DAYS * 24 * 60 * 60 * 1e3;
4984
+ for (const projectName of projectManager.listProjects()) {
4985
+ const dbPath = projectManager.getProjectDbPath(projectName);
4986
+ if (existsSync2(dbPath)) {
4987
+ try {
4988
+ const tempStore = new SqliteStore({ dbPath });
4989
+ const deleted = tempStore.deleteOldEvents(cutoffMs);
4990
+ if (deleted > 0) {
4991
+ console.error(`[RuntimeScope] Pruned ${deleted} events older than ${RETENTION_DAYS}d from "${projectName}"`);
4992
+ }
4993
+ tempStore.close();
4994
+ } catch {
4995
+ }
4996
+ }
4997
+ }
4998
+ }
4999
+ let pmStore;
5000
+ let discovery;
5001
+ if (isSqliteAvailable()) {
5002
+ const pmDbPath = join2(projectManager.rootDir, "pm.db");
5003
+ pmStore = new PmStore({ dbPath: pmDbPath });
5004
+ discovery = new ProjectDiscovery(pmStore, projectManager);
5005
+ discovery.discoverAll().then((result) => {
5006
+ console.error(`[RuntimeScope] PM: ${result.projectsDiscovered} projects, ${result.sessionsDiscovered} sessions discovered`);
5007
+ }).catch((err) => {
5008
+ console.error("[RuntimeScope] PM discovery error:", err.message);
5009
+ });
4611
5010
  }
4612
5011
  const httpServer = new HttpServer(store, processMonitor, {
4613
5012
  authManager,
4614
- allowedOrigins: corsOrigins
5013
+ allowedOrigins: corsOrigins,
5014
+ rateLimiter: collector.getRateLimiter(),
5015
+ pmStore,
5016
+ discovery,
5017
+ getConnectedSessions: () => collector.getConnectedSessions()
4615
5018
  });
4616
5019
  try {
4617
5020
  await httpServer.start({ port: HTTP_PORT2, tls: tlsConfig });
4618
5021
  } catch (err) {
4619
5022
  console.error("[RuntimeScope] HTTP API failed to start:", err.message);
4620
5023
  }
5024
+ collector.onConnect((sessionId, projectName) => {
5025
+ httpServer.broadcastSessionChange("session_connected", sessionId, projectName);
5026
+ });
5027
+ collector.onDisconnect((sessionId, projectName) => {
5028
+ httpServer.broadcastSessionChange("session_disconnected", sessionId, projectName);
5029
+ });
4621
5030
  const scanner = new PlaywrightScanner();
4622
5031
  const mcp = new McpServer({
4623
5032
  name: "runtimescope",
@@ -4638,7 +5047,7 @@ async function main() {
4638
5047
  registerDatabaseTools(mcp, store, connectionManager, schemaIntrospector, dataBrowser);
4639
5048
  registerProcessMonitorTools(mcp, processMonitor);
4640
5049
  registerInfraTools(mcp, infraConnector);
4641
- registerSessionDiffTools(mcp, sessionManager);
5050
+ registerSessionDiffTools(mcp, sessionManager, collector);
4642
5051
  registerReconMetadataTools(mcp, store, collector);
4643
5052
  registerReconDesignTokenTools(mcp, store, collector);
4644
5053
  registerReconFontTools(mcp, store);
@@ -4649,6 +5058,7 @@ async function main() {
4649
5058
  registerReconAssetTools(mcp, store);
4650
5059
  registerReconStyleDiffTools(mcp, store);
4651
5060
  registerScannerTools(mcp, store, scanner);
5061
+ registerCustomEventTools(mcp, store);
4652
5062
  registerHistoryTools(mcp, collector, projectManager);
4653
5063
  const transport = new StdioServerTransport();
4654
5064
  await mcp.connect(transport);
@@ -4660,11 +5070,13 @@ async function main() {
4660
5070
  const shutdown = async () => {
4661
5071
  if (shuttingDown) return;
4662
5072
  shuttingDown = true;
5073
+ clearInterval(autoSnapshotTimer);
4663
5074
  processMonitor.stop();
4664
5075
  await scanner.shutdown();
4665
5076
  await connectionManager.closeAll();
4666
5077
  await httpServer.stop();
4667
5078
  collector.stop();
5079
+ pmStore?.close();
4668
5080
  process.exit(0);
4669
5081
  };
4670
5082
  process.on("SIGINT", () => {