Skip to content

Commit 3dac16c

Browse files
committed
Add basic MCP Server metrics
1 parent e25967e commit 3dac16c

File tree

11 files changed

+6051
-3
lines changed

11 files changed

+6051
-3
lines changed

packages/mcp-common/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717
"agents": "0.0.62",
1818
"cloudflare": "4.2.0",
1919
"hono": "4.7.6",
20-
"zod": "3.24.2"
20+
"zod": "3.24.2",
21+
"@repo/mcp-observability": "workspace:*"
2122
},
2223
"devDependencies": {
2324
"@cloudflare/types": "6.29.1",

packages/mcp-common/src/cloudflare-oauth-handler.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { zValidator } from '@hono/zod-validator'
22
import { Hono } from 'hono'
33
import { z } from 'zod'
44

5+
import { AuthUser, MetricsTracker } from '@repo/mcp-observability'
6+
57
import { getAuthorizationURL, getAuthToken, refreshAuthToken } from './cloudflare-auth'
68
import { McpError } from './mcp-error'
79

@@ -141,10 +143,20 @@ export async function handleTokenExchangeCallback(
141143
* @param scopes optional subset of scopes to request when handling authorization requests
142144
* @returns a Hono app with configured OAuth routes
143145
*/
144-
export function createAuthHandlers({ scopes }: { scopes: Record<string, string> }) {
146+
export function createAuthHandlers({
147+
serverInfo,
148+
scopes,
149+
metrics,
150+
}: {
151+
serverInfo: {
152+
name: string,
153+
version: string,
154+
}
155+
scopes: Record<string, string>
156+
metrics: MetricsTracker
157+
}) {
145158
{
146159
const app = new Hono<AuthContext>()
147-
148160
/**
149161
* OAuth Authorization Endpoint
150162
*
@@ -170,6 +182,13 @@ export function createAuthHandlers({ scopes }: { scopes: Record<string, string>
170182

171183
return Response.redirect(res.authUrl, 302)
172184
} catch (e) {
185+
metrics.logEvent(
186+
new AuthUser({
187+
mcpServer: serverInfo.name,
188+
mcpServerVersion: serverInfo.version,
189+
errorMessage: `Authorize Error: ${(e as any).toString()}`,
190+
})
191+
)
173192
if (e instanceof McpError) {
174193
return c.text(e.message, { status: e.code })
175194
}
@@ -231,9 +250,24 @@ export function createAuthHandlers({ scopes }: { scopes: Record<string, string>
231250
},
232251
})
233252

253+
metrics.logEvent(
254+
new AuthUser({
255+
userId: user.id,
256+
mcpServer: serverInfo.name,
257+
mcpServerVersion: serverInfo.version,
258+
})
259+
)
260+
234261
return Response.redirect(redirectTo, 302)
235262
} catch (e) {
236263
console.error(e)
264+
metrics.logEvent(
265+
new AuthUser({
266+
mcpServer: serverInfo.name,
267+
mcpServerVersion: serverInfo.version,
268+
errorMessage: `Callback Error: ${(e as any).toString()}`,
269+
})
270+
)
237271
if (e instanceof McpError) {
238272
return c.text(e.message, { status: e.code })
239273
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{
2+
"name": "@repo/mcp-observability",
3+
"version": "0.31.1",
4+
"private": true,
5+
"sideEffects": false,
6+
"type": "module",
7+
"main": "./src/index.ts",
8+
"directories": {
9+
"bin": "bin"
10+
},
11+
"dependencies": {
12+
"wrangler": "^4.10.0",
13+
"zod": "^3.24.2"
14+
},
15+
"devDependencies": {
16+
"@repo/typescript-config": "workspace:*"
17+
}
18+
}
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Generic metrics event utilities
3+
* @description Wrapper for RA binding
4+
*/
5+
export class MetricsTracker {
6+
constructor(
7+
private wae: AnalyticsEngineDataset,
8+
//private env: MetricsBindings['ENVIRONMENT']
9+
) {}
10+
11+
logEvent(event: MetricsEvent): void {
12+
try {
13+
this.wae.writeDataPoint(event.toDataPoint())
14+
} catch (e) {
15+
console.error(`Failed to log metrics event, ${e}`)
16+
// rethrow errors in vitest, but failsafe in other environments
17+
/*if (this.env === 'VITEST') {
18+
throw e
19+
}*/
20+
}
21+
}
22+
}
23+
24+
/**
25+
* MetricsEvent
26+
*
27+
* Each event type is stored with a different indexId and has an associated class which
28+
* maps a more ergonomic event object to a ReadyAnalyticsEvent
29+
*/
30+
export interface MetricsEvent {
31+
toDataPoint(): AnalyticsEngineDataPoint
32+
}
33+
34+
export enum MetricsEventIndexIds {
35+
AUTH_USER = 'auth_user',
36+
SESSION_START = 'session_start',
37+
TOOL_CALL = 'tool_call',
38+
}
39+
40+
/**
41+
* Utility functions to map named blob/double objects to an array
42+
* We do this so we don't have to annotate `blob1`, `blob2`, etc in comments.
43+
*
44+
* I prefer this to just writing it in an array because it'll be easier to reference
45+
* later when we are writing ready analytics queries.
46+
*
47+
* IMO named tuples and raw arrays aren't as ergonomic to work with, but they require less of this code below
48+
*/
49+
type Range1To20 =
50+
| 1
51+
| 2
52+
| 3
53+
| 4
54+
| 5
55+
| 6
56+
| 7
57+
| 8
58+
| 9
59+
| 10
60+
| 11
61+
| 12
62+
| 13
63+
| 14
64+
| 15
65+
| 16
66+
| 17
67+
| 18
68+
| 19
69+
| 20
70+
71+
type Blobs = {
72+
[key in `blob${Range1To20}`]?: string | null
73+
}
74+
75+
type Doubles = {
76+
[key in `double${Range1To20}`]?: number
77+
}
78+
79+
export class MetricsError extends Error {
80+
constructor(message: string) {
81+
super(message)
82+
this.name = 'MetricsError'
83+
}
84+
}
85+
86+
export function mapBlobs(blobs: Blobs): Array<string | null> {
87+
const blobsArray = new Array(Object.keys(blobs).length)
88+
for (const [key, value] of Object.entries(blobs)) {
89+
const match = key.match(/^blob(\d+)$/)
90+
if (match === null || match.length < 2) {
91+
// we should never hit this because of the typedefinitions above,
92+
// but this error is for safety
93+
throw new MetricsError('Failed to map blobs, invalid key')
94+
}
95+
const index = parseInt(match[1], 10)
96+
if (isNaN(index)) {
97+
// we should never hit this because of the typedefinitions above,
98+
// but this error is for safety
99+
throw new MetricsError('Failed to map blobs, invalid index')
100+
}
101+
if (index - 1 >= blobsArray.length) {
102+
throw new MetricsError('Failed to map blobs, missing blob')
103+
}
104+
blobsArray[index - 1] = value
105+
}
106+
return blobsArray
107+
}
108+
109+
export function mapDoubles(doubles: Doubles): number[] {
110+
const doublesArray = new Array(Object.keys(doubles).length)
111+
for (const [key, value] of Object.entries(doubles)) {
112+
const match = key.match(/^double(\d+)$/)
113+
if (match === null || match.length < 2) {
114+
// we should never hit this because of the typedefinitions above,
115+
// but this error is for safety
116+
throw new MetricsError(': Failed to map doubles, invalid key')
117+
}
118+
const index = parseInt(match[1], 10)
119+
if (isNaN(index)) {
120+
// we should never hit this because of the typedefinitions above,
121+
// but this error is for safety
122+
throw new MetricsError('Failed to map doubles, invalid index')
123+
}
124+
if (index - 1 >= doublesArray.length) {
125+
throw new MetricsError('Failed to map doubles, missing blob')
126+
}
127+
doublesArray[index - 1] = value
128+
}
129+
return doublesArray
130+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
declare module 'cloudflare:test' {
2+
interface ProvidedEnv extends Env {}
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from "./analytics-engine"
2+
export * from "./metrics"
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { MetricsEvent, MetricsEventIndexIds, mapBlobs, mapDoubles } from "./analytics-engine"
2+
3+
export type MetricsBindings = {
4+
MCP_METRICS: AnalyticsEngineDataset
5+
}
6+
7+
export class ToolCall implements MetricsEvent {
8+
constructor(
9+
private toolCall: {
10+
userId?: string
11+
mcpServer: string
12+
mcpServerVersion: string
13+
sessionId: string
14+
toolName: string
15+
errorCode?: number
16+
}
17+
) {}
18+
19+
toDataPoint(): AnalyticsEngineDataPoint {
20+
return {
21+
indexes: [MetricsEventIndexIds.TOOL_CALL],
22+
blobs: mapBlobs({
23+
blob1: this.toolCall.userId,
24+
blob2: this.toolCall.mcpServer,
25+
blob3: this.toolCall.mcpServerVersion,
26+
blob4: this.toolCall.sessionId,
27+
blob5: this.toolCall.toolName,
28+
}),
29+
doubles: mapDoubles({
30+
double1: this.toolCall.errorCode
31+
})
32+
}
33+
}
34+
}
35+
36+
export class SessionStart implements MetricsEvent {
37+
constructor(
38+
private session: {
39+
userId?: string,
40+
mcpServer: string,
41+
mcpServerVersion: string,
42+
sessionId: string,
43+
client: string
44+
}
45+
) {}
46+
47+
toDataPoint(): AnalyticsEngineDataPoint {
48+
return {
49+
indexes: [MetricsEventIndexIds.SESSION_START],
50+
blobs: mapBlobs({
51+
blob1: this.session.userId,
52+
blob2: this.session.mcpServer,
53+
blob3: this.session.mcpServerVersion,
54+
blob4: this.session.sessionId,
55+
blob5: this.session.client,
56+
}),
57+
}
58+
}
59+
}
60+
61+
export class AuthUser implements MetricsEvent {
62+
constructor(
63+
private authUser: {
64+
userId?: string,
65+
mcpServer: string,
66+
mcpServerVersion: string,
67+
errorMessage?: string
68+
}
69+
) {}
70+
71+
toDataPoint(): AnalyticsEngineDataPoint {
72+
return {
73+
indexes: [MetricsEventIndexIds.SESSION_START],
74+
blobs: mapBlobs({
75+
blob1: this.authUser.userId,
76+
blob2: this.authUser.mcpServer,
77+
blob3: this.authUser.mcpServerVersion,
78+
blob4: this.authUser.errorMessage
79+
}),
80+
}
81+
}
82+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"extends": "@repo/typescript-config/workers.json",
3+
"include": ["**/*.ts"],
4+
"exclude": ["node_modules", "tsconfig.json"]
5+
}

0 commit comments

Comments
 (0)