diff --git a/server/src/internal/analytics/actions/listRawEvents.ts b/server/src/internal/analytics/actions/listRawEvents.ts index b13e68545..bdb832ba5 100644 --- a/server/src/internal/analytics/actions/listRawEvents.ts +++ b/server/src/internal/analytics/actions/listRawEvents.ts @@ -71,6 +71,7 @@ export type ListRawEventsParams = { customer?: FullCustomer; aggregateAll?: boolean; event_name?: string; + event_names?: string[]; limit?: number; }; @@ -114,6 +115,16 @@ export const listRawEvents = async ({ ? billingCycleResult.endDate : formatJsDateToClickHouseDateTime(new Date()); + const eventNameFilter = (() => { + if (params.event_names && params.event_names.length > 0) { + return params.event_names; + } + if (params.event_name) { + return [params.event_name]; + } + return undefined; + })(); + const pipeParams = { org_id: org.id, env, @@ -121,7 +132,7 @@ export const listRawEvents = async ({ end_date: finalEndDate, customer_id: params.aggregateAll ? undefined : params.customer_id, entity_id: params.entity_id, - event_names: params.event_name ? [params.event_name] : undefined, + event_names: eventNameFilter, limit: params.limit ?? DEFAULT_LIMIT, offset: 0, }; diff --git a/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts index 26cc625ae..e69883218 100644 --- a/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts +++ b/server/src/internal/analytics/internalHandlers/handleInternalListRawEvents.ts @@ -13,6 +13,7 @@ const InternalListRawEventsSchema = z.object({ message: "custom_range.start must be before custom_range.end", }) .optional(), + event_names: z.array(z.string()).optional(), customer_id: z.string().nullish(), entity_id: z.string().optional(), }); @@ -26,7 +27,7 @@ export const handleInternalListRawEvents = createRoute({ handler: async (c) => { const ctx = c.get("ctx"); const { db, org, env } = ctx; - const { interval, custom_range, customer_id, entity_id } = + const { interval, custom_range, event_names, customer_id, entity_id } = c.req.valid("json"); let aggregateAll = false; @@ -60,6 +61,7 @@ export const handleInternalListRawEvents = createRoute({ entity_id: entity_id, interval: interval ?? undefined, custom_range: custom_range ?? undefined, + event_names: event_names?.filter((name) => name !== ""), customer, aggregateAll, }, diff --git a/vite/src/views/customers/customer/analytics/components/ChartSkeleton.tsx b/vite/src/views/customers/customer/analytics/components/ChartSkeleton.tsx index f5e104033..4f6ac1363 100644 --- a/vite/src/views/customers/customer/analytics/components/ChartSkeleton.tsx +++ b/vite/src/views/customers/customer/analytics/components/ChartSkeleton.tsx @@ -36,7 +36,7 @@ export const ChartSkeleton = ({ const bars = useMemo(() => buildSkeletonBars(barCount), [barCount]); return ( -
+
{[72, 56, 64].map((width, i) => (
@@ -92,6 +92,8 @@ export const ChartSkeleton = ({ ))}
+ +
); }; diff --git a/vite/src/views/customers/customer/analytics/components/EventsTable.tsx b/vite/src/views/customers/customer/analytics/components/EventsTable.tsx index f92101150..40b31abab 100644 --- a/vite/src/views/customers/customer/analytics/components/EventsTable.tsx +++ b/vite/src/views/customers/customer/analytics/components/EventsTable.tsx @@ -2,7 +2,6 @@ import { CaretLeftIcon, CaretRightIcon, DatabaseIcon } from "@phosphor-icons/rea import { memo, useState } from "react"; import { Table } from "@/components/general/table"; import { IconButton } from "@/components/v2/buttons/IconButton"; -import { Skeleton } from "@/components/ui/skeleton"; import { Select, SelectContent, @@ -11,32 +10,15 @@ import { SelectValue, } from "@/components/v2/selects/Select"; import { cn } from "@/lib/utils"; -import type { ColumnDef } from "@tanstack/react-table"; import type { IRow } from "./analytics-types"; import { createEventsColumns } from "./EventsColumns"; import { RowClickDialog } from "./RowClickDialog"; import { useEventsTable } from "../hooks/useEventsTable"; const PAGE_SIZE_OPTIONS = [100, 500, 1000] as const; + const columns = createEventsColumns(); -const SKELETON_CELL_WIDTHS = ["w-28", "w-20", "w-8", "w-40"] as const; - -const skeletonColumns: ColumnDef[] = columns.map((col, i) => ({ - ...col, - cell: () => , -})); - -const PLACEHOLDER_ROWS: IRow[] = Array.from({ length: 15 }, (_, i) => ({ - timestamp: "", - event_name: "", - value: 0, - properties: "", - idempotency_key: String(i), - entity_id: "", - customer_id: "", -})); - export const EventsTable = memo(function EventsTable({ data, isLoading = false, @@ -51,9 +33,7 @@ export const EventsTable = memo(function EventsTable({ const [selectedEvent, setSelectedEvent] = useState(null); const [isDialogOpen, setIsDialogOpen] = useState(false); - const tableData = isLoading ? PLACEHOLDER_ROWS : data; - const activeColumns = isLoading ? skeletonColumns : columns; - const table = useEventsTable({ data: tableData, columns: activeColumns }); + const table = useEventsTable({ data, columns }); const { pageIndex, pageSize } = table.getState().pagination; const totalPages = table.getPageCount(); @@ -130,8 +110,8 @@ export const EventsTable = memo(function EventsTable({ { @@ -28,8 +27,6 @@ export const useAnalyticsData = ({ const [searchParams] = useSearchParams(); const customerId = searchParams.get("customer_id"); const entityId = searchParams.get("entity_id"); - const featureIds = searchParams.get("feature_ids")?.split(","); - const eventNames = searchParams.get("event_names")?.split(","); const groupBy = searchParams.get("group_by"); const maxGroups = Number(searchParams.get("max_groups")) || 10; @@ -38,36 +35,17 @@ export const useAnalyticsData = ({ const customRange = interval === "custom" && start && end ? { start, end } : undefined; - const { eventNames: cachedEventNames, isLoading: eventNamesLoading } = - useEventNames(); + const { selectedEventNames, featuresData, featuresLoading, eventNamesLoading } = + useSelectedEventNames(); const timezone = useMemo(() => getUserTimezone(), []); - const { features: featuresData, isLoading: featuresLoading } = - useFeaturesQuery(); - const formattedGroupBy = groupBy ? groupBy === "customer_id" || groupBy === "entity_id" || groupBy === "plan_id" ? groupBy : `properties.${groupBy}` : undefined; - const featureLinkedEventNames = useMemo(() => { - if (!featuresData?.length) return cachedEventNames; - return cachedEventNames.filter((e) => - featuresData.some( - (f) => - (f.type === FeatureType.Metered || f.type === FeatureType.CreditSystem) && - (f.event_names?.includes(e.event_name) || f.id === e.event_name), - ), - ); - }, [cachedEventNames, featuresData]); - - const selectedEventNames = - eventNames || featureIds - ? [...(eventNames || []), ...(featureIds || [])] - : featureLinkedEventNames.slice(0, 3).map((e) => e.event_name); - const postBody = { customer_id: customerId || undefined, entity_id: entityId || undefined, @@ -142,21 +120,21 @@ export const useRawAnalyticsData = () => { const customRange = interval === "custom" && start && end ? { start, end } : undefined; - const { features: featuresData, isLoading: featuresLoading } = - useFeaturesQuery(); + const { selectedEventNames, featuresData, featuresLoading, eventNamesLoading } = + useSelectedEventNames(); + + const isReady = !eventNamesLoading && !featuresLoading; const postBody = { customer_id: customerId || undefined, entity_id: entityId || undefined, interval: customRange ? undefined : interval, custom_range: customRange, + event_names: selectedEventNames, }; - const { - data, - isLoading: queryLoading, - error, - } = useQuery({ + const { data, isLoading, error } = useQuery({ + enabled: isReady, queryKey: buildKey([ "query-raw-events", customerId, @@ -164,6 +142,7 @@ export const useRawAnalyticsData = () => { interval, String(start ?? ""), String(end ?? ""), + ...selectedEventNames.sort(), ]), queryFn: async () => { const { data } = await axiosInstance.post("/query/raw", postBody); @@ -171,6 +150,8 @@ export const useRawAnalyticsData = () => { }, }); + const queryLoading = !isReady || isLoading; + return { customer: data?.customer, features: featuresData || [], diff --git a/vite/src/views/customers/customer/analytics/hooks/useSelectedEventNames.tsx b/vite/src/views/customers/customer/analytics/hooks/useSelectedEventNames.tsx new file mode 100644 index 000000000..bf64e4a09 --- /dev/null +++ b/vite/src/views/customers/customer/analytics/hooks/useSelectedEventNames.tsx @@ -0,0 +1,48 @@ +import { FeatureType } from "@autumn/shared"; +import { parseAsArrayOf, parseAsString, useQueryStates } from "nuqs"; +import { useMemo } from "react"; +import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery"; +import { type EventNameWithCount, useEventNames } from "./useEventNames"; + +/** Resolves the event names the analytics views filter by: explicit URL + * selection (event_names / feature_ids) or the top metered events by default. + * Shared by the chart and the events table so they stay in sync. */ +export const useSelectedEventNames = () => { + const [{ feature_ids: featureIds, event_names: eventNames }] = useQueryStates({ + feature_ids: parseAsArrayOf(parseAsString), + event_names: parseAsArrayOf(parseAsString), + }); + + const { eventNames: cachedEventNames, isLoading: eventNamesLoading } = + useEventNames(); + const { features: featuresData, isLoading: featuresLoading } = + useFeaturesQuery(); + + const featureLinkedEventNames = useMemo(() => { + if (!featuresData?.length) { + return cachedEventNames; + } + return cachedEventNames.filter((e: EventNameWithCount) => + featuresData.some( + (f) => + (f.type === FeatureType.Metered || + f.type === FeatureType.CreditSystem) && + (f.event_names?.includes(e.event_name) || f.id === e.event_name), + ), + ); + }, [cachedEventNames, featuresData]); + + const selectedEventNames = + eventNames || featureIds + ? [...(eventNames || []), ...(featureIds || [])] + : featureLinkedEventNames + .slice(0, 3) + .map((e: EventNameWithCount) => e.event_name); + + return { + selectedEventNames, + featuresData, + featuresLoading, + eventNamesLoading, + }; +};