Skip to content

Commit 31734dc

Browse files
fix(web): count canonical MCP sources in analytics (#1651)
* fix(web): count canonical MCP sources in analytics * chore: update changelog for MCP analytics fix * test(web): verify MCP analytics classification
1 parent a0ee223 commit 31734dc

7 files changed

Lines changed: 134 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1717
- Upgraded `smol-toml` to `1.8.0`. [#1644](https://github.com/sourcebot-dev/sourcebot/pull/1644)
1818
- Upgraded `hono` to `4.13.7`. [#1643](https://github.com/sourcebot-dev/sourcebot/pull/1643)
1919
- Upgraded `nodemailer` to `9.1.1`. [#1642](https://github.com/sourcebot-dev/sourcebot/pull/1642)
20+
- [EE] Fixed MCP activity using the canonical source label being omitted from analytics. [#1651](https://github.com/sourcebot-dev/sourcebot/pull/1651)
2021

2122
## [5.1.11] - 2026-09-10
2223

‎packages/web/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,7 @@
211211
},
212212
"devDependencies": {
213213
"@asteasolutions/zod-to-openapi": "7.3.4",
214+
"@electric-sql/pglite": "0.5.8",
214215
"@eslint/eslintrc": "^3",
215216
"@react-email/ui": "6.1.4",
216217
"@react-grab/mcp": "^0.1.23",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// @vitest-environment node
2+
3+
import { PGlite } from '@electric-sql/pglite';
4+
import { Prisma } from '@sourcebot/db';
5+
import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest';
6+
7+
const mocks = vi.hoisted(() => ({
8+
queryRaw: vi.fn(),
9+
findFirst: vi.fn(),
10+
}));
11+
12+
vi.mock('@/middleware/sew', () => ({
13+
sew: (callback: () => unknown) => callback(),
14+
}));
15+
vi.mock('@/middleware/withAuth', () => ({
16+
withAuth: (callback: (context: unknown) => unknown) => callback({
17+
org: { id: 1 },
18+
role: 'OWNER',
19+
prisma: {
20+
$queryRaw: mocks.queryRaw,
21+
audit: { findFirst: mocks.findFirst },
22+
},
23+
}),
24+
}));
25+
vi.mock('@/middleware/withMinimumOrgRole', () => ({
26+
withMinimumOrgRole: (
27+
_role: unknown,
28+
_minimumRole: unknown,
29+
callback: () => unknown,
30+
) => callback(),
31+
}));
32+
vi.mock('@/lib/entitlements', () => ({
33+
hasEntitlement: vi.fn().mockResolvedValue(true),
34+
}));
35+
vi.mock('@sourcebot/shared', () => ({
36+
env: { SOURCEBOT_EE_AUDIT_RETENTION_DAYS: 180 },
37+
}));
38+
39+
const { getAnalytics } = await import('./actions');
40+
41+
const database = new PGlite();
42+
43+
beforeAll(async () => {
44+
await database.exec(`
45+
CREATE TABLE "Audit" (
46+
"timestamp" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
47+
action text NOT NULL,
48+
"actorId" text NOT NULL,
49+
metadata jsonb,
50+
"orgId" integer NOT NULL
51+
);
52+
`);
53+
});
54+
55+
afterAll(async () => {
56+
await database.close();
57+
});
58+
59+
beforeEach(async () => {
60+
vi.clearAllMocks();
61+
await database.exec(`
62+
TRUNCATE TABLE "Audit";
63+
INSERT INTO "Audit" (action, "actorId", metadata, "orgId") VALUES
64+
('user.performed_code_search', 'canonical-user', '{"source":"sourcebot-mcp-server"}', 1),
65+
('user.fetched_file_source', 'canonical-user', '{"source":"sourcebot-mcp-server"}', 1),
66+
('user.fetched_file_tree', 'legacy-user', '{"source":"mcp"}', 1),
67+
('user.performed_code_search', 'api-user', '{}', 1),
68+
('user.performed_code_search', 'web-user', '{"source":"sourcebot-web-client"}', 1);
69+
`);
70+
mocks.queryRaw.mockImplementation(async (queryParts: TemplateStringsArray, ...parameters: unknown[]) => {
71+
const query = Prisma.sql(queryParts, ...parameters as never[]);
72+
const result = await database.query(query.text, query.values as never[]);
73+
return result.rows;
74+
});
75+
mocks.findFirst.mockResolvedValue(null);
76+
});
77+
78+
describe('getAnalytics', () => {
79+
test('classifies canonical and legacy MCP audit sources as MCP activity', async () => {
80+
const result = await getAnalytics();
81+
82+
if ('statusCode' in result) {
83+
throw new Error(result.message);
84+
}
85+
86+
const daily = result.rows.find((row) => row.period === 'day');
87+
expect(daily).toMatchObject({
88+
active_users: 4,
89+
web_active_users: 1,
90+
non_web_active_users: 3,
91+
mcp_requests: 3,
92+
mcp_active_users: 2,
93+
api_requests: 1,
94+
api_active_users: 1,
95+
});
96+
});
97+
});

‎packages/web/src/ee/features/analytics/actions.ts‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { hasEntitlement } from "@/lib/entitlements";
1010
import { ErrorCode } from "@/lib/errorCodes";
1111
import { StatusCodes } from "http-status-codes";
1212
import { OrgRole } from "@sourcebot/db";
13+
import { LEGACY_MCP_SERVER_SOURCE, MCP_SERVER_SOURCE } from "@/ee/features/mcp/constants";
1314

1415
export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError> => sew(() =>
1516
withAuth(async ({ org, role, prisma }) =>
@@ -30,7 +31,13 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>
3031
date_trunc('month', "timestamp") AS month,
3132
action,
3233
"actorId",
33-
metadata
34+
metadata,
35+
CASE
36+
WHEN metadata->>'source' IN (${LEGACY_MCP_SERVER_SOURCE}, ${MCP_SERVER_SOURCE}) THEN 'mcp'
37+
WHEN metadata->>'source' IS NULL
38+
OR metadata->>'source' NOT LIKE 'sourcebot-%' THEN 'api'
39+
ELSE 'sourcebot'
40+
END AS source_category
3441
FROM "Audit"
3542
WHERE "orgId" = ${org.id}
3643
AND action IN (
@@ -85,7 +92,7 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>
8592
8693
-- Global active users (any action, any source; excludes web repo listings)
8794
COUNT(DISTINCT c."actorId") FILTER (
88-
WHERE NOT (c.action = 'user.listed_repos' AND c.metadata->>'source' LIKE 'sourcebot-%')
95+
WHERE NOT (c.action = 'user.listed_repos' AND c.source_category = 'sourcebot')
8996
) AS active_users,
9097
9198
-- Web App metrics
@@ -116,26 +123,23 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>
116123
117124
-- MCP + API combined active users (any non-web source)
118125
COUNT(DISTINCT c."actorId") FILTER (
119-
WHERE c.metadata->>'source' IS NULL
120-
OR c.metadata->>'source' NOT LIKE 'sourcebot-%'
126+
WHERE c.source_category IN ('mcp', 'api')
121127
) AS non_web_active_users,
122128
123-
-- MCP metrics (source = 'mcp')
129+
-- MCP metrics (canonical source plus the legacy 'mcp' source)
124130
COUNT(*) FILTER (
125-
WHERE c.metadata->>'source' = 'mcp'
131+
WHERE c.source_category = 'mcp'
126132
) AS mcp_requests,
127133
COUNT(DISTINCT c."actorId") FILTER (
128-
WHERE c.metadata->>'source' = 'mcp'
134+
WHERE c.source_category = 'mcp'
129135
) AS mcp_active_users,
130136
131-
-- API metrics (source IS NULL or not sourcebot-*/mcp)
137+
-- API metrics (source IS NULL or not a Sourcebot/MCP source)
132138
COUNT(*) FILTER (
133-
WHERE c.metadata->>'source' IS NULL
134-
OR (c.metadata->>'source' NOT LIKE 'sourcebot-%' AND c.metadata->>'source' != 'mcp')
139+
WHERE c.source_category = 'api'
135140
) AS api_requests,
136141
COUNT(DISTINCT c."actorId") FILTER (
137-
WHERE c.metadata->>'source' IS NULL
138-
OR (c.metadata->>'source' NOT LIKE 'sourcebot-%' AND c.metadata->>'source' != 'mcp')
142+
WHERE c.source_category = 'api'
139143
) AS api_active_users
140144
141145
FROM core c
@@ -179,4 +183,4 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>
179183
oldestRecordDate: oldestRecord?.timestamp ?? null,
180184
};
181185
}))
182-
);
186+
);

‎packages/web/src/ee/features/mcp/constants.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
export const MCP_DOCS_URL = "https://docs.sourcebot.dev/docs/features/mcp-server";
2+
3+
export const MCP_SERVER_SOURCE = 'sourcebot-mcp-server';
4+
export const LEGACY_MCP_SERVER_SOURCE = 'mcp';
25
export const PRICING_URL = "https://www.sourcebot.dev/pricing";
36

47
// Surfaced to MCP clients (and the programmatic blocking endpoint) when the

‎packages/web/src/ee/features/mcp/server.ts‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
globDefinition,
2929
updateSkillDefinition,
3030
} from '@/features/tools';
31+
import { MCP_SERVER_SOURCE } from './constants';
3132

3233
const dedent = _dedent.withOptions({ alignValues: true });
3334

@@ -41,15 +42,15 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo
4142
}
4243

