Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions packages/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
97 changes: 97 additions & 0 deletions packages/web/src/ee/features/analytics/actions.test.ts
Original file line number Diff line number Diff line change
@@ -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,
});
});
});
30 changes: 17 additions & 13 deletions packages/web/src/ee/features/analytics/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AnalyticsResponse | ServiceError> => sew(() =>
withAuth(async ({ org, role, prisma }) =>
Expand All @@ -30,7 +31,13 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>
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 (
Expand Down Expand Up @@ -85,7 +92,7 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>

-- 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
Expand Down Expand Up @@ -116,26 +123,23 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>

-- 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
Expand Down Expand Up @@ -179,4 +183,4 @@ export const getAnalytics = async (): Promise<AnalyticsResponse | ServiceError>
oldestRecordDate: oldestRecord?.timestamp ?? null,
};
}))
);
);
3 changes: 3 additions & 0 deletions packages/web/src/ee/features/mcp/constants.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
13 changes: 7 additions & 6 deletions packages/web/src/ee/features/mcp/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
globDefinition,
updateSkillDefinition,
} from '@/features/tools';
import { MCP_SERVER_SOURCE } from './constants';

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

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

const server = new McpServer({
name: 'sourcebot-mcp-server',
name: MCP_SERVER_SOURCE,
version: SOURCEBOT_VERSION,
});

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

const toolContext: ToolContext = {
source: 'sourcebot-mcp-server',
source: MCP_SERVER_SOURCE,
}

registerMcpTool(server, grepDefinition, toolContext);
Expand Down Expand Up @@ -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) }] };
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
});

Expand Down
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
Loading