Merge pull request #1806 from useautumn/charlie/analytics-chart-improvements

chore: add events table filtering
This commit is contained in:
Charlie Lamb
2026-06-03 16:23:36 +01:00
committed by GitHub
6 changed files with 84 additions and 60 deletions

View File

@@ -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,
};

View File

@@ -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,
},

View File

@@ -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>
);
};

View File

@@ -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",

View File

@@ -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 || [],

View File

@@ -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,
};
};