@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 +454 -42
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
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(
|
|
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
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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: "
|
|
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(
|
|
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: `
|
|
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:
|
|
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/
|
|
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:
|
|
4397
|
-
event_types:
|
|
4398
|
-
since:
|
|
4399
|
-
until:
|
|
4400
|
-
session_id:
|
|
4401
|
-
limit:
|
|
4402
|
-
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
|
|
4597
|
-
|
|
4598
|
-
|
|
4599
|
-
const
|
|
4600
|
-
if (
|
|
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
|
-
|
|
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", () => {
|