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