Skip to content

Commit 9a56230

Browse files
authored
feat(telemetry): add page_view and event to traces (#387)
* chore(sentry): add page_view traces, creates tracking analytics fn * feat(telemtry): track client register/unregister * feat(telemetry-server): track start/stop workload * feat(telemetry): track workload creation from registry * feat(telemetry): track run custom workload * chore(telemetry): add analytics source and types attrs simplifying filter * test: traking event run mcp * fix: conflicts * chore: add headers for identifying studio client * fix: get only release tag without commit in development
1 parent d7e9a9e commit 9a56230

File tree

10 files changed

+122
-5
lines changed

10 files changed

+122
-5
lines changed

main/src/util.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ function getVersionFromGit(): string {
1616
stdio: 'pipe',
1717
}).trim()
1818

19-
return describe.replace(/^v/, '')
19+
const version = describe.replace(/^v/, '').split('-')[0]
20+
return version ?? app.getVersion()
2021
} catch {
2122
return app.getVersion()
2223
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import * as Sentry from '@sentry/electron/renderer'
2+
3+
export function trackEvent(eventName: string, data = {}) {
4+
Sentry.startSpan(
5+
{
6+
name: eventName,
7+
op: 'user.event',
8+
attributes: {
9+
'analytics.source': 'tracking',
10+
'analytics.type': 'event',
11+
...data,
12+
timestamp: new Date().toISOString(),
13+
},
14+
},
15+
() => {}
16+
)
17+
}
18+
19+
export function trackPageView(pageName: string, data = {}) {
20+
Sentry.startSpan(
21+
{
22+
name: `Page: ${pageName}`,
23+
op: 'page_view',
24+
attributes: {
25+
'analytics.source': 'tracking',
26+
'analytics.type': 'page_view',
27+
'page.name': pageName,
28+
'action.type': 'navigation',
29+
...data,
30+
timestamp: new Date().toISOString(),
31+
},
32+
},
33+
() => {}
34+
)
35+
}

renderer/src/features/clients/components/card-client.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { Switch } from '@/common/components/ui/switch'
99
import { useMutationRegisterClient } from '../hooks/use-mutation-register-client'
1010
import { useMutationUnregisterClient } from '../hooks/use-mutation-unregister-client'
11+
import { trackEvent } from '@/common/lib/analytics'
1112

1213
// There is an issue with openAPI generator in BE, similar issue https://github.com/stacklok/toolhive/issues/780
1314
const CLIENT_TYPE_LABEL_MAP = {
@@ -46,12 +47,18 @@ export function CardClient({ client }: { client: ClientMcpClientStatus }) {
4647
name: client.client_type ?? '',
4748
},
4849
})
50+
trackEvent(`Client ${client.client_type} unregistered`, {
51+
client: client.client_type,
52+
})
4953
} else {
5054
registerClient({
5155
body: {
5256
name: client.client_type ?? '',
5357
},
5458
})
59+
trackEvent(`Client ${client.client_type} registered`, {
60+
client: client.client_type,
61+
})
5562
}
5663
}}
5764
/>

renderer/src/features/mcp-servers/components/__tests__/card-mcp-server.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ const router = createTestRouter(() => (
1111
status="running"
1212
statusContext={undefined}
1313
url=""
14+
transport="http"
1415
/>
1516
))
1617

renderer/src/features/mcp-servers/components/card-mcp-server.tsx

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,14 +28,20 @@ import { useSearch } from '@tanstack/react-router'
2828
import { getApiV1BetaRegistryByNameServersByServerName } from '@/common/api/generated/sdk.gen'
2929
import { useEffect, useState } from 'react'
3030
import { twMerge } from 'tailwind-merge'
31+
import { trackEvent } from '@/common/lib/analytics'
3132

3233
type CardContentMcpServerProps = {
3334
status: WorkloadsWorkload['status']
3435
statusContext: WorkloadsWorkload['status_context']
3536
name: string
37+
transport: WorkloadsWorkload['transport_type']
3638
}
3739

