Merge pull request #1806 from useautumn/charlie/analytics-chart-improvements
chore: add events table filtering
This commit is contained in:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -36,7 +36,7 @@ export const ChartSkeleton = ({
|
||||
const bars = useMemo(() => buildSkeletonBars(barCount), [barCount]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="relative flex flex-1 flex-col">
|
||||
<div className="flex h-7 shrink-0 items-center gap-4 border-b bg-card px-2">
|
||||
{[72, 56, 64].map((width, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
@@ -92,6 +92,8 @@ export const ChartSkeleton = ({
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/40 dark:bg-black/40 pointer-events-none absolute inset-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<IRow, unknown>[] = columns.map((col, i) => ({
|
||||
...col,
|
||||
cell: () => <Skeleton className={cn("h-3 rounded-sm", SKELETON_CELL_WIDTHS[i])} />,
|
||||
}));
|
||||
|
||||
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<IRow | null>(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({
|
||||
<Table.Provider
|
||||
config={{
|
||||
table,
|
||||
numberOfColumns: activeColumns.length,
|
||||
isLoading: false,
|
||||
numberOfColumns: columns.length,
|
||||
isLoading,
|
||||
enableSorting: !isLoading,
|
||||
onRowClick: handleRowClick,
|
||||
rowClassName: "h-8",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { ErrCode, FeatureType } from "@autumn/shared";
|
||||
import { ErrCode } from "@autumn/shared";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useQueryKeyFactory } from "@/hooks/common/useQueryKeyFactory";
|
||||
import { useFeaturesQuery } from "@/hooks/queries/useFeaturesQuery";
|
||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||
import { useAnalyticsQueryState } from "./useAnalyticsQueryState";
|
||||
import { useEventNames } from "./useEventNames";
|
||||
import { useSelectedEventNames } from "./useSelectedEventNames";
|
||||
|
||||
/** Gets the user's IANA timezone (e.g., "America/New_York") */
|
||||
const getUserTimezone = (): string => {
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user