diff --git a/CHANGELOG.md b/CHANGELOG.md index c028736c4..bea08bd56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- [EE] Add Ask chat usage metrics to analytics dashboard [#736](https://github.com/sourcebot-dev/sourcebot/pull/736) + ## [4.10.9] - 2026-01-14 ### Changed diff --git a/docs/docs/configuration/audit-logs.mdx b/docs/docs/configuration/audit-logs.mdx index f229caaf3..6b38a4ea5 100644 --- a/docs/docs/configuration/audit-logs.mdx +++ b/docs/docs/configuration/audit-logs.mdx @@ -119,6 +119,7 @@ curl --request GET '$SOURCEBOT_URL/api/ee/audit' \ | `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` | diff --git a/docs/docs/features/analytics.mdx b/docs/docs/features/analytics.mdx index 0b22acadd..438e0bb5c 100644 --- a/docs/docs/features/analytics.mdx +++ b/docs/docs/features/analytics.mdx @@ -31,21 +31,12 @@ This dashboard is backed by [audit log](/docs/configuration/audit-logs) events. 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. -![DAU Chart](/images/dau_chart.png) - ### Code Searches Counts the number of code search operations performed by your team. -![Code Search Chart](/images/code_search_chart.png) - ### 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. -![Code Nav Chart](/images/code_nav_chart.png) - -## Cost Savings Calculator - -The analytics dashboard includes a built-in cost savings calculator that helps you quantify the ROI of using Sourcebot. - -![Cost Savings Chart](/images/cost_savings_chart.png) +### Ask Chats +Tracks the number of new Ask chat sessions created by your team. \ No newline at end of file diff --git a/docs/images/analytics_demo.mp4 b/docs/images/analytics_demo.mp4 index 10b537df7..ca971bdbd 100644 Binary files a/docs/images/analytics_demo.mp4 and b/docs/images/analytics_demo.mp4 differ diff --git a/docs/images/code_nav_chart.png b/docs/images/code_nav_chart.png deleted file mode 100644 index e56b117fd..000000000 Binary files a/docs/images/code_nav_chart.png and /dev/null differ diff --git a/docs/images/code_search_chart.png b/docs/images/code_search_chart.png deleted file mode 100644 index 81a903ff7..000000000 Binary files a/docs/images/code_search_chart.png and /dev/null differ diff --git a/docs/images/dau_chart.png b/docs/images/dau_chart.png deleted file mode 100644 index d1bdb80b7..000000000 Binary files a/docs/images/dau_chart.png and /dev/null differ diff --git a/packages/db/tools/scripts/inject-audit-data.ts b/packages/db/tools/scripts/inject-audit-data.ts index c0c202d9f..56478e3e5 100644 --- a/packages/db/tools/scripts/inject-audit-data.ts +++ b/packages/db/tools/scripts/inject-audit-data.ts @@ -27,7 +27,8 @@ export const injectAuditData: Script = { const actions = [ 'user.performed_code_search', 'user.performed_find_references', - 'user.performed_goto_definition' + 'user.performed_goto_definition', + 'user.created_ask_chat' ]; // Generate data for the last 90 days @@ -119,6 +120,37 @@ export const injectAuditData: Script = { } }); } + + // 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 + } + }); + } } } diff --git a/packages/web/src/ee/features/analytics/actions.ts b/packages/web/src/ee/features/analytics/actions.ts index 28f07ec6d..4610fd91b 100644 --- a/packages/web/src/ee/features/analytics/actions.ts +++ b/packages/web/src/ee/features/analytics/actions.ts @@ -33,7 +33,8 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined = AND action IN ( 'user.performed_code_search', 'user.performed_find_references', - 'user.performed_goto_definition' + 'user.performed_goto_definition', + 'user.created_ask_chat' ) ), @@ -77,6 +78,7 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined = 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(DISTINCT c."actorId") AS active_users FROM core c JOIN LATERAL ( @@ -90,6 +92,7 @@ export const getAnalytics = async (domain: string, apiKey: string | undefined = 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 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 1e2d782c7..093b2c7ec 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, DollarSign } from "lucide-react" +import { Users, LucideIcon, Search, ArrowRight, Activity, Calendar, MessageCircle } from "lucide-react" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { ChartContainer } from "@/components/ui/chart" import { useQuery } from "@tanstack/react-query" @@ -13,15 +13,22 @@ import { AnalyticsResponse } from "./types" import { getAnalytics } from "./actions" import { useTheme } from "next-themes" import { useMemo, useState } from "react" -import { Input } from "@/components/ui/input" -import { Label } from "@/components/ui/label" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" + +type TimePeriod = "day" | "week" | "month" + +const periodLabels: Record = { + day: "Daily", + week: "Weekly", + month: "Monthly", +} interface AnalyticsChartProps { data: AnalyticsResponse title: string icon: LucideIcon period: "day" | "week" | "month" - dataKey: "code_searches" | "navigations" | "active_users" + dataKey: "code_searches" | "navigations" | "ask_chats" | "active_users" color: string gradientId: string } @@ -152,196 +159,36 @@ function AnalyticsChart({ data, title, icon: Icon, period, dataKey, color, gradi ) } -interface SavingsChartProps { - data: AnalyticsResponse - title: string - icon: LucideIcon - period: "day" | "week" | "month" - color: string - gradientId: string - avgMinutesSaved: number - avgSalary: number -} - -function SavingsChart({ data, title, icon: Icon, period, color, gradientId, avgMinutesSaved, avgSalary }: SavingsChartProps) { - const { theme } = useTheme() - const isDark = theme === "dark" - - const savingsData = useMemo(() => { - return data.map(row => { - const totalOperations = row.code_searches + row.navigations - const totalMinutesSaved = totalOperations * avgMinutesSaved - const hourlyRate = avgSalary / (40 * 52) // Assuming 40 hours per week, 52 weeks per year - const hourlySavings = totalMinutesSaved / 60 * hourlyRate - - return { - ...row, - savings: Math.round(hourlySavings * 100) / 100 // Round to 2 decimal places - } - }) - }, [data, avgMinutesSaved, avgSalary]) - - const chartConfig = { - savings: { - label: title, - theme: { - light: color, - dark: color, - }, - }, - } - +function LoadingSkeleton() { return ( - - -
-
-
- -
-
- {title} -
-
+
+ {/* Header skeleton */} +
+
+ +
- - - - - - - - - - - - - { - 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.toFixed(0)}` - }} - /> - { - 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) => ( -
-
-
- {title} -
- ${entry.value?.toLocaleString()} -
- ))} -
- ) - } - return null - }} - /> - - - - - - - ) -} +
+ + +
+
-function LoadingSkeleton() { - return ( -
- {[1, 2, 3].map((groupIndex) => ( -
- {/* Full-width chart skeleton */} - - -
- -
- -
+ {/* Chart skeletons */} + {[1, 2, 3, 4].map((chartIndex) => ( + + +
+ +
+
- - - - - - - {/* Side-by-side charts skeleton */} -
- {[1, 2].map((chartIndex) => ( - - -
- -
- -
-
-
- - - -
- ))} -
-
+
+
+ + + +
))}
) @@ -351,11 +198,8 @@ export function AnalyticsContent() { const domain = useDomain() const { theme } = useTheme() - // Store these values as strings in the state to allow us to have empty fields for better UX - const [avgMinutesSaved, setAvgMinutesSaved] = useState("2") - const [avgSalary, setAvgSalary] = useState("100000") - const numericAvgMinutesSaved = parseFloat(avgMinutesSaved) || 0 - const numericAvgSalary = parseInt(avgSalary, 10) || 0 + // Time period selector state + const [selectedPeriod, setSelectedPeriod] = useState("day") const { data: analyticsResponse, @@ -380,9 +224,9 @@ export function AnalyticsContent() { light: "#ef4444", dark: "#f87171", }, - savings: { - light: "#10b981", - dark: "#34d399", + askChats: { + light: "#8b5cf6", + dark: "#a78bfa", }, }), []) @@ -390,14 +234,6 @@ export function AnalyticsContent() { return theme === "dark" ? chartColors[colorKey].dark : chartColors[colorKey].light } - const totalSavings = useMemo(() => { - if (!analyticsResponse) return 0 - const totalOperations = analyticsResponse.reduce((sum, row) => sum + row.code_searches + row.navigations, 0) - const totalMinutesSaved = totalOperations * numericAvgMinutesSaved - const hourlyRate = numericAvgSalary / (40 * 52) - return Math.round((totalMinutesSaved / 60 * hourlyRate) * 100) / 100 - }, [analyticsResponse, numericAvgMinutesSaved, numericAvgSalary]) - if (isPending) { return (
@@ -422,229 +258,79 @@ export function AnalyticsContent() { ) } - const dailyData = analyticsResponse.filter((row) => row.period === "day") - const weeklyData = analyticsResponse.filter((row) => row.period === "week") - const monthlyData = analyticsResponse.filter((row) => row.period === "month") + const periodData = analyticsResponse.filter((row) => row.period === selectedPeriod) - const chartGroups = [ + const charts = [ { - title: "Active Users", + title: `${periodLabels[selectedPeriod]} Active Users`, icon: Users, color: getColor("users"), - charts: [ - { - title: "Daily Active Users", - data: dailyData, - dataKey: "active_users" as const, - gradientId: "dailyUsers", - fullWidth: true, - }, - { - title: "Weekly Active Users", - data: weeklyData, - dataKey: "active_users" as const, - gradientId: "weeklyUsers", - fullWidth: false, - }, - { - title: "Monthly Active Users", - data: monthlyData, - dataKey: "active_users" as const, - gradientId: "monthlyUsers", - fullWidth: false, - }, - ], + dataKey: "active_users" as const, + gradientId: "activeUsers", }, { - title: "Code Searches", + title: `${periodLabels[selectedPeriod]} Code Searches`, icon: Search, color: getColor("searches"), - charts: [ - { - title: "Daily Code Searches", - data: dailyData, - dataKey: "code_searches" as const, - gradientId: "dailyCodeSearches", - fullWidth: true, - }, - { - title: "Weekly Code Searches", - data: weeklyData, - dataKey: "code_searches" as const, - gradientId: "weeklyCodeSearches", - fullWidth: false, - }, - { - title: "Monthly Code Searches", - data: monthlyData, - dataKey: "code_searches" as const, - gradientId: "monthlyCodeSearches", - fullWidth: false, - }, - ], + dataKey: "code_searches" as const, + gradientId: "codeSearches", }, { - title: "Navigations", + title: `${periodLabels[selectedPeriod]} Navigations`, icon: ArrowRight, color: getColor("navigations"), - charts: [ - { - title: "Daily Navigations", - data: dailyData, - dataKey: "navigations" as const, - gradientId: "dailyNavigations", - fullWidth: true, - }, - { - title: "Weekly Navigations", - data: weeklyData, - dataKey: "navigations" as const, - gradientId: "weeklyNavigations", - fullWidth: false, - }, - { - title: "Monthly Navigations", - data: monthlyData, - dataKey: "navigations" as const, - gradientId: "monthlyNavigations", - fullWidth: false, - }, - ], + dataKey: "navigations" as const, + gradientId: "navigations", + }, + { + title: `${periodLabels[selectedPeriod]} Ask Chats`, + icon: MessageCircle, + color: getColor("askChats"), + dataKey: "ask_chats" as const, + gradientId: "askChats", }, ] return ( -
- {chartGroups.map((group) => ( -
- {group.charts - .filter(chart => chart.fullWidth) - .map((chart) => ( -
- -
- ))} - -
- {group.charts - .filter(chart => !chart.fullWidth) - .map((chart) => ( - - ))} -
+
+ {/* Page Header */} +
+
+

Analytics

+

+ View usage metrics across your organization. +

- ))} - -
- - -
-
- -
-
- Savings Calculator -

Calculate the monetary value of time saved using Sourcebot

-
-
-
- -
-
- - setAvgMinutesSaved(e.target.value)} - placeholder="2" - /> -

Estimated time saved per search or navigation operation

-
-
- - setAvgSalary(e.target.value)} - placeholder="100000" - /> -

Average annual salary of your engineering team

-
-
- - - -
-

Total Estimated Savings

-

${totalSavings.toLocaleString()}

-

- Based on {analyticsResponse.reduce((sum, row) => sum + row.code_searches + row.navigations, 0).toLocaleString()} total operations -

-
-
-
-
-
-
- - -
- - -
+ {/* Time Period Selector */} +
+ +
+ + {/* Analytics Charts */} + {charts.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 283850d88..c2b573616 100644 --- a/packages/web/src/ee/features/analytics/types.ts +++ b/packages/web/src/ee/features/analytics/types.ts @@ -5,6 +5,7 @@ export const analyticsResponseSchema = z.array(z.object({ bucket: z.date(), code_searches: z.number(), navigations: z.number(), + ask_chats: z.number(), active_users: z.number(), })) export type AnalyticsResponse = z.infer; \ No newline at end of file diff --git a/packages/web/src/features/chat/actions.ts b/packages/web/src/features/chat/actions.ts index a9053bd2d..85084e9e3 100644 --- a/packages/web/src/features/chat/actions.ts +++ b/packages/web/src/features/chat/actions.ts @@ -1,6 +1,7 @@ 'use server'; import { sew } from "@/actions"; +import { getAuditService } from "@/ee/features/audit/factory"; import { ErrorCode } from "@/lib/errorCodes"; import { chatIsReadonly, notFound, ServiceError, serviceErrorResponse } from "@/lib/serviceError"; import { createAmazonBedrock } from '@ai-sdk/amazon-bedrock'; @@ -30,6 +31,7 @@ import { LanguageModelInfo, SBChatMessage } from "./types"; import { withAuthV2, withOptionalAuthV2 } from "@/withAuthV2"; const logger = createLogger('chat-actions'); +const auditService = getAuditService(); export const createChat = async () => sew(() => withOptionalAuthV2(async ({ org, user, prisma }) => { @@ -44,6 +46,22 @@ export const createChat = async () => sew(() => }, }); + // Only create audit log for authenticated users + if (!isGuestUser) { + await auditService.createAudit({ + action: "user.created_ask_chat", + actor: { + id: user.id, + type: "user", + }, + target: { + id: org.id.toString(), + type: "org", + }, + orgId: org.id, + }); + } + return { id: chat.id, }