38-
function CardContentMcpServer({ name, status }: CardContentMcpServerProps) {
40+
function CardContentMcpServer({
41+
name,
42+
status,
43+
transport,
44+
}: CardContentMcpServerProps) {
3945
const isRunning = status === 'running'
4046
const { mutateAsync: restartMutate, isPending: isRestartPending } =
4147
useMutationRestartServer({
@@ -55,18 +61,26 @@ function CardContentMcpServer({ name, status }: CardContentMcpServerProps) {
5561
isPending={isRestartPending || isStopPending}
5662
mutate={() => {
5763
if (isRunning) {
58-
return stopMutate({
64+
stopMutate({
5965
path: {
6066
name,
6167
},
6268
})
69+
return trackEvent(`Workload ${name} stopped`, {
70+
workload: name,
71+
transport,
72+
})
6373
}
6474

65-
return restartMutate({
75+
restartMutate({
6676
path: {
6777
name,
6878
},
6979
})
80+
return trackEvent(`Workload ${name} started`, {
81+
workload: name,
82+
transport,
83+
})
7084
}}
7185
/>
7286
</div>
@@ -80,11 +94,13 @@ export function CardMcpServer({
8094
status,
8195
statusContext,
8296
url,
97+
transport,
8398
}: {
8499
name: string
85100
status: WorkloadsWorkload['status']
86101
statusContext: WorkloadsWorkload['status_context']
87102
url: string
103+
transport: WorkloadsWorkload['transport_type']
88104
}) {
89105
const confirm = useConfirm()
90106
const { mutateAsync: deleteServer, isPending: isDeletePending } =
@@ -265,6 +281,7 @@ export function CardMcpServer({
265281
status={status}
266282
statusContext={statusContext}
267283
name={name}
284+
transport={transport}
268285
/>
269286
</Card>
270287
)

renderer/src/features/mcp-servers/components/grid-cards-mcp-server.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export function GridCardsMcpServers({
4949
status={mcpServer.status}
5050
statusContext={mcpServer.status_context}
5151
url={mcpServer.url ?? ''}
52+
transport={mcpServer.transport_type}
5253
/>
5354
) : null
5455
)}

renderer/src/features/mcp-servers/lib/__tests__/orchestrate-run-custom-server.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,13 @@ import { toast } from 'sonner'
77
import { server } from '@/common/mocks/node'
88
import { http, HttpResponse } from 'msw'
99
import { mswEndpoint } from '@/common/mocks/msw-endpoint'
10+
import * as Sentry from '@sentry/electron/renderer'
11+
12+
vi.mock('@sentry/electron/renderer', () => ({
13+
startSpan: vi.fn(),
14+
}))
15+
16+
const mockStartSpan = vi.mocked(Sentry.startSpan)
1017

1118
vi.mock('sonner', async () => {
1219
const original = await vi.importActual<typeof import('sonner')>('sonner')
@@ -73,6 +80,22 @@ it('submits without any optional fields', async () => {
7380
'"foo-bar" started successfully.',
7481
expect.any(Object)
7582
)
83+
84+
expect(mockStartSpan).toHaveBeenCalledWith(
85+
{
86+
name: 'Workload foo-bar started',
87+
op: 'user.event',
88+
attributes: {
89+
'analytics.source': 'tracking',
90+
'analytics.type': 'event',
91+
workload: 'foo-bar',
92+
transport: 'stdio',
93+
'route.pathname': '/',
94+
timestamp: expect.any(String),
95+
},
96+
},
97+
expect.any(Function)
98+
)
7699
})
77100

78101
it('handles new secrets properly', async () => {

renderer/src/features/mcp-servers/lib/orchestrate-run-custom-server.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { Button } from '@/common/components/ui/button'
1919
import type { FormSchemaRunMcpCommand } from './form-schema-run-mcp-server-with-command'
2020
import type { DefinedSecret, PreparedSecret } from '@/common/types/secrets'
2121
import { prepareSecretsWithoutNamingCollision } from '@/common/lib/secrets/prepare-secrets-without-naming-collision'
22+
import { trackEvent } from '@/common/lib/analytics'
2223

2324
type SaveSecretFn = UseMutateAsyncFunction<
2425
V1CreateSecretResponse,
@@ -289,6 +290,11 @@ export async function orchestrateRunCustomServer({
289290
await createWorkload({
290291
body: createRequest,
291292
})
293+
trackEvent(`Workload ${data.name} started`, {
294+
workload: data.name,
295+
transport: data.transport,
296+
'route.pathname': '/',
297+
})
292298
} catch (error) {
293299
toast.error(
294300
[

renderer/src/features/registry-servers/hooks/use-run-from-registry.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { toast } from 'sonner'
2626
import { Button } from '@/common/components/ui/button'
2727
import { prepareSecretsWithoutNamingCollision } from '@/common/lib/secrets/prepare-secrets-without-naming-collision'
2828
import { Link } from '@tanstack/react-router'
29+
import { trackEvent } from '@/common/lib/analytics'
2930

3031
type InstallServerCheck = (
3132
data: FormSchemaRunFromRegistry
@@ -49,6 +50,12 @@ export function useRunFromRegistry({
4950
})
5051
const { mutateAsync: createWorkload } = useMutation({
5152
...postApiV1BetaWorkloadsMutation(),
53+
onSuccess: (data) => {
54+
trackEvent(`Workload ${data.name} started`, {
55+
workload: data.name,
56+
'route.pathname': '/registry',
57+
})
58+
},
5259
})
5360

5461
const handleSettled = useCallback<InstallServerCheck>(

renderer/src/renderer.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import log from 'electron-log/renderer'
1616

1717
import './index.css'
1818
import { ConfirmProvider } from './common/contexts/confirm/provider'
19+
import { trackPageView } from './common/lib/analytics'
1920

2021
// Sentry setup
2122
Sentry.init({
@@ -58,15 +59,33 @@ const router = createRouter({
5859
history: memoryHistory,
5960
})
6061

62+
router.subscribe('onLoad', (data) => {
63+
trackPageView(data.toLocation.pathname, {
64+
'route.from': data.fromLocation?.pathname ?? '/',
65+
'route.pathname': data.toLocation.pathname,
66+
'route.search': JSON.stringify(data.toLocation.search),
67+
'route.hash': data.toLocation.hash,
68+
})
69+
})
70+
6171
if (!window.electronAPI || !window.electronAPI.getToolhivePort) {
6272
log.error('ToolHive port API not available in renderer')
6373
}
6474

6575
;(async () => {
6676
try {
6777
const port = await window.electronAPI.getToolhivePort()
78+
const appVersion = await window.electronAPI.getAppVersion()
6879
const baseUrl = `http://localhost:${port}`
69-
client.setConfig({ baseUrl })
80+
81+
client.setConfig({
82+
baseUrl,
83+
headers: {
84+
'X-Client-Type': 'toolhive-studio',
85+
'X-Client-Version': appVersion,
86+
'X-Client-Platform': window.electronAPI.platform,
87+
},
88+
})
7089
} catch (e) {
7190
log.error('Failed to get ToolHive port from main process: ', e)
7291
throw e

0 commit comments

Comments
 (0)