diff --git a/package.json b/package.json index 89c555a66..c4852a30e 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,8 @@ "db:pull": "bun scripts/db/pull.ts", "db:push": " bun -F @autumn/shared db:push", "db:generate": "bun -F @autumn/shared db:generate", - "db:migrate": " bun -F @autumn/shared db:migrate" + "db:migrate": " bun -F @autumn/shared db:migrate", + "db:studio": "bun -F @autumn/shared db:studio" }, "dependencies": { "@wooorm/starry-night": "^3.8.0", diff --git a/server/src/external/clickhouse/ClickHouseManager.ts b/server/src/external/clickhouse/ClickHouseManager.ts index 28858218c..b7e215480 100644 --- a/server/src/external/clickhouse/ClickHouseManager.ts +++ b/server/src/external/clickhouse/ClickHouseManager.ts @@ -11,6 +11,7 @@ export enum ClickHouseQuery { CREATE_DATE_RANGE_VIEW = "CREATE_DATE_RANGE_VIEW", CREATE_DATE_RANGE_BC_VIEW = "CREATE_DATE_RANGE_BC_VIEW", CREATE_ORG_EVENTS_VIEW = "CREATE_ORG_EVENTS_VIEW", + CREATE_EVENTS_USAGE_MATERIALIZED_VIEW = "CREATE_EVENTS_USAGE_MATERIALIZED_VIEW", // CREATE_GENERATE_EVENT_COUNT_EXPRESSIONS_FUNCTION = "CREATE_GENERATE_EVENT_COUNTS_EXPRESSIONS", // CREATE_GENERATE_EVENT_COUNT_EXPRESSIONS_NO_COUNT_FUNCTION = "CREATE_GENERATE_EVENT_COUNTS_EXPRESSIONS_NO_COUNT", GENERATE_EVENT_COUNT_EXPRESSIONS = "GENERATE_EVENT_COUNT_EXPRESSIONS", @@ -105,11 +106,35 @@ export class ClickHouseManager { static async createDateRangeBcView() {} static async createOrgEventsView() {} + static async createEventsUsageMaterializedView() { + const manager = await ClickHouseManager.getInstance(); + if (!manager.client) { + throw new Error("ClickHouse client not initialized"); + } + if (!ClickHouseManager.clickhouseAvailable) { + console.log( + "ClickHouse is not available, cannot create materialized view", + ); + return; + } + try { + await manager.executeQuery( + ClickHouseQuery.CREATE_EVENTS_USAGE_MATERIALIZED_VIEW, + manager.client, + ); + console.log("✓ Successfully created events_usage_mv materialized view"); + } catch (error) { + console.error("✗ Failed to create materialized view:", error); + throw error; + } + } + static async ensureSQLFilesExist() { const requiredQueries = [ ClickHouseQuery.CREATE_DATE_RANGE_VIEW, ClickHouseQuery.CREATE_DATE_RANGE_BC_VIEW, ClickHouseQuery.CREATE_ORG_EVENTS_VIEW, + ClickHouseQuery.CREATE_EVENTS_USAGE_MATERIALIZED_VIEW, // ClickHouseQuery.CREATE_GENERATE_EVENT_COUNT_EXPRESSIONS_FUNCTION, // ClickHouseQuery.CREATE_GENERATE_EVENT_COUNT_EXPRESSIONS_NO_COUNT_FUNCTION, ClickHouseQuery.GENERATE_EVENT_COUNT_EXPRESSIONS, @@ -171,6 +196,7 @@ export class ClickHouseManager { ClickHouseQuery.CREATE_DATE_RANGE_BC_VIEW, ClickHouseQuery.CREATE_DATE_RANGE_VIEW, ClickHouseQuery.CREATE_ORG_EVENTS_VIEW, + ClickHouseQuery.CREATE_EVENTS_USAGE_MATERIALIZED_VIEW, // ClickHouseQuery.CREATE_GENERATE_EVENT_COUNT_EXPRESSIONS_FUNCTION, ]; @@ -214,16 +240,16 @@ export class ClickHouseManager { throw new Error(`Query ${query} not found`); } - // For CREATE FUNCTION queries, use command() instead of query() to avoid FORMAT clause - // if ( - // query === ClickHouseQuery.CREATE_GENERATE_EVENT_COUNT_EXPRESSIONS_FUNCTION - // ) { - // const result = await client.command({ - // query: queryContent, - // ...options, - // }); - // return result; - // } + // For CREATE MATERIALIZED VIEW queries, use command() instead of query() to avoid FORMAT clause + if ( + query === ClickHouseQuery.CREATE_EVENTS_USAGE_MATERIALIZED_VIEW + ) { + const result = await client.command({ + query: queryContent, + ...options, + }); + return result; + } const result = await client.query({ query: queryContent, diff --git a/server/src/external/clickhouse/queries/CREATE_EVENTS_USAGE_MATERIALIZED_VIEW.sql b/server/src/external/clickhouse/queries/CREATE_EVENTS_USAGE_MATERIALIZED_VIEW.sql new file mode 100644 index 000000000..ea0efe837 --- /dev/null +++ b/server/src/external/clickhouse/queries/CREATE_EVENTS_USAGE_MATERIALIZED_VIEW.sql @@ -0,0 +1,36 @@ +-- Materialized view for analytics usage aggregations +-- Pre-aggregates events by org_id, env, customer_id, event_name, and time periods +-- Optimizes timeseries queries that group events by hour/day + +CREATE MATERIALIZED VIEW IF NOT EXISTS events_usage_mv +ENGINE = SummingMergeTree(value) +PARTITION BY toYYYYMM(period_hour) +ORDER BY (org_id, env, customer_id, event_name, period_hour) +SETTINGS allow_nullable_key = 1 +POPULATE +AS +SELECT + org_id, + env, + customer_id, + event_name, + date_trunc('hour', timestamp) as period_hour, + sum( + case + when isNotNull(JSONExtractString(properties, 'value')) AND JSONExtractString(properties, 'value') != '' + then round(toFloat64OrZero(JSONExtractString(properties, 'value')), 6) + when isNotNull(value) + then round(toFloat64(value), 6) + else 1.0 + end + ) as value +FROM events +WHERE set_usage = false + AND timestamp IS NOT NULL +GROUP BY + org_id, + env, + customer_id, + event_name, + date_trunc('hour', timestamp); + diff --git a/server/src/internal/analytics/AnalyticsService.ts b/server/src/internal/analytics/AnalyticsService.ts index ff1a1a694..82accdf63 100644 --- a/server/src/internal/analytics/AnalyticsService.ts +++ b/server/src/internal/analytics/AnalyticsService.ts @@ -8,10 +8,7 @@ import type { ClickHouseClient } from "@clickhouse/client"; import { Decimal } from "decimal.js"; import { StatusCodes } from "http-status-codes"; import type { ExtendedRequest } from "@/utils/models/Request.js"; -import { - generateEventCountExpressions, - getBillingCycleStartDate, -} from "./analyticsUtils.js"; +import { getBillingCycleStartDate } from "./analyticsUtils.js"; export class AnalyticsService { static clickhouseAvailable = @@ -69,7 +66,9 @@ export class AnalyticsService { const resultJson = await result.json(); return { - eventNames: resultJson.data.map((row: any) => row.event_name), + eventNames: (resultJson.data as { event_name: string }[]).map( + (row) => row.event_name, + ), result: resultJson, }; } @@ -220,13 +219,138 @@ WHERE org_id = {org_id:String} )) as { startDate: string; endDate: string; gap: number } | null) : null; - const countExpressions = generateEventCountExpressions( - params.event_names, - params.no_count, - ); + // Generate expressions for queries (works for both MV and original) + const generateExpressions = ( + eventNames: string[], + noCount: boolean, + useMV: boolean, + ) => { + const alias = useMV ? "ce" : "e"; + return eventNames + .map((eventName) => { + const escapedEventName = eventName.replace(/'/g, "''"); + const columnName = noCount ? eventName : `${eventName}_count`; + return `coalesce(sumIf(${alias}.value, ${alias}.event_name = '${escapedEventName}'), 0) as \`${columnName}\``; + }) + .join(",\n "); + }; if (AnalyticsService.clickhouseAvailable) { - const query = ` + const queryParams = { + org_id: org?.id, + env: env, + customer_id: params.customer_id, + days: + intervalType === "24h" + ? 1 + : intervalType === "7d" + ? 7 + : intervalType === "30d" + ? 30 + : intervalType === "90d" + ? 90 + : intervalType === "1bc" + ? (getBCResults?.gap ?? 0) + 1 + : intervalType === "3bc" + ? (getBCResults?.gap ?? 0) + : 0, + bin_size: intervalType === "24h" ? "hour" : "day", + end_date: isBillingCycle ? getBCResults?.endDate : undefined, + }; + + // Try materialized view first, fallback to original query if it fails + try { + const mvCountExpressions = generateExpressions( + params.event_names, + params.no_count ?? false, + true, + ); + + const mvQuery = ` +with customer_events as ( + select + period_hour, + event_name, + sum(value) as value + from events_usage_mv + where org_id = {org_id:String} + and env = {env:String} + ${aggregateAll ? "" : "and customer_id = {customer_id:String}"} + and period_hour >= date_trunc({bin_size:String}, now() - INTERVAL {days:UInt32} day) + group by period_hour, event_name +) +select + dr.period, + ${mvCountExpressions} +from date_range_view(bin_size={bin_size:String}, days={days:UInt32}) dr + left join customer_events ce + on ${intervalType === "24h" ? "ce.period_hour" : `date_trunc('day', ce.period_hour)`} = dr.period +group by dr.period +order by dr.period; +`; + + const mvQueryBillingCycle = ` +with customer_events as ( + select + period_hour, + event_name, + sum(value) as value + from events_usage_mv + where org_id = {org_id:String} + and env = {env:String} + ${aggregateAll ? "" : "and customer_id = {customer_id:String}"} + and period_hour >= date_trunc('day', toDateTime({end_date:String}) - INTERVAL {days:UInt32} day) + and period_hour < date_trunc('day', toDateTime({end_date:String})) + group by period_hour, event_name +) +select + dr.period, + ${mvCountExpressions} +from date_range_bc_view(bin_size={bin_size:String}, start_date={end_date:DateTime}, days={days:UInt32}) dr + left join customer_events ce + on ${intervalType === "24h" ? "ce.period_hour" : `date_trunc('day', ce.period_hour)`} = dr.period +group by dr.period +order by dr.period; + `; + + const mvQueryToUse = + isBillingCycle && !aggregateAll && getBCResults?.startDate + ? mvQueryBillingCycle + : mvQuery; + + const result = await (clickhouseClient as ClickHouseClient).query({ + query: mvQueryToUse, + query_params: queryParams, + format: "JSON", + clickhouse_settings: { + output_format_json_quote_decimals: 0, + output_format_json_quote_64bit_integers: 1, + output_format_json_quote_64bit_floats: 1, + }, + }); + + const resultJson = await result.json(); + + (resultJson.data as Record[]).forEach((row) => { + Object.keys(row).forEach((key: string) => { + if (key !== "period") { + row[key] = new Decimal(row[key] as number) + .toDecimalPlaces(10) + .toNumber(); + } + }); + }); + + return resultJson; + } catch { + // Fallback to original query if materialized view doesn't exist or fails + const fallbackCountExpressions = generateExpressions( + params.event_names, + params.no_count ?? false, + false, + ); + + const fallbackQuery = ` with customer_events as ( select * from org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) @@ -234,7 +358,7 @@ with customer_events as ( ) select dr.period, - ${countExpressions} + ${fallbackCountExpressions} from date_range_view(bin_size={bin_size:String}, days={days:UInt32}) dr left join customer_events e on date_trunc({bin_size:String}, e.timestamp) = dr.period @@ -242,7 +366,7 @@ group by dr.period order by dr.period; `; - const queryBillingCycle = ` + const fallbackQueryBillingCycle = ` with customer_events as ( select * from org_events_view(org_id={org_id:String}, org_slug='', env={env:String}) @@ -250,7 +374,7 @@ with customer_events as ( ) select dr.period, - ${countExpressions} + ${fallbackCountExpressions} from date_range_bc_view(bin_size={bin_size:String}, start_date={end_date:DateTime}, days={days:UInt32}) dr left join customer_events e on date_trunc({bin_size:String}, e.timestamp) = dr.period @@ -258,56 +382,36 @@ group by dr.period order by dr.period; `; - const queryParams = { - org_id: org?.id, - env: env, - customer_id: params.customer_id, - days: - intervalType === "24h" - ? 1 - : intervalType === "7d" - ? 7 - : intervalType === "30d" - ? 30 - : intervalType === "90d" - ? 90 - : intervalType === "1bc" - ? (getBCResults?.gap ?? 0) + 1 - : intervalType === "3bc" - ? (getBCResults?.gap ?? 0) - : 0, - bin_size: intervalType === "24h" ? "hour" : "day", - end_date: isBillingCycle ? getBCResults?.endDate : undefined, - }; - - // Use regular query for aggregateAll or when no billing cycle data is available - const queryToUse = - isBillingCycle && !aggregateAll && getBCResults?.startDate - ? queryBillingCycle - : query; - - const result = await (clickhouseClient as ClickHouseClient).query({ - query: queryToUse, - query_params: queryParams, - format: "JSON", - clickhouse_settings: { - output_format_json_quote_decimals: 0, - output_format_json_quote_64bit_integers: 1, - output_format_json_quote_64bit_floats: 1, - }, - }); + const fallbackQueryToUse = + isBillingCycle && !aggregateAll && getBCResults?.startDate + ? fallbackQueryBillingCycle + : fallbackQuery; + + const result = await (clickhouseClient as ClickHouseClient).query({ + query: fallbackQueryToUse, + query_params: queryParams, + format: "JSON", + clickhouse_settings: { + output_format_json_quote_decimals: 0, + output_format_json_quote_64bit_integers: 1, + output_format_json_quote_64bit_floats: 1, + }, + }); - const resultJson = await result.json(); + const resultJson = await result.json(); - resultJson.data.forEach((row: any) => { - Object.keys(row).forEach((key: string) => { - if (key !== "period") { - row[key] = new Decimal(row[key]).toDecimalPlaces(10).toNumber(); - } + (resultJson.data as Record[]).forEach((row) => { + Object.keys(row).forEach((key: string) => { + if (key !== "period") { + row[key] = new Decimal(row[key] as number) + .toDecimalPlaces(10) + .toNumber(); + } + }); }); - }); - return resultJson; + return resultJson; + } } } @@ -318,7 +422,7 @@ order by dr.period; aggregateAll = false, }: { req: ExtendedRequest; - params: any; + params: { customer_id?: string; interval?: string }; customer?: FullCustomer; aggregateAll?: boolean; }) { diff --git a/vite/src/views/customers/customer/analytics/AnalyticsContext.tsx b/vite/src/views/customers/customer/analytics/AnalyticsContext.tsx index 255f40f7a..abc533456 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsContext.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsContext.tsx @@ -7,7 +7,7 @@ export const useAnalyticsContext = () => { if (context === undefined) { throw new Error( - "useCustomersContext must be used within a CustomersContextProvider", + "useAnalyticsContext must be used within an AnalyticsContextProvider", ); } diff --git a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx index 83b2f9139..1e5a82f4d 100644 --- a/vite/src/views/customers/customer/analytics/AnalyticsView.tsx +++ b/vite/src/views/customers/customer/analytics/AnalyticsView.tsx @@ -1,15 +1,12 @@ import { ErrCode } from "@autumn/shared"; -import { - ArrowSquareOutIcon, - ChartBarIcon, - DatabaseIcon, -} from "@phosphor-icons/react"; +import { ArrowSquareOutIcon, ChartBarIcon, DatabaseIcon, WarningIcon } from "@phosphor-icons/react"; import type { AgGridReact } from "ag-grid-react"; import { useEffect, useMemo, useRef, useState } from "react"; import { useNavigate, useSearchParams } from "react-router"; import { Card, CardContent } from "@/components/ui/card"; import { IconButton } from "@/components/v2/buttons/IconButton"; import { EmptyState } from "@/components/v2/empty-states/EmptyState"; +import { getBackendErr } from "@/utils/genUtils"; import { AnalyticsContext } from "./AnalyticsContext"; import { EventsAGGrid, EventsBarChart } from "./AnalyticsGraph"; import { colors } from "./components/AGGrid"; @@ -51,6 +48,7 @@ export const AnalyticsView = () => { topEventsLoading, topEvents, groupBy, + mutate: mutateAnalytics, } = useAnalyticsData({ hasCleared }); // Clear the filter when groupBy changes @@ -77,7 +75,11 @@ export const AnalyticsView = () => { return Array.from(uniqueValues).sort(); }, [groupBy, events?.data]); - const { rawEvents, queryLoading: rawQueryLoading } = useRawAnalyticsData(); + const { + rawEvents, + queryLoading: rawQueryLoading, + mutate: mutateRawAnalytics, + } = useRawAnalyticsData(); // Extract property keys from raw events for the group by dropdown const propertyKeys = useMemo(() => { @@ -86,7 +88,7 @@ export const AnalyticsView = () => { // Transform and configure chart data const { chartData, chartConfig } = useMemo(() => { - if (!events) { + if (!events || !events.data || events.data.length === 0) { return { chartData: null, chartConfig: null }; } @@ -119,6 +121,15 @@ export const AnalyticsView = () => { originalColors: colors, }); + console.log("Chart debug:", { + hasGroupBy: !!groupBy, + eventsCount: events.data.length, + transformedCount: transformed.data.length, + configCount: config.length, + sampleTransformed: transformed.data[0], + meta: transformed.meta, + }); + return { chartData: transformed, chartConfig: config }; }, [events, features, groupBy, groupFilter]); @@ -136,10 +147,18 @@ export const AnalyticsView = () => { ); } + // Extract error message if there's a validation error + const errorMessage = error + ? getBackendErr(error, "An error occurred while loading analytics") + : null; + const isValidationError = + error?.response?.data?.code === ErrCode.InvalidInputs; + // Show empty state if no actual analytics events (check rawEvents and totalRows) const hasNoData = !rawQueryLoading && !topEventsLoading && + !error && (!rawEvents || !rawEvents.data || rawEvents.data.length === 0) && totalRows === 0; @@ -199,6 +218,9 @@ export const AnalyticsView = () => { groupFilter, setGroupFilter, availableGroupValues, + refreshAnalytics: async () => { + await Promise.all([mutateAnalytics(), mutateRawAnalytics()]); + }, }} >
@@ -216,28 +238,47 @@ export const AnalyticsView = () => { Loading chart {customerId ? `for ${customerId}` : ""}

- )} + )}
- {chartData && chartData.data.length > 0 && ( -
- [0]["data"] - } - chartConfig={chartConfig} - /> -
- )} + {chartData && + chartData.data.length > 0 && + chartConfig && + chartConfig.length > 0 && ( +
+ [0]["data"] + } + chartConfig={chartConfig} + /> +
+ )} {!chartData && !queryLoading && (
-

- No events found. Please widen your filters.{" "} - {eventNames.length === 0 - ? "Try to select some events in the dropdown above." - : ""} -

+ {isValidationError && errorMessage ? ( +
+
+ +

+ Grouping Error +

+
+

{errorMessage}

+

+ Please select a different property to group by, or remove + the grouping to view all events. +

+
+ ) : ( +

+ No events found. Please widen your filters.{" "} + {eventNames.length === 0 + ? "Try to select some events in the dropdown above." + : ""} +

+ )}
)}
diff --git a/vite/src/views/customers/customer/analytics/ViewUserEvents.tsx b/vite/src/views/customers/customer/analytics/ViewUserEvents.tsx index 3b3b3e7e2..066903b8f 100644 --- a/vite/src/views/customers/customer/analytics/ViewUserEvents.tsx +++ b/vite/src/views/customers/customer/analytics/ViewUserEvents.tsx @@ -1,13 +1,13 @@ +import { useParams } from "react-router"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, + DialogDescription, DialogHeader, DialogTitle, - DialogDescription, DialogTrigger, } from "@/components/ui/dialog"; -import { useParams } from "react-router"; export const ViewUserEvents = ({ customer }: { customer: any }) => { const params = useParams(); diff --git a/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx b/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx index 419ee8363..f8ba23cf9 100644 --- a/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx +++ b/vite/src/views/customers/customer/analytics/components/QueryTopbar.tsx @@ -1,6 +1,8 @@ import { CaretDownIcon } from "@phosphor-icons/react"; -import { Check } from "lucide-react"; +import { Check, RefreshCw } from "lucide-react"; +import { useState } from "react"; import { useLocation, useNavigate } from "react-router"; +import { Button } from "@/components/ui/button"; import { DropdownMenu, DropdownMenuContent, @@ -30,9 +32,11 @@ export const QueryTopbar = () => { setSelectedInterval, bcExclusionFlag, propertyKeys, + refreshAnalytics, } = useAnalyticsContext(); const navigate = useNavigate(); const location = useLocation(); + const [isRefreshing, setIsRefreshing] = useState(false); const updateQueryParams = (key: string, value: string) => { const params = new URLSearchParams(location.search); @@ -40,6 +44,16 @@ export const QueryTopbar = () => { navigate(`${location.pathname}?${params.toString()}`); }; + const handleRefresh = async () => { + if (!refreshAnalytics || isRefreshing) return; + setIsRefreshing(true); + try { + await refreshAnalytics(); + } finally { + setIsRefreshing(false); + } + }; + return (
{ )} +
); }; diff --git a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx index 9ffbd7fde..391fbd1d7 100644 --- a/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx +++ b/vite/src/views/customers/customer/analytics/components/SelectGroupByDropdown.tsx @@ -151,4 +151,3 @@ export const SelectGroupByDropdown = ({ ); }; - diff --git a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx index b1b495793..21d4a89ee 100644 --- a/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx +++ b/vite/src/views/customers/customer/analytics/hooks/useAnalyticsData.tsx @@ -1,19 +1,14 @@ import { ErrCode } from "@autumn/shared"; -import { useNavigate, useSearchParams } from "react-router"; +import { useSearchParams } from "react-router"; import { useOrg } from "@/hooks/common/useOrg"; import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; import { useAxiosSWR, usePostSWR } from "@/services/useAxiosSwr"; import { useEnv } from "@/utils/envUtils"; import { useTopEventNames } from "./useTopEventNames"; -export const useAnalyticsData = ({ - hasCleared = false, -}: { - hasCleared?: boolean; -}) => { +export const useAnalyticsData = (_options?: { hasCleared?: boolean }) => { const { org } = useOrg(); - const navigate = useNavigate(); const [searchParams] = useSearchParams(); const customerId = searchParams.get("customer_id"); const featureIds = searchParams.get("feature_ids")?.split(","); @@ -21,7 +16,7 @@ export const useAnalyticsData = ({ const interval = searchParams.get("interval"); const groupBy = searchParams.get("group_by"); - const { topEvents, isLoading: topEventsLoading } = useTopEventNames(); + const { isLoading: topEventsLoading } = useTopEventNames(); // const { data: featuresData, isLoading: featuresLoading } = useAxiosSWR({ // url: `/features`, @@ -49,6 +44,7 @@ export const useAnalyticsData = ({ data, isLoading: queryLoading, error, + mutate, } = usePostSWR({ url: `/query/events`, data: { @@ -75,10 +71,11 @@ export const useAnalyticsData = ({ queryLoading, events: data?.events, topEvents: data?.topEvents, - error: error?.code === ErrCode.ClickHouseDisabled ? null : error, + error: error?.code === ErrCode.ClickHouseDisabled ? null : error || null, bcExclusionFlag: data?.bcExclusionFlag ?? false, topEventsLoading, groupBy, + mutate, }; }; @@ -111,6 +108,7 @@ export const useRawAnalyticsData = () => { data, isLoading: queryLoading, error, + mutate, } = usePostSWR({ url: `/query/raw`, data: { @@ -136,6 +134,7 @@ export const useRawAnalyticsData = () => { queryLoading, rawEvents: data?.rawEvents, error: error?.code === ErrCode.ClickHouseDisabled ? null : error, + mutate, }; }; diff --git a/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx b/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx index 8cfee3e88..775196f32 100644 --- a/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx +++ b/vite/src/views/customers/customer/analytics/hooks/useTopEventNames.tsx @@ -1,5 +1,5 @@ -import { usePostSWR } from "@/services/useAxiosSwr.js"; import { ErrCode } from "@autumn/shared"; +import { usePostSWR } from "@/services/useAxiosSwr.js"; export const useTopEventNames = () => { const { diff --git a/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts b/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts index aa3d9a9d6..5fed13292 100644 --- a/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts +++ b/vite/src/views/customers/customer/analytics/utils/extractPropertyKeys.ts @@ -66,4 +66,3 @@ function parseProperties( return properties; } - diff --git a/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts b/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts index 3e729b9ab..64f0365a8 100644 --- a/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts +++ b/vite/src/views/customers/customer/analytics/utils/transformGroupedChartData.ts @@ -10,7 +10,7 @@ type EventRow = Record; * Events data structure from the API */ interface EventsData { - meta: Array<{ name: string }>; + meta?: Array<{ name: string }>; rows: number; data: EventRow[]; } @@ -92,16 +92,26 @@ export function transformGroupedData({ return events; } + if (!events.data || events.data.length === 0) { + return events; + } + const groupByColumn = `properties.${groupBy}`; + // Build meta from first row if not present + const meta = + events.meta && events.meta.length > 0 + ? events.meta + : Object.keys(events.data[0]).map((key) => ({ name: key })); + // Check if data has the group_by column - const hasGroupColumn = events.meta.some((m) => m.name === groupByColumn); + const hasGroupColumn = meta.some((m) => m.name === groupByColumn); if (!hasGroupColumn) { - return events; + return { ...events, meta }; } // Get feature columns (exclude period and group_by column) - const featureColumns = events.meta + const featureColumns = meta .filter((m) => m.name !== "period" && m.name !== groupByColumn) .map((m) => m.name); @@ -180,9 +190,17 @@ export function generateChartConfig({ }): ChartSeriesConfig[] { const colorsToUse = groupBy ? CHART_COLORS : originalColors; + // Build meta from first row if not present + const meta = + events.meta && events.meta.length > 0 + ? events.meta + : events.data && events.data.length > 0 + ? Object.keys(events.data[0]).map((key) => ({ name: key })) + : []; + if (!groupBy) { // Non-grouped: original behavior - return events.meta + return meta .filter((m) => m.name !== "period") .map((m, index) => ({ xKey: "period", @@ -198,11 +216,11 @@ export function generateChartConfig({ const config: ChartSeriesConfig[] = []; let colorIndex = 0; - for (const meta of events.meta) { - if (meta.name === "period") continue; + for (const m of meta) { + if (m.name === "period") continue; // Parse feature__groupValue format - const parts = meta.name.split("__"); + const parts = m.name.split("__"); if (parts.length < 2) continue; const featureKey = parts.slice(0, -1).join("__"); // Handle feature names with underscores @@ -212,7 +230,7 @@ export function generateChartConfig({ config.push({ xKey: "period", - yKey: meta.name, + yKey: m.name, type: "bar", stacked: true, yName: `${featureName} (${groupValue})`,