Skip to content

Commit 0f7cba8

Browse files
authored
add analytics tracking for prompts (#246)
Intercepts all prompts added with `addPrompt` and adds analytics tracking if an analytics instance is configured. Does not capture any specific arguments, but does track whether any arguments were supplied at all.
1 parent 7fbe88a commit 0f7cba8

File tree

4 files changed

+203
-3
lines changed

4 files changed

+203
-3
lines changed

pkgs/dart_mcp_server/lib/src/server.dart

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,51 @@ final class DartMCPServer extends MCPServer
207207
validateArguments: validateArguments,
208208
);
209209
}
210+
211+
@override
212+
void addPrompt(
213+
Prompt prompt,
214+
FutureOr<GetPromptResult> Function(GetPromptRequest) impl,
215+
) {
216+
// For type promotion.
217+
final analytics = this.analytics;
218+
219+
super.addPrompt(
220+
prompt,
221+
analytics == null
222+
? impl
223+
: (request) async {
224+
final watch = Stopwatch()..start();
225+
GetPromptResult? result;
226+
try {
227+
return result = await impl(request);
228+
} finally {
229+
watch.stop();
230+
try {
231+
analytics.send(
232+
Event.dartMCPEvent(
233+
client: clientInfo.name,
234+
clientVersion: clientInfo.version,
235+
serverVersion: implementation.version,
236+
type: AnalyticsEvent.getPrompt.name,
237+
additionalData: GetPromptMetrics(
238+
name: request.name,
239+
success: result != null && result.messages.isNotEmpty,
240+
elapsedMilliseconds: watch.elapsedMilliseconds,
241+
withArguments: request.arguments?.isNotEmpty == true,
242+
),
243+
),
244+
);
245+
} catch (e) {
246+
log(
247+
LoggingLevel.warning,
248+
'Error sending analytics event: $e',
249+
);
250+
}
251+
}
252+
},
253+
);
254+
}
210255
}
211256

212257
/// Creates a `Sink<String>` for [logFile].

pkgs/dart_mcp_server/lib/src/utils/analytics.dart

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ abstract interface class AnalyticsSupport {
1414
Analytics? get analytics;
1515
}
1616

17-
enum AnalyticsEvent { callTool, readResource }
17+
enum AnalyticsEvent { callTool, readResource, getPrompt }
1818

1919
/// The metrics for a resources/read MCP handler.
2020
final class ReadResourceMetrics extends CustomMetrics {
@@ -43,6 +43,36 @@ final class ReadResourceMetrics extends CustomMetrics {
4343
};
4444
}
4545

