diff --git a/apps/admin/src/app/(dashboard)/components/HogQLLineTile/HogQLLineTile.tsx b/apps/admin/src/app/(dashboard)/components/HogQLLineTile/HogQLLineTile.tsx index 08c99005ab2..bc93c696379 100644 --- a/apps/admin/src/app/(dashboard)/components/HogQLLineTile/HogQLLineTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/HogQLLineTile/HogQLLineTile.tsx @@ -11,15 +11,12 @@ import { ChartTooltip, ChartTooltipContent, } from "@superset/ui/chart"; -import { useQuery } from "@tanstack/react-query"; import { Bar, ComposedChart, Line, XAxis, YAxis } from "recharts"; -import { useTRPC } from "@/trpc/react"; +import { useInsightResults } from "../../hooks/useInsightResults"; import { makeDateAxis } from "../../utils/chartAxis"; import { InsightTileFrame } from "../InsightTileFrame"; -const STALE_TIME_MS = 10 * 60 * 1000; - // Renders a saved HogQL (DataVisualizationNode) insight whose result is an // array of rows. Column order is part of the canonical definition in PostHog, // so series are addressed by column index. @@ -47,13 +44,7 @@ export function HogQLLineTile({ xColumn, series, }: HogQLLineTileProps) { - const trpc = useTRPC(); - const query = useQuery( - trpc.analytics.getInsightResults.queryOptions( - { insight }, - { staleTime: STALE_TIME_MS }, - ), - ); + const query = useInsightResults(insight); const rows = Array.isArray(query.data?.result) ? (query.data.result as unknown[][]).filter(Array.isArray) @@ -82,7 +73,7 @@ export function HogQLLineTile({ title={query.data?.name ?? insight} description={description} lastRefresh={query.data?.lastRefresh} - isLoading={query.isLoading} + isLoading={query.isLoading || query.data?.result == null} error={query.error} href={`${POSTHOG_PROJECT_URL}/insights/${ADMIN_INSIGHTS[insight]}`} onRefresh={() => query.refetch()} diff --git a/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx b/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx index e9e78dbde1a..d8a8e869cc0 100644 --- a/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/PostHogFunnelTile/PostHogFunnelTile.tsx @@ -6,15 +6,11 @@ import { } from "@superset/trpc/insight-registry"; import { Button } from "@superset/ui/button"; import { cn } from "@superset/ui/utils"; -import { useQuery } from "@tanstack/react-query"; import { LuExternalLink, LuRefreshCw } from "react-icons/lu"; -import { useTRPC } from "@/trpc/react"; - +import { useInsightResults } from "../../hooks/useInsightResults"; import { FunnelChart } from "../FunnelChart"; -const STALE_TIME_MS = 10 * 60 * 1000; - interface PostHogFunnelStep { name: string; custom_name?: string | null; @@ -31,13 +27,7 @@ const STEP_NAME_OVERRIDES: Record = { }; export function PostHogFunnelTile() { - const trpc = useTRPC(); - const insight = useQuery( - trpc.analytics.getInsightResults.queryOptions( - { insight: "activationFunnel" }, - { staleTime: STALE_TIME_MS }, - ), - ); + const insight = useInsightResults("activationFunnel"); const steps = Array.isArray(insight.data?.result) ? (insight.data.result as PostHogFunnelStep[]) @@ -54,7 +44,7 @@ export function PostHogFunnelTile() { title={insight.data?.name ?? "New-user activation"} description="First sign-in view → auth → onboarding → real workspace (last 7d, 2d window)" steps={data} - isLoading={insight.isLoading} + isLoading={insight.isLoading || insight.data?.result == null} error={insight.error} headerAction={
diff --git a/apps/admin/src/app/(dashboard)/components/RetentionGridTile/RetentionGridTile.tsx b/apps/admin/src/app/(dashboard)/components/RetentionGridTile/RetentionGridTile.tsx index 9d8a46af1e5..03d930e0fac 100644 --- a/apps/admin/src/app/(dashboard)/components/RetentionGridTile/RetentionGridTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/RetentionGridTile/RetentionGridTile.tsx @@ -4,10 +4,7 @@ import { ADMIN_INSIGHTS, POSTHOG_PROJECT_URL, } from "@superset/trpc/insight-registry"; -import { useQuery } from "@tanstack/react-query"; - -import { useTRPC } from "@/trpc/react"; - +import { useInsightResults } from "../../hooks/useInsightResults"; import { formatDay } from "../../utils/chartAxis"; import { type CohortCellState, @@ -16,7 +13,6 @@ import { } from "../CohortGrid"; import { InsightTileFrame } from "../InsightTileFrame"; -const STALE_TIME_MS = 10 * 60 * 1000; const WEEK_MS = 7 * 24 * 60 * 60 * 1000; interface RetentionCohortResult { @@ -37,13 +33,7 @@ function cellState( } export function RetentionGridTile() { - const trpc = useTRPC(); - const query = useQuery( - trpc.analytics.getInsightResults.queryOptions( - { insight: "cohortRetention" }, - { staleTime: STALE_TIME_MS }, - ), - ); + const query = useInsightResults("cohortRetention"); const cohorts = Array.isArray(query.data?.result) ? (query.data.result as RetentionCohortResult[]).filter((c) => @@ -107,7 +97,7 @@ export function RetentionGridTile() { title={query.data?.name ?? "Cohort retention"} description="Weekly cohorts by first real workspace; % returning with another workspace each week" lastRefresh={query.data?.lastRefresh} - isLoading={query.isLoading} + isLoading={query.isLoading || query.data?.result == null} error={query.error} empty={cohorts.length === 0} href={`${POSTHOG_PROJECT_URL}/insights/${ADMIN_INSIGHTS.cohortRetention}`} diff --git a/apps/admin/src/app/(dashboard)/components/TrendSeriesTile/TrendSeriesTile.tsx b/apps/admin/src/app/(dashboard)/components/TrendSeriesTile/TrendSeriesTile.tsx index 72da8bb1dba..b451fed2174 100644 --- a/apps/admin/src/app/(dashboard)/components/TrendSeriesTile/TrendSeriesTile.tsx +++ b/apps/admin/src/app/(dashboard)/components/TrendSeriesTile/TrendSeriesTile.tsx @@ -11,16 +11,12 @@ import { ChartTooltip, ChartTooltipContent, } from "@superset/ui/chart"; -import { useQuery } from "@tanstack/react-query"; import { Line, LineChart, XAxis, YAxis } from "recharts"; -import { useTRPC } from "@/trpc/react"; - +import { useInsightResults } from "../../hooks/useInsightResults"; import { makeDateAxis } from "../../utils/chartAxis"; import { InsightTileFrame } from "../InsightTileFrame"; -const STALE_TIME_MS = 10 * 60 * 1000; - // Renders TrendsQuery / funnel-trends insight results: an array of series, // each carrying parallel `data` values and `labels`/`days` for the x axis. interface TrendSeries { @@ -48,13 +44,7 @@ export function TrendSeriesTile({ valueSuffix, dashIncompleteLast, }: TrendSeriesTileProps) { - const trpc = useTRPC(); - const query = useQuery( - trpc.analytics.getInsightResults.queryOptions( - { insight }, - { staleTime: STALE_TIME_MS }, - ), - ); + const query = useInsightResults(insight); const series = Array.isArray(query.data?.result) ? (query.data.result as TrendSeries[]).filter((s) => Array.isArray(s.data)) @@ -95,7 +85,7 @@ export function TrendSeriesTile({ title={query.data?.name ?? insight} description={description} lastRefresh={query.data?.lastRefresh} - isLoading={query.isLoading} + isLoading={query.isLoading || query.data?.result == null} error={query.error} href={`${POSTHOG_PROJECT_URL}/insights/${ADMIN_INSIGHTS[insight]}`} onRefresh={() => query.refetch()} diff --git a/apps/admin/src/app/(dashboard)/hooks/useInsightResults/index.ts b/apps/admin/src/app/(dashboard)/hooks/useInsightResults/index.ts new file mode 100644 index 00000000000..b35f6c34d94 --- /dev/null +++ b/apps/admin/src/app/(dashboard)/hooks/useInsightResults/index.ts @@ -0,0 +1 @@ +export { useInsightResults } from "./useInsightResults"; diff --git a/apps/admin/src/app/(dashboard)/hooks/useInsightResults/useInsightResults.ts b/apps/admin/src/app/(dashboard)/hooks/useInsightResults/useInsightResults.ts new file mode 100644 index 00000000000..c51b6edcafa --- /dev/null +++ b/apps/admin/src/app/(dashboard)/hooks/useInsightResults/useInsightResults.ts @@ -0,0 +1,26 @@ +"use client"; + +import type { AdminInsightKey } from "@superset/trpc/insight-registry"; +import { useQuery } from "@tanstack/react-query"; + +import { useTRPC } from "@/trpc/react"; + +const STALE_TIME_MS = 10 * 60 * 1000; +const PENDING_POLL_MS = 3 * 1000; + +// PostHog returns its cached result right away and recomputes stale insights in +// the background, so a cold insight comes back `pending` with no result yet. +// Poll until it lands instead of holding the request open for the recompute. +export function useInsightResults(insight: AdminInsightKey) { + const trpc = useTRPC(); + return useQuery( + trpc.analytics.getInsightResults.queryOptions( + { insight }, + { + staleTime: STALE_TIME_MS, + refetchInterval: (q) => + q.state.data?.pending ? PENDING_POLL_MS : false, + }, + ), + ); +} diff --git a/packages/trpc/src/lib/posthog-client.ts b/packages/trpc/src/lib/posthog-client.ts index a03d1d0db8f..2045ef9677f 100644 --- a/packages/trpc/src/lib/posthog-client.ts +++ b/packages/trpc/src/lib/posthog-client.ts @@ -4,16 +4,21 @@ export interface InsightResults { name: string; shortId: string; lastRefresh: string | null; + // PostHog is recomputing and had nothing cached to serve; the caller polls + // until a result lands. + pending: boolean; result: unknown; } -// No app-side cache: refresh=blocking serves PostHog's own result cache when -// fresh and recomputes only past its refresh throttle (~15 min). +// No app-side cache: refresh=lazy_async serves PostHog's own result cache and +// recomputes in the background past its refresh throttle (~15 min). It must +// stay async — the heavy HogQL insights take 15-20s to recompute, and +// refresh=blocking held the request open for all of it. export async function fetchInsightResults( shortId: string, ): Promise { const response = await fetch( - `${env.POSTHOG_API_HOST}/api/projects/${env.POSTHOG_PROJECT_ID}/insights/?short_id=${shortId}&refresh=blocking`, + `${env.POSTHOG_API_HOST}/api/projects/${env.POSTHOG_PROJECT_ID}/insights/?short_id=${shortId}&refresh=lazy_async`, { headers: { Authorization: `Bearer ${env.POSTHOG_API_KEY}`, @@ -31,6 +36,11 @@ export async function fetchInsightResults( name: string | null; short_id: string; last_refresh: string | null; + query_status: { + complete?: boolean; + error?: boolean; + error_message?: string | null; + } | null; result: unknown; }>; }; @@ -40,10 +50,24 @@ export async function fetchInsightResults( throw new Error(`PostHog insight not found: ${shortId}`); } + const status = insight.query_status; + + // A failed query is terminal — retrying means starting a new one, so polling + // this one would spin forever behind a skeleton. Surface it instead. + if (status?.error) { + throw new Error( + `PostHog insight ${shortId}: ${status.error_message ?? "query failed"}`, + ); + } + return { name: insight.name ?? "", shortId: insight.short_id, lastRefresh: insight.last_refresh, + // Past cache_target_age PostHog hands back the stale result *and* a live + // query_status for the recompute it just started, so a present result is + // not on its own a sign that there is nothing left to wait for. + pending: insight.result == null || (status != null && !status.complete), result: insight.result, }; }