From f166e0166ffe6063ce2c6d268621c79cde72cb5b Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 15:06:42 -0800
Subject: [PATCH 01/19] feat(web): add MCP and API key usage tracking to
analytics
Move audit event creation from client-side to service functions for search, repos, file source, and file tree endpoints. Add source metadata to distinguish MCP requests from other API calls. Extend analytics SQL to include new actions and display MCP request and API request counts on the analytics dashboard.
Co-Authored-By: Claude Haiku 4.5
---
.../components/searchBar/searchBar.tsx | 8 -----
.../app/api/(server)/repos/listReposApi.ts | 15 +++++++++-
.../web/src/ee/features/analytics/actions.ts | 20 +++++++++----
.../features/analytics/analyticsContent.tsx | 30 ++++++++++++++++---
.../web/src/ee/features/analytics/types.ts | 2 ++
packages/web/src/ee/features/audit/types.ts | 1 +
.../web/src/features/git/getFileSourceApi.ts | 15 +++++++++-
packages/web/src/features/git/getTreeApi.ts | 15 +++++++++-
packages/web/src/features/search/searchApi.ts | 28 +++++++++++++++--
9 files changed, 111 insertions(+), 23 deletions(-)
diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
index 1093dba86..423d77fd1 100644
--- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
+++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
@@ -42,7 +42,6 @@ import { Separator } from "@/components/ui/separator";
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
import { Toggle } from "@/components/ui/toggle";
import { useDomain } from "@/hooks/useDomain";
-import { createAuditAction } from "@/ee/features/audit/actions";
import tailwind from "@/tailwind";
import React from "react";
import Link from "next/link";
@@ -228,13 +227,6 @@ export const SearchBar = ({
setActivePanel(undefined);
setIsHistorySearchEnabled(false);
- createAuditAction({
- action: "user.performed_code_search",
- metadata: {
- message: query,
- },
- })
-
const url = createPathWithQueryParams(`/${domain}/search`,
[SearchQueryParams.query, query],
[SearchQueryParams.isRegexEnabled, isRegexEnabled ? "true" : null],
diff --git a/packages/web/src/app/api/(server)/repos/listReposApi.ts b/packages/web/src/app/api/(server)/repos/listReposApi.ts
index d5f743cbb..adffe1a00 100644
--- a/packages/web/src/app/api/(server)/repos/listReposApi.ts
+++ b/packages/web/src/app/api/(server)/repos/listReposApi.ts
@@ -1,11 +1,24 @@
import { sew } from "@/actions";
+import { getAuditService } from "@/ee/features/audit/factory";
import { ListReposQueryParams, RepositoryQuery } from "@/lib/types";
import { withOptionalAuthV2 } from "@/withAuthV2";
import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
import { env } from "@sourcebot/shared";
+import { headers } from "next/headers";
export const listRepos = async ({ query, page, perPage, sort, direction }: ListReposQueryParams) => sew(() =>
- withOptionalAuthV2(async ({ org, prisma }) => {
+ withOptionalAuthV2(async ({ org, prisma, user }) => {
+ if (user) {
+ const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ getAuditService().createAudit({
+ action: 'user.listed_repos',
+ actor: { id: user.id, type: 'user' },
+ target: { id: org.id.toString(), type: 'org' },
+ orgId: org.id,
+ metadata: { source },
+ }).catch(() => {});
+ }
+
const skip = (page - 1) * perPage;
const orderByField = sort === 'pushed' ? 'pushedAt' : 'name';
const baseUrl = env.AUTH_URL;
diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts
index 4610fd91b..3c353b03b 100644
--- a/packages/web/src/ee/features/analytics/actions.ts
+++ b/packages/web/src/ee/features/analytics/actions.ts
@@ -27,21 +27,25 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
date_trunc('week', "timestamp") AS week,
date_trunc('month', "timestamp") AS month,
action,
- "actorId"
+ "actorId",
+ metadata
FROM "Audit"
WHERE "orgId" = ${org.id}
AND action IN (
'user.performed_code_search',
'user.performed_find_references',
'user.performed_goto_definition',
- 'user.created_ask_chat'
+ 'user.created_ask_chat',
+ 'user.listed_repos',
+ 'user.fetched_file_source',
+ 'user.fetched_file_tree'
)
),
-
+
periods AS (
SELECT unnest(array['day', 'week', 'month']) AS period
),
-
+
buckets AS (
SELECT
generate_series(
@@ -67,7 +71,7 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
),
'month'
),
-
+
aggregated AS (
SELECT
b.period,
@@ -79,6 +83,8 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
COUNT(*) FILTER (WHERE c.action = 'user.performed_code_search') AS code_searches,
COUNT(*) FILTER (WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')) AS navigations,
COUNT(*) FILTER (WHERE c.action = 'user.created_ask_chat') AS ask_chats,
+ COUNT(*) FILTER (WHERE c.metadata->>'source' = 'mcp') AS mcp_requests,
+ COUNT(*) FILTER (WHERE c.metadata->>'source' IS NOT NULL AND c.metadata->>'source' != 'mcp') AS api_requests,
COUNT(DISTINCT c."actorId") AS active_users
FROM core c
JOIN LATERAL (
@@ -86,13 +92,15 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
) b ON true
GROUP BY b.period, bucket
)
-
+
SELECT
b.period,
b.bucket,
COALESCE(a.code_searches, 0)::int AS code_searches,
COALESCE(a.navigations, 0)::int AS navigations,
COALESCE(a.ask_chats, 0)::int AS ask_chats,
+ COALESCE(a.mcp_requests, 0)::int AS mcp_requests,
+ COALESCE(a.api_requests, 0)::int AS api_requests,
COALESCE(a.active_users, 0)::int AS active_users
FROM buckets b
LEFT JOIN aggregated a
diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx
index 093b2c7ec..562c9f888 100644
--- a/packages/web/src/ee/features/analytics/analyticsContent.tsx
+++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx
@@ -2,7 +2,7 @@
import { ChartTooltip } from "@/components/ui/chart"
import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
-import { Users, LucideIcon, Search, ArrowRight, Activity, Calendar, MessageCircle } from "lucide-react"
+import { Users, LucideIcon, Search, ArrowRight, Activity, Calendar, MessageCircle, Wrench, Key } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartContainer } from "@/components/ui/chart"
import { useQuery } from "@tanstack/react-query"
@@ -28,7 +28,7 @@ interface AnalyticsChartProps {
title: string
icon: LucideIcon
period: "day" | "week" | "month"
- dataKey: "code_searches" | "navigations" | "ask_chats" | "active_users"
+ dataKey: "code_searches" | "navigations" | "ask_chats" | "mcp_requests" | "api_requests" | "active_users"
color: string
gradientId: string
}
@@ -175,7 +175,7 @@ function LoadingSkeleton() {
{/* Chart skeletons */}
- {[1, 2, 3, 4].map((chartIndex) => (
+ {[1, 2, 3, 4, 5, 6].map((chartIndex) => (
@@ -217,7 +217,7 @@ export function AnalyticsContent() {
dark: "#60a5fa",
},
searches: {
- light: "#f59e0b",
+ light: "#f59e0b",
dark: "#fbbf24",
},
navigations: {
@@ -228,6 +228,14 @@ export function AnalyticsContent() {
light: "#8b5cf6",
dark: "#a78bfa",
},
+ mcpRequests: {
+ light: "#10b981",
+ dark: "#34d399",
+ },
+ apiRequests: {
+ light: "#14b8a6",
+ dark: "#2dd4bf",
+ },
}), [])
const getColor = (colorKey: keyof typeof chartColors) => {
@@ -289,6 +297,20 @@ export function AnalyticsContent() {
dataKey: "ask_chats" as const,
gradientId: "askChats",
},
+ {
+ title: `${periodLabels[selectedPeriod]} MCP Requests`,
+ icon: Wrench,
+ color: getColor("mcpRequests"),
+ dataKey: "mcp_requests" as const,
+ gradientId: "mcpRequests",
+ },
+ {
+ title: `${periodLabels[selectedPeriod]} API Requests`,
+ icon: Key,
+ color: getColor("apiRequests"),
+ dataKey: "api_requests" as const,
+ gradientId: "apiRequests",
+ },
]
return (
diff --git a/packages/web/src/ee/features/analytics/types.ts b/packages/web/src/ee/features/analytics/types.ts
index c2b573616..67d5b019b 100644
--- a/packages/web/src/ee/features/analytics/types.ts
+++ b/packages/web/src/ee/features/analytics/types.ts
@@ -6,6 +6,8 @@ export const analyticsResponseSchema = z.array(z.object({
code_searches: z.number(),
navigations: z.number(),
ask_chats: z.number(),
+ mcp_requests: z.number(),
+ api_requests: z.number(),
active_users: z.number(),
}))
export type AnalyticsResponse = z.infer
;
\ No newline at end of file
diff --git a/packages/web/src/ee/features/audit/types.ts b/packages/web/src/ee/features/audit/types.ts
index bd19d6bb0..e79b6957f 100644
--- a/packages/web/src/ee/features/audit/types.ts
+++ b/packages/web/src/ee/features/audit/types.ts
@@ -17,6 +17,7 @@ export const auditMetadataSchema = z.object({
message: z.string().optional(),
api_key: z.string().optional(),
emails: z.string().optional(), // comma separated list of emails
+ source: z.string().optional(), // request source (e.g., 'mcp') from X-Sourcebot-Client-Source header
})
export type AuditMetadata = z.infer;
diff --git a/packages/web/src/features/git/getFileSourceApi.ts b/packages/web/src/features/git/getFileSourceApi.ts
index edaa06cc3..dfb3895f2 100644
--- a/packages/web/src/features/git/getFileSourceApi.ts
+++ b/packages/web/src/features/git/getFileSourceApi.ts
@@ -1,11 +1,13 @@
import { sew } from '@/actions';
import { getBrowsePath } from '@/app/[domain]/browse/hooks/utils';
+import { getAuditService } from '@/ee/features/audit/factory';
import { SINGLE_TENANT_ORG_DOMAIN } from '@/lib/constants';
import { detectLanguageFromFilename } from '@/lib/languageDetection';
import { ServiceError, notFound, fileNotFound, invalidGitRef, unexpectedError } from '@/lib/serviceError';
import { getCodeHostBrowseFileAtBranchUrl } from '@/lib/utils';
import { withOptionalAuthV2 } from '@/withAuthV2';
import { getRepoPath } from '@sourcebot/shared';
+import { headers } from 'next/headers';
import simpleGit from 'simple-git';
import z from 'zod';
import { isGitRefValid, isPathValid } from './utils';
@@ -31,7 +33,18 @@ export const fileSourceResponseSchema = z.object({
});
export type FileSourceResponse = z.infer;
-export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma }) => {
+export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma, user }) => {
+ if (user) {
+ const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ getAuditService().createAudit({
+ action: 'user.fetched_file_source',
+ actor: { id: user.id, type: 'user' },
+ target: { id: org.id.toString(), type: 'org' },
+ orgId: org.id,
+ metadata: { source },
+ }).catch(() => {});
+ }
+
const repo = await prisma.repo.findFirst({
where: { name: repoName, orgId: org.id },
});
diff --git a/packages/web/src/features/git/getTreeApi.ts b/packages/web/src/features/git/getTreeApi.ts
index 6c82eba9e..0664822eb 100644
--- a/packages/web/src/features/git/getTreeApi.ts
+++ b/packages/web/src/features/git/getTreeApi.ts
@@ -1,7 +1,9 @@
import { sew } from '@/actions';
+import { getAuditService } from '@/ee/features/audit/factory';
import { invalidGitRef, notFound, ServiceError, unexpectedError } from '@/lib/serviceError';
import { withOptionalAuthV2 } from "@/withAuthV2";
import { getRepoPath } from '@sourcebot/shared';
+import { headers } from 'next/headers';
import simpleGit from 'simple-git';
import z from 'zod';
import { fileTreeNodeSchema } from './types';
@@ -25,7 +27,18 @@ export type GetTreeResponse = z.infer;
* into a single tree.
*/
export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest): Promise => sew(() =>
- withOptionalAuthV2(async ({ org, prisma }) => {
+ withOptionalAuthV2(async ({ org, prisma, user }) => {
+ if (user) {
+ const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ getAuditService().createAudit({
+ action: 'user.fetched_file_tree',
+ actor: { id: user.id, type: 'user' },
+ target: { id: org.id.toString(), type: 'org' },
+ orgId: org.id,
+ metadata: { source },
+ }).catch(() => {});
+ }
+
const repo = await prisma.repo.findFirst({
where: {
name: repoName,
diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts
index ff2fb0da7..01cf33ec8 100644
--- a/packages/web/src/features/search/searchApi.ts
+++ b/packages/web/src/features/search/searchApi.ts
@@ -1,8 +1,10 @@
import { sew } from "@/actions";
+import { getAuditService } from "@/ee/features/audit/factory";
import { getRepoPermissionFilterForUser } from "@/prisma";
import { withOptionalAuthV2 } from "@/withAuthV2";
import { PrismaClient, UserWithAccounts } from "@sourcebot/db";
import { env, hasEntitlement } from "@sourcebot/shared";
+import { headers } from "next/headers";
import { QueryIR } from './ir';
import { parseQuerySyntaxIntoIR } from './parser';
import { SearchOptions } from "./types";
@@ -25,7 +27,18 @@ type QueryIRSearchRequest = {
type SearchRequest = QueryStringSearchRequest | QueryIRSearchRequest;
export const search = (request: SearchRequest) => sew(() =>
- withOptionalAuthV2(async ({ prisma, user }) => {
+ withOptionalAuthV2(async ({ prisma, user, org }) => {
+ if (user) {
+ const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ getAuditService().createAudit({
+ action: 'user.performed_code_search',
+ actor: { id: user.id, type: 'user' },
+ target: { id: org.id.toString(), type: 'org' },
+ orgId: org.id,
+ metadata: { source },
+ }).catch(() => {});
+ }
+
const repoSearchScope = await getAccessibleRepoNamesForUser({ user, prisma });
// If needed, parse the query syntax into the query intermediate representation.
@@ -45,7 +58,18 @@ export const search = (request: SearchRequest) => sew(() =>
}));
export const streamSearch = (request: SearchRequest) => sew(() =>
- withOptionalAuthV2(async ({ prisma, user }) => {
+ withOptionalAuthV2(async ({ prisma, user, org }) => {
+ if (user) {
+ const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ getAuditService().createAudit({
+ action: 'user.performed_code_search',
+ actor: { id: user.id, type: 'user' },
+ target: { id: org.id.toString(), type: 'org' },
+ orgId: org.id,
+ metadata: { source },
+ }).catch(() => {});
+ }
+
const repoSearchScope = await getAccessibleRepoNamesForUser({ user, prisma });
// If needed, parse the query syntax into the query intermediate representation.
From 2edeba0b17ac77256101cdd45624b8f08992d4fe Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 15:07:05 -0800
Subject: [PATCH 02/19] chore: update CHANGELOG for MCP analytics tracking
(#948)
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 62055d270..63aef31fa 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support a MCP streamable http transport hosted at `/api/mcp`. [#976](https://github.com/sourcebot-dev/sourcebot/pull/976)
+- Added MCP and API key usage tracking to analytics dashboard. Move audit events from client-side to service functions to capture all API calls (web UI, MCP, and non-MCP). Display MCP requests and API requests on separate charts. [#948](https://github.com/sourcebot-dev/sourcebot/pull/948)
## [4.13.2] - 2026-03-02
From 3948d9142328ebc737bb5cae2424c30286782c8a Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 16:59:07 -0800
Subject: [PATCH 03/19] feat: add audit log retention policy, update analytics
UI and docs
- Add SOURCEBOT_EE_AUDIT_RETENTION_DAYS env var (default 180) and AuditLogPruner background job that prunes old audit records daily in batches
- Surface retention period and oldest record date in analytics page header
- Update audit action types table in docs (remove 4 stale, add 11 missing)
- Add audit log storage section to sizing guide with enterprise callout and storage estimates
- Update mock data script with mixed-usage user profiles and new audit actions
Co-Authored-By: Claude Opus 4.6
---
docs/docs/configuration/audit-logs.mdx | 49 ++-
.../configuration/environment-variables.mdx | 1 +
docs/docs/deployment/sizing-guide.mdx | 28 ++
packages/backend/src/ee/auditLogPruner.ts | 71 ++++
packages/backend/src/index.ts | 4 +
.../db/tools/scripts/inject-audit-data.ts | 358 ++++++++++++------
packages/shared/src/env.server.ts | 1 +
.../web/src/ee/features/analytics/actions.ts | 18 +-
.../features/analytics/analyticsContent.tsx | 16 +-
.../web/src/ee/features/analytics/types.ts | 12 +-
10 files changed, 411 insertions(+), 147 deletions(-)
create mode 100644 packages/backend/src/ee/auditLogPruner.ts
diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx
index 6b38a4ea5..8828cb8e5 100644
--- a/docs/docs/configuration/audit-logs.mdx
+++ b/docs/docs/configuration/audit-logs.mdx
@@ -15,6 +15,9 @@ This feature gives security and compliance teams the necessary information to en
## Enabling/Disabling Audit Logs
Audit logs are enabled by default and can be controlled with the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables).
+## Retention Policy
+By default, audit logs older than 180 days are automatically pruned daily. You can configure the retention period using the `SOURCEBOT_EE_AUDIT_RETENTION_DAYS` [environment variable](/docs/configuration/environment-variables). Set it to `0` to disable automatic pruning and retain logs indefinitely.
+
## Fetching Audit Logs
Audit logs are stored in the [postgres database](/docs/overview#architecture) connected to Sourcebot. To fetch all of the audit logs, you can use the following API:
@@ -110,30 +113,37 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| Action | Actor Type | Target Type |
| :------- | :------ | :------|
-| `api_key.creation_failed` | `user` | `org` |
| `api_key.created` | `user` | `api_key` |
-| `api_key.deletion_failed` | `user` | `org` |
+| `api_key.creation_failed` | `user` | `org` |
| `api_key.deleted` | `user` | `api_key` |
+| `api_key.deletion_failed` | `user` | `org` |
+| `audit.fetch` | `user` | `org` |
+| `chat.deleted` | `user` | `chat` |
+| `chat.shared_with_users` | `user` | `chat` |
+| `chat.unshared_with_user` | `user` | `chat` |
+| `chat.visibility_updated` | `user` | `chat` |
+| `org.ownership_transfer_failed` | `user` | `org` |
+| `org.ownership_transferred` | `user` | `org` |
+| `user.created_ask_chat` | `user` | `org` |
| `user.creation_failed` | `user` | `user` |
-| `user.owner_created` | `user` | `org` |
-| `user.performed_code_search` | `user` | `org` |
-| `user.performed_find_references` | `user` | `org` |
-| `user.performed_goto_definition` | `user` | `org` |
-| `user.created_ask_chat` | `user` | `org` |
-| `user.jit_provisioning_failed` | `user` | `org` |
-| `user.jit_provisioned` | `user` | `org` |
-| `user.join_request_creation_failed` | `user` | `org` |
-| `user.join_requested` | `user` | `org` |
-| `user.join_request_approve_failed` | `user` | `account_join_request` |
-| `user.join_request_approved` | `user` | `account_join_request` |
-| `user.invite_failed` | `user` | `org` |
-| `user.invites_created` | `user` | `org` |
+| `user.delete` | `user` | `user` |
+| `user.fetched_file_source` | `user` | `org` |
+| `user.fetched_file_tree` | `user` | `org` |
| `user.invite_accept_failed` | `user` | `invite` |
| `user.invite_accepted` | `user` | `invite` |
+| `user.invite_failed` | `user` | `org` |
+| `user.invites_created` | `user` | `org` |
+| `user.join_request_approve_failed` | `user` | `account_join_request` |
+| `user.join_request_approved` | `user` | `account_join_request` |
+| `user.list` | `user` | `org` |
+| `user.listed_repos` | `user` | `org` |
+| `user.owner_created` | `user` | `org` |
+| `user.performed_code_search` | `user` | `org` |
+| `user.performed_find_references` | `user` | `org` |
+| `user.performed_goto_definition` | `user` | `org` |
+| `user.read` | `user` | `user` |
| `user.signed_in` | `user` | `user` |
| `user.signed_out` | `user` | `user` |
-| `org.ownership_transfer_failed` | `user` | `org` |
-| `org.ownership_transferred` | `user` | `org` |
## Response schema
@@ -180,7 +190,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
},
"targetType": {
"type": "string",
- "enum": ["user", "org", "file", "api_key", "account_join_request", "invite"]
+ "enum": ["user", "org", "file", "api_key", "account_join_request", "invite", "chat"]
},
"sourcebotVersion": {
"type": "string"
@@ -192,7 +202,8 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
"properties": {
"message": { "type": "string" },
"api_key": { "type": "string" },
- "emails": { "type": "string" }
+ "emails": { "type": "string" },
+ "source": { "type": "string" }
},
"additionalProperties": false
},
diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx
index 54a0609e1..e802da0fe 100644
--- a/docs/docs/configuration/environment-variables.mdx
+++ b/docs/docs/configuration/environment-variables.mdx
@@ -42,6 +42,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `HTTPS_PROXY` | - | HTTPS proxy URL for routing SSL requests through a proxy server (e.g., `http://proxy.company.com:8080`). Requires `NODE_USE_ENV_PROXY=1`.
|
| `NO_PROXY` | - | Comma-separated list of hostnames or domains that should bypass the proxy (e.g., `localhost,127.0.0.1,.internal.domain`). Requires `NODE_USE_ENV_PROXY=1`.
|
| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | Enables/disables audit logging
|
+| `SOURCEBOT_EE_AUDIT_RETENTION_DAYS` | `180` | The number of days to retain audit logs. Audit log records older than this will be automatically pruned daily. Set to `0` to disable pruning and retain logs indefinitely.
|
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect
|
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning
|
| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` | Enables [permission syncing](/docs/features/permission-syncing).
|
diff --git a/docs/docs/deployment/sizing-guide.mdx b/docs/docs/deployment/sizing-guide.mdx
index 0dd3b7344..0966ff141 100644
--- a/docs/docs/deployment/sizing-guide.mdx
+++ b/docs/docs/deployment/sizing-guide.mdx
@@ -45,6 +45,34 @@ If your instance is resource-constrained, you can reduce the concurrency of back
Lowering these values reduces peak resource usage at the cost of slower initial indexing.
+## Audit log storage
+
+
+Audit logging is an enterprise feature and is only available with an [enterprise license](/docs/overview#license-key). If you are not on an enterprise plan, audit logs are not stored and this section does not apply.
+
+
+[Audit logs](/docs/configuration/audit-logs) are stored in the Postgres database connected to your Sourcebot deployment. Each audit record captures the action performed, the actor, the target, a timestamp, and optional metadata (e.g., request source). There are three database indexes on the audit table to support analytics and lookup queries.
+
+**Estimated storage per audit event: ~350 bytes** (including row data and indexes).
+
+
+The table below assumes 50 events per user per day. The actual number depends on usage patterns — each user action (code search, file view, navigation, Ask chat, etc.) creates one audit event. Users who interact via [MCP](/docs/features/mcp-server) or the [API](/docs/api-reference/search) tend to generate significantly more events than web-only users, so your real usage may vary.
+
+
+| Team size | Avg events / user / day | Daily events | Monthly storage | 6-month storage |
+|---|---|---|---|---|
+| 10 users | 50 | 500 | ~5 MB | ~30 MB |
+| 50 users | 50 | 2,500 | ~25 MB | ~150 MB |
+| 100 users | 50 | 5,000 | ~50 MB | ~300 MB |
+| 500 users | 50 | 25,000 | ~250 MB | ~1.5 GB |
+| 1,000 users | 50 | 50,000 | ~500 MB | ~3 GB |
+
+### Retention policy
+
+By default, audit logs older than **180 days** are automatically pruned daily by a background job. You can adjust this with the `SOURCEBOT_EE_AUDIT_RETENTION_DAYS` [environment variable](/docs/configuration/environment-variables). Set it to `0` to disable pruning and retain logs indefinitely.
+
+For most deployments, the default 180-day retention keeps database size manageable. If you have a large team with heavy MCP/API usage and need longer retention, plan your Postgres disk allocation accordingly using the estimates above.
+
## Monitoring
We recommend monitoring the following metrics after deployment to validate your sizing:
diff --git a/packages/backend/src/ee/auditLogPruner.ts b/packages/backend/src/ee/auditLogPruner.ts
new file mode 100644
index 000000000..aa98cd0a8
--- /dev/null
+++ b/packages/backend/src/ee/auditLogPruner.ts
@@ -0,0 +1,71 @@
+import { PrismaClient } from "@sourcebot/db";
+import { createLogger, env } from "@sourcebot/shared";
+import { setIntervalAsync } from "../utils.js";
+
+const BATCH_SIZE = 10_000;
+const ONE_DAY_MS = 24 * 60 * 60 * 1000;
+
+const logger = createLogger('audit-log-pruner');
+
+export class AuditLogPruner {
+ private interval?: NodeJS.Timeout;
+
+ constructor(private db: PrismaClient) {}
+
+ startScheduler() {
+ if (env.SOURCEBOT_EE_AUDIT_LOGGING_ENABLED !== 'true') {
+ logger.info('Audit logging is disabled, skipping audit log pruner.');
+ return;
+ }
+
+ if (env.SOURCEBOT_EE_AUDIT_RETENTION_DAYS <= 0) {
+ logger.info('SOURCEBOT_EE_AUDIT_RETENTION_DAYS is 0, audit log pruning is disabled.');
+ return;
+ }
+
+ logger.info(`Audit log pruner started. Retaining logs for ${env.SOURCEBOT_EE_AUDIT_RETENTION_DAYS} days.`);
+
+ // Run immediately on startup, then every 24 hours
+ this.pruneOldAuditLogs();
+ this.interval = setIntervalAsync(() => this.pruneOldAuditLogs(), ONE_DAY_MS);
+ }
+
+ async dispose() {
+ if (this.interval) {
+ clearInterval(this.interval);
+ this.interval = undefined;
+ }
+ }
+
+ private async pruneOldAuditLogs() {
+ const cutoff = new Date(Date.now() - env.SOURCEBOT_EE_AUDIT_RETENTION_DAYS * ONE_DAY_MS);
+ let totalDeleted = 0;
+
+ logger.info(`Pruning audit logs older than ${cutoff.toISOString()}...`);
+
+ // Delete in batches to avoid long-running transactions
+ while (true) {
+ const batch = await this.db.audit.findMany({
+ where: { timestamp: { lt: cutoff } },
+ select: { id: true },
+ take: BATCH_SIZE,
+ });
+
+ if (batch.length === 0) break;
+
+ const result = await this.db.audit.deleteMany({
+ where: { id: { in: batch.map(r => r.id) } },
+ });
+
+ totalDeleted += result.count;
+
+ if (batch.length < BATCH_SIZE) break;
+ }
+
+ if (totalDeleted > 0) {
+ logger.info(`Pruned ${totalDeleted} audit log records.`);
+ } else {
+ logger.info('No audit log records to prune.');
+ }
+ }
+}
diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts
index 072b53008..db3ceaef0 100644
--- a/packages/backend/src/index.ts
+++ b/packages/backend/src/index.ts
@@ -12,6 +12,7 @@ import { ConfigManager } from "./configManager.js";
import { ConnectionManager } from './connectionManager.js';
import { INDEX_CACHE_DIR, REPOS_CACHE_DIR, SHUTDOWN_SIGNALS } from './constants.js';
import { AccountPermissionSyncer } from "./ee/accountPermissionSyncer.js";
+import { AuditLogPruner } from "./ee/auditLogPruner.js";
import { GithubAppManager } from "./ee/githubAppManager.js";
import { RepoPermissionSyncer } from './ee/repoPermissionSyncer.js';
import { shutdownPosthog } from "./posthog.js";
@@ -64,9 +65,11 @@ const repoPermissionSyncer = new RepoPermissionSyncer(prisma, settings, redis);
const accountPermissionSyncer = new AccountPermissionSyncer(prisma, settings, redis);
const repoIndexManager = new RepoIndexManager(prisma, settings, redis, promClient);
const configManager = new ConfigManager(prisma, connectionManager, env.CONFIG_PATH);
+const auditLogPruner = new AuditLogPruner(prisma);
connectionManager.startScheduler();
await repoIndexManager.startScheduler();
+auditLogPruner.startScheduler();
if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && !hasEntitlement('permission-syncing')) {
logger.error('Permission syncing is not supported in current plan. Please contact team@sourcebot.dev for assistance.');
@@ -105,6 +108,7 @@ const listenToShutdownSignals = () => {
await connectionManager.dispose()
await repoPermissionSyncer.dispose()
await accountPermissionSyncer.dispose()
+ await auditLogPruner.dispose()
await configManager.dispose()
await prisma.$disconnect();
diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts
index 56478e3e5..404ee9c45 100644
--- a/packages/db/tools/scripts/inject-audit-data.ts
+++ b/packages/db/tools/scripts/inject-audit-data.ts
@@ -1,18 +1,35 @@
import { Script } from "../scriptRunner";
-import { PrismaClient } from "../../dist";
+import { PrismaClient, Prisma } from "../../dist";
import { confirmAction } from "../utils";
+// User profile: defines how a user interacts with Sourcebot
+interface UserProfile {
+ id: string
+ // Whether this user uses the web UI, and how active they are (0 = never, 1 = heavy)
+ webWeight: number
+ // Whether this user uses MCP, and how active they are (0 = never, 1 = heavy)
+ mcpWeight: number
+ // Whether this user uses the API directly, and how active they are (0 = never, 1 = heavy)
+ apiWeight: number
+ // API source label (for non-MCP API usage)
+ apiSource: string
+ // How likely they are to be active on a weekday (0-1)
+ weekdayActivity: number
+ // How likely they are to be active on a weekend (0-1)
+ weekendActivity: number
+}
+
// Generate realistic audit data for analytics testing
-// Simulates 50 engineers with varying activity patterns
+// Simulates 50 users with mixed usage patterns across web UI, MCP, and API
export const injectAuditData: Script = {
run: async (prisma: PrismaClient) => {
const orgId = 1;
-
+
// Check if org exists
const org = await prisma.org.findUnique({
where: { id: orgId }
});
-
+
if (!org) {
console.error(`Organization with id ${orgId} not found. Please create it first.`);
return;
@@ -20,154 +37,259 @@ export const injectAuditData: Script = {
console.log(`Injecting audit data for organization: ${org.name} (${org.domain})`);
- // Generate 50 fake user IDs
- const userIds = Array.from({ length: 50 }, (_, i) => `user_${String(i + 1).padStart(3, '0')}`);
-
- // Actions we're tracking
- const actions = [
- 'user.performed_code_search',
- 'user.performed_find_references',
- 'user.performed_goto_definition',
- 'user.created_ask_chat'
- ];
+ const apiSources = ['cli', 'sdk', 'custom-app'];
+
+ // Build user profiles with mixed usage patterns
+ const users: UserProfile[] = [];
+
+ // Web-only users (20): browse the UI, never use MCP or API
+ for (let i = 0; i < 20; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.6 + Math.random() * 0.4, // 0.6-1.0
+ mcpWeight: 0,
+ apiWeight: 0,
+ apiSource: '',
+ weekdayActivity: 0.7 + Math.random() * 0.2,
+ weekendActivity: 0.05 + Math.random() * 0.15,
+ });
+ }
+
+ // Hybrid web + MCP users (12): use the web UI daily and also have MCP set up in their IDE
+ for (let i = 0; i < 12; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.4 + Math.random() * 0.4, // 0.4-0.8
+ mcpWeight: 0.5 + Math.random() * 0.5, // 0.5-1.0
+ apiWeight: 0,
+ apiSource: '',
+ weekdayActivity: 0.8 + Math.random() * 0.15,
+ weekendActivity: 0.1 + Math.random() * 0.2,
+ });
+ }
+
+ // MCP-heavy users (8): primarily use MCP through their IDE, occasionally check the web UI
+ for (let i = 0; i < 8; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.05 + Math.random() * 0.2, // 0.05-0.25 (occasional)
+ mcpWeight: 0.7 + Math.random() * 0.3, // 0.7-1.0
+ apiWeight: 0,
+ apiSource: '',
+ weekdayActivity: 0.85 + Math.random() * 0.1,
+ weekendActivity: 0.3 + Math.random() * 0.3,
+ });
+ }
+
+ // API-only users (5): automated scripts/CI, no web UI or MCP
+ for (let i = 0; i < 5; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0,
+ mcpWeight: 0,
+ apiWeight: 0.6 + Math.random() * 0.4,
+ apiSource: apiSources[i % apiSources.length],
+ weekdayActivity: 0.9 + Math.random() * 0.1,
+ weekendActivity: 0.6 + Math.random() * 0.3,
+ });
+ }
+
+ // Hybrid web + API users (5): developers who use both the UI and have scripts that call the API
+ for (let i = 0; i < 5; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.3 + Math.random() * 0.4,
+ mcpWeight: 0,
+ apiWeight: 0.4 + Math.random() * 0.4,
+ apiSource: apiSources[i % apiSources.length],
+ weekdayActivity: 0.8 + Math.random() * 0.15,
+ weekendActivity: 0.1 + Math.random() * 0.2,
+ });
+ }
// Generate data for the last 90 days
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 90);
+ const webOnlyCount = users.filter(u => u.webWeight > 0 && u.mcpWeight === 0 && u.apiWeight === 0).length;
+ const hybridWebMcpCount = users.filter(u => u.webWeight > 0 && u.mcpWeight > 0).length;
+ const mcpHeavyCount = users.filter(u => u.mcpWeight > 0 && u.webWeight < 0.3).length;
+ const apiOnlyCount = users.filter(u => u.apiWeight > 0 && u.webWeight === 0 && u.mcpWeight === 0).length;
+ const hybridWebApiCount = users.filter(u => u.webWeight > 0 && u.apiWeight > 0).length;
+
console.log(`Generating data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
+ console.log(`User breakdown: ${webOnlyCount} web-only, ${hybridWebMcpCount} web+MCP, ${mcpHeavyCount} MCP-heavy, ${apiOnlyCount} API-only, ${hybridWebApiCount} web+API`);
confirmAction();
+ function randomTimestamp(date: Date, isWeekend: boolean): Date {
+ const ts = new Date(date);
+ if (isWeekend) {
+ ts.setHours(9 + Math.floor(Math.random() * 12));
+ } else {
+ ts.setHours(9 + Math.floor(Math.random() * 9));
+ }
+ ts.setMinutes(Math.floor(Math.random() * 60));
+ ts.setSeconds(Math.floor(Math.random() * 60));
+ return ts;
+ }
+
+ function scaledCount(baseMin: number, baseMax: number, weight: number, isWeekend: boolean): number {
+ const weekendFactor = isWeekend ? 0.3 : 1.0;
+ const scaledMax = Math.round(baseMax * weight * weekendFactor);
+ const scaledMin = Math.min(Math.round(baseMin * weight * weekendFactor), scaledMax);
+ if (scaledMax <= 0) return 0;
+ return scaledMin + Math.floor(Math.random() * (scaledMax - scaledMin + 1));
+ }
+
+ async function createAudits(
+ userId: string,
+ action: string,
+ count: number,
+ currentDate: Date,
+ isWeekend: boolean,
+ targetType: string,
+ metadata?: Prisma.InputJsonValue,
+ ) {
+ for (let i = 0; i < count; i++) {
+ await prisma.audit.create({
+ data: {
+ timestamp: randomTimestamp(currentDate, isWeekend),
+ action,
+ actorId: userId,
+ actorType: 'user',
+ targetId: `${targetType}_${Math.floor(Math.random() * 1000)}`,
+ targetType,
+ sourcebotVersion: '1.0.0',
+ orgId,
+ ...(metadata ? { metadata } : {}),
+ }
+ });
+ }
+ }
+
// Generate data for each day
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const currentDate = new Date(d);
- const dayOfWeek = currentDate.getDay(); // 0 = Sunday, 6 = Saturday
+ const dayOfWeek = currentDate.getDay();
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
-
- // For each user, generate activity for this day
- for (const userId of userIds) {
- // Determine if user is active today (higher chance on weekdays)
- const isActiveToday = isWeekend
- ? Math.random() < 0.15 // 15% chance on weekends
- : Math.random() < 0.85; // 85% chance on weekdays
-
- if (!isActiveToday) continue;
-
- // Generate code searches (2-5 per day)
- const codeSearches = isWeekend
- ? Math.floor(Math.random() * 2) + 1 // 1-2 on weekends
- : Math.floor(Math.random() * 4) + 2; // 2-5 on weekdays
-
- // Generate navigation actions (5-10 per day)
- const navigationActions = isWeekend
- ? Math.floor(Math.random() * 3) + 1 // 1-3 on weekends
- : Math.floor(Math.random() * 6) + 5; // 5-10 on weekdays
-
- // Create code search records
- for (let i = 0; i < codeSearches; i++) {
- const timestamp = new Date(currentDate);
- // Spread throughout the day (9 AM to 6 PM on weekdays, more random on weekends)
- if (isWeekend) {
- timestamp.setHours(9 + Math.floor(Math.random() * 12));
- timestamp.setMinutes(Math.floor(Math.random() * 60));
- } else {
- timestamp.setHours(9 + Math.floor(Math.random() * 9));
- timestamp.setMinutes(Math.floor(Math.random() * 60));
+
+ for (const user of users) {
+ // Determine if user is active today
+ const activityChance = isWeekend ? user.weekendActivity : user.weekdayActivity;
+ if (Math.random() >= activityChance) continue;
+
+ // --- Web UI activity (no source metadata) ---
+ if (user.webWeight > 0) {
+ // Code searches (2-5 base)
+ await createAudits(user.id, 'user.performed_code_search',
+ scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'search');
+
+ // Navigations: find references + goto definition (5-10 base)
+ const navCount = scaledCount(5, 10, user.webWeight, isWeekend);
+ for (let i = 0; i < navCount; i++) {
+ const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
+ await createAudits(user.id, action, 1, currentDate, isWeekend, 'symbol');
}
- timestamp.setSeconds(Math.floor(Math.random() * 60));
-
- await prisma.audit.create({
- data: {
- timestamp,
- action: 'user.performed_code_search',
- actorId: userId,
- actorType: 'user',
- targetId: `search_${Math.floor(Math.random() * 1000)}`,
- targetType: 'search',
- sourcebotVersion: '1.0.0',
- orgId
- }
- });
+
+ // Ask chats (0-2 base) - web only
+ await createAudits(user.id, 'user.created_ask_chat',
+ scaledCount(0, 2, user.webWeight, isWeekend), currentDate, isWeekend, 'org');
+
+ // File source views (3-8 base)
+ await createAudits(user.id, 'user.fetched_file_source',
+ scaledCount(3, 8, user.webWeight, isWeekend), currentDate, isWeekend, 'file');
+
+ // File tree browsing (2-5 base)
+ await createAudits(user.id, 'user.fetched_file_tree',
+ scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'repo');
+
+ // List repos (1-3 base)
+ await createAudits(user.id, 'user.listed_repos',
+ scaledCount(1, 3, user.webWeight, isWeekend), currentDate, isWeekend, 'org');
}
- // Create navigation action records
- for (let i = 0; i < navigationActions; i++) {
- const timestamp = new Date(currentDate);
- if (isWeekend) {
- timestamp.setHours(9 + Math.floor(Math.random() * 12));
- timestamp.setMinutes(Math.floor(Math.random() * 60));
- } else {
- timestamp.setHours(9 + Math.floor(Math.random() * 9));
- timestamp.setMinutes(Math.floor(Math.random() * 60));
- }
- timestamp.setSeconds(Math.floor(Math.random() * 60));
+ // --- MCP activity (source='mcp') ---
+ if (user.mcpWeight > 0) {
+ const meta: Prisma.InputJsonValue = { source: 'mcp' };
- // Randomly choose between find references and goto definition
- const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
+ // MCP code searches (5-15 base) - higher volume than web
+ await createAudits(user.id, 'user.performed_code_search',
+ scaledCount(5, 15, user.mcpWeight, isWeekend), currentDate, isWeekend, 'search', meta);
- await prisma.audit.create({
- data: {
- timestamp,
- action,
- actorId: userId,
- actorType: 'user',
- targetId: `symbol_${Math.floor(Math.random() * 1000)}`,
- targetType: 'symbol',
- sourcebotVersion: '1.0.0',
- orgId
- }
- });
+ // MCP file source fetches (5-12 base)
+ await createAudits(user.id, 'user.fetched_file_source',
+ scaledCount(5, 12, user.mcpWeight, isWeekend), currentDate, isWeekend, 'file', meta);
+
+ // MCP file tree fetches (3-6 base)
+ await createAudits(user.id, 'user.fetched_file_tree',
+ scaledCount(3, 6, user.mcpWeight, isWeekend), currentDate, isWeekend, 'repo', meta);
+
+ // MCP list repos (3-8 base)
+ await createAudits(user.id, 'user.listed_repos',
+ scaledCount(3, 8, user.mcpWeight, isWeekend), currentDate, isWeekend, 'org', meta);
}
- // Generate Ask chat sessions (0-2 per day on weekdays, 0-1 on weekends)
- const askChats = isWeekend
- ? Math.floor(Math.random() * 2) // 0-1 on weekends
- : Math.floor(Math.random() * 3); // 0-2 on weekdays
-
- // Create Ask chat records
- for (let i = 0; i < askChats; i++) {
- const timestamp = new Date(currentDate);
- if (isWeekend) {
- timestamp.setHours(9 + Math.floor(Math.random() * 12));
- timestamp.setMinutes(Math.floor(Math.random() * 60));
- } else {
- timestamp.setHours(9 + Math.floor(Math.random() * 9));
- timestamp.setMinutes(Math.floor(Math.random() * 60));
- }
- timestamp.setSeconds(Math.floor(Math.random() * 60));
-
- await prisma.audit.create({
- data: {
- timestamp,
- action: 'user.created_ask_chat',
- actorId: userId,
- actorType: 'user',
- targetId: orgId.toString(),
- targetType: 'org',
- sourcebotVersion: '1.0.0',
- orgId
- }
- });
+ // --- API activity (source=cli/sdk/custom-app) ---
+ if (user.apiWeight > 0) {
+ const meta: Prisma.InputJsonValue = { source: user.apiSource };
+
+ // API code searches (10-30 base) - highest volume, automated
+ await createAudits(user.id, 'user.performed_code_search',
+ scaledCount(10, 30, user.apiWeight, isWeekend), currentDate, isWeekend, 'search', meta);
+
+ // API file source fetches (8-20 base)
+ await createAudits(user.id, 'user.fetched_file_source',
+ scaledCount(8, 20, user.apiWeight, isWeekend), currentDate, isWeekend, 'file', meta);
+
+ // API file tree fetches (4-10 base)
+ await createAudits(user.id, 'user.fetched_file_tree',
+ scaledCount(4, 10, user.apiWeight, isWeekend), currentDate, isWeekend, 'repo', meta);
+
+ // API list repos (5-15 base)
+ await createAudits(user.id, 'user.listed_repos',
+ scaledCount(5, 15, user.apiWeight, isWeekend), currentDate, isWeekend, 'org', meta);
}
}
}
console.log(`\nAudit data injection complete!`);
- console.log(`Users: ${userIds.length}`);
+ console.log(`Users: ${users.length}`);
console.log(`Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
-
- // Show some statistics
+
+ // Show statistics
const stats = await prisma.audit.groupBy({
by: ['action'],
where: { orgId },
_count: { action: true }
});
-
+
console.log('\nAction breakdown:');
stats.forEach(stat => {
console.log(` ${stat.action}: ${stat._count.action}`);
});
+
+ // Show source breakdown
+ const allAudits = await prisma.audit.findMany({
+ where: { orgId },
+ select: { metadata: true }
+ });
+
+ let webCount = 0, mcpCount = 0, apiCount = 0;
+ for (const audit of allAudits) {
+ const meta = audit.metadata as Record | null;
+ if (!meta || !meta.source) {
+ webCount++;
+ } else if (meta.source === 'mcp') {
+ mcpCount++;
+ } else {
+ apiCount++;
+ }
+ }
+ console.log('\nSource breakdown:');
+ console.log(` Web UI (no source): ${webCount}`);
+ console.log(` MCP (source=mcp): ${mcpCount}`);
+ console.log(` API (source=other): ${apiCount}`);
},
-};
\ No newline at end of file
+};
diff --git a/packages/shared/src/env.server.ts b/packages/shared/src/env.server.ts
index f3627ad9b..2b762113f 100644
--- a/packages/shared/src/env.server.ts
+++ b/packages/shared/src/env.server.ts
@@ -191,6 +191,7 @@ export const env = createEnv({
// EE License
SOURCEBOT_EE_LICENSE_KEY: z.string().optional(),
SOURCEBOT_EE_AUDIT_LOGGING_ENABLED: booleanSchema.default('true'),
+ SOURCEBOT_EE_AUDIT_RETENTION_DAYS: numberSchema.default(180),
// GitHub app for review agent
GITHUB_REVIEW_AGENT_APP_ID: z.string().optional(),
diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts
index 3c353b03b..a75c7bedf 100644
--- a/packages/web/src/ee/features/analytics/actions.ts
+++ b/packages/web/src/ee/features/analytics/actions.ts
@@ -4,8 +4,8 @@ import { sew, withAuth, withOrgMembership } from "@/actions";
import { OrgRole } from "@sourcebot/db";
import { prisma } from "@/prisma";
import { ServiceError } from "@/lib/serviceError";
-import { AnalyticsResponse } from "./types";
-import { hasEntitlement } from "@sourcebot/shared";
+import { AnalyticsResponse, AnalyticsRow } from "./types";
+import { env, hasEntitlement } from "@sourcebot/shared";
import { ErrorCode } from "@/lib/errorCodes";
import { StatusCodes } from "http-status-codes";
@@ -20,7 +20,7 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
} satisfies ServiceError;
}
- const rows = await prisma.$queryRaw`
+ const rows = await prisma.$queryRaw`
WITH core AS (
SELECT
date_trunc('day', "timestamp") AS day,
@@ -109,6 +109,16 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
`;
- return rows;
+ const oldestRecord = await prisma.audit.findFirst({
+ where: { orgId: org.id },
+ orderBy: { timestamp: 'asc' },
+ select: { timestamp: true },
+ });
+
+ return {
+ rows,
+ retentionDays: env.SOURCEBOT_EE_AUDIT_RETENTION_DAYS,
+ oldestRecordDate: oldestRecord?.timestamp ?? null,
+ };
}, /* minRequiredRole = */ OrgRole.MEMBER), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
);
\ No newline at end of file
diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx
index 562c9f888..c8ef9de1f 100644
--- a/packages/web/src/ee/features/analytics/analyticsContent.tsx
+++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx
@@ -9,7 +9,7 @@ import { useQuery } from "@tanstack/react-query"
import { useDomain } from "@/hooks/useDomain"
import { unwrapServiceError } from "@/lib/utils"
import { Skeleton } from "@/components/ui/skeleton"
-import { AnalyticsResponse } from "./types"
+import { AnalyticsRow } from "./types"
import { getAnalytics } from "./actions"
import { useTheme } from "next-themes"
import { useMemo, useState } from "react"
@@ -24,7 +24,7 @@ const periodLabels: Record = {
}
interface AnalyticsChartProps {
- data: AnalyticsResponse
+ data: AnalyticsRow[]
title: string
icon: LucideIcon
period: "day" | "week" | "month"
@@ -266,7 +266,7 @@ export function AnalyticsContent() {
)
}
- const periodData = analyticsResponse.filter((row) => row.period === selectedPeriod)
+ const periodData = analyticsResponse.rows.filter((row) => row.period === selectedPeriod)
const charts = [
{
@@ -322,6 +322,16 @@ export function AnalyticsContent() {
View usage metrics across your organization.
+
+
+ Retention period: {analyticsResponse.retentionDays > 0 ? `${analyticsResponse.retentionDays} days` : "Indefinite"}
+
+ {analyticsResponse.oldestRecordDate && (
+
+ Data since: {new Date(analyticsResponse.oldestRecordDate).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })}
+
+ )}
+
{/* Time Period Selector */}
diff --git a/packages/web/src/ee/features/analytics/types.ts b/packages/web/src/ee/features/analytics/types.ts
index 67d5b019b..cd20ff8cd 100644
--- a/packages/web/src/ee/features/analytics/types.ts
+++ b/packages/web/src/ee/features/analytics/types.ts
@@ -1,6 +1,6 @@
import { z } from "zod";
-export const analyticsResponseSchema = z.array(z.object({
+export const analyticsRowSchema = z.object({
period: z.enum(['day', 'week', 'month']),
bucket: z.date(),
code_searches: z.number(),
@@ -9,5 +9,11 @@ export const analyticsResponseSchema = z.array(z.object({
mcp_requests: z.number(),
api_requests: z.number(),
active_users: z.number(),
-}))
-export type AnalyticsResponse = z.infer;
\ No newline at end of file
+});
+export type AnalyticsRow = z.infer;
+
+export type AnalyticsResponse = {
+ rows: AnalyticsRow[];
+ retentionDays: number;
+ oldestRecordDate: Date | null;
+};
\ No newline at end of file
From 5f487a369db2e0177c8b32402d1e1e336e92cba1 Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 17:01:12 -0800
Subject: [PATCH 04/19] chore: update CHANGELOG for audit log retention policy
(#950)
---
CHANGELOG.md | 1 +
1 file changed, 1 insertion(+)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 63aef31fa..12e240c27 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added support a MCP streamable http transport hosted at `/api/mcp`. [#976](https://github.com/sourcebot-dev/sourcebot/pull/976)
- Added MCP and API key usage tracking to analytics dashboard. Move audit events from client-side to service functions to capture all API calls (web UI, MCP, and non-MCP). Display MCP requests and API requests on separate charts. [#948](https://github.com/sourcebot-dev/sourcebot/pull/948)
+- Added audit log retention policy with `SOURCEBOT_EE_AUDIT_RETENTION_DAYS` environment variable (default 180 days). Daily background job prunes old audit records. [#950](https://github.com/sourcebot-dev/sourcebot/pull/950)
## [4.13.2] - 2026-03-02
From 4ffb9d08d0328f55aebd9f8c2b3c53aa2ca6d593 Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 17:52:13 -0800
Subject: [PATCH 05/19] feat(web): add sourceOverride to getFileSource and
getTree
Extend the sourceOverride pattern to getFileSource and getTree so
internal callers (chat AI agent) can tag audit events with the correct
source instead of relying on the HTTP header.
Co-Authored-By: Claude Opus 4.6
---
packages/web/src/features/chat/agent.ts | 2 +-
packages/web/src/features/chat/tools.ts | 7 ++++---
packages/web/src/features/git/getFileSourceApi.ts | 4 ++--
packages/web/src/features/git/getTreeApi.ts | 4 ++--
4 files changed, 9 insertions(+), 8 deletions(-)
diff --git a/packages/web/src/features/chat/agent.ts b/packages/web/src/features/chat/agent.ts
index da9fb3baa..c3b4dbb80 100644
--- a/packages/web/src/features/chat/agent.ts
+++ b/packages/web/src/features/chat/agent.ts
@@ -171,7 +171,7 @@ const createAgentStream = async ({
path: source.path,
repo: source.repo,
ref: source.revision,
- });
+ }, { sourceOverride: 'sourcebot-ask-agent' });
if (isServiceError(fileSource)) {
logger.error("Error fetching file source:", fileSource);
diff --git a/packages/web/src/features/chat/tools.ts b/packages/web/src/features/chat/tools.ts
index 87a251214..86af93679 100644
--- a/packages/web/src/features/chat/tools.ts
+++ b/packages/web/src/features/chat/tools.ts
@@ -115,7 +115,7 @@ export const readFilesTool = tool({
path,
repo: repository,
ref: revision,
- });
+ }, { sourceOverride: 'sourcebot-ask-agent' });
}));
if (responses.some(isServiceError)) {
@@ -221,7 +221,8 @@ export const createCodeSearchTool = (selectedRepos: string[]) => tool({
contextLines: 3,
isCaseSensitivityEnabled: caseSensitive,
isRegexEnabled: useRegex,
- }
+ },
+ sourceOverride: 'sourcebot-ask-agent',
});
if (isServiceError(response)) {
@@ -253,7 +254,7 @@ export const listReposTool = tool({
description: 'Lists repositories in the organization with optional filtering and pagination.',
inputSchema: listReposQueryParamsSchema,
execute: async (request: ListReposQueryParams) => {
- const reposResponse = await listRepos(request);
+ const reposResponse = await listRepos({ ...request, sourceOverride: 'sourcebot-ask-agent' });
if (isServiceError(reposResponse)) {
return reposResponse;
diff --git a/packages/web/src/features/git/getFileSourceApi.ts b/packages/web/src/features/git/getFileSourceApi.ts
index dfb3895f2..7b48b8044 100644
--- a/packages/web/src/features/git/getFileSourceApi.ts
+++ b/packages/web/src/features/git/getFileSourceApi.ts
@@ -33,9 +33,9 @@ export const fileSourceResponseSchema = z.object({
});
export type FileSourceResponse = z.infer;
-export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma, user }) => {
+export const getFileSource = async ({ path: filePath, repo: repoName, ref }: FileSourceRequest, { sourceOverride }: { sourceOverride?: string } = {}): Promise => sew(() => withOptionalAuthV2(async ({ org, prisma, user }) => {
if (user) {
- const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ const source = sourceOverride ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
getAuditService().createAudit({
action: 'user.fetched_file_source',
actor: { id: user.id, type: 'user' },
diff --git a/packages/web/src/features/git/getTreeApi.ts b/packages/web/src/features/git/getTreeApi.ts
index 0664822eb..5655388b4 100644
--- a/packages/web/src/features/git/getTreeApi.ts
+++ b/packages/web/src/features/git/getTreeApi.ts
@@ -26,10 +26,10 @@ export type GetTreeResponse = z.infer;
* repo/revision, including intermediate directories needed to connect them
* into a single tree.
*/
-export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest): Promise => sew(() =>
+export const getTree = async ({ repoName, revisionName, paths }: GetTreeRequest, { sourceOverride }: { sourceOverride?: string } = {}): Promise => sew(() =>
withOptionalAuthV2(async ({ org, prisma, user }) => {
if (user) {
- const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ const source = sourceOverride ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
getAuditService().createAudit({
action: 'user.fetched_file_tree',
actor: { id: user.id, type: 'user' },
From 40c832ca85c1209488e320f6d6be78b296306b04 Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 18:10:31 -0800
Subject: [PATCH 06/19] feat(web): restructure analytics by source and add
global active users
- Tag all audit events with source metadata (sourcebot-web-client,
sourcebot-ask-agent, sourcebot-ui-codenav, mcp) via sourceOverride
- Restructure analytics SQL to segment by Web App (sourcebot-*),
MCP, and API (everything else)
- Add global active users chart at top of analytics page
- Add info hover tooltips explaining each chart
- Prefix chart names with their section (Web/MCP/API) for clarity
- Update inject-audit-data script to use correct source values
Co-Authored-By: Claude Opus 4.6
---
.../db/tools/scripts/inject-audit-data.ts | 26 +-
.../app/api/(server)/repos/listReposApi.ts | 4 +-
.../web/src/ee/features/analytics/actions.ts | 56 +++-
.../features/analytics/analyticsContent.tsx | 239 +++++++++++++-----
.../web/src/ee/features/analytics/types.ts | 11 +-
.../components/symbolHoverPopup/index.tsx | 2 +
packages/web/src/features/chat/actions.ts | 3 +-
.../features/chat/useCreateNewChatThread.ts | 2 +-
packages/web/src/features/codeNav/api.ts | 6 +-
packages/web/src/features/mcp/askCodebase.ts | 11 +
packages/web/src/features/search/searchApi.ts | 6 +-
11 files changed, 275 insertions(+), 91 deletions(-)
diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts
index 404ee9c45..bcfbf7685 100644
--- a/packages/db/tools/scripts/inject-audit-data.ts
+++ b/packages/db/tools/scripts/inject-audit-data.ts
@@ -180,34 +180,37 @@ export const injectAuditData: Script = {
const activityChance = isWeekend ? user.weekendActivity : user.weekdayActivity;
if (Math.random() >= activityChance) continue;
- // --- Web UI activity (no source metadata) ---
+ // --- Web UI activity (source='sourcebot-web-client' or 'sourcebot-ui-codenav') ---
if (user.webWeight > 0) {
+ const webMeta: Prisma.InputJsonValue = { source: 'sourcebot-web-client' };
+ const codenavMeta: Prisma.InputJsonValue = { source: 'sourcebot-ui-codenav' };
+
// Code searches (2-5 base)
await createAudits(user.id, 'user.performed_code_search',
- scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'search');
+ scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'search', webMeta);
// Navigations: find references + goto definition (5-10 base)
const navCount = scaledCount(5, 10, user.webWeight, isWeekend);
for (let i = 0; i < navCount; i++) {
const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
- await createAudits(user.id, action, 1, currentDate, isWeekend, 'symbol');
+ await createAudits(user.id, action, 1, currentDate, isWeekend, 'symbol', codenavMeta);
}
// Ask chats (0-2 base) - web only
await createAudits(user.id, 'user.created_ask_chat',
- scaledCount(0, 2, user.webWeight, isWeekend), currentDate, isWeekend, 'org');
+ scaledCount(0, 2, user.webWeight, isWeekend), currentDate, isWeekend, 'org', webMeta);
// File source views (3-8 base)
await createAudits(user.id, 'user.fetched_file_source',
- scaledCount(3, 8, user.webWeight, isWeekend), currentDate, isWeekend, 'file');
+ scaledCount(3, 8, user.webWeight, isWeekend), currentDate, isWeekend, 'file', webMeta);
// File tree browsing (2-5 base)
await createAudits(user.id, 'user.fetched_file_tree',
- scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'repo');
+ scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'repo', webMeta);
// List repos (1-3 base)
await createAudits(user.id, 'user.listed_repos',
- scaledCount(1, 3, user.webWeight, isWeekend), currentDate, isWeekend, 'org');
+ scaledCount(1, 3, user.webWeight, isWeekend), currentDate, isWeekend, 'org', webMeta);
}
// --- MCP activity (source='mcp') ---
@@ -279,17 +282,18 @@ export const injectAuditData: Script = {
let webCount = 0, mcpCount = 0, apiCount = 0;
for (const audit of allAudits) {
const meta = audit.metadata as Record | null;
- if (!meta || !meta.source) {
+ const source = meta?.source as string | undefined;
+ if (source && typeof source === 'string' && source.startsWith('sourcebot-')) {
webCount++;
- } else if (meta.source === 'mcp') {
+ } else if (source === 'mcp') {
mcpCount++;
} else {
apiCount++;
}
}
console.log('\nSource breakdown:');
- console.log(` Web UI (no source): ${webCount}`);
+ console.log(` Web UI (source=sourcebot-*): ${webCount}`);
console.log(` MCP (source=mcp): ${mcpCount}`);
- console.log(` API (source=other): ${apiCount}`);
+ console.log(` API (source=other/null): ${apiCount}`);
},
};
diff --git a/packages/web/src/app/api/(server)/repos/listReposApi.ts b/packages/web/src/app/api/(server)/repos/listReposApi.ts
index adffe1a00..8ba2e9c6d 100644
--- a/packages/web/src/app/api/(server)/repos/listReposApi.ts
+++ b/packages/web/src/app/api/(server)/repos/listReposApi.ts
@@ -6,10 +6,10 @@ import { getBrowsePath } from "@/app/[domain]/browse/hooks/utils";
import { env } from "@sourcebot/shared";
import { headers } from "next/headers";
-export const listRepos = async ({ query, page, perPage, sort, direction }: ListReposQueryParams) => sew(() =>
+export const listRepos = async ({ query, page, perPage, sort, direction, sourceOverride }: ListReposQueryParams & { sourceOverride?: string }) => sew(() =>
withOptionalAuthV2(async ({ org, prisma, user }) => {
if (user) {
- const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ const source = sourceOverride ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
getAuditService().createAudit({
action: 'user.listed_repos',
actor: { id: user.id, type: 'user' },
diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts
index a75c7bedf..18d9a897b 100644
--- a/packages/web/src/ee/features/analytics/actions.ts
+++ b/packages/web/src/ee/features/analytics/actions.ts
@@ -80,12 +80,45 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
WHEN 'week' THEN c.week
ELSE c.month
END AS bucket,
- COUNT(*) FILTER (WHERE c.action = 'user.performed_code_search') AS code_searches,
- COUNT(*) FILTER (WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')) AS navigations,
- COUNT(*) FILTER (WHERE c.action = 'user.created_ask_chat') AS ask_chats,
- COUNT(*) FILTER (WHERE c.metadata->>'source' = 'mcp') AS mcp_requests,
- COUNT(*) FILTER (WHERE c.metadata->>'source' IS NOT NULL AND c.metadata->>'source' != 'mcp') AS api_requests,
- COUNT(DISTINCT c."actorId") AS active_users
+
+ -- Global active users (any action, any source)
+ COUNT(DISTINCT c."actorId") AS active_users,
+
+ -- Web App metrics (source LIKE 'sourcebot-%')
+ COUNT(*) FILTER (
+ WHERE c.action = 'user.performed_code_search'
+ AND c.metadata->>'source' LIKE 'sourcebot-%'
+ ) AS web_code_searches,
+ COUNT(*) FILTER (
+ WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')
+ AND c.metadata->>'source' LIKE 'sourcebot-%'
+ ) AS web_navigations,
+ COUNT(*) FILTER (
+ WHERE c.action = 'user.created_ask_chat'
+ AND c.metadata->>'source' LIKE 'sourcebot-%'
+ ) AS web_ask_chats,
+ COUNT(DISTINCT c."actorId") FILTER (
+ WHERE c.metadata->>'source' LIKE 'sourcebot-%'
+ ) AS web_active_users,
+
+ -- MCP metrics (source = 'mcp')
+ COUNT(*) FILTER (
+ WHERE c.metadata->>'source' = 'mcp'
+ ) AS mcp_requests,
+ COUNT(DISTINCT c."actorId") FILTER (
+ WHERE c.metadata->>'source' = 'mcp'
+ ) AS mcp_active_users,
+
+ -- API metrics (source IS NULL or not sourcebot-*/mcp)
+ COUNT(*) FILTER (
+ WHERE c.metadata->>'source' IS NULL
+ OR (c.metadata->>'source' NOT LIKE 'sourcebot-%' AND c.metadata->>'source' != 'mcp')
+ ) 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')
+ ) AS api_active_users
+
FROM core c
JOIN LATERAL (
SELECT unnest(array['day', 'week', 'month']) AS period
@@ -96,12 +129,15 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
SELECT
b.period,
b.bucket,
- COALESCE(a.code_searches, 0)::int AS code_searches,
- COALESCE(a.navigations, 0)::int AS navigations,
- COALESCE(a.ask_chats, 0)::int AS ask_chats,
+ COALESCE(a.active_users, 0)::int AS active_users,
+ COALESCE(a.web_code_searches, 0)::int AS web_code_searches,
+ COALESCE(a.web_navigations, 0)::int AS web_navigations,
+ COALESCE(a.web_ask_chats, 0)::int AS web_ask_chats,
+ COALESCE(a.web_active_users, 0)::int AS web_active_users,
COALESCE(a.mcp_requests, 0)::int AS mcp_requests,
+ COALESCE(a.mcp_active_users, 0)::int AS mcp_active_users,
COALESCE(a.api_requests, 0)::int AS api_requests,
- COALESCE(a.active_users, 0)::int AS active_users
+ COALESCE(a.api_active_users, 0)::int AS api_active_users
FROM buckets b
LEFT JOIN aggregated a
ON a.period = b.period AND a.bucket = b.bucket
diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx
index c8ef9de1f..1f295824e 100644
--- a/packages/web/src/ee/features/analytics/analyticsContent.tsx
+++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx
@@ -2,7 +2,7 @@
import { ChartTooltip } from "@/components/ui/chart"
import { Area, AreaChart, ResponsiveContainer, XAxis, YAxis } from "recharts"
-import { Users, LucideIcon, Search, ArrowRight, Activity, Calendar, MessageCircle, Wrench, Key } from "lucide-react"
+import { Users, LucideIcon, Search, ArrowRight, Activity, Calendar, MessageCircle, Wrench, Key, Info } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { ChartContainer } from "@/components/ui/chart"
import { useQuery } from "@tanstack/react-query"
@@ -14,6 +14,7 @@ import { getAnalytics } from "./actions"
import { useTheme } from "next-themes"
import { useMemo, useState } from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"
type TimePeriod = "day" | "week" | "month"
@@ -23,17 +24,27 @@ const periodLabels: Record = {
month: "Monthly",
}
+interface ChartDefinition {
+ title: string
+ icon: LucideIcon
+ color: string
+ dataKey: keyof Omit
+ gradientId: string
+ description: string
+}
+
interface AnalyticsChartProps {
data: AnalyticsRow[]
title: string
icon: LucideIcon
period: "day" | "week" | "month"
- dataKey: "code_searches" | "navigations" | "ask_chats" | "mcp_requests" | "api_requests" | "active_users"
+ dataKey: keyof Omit
color: string
gradientId: string
+ description: string
}
-function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradientId }: AnalyticsChartProps) {
+function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradientId, description }: AnalyticsChartProps) {
const { theme } = useTheme()
const isDark = theme === "dark"
@@ -57,8 +68,16 @@ function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradi
>
-
+
{title}
+
+
+
+
+
+ {description}
+
+
@@ -159,6 +178,26 @@ function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradi
)
}
+function ChartSkeletonGroup({ count }: { count: number }) {
+ return (
+ <>
+ {Array.from({ length: count }, (_, i) => (
+
+
+
+
+
+
+
+
+
+
+
+ ))}
+ >
+ )
+}
+
function LoadingSkeleton() {
return (
@@ -174,22 +213,16 @@ function LoadingSkeleton() {
- {/* Chart skeletons */}
- {[1, 2, 3, 4, 5, 6].map((chartIndex) => (
-
-
-
-
-
-
-
-
- ))}
+ {/* Global chart skeleton */}
+
+
+ {/* Web App section skeleton */}
+
+
+
+ {/* API section skeleton */}
+
+
)
}
@@ -197,7 +230,7 @@ function LoadingSkeleton() {
export function AnalyticsContent() {
const domain = useDomain()
const { theme } = useTheme()
-
+
// Time period selector state
const [selectedPeriod, setSelectedPeriod] = useState("day")
@@ -212,19 +245,23 @@ export function AnalyticsContent() {
})
const chartColors = useMemo(() => ({
- users: {
+ globalUsers: {
+ light: "#6366f1",
+ dark: "#818cf8",
+ },
+ webUsers: {
light: "#3b82f6",
dark: "#60a5fa",
},
- searches: {
+ webSearches: {
light: "#f59e0b",
dark: "#fbbf24",
},
- navigations: {
+ webNavigations: {
light: "#ef4444",
dark: "#f87171",
},
- askChats: {
+ webAskChats: {
light: "#8b5cf6",
dark: "#a78bfa",
},
@@ -232,10 +269,18 @@ export function AnalyticsContent() {
light: "#10b981",
dark: "#34d399",
},
+ mcpUsers: {
+ light: "#06b6d4",
+ dark: "#22d3ee",
+ },
apiRequests: {
light: "#14b8a6",
dark: "#2dd4bf",
},
+ apiUsers: {
+ light: "#f97316",
+ dark: "#fb923c",
+ },
}), [])
const getColor = (colorKey: keyof typeof chartColors) => {
@@ -268,41 +313,66 @@ export function AnalyticsContent() {
const periodData = analyticsResponse.rows.filter((row) => row.period === selectedPeriod)
- const charts = [
+ const globalChart: ChartDefinition = {
+ title: `${periodLabels[selectedPeriod]} Active Users`,
+ icon: Users,
+ color: getColor("globalUsers"),
+ dataKey: "active_users" as const,
+ gradientId: "activeUsers",
+ description: "Unique users who performed any tracked action across all interfaces (web app, MCP, and API).",
+ }
+
+ const webCharts: ChartDefinition[] = [
{
- title: `${periodLabels[selectedPeriod]} Active Users`,
+ title: `${periodLabels[selectedPeriod]} Web Active Users`,
icon: Users,
- color: getColor("users"),
- dataKey: "active_users" as const,
- gradientId: "activeUsers",
+ color: getColor("webUsers"),
+ dataKey: "web_active_users" as const,
+ gradientId: "webActiveUsers",
+ description: "Unique users who performed any action through the Sourcebot web interface, including searches, navigations, chats, and file views.",
},
{
- title: `${periodLabels[selectedPeriod]} Code Searches`,
+ title: `${periodLabels[selectedPeriod]} Web Code Searches`,
icon: Search,
- color: getColor("searches"),
- dataKey: "code_searches" as const,
- gradientId: "codeSearches",
+ color: getColor("webSearches"),
+ dataKey: "web_code_searches" as const,
+ gradientId: "webCodeSearches",
+ description: "Number of code searches performed through the Sourcebot web interface.",
},
{
- title: `${periodLabels[selectedPeriod]} Navigations`,
- icon: ArrowRight,
- color: getColor("navigations"),
- dataKey: "navigations" as const,
- gradientId: "navigations",
+ title: `${periodLabels[selectedPeriod]} Web Ask Chats`,
+ icon: MessageCircle,
+ color: getColor("webAskChats"),
+ dataKey: "web_ask_chats" as const,
+ gradientId: "webAskChats",
+ description: "Number of Ask chat conversations created through the Sourcebot web interface.",
},
{
- title: `${periodLabels[selectedPeriod]} Ask Chats`,
- icon: MessageCircle,
- color: getColor("askChats"),
- dataKey: "ask_chats" as const,
- gradientId: "askChats",
+ title: `${periodLabels[selectedPeriod]} Web Navigations`,
+ icon: ArrowRight,
+ color: getColor("webNavigations"),
+ dataKey: "web_navigations" as const,
+ gradientId: "webNavigations",
+ description: "Number of go-to-definition and find-references actions performed in the web interface.",
},
+ ]
+
+ const apiCharts: ChartDefinition[] = [
{
title: `${periodLabels[selectedPeriod]} MCP Requests`,
icon: Wrench,
color: getColor("mcpRequests"),
dataKey: "mcp_requests" as const,
gradientId: "mcpRequests",
+ description: "Total number of requests made through MCP (Model Context Protocol) integrations.",
+ },
+ {
+ title: `${periodLabels[selectedPeriod]} MCP Active Users`,
+ icon: Users,
+ color: getColor("mcpUsers"),
+ dataKey: "mcp_active_users" as const,
+ gradientId: "mcpActiveUsers",
+ description: "Unique users who made requests through MCP integrations.",
},
{
title: `${periodLabels[selectedPeriod]} API Requests`,
@@ -310,6 +380,15 @@ export function AnalyticsContent() {
color: getColor("apiRequests"),
dataKey: "api_requests" as const,
gradientId: "apiRequests",
+ description: "Total number of requests made through direct API access, excluding web app and MCP traffic.",
+ },
+ {
+ title: `${periodLabels[selectedPeriod]} API Active Users`,
+ icon: Users,
+ color: getColor("apiUsers"),
+ dataKey: "api_active_users" as const,
+ gradientId: "apiActiveUsers",
+ description: "Unique users who made requests through direct API access, excluding web app and MCP traffic.",
},
]
@@ -350,19 +429,63 @@ export function AnalyticsContent() {
- {/* Analytics Charts */}
- {charts.map((chart) => (
-
- ))}
+ {/* Global Active Users */}
+
+
+ {/* Web App Section */}
+
+
+
Web App
+
+ Usage from the Sourcebot web interface.
+
+
+ {webCharts.map((chart) => (
+
+ ))}
+
+
+ {/* API Section */}
+
+
+
API
+
+ Usage from MCP integrations and direct API access.
+
+
+ {apiCharts.map((chart) => (
+
+ ))}
+
)
-}
\ No newline at end of file
+}
diff --git a/packages/web/src/ee/features/analytics/types.ts b/packages/web/src/ee/features/analytics/types.ts
index cd20ff8cd..ef44ad287 100644
--- a/packages/web/src/ee/features/analytics/types.ts
+++ b/packages/web/src/ee/features/analytics/types.ts
@@ -3,12 +3,15 @@ import { z } from "zod";
export const analyticsRowSchema = z.object({
period: z.enum(['day', 'week', 'month']),
bucket: z.date(),
- code_searches: z.number(),
- navigations: z.number(),
- ask_chats: z.number(),
+ active_users: z.number(),
+ web_code_searches: z.number(),
+ web_navigations: z.number(),
+ web_ask_chats: z.number(),
+ web_active_users: z.number(),
mcp_requests: z.number(),
+ mcp_active_users: z.number(),
api_requests: z.number(),
- active_users: z.number(),
+ api_active_users: z.number(),
});
export type AnalyticsRow = z.infer;
diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx
index 2dd86d505..f72ecd6d4 100644
--- a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx
+++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx
@@ -123,6 +123,7 @@ export const SymbolHoverPopup: React.FC = ({
action: "user.performed_goto_definition",
metadata: {
message: symbolInfo.symbolName,
+ source: 'sourcebot-ui-codenav',
},
});
@@ -176,6 +177,7 @@ export const SymbolHoverPopup: React.FC = ({
action: "user.performed_find_references",
metadata: {
message: symbolInfo.symbolName,
+ source: 'sourcebot-ui-codenav',
},
})
diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts
index f608a0d34..c1b5aec48 100644
--- a/packages/web/src/features/chat/actions.ts
+++ b/packages/web/src/features/chat/actions.ts
@@ -15,7 +15,7 @@ import { generateChatNameFromMessage, getConfiguredLanguageModels, isChatSharedW
const auditService = getAuditService();
-export const createChat = async () => sew(() =>
+export const createChat = async ({ source }: { source?: string } = {}) => sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
const isGuestUser = user === undefined;
@@ -45,6 +45,7 @@ export const createChat = async () => sew(() =>
type: "org",
},
orgId: org.id,
+ metadata: { source },
});
}
diff --git a/packages/web/src/features/chat/useCreateNewChatThread.ts b/packages/web/src/features/chat/useCreateNewChatThread.ts
index e3cda1d04..74d46f23b 100644
--- a/packages/web/src/features/chat/useCreateNewChatThread.ts
+++ b/packages/web/src/features/chat/useCreateNewChatThread.ts
@@ -37,7 +37,7 @@ export const useCreateNewChatThread = ({ isAuthenticated = false }: UseCreateNew
const inputMessage = createUIMessage(text, mentions.map((mention) => mention.data), selectedSearchScopes);
setIsLoading(true);
- const response = await createChat();
+ const response = await createChat({ source: 'sourcebot-web-client' });
if (isServiceError(response)) {
toast({
description: `❌ Failed to create chat. Reason: ${response.message}`
diff --git a/packages/web/src/features/codeNav/api.ts b/packages/web/src/features/codeNav/api.ts
index 83e0a8873..93c2e492f 100644
--- a/packages/web/src/features/codeNav/api.ts
+++ b/packages/web/src/features/codeNav/api.ts
@@ -57,7 +57,8 @@ export const findSearchBasedSymbolReferences = async (props: FindRelatedSymbolsR
options: {
matches: MAX_REFERENCE_COUNT,
contextLines: 0,
- }
+ },
+ sourceOverride: 'sourcebot-ui-codenav',
});
if (isServiceError(searchResult)) {
@@ -116,7 +117,8 @@ export const findSearchBasedSymbolDefinitions = async (props: FindRelatedSymbols
options: {
matches: MAX_REFERENCE_COUNT,
contextLines: 0,
- }
+ },
+ sourceOverride: 'sourcebot-ui-codenav',
});
if (isServiceError(searchResult)) {
diff --git a/packages/web/src/features/mcp/askCodebase.ts b/packages/web/src/features/mcp/askCodebase.ts
index 1d8af5aea..e4d14e8f2 100644
--- a/packages/web/src/features/mcp/askCodebase.ts
+++ b/packages/web/src/features/mcp/askCodebase.ts
@@ -11,6 +11,7 @@ import { randomUUID } from "crypto";
import { StatusCodes } from "http-status-codes";
import { InferUIMessageChunk, UIDataTypes, UIMessage, UITools } from "ai";
import { captureEvent } from "@/lib/posthog";
+import { getAuditService } from "@/ee/features/audit/factory";
import { createMessageStream } from "../chat/agent";
const logger = createLogger('ask-codebase-api');
@@ -89,6 +90,16 @@ export const askCodebase = (params: AskCodebaseParams): Promise {});
+ }
+
logger.debug(`Starting blocking agent for chat ${chat.id}`, {
chatId: chat.id,
query: query.substring(0, 100),
diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts
index 01cf33ec8..b80490bba 100644
--- a/packages/web/src/features/search/searchApi.ts
+++ b/packages/web/src/features/search/searchApi.ts
@@ -15,6 +15,7 @@ type QueryStringSearchRequest = {
queryType: 'string';
query: string;
options: SearchOptions;
+ sourceOverride?: string;
}
type QueryIRSearchRequest = {
@@ -22,6 +23,7 @@ type QueryIRSearchRequest = {
query: QueryIR;
// Omit options that are specific to query syntax parsing.
options: Omit;
+ sourceOverride?: string;
}
type SearchRequest = QueryStringSearchRequest | QueryIRSearchRequest;
@@ -29,7 +31,7 @@ type SearchRequest = QueryStringSearchRequest | QueryIRSearchRequest;
export const search = (request: SearchRequest) => sew(() =>
withOptionalAuthV2(async ({ prisma, user, org }) => {
if (user) {
- const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ const source = request.sourceOverride ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
getAuditService().createAudit({
action: 'user.performed_code_search',
actor: { id: user.id, type: 'user' },
@@ -60,7 +62,7 @@ export const search = (request: SearchRequest) => sew(() =>
export const streamSearch = (request: SearchRequest) => sew(() =>
withOptionalAuthV2(async ({ prisma, user, org }) => {
if (user) {
- const source = (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
+ const source = request.sourceOverride ?? (await headers()).get('X-Sourcebot-Client-Source') ?? undefined;
getAuditService().createAudit({
action: 'user.performed_code_search',
actor: { id: user.id, type: 'user' },
From 1ce7e738833196b7807d004c5e2b11307cd9ca12 Mon Sep 17 00:00:00 2001
From: msukkari
Date: Thu, 26 Feb 2026 18:23:57 -0800
Subject: [PATCH 07/19] feat(db): backfill audit source metadata and add v2
inject script
Add a migration that backfills the 'source' field in audit metadata for
historical events created before source tracking was introduced. All
old events were web-only, so code searches and chats get
'sourcebot-web-client' and navigations get 'sourcebot-ui-codenav'.
Also restore the original inject-audit-data script and add
inject-audit-data-v2 with source-aware mock data generation.
Co-Authored-By: Claude Opus 4.6
---
.../migration.sql | 19 +
packages/db/tools/scriptRunner.ts | 2 +
.../db/tools/scripts/inject-audit-data-v2.ts | 299 +++++++++++++++
.../db/tools/scripts/inject-audit-data.ts | 362 ++++++------------
4 files changed, 438 insertions(+), 244 deletions(-)
create mode 100644 packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
create mode 100644 packages/db/tools/scripts/inject-audit-data-v2.ts
diff --git a/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql b/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
new file mode 100644
index 000000000..72485b9aa
--- /dev/null
+++ b/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
@@ -0,0 +1,19 @@
+-- Backfill source metadata for historical audit events.
+--
+-- Before this change, all audit events were created from the web UI without
+-- a 'source' field in metadata. The new analytics dashboard segments events
+-- by source (sourcebot-*, mcp, or null/other for API). Without this backfill,
+-- historical web UI events would be misclassified as API traffic.
+
+-- Code searches and chat creation were web-only (no server-side audit existed)
+UPDATE "Audit"
+SET metadata = jsonb_set(COALESCE(metadata, '{}')::jsonb, '{source}', '"sourcebot-web-client"')
+WHERE action IN ('user.performed_code_search', 'user.created_ask_chat')
+ AND (metadata IS NULL OR metadata->>'source' IS NULL);
+
+-- Navigation events (find references, goto definition) were web-only
+-- (created from the symbolHoverPopup client component)
+UPDATE "Audit"
+SET metadata = jsonb_set(COALESCE(metadata, '{}')::jsonb, '{source}', '"sourcebot-ui-codenav"')
+WHERE action IN ('user.performed_find_references', 'user.performed_goto_definition')
+ AND (metadata IS NULL OR metadata->>'source' IS NULL);
diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts
index 8c7b5ab55..9732b9a84 100644
--- a/packages/db/tools/scriptRunner.ts
+++ b/packages/db/tools/scriptRunner.ts
@@ -2,6 +2,7 @@ import { PrismaClient } from "@sourcebot/db";
import { ArgumentParser } from "argparse";
import { migrateDuplicateConnections } from "./scripts/migrate-duplicate-connections";
import { injectAuditData } from "./scripts/inject-audit-data";
+import { injectAuditDataV2 } from "./scripts/inject-audit-data-v2";
import { injectUserData } from "./scripts/inject-user-data";
import { confirmAction } from "./utils";
import { injectRepoData } from "./scripts/inject-repo-data";
@@ -14,6 +15,7 @@ export interface Script {
export const scripts: Record = {
"migrate-duplicate-connections": migrateDuplicateConnections,
"inject-audit-data": injectAuditData,
+ "inject-audit-data-v2": injectAuditDataV2,
"inject-user-data": injectUserData,
"inject-repo-data": injectRepoData,
"test-repo-query-perf": testRepoQueryPerf,
diff --git a/packages/db/tools/scripts/inject-audit-data-v2.ts b/packages/db/tools/scripts/inject-audit-data-v2.ts
new file mode 100644
index 000000000..2d789e1a2
--- /dev/null
+++ b/packages/db/tools/scripts/inject-audit-data-v2.ts
@@ -0,0 +1,299 @@
+import { Script } from "../scriptRunner";
+import { PrismaClient, Prisma } from "../../dist";
+import { confirmAction } from "../utils";
+
+// User profile: defines how a user interacts with Sourcebot
+interface UserProfile {
+ id: string
+ // Whether this user uses the web UI, and how active they are (0 = never, 1 = heavy)
+ webWeight: number
+ // Whether this user uses MCP, and how active they are (0 = never, 1 = heavy)
+ mcpWeight: number
+ // Whether this user uses the API directly, and how active they are (0 = never, 1 = heavy)
+ apiWeight: number
+ // API source label (for non-MCP API usage)
+ apiSource: string
+ // How likely they are to be active on a weekday (0-1)
+ weekdayActivity: number
+ // How likely they are to be active on a weekend (0-1)
+ weekendActivity: number
+}
+
+// Generate realistic audit data for analytics testing
+// Simulates 50 users with mixed usage patterns across web UI, MCP, and API
+export const injectAuditDataV2: Script = {
+ run: async (prisma: PrismaClient) => {
+ const orgId = 1;
+
+ // Check if org exists
+ const org = await prisma.org.findUnique({
+ where: { id: orgId }
+ });
+
+ if (!org) {
+ console.error(`Organization with id ${orgId} not found. Please create it first.`);
+ return;
+ }
+
+ console.log(`Injecting audit data for organization: ${org.name} (${org.domain})`);
+
+ const apiSources = ['cli', 'sdk', 'custom-app'];
+
+ // Build user profiles with mixed usage patterns
+ const users: UserProfile[] = [];
+
+ // Web-only users (20): browse the UI, never use MCP or API
+ for (let i = 0; i < 20; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.6 + Math.random() * 0.4, // 0.6-1.0
+ mcpWeight: 0,
+ apiWeight: 0,
+ apiSource: '',
+ weekdayActivity: 0.7 + Math.random() * 0.2,
+ weekendActivity: 0.05 + Math.random() * 0.15,
+ });
+ }
+
+ // Hybrid web + MCP users (12): use the web UI daily and also have MCP set up in their IDE
+ for (let i = 0; i < 12; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.4 + Math.random() * 0.4, // 0.4-0.8
+ mcpWeight: 0.5 + Math.random() * 0.5, // 0.5-1.0
+ apiWeight: 0,
+ apiSource: '',
+ weekdayActivity: 0.8 + Math.random() * 0.15,
+ weekendActivity: 0.1 + Math.random() * 0.2,
+ });
+ }
+
+ // MCP-heavy users (8): primarily use MCP through their IDE, occasionally check the web UI
+ for (let i = 0; i < 8; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.05 + Math.random() * 0.2, // 0.05-0.25 (occasional)
+ mcpWeight: 0.7 + Math.random() * 0.3, // 0.7-1.0
+ apiWeight: 0,
+ apiSource: '',
+ weekdayActivity: 0.85 + Math.random() * 0.1,
+ weekendActivity: 0.3 + Math.random() * 0.3,
+ });
+ }
+
+ // API-only users (5): automated scripts/CI, no web UI or MCP
+ for (let i = 0; i < 5; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0,
+ mcpWeight: 0,
+ apiWeight: 0.6 + Math.random() * 0.4,
+ apiSource: apiSources[i % apiSources.length],
+ weekdayActivity: 0.9 + Math.random() * 0.1,
+ weekendActivity: 0.6 + Math.random() * 0.3,
+ });
+ }
+
+ // Hybrid web + API users (5): developers who use both the UI and have scripts that call the API
+ for (let i = 0; i < 5; i++) {
+ users.push({
+ id: `user_${String(users.length + 1).padStart(3, '0')}`,
+ webWeight: 0.3 + Math.random() * 0.4,
+ mcpWeight: 0,
+ apiWeight: 0.4 + Math.random() * 0.4,
+ apiSource: apiSources[i % apiSources.length],
+ weekdayActivity: 0.8 + Math.random() * 0.15,
+ weekendActivity: 0.1 + Math.random() * 0.2,
+ });
+ }
+
+ // Generate data for the last 90 days
+ const endDate = new Date();
+ const startDate = new Date();
+ startDate.setDate(startDate.getDate() - 90);
+
+ const webOnlyCount = users.filter(u => u.webWeight > 0 && u.mcpWeight === 0 && u.apiWeight === 0).length;
+ const hybridWebMcpCount = users.filter(u => u.webWeight > 0 && u.mcpWeight > 0).length;
+ const mcpHeavyCount = users.filter(u => u.mcpWeight > 0 && u.webWeight < 0.3).length;
+ const apiOnlyCount = users.filter(u => u.apiWeight > 0 && u.webWeight === 0 && u.mcpWeight === 0).length;
+ const hybridWebApiCount = users.filter(u => u.webWeight > 0 && u.apiWeight > 0).length;
+
+ console.log(`Generating data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
+ console.log(`User breakdown: ${webOnlyCount} web-only, ${hybridWebMcpCount} web+MCP, ${mcpHeavyCount} MCP-heavy, ${apiOnlyCount} API-only, ${hybridWebApiCount} web+API`);
+
+ confirmAction();
+
+ function randomTimestamp(date: Date, isWeekend: boolean): Date {
+ const ts = new Date(date);
+ if (isWeekend) {
+ ts.setHours(9 + Math.floor(Math.random() * 12));
+ } else {
+ ts.setHours(9 + Math.floor(Math.random() * 9));
+ }
+ ts.setMinutes(Math.floor(Math.random() * 60));
+ ts.setSeconds(Math.floor(Math.random() * 60));
+ return ts;
+ }
+
+ function scaledCount(baseMin: number, baseMax: number, weight: number, isWeekend: boolean): number {
+ const weekendFactor = isWeekend ? 0.3 : 1.0;
+ const scaledMax = Math.round(baseMax * weight * weekendFactor);
+ const scaledMin = Math.min(Math.round(baseMin * weight * weekendFactor), scaledMax);
+ if (scaledMax <= 0) return 0;
+ return scaledMin + Math.floor(Math.random() * (scaledMax - scaledMin + 1));
+ }
+
+ async function createAudits(
+ userId: string,
+ action: string,
+ count: number,
+ currentDate: Date,
+ isWeekend: boolean,
+ targetType: string,
+ metadata?: Prisma.InputJsonValue,
+ ) {
+ for (let i = 0; i < count; i++) {
+ await prisma.audit.create({
+ data: {
+ timestamp: randomTimestamp(currentDate, isWeekend),
+ action,
+ actorId: userId,
+ actorType: 'user',
+ targetId: `${targetType}_${Math.floor(Math.random() * 1000)}`,
+ targetType,
+ sourcebotVersion: '1.0.0',
+ orgId,
+ ...(metadata ? { metadata } : {}),
+ }
+ });
+ }
+ }
+
+ // Generate data for each day
+ for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
+ const currentDate = new Date(d);
+ const dayOfWeek = currentDate.getDay();
+ const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
+
+ for (const user of users) {
+ // Determine if user is active today
+ const activityChance = isWeekend ? user.weekendActivity : user.weekdayActivity;
+ if (Math.random() >= activityChance) continue;
+
+ // --- Web UI activity (source='sourcebot-web-client' or 'sourcebot-ui-codenav') ---
+ if (user.webWeight > 0) {
+ const webMeta: Prisma.InputJsonValue = { source: 'sourcebot-web-client' };
+ const codenavMeta: Prisma.InputJsonValue = { source: 'sourcebot-ui-codenav' };
+
+ // Code searches (2-5 base)
+ await createAudits(user.id, 'user.performed_code_search',
+ scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'search', webMeta);
+
+ // Navigations: find references + goto definition (5-10 base)
+ const navCount = scaledCount(5, 10, user.webWeight, isWeekend);
+ for (let i = 0; i < navCount; i++) {
+ const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
+ await createAudits(user.id, action, 1, currentDate, isWeekend, 'symbol', codenavMeta);
+ }
+
+ // Ask chats (0-2 base) - web only
+ await createAudits(user.id, 'user.created_ask_chat',
+ scaledCount(0, 2, user.webWeight, isWeekend), currentDate, isWeekend, 'org', webMeta);
+
+ // File source views (3-8 base)
+ await createAudits(user.id, 'user.fetched_file_source',
+ scaledCount(3, 8, user.webWeight, isWeekend), currentDate, isWeekend, 'file', webMeta);
+
+ // File tree browsing (2-5 base)
+ await createAudits(user.id, 'user.fetched_file_tree',
+ scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'repo', webMeta);
+
+ // List repos (1-3 base)
+ await createAudits(user.id, 'user.listed_repos',
+ scaledCount(1, 3, user.webWeight, isWeekend), currentDate, isWeekend, 'org', webMeta);
+ }
+
+ // --- MCP activity (source='mcp') ---
+ if (user.mcpWeight > 0) {
+ const meta: Prisma.InputJsonValue = { source: 'mcp' };
+
+ // MCP code searches (5-15 base) - higher volume than web
+ await createAudits(user.id, 'user.performed_code_search',
+ scaledCount(5, 15, user.mcpWeight, isWeekend), currentDate, isWeekend, 'search', meta);
+
+ // MCP file source fetches (5-12 base)
+ await createAudits(user.id, 'user.fetched_file_source',
+ scaledCount(5, 12, user.mcpWeight, isWeekend), currentDate, isWeekend, 'file', meta);
+
+ // MCP file tree fetches (3-6 base)
+ await createAudits(user.id, 'user.fetched_file_tree',
+ scaledCount(3, 6, user.mcpWeight, isWeekend), currentDate, isWeekend, 'repo', meta);
+
+ // MCP list repos (3-8 base)
+ await createAudits(user.id, 'user.listed_repos',
+ scaledCount(3, 8, user.mcpWeight, isWeekend), currentDate, isWeekend, 'org', meta);
+ }
+
+ // --- API activity (source=cli/sdk/custom-app) ---
+ if (user.apiWeight > 0) {
+ const meta: Prisma.InputJsonValue = { source: user.apiSource };
+
+ // API code searches (10-30 base) - highest volume, automated
+ await createAudits(user.id, 'user.performed_code_search',
+ scaledCount(10, 30, user.apiWeight, isWeekend), currentDate, isWeekend, 'search', meta);
+
+ // API file source fetches (8-20 base)
+ await createAudits(user.id, 'user.fetched_file_source',
+ scaledCount(8, 20, user.apiWeight, isWeekend), currentDate, isWeekend, 'file', meta);
+
+ // API file tree fetches (4-10 base)
+ await createAudits(user.id, 'user.fetched_file_tree',
+ scaledCount(4, 10, user.apiWeight, isWeekend), currentDate, isWeekend, 'repo', meta);
+
+ // API list repos (5-15 base)
+ await createAudits(user.id, 'user.listed_repos',
+ scaledCount(5, 15, user.apiWeight, isWeekend), currentDate, isWeekend, 'org', meta);
+ }
+ }
+ }
+
+ console.log(`\nAudit data injection complete!`);
+ console.log(`Users: ${users.length}`);
+ console.log(`Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
+
+ // Show statistics
+ const stats = await prisma.audit.groupBy({
+ by: ['action'],
+ where: { orgId },
+ _count: { action: true }
+ });
+
+ console.log('\nAction breakdown:');
+ stats.forEach(stat => {
+ console.log(` ${stat.action}: ${stat._count.action}`);
+ });
+
+ // Show source breakdown
+ const allAudits = await prisma.audit.findMany({
+ where: { orgId },
+ select: { metadata: true }
+ });
+
+ let webCount = 0, mcpCount = 0, apiCount = 0;
+ for (const audit of allAudits) {
+ const meta = audit.metadata as Record | null;
+ const source = meta?.source as string | undefined;
+ if (source && typeof source === 'string' && source.startsWith('sourcebot-')) {
+ webCount++;
+ } else if (source === 'mcp') {
+ mcpCount++;
+ } else {
+ apiCount++;
+ }
+ }
+ console.log('\nSource breakdown:');
+ console.log(` Web UI (source=sourcebot-*): ${webCount}`);
+ console.log(` MCP (source=mcp): ${mcpCount}`);
+ console.log(` API (source=other/null): ${apiCount}`);
+ },
+};
diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts
index bcfbf7685..56478e3e5 100644
--- a/packages/db/tools/scripts/inject-audit-data.ts
+++ b/packages/db/tools/scripts/inject-audit-data.ts
@@ -1,35 +1,18 @@
import { Script } from "../scriptRunner";
-import { PrismaClient, Prisma } from "../../dist";
+import { PrismaClient } from "../../dist";
import { confirmAction } from "../utils";
-// User profile: defines how a user interacts with Sourcebot
-interface UserProfile {
- id: string
- // Whether this user uses the web UI, and how active they are (0 = never, 1 = heavy)
- webWeight: number
- // Whether this user uses MCP, and how active they are (0 = never, 1 = heavy)
- mcpWeight: number
- // Whether this user uses the API directly, and how active they are (0 = never, 1 = heavy)
- apiWeight: number
- // API source label (for non-MCP API usage)
- apiSource: string
- // How likely they are to be active on a weekday (0-1)
- weekdayActivity: number
- // How likely they are to be active on a weekend (0-1)
- weekendActivity: number
-}
-
// Generate realistic audit data for analytics testing
-// Simulates 50 users with mixed usage patterns across web UI, MCP, and API
+// Simulates 50 engineers with varying activity patterns
export const injectAuditData: Script = {
run: async (prisma: PrismaClient) => {
const orgId = 1;
-
+
// Check if org exists
const org = await prisma.org.findUnique({
where: { id: orgId }
});
-
+
if (!org) {
console.error(`Organization with id ${orgId} not found. Please create it first.`);
return;
@@ -37,263 +20,154 @@ export const injectAuditData: Script = {
console.log(`Injecting audit data for organization: ${org.name} (${org.domain})`);
- const apiSources = ['cli', 'sdk', 'custom-app'];
-
- // Build user profiles with mixed usage patterns
- const users: UserProfile[] = [];
-
- // Web-only users (20): browse the UI, never use MCP or API
- for (let i = 0; i < 20; i++) {
- users.push({
- id: `user_${String(users.length + 1).padStart(3, '0')}`,
- webWeight: 0.6 + Math.random() * 0.4, // 0.6-1.0
- mcpWeight: 0,
- apiWeight: 0,
- apiSource: '',
- weekdayActivity: 0.7 + Math.random() * 0.2,
- weekendActivity: 0.05 + Math.random() * 0.15,
- });
- }
-
- // Hybrid web + MCP users (12): use the web UI daily and also have MCP set up in their IDE
- for (let i = 0; i < 12; i++) {
- users.push({
- id: `user_${String(users.length + 1).padStart(3, '0')}`,
- webWeight: 0.4 + Math.random() * 0.4, // 0.4-0.8
- mcpWeight: 0.5 + Math.random() * 0.5, // 0.5-1.0
- apiWeight: 0,
- apiSource: '',
- weekdayActivity: 0.8 + Math.random() * 0.15,
- weekendActivity: 0.1 + Math.random() * 0.2,
- });
- }
-
- // MCP-heavy users (8): primarily use MCP through their IDE, occasionally check the web UI
- for (let i = 0; i < 8; i++) {
- users.push({
- id: `user_${String(users.length + 1).padStart(3, '0')}`,
- webWeight: 0.05 + Math.random() * 0.2, // 0.05-0.25 (occasional)
- mcpWeight: 0.7 + Math.random() * 0.3, // 0.7-1.0
- apiWeight: 0,
- apiSource: '',
- weekdayActivity: 0.85 + Math.random() * 0.1,
- weekendActivity: 0.3 + Math.random() * 0.3,
- });
- }
-
- // API-only users (5): automated scripts/CI, no web UI or MCP
- for (let i = 0; i < 5; i++) {
- users.push({
- id: `user_${String(users.length + 1).padStart(3, '0')}`,
- webWeight: 0,
- mcpWeight: 0,
- apiWeight: 0.6 + Math.random() * 0.4,
- apiSource: apiSources[i % apiSources.length],
- weekdayActivity: 0.9 + Math.random() * 0.1,
- weekendActivity: 0.6 + Math.random() * 0.3,
- });
- }
-
- // Hybrid web + API users (5): developers who use both the UI and have scripts that call the API
- for (let i = 0; i < 5; i++) {
- users.push({
- id: `user_${String(users.length + 1).padStart(3, '0')}`,
- webWeight: 0.3 + Math.random() * 0.4,
- mcpWeight: 0,
- apiWeight: 0.4 + Math.random() * 0.4,
- apiSource: apiSources[i % apiSources.length],
- weekdayActivity: 0.8 + Math.random() * 0.15,
- weekendActivity: 0.1 + Math.random() * 0.2,
- });
- }
+ // Generate 50 fake user IDs
+ const userIds = Array.from({ length: 50 }, (_, i) => `user_${String(i + 1).padStart(3, '0')}`);
+
+ // Actions we're tracking
+ const actions = [
+ 'user.performed_code_search',
+ 'user.performed_find_references',
+ 'user.performed_goto_definition',
+ 'user.created_ask_chat'
+ ];
// Generate data for the last 90 days
const endDate = new Date();
const startDate = new Date();
startDate.setDate(startDate.getDate() - 90);
- const webOnlyCount = users.filter(u => u.webWeight > 0 && u.mcpWeight === 0 && u.apiWeight === 0).length;
- const hybridWebMcpCount = users.filter(u => u.webWeight > 0 && u.mcpWeight > 0).length;
- const mcpHeavyCount = users.filter(u => u.mcpWeight > 0 && u.webWeight < 0.3).length;
- const apiOnlyCount = users.filter(u => u.apiWeight > 0 && u.webWeight === 0 && u.mcpWeight === 0).length;
- const hybridWebApiCount = users.filter(u => u.webWeight > 0 && u.apiWeight > 0).length;
-
console.log(`Generating data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
- console.log(`User breakdown: ${webOnlyCount} web-only, ${hybridWebMcpCount} web+MCP, ${mcpHeavyCount} MCP-heavy, ${apiOnlyCount} API-only, ${hybridWebApiCount} web+API`);
confirmAction();
- function randomTimestamp(date: Date, isWeekend: boolean): Date {
- const ts = new Date(date);
- if (isWeekend) {
- ts.setHours(9 + Math.floor(Math.random() * 12));
- } else {
- ts.setHours(9 + Math.floor(Math.random() * 9));
- }
- ts.setMinutes(Math.floor(Math.random() * 60));
- ts.setSeconds(Math.floor(Math.random() * 60));
- return ts;
- }
-
- function scaledCount(baseMin: number, baseMax: number, weight: number, isWeekend: boolean): number {
- const weekendFactor = isWeekend ? 0.3 : 1.0;
- const scaledMax = Math.round(baseMax * weight * weekendFactor);
- const scaledMin = Math.min(Math.round(baseMin * weight * weekendFactor), scaledMax);
- if (scaledMax <= 0) return 0;
- return scaledMin + Math.floor(Math.random() * (scaledMax - scaledMin + 1));
- }
-
- async function createAudits(
- userId: string,
- action: string,
- count: number,
- currentDate: Date,
- isWeekend: boolean,
- targetType: string,
- metadata?: Prisma.InputJsonValue,
- ) {
- for (let i = 0; i < count; i++) {
- await prisma.audit.create({
- data: {
- timestamp: randomTimestamp(currentDate, isWeekend),
- action,
- actorId: userId,
- actorType: 'user',
- targetId: `${targetType}_${Math.floor(Math.random() * 1000)}`,
- targetType,
- sourcebotVersion: '1.0.0',
- orgId,
- ...(metadata ? { metadata } : {}),
- }
- });
- }
- }
-
// Generate data for each day
for (let d = new Date(startDate); d <= endDate; d.setDate(d.getDate() + 1)) {
const currentDate = new Date(d);
- const dayOfWeek = currentDate.getDay();
+ const dayOfWeek = currentDate.getDay(); // 0 = Sunday, 6 = Saturday
const isWeekend = dayOfWeek === 0 || dayOfWeek === 6;
-
- for (const user of users) {
- // Determine if user is active today
- const activityChance = isWeekend ? user.weekendActivity : user.weekdayActivity;
- if (Math.random() >= activityChance) continue;
-
- // --- Web UI activity (source='sourcebot-web-client' or 'sourcebot-ui-codenav') ---
- if (user.webWeight > 0) {
- const webMeta: Prisma.InputJsonValue = { source: 'sourcebot-web-client' };
- const codenavMeta: Prisma.InputJsonValue = { source: 'sourcebot-ui-codenav' };
-
- // Code searches (2-5 base)
- await createAudits(user.id, 'user.performed_code_search',
- scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'search', webMeta);
-
- // Navigations: find references + goto definition (5-10 base)
- const navCount = scaledCount(5, 10, user.webWeight, isWeekend);
- for (let i = 0; i < navCount; i++) {
- const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
- await createAudits(user.id, action, 1, currentDate, isWeekend, 'symbol', codenavMeta);
+
+ // For each user, generate activity for this day
+ for (const userId of userIds) {
+ // Determine if user is active today (higher chance on weekdays)
+ const isActiveToday = isWeekend
+ ? Math.random() < 0.15 // 15% chance on weekends
+ : Math.random() < 0.85; // 85% chance on weekdays
+
+ if (!isActiveToday) continue;
+
+ // Generate code searches (2-5 per day)
+ const codeSearches = isWeekend
+ ? Math.floor(Math.random() * 2) + 1 // 1-2 on weekends
+ : Math.floor(Math.random() * 4) + 2; // 2-5 on weekdays
+
+ // Generate navigation actions (5-10 per day)
+ const navigationActions = isWeekend
+ ? Math.floor(Math.random() * 3) + 1 // 1-3 on weekends
+ : Math.floor(Math.random() * 6) + 5; // 5-10 on weekdays
+
+ // Create code search records
+ for (let i = 0; i < codeSearches; i++) {
+ const timestamp = new Date(currentDate);
+ // Spread throughout the day (9 AM to 6 PM on weekdays, more random on weekends)
+ if (isWeekend) {
+ timestamp.setHours(9 + Math.floor(Math.random() * 12));
+ timestamp.setMinutes(Math.floor(Math.random() * 60));
+ } else {
+ timestamp.setHours(9 + Math.floor(Math.random() * 9));
+ timestamp.setMinutes(Math.floor(Math.random() * 60));
}
-
- // Ask chats (0-2 base) - web only
- await createAudits(user.id, 'user.created_ask_chat',
- scaledCount(0, 2, user.webWeight, isWeekend), currentDate, isWeekend, 'org', webMeta);
-
- // File source views (3-8 base)
- await createAudits(user.id, 'user.fetched_file_source',
- scaledCount(3, 8, user.webWeight, isWeekend), currentDate, isWeekend, 'file', webMeta);
-
- // File tree browsing (2-5 base)
- await createAudits(user.id, 'user.fetched_file_tree',
- scaledCount(2, 5, user.webWeight, isWeekend), currentDate, isWeekend, 'repo', webMeta);
-
- // List repos (1-3 base)
- await createAudits(user.id, 'user.listed_repos',
- scaledCount(1, 3, user.webWeight, isWeekend), currentDate, isWeekend, 'org', webMeta);
+ timestamp.setSeconds(Math.floor(Math.random() * 60));
+
+ await prisma.audit.create({
+ data: {
+ timestamp,
+ action: 'user.performed_code_search',
+ actorId: userId,
+ actorType: 'user',
+ targetId: `search_${Math.floor(Math.random() * 1000)}`,
+ targetType: 'search',
+ sourcebotVersion: '1.0.0',
+ orgId
+ }
+ });
}
- // --- MCP activity (source='mcp') ---
- if (user.mcpWeight > 0) {
- const meta: Prisma.InputJsonValue = { source: 'mcp' };
-
- // MCP code searches (5-15 base) - higher volume than web
- await createAudits(user.id, 'user.performed_code_search',
- scaledCount(5, 15, user.mcpWeight, isWeekend), currentDate, isWeekend, 'search', meta);
-
- // MCP file source fetches (5-12 base)
- await createAudits(user.id, 'user.fetched_file_source',
- scaledCount(5, 12, user.mcpWeight, isWeekend), currentDate, isWeekend, 'file', meta);
+ // Create navigation action records
+ for (let i = 0; i < navigationActions; i++) {
+ const timestamp = new Date(currentDate);
+ if (isWeekend) {
+ timestamp.setHours(9 + Math.floor(Math.random() * 12));
+ timestamp.setMinutes(Math.floor(Math.random() * 60));
+ } else {
+ timestamp.setHours(9 + Math.floor(Math.random() * 9));
+ timestamp.setMinutes(Math.floor(Math.random() * 60));
+ }
+ timestamp.setSeconds(Math.floor(Math.random() * 60));
- // MCP file tree fetches (3-6 base)
- await createAudits(user.id, 'user.fetched_file_tree',
- scaledCount(3, 6, user.mcpWeight, isWeekend), currentDate, isWeekend, 'repo', meta);
+ // Randomly choose between find references and goto definition
+ const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
- // MCP list repos (3-8 base)
- await createAudits(user.id, 'user.listed_repos',
- scaledCount(3, 8, user.mcpWeight, isWeekend), currentDate, isWeekend, 'org', meta);
+ await prisma.audit.create({
+ data: {
+ timestamp,
+ action,
+ actorId: userId,
+ actorType: 'user',
+ targetId: `symbol_${Math.floor(Math.random() * 1000)}`,
+ targetType: 'symbol',
+ sourcebotVersion: '1.0.0',
+ orgId
+ }
+ });
}
- // --- API activity (source=cli/sdk/custom-app) ---
- if (user.apiWeight > 0) {
- const meta: Prisma.InputJsonValue = { source: user.apiSource };
-
- // API code searches (10-30 base) - highest volume, automated
- await createAudits(user.id, 'user.performed_code_search',
- scaledCount(10, 30, user.apiWeight, isWeekend), currentDate, isWeekend, 'search', meta);
-
- // API file source fetches (8-20 base)
- await createAudits(user.id, 'user.fetched_file_source',
- scaledCount(8, 20, user.apiWeight, isWeekend), currentDate, isWeekend, 'file', meta);
-
- // API file tree fetches (4-10 base)
- await createAudits(user.id, 'user.fetched_file_tree',
- scaledCount(4, 10, user.apiWeight, isWeekend), currentDate, isWeekend, 'repo', meta);
-
- // API list repos (5-15 base)
- await createAudits(user.id, 'user.listed_repos',
- scaledCount(5, 15, user.apiWeight, isWeekend), currentDate, isWeekend, 'org', meta);
+ // Generate Ask chat sessions (0-2 per day on weekdays, 0-1 on weekends)
+ const askChats = isWeekend
+ ? Math.floor(Math.random() * 2) // 0-1 on weekends
+ : Math.floor(Math.random() * 3); // 0-2 on weekdays
+
+ // Create Ask chat records
+ for (let i = 0; i < askChats; i++) {
+ const timestamp = new Date(currentDate);
+ if (isWeekend) {
+ timestamp.setHours(9 + Math.floor(Math.random() * 12));
+ timestamp.setMinutes(Math.floor(Math.random() * 60));
+ } else {
+ timestamp.setHours(9 + Math.floor(Math.random() * 9));
+ timestamp.setMinutes(Math.floor(Math.random() * 60));
+ }
+ timestamp.setSeconds(Math.floor(Math.random() * 60));
+
+ await prisma.audit.create({
+ data: {
+ timestamp,
+ action: 'user.created_ask_chat',
+ actorId: userId,
+ actorType: 'user',
+ targetId: orgId.toString(),
+ targetType: 'org',
+ sourcebotVersion: '1.0.0',
+ orgId
+ }
+ });
}
}
}
console.log(`\nAudit data injection complete!`);
- console.log(`Users: ${users.length}`);
+ console.log(`Users: ${userIds.length}`);
console.log(`Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
-
- // Show statistics
+
+ // Show some statistics
const stats = await prisma.audit.groupBy({
by: ['action'],
where: { orgId },
_count: { action: true }
});
-
+
console.log('\nAction breakdown:');
stats.forEach(stat => {
console.log(` ${stat.action}: ${stat._count.action}`);
});
-
- // Show source breakdown
- const allAudits = await prisma.audit.findMany({
- where: { orgId },
- select: { metadata: true }
- });
-
- let webCount = 0, mcpCount = 0, apiCount = 0;
- for (const audit of allAudits) {
- const meta = audit.metadata as Record | null;
- const source = meta?.source as string | undefined;
- if (source && typeof source === 'string' && source.startsWith('sourcebot-')) {
- webCount++;
- } else if (source === 'mcp') {
- mcpCount++;
- } else {
- apiCount++;
- }
- }
- console.log('\nSource breakdown:');
- console.log(` Web UI (source=sourcebot-*): ${webCount}`);
- console.log(` MCP (source=mcp): ${mcpCount}`);
- console.log(` API (source=other/null): ${apiCount}`);
},
-};
+};
\ No newline at end of file
From 2219bf09d8fe02577a22bce30fbe777d0e04667e Mon Sep 17 00:00:00 2001
From: msukkari
Date: Wed, 4 Mar 2026 18:36:14 -0800
Subject: [PATCH 08/19] feat(web): add source-segmented analytics with MCP/API
tracking
Restructure analytics dashboard to segment metrics by source (web, MCP,
API). Add audit events for file source, file tree, and repo listing
actions. Pass source metadata through all audit event paths including
MCP server, chat blocking API, and code navigation. Backfill historical
audit events with sourcebot-web-client source.
Co-Authored-By: Claude Opus 4.6
---
.../migration.sql | 2 +-
.../db/tools/scripts/inject-audit-data-v2.ts | 4 +-
.../[...path]/components/codePreviewPanel.tsx | 2 +-
.../app/api/(server)/chat/blocking/route.ts | 3 +-
.../web/src/ee/features/analytics/actions.ts | 34 +-
.../features/analytics/analyticsContent.tsx | 303 ++++++++++++++----
.../web/src/ee/features/analytics/types.ts | 3 +
.../components/symbolHoverPopup/index.tsx | 4 +-
packages/web/src/features/mcp/askCodebase.ts | 5 +-
packages/web/src/features/mcp/server.ts | 8 +-
10 files changed, 282 insertions(+), 86 deletions(-)
diff --git a/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql b/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
index 72485b9aa..fe1ef0f79 100644
--- a/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
+++ b/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
@@ -14,6 +14,6 @@ WHERE action IN ('user.performed_code_search', 'user.created_ask_chat')
-- Navigation events (find references, goto definition) were web-only
-- (created from the symbolHoverPopup client component)
UPDATE "Audit"
-SET metadata = jsonb_set(COALESCE(metadata, '{}')::jsonb, '{source}', '"sourcebot-ui-codenav"')
+SET metadata = jsonb_set(COALESCE(metadata, '{}')::jsonb, '{source}', '"sourcebot-web-client"')
WHERE action IN ('user.performed_find_references', 'user.performed_goto_definition')
AND (metadata IS NULL OR metadata->>'source' IS NULL);
diff --git a/packages/db/tools/scripts/inject-audit-data-v2.ts b/packages/db/tools/scripts/inject-audit-data-v2.ts
index 2d789e1a2..eba7b031a 100644
--- a/packages/db/tools/scripts/inject-audit-data-v2.ts
+++ b/packages/db/tools/scripts/inject-audit-data-v2.ts
@@ -180,10 +180,10 @@ export const injectAuditDataV2: Script = {
const activityChance = isWeekend ? user.weekendActivity : user.weekdayActivity;
if (Math.random() >= activityChance) continue;
- // --- Web UI activity (source='sourcebot-web-client' or 'sourcebot-ui-codenav') ---
+ // --- Web UI activity (source='sourcebot-web-client') ---
if (user.webWeight > 0) {
const webMeta: Prisma.InputJsonValue = { source: 'sourcebot-web-client' };
- const codenavMeta: Prisma.InputJsonValue = { source: 'sourcebot-ui-codenav' };
+ const codenavMeta: Prisma.InputJsonValue = { source: 'sourcebot-web-client' };
// Code searches (2-5 base)
await createAudits(user.id, 'user.performed_code_search',
diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx
index cc5be9090..65d756ce7 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/components/codePreviewPanel.tsx
@@ -18,7 +18,7 @@ export const CodePreviewPanel = async ({ path, repoName, revisionName }: CodePre
path,
repo: repoName,
ref: revisionName,
- }),
+ }, { sourceOverride: 'sourcebot-web-client' }),
getRepoInfoByName(repoName),
]);
diff --git a/packages/web/src/app/api/(server)/chat/blocking/route.ts b/packages/web/src/app/api/(server)/chat/blocking/route.ts
index 5aaa04d1b..3f5cc585f 100644
--- a/packages/web/src/app/api/(server)/chat/blocking/route.ts
+++ b/packages/web/src/app/api/(server)/chat/blocking/route.ts
@@ -45,7 +45,8 @@ export const POST = apiHandler(async (request: NextRequest) => {
return serviceErrorResponse(requestBodySchemaValidationError(parsed.error));
}
- const response = await askCodebase(parsed.data);
+ const source = request.headers.get('X-Sourcebot-Client-Source') ?? undefined;
+ const response = await askCodebase({ ...parsed.data, source });
if (isServiceError(response)) {
return serviceErrorResponse(response);
diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts
index 18d9a897b..571207405 100644
--- a/packages/web/src/ee/features/analytics/actions.ts
+++ b/packages/web/src/ee/features/analytics/actions.ts
@@ -81,26 +81,43 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
ELSE c.month
END AS bucket,
- -- Global active users (any action, any source)
- COUNT(DISTINCT c."actorId") AS active_users,
+ -- 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-%')
+ ) AS active_users,
- -- Web App metrics (source LIKE 'sourcebot-%')
+ -- Web App metrics
+ COUNT(DISTINCT c."actorId") FILTER (
+ WHERE c.action = 'user.performed_code_search'
+ AND c.metadata->>'source' = 'sourcebot-web-client'
+ ) AS web_search_active_users,
COUNT(*) FILTER (
WHERE c.action = 'user.performed_code_search'
- AND c.metadata->>'source' LIKE 'sourcebot-%'
+ AND c.metadata->>'source' = 'sourcebot-web-client'
) AS web_code_searches,
COUNT(*) FILTER (
WHERE c.action IN ('user.performed_find_references', 'user.performed_goto_definition')
- AND c.metadata->>'source' LIKE 'sourcebot-%'
+ AND c.metadata->>'source' = 'sourcebot-web-client'
) AS web_navigations,
+ COUNT(DISTINCT c."actorId") FILTER (
+ WHERE c.action = 'user.created_ask_chat'
+ AND c.metadata->>'source' = 'sourcebot-web-client'
+ ) AS web_ask_active_users,
COUNT(*) FILTER (
WHERE c.action = 'user.created_ask_chat'
- AND c.metadata->>'source' LIKE 'sourcebot-%'
+ AND c.metadata->>'source' = 'sourcebot-web-client'
) AS web_ask_chats,
COUNT(DISTINCT c."actorId") FILTER (
- WHERE c.metadata->>'source' LIKE 'sourcebot-%'
+ WHERE c.metadata->>'source' = 'sourcebot-web-client'
+ AND c.action != 'user.listed_repos'
) AS web_active_users,
+ -- 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-%'
+ ) AS non_web_active_users,
+
-- MCP metrics (source = 'mcp')
COUNT(*) FILTER (
WHERE c.metadata->>'source' = 'mcp'
@@ -130,10 +147,13 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined =
b.period,
b.bucket,
COALESCE(a.active_users, 0)::int AS active_users,
+ COALESCE(a.web_search_active_users, 0)::int AS web_search_active_users,
COALESCE(a.web_code_searches, 0)::int AS web_code_searches,
COALESCE(a.web_navigations, 0)::int AS web_navigations,
+ COALESCE(a.web_ask_active_users, 0)::int AS web_ask_active_users,
COALESCE(a.web_ask_chats, 0)::int AS web_ask_chats,
COALESCE(a.web_active_users, 0)::int AS web_active_users,
+ COALESCE(a.non_web_active_users, 0)::int AS non_web_active_users,
COALESCE(a.mcp_requests, 0)::int AS mcp_requests,
COALESCE(a.mcp_active_users, 0)::int AS mcp_active_users,
COALESCE(a.api_requests, 0)::int AS api_requests,
diff --git a/packages/web/src/ee/features/analytics/analyticsContent.tsx b/packages/web/src/ee/features/analytics/analyticsContent.tsx
index 1f295824e..3e54f0cdb 100644
--- a/packages/web/src/ee/features/analytics/analyticsContent.tsx
+++ b/packages/web/src/ee/features/analytics/analyticsContent.tsx
@@ -178,6 +178,161 @@ function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradi
)
}
+interface MultiLineSeriesDefinition {
+ dataKey: keyof Omit
+ label: string
+ color: string
+ gradientId: string
+}
+
+interface MultiLineChartProps {
+ data: AnalyticsRow[]
+ title: string
+ icon: LucideIcon
+ period: "day" | "week" | "month"
+ series: MultiLineSeriesDefinition[]
+ description: string
+}
+
+function MultiLineAnalyticsChart({ data, title, icon: Icon, period, series, description }: MultiLineChartProps) {
+ const { theme } = useTheme()
+ const isDark = theme === "dark"
+
+ const chartConfig = Object.fromEntries(
+ series.map((s) => [s.dataKey, { label: s.label, theme: { light: s.color, dark: s.color } }])
+ )
+
+ return (
+
+
+
+
+
+
+
+
+ {title}
+
+
+
+
+
+ {description}
+
+
+
+
+
+ {series.map((s) => (
+
+ ))}
+
+
+
+
+
+
+
+
+ {series.map((s) => (
+
+
+
+
+
+ ))}
+
+ {
+ const utcDate = new Date(value)
+ const displayDate = new Date(utcDate.getUTCFullYear(), utcDate.getUTCMonth(), utcDate.getUTCDate())
+ const opts: Intl.DateTimeFormatOptions =
+ period === "day" || period === "week"
+ ? { month: "short", day: "numeric" }
+ : { month: "short", year: "numeric" }
+ return displayDate.toLocaleDateString("en-US", opts)
+ }}
+ />
+ {
+ if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`
+ if (value >= 1000) return `${(value / 1000).toFixed(1)}K`
+ return value.toString()
+ }}
+ />
+ {
+ if (active && payload && payload.length) {
+ return (
+
+
+ {(() => {
+ const utcDate = new Date(label)
+ const displayDate = new Date(
+ utcDate.getUTCFullYear(),
+ utcDate.getUTCMonth(),
+ utcDate.getUTCDate(),
+ )
+ const opts: Intl.DateTimeFormatOptions =
+ period === "day" || period === "week"
+ ? { weekday: "short", month: "long", day: "numeric" }
+ : { month: "long", year: "numeric" }
+ return displayDate.toLocaleDateString("en-US", opts)
+ })()}
+
+ {payload.map((entry, index) => (
+
+
+
+
+ {series.find((s) => s.dataKey === entry.dataKey)?.label ?? entry.dataKey}
+
+
+
{entry.value?.toLocaleString()}
+
+ ))}
+
+ )
+ }
+ return null
+ }}
+ />
+ {series.map((s) => (
+
+ ))}
+
+
+
+
+
+ )
+}
+
function ChartSkeletonGroup({ count }: { count: number }) {
return (
<>
@@ -319,76 +474,84 @@ export function AnalyticsContent() {
color: getColor("globalUsers"),
dataKey: "active_users" as const,
gradientId: "activeUsers",
- description: "Unique users who performed any tracked action across all interfaces (web app, MCP, and API).",
+ description: "Unique users who performed any tracked action across all sources (web app, MCP, and API). Includes code searches, navigations, Ask chats, file views, and tree browsing. Excludes web repo listings to reduce noise.",
}
- const webCharts: ChartDefinition[] = [
+ const webActiveUsersSeries: MultiLineSeriesDefinition[] = [
{
- title: `${periodLabels[selectedPeriod]} Web Active Users`,
- icon: Users,
+ dataKey: "web_active_users",
+ label: "All",
color: getColor("webUsers"),
- dataKey: "web_active_users" as const,
gradientId: "webActiveUsers",
- description: "Unique users who performed any action through the Sourcebot web interface, including searches, navigations, chats, and file views.",
},
{
- title: `${periodLabels[selectedPeriod]} Web Code Searches`,
- icon: Search,
+ dataKey: "web_search_active_users",
+ label: "Search",
+ color: getColor("webSearches"),
+ gradientId: "webSearchActiveUsers",
+ },
+ {
+ dataKey: "web_ask_active_users",
+ label: "Ask",
+ color: getColor("webAskChats"),
+ gradientId: "webAskActiveUsers",
+ },
+ ]
+
+ const webActivitySeries: MultiLineSeriesDefinition[] = [
+ {
+ dataKey: "web_code_searches",
+ label: "Code Searches",
color: getColor("webSearches"),
- dataKey: "web_code_searches" as const,
gradientId: "webCodeSearches",
- description: "Number of code searches performed through the Sourcebot web interface.",
},
{
- title: `${periodLabels[selectedPeriod]} Web Ask Chats`,
- icon: MessageCircle,
+ dataKey: "web_ask_chats",
+ label: "Ask Chats",
color: getColor("webAskChats"),
- dataKey: "web_ask_chats" as const,
gradientId: "webAskChats",
- description: "Number of Ask chat conversations created through the Sourcebot web interface.",
},
{
- title: `${periodLabels[selectedPeriod]} Web Navigations`,
- icon: ArrowRight,
+ dataKey: "web_navigations",
+ label: "Navigations",
color: getColor("webNavigations"),
- dataKey: "web_navigations" as const,
gradientId: "webNavigations",
- description: "Number of go-to-definition and find-references actions performed in the web interface.",
},
]
- const apiCharts: ChartDefinition[] = [
+ const apiActiveUsersSeries: MultiLineSeriesDefinition[] = [
{
- title: `${periodLabels[selectedPeriod]} MCP Requests`,
- icon: Wrench,
- color: getColor("mcpRequests"),
- dataKey: "mcp_requests" as const,
- gradientId: "mcpRequests",
- description: "Total number of requests made through MCP (Model Context Protocol) integrations.",
+ dataKey: "non_web_active_users",
+ label: "Any",
+ color: getColor("globalUsers"),
+ gradientId: "nonWebActiveUsers",
},
{
- title: `${periodLabels[selectedPeriod]} MCP Active Users`,
- icon: Users,
+ dataKey: "mcp_active_users",
+ label: "MCP",
color: getColor("mcpUsers"),
- dataKey: "mcp_active_users" as const,
gradientId: "mcpActiveUsers",
- description: "Unique users who made requests through MCP integrations.",
- },
- {
- title: `${periodLabels[selectedPeriod]} API Requests`,
- icon: Key,
- color: getColor("apiRequests"),
- dataKey: "api_requests" as const,
- gradientId: "apiRequests",
- description: "Total number of requests made through direct API access, excluding web app and MCP traffic.",
},
{
- title: `${periodLabels[selectedPeriod]} API Active Users`,
- icon: Users,
+ dataKey: "api_active_users",
+ label: "API",
color: getColor("apiUsers"),
- dataKey: "api_active_users" as const,
gradientId: "apiActiveUsers",
- description: "Unique users who made requests through direct API access, excluding web app and MCP traffic.",
+ },
+ ]
+
+ const apiActivitySeries: MultiLineSeriesDefinition[] = [
+ {
+ dataKey: "mcp_requests",
+ label: "MCP",
+ color: getColor("mcpRequests"),
+ gradientId: "mcpRequests",
+ },
+ {
+ dataKey: "api_requests",
+ label: "API",
+ color: getColor("apiRequests"),
+ gradientId: "apiRequests",
},
]
@@ -449,19 +612,22 @@ export function AnalyticsContent() {
Usage from the Sourcebot web interface.
- {webCharts.map((chart) => (
-
- ))}
+
+
{/* API Section */}
@@ -472,19 +638,22 @@ export function AnalyticsContent() {
Usage from MCP integrations and direct API access.
- {apiCharts.map((chart) => (
-
- ))}
+
+
)
diff --git a/packages/web/src/ee/features/analytics/types.ts b/packages/web/src/ee/features/analytics/types.ts
index ef44ad287..fe8f593e3 100644
--- a/packages/web/src/ee/features/analytics/types.ts
+++ b/packages/web/src/ee/features/analytics/types.ts
@@ -4,10 +4,13 @@ export const analyticsRowSchema = z.object({
period: z.enum(['day', 'week', 'month']),
bucket: z.date(),
active_users: z.number(),
+ web_search_active_users: z.number(),
web_code_searches: z.number(),
web_navigations: z.number(),
+ web_ask_active_users: z.number(),
web_ask_chats: z.number(),
web_active_users: z.number(),
+ non_web_active_users: z.number(),
mcp_requests: z.number(),
mcp_active_users: z.number(),
api_requests: z.number(),
diff --git a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx
index f72ecd6d4..1e3616f7d 100644
--- a/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx
+++ b/packages/web/src/ee/features/codeNav/components/symbolHoverPopup/index.tsx
@@ -123,7 +123,7 @@ export const SymbolHoverPopup: React.FC = ({
action: "user.performed_goto_definition",
metadata: {
message: symbolInfo.symbolName,
- source: 'sourcebot-ui-codenav',
+ source: 'sourcebot-web-client',
},
});
@@ -177,7 +177,7 @@ export const SymbolHoverPopup: React.FC = ({
action: "user.performed_find_references",
metadata: {
message: symbolInfo.symbolName,
- source: 'sourcebot-ui-codenav',
+ source: 'sourcebot-web-client',
},
})
diff --git a/packages/web/src/features/mcp/askCodebase.ts b/packages/web/src/features/mcp/askCodebase.ts
index e4d14e8f2..2c1845245 100644
--- a/packages/web/src/features/mcp/askCodebase.ts
+++ b/packages/web/src/features/mcp/askCodebase.ts
@@ -21,6 +21,7 @@ export type AskCodebaseParams = {
repos?: string[];
languageModel?: LanguageModelInfo;
visibility?: ChatVisibility;
+ source?: string;
};
export type AskCodebaseResult = {
@@ -43,7 +44,7 @@ const blockStreamUntilFinish = async =>
sew(() =>
withOptionalAuthV2(async ({ org, user, prisma }) => {
- const { query, repos = [], languageModel: requestedLanguageModel, visibility: requestedVisibility } = params;
+ const { query, repos = [], languageModel: requestedLanguageModel, visibility: requestedVisibility, source } = params;
const configuredModels = await getConfiguredLanguageModels();
if (configuredModels.length === 0) {
@@ -96,7 +97,7 @@ export const askCodebase = (params: AskCodebaseParams): Promise {});
}
diff --git a/packages/web/src/features/mcp/server.ts b/packages/web/src/features/mcp/server.ts
index 07a27ec31..785f14eff 100644
--- a/packages/web/src/features/mcp/server.ts
+++ b/packages/web/src/features/mcp/server.ts
@@ -127,6 +127,7 @@ export function createMcpServer(): McpServer {
isRegexEnabled: useRegex,
isCaseSensitivityEnabled: caseSensitive,
},
+ sourceOverride: 'mcp',
});
if (isServiceError(response)) {
@@ -241,7 +242,7 @@ export function createMcpServer(): McpServer {
})
},
async ({ query, page, perPage, sort, direction }) => {
- const result = await listRepos({ query, page, perPage, sort, direction });
+ const result = await listRepos({ query, page, perPage, sort, direction, sourceOverride: 'mcp' });
if (isServiceError(result)) {
return {
@@ -279,7 +280,7 @@ export function createMcpServer(): McpServer {
},
},
async ({ repo, path, ref }) => {
- const response = await getFileSource({ repo, path, ref });
+ const response = await getFileSource({ repo, path, ref }, { sourceOverride: 'mcp' });
if (isServiceError(response)) {
return {
@@ -372,7 +373,7 @@ export function createMcpServer(): McpServer {
repoName: repo,
revisionName: ref,
paths: currentLevelPaths.filter(Boolean),
- });
+ }, { sourceOverride: 'mcp' });
if (isServiceError(treeResult)) {
treeError = treeResult.message;
@@ -484,6 +485,7 @@ export function createMcpServer(): McpServer {
repos: request.repos,
languageModel: request.languageModel,
visibility: request.visibility as ChatVisibility | undefined,
+ source: 'mcp',
});
if (isServiceError(result)) {
From b810cf1caf21e4ef3da50c4c4a8a540db7239a64 Mon Sep 17 00:00:00 2001
From: msukkari
Date: Wed, 4 Mar 2026 20:32:51 -0800
Subject: [PATCH 09/19] shift migrations
---
.../migration.sql | 0
1 file changed, 0 insertions(+), 0 deletions(-)
rename packages/db/prisma/migrations/{20260226000000_backfill_audit_source_metadata => 20260305000000_backfill_audit_source_metadata}/migration.sql (100%)
diff --git a/packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql b/packages/db/prisma/migrations/20260305000000_backfill_audit_source_metadata/migration.sql
similarity index 100%
rename from packages/db/prisma/migrations/20260226000000_backfill_audit_source_metadata/migration.sql
rename to packages/db/prisma/migrations/20260305000000_backfill_audit_source_metadata/migration.sql
From 693d53d28e6bdab72f710185ed0022862b2e4e94 Mon Sep 17 00:00:00 2001
From: msukkari
Date: Wed, 4 Mar 2026 20:49:14 -0800
Subject: [PATCH 10/19] new docs and news data
---
docs/docs/features/analytics.mdx | 44 +++++++++++++++++++++++--------
docs/images/analytics_demo.mp4 | Bin 1806708 -> 1503118 bytes
packages/web/src/lib/newsData.ts | 6 +++++
3 files changed, 39 insertions(+), 11 deletions(-)
diff --git a/docs/docs/features/analytics.mdx b/docs/docs/features/analytics.mdx
index 438e0bb5c..3c82b82bc 100644
--- a/docs/docs/features/analytics.mdx
+++ b/docs/docs/features/analytics.mdx
@@ -14,7 +14,7 @@ import { Callout } from 'nextra/components'
Analytics provides comprehensive insights into your organization's usage of Sourcebot, helping you understand adoption patterns and
quantify the value of time saved.
-This dashboard is backed by [audit log](/docs/configuration/audit-logs) events. Please ensure you have audit logging enabled in order to see these insights.
+This dashboard is backed by [audit log](/docs/configuration/audit-logs) events. Please ensure you have audit logging enabled in order to see these insights. Analytics data is subject to the audit log [retention policy](/docs/configuration/audit-logs#retention-policy). By default, data older than 180 days is automatically pruned.
-## Data Metrics
+The analytics dashboard segments usage by source so you can understand how your team interacts with Sourcebot across different interfaces. Each chart supports daily, weekly, and monthly time periods.
+
+## Global
### Active Users
-Tracks the number of unique users who performed any Sourcebot operation within each time period. This metric helps you understand team adoption
-and engagement with Sourcebot.
+Tracks the number of unique users who performed any tracked action across all sources (web app, MCP, and API). This includes code searches, navigations, Ask chats, file views, and tree browsing. Web repo listings are excluded to reduce noise from passive page loads.
+
+## Web App
+
+Metrics from the Sourcebot web interface.
+
+### Web Active Users
+Shows unique users who interacted with the web interface, broken down by activity type:
+- **All**: Users who performed any web action (code searches, navigations, Ask chats, or file views), excluding repo listings.
+- **Search**: The subset of users who performed code searches.
+- **Ask**: The subset of users who created Ask chat sessions.
+
+### Web Activity
+Total event counts for web interface activity:
+- **Code Searches**: Searches performed in the web search bar.
+- **Ask Chats**: Conversations created through the web interface.
+- **Navigations**: "Go to Definition" and "Find All References" actions in the code viewer.
+
+## API
-### Code Searches
-Counts the number of code search operations performed by your team.
+Metrics from MCP integrations and direct API access.
-### Code Navigation
-Tracks "Go to Definition" and "Find All References" navigation actions. Navigation actions help developers quickly move
-between code locations and understand code relationships.
+### API Active Users
+Shows unique users who interacted via non-web sources:
+- **Any**: Users who used either MCP or the API.
+- **MCP**: Users from IDE extensions and other MCP clients.
+- **API**: Users from direct HTTP API access (e.g., via API keys), excluding web app and MCP traffic.
-### Ask Chats
-Tracks the number of new Ask chat sessions created by your team.
\ No newline at end of file
+### API Requests
+Total request counts:
+- **MCP**: Code searches, file reads, tree listings, repo listings, and Ask chats from MCP clients.
+- **API**: Direct HTTP API requests, excluding web app and MCP traffic.
\ No newline at end of file
diff --git a/docs/images/analytics_demo.mp4 b/docs/images/analytics_demo.mp4
index ca971bdbdd15d607a1134d9585e56f8f095593a6..4038cdaa3982d33616fa1c7f29d9506f387bae0c 100644
GIT binary patch
literal 1503118
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
z
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