@fragments-sdk/cli 0.7.15 → 0.7.17
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/bin.js +2 -2
- package/dist/init-DIZ6UNBL.js +806 -0
- package/dist/init-DIZ6UNBL.js.map +1 -0
- package/dist/{viewer-7I4WGVU3.js → viewer-QKIAPTPG.js} +67 -4
- package/dist/viewer-QKIAPTPG.js.map +1 -0
- package/package.json +3 -2
- package/src/commands/init-framework.ts +414 -0
- package/src/commands/init.ts +41 -1
- package/src/theme/__tests__/component-contrast.test.ts +210 -157
- package/src/viewer/components/App.tsx +5 -0
- package/src/viewer/components/HealthDashboard.tsx +1 -1
- package/src/viewer/components/PropsTable.tsx +2 -2
- package/src/viewer/components/RuntimeToolsRegistrar.tsx +17 -0
- package/src/viewer/components/ViewerStateSync.tsx +52 -0
- package/src/viewer/components/WebMCPDevTools.tsx +509 -0
- package/src/viewer/components/WebMCPIntegration.tsx +47 -0
- package/src/viewer/components/WebMCPStatusIndicator.tsx +60 -0
- package/src/viewer/entry.tsx +6 -3
- package/src/viewer/hooks/useA11yService.ts +1 -135
- package/src/viewer/hooks/useCompiledFragments.ts +42 -0
- package/src/viewer/server.ts +58 -3
- package/src/viewer/vite-plugin.ts +18 -0
- package/src/viewer/webmcp/__tests__/analytics.test.ts +108 -0
- package/src/viewer/webmcp/analytics.ts +165 -0
- package/src/viewer/webmcp/index.ts +3 -0
- package/src/viewer/webmcp/posthog-bridge.ts +39 -0
- package/src/viewer/webmcp/runtime-tools.ts +152 -0
- package/src/viewer/webmcp/scan-utils.ts +135 -0
- package/src/viewer/webmcp/use-tool-analytics.ts +69 -0
- package/src/viewer/webmcp/viewer-state.ts +45 -0
- package/dist/init-V42FFMUJ.js +0 -498
- package/dist/init-V42FFMUJ.js.map +0 -1
- package/dist/viewer-7I4WGVU3.js.map +0 -1
|
@@ -8,10 +8,8 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
11
|
-
import type { Result } from 'axe-core';
|
|
12
11
|
import type {
|
|
13
12
|
A11yServiceConfig,
|
|
14
|
-
SerializedViolation,
|
|
15
13
|
ComponentScanState,
|
|
16
14
|
ScanStatus,
|
|
17
15
|
} from '../types/a11y.js';
|
|
@@ -22,6 +20,7 @@ import {
|
|
|
22
20
|
isComponentStale,
|
|
23
21
|
getA11ySummary,
|
|
24
22
|
} from './useA11yCache.js';
|
|
23
|
+
import { runAxeScan, type ScanResult } from '../webmcp/scan-utils.js';
|
|
25
24
|
|
|
26
25
|
// Default configuration
|
|
27
26
|
const DEFAULT_CONFIG: A11yServiceConfig = {
|
|
@@ -54,139 +53,6 @@ const serviceState: A11yServiceState = {
|
|
|
54
53
|
componentStates: new Map(),
|
|
55
54
|
};
|
|
56
55
|
|
|
57
|
-
// Cache the axe-core module
|
|
58
|
-
let axeModule: typeof import('axe-core') | null = null;
|
|
59
|
-
|
|
60
|
-
export interface ScanResult {
|
|
61
|
-
violations: SerializedViolation[];
|
|
62
|
-
passes: number;
|
|
63
|
-
incomplete: number;
|
|
64
|
-
counts: {
|
|
65
|
-
critical: number;
|
|
66
|
-
serious: number;
|
|
67
|
-
moderate: number;
|
|
68
|
-
minor: number;
|
|
69
|
-
};
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Convert axe-core Result to SerializedViolation
|
|
74
|
-
*/
|
|
75
|
-
function serializeViolation(result: Result): SerializedViolation {
|
|
76
|
-
return {
|
|
77
|
-
id: result.id,
|
|
78
|
-
impact: result.impact || null,
|
|
79
|
-
description: result.description,
|
|
80
|
-
help: result.help,
|
|
81
|
-
helpUrl: result.helpUrl,
|
|
82
|
-
tags: result.tags,
|
|
83
|
-
nodes: result.nodes.map(node => ({
|
|
84
|
-
html: node.html,
|
|
85
|
-
target: node.target as string[],
|
|
86
|
-
failureSummary: node.failureSummary,
|
|
87
|
-
any: node.any?.map(check => ({
|
|
88
|
-
id: check.id,
|
|
89
|
-
data: check.data,
|
|
90
|
-
relatedNodes: check.relatedNodes?.map(rn => ({
|
|
91
|
-
html: rn.html,
|
|
92
|
-
target: rn.target as string[],
|
|
93
|
-
})),
|
|
94
|
-
impact: check.impact,
|
|
95
|
-
message: check.message,
|
|
96
|
-
})),
|
|
97
|
-
all: node.all?.map(check => ({
|
|
98
|
-
id: check.id,
|
|
99
|
-
data: check.data,
|
|
100
|
-
relatedNodes: check.relatedNodes?.map(rn => ({
|
|
101
|
-
html: rn.html,
|
|
102
|
-
target: rn.target as string[],
|
|
103
|
-
})),
|
|
104
|
-
impact: check.impact,
|
|
105
|
-
message: check.message,
|
|
106
|
-
})),
|
|
107
|
-
none: node.none?.map(check => ({
|
|
108
|
-
id: check.id,
|
|
109
|
-
data: check.data,
|
|
110
|
-
relatedNodes: check.relatedNodes?.map(rn => ({
|
|
111
|
-
html: rn.html,
|
|
112
|
-
target: rn.target as string[],
|
|
113
|
-
})),
|
|
114
|
-
impact: check.impact,
|
|
115
|
-
message: check.message,
|
|
116
|
-
})),
|
|
117
|
-
})),
|
|
118
|
-
};
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* Run axe-core scan on a target element
|
|
123
|
-
*/
|
|
124
|
-
async function runAxeScan(targetSelector: string): Promise<ScanResult | null> {
|
|
125
|
-
// Load axe-core if not cached
|
|
126
|
-
if (!axeModule) {
|
|
127
|
-
axeModule = await import('axe-core');
|
|
128
|
-
}
|
|
129
|
-
// Handle both ESM default export and CommonJS module
|
|
130
|
-
const axe = (axeModule as { default?: typeof import('axe-core') }).default || axeModule;
|
|
131
|
-
|
|
132
|
-
const target = document.querySelector(targetSelector);
|
|
133
|
-
if (!target) {
|
|
134
|
-
console.warn(`[A11y] Target element not found: ${targetSelector}`);
|
|
135
|
-
return null;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Configure axe-core
|
|
139
|
-
axe.configure({
|
|
140
|
-
rules: [
|
|
141
|
-
{ id: 'color-contrast', enabled: true },
|
|
142
|
-
{ id: 'image-alt', enabled: true },
|
|
143
|
-
{ id: 'button-name', enabled: true },
|
|
144
|
-
{ id: 'link-name', enabled: true },
|
|
145
|
-
{ id: 'label', enabled: true },
|
|
146
|
-
{ id: 'aria-valid-attr', enabled: true },
|
|
147
|
-
{ id: 'aria-valid-attr-value', enabled: true },
|
|
148
|
-
],
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
// Run the scan
|
|
152
|
-
const results = await axe.run(target as HTMLElement, {
|
|
153
|
-
resultTypes: ['violations', 'passes', 'incomplete', 'inapplicable'],
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
// Count violations by severity
|
|
157
|
-
const counts = {
|
|
158
|
-
critical: 0,
|
|
159
|
-
serious: 0,
|
|
160
|
-
moderate: 0,
|
|
161
|
-
minor: 0,
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
for (const violation of results.violations) {
|
|
165
|
-
switch (violation.impact) {
|
|
166
|
-
case 'critical':
|
|
167
|
-
counts.critical++;
|
|
168
|
-
break;
|
|
169
|
-
case 'serious':
|
|
170
|
-
counts.serious++;
|
|
171
|
-
break;
|
|
172
|
-
case 'moderate':
|
|
173
|
-
counts.moderate++;
|
|
174
|
-
break;
|
|
175
|
-
case 'minor':
|
|
176
|
-
default:
|
|
177
|
-
counts.minor++;
|
|
178
|
-
break;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
return {
|
|
183
|
-
violations: results.violations.map(serializeViolation),
|
|
184
|
-
passes: results.passes.length,
|
|
185
|
-
incomplete: results.incomplete.length,
|
|
186
|
-
counts,
|
|
187
|
-
};
|
|
188
|
-
}
|
|
189
|
-
|
|
190
56
|
/**
|
|
191
57
|
* Process the scan queue
|
|
192
58
|
*/
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useState, useEffect } from "react";
|
|
2
|
+
import type { CompiledFragmentsFile } from "@fragments-sdk/context/types";
|
|
3
|
+
|
|
4
|
+
interface UseCompiledFragmentsResult {
|
|
5
|
+
data: CompiledFragmentsFile | null;
|
|
6
|
+
loading: boolean;
|
|
7
|
+
error: string | null;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function useCompiledFragments(): UseCompiledFragmentsResult {
|
|
11
|
+
const [data, setData] = useState<CompiledFragmentsFile | null>(null);
|
|
12
|
+
const [loading, setLoading] = useState(true);
|
|
13
|
+
const [error, setError] = useState<string | null>(null);
|
|
14
|
+
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
let cancelled = false;
|
|
17
|
+
|
|
18
|
+
fetch("/fragments/compiled.json")
|
|
19
|
+
.then((res) => {
|
|
20
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
21
|
+
return res.json();
|
|
22
|
+
})
|
|
23
|
+
.then((json) => {
|
|
24
|
+
if (!cancelled) {
|
|
25
|
+
setData(json as CompiledFragmentsFile);
|
|
26
|
+
setLoading(false);
|
|
27
|
+
}
|
|
28
|
+
})
|
|
29
|
+
.catch((err) => {
|
|
30
|
+
if (!cancelled) {
|
|
31
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
32
|
+
setLoading(false);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
return () => {
|
|
37
|
+
cancelled = true;
|
|
38
|
+
};
|
|
39
|
+
}, []);
|
|
40
|
+
|
|
41
|
+
return { data, loading, error };
|
|
42
|
+
}
|
package/src/viewer/server.ts
CHANGED
|
@@ -32,6 +32,8 @@ const packagesRoot = resolve(cliPackageRoot, "..");
|
|
|
32
32
|
const localUiLibRoot = resolve(packagesRoot, "../libs/ui/src");
|
|
33
33
|
const localSharedLibRoot = resolve(packagesRoot, "../libs/shared/src");
|
|
34
34
|
const vendoredSharedLibRoot = resolve(viewerRoot, "vendor/shared/src");
|
|
35
|
+
const localWebMCPRoot = resolve(packagesRoot, "webmcp/src");
|
|
36
|
+
const localContextRoot = resolve(packagesRoot, "context/src");
|
|
35
37
|
|
|
36
38
|
/**
|
|
37
39
|
* Resolve the @fragments/ui alias to the correct path.
|
|
@@ -57,6 +59,44 @@ function resolveUiLib(nodeModulesDir: string): string {
|
|
|
57
59
|
return localUiLibRoot;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Resolve @fragments-sdk/webmcp to monorepo source or installed package.
|
|
64
|
+
*/
|
|
65
|
+
function resolveWebMCPLib(nodeModulesDir: string): string {
|
|
66
|
+
const localIndex = join(localWebMCPRoot, "index.ts");
|
|
67
|
+
if (existsSync(localIndex)) {
|
|
68
|
+
return localWebMCPRoot;
|
|
69
|
+
}
|
|
70
|
+
const installedSrc = join(nodeModulesDir, "@fragments-sdk/webmcp/src/index.ts");
|
|
71
|
+
if (existsSync(installedSrc)) {
|
|
72
|
+
return resolve(dirname(installedSrc));
|
|
73
|
+
}
|
|
74
|
+
const installedDist = join(nodeModulesDir, "@fragments-sdk/webmcp");
|
|
75
|
+
if (existsSync(installedDist)) {
|
|
76
|
+
return installedDist;
|
|
77
|
+
}
|
|
78
|
+
return localWebMCPRoot;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Resolve @fragments-sdk/context to monorepo source or installed package.
|
|
83
|
+
*/
|
|
84
|
+
function resolveContextLib(nodeModulesDir: string): string {
|
|
85
|
+
const localIndex = join(localContextRoot, "index.ts");
|
|
86
|
+
if (existsSync(localIndex)) {
|
|
87
|
+
return localContextRoot;
|
|
88
|
+
}
|
|
89
|
+
const installedSrc = join(nodeModulesDir, "@fragments-sdk/context/src/index.ts");
|
|
90
|
+
if (existsSync(installedSrc)) {
|
|
91
|
+
return resolve(dirname(installedSrc));
|
|
92
|
+
}
|
|
93
|
+
const installedDist = join(nodeModulesDir, "@fragments-sdk/context");
|
|
94
|
+
if (existsSync(installedDist)) {
|
|
95
|
+
return installedDist;
|
|
96
|
+
}
|
|
97
|
+
return localContextRoot;
|
|
98
|
+
}
|
|
99
|
+
|
|
60
100
|
/**
|
|
61
101
|
* Resolve the @fragments-sdk/shared alias to either monorepo source
|
|
62
102
|
* or vendored viewer fallback for npm installs.
|
|
@@ -211,6 +251,8 @@ export async function createDevServer(
|
|
|
211
251
|
const nodeModulesPath = findNodeModules(projectRoot);
|
|
212
252
|
const uiLibRoot = resolveUiLib(nodeModulesPath);
|
|
213
253
|
const sharedLibRoot = resolveSharedLib();
|
|
254
|
+
const webmcpLibRoot = resolveWebMCPLib(nodeModulesPath);
|
|
255
|
+
const contextLibRoot = resolveContextLib(nodeModulesPath);
|
|
214
256
|
console.log(`📁 Using node_modules: ${nodeModulesPath}`);
|
|
215
257
|
|
|
216
258
|
// Collect installed package roots so Vite can serve files from node_modules
|
|
@@ -238,7 +280,7 @@ export async function createDevServer(
|
|
|
238
280
|
open: open ? "/fragments/" : false,
|
|
239
281
|
fs: {
|
|
240
282
|
// Allow serving files from viewer package, project, shared libs, and node_modules root
|
|
241
|
-
allow: [viewerRoot, uiLibRoot, sharedLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
|
|
283
|
+
allow: [viewerRoot, uiLibRoot, sharedLibRoot, webmcpLibRoot, contextLibRoot, projectRoot, configDir, dirname(nodeModulesPath), ...installedPkgRoots],
|
|
242
284
|
},
|
|
243
285
|
},
|
|
244
286
|
|
|
@@ -257,8 +299,13 @@ export async function createDevServer(
|
|
|
257
299
|
}),
|
|
258
300
|
],
|
|
259
301
|
|
|
260
|
-
// CSS configuration
|
|
261
|
-
|
|
302
|
+
// CSS configuration — preserve original hyphenated class names in CSS modules
|
|
303
|
+
// Vite 6 defaults to camelCaseOnly, but our components use styles['gap-sm'] etc.
|
|
304
|
+
css: {
|
|
305
|
+
modules: {
|
|
306
|
+
localsConvention: 'camelCase',
|
|
307
|
+
},
|
|
308
|
+
},
|
|
262
309
|
|
|
263
310
|
optimizeDeps: {
|
|
264
311
|
// Include common dependencies for faster startup
|
|
@@ -274,6 +321,14 @@ export async function createDevServer(
|
|
|
274
321
|
"@fragments-sdk/ui": uiLibRoot,
|
|
275
322
|
// Resolve @fragments-sdk/shared to monorepo source or vendored fallback
|
|
276
323
|
"@fragments-sdk/shared": sharedLibRoot,
|
|
324
|
+
// Resolve @fragments-sdk/webmcp subpaths to monorepo source or installed package
|
|
325
|
+
"@fragments-sdk/webmcp/react": join(webmcpLibRoot, "react/index.ts"),
|
|
326
|
+
"@fragments-sdk/webmcp/fragments": join(webmcpLibRoot, "fragments/index.ts"),
|
|
327
|
+
"@fragments-sdk/webmcp": webmcpLibRoot,
|
|
328
|
+
// Resolve @fragments-sdk/context subpaths to monorepo source or installed package
|
|
329
|
+
"@fragments-sdk/context/types": join(contextLibRoot, "types/index.ts"),
|
|
330
|
+
"@fragments-sdk/context/mcp-tools": join(contextLibRoot, "mcp-tools/index.ts"),
|
|
331
|
+
"@fragments-sdk/context": contextLibRoot,
|
|
277
332
|
// Resolve @fragments-sdk/cli/core to the CLI's own core source
|
|
278
333
|
"@fragments-sdk/cli/core": resolve(cliPackageRoot, "src/core/index.ts"),
|
|
279
334
|
// Ensure ALL react imports resolve to project's node_modules
|
|
@@ -928,6 +928,24 @@ export function fragmentsPlugin(options: FragmentsPluginOptions): Plugin[] {
|
|
|
928
928
|
return;
|
|
929
929
|
}
|
|
930
930
|
|
|
931
|
+
// Handle /fragments/compiled.json endpoint for WebMCP integration
|
|
932
|
+
if (req.url === "/fragments/compiled.json") {
|
|
933
|
+
try {
|
|
934
|
+
const { join } = await import("node:path");
|
|
935
|
+
const fragmentsJsonPath = join(projectRoot, BRAND.outFile);
|
|
936
|
+
const content = await readFile(fragmentsJsonPath, "utf-8");
|
|
937
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
938
|
+
res.end(content);
|
|
939
|
+
} catch (error) {
|
|
940
|
+
console.warn(`[${BRAND.name}] Failed to serve compiled.json:`, error);
|
|
941
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
942
|
+
res.end(JSON.stringify({
|
|
943
|
+
error: `${BRAND.outFile} not found. Run '${BRAND.cliCommand} build' first.`,
|
|
944
|
+
}));
|
|
945
|
+
}
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
|
|
931
949
|
// Handle /fragments/context endpoint for AI context generation
|
|
932
950
|
if (req.url?.startsWith("/fragments/context")) {
|
|
933
951
|
try {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { ToolAnalyticsCollector } from '../analytics.js';
|
|
3
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
4
|
+
|
|
5
|
+
function createEvent(overrides: Partial<ToolCallEvent> = {}): ToolCallEvent {
|
|
6
|
+
return {
|
|
7
|
+
toolName: 'fragments_discover',
|
|
8
|
+
input: {},
|
|
9
|
+
output: {},
|
|
10
|
+
error: null,
|
|
11
|
+
durationMs: 50,
|
|
12
|
+
timestamp: Date.now(),
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('ToolAnalyticsCollector', () => {
|
|
18
|
+
it('records events and computes summary', () => {
|
|
19
|
+
const collector = new ToolAnalyticsCollector();
|
|
20
|
+
collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 10 }));
|
|
21
|
+
collector.record(createEvent({ toolName: 'fragments_discover', durationMs: 20 }));
|
|
22
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', durationMs: 30 }));
|
|
23
|
+
|
|
24
|
+
const summary = collector.toSummary();
|
|
25
|
+
expect(summary.totalCalls).toBe(3);
|
|
26
|
+
expect(summary.uniqueTools).toBe(2);
|
|
27
|
+
expect(summary.toolStats.fragments_discover.callCount).toBe(2);
|
|
28
|
+
expect(summary.toolStats.fragments_discover.avgDuration).toBe(15);
|
|
29
|
+
expect(summary.toolStats.fragments_inspect.callCount).toBe(1);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('tracks error rate', () => {
|
|
33
|
+
const collector = new ToolAnalyticsCollector();
|
|
34
|
+
collector.record(createEvent({ error: null }));
|
|
35
|
+
collector.record(createEvent({ error: 'fail' }));
|
|
36
|
+
|
|
37
|
+
const summary = collector.toSummary();
|
|
38
|
+
expect(summary.errorRate).toBe(0.5);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('detects common flows', () => {
|
|
42
|
+
const collector = new ToolAnalyticsCollector();
|
|
43
|
+
const base = Date.now();
|
|
44
|
+
|
|
45
|
+
// Create a repeated flow: discover -> inspect -> discover -> inspect
|
|
46
|
+
collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base }));
|
|
47
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 100 }));
|
|
48
|
+
collector.record(createEvent({ toolName: 'fragments_discover', timestamp: base + 200 }));
|
|
49
|
+
collector.record(createEvent({ toolName: 'fragments_inspect', timestamp: base + 300 }));
|
|
50
|
+
|
|
51
|
+
const summary = collector.toSummary();
|
|
52
|
+
expect(summary.commonFlows.length).toBeGreaterThan(0);
|
|
53
|
+
expect(summary.commonFlows[0].sequence).toEqual(['fragments_discover', 'fragments_inspect']);
|
|
54
|
+
expect(summary.commonFlows[0].count).toBe(2);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('splits sessions on 30s gap', () => {
|
|
58
|
+
const collector = new ToolAnalyticsCollector();
|
|
59
|
+
const base = Date.now();
|
|
60
|
+
|
|
61
|
+
collector.record(createEvent({ toolName: 'a', timestamp: base }));
|
|
62
|
+
collector.record(createEvent({ toolName: 'b', timestamp: base + 100 }));
|
|
63
|
+
// 31s gap
|
|
64
|
+
collector.record(createEvent({ toolName: 'a', timestamp: base + 31_100 }));
|
|
65
|
+
collector.record(createEvent({ toolName: 'b', timestamp: base + 31_200 }));
|
|
66
|
+
|
|
67
|
+
const summary = collector.toSummary();
|
|
68
|
+
// "a -> b" appears in both sessions
|
|
69
|
+
const abFlow = summary.commonFlows.find(f =>
|
|
70
|
+
f.sequence[0] === 'a' && f.sequence[1] === 'b'
|
|
71
|
+
);
|
|
72
|
+
expect(abFlow).toBeDefined();
|
|
73
|
+
expect(abFlow!.count).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('computes percentiles', () => {
|
|
77
|
+
const collector = new ToolAnalyticsCollector();
|
|
78
|
+
for (let i = 1; i <= 100; i++) {
|
|
79
|
+
collector.record(createEvent({ durationMs: i }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const summary = collector.toSummary();
|
|
83
|
+
const stat = summary.toolStats.fragments_discover;
|
|
84
|
+
expect(stat.p50).toBeGreaterThanOrEqual(49);
|
|
85
|
+
expect(stat.p50).toBeLessThanOrEqual(51);
|
|
86
|
+
expect(stat.p95).toBeGreaterThanOrEqual(94);
|
|
87
|
+
expect(stat.p95).toBeLessThanOrEqual(96);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('produces PostHog properties', () => {
|
|
91
|
+
const collector = new ToolAnalyticsCollector();
|
|
92
|
+
collector.record(createEvent());
|
|
93
|
+
|
|
94
|
+
const props = collector.toPostHogProperties();
|
|
95
|
+
expect(props.webmcp_total_calls).toBe(1);
|
|
96
|
+
expect(props.webmcp_unique_tools).toBe(1);
|
|
97
|
+
expect(typeof props.webmcp_tool_breakdown).toBe('string');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('resets state', () => {
|
|
101
|
+
const collector = new ToolAnalyticsCollector();
|
|
102
|
+
collector.record(createEvent());
|
|
103
|
+
collector.reset();
|
|
104
|
+
|
|
105
|
+
const summary = collector.toSummary();
|
|
106
|
+
expect(summary.totalCalls).toBe(0);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
2
|
+
|
|
3
|
+
interface ToolStats {
|
|
4
|
+
callCount: number;
|
|
5
|
+
totalDuration: number;
|
|
6
|
+
durations: number[];
|
|
7
|
+
errorCount: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface ToolFlow {
|
|
11
|
+
sequence: string[];
|
|
12
|
+
count: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface AnalyticsSummary {
|
|
16
|
+
totalCalls: number;
|
|
17
|
+
uniqueTools: number;
|
|
18
|
+
sessionDurationMs: number;
|
|
19
|
+
errorRate: number;
|
|
20
|
+
toolStats: Record<string, {
|
|
21
|
+
callCount: number;
|
|
22
|
+
avgDuration: number;
|
|
23
|
+
p50: number;
|
|
24
|
+
p95: number;
|
|
25
|
+
errorCount: number;
|
|
26
|
+
}>;
|
|
27
|
+
commonFlows: ToolFlow[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const SESSION_GAP_MS = 30_000;
|
|
31
|
+
|
|
32
|
+
export class ToolAnalyticsCollector {
|
|
33
|
+
private stats = new Map<string, ToolStats>();
|
|
34
|
+
private events: ToolCallEvent[] = [];
|
|
35
|
+
private sessionStart: number | null = null;
|
|
36
|
+
|
|
37
|
+
record(event: ToolCallEvent): void {
|
|
38
|
+
this.events.push(event);
|
|
39
|
+
|
|
40
|
+
if (this.sessionStart === null) {
|
|
41
|
+
this.sessionStart = event.timestamp;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const existing = this.stats.get(event.toolName);
|
|
45
|
+
if (existing) {
|
|
46
|
+
existing.callCount++;
|
|
47
|
+
existing.totalDuration += event.durationMs;
|
|
48
|
+
existing.durations.push(event.durationMs);
|
|
49
|
+
if (event.error) existing.errorCount++;
|
|
50
|
+
} else {
|
|
51
|
+
this.stats.set(event.toolName, {
|
|
52
|
+
callCount: 1,
|
|
53
|
+
totalDuration: event.durationMs,
|
|
54
|
+
durations: [event.durationMs],
|
|
55
|
+
errorCount: event.error ? 1 : 0,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toSummary(): AnalyticsSummary {
|
|
61
|
+
let totalCalls = 0;
|
|
62
|
+
let totalErrors = 0;
|
|
63
|
+
const toolStats: AnalyticsSummary['toolStats'] = {};
|
|
64
|
+
|
|
65
|
+
for (const [name, stat] of this.stats) {
|
|
66
|
+
totalCalls += stat.callCount;
|
|
67
|
+
totalErrors += stat.errorCount;
|
|
68
|
+
|
|
69
|
+
const sorted = [...stat.durations].sort((a, b) => a - b);
|
|
70
|
+
toolStats[name] = {
|
|
71
|
+
callCount: stat.callCount,
|
|
72
|
+
avgDuration: Math.round(stat.totalDuration / stat.callCount),
|
|
73
|
+
p50: percentile(sorted, 50),
|
|
74
|
+
p95: percentile(sorted, 95),
|
|
75
|
+
errorCount: stat.errorCount,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const sessionDurationMs = this.sessionStart !== null && this.events.length > 0
|
|
80
|
+
? this.events[this.events.length - 1].timestamp - this.sessionStart
|
|
81
|
+
: 0;
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
totalCalls,
|
|
85
|
+
uniqueTools: this.stats.size,
|
|
86
|
+
sessionDurationMs,
|
|
87
|
+
errorRate: totalCalls > 0 ? Math.round((totalErrors / totalCalls) * 100) / 100 : 0,
|
|
88
|
+
toolStats,
|
|
89
|
+
commonFlows: this.detectFlows(),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toPostHogProperties(): Record<string, unknown> {
|
|
94
|
+
const summary = this.toSummary();
|
|
95
|
+
return {
|
|
96
|
+
webmcp_total_calls: summary.totalCalls,
|
|
97
|
+
webmcp_unique_tools: summary.uniqueTools,
|
|
98
|
+
webmcp_session_duration_ms: summary.sessionDurationMs,
|
|
99
|
+
webmcp_error_rate: summary.errorRate,
|
|
100
|
+
webmcp_tool_breakdown: JSON.stringify(summary.toolStats),
|
|
101
|
+
webmcp_common_flows: JSON.stringify(summary.commonFlows),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private detectFlows(): ToolFlow[] {
|
|
106
|
+
if (this.events.length < 2) return [];
|
|
107
|
+
|
|
108
|
+
// Split into sessions (30s gap)
|
|
109
|
+
const sessions: ToolCallEvent[][] = [];
|
|
110
|
+
let currentSession: ToolCallEvent[] = [this.events[0]];
|
|
111
|
+
|
|
112
|
+
for (let i = 1; i < this.events.length; i++) {
|
|
113
|
+
if (this.events[i].timestamp - this.events[i - 1].timestamp > SESSION_GAP_MS) {
|
|
114
|
+
sessions.push(currentSession);
|
|
115
|
+
currentSession = [];
|
|
116
|
+
}
|
|
117
|
+
currentSession.push(this.events[i]);
|
|
118
|
+
}
|
|
119
|
+
sessions.push(currentSession);
|
|
120
|
+
|
|
121
|
+
// Count 2-grams and 3-grams
|
|
122
|
+
const flowCounts = new Map<string, number>();
|
|
123
|
+
|
|
124
|
+
for (const session of sessions) {
|
|
125
|
+
const names = session.map(e => e.toolName);
|
|
126
|
+
|
|
127
|
+
// 2-grams
|
|
128
|
+
for (let i = 0; i < names.length - 1; i++) {
|
|
129
|
+
const key = `${names[i]} → ${names[i + 1]}`;
|
|
130
|
+
flowCounts.set(key, (flowCounts.get(key) ?? 0) + 1);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 3-grams
|
|
134
|
+
for (let i = 0; i < names.length - 2; i++) {
|
|
135
|
+
const key = `${names[i]} → ${names[i + 1]} → ${names[i + 2]}`;
|
|
136
|
+
flowCounts.set(key, (flowCounts.get(key) ?? 0) + 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Return top flows (count >= 2)
|
|
141
|
+
return [...flowCounts.entries()]
|
|
142
|
+
.filter(([, count]) => count >= 2)
|
|
143
|
+
.sort((a, b) => b[1] - a[1])
|
|
144
|
+
.slice(0, 10)
|
|
145
|
+
.map(([key, count]) => ({
|
|
146
|
+
sequence: key.split(' → '),
|
|
147
|
+
count,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
reset(): void {
|
|
152
|
+
this.stats.clear();
|
|
153
|
+
this.events = [];
|
|
154
|
+
this.sessionStart = null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function percentile(sorted: number[], p: number): number {
|
|
159
|
+
if (sorted.length === 0) return 0;
|
|
160
|
+
const index = (p / 100) * (sorted.length - 1);
|
|
161
|
+
const lower = Math.floor(index);
|
|
162
|
+
const upper = Math.ceil(index);
|
|
163
|
+
if (lower === upper) return sorted[lower];
|
|
164
|
+
return Math.round(sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower));
|
|
165
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ToolCallEvent } from '@fragments-sdk/webmcp';
|
|
2
|
+
import type { ToolAnalyticsCollector } from './analytics.js';
|
|
3
|
+
|
|
4
|
+
interface PostHogLike {
|
|
5
|
+
capture(event: string, properties?: Record<string, unknown>): void;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
interface PostHogBridge {
|
|
9
|
+
onToolCall(event: ToolCallEvent): void;
|
|
10
|
+
onSessionEnd(): void;
|
|
11
|
+
flush(): void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function createPostHogBridge(
|
|
15
|
+
posthog: PostHogLike,
|
|
16
|
+
collector: ToolAnalyticsCollector
|
|
17
|
+
): PostHogBridge {
|
|
18
|
+
return {
|
|
19
|
+
onToolCall(event: ToolCallEvent): void {
|
|
20
|
+
posthog.capture('webmcp_tool_call', {
|
|
21
|
+
tool_name: event.toolName,
|
|
22
|
+
duration_ms: event.durationMs,
|
|
23
|
+
success: event.error === null,
|
|
24
|
+
error: event.error,
|
|
25
|
+
});
|
|
26
|
+
},
|
|
27
|
+
|
|
28
|
+
onSessionEnd(): void {
|
|
29
|
+
const props = collector.toPostHogProperties();
|
|
30
|
+
if ((props.webmcp_total_calls as number) > 0) {
|
|
31
|
+
posthog.capture('webmcp_session_summary', props);
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
flush(): void {
|
|
36
|
+
this.onSessionEnd();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|