4344
const server = new McpServer({
44-
name: 'sourcebot-mcp-server',
45+
name: MCP_SERVER_SOURCE,
4546
version: SOURCEBOT_VERSION,
4647
});
4748

4849
const configuredLanguageModels = await getConfiguredLanguageModelsInfo();
4950
const hasLanguageModels = configuredLanguageModels.length > 0;
5051

5152
const toolContext: ToolContext = {
52-
source: 'sourcebot-mcp-server',
53+
source: MCP_SERVER_SOURCE,
5354
}
5455

5556
registerMcpTool(server, grepDefinition, toolContext);
@@ -89,7 +90,7 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo
8990
const models = await getConfiguredLanguageModelsInfo();
9091
captureEvent('tool_used', {
9192
toolName: 'list_language_models',
92-
source: 'sourcebot-mcp-server',
93+
source: MCP_SERVER_SOURCE,
9394
success: true,
9495
});
9596
return { content: [{ type: "text", text: JSON.stringify(models) }] };
@@ -132,13 +133,13 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo
132133
repos: request.repos,
133134
languageModel: request.languageModel,
134135
visibility: request.visibility as ChatVisibility | undefined,
135-
source: 'mcp',
136+
source: MCP_SERVER_SOURCE,
136137
});
137138

138139
if (isServiceError(result)) {
139140
captureEvent('tool_used', {
140141
toolName: 'ask_codebase',
141-
source: 'sourcebot-mcp-server',
142+
source: MCP_SERVER_SOURCE,
142143
success: false,
143144
});
144145
return {
@@ -148,7 +149,7 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo
148149

149150
captureEvent('tool_used', {
150151
toolName: 'ask_codebase',
151-
source: 'sourcebot-mcp-server',
152+
source: MCP_SERVER_SOURCE,
152153
success: true,
153154
});
154155

‎yarn.lock‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1996,6 +1996,13 @@ __metadata:
19961996
languageName: node
19971997
linkType: hard
19981998

1999+
"@electric-sql/pglite@npm:0.5.8":
2000+
version: 0.5.8
2001+
resolution: "@electric-sql/pglite@npm:0.5.8"
2002+
checksum: 10c0/02af377bb73428c1fb4559683dac0a4236d62320d5cda62ad4fc45d662a74fb011f767a161cb52b9160df734066376fc80f0d1506c2bbe9506490753fa12b532
2003+
languageName: node
2004+
linkType: hard
2005+
19992006
"@emnapi/core@npm:1.10.0":
20002007
version: 1.10.0
20012008
resolution: "@emnapi/core@npm:1.10.0"
@@ -9026,6 +9033,7 @@ __metadata:
90269033
"@codemirror/search": "npm:^6.5.6"
90279034
"@codemirror/state": "npm:^6.4.1"
90289035
"@codemirror/view": "npm:^6.33.0"
9036+
"@electric-sql/pglite": "npm:0.5.8"
90299037
"@eslint/eslintrc": "npm:^3"
90309038
"@floating-ui/react": "npm:^0.27.2"
90319039
"@gitbeaker/rest": "npm:^40.5.1"

0 commit comments

Comments
 (0)