From 3ad626d44d20c8e073626b8acc476070349376ae Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 07:27:47 +0000 Subject: [PATCH 1/3] fix(health): recover stuck loads and stop premature healthy state The Health page could strand users on skeletons forever when the issues request never settled, and told brand-new projects "All systems healthy" before any check had data to run against. - Bound the issues load with a 30s timeout so an unsettled request rejects and falls through to the existing retry banner. - Show an install-oriented empty state for a project that has never ingested an event, instead of claiming health. Generated-By: PostHog Desktop Task-Id: 890a1602-a256-4293-8cf8-a50a01b32c49 --- .../components/HealthEmptyState.test.tsx | 25 ++++++++++++ .../health/components/HealthEmptyState.tsx | 38 +++++++++++++++++++ .../health/components/HealthIssueList.tsx | 10 ++--- .../components/HealthTables.stories.tsx | 5 +++ .../src/scenes/health/healthSceneLogic.tsx | 23 +++++++++-- frontend/src/scenes/health/types.ts | 4 ++ 6 files changed, 94 insertions(+), 11 deletions(-) create mode 100644 frontend/src/scenes/health/components/HealthEmptyState.test.tsx create mode 100644 frontend/src/scenes/health/components/HealthEmptyState.tsx diff --git a/frontend/src/scenes/health/components/HealthEmptyState.test.tsx b/frontend/src/scenes/health/components/HealthEmptyState.test.tsx new file mode 100644 index 000000000000..edd98984ba97 --- /dev/null +++ b/frontend/src/scenes/health/components/HealthEmptyState.test.tsx @@ -0,0 +1,25 @@ +import '@testing-library/jest-dom' + +import { cleanup, render, screen } from '@testing-library/react' + +import { HealthEmptyState } from './HealthEmptyState' + +describe('HealthEmptyState', () => { + afterEach(cleanup) + + it('points a project with no events at install instead of declaring it healthy', () => { + render() + + expect(screen.getByText('Health checks have not run yet')).toBeInTheDocument() + // LemonBanner renders the action twice for its responsive layout, so there is at least one. + expect(screen.getAllByText('Install PostHog').length).toBeGreaterThan(0) + expect(screen.queryByText('All systems healthy')).not.toBeInTheDocument() + }) + + it('declares a project with events healthy when no issues are found', () => { + render() + + expect(screen.getByText('All systems healthy')).toBeInTheDocument() + expect(screen.queryByText('Install PostHog')).not.toBeInTheDocument() + }) +}) diff --git a/frontend/src/scenes/health/components/HealthEmptyState.tsx b/frontend/src/scenes/health/components/HealthEmptyState.tsx new file mode 100644 index 000000000000..52ee058cd130 --- /dev/null +++ b/frontend/src/scenes/health/components/HealthEmptyState.tsx @@ -0,0 +1,38 @@ +import { LemonBanner } from '@posthog/lemon-ui' + +import { urls } from 'scenes/urls' + +import { ProductKey } from '~/queries/schema/schema-general' +import { OnboardingStepKey } from '~/types' + +export function HealthEmptyState({ hasIngestedEvents }: { hasIngestedEvents: boolean }): JSX.Element { + // Without any ingested events the checks have nothing to run against, so treat an empty result + // set as "not set up yet" and point the user at install rather than claiming everything is fine. + if (!hasIngestedEvents) { + return ( + +

Health checks have not run yet

+

+ Health checks start once your project receives data. Install PostHog to send your first events. +

+
+ ) + } + + return ( + +

All systems healthy

+

No active health issues found for your project.

+
+ ) +} diff --git a/frontend/src/scenes/health/components/HealthIssueList.tsx b/frontend/src/scenes/health/components/HealthIssueList.tsx index a812f1715f40..3c4b1e4a3ae2 100644 --- a/frontend/src/scenes/health/components/HealthIssueList.tsx +++ b/frontend/src/scenes/health/components/HealthIssueList.tsx @@ -10,10 +10,11 @@ import type { HealthIssueCategory } from '../healthCategories' import { healthSceneLogic } from '../healthSceneLogic' import { severityToTagType, worstSeverity } from '../healthUtils' import type { HealthIssue } from '../types' +import { HealthEmptyState } from './HealthEmptyState' import { HealthIssueCard } from './HealthIssueCard' export const HealthIssueList = (): JSX.Element => { - const { issues, healthIssuesLoading, healthIssues } = useValues(healthSceneLogic) + const { issues, healthIssuesLoading, healthIssues, hasIngestedEvents } = useValues(healthSceneLogic) const { snoozeIssue, dismissIssue, undismissIssue, loadHealthIssues } = useActions(healthSceneLogic) if (healthIssuesLoading && !healthIssues) { @@ -35,12 +36,7 @@ export const HealthIssueList = (): JSX.Element => { } if (issues.length === 0) { - return ( - -

All systems healthy

-

No active health issues found for your project.

-
- ) + return } const groupedByCategory: Partial> = {} diff --git a/frontend/src/scenes/health/components/HealthTables.stories.tsx b/frontend/src/scenes/health/components/HealthTables.stories.tsx index 34d48612cd3b..954ac1440b8f 100644 --- a/frontend/src/scenes/health/components/HealthTables.stories.tsx +++ b/frontend/src/scenes/health/components/HealthTables.stories.tsx @@ -4,6 +4,7 @@ import DataModelingDetailContent from '../categoryDetail/categories/DataModeling import { SdkOutdatedRenderer } from '../renderers/SdkOutdatedRenderer' import type { HealthIssue, HealthIssueSeverity } from '../types' import { DataModelingHealthTable } from './DataModelingHealthTable' +import { HealthEmptyState } from './HealthEmptyState' import { IngestionWarningTable } from './IngestionWarningTable' import { PipelineHealthTable } from './PipelineHealthTable' import { WebAnalyticsHealthTable } from './WebAnalyticsHealthTable' @@ -298,6 +299,10 @@ export const WebAnalyticsEmpty: StoryFn = () => ( ) +export const EmptyAllHealthy: StoryFn = () => + +export const EmptyNoEventsYet: StoryFn = () => + export const SdkOutdatedDefault: StoryFn = () => export const SdkOutdatedEmpty: StoryFn = () => diff --git a/frontend/src/scenes/health/healthSceneLogic.tsx b/frontend/src/scenes/health/healthSceneLogic.tsx index 666964fa3ee8..418a831085ed 100644 --- a/frontend/src/scenes/health/healthSceneLogic.tsx +++ b/frontend/src/scenes/health/healthSceneLogic.tsx @@ -8,11 +8,17 @@ import { sceneConfigurations } from 'scenes/scenes' import { Scene } from 'scenes/sceneTypes' import { teamLogic } from 'scenes/teamLogic' -import { Breadcrumb } from '~/types' +import { Breadcrumb, TeamPublicType, TeamType } from '~/types' import { CATEGORY_ORDER, HEALTH_CATEGORY_CONFIG, categoryForKind } from './healthCategories' import type { CategoryHealthSummary, HealthIssue, HealthIssueSeverity } from './types' -import { REFRESH_COOLDOWN_MS, REFRESH_POLL_COUNT, REFRESH_POLL_INTERVAL_MS, SEVERITY_ORDER } from './types' +import { + HEALTH_ISSUES_LOAD_TIMEOUT_MS, + REFRESH_COOLDOWN_MS, + REFRESH_POLL_COUNT, + REFRESH_POLL_INTERVAL_MS, + SEVERITY_ORDER, +} from './types' export interface HealthIssuesResponse { results: HealthIssue[] @@ -23,9 +29,11 @@ export interface HealthIssuesResponse { // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface healthSceneLogicValues { + currentTeam: TeamPublicType | TeamType | null // teamLogic currentTeamIdStrict: number | string // teamLogic breadcrumbs: Breadcrumb[] categorySummaries: CategoryHealthSummary[] + hasIngestedEvents: boolean healthIssues: HealthIssuesResponse | null healthIssuesLoading: boolean isManualRefresh: boolean @@ -88,6 +96,7 @@ export interface healthSceneLogicMeta { __keaTypeGenInternalSelectorTypes: { issues: (healthIssues: HealthIssuesResponse | null) => HealthIssue[] totalCount: (healthIssues: HealthIssuesResponse | null) => number + hasIngestedEvents: (currentTeam: TeamPublicType | TeamType | null) => boolean categorySummaries: (issues: HealthIssue[]) => CategoryHealthSummary[] } } @@ -102,7 +111,7 @@ export type healthSceneLogicType = MakeLogicType< export const healthSceneLogic = kea([ path(['scenes', 'health', 'healthSceneLogic']), connect({ - values: [teamLogic, ['currentTeamIdStrict']], + values: [teamLogic, ['currentTeamIdStrict', 'currentTeam']], }), actions({ setShowDismissed: (show: boolean) => ({ show }), @@ -156,7 +165,7 @@ export const healthSceneLogic = kea([ const queryString = new URLSearchParams(params).toString() const url = `api/environments/${values.currentTeamIdStrict}/health_issues/?${queryString}` - return await api.get(url) + return await api.get(url, { signal: AbortSignal.timeout(HEALTH_ISSUES_LOAD_TIMEOUT_MS) }) }, }, ], @@ -170,6 +179,12 @@ export const healthSceneLogic = kea([ (s) => [s.healthIssues], (healthIssues: HealthIssuesResponse | null): number => healthIssues?.count ?? 0, ], + // A project that has never ingested an event has no data for any check to run against, so an + // empty result set there means "checks have not run yet", not "everything is healthy". + hasIngestedEvents: [ + (s) => [s.currentTeam], + (currentTeam: TeamPublicType | TeamType | null): boolean => !!currentTeam?.ingested_event, + ], categorySummaries: [ (s) => [s.issues], (issues: HealthIssue[]): CategoryHealthSummary[] => { diff --git a/frontend/src/scenes/health/types.ts b/frontend/src/scenes/health/types.ts index a7a23f432704..1e78d07a3fe0 100644 --- a/frontend/src/scenes/health/types.ts +++ b/frontend/src/scenes/health/types.ts @@ -12,6 +12,10 @@ export const REFRESH_COOLDOWN_MS = 5 * 60 * 1000 export const REFRESH_POLL_INTERVAL_MS = 5000 export const REFRESH_POLL_COUNT = 12 +// Bound the issues load so a request that never settles fails instead of leaving the page on +// skeletons forever. On timeout the loader rejects and the scene falls through to its retry banner. +export const HEALTH_ISSUES_LOAD_TIMEOUT_MS = 30 * 1000 + export interface HealthIssue { id: string kind: string From e69351be325c30960b8b70a60dd674c9a6705ea3 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:06:35 +0000 Subject: [PATCH 2/3] fix(health): defer empty state until the team resolves The health request resolves off currentTeamIdStrict, which falls back to "@current", so it can complete before currentTeam loads (e.g. during OAuth bootstrap). With currentTeam still null, an empty issue list rendered the "Install PostHog" prompt at a project that is already installed. Branch on the unresolved-team state first (per frontend Rule 5) and keep the loading skeleton until the team resolves, so the install-vs-healthy choice is only made once currentTeam is known. Generated-By: PostHog Desktop Task-Id: 1d1f3899-dcfa-4e00-a251-942af9b43f37 --- .../health/components/HealthIssueList.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/scenes/health/components/HealthIssueList.tsx b/frontend/src/scenes/health/components/HealthIssueList.tsx index 3c4b1e4a3ae2..c9d1cabfcebe 100644 --- a/frontend/src/scenes/health/components/HealthIssueList.tsx +++ b/frontend/src/scenes/health/components/HealthIssueList.tsx @@ -14,17 +14,19 @@ import { HealthEmptyState } from './HealthEmptyState' import { HealthIssueCard } from './HealthIssueCard' export const HealthIssueList = (): JSX.Element => { - const { issues, healthIssuesLoading, healthIssues, hasIngestedEvents } = useValues(healthSceneLogic) + const { issues, healthIssuesLoading, healthIssues, hasIngestedEvents, currentTeam } = useValues(healthSceneLogic) const { snoozeIssue, dismissIssue, undismissIssue, loadHealthIssues } = useActions(healthSceneLogic) + const loadingSkeleton = ( +
+ + + +
+ ) + if (healthIssuesLoading && !healthIssues) { - return ( -
- - - -
- ) + return loadingSkeleton } if (!healthIssuesLoading && healthIssues === null) { @@ -36,6 +38,13 @@ export const HealthIssueList = (): JSX.Element => { } if (issues.length === 0) { + // The health request resolves off currentTeamIdStrict, which falls back to "@current", so it + // can finish before currentTeam itself loads (e.g. during OAuth bootstrap). Until the team + // resolves we can't tell "no events yet" from "already installed", so keep the loading state + // rather than flash the install prompt at a project that is already installed. + if (!currentTeam) { + return loadingSkeleton + } return } From 741f30f6be75daf22842521b3f474a64bd135775 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:11:54 +0000 Subject: [PATCH 3/3] fix(health): hide healthy summary cards for a project with no events The empty issue list now shows an install prompt for a project that has never ingested an event, but HealthIssueSummaryCards kept rendering six green "healthy" cards directly above it, so the page contradicted itself: "health checks have not run yet" under six success claims. Read the existing hasIngestedEvents selector in HealthIssueSummaryCards and hide the cards when the project has no data, so the install prompt is the only message until checks have something to run against. No change for a project that has ingested events. Generated-By: PostHog Desktop Task-Id: 1d1f3899-dcfa-4e00-a251-942af9b43f37 --- .../scenes/health/components/HealthIssueSummaryCards.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx b/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx index b86ce5e53a86..e296832c0870 100644 --- a/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx +++ b/frontend/src/scenes/health/components/HealthIssueSummaryCards.tsx @@ -13,7 +13,7 @@ import { severityColor } from '../healthUtils' import type { CategoryHealthSummary } from '../types' export const HealthIssueSummaryCards = (): JSX.Element => { - const { categorySummaries, healthIssuesLoading, healthIssues } = useValues(healthSceneLogic) + const { categorySummaries, healthIssuesLoading, healthIssues, hasIngestedEvents } = useValues(healthSceneLogic) if (healthIssuesLoading && !healthIssues) { return ( @@ -29,6 +29,13 @@ export const HealthIssueSummaryCards = (): JSX.Element => { return <> } + // A project that has never ingested an event has no data for any check to run against, so the + // per-category "healthy" cards would be a premature success claim — and would contradict the + // install prompt the empty issue list shows just below them. Hide the cards until data arrives. + if (!hasIngestedEvents) { + return <> + } + return (
{categorySummaries.map((summary: CategoryHealthSummary) => (