diff --git a/CHANGELOG.md b/CHANGELOG.md index 51798fe53..1f91e87f0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Upgraded `smol-toml` to `1.8.0`. [#1644](https://github.com/sourcebot-dev/sourcebot/pull/1644) - Upgraded `hono` to `4.13.7`. [#1643](https://github.com/sourcebot-dev/sourcebot/pull/1643) - Upgraded `nodemailer` to `9.1.1`. [#1642](https://github.com/sourcebot-dev/sourcebot/pull/1642) +- [EE] Fixed MCP activity using the canonical source label being omitted from analytics. [#1651](https://github.com/sourcebot-dev/sourcebot/pull/1651) ## [5.1.11] - 2026-09-10 diff --git a/packages/web/package.json b/packages/web/package.json index f90ba7555..c719057c6 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -211,6 +211,7 @@ }, "devDependencies": { "@asteasolutions/zod-to-openapi": "7.3.4", + "@electric-sql/pglite": "0.5.8", "@eslint/eslintrc": "^3", "@react-email/ui": "6.1.4", "@react-grab/mcp": "^0.1.23", diff --git a/packages/web/src/ee/features/analytics/actions.test.ts b/packages/web/src/ee/features/analytics/actions.test.ts new file mode 100644 index 000000000..1991f3693 --- /dev/null +++ b/packages/web/src/ee/features/analytics/actions.test.ts @@ -0,0 +1,97 @@ +// @vitest-environment node + +import { PGlite } from '@electric-sql/pglite'; +import { Prisma } from '@sourcebot/db'; +import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + queryRaw: vi.fn(), + findFirst: vi.fn(), +})); + +vi.mock('@/middleware/sew', () => ({ + sew: (callback: () => unknown) => callback(), +})); +vi.mock('@/middleware/withAuth', () => ({ + withAuth: (callback: (context: unknown) => unknown) => callback({ + org: { id: 1 }, + role: 'OWNER', + prisma: { + $queryRaw: mocks.queryRaw, + audit: { findFirst: mocks.findFirst }, + }, + }), +})); +vi.mock('@/middleware/withMinimumOrgRole', () => ({ + withMinimumOrgRole: ( + _role: unknown, + _minimumRole: unknown, + callback: () => unknown, + ) => callback(), +})); +vi.mock('@/lib/entitlements', () => ({ + hasEntitlement: vi.fn().mockResolvedValue(true), +})); +vi.mock('@sourcebot/shared', () => ({ + env: { SOURCEBOT_EE_AUDIT_RETENTION_DAYS: 180 }, +})); + +const { getAnalytics } = await import('./actions'); + +const database = new PGlite(); + +beforeAll(async () => { + await database.exec(` + CREATE TABLE "Audit" ( + "timestamp" timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + action text NOT NULL, + "actorId" text NOT NULL, + metadata jsonb, + "orgId" integer NOT NULL + ); + `); +}); + +afterAll(async () => { + await database.close(); +}); + +beforeEach(async () => { + vi.clearAllMocks(); + await database.exec(` + TRUNCATE TABLE "Audit"; + INSERT INTO "Audit" (action, "actorId", metadata, "orgId") VALUES + ('user.performed_code_search', 'canonical-user', '{"source":"sourcebot-mcp-server"}', 1), + ('user.fetched_file_source', 'canonical-user', '{"source":"sourcebot-mcp-server"}', 1), + ('user.fetched_file_tree', 'legacy-user', '{"source":"mcp"}', 1), + ('user.performed_code_search', 'api-user', '{}', 1), + ('user.performed_code_search', 'web-user', '{"source":"sourcebot-web-client"}', 1); + `); + mocks.queryRaw.mockImplementation(async (queryParts: TemplateStringsArray, ...parameters: unknown[]) => { + const query = Prisma.sql(queryParts, ...parameters as never[]); + const result = await database.query(query.text, query.values as never[]); + return result.rows; + }); + mocks.findFirst.mockResolvedValue(null); +}); + +describe('getAnalytics', () => { + test('classifies canonical and legacy MCP audit sources as MCP activity', async () => { + const result = await getAnalytics(); + + if ('statusCode' in result) { + throw new Error(result.message); + } + + const daily = result.rows.find((row) => row.period === 'day'); + expect(daily).toMatchObject({ + active_users: 4, + web_active_users: 1, + non_web_active_users: 3, + mcp_requests: 3, + mcp_active_users: 2, + api_requests: 1, + api_active_users: 1, + }); + }); +}); diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts index 6cacd2905..55780ceea 100644 --- a/packages/web/src/ee/features/analytics/actions.ts +++ b/packages/web/src/ee/features/analytics/actions.ts @@ -10,6 +10,7 @@ import { hasEntitlement } from "@/lib/entitlements"; import { ErrorCode } from "@/lib/errorCodes"; import { StatusCodes } from "http-status-codes"; import { OrgRole } from "@sourcebot/db"; +import { LEGACY_MCP_SERVER_SOURCE, MCP_SERVER_SOURCE } from "@/ee/features/mcp/constants"; export const getAnalytics = async (): Promise => sew(() => withAuth(async ({ org, role, prisma }) => @@ -30,7 +31,13 @@ export const getAnalytics = async (): Promise date_trunc('month', "timestamp") AS month, action, "actorId", - metadata + metadata, + CASE + WHEN metadata->>'source' IN (${LEGACY_MCP_SERVER_SOURCE}, ${MCP_SERVER_SOURCE}) THEN 'mcp' + WHEN metadata->>'source' IS NULL + OR metadata->>'source' NOT LIKE 'sourcebot-%' THEN 'api' + ELSE 'sourcebot' + END AS source_category FROM "Audit" WHERE "orgId" = ${org.id} AND action IN ( @@ -85,7 +92,7 @@ export const getAnalytics = async (): Promise -- Global active users (any action, any source; excludes web repo listings) COUNT(DISTINCT c."actorId") FILTER ( - WHERE NOT (c.action = 'user.listed_repos' AND c.metadata->>'source' LIKE 'sourcebot-%') + WHERE NOT (c.action = 'user.listed_repos' AND c.source_category = 'sourcebot') ) AS active_users, -- Web App metrics @@ -116,26 +123,23 @@ export const getAnalytics = async (): Promise -- MCP + API combined active users (any non-web source) COUNT(DISTINCT c."actorId") FILTER ( - WHERE c.metadata->>'source' IS NULL - OR c.metadata->>'source' NOT LIKE 'sourcebot-%' + WHERE c.source_category IN ('mcp', 'api') ) AS non_web_active_users, - -- MCP metrics (source = 'mcp') + -- MCP metrics (canonical source plus the legacy 'mcp' source) COUNT(*) FILTER ( - WHERE c.metadata->>'source' = 'mcp' + WHERE c.source_category = 'mcp' ) AS mcp_requests, COUNT(DISTINCT c."actorId") FILTER ( - WHERE c.metadata->>'source' = 'mcp' + WHERE c.source_category = 'mcp' ) AS mcp_active_users, - -- API metrics (source IS NULL or not sourcebot-*/mcp) + -- API metrics (source IS NULL or not a Sourcebot/MCP source) COUNT(*) FILTER ( - WHERE c.metadata->>'source' IS NULL - OR (c.metadata->>'source' NOT LIKE 'sourcebot-%' AND c.metadata->>'source' != 'mcp') + WHERE c.source_category = 'api' ) AS api_requests, COUNT(DISTINCT c."actorId") FILTER ( - WHERE c.metadata->>'source' IS NULL - OR (c.metadata->>'source' NOT LIKE 'sourcebot-%' AND c.metadata->>'source' != 'mcp') + WHERE c.source_category = 'api' ) AS api_active_users FROM core c @@ -179,4 +183,4 @@ export const getAnalytics = async (): Promise oldestRecordDate: oldestRecord?.timestamp ?? null, }; })) -); \ No newline at end of file +); diff --git a/packages/web/src/ee/features/mcp/constants.ts b/packages/web/src/ee/features/mcp/constants.ts index 95f7aa2aa..65c3faed5 100644 --- a/packages/web/src/ee/features/mcp/constants.ts +++ b/packages/web/src/ee/features/mcp/constants.ts @@ -1,4 +1,7 @@ export const MCP_DOCS_URL = "https://docs.sourcebot.dev/docs/features/mcp-server"; + +export const MCP_SERVER_SOURCE = 'sourcebot-mcp-server'; +export const LEGACY_MCP_SERVER_SOURCE = 'mcp'; export const PRICING_URL = "https://www.sourcebot.dev/pricing"; // Surfaced to MCP clients (and the programmatic blocking endpoint) when the diff --git a/packages/web/src/ee/features/mcp/server.ts b/packages/web/src/ee/features/mcp/server.ts index 3f08f2fa9..7fb422a4f 100644 --- a/packages/web/src/ee/features/mcp/server.ts +++ b/packages/web/src/ee/features/mcp/server.ts @@ -28,6 +28,7 @@ import { globDefinition, updateSkillDefinition, } from '@/features/tools'; +import { MCP_SERVER_SOURCE } from './constants'; const dedent = _dedent.withOptions({ alignValues: true }); @@ -41,7 +42,7 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo } const server = new McpServer({ - name: 'sourcebot-mcp-server', + name: MCP_SERVER_SOURCE, version: SOURCEBOT_VERSION, }); @@ -49,7 +50,7 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo const hasLanguageModels = configuredLanguageModels.length > 0; const toolContext: ToolContext = { - source: 'sourcebot-mcp-server', + source: MCP_SERVER_SOURCE, } registerMcpTool(server, grepDefinition, toolContext); @@ -89,7 +90,7 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo const models = await getConfiguredLanguageModelsInfo(); captureEvent('tool_used', { toolName: 'list_language_models', - source: 'sourcebot-mcp-server', + source: MCP_SERVER_SOURCE, success: true, }); return { content: [{ type: "text", text: JSON.stringify(models) }] }; @@ -132,13 +133,13 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo repos: request.repos, languageModel: request.languageModel, visibility: request.visibility as ChatVisibility | undefined, - source: 'mcp', + source: MCP_SERVER_SOURCE, }); if (isServiceError(result)) { captureEvent('tool_used', { toolName: 'ask_codebase', - source: 'sourcebot-mcp-server', + source: MCP_SERVER_SOURCE, success: false, }); return { @@ -148,7 +149,7 @@ export async function createMcpServer({ canManageSkills }: { canManageSkills: bo captureEvent('tool_used', { toolName: 'ask_codebase', - source: 'sourcebot-mcp-server', + source: MCP_SERVER_SOURCE, success: true, }); diff --git a/yarn.lock b/yarn.lock index 6ea3fa18d..c07ff3465 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1996,6 +1996,13 @@ __metadata: languageName: node linkType: hard +"@electric-sql/pglite@npm:0.5.8": + version: 0.5.8 + resolution: "@electric-sql/pglite@npm:0.5.8" + checksum: 10c0/02af377bb73428c1fb4559683dac0a4236d62320d5cda62ad4fc45d662a74fb011f767a161cb52b9160df734066376fc80f0d1506c2bbe9506490753fa12b532 + languageName: node + linkType: hard + "@emnapi/core@npm:1.10.0": version: 1.10.0 resolution: "@emnapi/core@npm:1.10.0" @@ -9026,6 +9033,7 @@ __metadata: "@codemirror/search": "npm:^6.5.6" "@codemirror/state": "npm:^6.4.1" "@codemirror/view": "npm:^6.33.0" + "@electric-sql/pglite": "npm:0.5.8" "@eslint/eslintrc": "npm:^3" "@floating-ui/react": "npm:^0.27.2" "@gitbeaker/rest": "npm:^40.5.1"