46+
/// The metrics for a prompts/get MCP handler.
47+
final class GetPromptMetrics extends CustomMetrics {
48+
/// The name of the prompt that was retrieved.
49+
final String name;
50+
51+
/// Whether or not the prompt was given with arguments.
52+
final bool withArguments;
53+
54+
/// The time it took to generate the prompt.
55+
final int elapsedMilliseconds;
56+
57+
/// Whether or not the prompt call succeeded.
58+
final bool success;
59+
60+
GetPromptMetrics({
61+
required this.name,
62+
required this.withArguments,
63+
required this.elapsedMilliseconds,
64+
required this.success,
65+
});
66+
67+
@override
68+
Map<String, Object> toMap() => {
69+
_name: name,
70+
_withArguments: withArguments,
71+
_elapsedMilliseconds: elapsedMilliseconds,
72+
_success: success,
73+
};
74+
}
75+
4676
/// The metrics for a tools/call MCP handler.
4777
final class CallToolMetrics extends CustomMetrics {
4878
/// The name of the tool that was invoked.
@@ -108,5 +138,7 @@ const _elapsedMilliseconds = 'elapsedMilliseconds';
108138
const _failureReason = 'failureReason';
109139
const _kind = 'kind';
110140
const _length = 'length';
141+
const _name = 'name';
111142
const _success = 'success';
112143
const _tool = 'tool';
144+
const _withArguments = 'withArguments';

pkgs/dart_mcp_server/test/dart_tooling_mcp_server_test.dart

Lines changed: 121 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ void main() {
4747
'client': server.clientInfo.name,
4848
'clientVersion': server.clientInfo.version,
4949
'serverVersion': server.implementation.version,
50-
'type': 'callTool',
50+
'type': AnalyticsEvent.callTool.name,
5151
'tool': 'hello',
5252
'success': true,
5353
'elapsedMilliseconds': isA<int>(),
@@ -85,7 +85,7 @@ void main() {
8585
'client': server.clientInfo.name,
8686
'clientVersion': server.clientInfo.version,
8787
'serverVersion': server.implementation.version,
88-
'type': 'callTool',
88+
'type': AnalyticsEvent.callTool.name,
8989
'tool': tool.name,
9090
'success': false,
9191
'elapsedMilliseconds': isA<int>(),
@@ -96,6 +96,125 @@ void main() {
9696
}
9797
});
9898

99+
group('are sent for prompts', () {
100+
final helloPrompt = Prompt(
101+
name: 'hello',
102+
arguments: [PromptArgument(name: 'name', required: false)],
103+
);
104+
GetPromptResult getHelloPrompt(GetPromptRequest request) {
105+
assert(request.name == helloPrompt.name);
106+
if (request.arguments?['throw'] == true) {
107+
throw StateError('Oh no!');
108+
}
109+
return GetPromptResult(
110+
messages: [
111+
PromptMessage(
112+
role: Role.user,
113+
content: Content.text(text: 'hello'),
114+
),
115+
if (request.arguments?['name'] case final name?)
116+
PromptMessage(
117+
role: Role.user,
118+
content: Content.text(text: ', my name is $name'),
119+
),
120+
],
121+
);
122+
}
123+
124+
setUp(() {
125+
server.addPrompt(helloPrompt, getHelloPrompt);
126+
});
127+
128+
test('with no arguments', () async {
129+
final result = await testHarness.getPrompt(
130+
GetPromptRequest(name: helloPrompt.name),
131+
);
132+
expect((result.messages.single.content as TextContent).text, 'hello');
133+
expect(result.messages.single.role, Role.user);
134+
expect(
135+
analytics.sentEvents.single,
136+
isA<Event>()
137+
.having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
138+
.having(
139+
(e) => e.eventData,
140+
'eventData',
141+
equals({
142+
'client': server.clientInfo.name,
143+
'clientVersion': server.clientInfo.version,
144+
'serverVersion': server.implementation.version,
145+
'type': AnalyticsEvent.getPrompt.name,
146+
'name': helloPrompt.name,
147+
'success': true,
148+
'elapsedMilliseconds': isA<int>(),
149+
'withArguments': false,
150+
}),
151+
),
152+
);
153+
});
154+
155+
test('with arguments', () async {
156+
final result = await testHarness.getPrompt(
157+
GetPromptRequest(name: helloPrompt.name, arguments: {'name': 'Bob'}),
158+
);
159+
expect((result.messages[0].content as TextContent).text, 'hello');
160+
expect(result.messages[0].role, Role.user);
161+
expect(
162+
(result.messages[1].content as TextContent).text,
163+
', my name is Bob',
164+
);
165+
expect(result.messages[1].role, Role.user);
166+
expect(
167+
analytics.sentEvents.single,
168+
isA<Event>()
169+
.having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
170+
.having(
171+
(e) => e.eventData,
172+
'eventData',
173+
equals({
174+
'client': server.clientInfo.name,
175+
'clientVersion': server.clientInfo.version,
176+
'serverVersion': server.implementation.version,
177+
'type': AnalyticsEvent.getPrompt.name,
178+
'name': helloPrompt.name,
179+
'success': true,
180+
'elapsedMilliseconds': isA<int>(),
181+
'withArguments': true,
182+
}),
183+
),
184+
);
185+
});
186+
187+
test('even if they throw', () async {
188+
try {
189+
await testHarness.getPrompt(
190+
GetPromptRequest(
191+
name: helloPrompt.name,
192+
arguments: {'throw': true},
193+
),
194+
);
195+
} catch (_) {}
196+
expect(
197+
analytics.sentEvents.single,
198+
isA<Event>()
199+
.having((e) => e.eventName, 'eventName', DashEvent.dartMCPEvent)
200+
.having(
201+
(e) => e.eventData,
202+
'eventData',
203+
equals({
204+
'client': server.clientInfo.name,
205+
'clientVersion': server.clientInfo.version,
206+
'serverVersion': server.implementation.version,
207+
'type': AnalyticsEvent.getPrompt.name,
208+
'name': helloPrompt.name,
209+
'success': false,
210+
'elapsedMilliseconds': isA<int>(),
211+
'withArguments': true,
212+
}),
213+
),
214+
);
215+
});
216+
});
217+
99218
test('Changelog version matches dart server version', () {
100219
final changelogFile = File('CHANGELOG.md');
101220
expect(

pkgs/dart_mcp_server/test/test_harness.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,10 @@ class TestHarness {
196196
await Future<void>.delayed(Duration(milliseconds: 100 * tryCount));
197197
}
198198
}
199+
200+
/// Calls [getPrompt] on the [mcpServerConnection].
201+
Future<GetPromptResult> getPrompt(GetPromptRequest request) =>
202+
mcpServerConnection.getPrompt(request);
199203
}
200204

201205
/// The debug session for a single app.

0 commit comments

Comments
 (0)