@seed-ship/mcp-ui-solid 5.5.1 → 5.6.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/CHANGELOG.md +47 -0
- package/dist/components/FormRenderer.cjs +13 -2
- package/dist/components/FormRenderer.cjs.map +1 -1
- package/dist/components/FormRenderer.d.ts.map +1 -1
- package/dist/components/FormRenderer.js +13 -2
- package/dist/components/FormRenderer.js.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.cjs +11 -0
- package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.d.ts.map +1 -1
- package/dist/components/GenerativeUIErrorBoundary.js +11 -0
- package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
- package/dist/components/StreamingUIRenderer.cjs +49 -3
- package/dist/components/StreamingUIRenderer.cjs.map +1 -1
- package/dist/components/StreamingUIRenderer.d.ts.map +1 -1
- package/dist/components/StreamingUIRenderer.js +51 -5
- package/dist/components/StreamingUIRenderer.js.map +1 -1
- package/dist/components/UIResourceRenderer.cjs +62 -3
- package/dist/components/UIResourceRenderer.cjs.map +1 -1
- package/dist/components/UIResourceRenderer.d.ts.map +1 -1
- package/dist/components/UIResourceRenderer.js +64 -5
- package/dist/components/UIResourceRenderer.js.map +1 -1
- package/dist/context/MCPUITelemetryContext.cjs +25 -0
- package/dist/context/MCPUITelemetryContext.cjs.map +1 -0
- package/dist/context/MCPUITelemetryContext.d.ts +36 -0
- package/dist/context/MCPUITelemetryContext.d.ts.map +1 -0
- package/dist/context/MCPUITelemetryContext.js +25 -0
- package/dist/context/MCPUITelemetryContext.js.map +1 -0
- package/dist/index.cjs +6 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.cjs +16 -5
- package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
- package/dist/mcp-ui-spec/dist/schemas.js +16 -5
- package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
- package/dist/services/telemetry.cjs +56 -0
- package/dist/services/telemetry.cjs.map +1 -0
- package/dist/services/telemetry.d.ts +87 -0
- package/dist/services/telemetry.d.ts.map +1 -0
- package/dist/services/telemetry.js +56 -0
- package/dist/services/telemetry.js.map +1 -0
- package/dist/services/validation.cjs +25 -24
- package/dist/services/validation.cjs.map +1 -1
- package/dist/services/validation.d.ts.map +1 -1
- package/dist/services/validation.js +26 -25
- package/dist/services/validation.js.map +1 -1
- package/package.json +2 -2
- package/src/components/FormRenderer.tsx +14 -0
- package/src/components/GenerativeUIErrorBoundary.tsx +17 -1
- package/src/components/StreamingUIRenderer.tsx +55 -3
- package/src/components/UIResourceRenderer.tsx +79 -3
- package/src/context/MCPUITelemetryContext.test.tsx +119 -0
- package/src/context/MCPUITelemetryContext.tsx +71 -0
- package/src/index.ts +15 -0
- package/src/services/telemetry.test.ts +134 -0
- package/src/services/telemetry.ts +149 -0
- package/src/services/validation.ts +43 -41
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -21,12 +21,13 @@
|
|
|
21
21
|
* ```
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
-
import { Show, For, createSignal, onMount } from 'solid-js'
|
|
24
|
+
import { Show, For, createSignal, onMount, onCleanup } from 'solid-js'
|
|
25
25
|
import { useStreamingUI, type UseStreamingUIOptions } from '../hooks/useStreamingUI'
|
|
26
26
|
import type { UIComponent, RendererError } from '../types'
|
|
27
27
|
import { validateComponent } from '../services/validation'
|
|
28
28
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
29
|
-
import { markRenderStart, markRenderEnd } from '../utils/perf'
|
|
29
|
+
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
30
|
+
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
30
31
|
import type { ValidationErrorMode } from './UIResourceRenderer'
|
|
31
32
|
|
|
32
33
|
export interface StreamingUIRendererProps extends UseStreamingUIOptions {
|
|
@@ -53,7 +54,47 @@ function StreamingComponentRenderer(props: {
|
|
|
53
54
|
}) {
|
|
54
55
|
// Performance marks (v5.4.0) — see utils/perf.ts
|
|
55
56
|
markRenderStart(props.component.id)
|
|
56
|
-
|
|
57
|
+
|
|
58
|
+
// Telemetry sink (B.5 — v5.6.0). Same wiring as ComponentRenderer in
|
|
59
|
+
// UIResourceRenderer.tsx — null when no Provider, no-op everywhere then.
|
|
60
|
+
const telemetry = useTelemetry()
|
|
61
|
+
|
|
62
|
+
onMount(() => {
|
|
63
|
+
markRenderEnd(props.component.id)
|
|
64
|
+
if (telemetry) {
|
|
65
|
+
const ts = Date.now()
|
|
66
|
+
telemetry.dispatch({
|
|
67
|
+
type: 'component:mounted',
|
|
68
|
+
id: props.component.id,
|
|
69
|
+
componentType: props.component.type,
|
|
70
|
+
ts,
|
|
71
|
+
})
|
|
72
|
+
if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
|
|
73
|
+
const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
|
|
74
|
+
const last = entries[entries.length - 1]
|
|
75
|
+
if (last) {
|
|
76
|
+
telemetry.dispatch({
|
|
77
|
+
type: 'component:rendered',
|
|
78
|
+
id: props.component.id,
|
|
79
|
+
componentType: props.component.type,
|
|
80
|
+
durationMs: last.duration,
|
|
81
|
+
ts,
|
|
82
|
+
})
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
onCleanup(() => {
|
|
89
|
+
if (telemetry) {
|
|
90
|
+
telemetry.dispatch({
|
|
91
|
+
type: 'component:unmounted',
|
|
92
|
+
id: props.component.id,
|
|
93
|
+
componentType: props.component.type,
|
|
94
|
+
ts: Date.now(),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
})
|
|
57
98
|
|
|
58
99
|
// Validate component before rendering
|
|
59
100
|
const validation = validateComponent(props.component)
|
|
@@ -65,6 +106,17 @@ function StreamingComponentRenderer(props: {
|
|
|
65
106
|
details: validation.errors,
|
|
66
107
|
})
|
|
67
108
|
|
|
109
|
+
if (telemetry) {
|
|
110
|
+
telemetry.dispatch({
|
|
111
|
+
type: 'validation:failed',
|
|
112
|
+
id: props.component.id,
|
|
113
|
+
componentType: props.component.type,
|
|
114
|
+
errorCount: validation.errors?.length ?? 0,
|
|
115
|
+
firstErrorCode: validation.errors?.[0]?.code ?? null,
|
|
116
|
+
ts: Date.now(),
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
68
120
|
const mode: ValidationErrorMode = props.errorMode ?? 'block'
|
|
69
121
|
const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
|
|
70
122
|
|
|
@@ -4,12 +4,13 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import DOMPurify from 'dompurify'
|
|
7
|
-
import { Component, createSignal, Show, For, createMemo, createEffect, onMount } from 'solid-js'
|
|
7
|
+
import { Component, createSignal, Show, For, createMemo, createEffect, onMount, onCleanup } from 'solid-js'
|
|
8
8
|
import { isServer } from 'solid-js/web'
|
|
9
9
|
import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } from '../types'
|
|
10
10
|
import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
|
|
11
11
|
import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
|
|
12
|
-
import { markRenderStart, markRenderEnd } from '../utils/perf'
|
|
12
|
+
import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
|
|
13
|
+
import { useTelemetry } from '../context/MCPUITelemetryContext'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* How `<UIResourceRenderer>` reacts when `validateComponent()` rejects a
|
|
@@ -1133,7 +1134,51 @@ function ComponentRenderer(props: {
|
|
|
1133
1134
|
// Performance marks — visible in Chrome DevTools "Performance" panel under
|
|
1134
1135
|
// user timings. Always-on, SSR-safe (see utils/perf.ts).
|
|
1135
1136
|
markRenderStart(props.component.id)
|
|
1136
|
-
|
|
1137
|
+
|
|
1138
|
+
// Telemetry sink (B.5 — v5.6.0). null when no MCPUITelemetryProvider is
|
|
1139
|
+
// mounted above; null-checked at every dispatch site so apps that don't
|
|
1140
|
+
// opt in see zero behavior change.
|
|
1141
|
+
const telemetry = useTelemetry()
|
|
1142
|
+
|
|
1143
|
+
onMount(() => {
|
|
1144
|
+
markRenderEnd(props.component.id)
|
|
1145
|
+
if (telemetry) {
|
|
1146
|
+
const ts = Date.now()
|
|
1147
|
+
telemetry.dispatch({
|
|
1148
|
+
type: 'component:mounted',
|
|
1149
|
+
id: props.component.id,
|
|
1150
|
+
componentType: props.component.type,
|
|
1151
|
+
ts,
|
|
1152
|
+
})
|
|
1153
|
+
// Read the perf measure we just emitted to surface durationMs without
|
|
1154
|
+
// double-measuring. The measure may be missing if `performance` is
|
|
1155
|
+
// unavailable (SSR) — skip rendered event in that case.
|
|
1156
|
+
if (typeof performance !== 'undefined' && typeof performance.getEntriesByName === 'function') {
|
|
1157
|
+
const entries = performance.getEntriesByName(`${PERF_PREFIX}${props.component.id}:render`, 'measure')
|
|
1158
|
+
const last = entries[entries.length - 1]
|
|
1159
|
+
if (last) {
|
|
1160
|
+
telemetry.dispatch({
|
|
1161
|
+
type: 'component:rendered',
|
|
1162
|
+
id: props.component.id,
|
|
1163
|
+
componentType: props.component.type,
|
|
1164
|
+
durationMs: last.duration,
|
|
1165
|
+
ts,
|
|
1166
|
+
})
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
})
|
|
1171
|
+
|
|
1172
|
+
onCleanup(() => {
|
|
1173
|
+
if (telemetry) {
|
|
1174
|
+
telemetry.dispatch({
|
|
1175
|
+
type: 'component:unmounted',
|
|
1176
|
+
id: props.component.id,
|
|
1177
|
+
componentType: props.component.type,
|
|
1178
|
+
ts: Date.now(),
|
|
1179
|
+
})
|
|
1180
|
+
}
|
|
1181
|
+
})
|
|
1137
1182
|
|
|
1138
1183
|
// Validate component before rendering
|
|
1139
1184
|
const validation = validateComponent(props.component)
|
|
@@ -1145,6 +1190,19 @@ function ComponentRenderer(props: {
|
|
|
1145
1190
|
details: validation.errors,
|
|
1146
1191
|
})
|
|
1147
1192
|
|
|
1193
|
+
// Privacy: only counts + first error code, NO error messages or paths
|
|
1194
|
+
// (which could leak payload data — §M.6.2 hard rule).
|
|
1195
|
+
if (telemetry) {
|
|
1196
|
+
telemetry.dispatch({
|
|
1197
|
+
type: 'validation:failed',
|
|
1198
|
+
id: props.component.id,
|
|
1199
|
+
componentType: props.component.type,
|
|
1200
|
+
errorCount: validation.errors?.length ?? 0,
|
|
1201
|
+
firstErrorCode: validation.errors?.[0]?.code ?? null,
|
|
1202
|
+
ts: Date.now(),
|
|
1203
|
+
})
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1148
1206
|
const mode: ValidationErrorMode = props.errorMode ?? 'block'
|
|
1149
1207
|
const firstError = validation.errors?.[0]?.message || 'Unknown validation error'
|
|
1150
1208
|
|
|
@@ -1264,9 +1322,27 @@ function ComponentRenderer(props: {
|
|
|
1264
1322
|
function ActionRenderer(props: { component: UIComponent }) {
|
|
1265
1323
|
const params = props.component.params as any
|
|
1266
1324
|
const { execute, isExecuting } = useAction()
|
|
1325
|
+
const telemetry = useTelemetry()
|
|
1326
|
+
|
|
1327
|
+
// Telemetry: action:dispatched on click (B.5 — v5.6.0). Fires for every
|
|
1328
|
+
// click attempt (tool-call or link), regardless of execute success.
|
|
1329
|
+
// Privacy: actionName is `toolName` (tool-call) or the action kind
|
|
1330
|
+
// (link/submit) — NO `params.params` payload, NO URL.
|
|
1331
|
+
function dispatchTelemetry() {
|
|
1332
|
+
if (!telemetry) return
|
|
1333
|
+
const actionName: string = params.toolName ?? params.action ?? 'unknown'
|
|
1334
|
+
telemetry.dispatch({
|
|
1335
|
+
type: 'action:dispatched',
|
|
1336
|
+
id: props.component.id,
|
|
1337
|
+
componentType: 'action',
|
|
1338
|
+
actionName,
|
|
1339
|
+
ts: Date.now(),
|
|
1340
|
+
})
|
|
1341
|
+
}
|
|
1267
1342
|
|
|
1268
1343
|
// Handle click to execute tool via Context (falls back to CustomEvent if no provider)
|
|
1269
1344
|
const handleClick = async (e: MouseEvent) => {
|
|
1345
|
+
dispatchTelemetry()
|
|
1270
1346
|
if (params.action === 'tool-call' && params.toolName) {
|
|
1271
1347
|
e.preventDefault()
|
|
1272
1348
|
await execute(params.toolName, params.params || {})
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Integration tests for MCPUITelemetryProvider + dispatch wiring (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* Verifies that the Provider correctly wraps a dispatcher and that dispatches
|
|
5
|
+
* from real renderer components arrive at the consumer sink — without the
|
|
6
|
+
* Provider, no events should fire (existing tests prove this implicitly,
|
|
7
|
+
* but we lock it explicitly here).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
11
|
+
import { render, cleanup } from '@solidjs/testing-library'
|
|
12
|
+
import { UIResourceRenderer } from '../components/UIResourceRenderer'
|
|
13
|
+
import { MCPUITelemetryProvider } from './MCPUITelemetryContext'
|
|
14
|
+
import type { TelemetryEvent, TelemetrySink } from '../services/telemetry'
|
|
15
|
+
import type { UIComponent } from '../types'
|
|
16
|
+
|
|
17
|
+
const validMetric: UIComponent = {
|
|
18
|
+
id: 'metric-1',
|
|
19
|
+
type: 'metric',
|
|
20
|
+
position: { colStart: 1, colSpan: 6 },
|
|
21
|
+
params: { title: 'OK', value: 42 },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const invalidMetric: UIComponent = {
|
|
25
|
+
id: 'metric-bad',
|
|
26
|
+
type: 'metric',
|
|
27
|
+
position: { colStart: 1, colSpan: 6 },
|
|
28
|
+
params: {} as any, // missing title + value → validation:failed
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('MCPUITelemetryProvider — integration with renderers (v5.6.0)', () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
cleanup()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('NO Provider = no events dispatched (renderer works as before)', () => {
|
|
37
|
+
// Render without Provider — should not throw, no events anywhere
|
|
38
|
+
expect(() => render(() => <UIResourceRenderer content={validMetric} />)).not.toThrow()
|
|
39
|
+
// No way to assert "no dispatch" without a sink; but the absence of
|
|
40
|
+
// throws + Provider's null-check semantics is what we lock here.
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('Provider receives component:mounted event for a valid component', async () => {
|
|
44
|
+
const events: TelemetryEvent[] = []
|
|
45
|
+
const sink: TelemetrySink = (batch) => {
|
|
46
|
+
events.push(...batch)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
render(() => (
|
|
50
|
+
<MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
|
|
51
|
+
<UIResourceRenderer content={validMetric} />
|
|
52
|
+
</MCPUITelemetryProvider>
|
|
53
|
+
))
|
|
54
|
+
|
|
55
|
+
const mounted = events.find((e) => e.type === 'component:mounted')
|
|
56
|
+
expect(mounted).toBeDefined()
|
|
57
|
+
expect(mounted?.id).toBe('metric-1')
|
|
58
|
+
expect(mounted?.componentType).toBe('metric')
|
|
59
|
+
expect(typeof mounted?.ts).toBe('number')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('Provider receives validation:failed event with errorCount + firstErrorCode (NO error message)', async () => {
|
|
63
|
+
const events: TelemetryEvent[] = []
|
|
64
|
+
const sink: TelemetrySink = (batch) => {
|
|
65
|
+
events.push(...batch)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
render(() => (
|
|
69
|
+
<MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
|
|
70
|
+
<UIResourceRenderer content={invalidMetric} errorMode="silent" />
|
|
71
|
+
</MCPUITelemetryProvider>
|
|
72
|
+
))
|
|
73
|
+
|
|
74
|
+
const failed = events.find((e) => e.type === 'validation:failed')
|
|
75
|
+
expect(failed).toBeDefined()
|
|
76
|
+
expect(failed).toMatchObject({
|
|
77
|
+
type: 'validation:failed',
|
|
78
|
+
id: 'metric-bad',
|
|
79
|
+
componentType: 'metric',
|
|
80
|
+
firstErrorCode: 'INVALID_METRIC',
|
|
81
|
+
})
|
|
82
|
+
if (failed?.type === 'validation:failed') {
|
|
83
|
+
expect(failed.errorCount).toBeGreaterThan(0)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Privacy hard rule: NO `errorMessage`, NO `errors` array, NO payload
|
|
87
|
+
// fields anywhere on the validation:failed event.
|
|
88
|
+
const failedKeys = failed ? Object.keys(failed) : []
|
|
89
|
+
expect(failedKeys.sort()).toEqual(
|
|
90
|
+
['componentType', 'errorCount', 'firstErrorCode', 'id', 'ts', 'type'].sort()
|
|
91
|
+
)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('FAIL-OPEN — sink that throws does NOT crash the renderer', () => {
|
|
95
|
+
const sink: TelemetrySink = () => {
|
|
96
|
+
throw new Error('sink down hard')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
expect(() =>
|
|
100
|
+
render(() => (
|
|
101
|
+
<MCPUITelemetryProvider sink={sink} options={{ bufferMs: 0 }}>
|
|
102
|
+
<UIResourceRenderer content={validMetric} />
|
|
103
|
+
</MCPUITelemetryProvider>
|
|
104
|
+
))
|
|
105
|
+
).not.toThrow()
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('options.sampleRate=0 results in no events at the sink (everything dropped)', () => {
|
|
109
|
+
const sink = vi.fn<TelemetrySink>()
|
|
110
|
+
|
|
111
|
+
render(() => (
|
|
112
|
+
<MCPUITelemetryProvider sink={sink} options={{ sampleRate: 0, bufferMs: 0 }}>
|
|
113
|
+
<UIResourceRenderer content={validMetric} />
|
|
114
|
+
</MCPUITelemetryProvider>
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
expect(sink).not.toHaveBeenCalled()
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCPUITelemetryProvider + useTelemetry hook (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* SolidJS Context wrapper around `createTelemetryDispatcher`. Optional —
|
|
5
|
+
* when no Provider is present, `useTelemetry()` returns `null` and dispatch
|
|
6
|
+
* sites no-op (zero behavior change for apps that don't opt in).
|
|
7
|
+
*
|
|
8
|
+
* See `services/telemetry.ts` for the dispatcher contract and
|
|
9
|
+
* `MCP-UI-AUDIT-2026-04-26.md` §M.6 for the full specification.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
createContext,
|
|
14
|
+
useContext,
|
|
15
|
+
onCleanup,
|
|
16
|
+
type Component,
|
|
17
|
+
type JSX,
|
|
18
|
+
} from 'solid-js'
|
|
19
|
+
import {
|
|
20
|
+
createTelemetryDispatcher,
|
|
21
|
+
type TelemetryDispatcher,
|
|
22
|
+
type TelemetrySink,
|
|
23
|
+
type TelemetryOptions,
|
|
24
|
+
} from '../services/telemetry'
|
|
25
|
+
|
|
26
|
+
export const MCPUITelemetryContext = createContext<TelemetryDispatcher | null>(null)
|
|
27
|
+
|
|
28
|
+
export interface MCPUITelemetryProviderProps {
|
|
29
|
+
/** Consumer-supplied sink. Receives a batch of events. Fail-open. */
|
|
30
|
+
sink: TelemetrySink
|
|
31
|
+
/** Sampling + buffering knobs (defaults: 100% / 100ms / max 50). */
|
|
32
|
+
options?: TelemetryOptions
|
|
33
|
+
children: JSX.Element
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const MCPUITelemetryProvider: Component<MCPUITelemetryProviderProps> = (props) => {
|
|
37
|
+
// Dispatcher is created once per Provider mount. `props.sink` and
|
|
38
|
+
// `props.options` are captured at that moment — consumers should treat
|
|
39
|
+
// them as effectively immutable for the Provider's lifetime (re-mount the
|
|
40
|
+
// Provider to swap them).
|
|
41
|
+
const dispatcher = createTelemetryDispatcher(props.sink, props.options)
|
|
42
|
+
|
|
43
|
+
// Force-flush on unmount so any buffered events from the last bufferMs
|
|
44
|
+
// window aren't lost (e.g. tab close, route change with cleanup).
|
|
45
|
+
onCleanup(() => {
|
|
46
|
+
dispatcher.flush()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<MCPUITelemetryContext.Provider value={dispatcher}>
|
|
51
|
+
{props.children}
|
|
52
|
+
</MCPUITelemetryContext.Provider>
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Returns the current telemetry dispatcher, or `null` when no Provider is
|
|
58
|
+
* mounted in the tree above. Dispatch sites should null-check and no-op
|
|
59
|
+
* when null — telemetry is OPT-IN and must not impose a Provider on apps.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```tsx
|
|
63
|
+
* const telemetry = useTelemetry()
|
|
64
|
+
* onMount(() => {
|
|
65
|
+
* telemetry?.dispatch({ type: 'component:mounted', id, componentType, ts: Date.now() })
|
|
66
|
+
* })
|
|
67
|
+
* ```
|
|
68
|
+
*/
|
|
69
|
+
export function useTelemetry(): TelemetryDispatcher | null {
|
|
70
|
+
return useContext(MCPUITelemetryContext)
|
|
71
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -96,6 +96,21 @@ export type { ValidationErrorMode } from './components/UIResourceRenderer'
|
|
|
96
96
|
export { setDebugMode, isDebugEnabled } from './utils/logger'
|
|
97
97
|
export { markRenderStart, markRenderEnd, PERF_PREFIX } from './utils/perf'
|
|
98
98
|
|
|
99
|
+
// Telemetry sink (B.5 — v5.6.0)
|
|
100
|
+
export {
|
|
101
|
+
MCPUITelemetryProvider,
|
|
102
|
+
MCPUITelemetryContext,
|
|
103
|
+
useTelemetry,
|
|
104
|
+
} from './context/MCPUITelemetryContext'
|
|
105
|
+
export type { MCPUITelemetryProviderProps } from './context/MCPUITelemetryContext'
|
|
106
|
+
export { createTelemetryDispatcher } from './services/telemetry'
|
|
107
|
+
export type {
|
|
108
|
+
TelemetryEvent,
|
|
109
|
+
TelemetrySink,
|
|
110
|
+
TelemetryOptions,
|
|
111
|
+
TelemetryDispatcher,
|
|
112
|
+
} from './services/telemetry'
|
|
113
|
+
|
|
99
114
|
export type { DraggableGridItemProps } from './components/DraggableGridItem'
|
|
100
115
|
export type { ResizeHandleProps as ResizeHandleComponentProps } from './components/ResizeHandle'
|
|
101
116
|
export type { EditableUIResourceRendererProps } from './components/EditableUIResourceRenderer'
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for createTelemetryDispatcher (B.5 — v5.6.0).
|
|
3
|
+
* Spec: MCP-UI-AUDIT-2026-04-26.md §M.6
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
7
|
+
import { createTelemetryDispatcher, type TelemetryEvent, type TelemetrySink } from './telemetry'
|
|
8
|
+
|
|
9
|
+
function evt(type: TelemetryEvent['type'], id = 'cmp-1'): TelemetryEvent {
|
|
10
|
+
const base = { id, componentType: 'metric' as const, ts: 1_700_000_000_000 }
|
|
11
|
+
switch (type) {
|
|
12
|
+
case 'component:rendered':
|
|
13
|
+
return { type, durationMs: 4, ...base }
|
|
14
|
+
case 'validation:failed':
|
|
15
|
+
return { type, errorCount: 1, firstErrorCode: 'INVALID_METRIC', ...base }
|
|
16
|
+
case 'render:error':
|
|
17
|
+
return { type, errorMessage: 'boom', ...base }
|
|
18
|
+
case 'action:dispatched':
|
|
19
|
+
return { type, actionName: 'submit', ...base }
|
|
20
|
+
default:
|
|
21
|
+
return { type, ...base }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('createTelemetryDispatcher (v5.6.0 — B.5)', () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
vi.useFakeTimers()
|
|
28
|
+
})
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
vi.useRealTimers()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('delivers events as a BATCH (array), even with bufferMs=0', () => {
|
|
34
|
+
const sink = vi.fn<TelemetrySink>()
|
|
35
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 0 })
|
|
36
|
+
d.dispatch(evt('component:mounted'))
|
|
37
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
38
|
+
expect(sink.mock.calls[0][0]).toHaveLength(1)
|
|
39
|
+
expect(Array.isArray(sink.mock.calls[0][0])).toBe(true)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('buffers and flushes after bufferMs', () => {
|
|
43
|
+
const sink = vi.fn<TelemetrySink>()
|
|
44
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 100 })
|
|
45
|
+
d.dispatch(evt('component:mounted', 'a'))
|
|
46
|
+
d.dispatch(evt('component:mounted', 'b'))
|
|
47
|
+
d.dispatch(evt('component:mounted', 'c'))
|
|
48
|
+
expect(sink).not.toHaveBeenCalled()
|
|
49
|
+
|
|
50
|
+
vi.advanceTimersByTime(100)
|
|
51
|
+
|
|
52
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
53
|
+
expect(sink.mock.calls[0][0]).toHaveLength(3)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('forces flush when bufferMax reached', () => {
|
|
57
|
+
const sink = vi.fn<TelemetrySink>()
|
|
58
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 5_000, bufferMax: 3 })
|
|
59
|
+
d.dispatch(evt('component:mounted', 'a'))
|
|
60
|
+
d.dispatch(evt('component:mounted', 'b'))
|
|
61
|
+
expect(sink).not.toHaveBeenCalled()
|
|
62
|
+
d.dispatch(evt('component:mounted', 'c')) // hits bufferMax
|
|
63
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
64
|
+
expect(sink.mock.calls[0][0]).toHaveLength(3)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('flush() is idempotent on empty buffer (no sink call)', () => {
|
|
68
|
+
const sink = vi.fn<TelemetrySink>()
|
|
69
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 1_000 })
|
|
70
|
+
d.flush()
|
|
71
|
+
d.flush()
|
|
72
|
+
expect(sink).not.toHaveBeenCalled()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('manual flush() clears the pending bufferMs timer', () => {
|
|
76
|
+
const sink = vi.fn<TelemetrySink>()
|
|
77
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 1_000 })
|
|
78
|
+
d.dispatch(evt('component:mounted'))
|
|
79
|
+
d.flush()
|
|
80
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
81
|
+
vi.advanceTimersByTime(2_000)
|
|
82
|
+
// timer was cleared — no second call
|
|
83
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('FAIL-OPEN — sink throw does NOT propagate', () => {
|
|
87
|
+
const sink = vi.fn<TelemetrySink>(() => {
|
|
88
|
+
throw new Error('sink down')
|
|
89
|
+
})
|
|
90
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 0 })
|
|
91
|
+
expect(() => d.dispatch(evt('component:mounted'))).not.toThrow()
|
|
92
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('FAIL-OPEN — sink rejected promise does NOT throw at dispatch site', async () => {
|
|
96
|
+
const sink = vi.fn<TelemetrySink>(async () => {
|
|
97
|
+
throw new Error('async sink down')
|
|
98
|
+
})
|
|
99
|
+
const d = createTelemetryDispatcher(sink, { bufferMs: 0 })
|
|
100
|
+
expect(() => d.dispatch(evt('component:mounted'))).not.toThrow()
|
|
101
|
+
// Microtask flush — should NOT bubble unhandled rejection
|
|
102
|
+
await Promise.resolve()
|
|
103
|
+
await Promise.resolve()
|
|
104
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('sampleRate 0 drops every event', () => {
|
|
108
|
+
const sink = vi.fn<TelemetrySink>()
|
|
109
|
+
const d = createTelemetryDispatcher(sink, { sampleRate: 0, bufferMs: 0 })
|
|
110
|
+
for (let i = 0; i < 100; i++) d.dispatch(evt('component:mounted'))
|
|
111
|
+
expect(sink).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('sampleRate 1 keeps every event', () => {
|
|
115
|
+
const sink = vi.fn<TelemetrySink>()
|
|
116
|
+
const d = createTelemetryDispatcher(sink, { sampleRate: 1, bufferMs: 0 })
|
|
117
|
+
for (let i = 0; i < 5; i++) d.dispatch(evt('component:mounted'))
|
|
118
|
+
expect(sink).toHaveBeenCalledTimes(5)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('sampleByType overrides sampleRate per event type', () => {
|
|
122
|
+
const sink = vi.fn<TelemetrySink>()
|
|
123
|
+
const d = createTelemetryDispatcher(sink, {
|
|
124
|
+
sampleRate: 0, // would drop everything
|
|
125
|
+
sampleByType: { 'render:error': 1.0 }, // ...except errors
|
|
126
|
+
bufferMs: 0,
|
|
127
|
+
})
|
|
128
|
+
d.dispatch(evt('component:mounted')) // dropped
|
|
129
|
+
d.dispatch(evt('render:error')) // kept
|
|
130
|
+
d.dispatch(evt('validation:failed')) // dropped
|
|
131
|
+
expect(sink).toHaveBeenCalledTimes(1)
|
|
132
|
+
expect(sink.mock.calls[0][0][0].type).toBe('render:error')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI telemetry sink (B.5 — v5.6.0)
|
|
3
|
+
*
|
|
4
|
+
* Minimal OpenTelemetry-like Provider that lets a consumer (e.g. deposium
|
|
5
|
+
* `/admin/ui-telemetry`) collect lifecycle + error + action events from
|
|
6
|
+
* mcp-ui components without imposing any API change on apps that don't use
|
|
7
|
+
* it. Spec'd in `MCP-UI-AUDIT-2026-04-26.md` §M.6.
|
|
8
|
+
*
|
|
9
|
+
* Three hard rules:
|
|
10
|
+
* 1. Provider is OPTIONAL. When absent, `useTelemetry()` returns `null`
|
|
11
|
+
* and dispatch sites no-op. Existing apps see zero behavior change.
|
|
12
|
+
* 2. Sink is FAIL-OPEN. A `sink()` throw or rejected promise is caught
|
|
13
|
+
* silently — telemetry never crashes the renderer.
|
|
14
|
+
* 3. Events carry NO payload data. Only meta (type + id + counts + timing)
|
|
15
|
+
* to avoid PII / data leaks in centralized logs.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { ComponentType } from '../types'
|
|
19
|
+
|
|
20
|
+
interface TelemetryEventBase {
|
|
21
|
+
/** Component instance id (from `UIComponent.id` or auto-generated). */
|
|
22
|
+
id: string
|
|
23
|
+
/** ComponentType, e.g. 'chart' / 'metric' / 'iframe'. */
|
|
24
|
+
componentType: ComponentType
|
|
25
|
+
/** Wall-clock timestamp (ms epoch) for cross-stack correlation. */
|
|
26
|
+
ts: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Discriminated union of all telemetry events emitted by mcp-ui.
|
|
31
|
+
* See §M.6.2 for field semantics + privacy rules.
|
|
32
|
+
*/
|
|
33
|
+
export type TelemetryEvent =
|
|
34
|
+
| ({ type: 'component:mounted' } & TelemetryEventBase)
|
|
35
|
+
| ({ type: 'component:rendered'; durationMs: number } & TelemetryEventBase)
|
|
36
|
+
| ({ type: 'component:unmounted' } & TelemetryEventBase)
|
|
37
|
+
| ({
|
|
38
|
+
type: 'validation:failed'
|
|
39
|
+
errorCount: number
|
|
40
|
+
firstErrorCode: string | null
|
|
41
|
+
} & TelemetryEventBase)
|
|
42
|
+
| ({ type: 'render:error'; errorMessage: string } & TelemetryEventBase)
|
|
43
|
+
| ({ type: 'action:dispatched'; actionName: string } & TelemetryEventBase)
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Consumer-supplied sink. Receives a batch of events (always an array,
|
|
47
|
+
* even for `bufferMs: 0` — single-element array in that case). Errors
|
|
48
|
+
* and rejected promises are caught silently by the dispatcher.
|
|
49
|
+
*/
|
|
50
|
+
export interface TelemetrySink {
|
|
51
|
+
(events: TelemetryEvent[]): void | Promise<void>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface TelemetryOptions {
|
|
55
|
+
/** Per-event base sampling rate, 0..1, default 1.0 (all events). */
|
|
56
|
+
sampleRate?: number
|
|
57
|
+
/** Buffer events and flush after N ms (default 100). 0 = no buffer. */
|
|
58
|
+
bufferMs?: number
|
|
59
|
+
/** Max buffered events before forced flush (default 50). */
|
|
60
|
+
bufferMax?: number
|
|
61
|
+
/** Per-event-type override on sampling (high-volume types can be lower). */
|
|
62
|
+
sampleByType?: Partial<Record<TelemetryEvent['type'], number>>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Dispatcher returned by `createTelemetryDispatcher`. Used internally by
|
|
67
|
+
* the Provider, exposed only so tests can drive it without React/Solid.
|
|
68
|
+
*/
|
|
69
|
+
export interface TelemetryDispatcher {
|
|
70
|
+
/**
|
|
71
|
+
* Push an event. Sampling + buffering applied transparently. Never throws.
|
|
72
|
+
*/
|
|
73
|
+
dispatch(event: TelemetryEvent): void
|
|
74
|
+
/**
|
|
75
|
+
* Force-flush the buffer. Useful on tab-hidden / unload, or for tests.
|
|
76
|
+
* Never throws.
|
|
77
|
+
*/
|
|
78
|
+
flush(): void
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const DEFAULT_BUFFER_MS = 100
|
|
82
|
+
const DEFAULT_BUFFER_MAX = 50
|
|
83
|
+
|
|
84
|
+
function shouldSample(
|
|
85
|
+
eventType: TelemetryEvent['type'],
|
|
86
|
+
options: TelemetryOptions | undefined
|
|
87
|
+
): boolean {
|
|
88
|
+
const perTypeRate = options?.sampleByType?.[eventType]
|
|
89
|
+
const rate = perTypeRate !== undefined ? perTypeRate : (options?.sampleRate ?? 1.0)
|
|
90
|
+
if (rate >= 1) return true
|
|
91
|
+
if (rate <= 0) return false
|
|
92
|
+
return Math.random() < rate
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Create a telemetry dispatcher. Pure function, no Solid context — exists
|
|
97
|
+
* separately from the Provider so it can be unit-tested in isolation.
|
|
98
|
+
*/
|
|
99
|
+
export function createTelemetryDispatcher(
|
|
100
|
+
sink: TelemetrySink,
|
|
101
|
+
options?: TelemetryOptions
|
|
102
|
+
): TelemetryDispatcher {
|
|
103
|
+
const buffer: TelemetryEvent[] = []
|
|
104
|
+
const bufferMs = options?.bufferMs ?? DEFAULT_BUFFER_MS
|
|
105
|
+
const bufferMax = options?.bufferMax ?? DEFAULT_BUFFER_MAX
|
|
106
|
+
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
|
107
|
+
|
|
108
|
+
function deliver(batch: TelemetryEvent[]): void {
|
|
109
|
+
try {
|
|
110
|
+
const result = sink(batch)
|
|
111
|
+
// Promise rejections are silenced too (fail-open, §M.6.1).
|
|
112
|
+
if (result && typeof (result as Promise<void>).then === 'function') {
|
|
113
|
+
;(result as Promise<void>).catch(() => {
|
|
114
|
+
/* silent */
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
/* silent */
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function flush(): void {
|
|
123
|
+
if (flushTimer !== undefined) {
|
|
124
|
+
clearTimeout(flushTimer)
|
|
125
|
+
flushTimer = undefined
|
|
126
|
+
}
|
|
127
|
+
if (buffer.length === 0) return
|
|
128
|
+
const batch = buffer.splice(0, buffer.length)
|
|
129
|
+
deliver(batch)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function dispatch(event: TelemetryEvent): void {
|
|
133
|
+
if (!shouldSample(event.type, options)) return
|
|
134
|
+
buffer.push(event)
|
|
135
|
+
if (buffer.length >= bufferMax) {
|
|
136
|
+
flush()
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (bufferMs <= 0) {
|
|
140
|
+
flush()
|
|
141
|
+
return
|
|
142
|
+
}
|
|
143
|
+
if (flushTimer === undefined) {
|
|
144
|
+
flushTimer = setTimeout(flush, bufferMs)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return { dispatch, flush }
|
|
149
|
+
}
|