diff --git a/CHANGELOG.md b/CHANGELOG.md
index 42b9e551..bdc2a90a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- Added analytics dashboard. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358)
+
### Fixed
- Fixed issue where invites appeared to be created successfully, but were not actually being created in the database. [#359](https://github.com/sourcebot-dev/sourcebot/pull/359)
+### Changed
+- Audit logging is now enabled by default. [#358](https://github.com/sourcebot-dev/sourcebot/pull/358)
+
## [4.4.0] - 2025-06-18
### Added
diff --git a/docs/docs.json b/docs/docs.json
index eb75dcb5..c8b3888e 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -36,6 +36,7 @@
]
},
"docs/features/code-navigation",
+ "docs/features/analytics",
"docs/features/mcp-server",
{
"group": "Agents",
diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx
index 52440160..2a3c0624 100644
--- a/docs/docs/configuration/audit-logs.mdx
+++ b/docs/docs/configuration/audit-logs.mdx
@@ -12,8 +12,8 @@ action, and when the action took place.
This feature gives security and compliance teams the necessary information to ensure proper governance and administration of your Sourcebot deployment.
-## Enabling Audit Logs
-Audit logs must be explicitly enabled by setting the `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` [environment variable](/docs/configuration/environment-variables) to `true`
+## 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).
## 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:
@@ -40,7 +40,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
{
"id": "cmc146c8r0001xgo2xyu0p463",
"timestamp": "2025-06-17T22:47:58.587Z",
- "action": "query.code_search",
+ "action": "user.performed_code_search",
"actorId": "cmc12tnje0000xgn58jj8655h",
"actorType": "user",
"targetId": "1",
@@ -54,7 +54,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
{
"id": "cmc12vqgb0008xgn5nv5hl9y5",
"timestamp": "2025-06-17T22:11:44.171Z",
- "action": "query.code_search",
+ "action": "user.performed_code_search",
"actorId": "cmc12tnje0000xgn58jj8655h",
"actorType": "user",
"targetId": "1",
@@ -68,7 +68,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
{
"id": "cmc12txwn0006xgn51ow1odid",
"timestamp": "2025-06-17T22:10:20.519Z",
- "action": "query.code_search",
+ "action": "user.performed_code_search",
"actorId": "cmc12tnje0000xgn58jj8655h",
"actorType": "user",
"targetId": "1",
@@ -116,6 +116,9 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `api_key.deleted` | `user` | `api_key` |
| `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.jit_provisioning_failed` | `user` | `org` |
| `user.jit_provisioned` | `user` | `org` |
| `user.join_request_creation_failed` | `user` | `org` |
@@ -131,9 +134,6 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \
| `user.signed_out` | `user` | `user` |
| `org.ownership_transfer_failed` | `user` | `org` |
| `org.ownership_transferred` | `user` | `org` |
-| `query.file_source` | `user \| api_key` | `file` |
-| `query.code_search` | `user \| api_key` | `org` |
-| `query.list_repositories` | `user \| api_key` | `org` |
## Response schema
diff --git a/docs/docs/configuration/environment-variables.mdx b/docs/docs/configuration/environment-variables.mdx
index 96f8e329..a55d735d 100644
--- a/docs/docs/configuration/environment-variables.mdx
+++ b/docs/docs/configuration/environment-variables.mdx
@@ -39,7 +39,7 @@ The following environment variables allow you to configure your Sourcebot deploy
### Enterprise Environment Variables
| Variable | Default | Description |
| :------- | :------ | :---------- |
-| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `false` |
Enables/disables audit logging
|
+| `SOURCEBOT_EE_AUDIT_LOGGING_ENABLED` | `true` | Enables/disables audit logging
|
| `AUTH_EE_ENABLE_JIT_PROVISIONING` | `false` | Enables/disables just-in-time user provisioning for SSO providers.
|
| `AUTH_EE_GITHUB_BASE_URL` | `https://github.com` | The base URL for GitHub Enterprise SSO authentication.
|
| `AUTH_EE_GITHUB_CLIENT_ID` | `-` | The client ID for GitHub Enterprise SSO authentication.
|
diff --git a/docs/docs/features/analytics.mdx b/docs/docs/features/analytics.mdx
new file mode 100644
index 00000000..0b22acad
--- /dev/null
+++ b/docs/docs/features/analytics.mdx
@@ -0,0 +1,51 @@
+---
+title: Analytics
+sidebarTitle: Analytics
+---
+
+import LicenseKeyRequired from '/snippets/license-key-required.mdx'
+import { Callout } from 'nextra/components'
+
+
+
+
+## Overview
+
+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.
+
+
+
+## Data Metrics
+
+### 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.
+
+
+
+### Code Searches
+Counts the number of code search operations performed by your team.
+
+
+
+### 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.
+
+
+
+## Cost Savings Calculator
+
+The analytics dashboard includes a built-in cost savings calculator that helps you quantify the ROI of using Sourcebot.
+
+
diff --git a/docs/images/analytics_demo.mp4 b/docs/images/analytics_demo.mp4
new file mode 100644
index 00000000..10b537df
Binary files /dev/null and b/docs/images/analytics_demo.mp4 differ
diff --git a/docs/images/code_nav_chart.png b/docs/images/code_nav_chart.png
new file mode 100644
index 00000000..e56b117f
Binary files /dev/null and b/docs/images/code_nav_chart.png differ
diff --git a/docs/images/code_search_chart.png b/docs/images/code_search_chart.png
new file mode 100644
index 00000000..81a903ff
Binary files /dev/null and b/docs/images/code_search_chart.png differ
diff --git a/docs/images/cost_savings_chart.png b/docs/images/cost_savings_chart.png
new file mode 100644
index 00000000..42238a4b
Binary files /dev/null and b/docs/images/cost_savings_chart.png differ
diff --git a/docs/images/dau_chart.png b/docs/images/dau_chart.png
new file mode 100644
index 00000000..d1bdb80b
Binary files /dev/null and b/docs/images/dau_chart.png differ
diff --git a/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql b/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql
new file mode 100644
index 00000000..b270515e
--- /dev/null
+++ b/packages/db/prisma/migrations/20250619231843_add_audit_indexes/migration.sql
@@ -0,0 +1,5 @@
+-- CreateIndex
+CREATE INDEX "idx_audit_core_actions_full" ON "Audit"("orgId", "timestamp", "action", "actorId");
+
+-- CreateIndex
+CREATE INDEX "idx_audit_actor_time_full" ON "Audit"("actorId", "timestamp");
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 113e13f0..29189373 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -245,6 +245,12 @@ model Audit {
orgId Int
@@index([actorId, actorType, targetId, targetType, orgId])
+
+ // Fast path for analytics queries – orgId is first because we assume most deployments are single tenant
+ @@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
+
+ // Fast path for analytics queries for a specific user
+ @@index([actorId, timestamp], map: "idx_audit_actor_time_full")
}
// @see : https://authjs.dev/concepts/database-models#user
diff --git a/packages/db/tools/scriptRunner.ts b/packages/db/tools/scriptRunner.ts
index ef9bfdd3..8e72063a 100644
--- a/packages/db/tools/scriptRunner.ts
+++ b/packages/db/tools/scriptRunner.ts
@@ -1,6 +1,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 { confirmAction } from "./utils";
import { createLogger } from "@sourcebot/logger";
@@ -10,6 +11,7 @@ export interface Script {
export const scripts: Record = {
"migrate-duplicate-connections": migrateDuplicateConnections,
+ "inject-audit-data": injectAuditData,
}
const parser = new ArgumentParser();
diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts
new file mode 100644
index 00000000..11f3e8cc
--- /dev/null
+++ b/packages/db/tools/scripts/inject-audit-data.ts
@@ -0,0 +1,144 @@
+import { Script } from "../scriptRunner";
+import { PrismaClient } from "../../dist";
+import { confirmAction } from "../utils";
+import { createLogger } from "@sourcebot/logger";
+
+const logger = createLogger('inject-audit-data');
+
+// Generate realistic audit data for analytics testing
+// 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) {
+ logger.error(`Organization with id ${orgId} not found. Please create it first.`);
+ return;
+ }
+
+ logger.info(`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'
+ ];
+
+ // Generate data for the last 90 days
+ const endDate = new Date();
+ const startDate = new Date();
+ startDate.setDate(startDate.getDate() - 90);
+
+ logger.info(`Generating data from ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
+
+ confirmAction();
+
+ // 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 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));
+ }
+ 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
+ }
+ });
+ }
+
+ // 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));
+
+ // Randomly choose between find references and goto definition
+ const action = Math.random() < 0.6 ? 'user.performed_find_references' : 'user.performed_goto_definition';
+
+ 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
+ }
+ });
+ }
+ }
+ }
+
+ logger.info(`\nAudit data injection complete!`);
+ logger.info(`Users: ${userIds.length}`);
+ logger.info(`Date range: ${startDate.toISOString().split('T')[0]} to ${endDate.toISOString().split('T')[0]}`);
+
+ // Show some statistics
+ const stats = await prisma.audit.groupBy({
+ by: ['action'],
+ where: { orgId },
+ _count: { action: true }
+ });
+
+ logger.info('\nAction breakdown:');
+ stats.forEach(stat => {
+ logger.info(` ${stat.action}: ${stat._count.action}`);
+ });
+ },
+};
\ No newline at end of file
diff --git a/packages/shared/src/entitlements.ts b/packages/shared/src/entitlements.ts
index 478fe66d..49e7e70f 100644
--- a/packages/shared/src/entitlements.ts
+++ b/packages/shared/src/entitlements.ts
@@ -37,15 +37,16 @@ const entitlements = [
"multi-tenancy",
"sso",
"code-nav",
- "audit"
+ "audit",
+ "analytics"
] as const;
export type Entitlement = (typeof entitlements)[number];
const entitlementsByPlan: Record = {
oss: [],
"cloud:team": ["billing", "multi-tenancy", "sso", "code-nav"],
- "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit"],
- "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit"],
+ "self-hosted:enterprise": ["search-contexts", "sso", "code-nav", "audit", "analytics"],
+ "self-hosted:enterprise-unlimited": ["search-contexts", "public-access", "sso", "code-nav", "audit", "analytics"],
// Special entitlement for https://demo.sourcebot.dev
"cloud:demo": ["public-access", "code-nav", "search-contexts"],
} as const;
diff --git a/packages/web/package.json b/packages/web/package.json
index 09987c54..fe82fad6 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -111,6 +111,7 @@
"codemirror-lang-sparql": "^2.0.0",
"codemirror-lang-spreadsheet": "^1.3.0",
"codemirror-lang-zig": "^0.1.0",
+ "date-fns": "^4.1.0",
"embla-carousel-auto-scroll": "^8.3.0",
"embla-carousel-react": "^8.3.0",
"escape-string-regexp": "^5.0.0",
@@ -119,7 +120,7 @@
"graphql": "^16.9.0",
"http-status-codes": "^2.3.0",
"input-otp": "^1.4.2",
- "lucide-react": "^0.435.0",
+ "lucide-react": "^0.517.0",
"micromatch": "^4.0.8",
"next": "14.2.26",
"next-auth": "^5.0.0-beta.25",
@@ -138,6 +139,7 @@
"react-hotkeys-hook": "^4.5.1",
"react-icons": "^5.3.0",
"react-resizable-panels": "^2.1.1",
+ "recharts": "^2.15.3",
"scroll-into-view-if-needed": "^3.1.0",
"server-only": "^0.0.1",
"sharp": "^0.33.5",
diff --git a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx
index c8f8384f..49124588 100644
--- a/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx
+++ b/packages/web/src/app/[domain]/browse/[...path]/components/pureCodePreviewPanel.tsx
@@ -17,6 +17,8 @@ import { BrowseHighlightRange, HIGHLIGHT_RANGE_QUERY_PARAM, useBrowseNavigation
import { useBrowseState } from "../../hooks/useBrowseState";
import { rangeHighlightingExtension } from "./rangeHighlightingExtension";
import useCaptureEvent from "@/hooks/useCaptureEvent";
+import { createAuditAction } from "@/ee/features/audit/actions";
+import { useDomain } from "@/hooks/useDomain";
interface PureCodePreviewPanelProps {
path: string;
@@ -40,6 +42,7 @@ export const PureCodePreviewPanel = ({
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
const { updateBrowseState } = useBrowseState();
const { navigateToPath } = useBrowseNavigation();
+ const domain = useDomain();
const captureEvent = useCaptureEvent();
const highlightRangeQuery = useNonEmptyQueryParam(HIGHLIGHT_RANGE_QUERY_PARAM);
@@ -134,6 +137,12 @@ export const PureCodePreviewPanel = ({
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_browse_find_references_pressed', {});
+ createAuditAction({
+ action: "user.performed_find_references",
+ metadata: {
+ message: symbolName,
+ },
+ }, domain)
updateBrowseState({
selectedSymbolInfo: {
@@ -145,13 +154,19 @@ export const PureCodePreviewPanel = ({
isBottomPanelCollapsed: false,
activeExploreMenuTab: "references",
})
- }, [captureEvent, updateBrowseState, repoName, revisionName, language]);
+ }, [captureEvent, updateBrowseState, repoName, revisionName, language, domain]);
// If we resolve multiple matches, instead of navigating to the first match, we should
// instead popup the bottom sheet with the list of matches.
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_browse_goto_definition_pressed', {});
+ createAuditAction({
+ action: "user.performed_goto_definition",
+ metadata: {
+ message: symbolName,
+ },
+ }, domain)
if (symbolDefinitions.length === 0) {
return;
@@ -180,7 +195,7 @@ export const PureCodePreviewPanel = ({
isBottomPanelCollapsed: false,
})
}
- }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language]);
+ }, [captureEvent, navigateToPath, revisionName, updateBrowseState, repoName, language, domain]);
const theme = useCodeMirrorTheme();
diff --git a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
index 450ed74b..d6fec7d8 100644
--- a/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
+++ b/packages/web/src/app/[domain]/components/searchBar/searchBar.tsx
@@ -43,6 +43,7 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip
import { Toggle } from "@/components/ui/toggle";
import { useDomain } from "@/hooks/useDomain";
import { KeyboardShortcutHint } from "@/app/components/keyboardShortcutHint";
+import { createAuditAction } from "@/ee/features/audit/actions";
import tailwind from "@/tailwind";
interface SearchBarProps {
@@ -204,6 +205,13 @@ export const SearchBar = ({
setIsSuggestionsEnabled(false);
setIsHistorySearchEnabled(false);
+ createAuditAction({
+ action: "user.performed_code_search",
+ metadata: {
+ message: query,
+ },
+ }, domain)
+
const url = createPathWithQueryParams(`/${domain}/search`,
[SearchQueryParams.query, query],
);
diff --git a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx
index bcea40af..8c869f8d 100644
--- a/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx
+++ b/packages/web/src/app/[domain]/search/components/codePreviewPanel/codePreview.tsx
@@ -21,6 +21,9 @@ import { SymbolHoverPopup } from "@/ee/features/codeNav/components/symbolHoverPo
import { symbolHoverTargetsExtension } from "@/ee/features/codeNav/components/symbolHoverPopup/symbolHoverTargetsExtension";
import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
import { SymbolDefinition } from "@/ee/features/codeNav/components/symbolHoverPopup/useHoveredOverSymbolInfo";
+import { createAuditAction } from "@/ee/features/audit/actions";
+import { useDomain } from "@/hooks/useDomain";
+
import useCaptureEvent from "@/hooks/useCaptureEvent";
export interface CodePreviewFile {
@@ -50,6 +53,7 @@ export const CodePreview = ({
const [editorRef, setEditorRef] = useState(null);
const { navigateToPath } = useBrowseNavigation();
const hasCodeNavEntitlement = useHasEntitlement("code-nav");
+ const domain = useDomain();
const [gutterWidth, setGutterWidth] = useState(0);
const theme = useCodeMirrorTheme();
@@ -116,6 +120,12 @@ export const CodePreview = ({
const onGotoDefinition = useCallback((symbolName: string, symbolDefinitions: SymbolDefinition[]) => {
captureEvent('wa_preview_panel_goto_definition_pressed', {});
+ createAuditAction({
+ action: "user.performed_goto_definition",
+ metadata: {
+ message: symbolName,
+ },
+ }, domain)
if (symbolDefinitions.length === 0) {
return;
@@ -150,10 +160,16 @@ export const CodePreview = ({
}
});
}
- }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
+ }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
const onFindReferences = useCallback((symbolName: string) => {
captureEvent('wa_preview_panel_find_references_pressed', {});
+ createAuditAction({
+ action: "user.performed_find_references",
+ metadata: {
+ message: symbolName,
+ },
+ }, domain)
navigateToPath({
repoName,
@@ -171,7 +187,7 @@ export const CodePreview = ({
isBottomPanelCollapsed: false,
}
})
- }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName]);
+ }, [captureEvent, file.filepath, file.language, file.revision, navigateToPath, repoName, domain]);
return (
diff --git a/packages/web/src/app/[domain]/settings/analytics/page.tsx b/packages/web/src/app/[domain]/settings/analytics/page.tsx
new file mode 100644
index 00000000..a542432b
--- /dev/null
+++ b/packages/web/src/app/[domain]/settings/analytics/page.tsx
@@ -0,0 +1,19 @@
+"use client"
+
+import { AnalyticsContent } from "@/ee/features/analytics/analyticsContent";
+import { AnalyticsEntitlementMessage } from "@/ee/features/analytics/analyticsEntitlementMessage";
+import { useHasEntitlement } from "@/features/entitlements/useHasEntitlement";
+
+export default function AnalyticsPage() {
+ return
;
+}
+
+function AnalyticsPageContent() {
+ const hasAnalyticsEntitlement = useHasEntitlement("analytics");
+
+ if (!hasAnalyticsEntitlement) {
+ return
;
+ }
+
+ return
;
+}
\ No newline at end of file
diff --git a/packages/web/src/app/[domain]/settings/layout.tsx b/packages/web/src/app/[domain]/settings/layout.tsx
index 4ad4c387..21fc834f 100644
--- a/packages/web/src/app/[domain]/settings/layout.tsx
+++ b/packages/web/src/app/[domain]/settings/layout.tsx
@@ -85,6 +85,10 @@ export default async function SettingsLayout({
title: "API Keys",
href: `/${domain}/settings/apiKeys`,
},
+ {
+ title: "Analytics",
+ href: `/${domain}/settings/analytics`,
+ },
...(env.NEXT_PUBLIC_SOURCEBOT_CLOUD_ENVIRONMENT === undefined ? [
{
title: "License",
diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts
index a341192d..769ff5f9 100644
--- a/packages/web/src/auth.ts
+++ b/packages/web/src/auth.ts
@@ -141,7 +141,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true,
events: {
createUser: onCreateUser,
- signIn: async ({ user, account }) => {
+ signIn: async ({ user }) => {
if (user.id) {
await auditService.createAudit({
action: "user.signed_in",
diff --git a/packages/web/src/components/ui/chart.tsx b/packages/web/src/components/ui/chart.tsx
new file mode 100644
index 00000000..39fba6d6
--- /dev/null
+++ b/packages/web/src/components/ui/chart.tsx
@@ -0,0 +1,365 @@
+"use client"
+
+import * as React from "react"
+import * as RechartsPrimitive from "recharts"
+
+import { cn } from "@/lib/utils"
+
+// Format: { THEME_NAME: CSS_SELECTOR }
+const THEMES = { light: "", dark: ".dark" } as const
+
+export type ChartConfig = {
+ [k in string]: {
+ label?: React.ReactNode
+ icon?: React.ComponentType
+ } & (
+ | { color?: string; theme?: never }
+ | { color?: never; theme: Record
}
+ )
+}
+
+type ChartContextProps = {
+ config: ChartConfig
+}
+
+const ChartContext = React.createContext(null)
+
+function useChart() {
+ const context = React.useContext(ChartContext)
+
+ if (!context) {
+ throw new Error("useChart must be used within a ")
+ }
+
+ return context
+}
+
+const ChartContainer = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ config: ChartConfig
+ children: React.ComponentProps<
+ typeof RechartsPrimitive.ResponsiveContainer
+ >["children"]
+ }
+>(({ id, className, children, config, ...props }, ref) => {
+ const uniqueId = React.useId()
+ const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
+
+ return (
+
+
+
+
+ {children}
+
+
+
+ )
+})
+ChartContainer.displayName = "Chart"
+
+const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
+ const colorConfig = Object.entries(config).filter(
+ ([, config]) => config.theme || config.color
+ )
+
+ if (!colorConfig.length) {
+ return null
+ }
+
+ return (
+