From 2edb5daffa322d5c014be1d3d6f55cffed0a123a Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:43:56 +0000 Subject: [PATCH] feat(customer-analytics): add explainable account health scores --- frontend/src/queries/schema.json | 123 ++++++- frontend/src/queries/schema/schema-general.ts | 41 +++ posthog/schema.py | 39 +++ posthog/schema_enums.py | 15 + .../backend/hogql_queries/account_health.py | 300 ++++++++++++++++++ .../accounts_table_query_runner.py | 38 +++ .../hogql_queries/test/test_account_health.py | 221 +++++++++++++ .../seed_customer_analytics_accounts.py | 136 +++++++- .../test_seed_customer_analytics_accounts.py | 53 +++- .../frontend/components/Accounts/AGENTS.md | 93 +++--- .../Accounts/AccountHealth.test.tsx | 101 ++++++ .../components/Accounts/AccountHealthCell.tsx | 52 +++ .../Accounts/AccountHealthDetails.tsx | 131 ++++++++ .../Accounts/AccountNotebooksExpansion.tsx | 10 + .../Accounts/AccountsTab.stories.tsx | 120 +++++++ .../components/Accounts/AccountsTable.tsx | 33 +- .../components/Accounts/accountHealth.ts | 33 ++ .../Accounts/accountsExpansionLogic.ts | 2 + .../components/Accounts/accountsLogic.ts | 5 +- .../Accounts/accountsTableQuery.test.ts | 13 +- .../components/Accounts/accountsTableQuery.ts | 7 +- products/customer_analytics/health_score.md | 67 ++++ 22 files changed, 1578 insertions(+), 55 deletions(-) create mode 100644 products/customer_analytics/backend/hogql_queries/account_health.py create mode 100644 products/customer_analytics/backend/hogql_queries/test/test_account_health.py create mode 100644 products/customer_analytics/frontend/components/Accounts/AccountHealth.test.tsx create mode 100644 products/customer_analytics/frontend/components/Accounts/AccountHealthCell.tsx create mode 100644 products/customer_analytics/frontend/components/Accounts/AccountHealthDetails.tsx create mode 100644 products/customer_analytics/frontend/components/Accounts/accountHealth.ts create mode 100644 products/customer_analytics/health_score.md diff --git a/frontend/src/queries/schema.json b/frontend/src/queries/schema.json index 99ff092bedb7..79532fc39e8b 100644 --- a/frontend/src/queries/schema.json +++ b/frontend/src/queries/schema.json @@ -69,6 +69,115 @@ "required": ["key", "operator", "type"], "type": "object" }, + "AccountHealthFactor": { + "additionalProperties": false, + "description": "One usage metric's contribution to an account health score.", + "properties": { + "contribution": { + "description": "Points this factor adds to the overall score after equal weighting.", + "type": ["number", "null"] + }, + "current": { + "type": "number" + }, + "metricId": { + "type": "string" + }, + "metricName": { + "type": "string" + }, + "normalizedScore": { + "anyOf": [ + { + "$ref": "#/definitions/integer" + }, + { + "type": "null" + } + ], + "description": "Current usage retained from the previous period, capped at 100." + }, + "previous": { + "type": "number" + } + }, + "required": ["metricId", "metricName", "current", "previous", "normalizedScore", "contribution"], + "type": "object" + }, + "AccountHealthNoDataReason": { + "description": "Why an account could not be scored.", + "enum": ["missing_external_id", "missing_group_type", "no_metrics", "no_activity", "calculation_error"], + "type": "string" + }, + "AccountHealthScore": { + "additionalProperties": false, + "description": "An explainable account health score calculated from usage metrics.", + "properties": { + "computedAt": { + "format": "date-time", + "type": "string" + }, + "currentPeriodStart": { + "format": "date-time", + "type": "string" + }, + "factors": { + "items": { + "$ref": "#/definitions/AccountHealthFactor" + }, + "type": "array" + }, + "isLimited": { + "type": "boolean" + }, + "lookbackDays": { + "$ref": "#/definitions/integer" + }, + "noDataReason": { + "anyOf": [ + { + "$ref": "#/definitions/AccountHealthNoDataReason" + }, + { + "type": "null" + } + ] + }, + "previousPeriodStart": { + "format": "date-time", + "type": "string" + }, + "score": { + "anyOf": [ + { + "$ref": "#/definitions/integer" + }, + { + "type": "null" + } + ] + }, + "status": { + "$ref": "#/definitions/AccountHealthStatus" + } + }, + "required": [ + "score", + "status", + "factors", + "lookbackDays", + "previousPeriodStart", + "currentPeriodStart", + "computedAt", + "isLimited" + ], + "type": "object" + }, + "AccountHealthStatus": { + "description": "Health bucket for an account.", + "enum": ["healthy", "neutral", "at_risk", "no_data"], + "type": "string" + }, "AccountsQuery": { "additionalProperties": false, "properties": { @@ -726,6 +835,10 @@ "externalId": { "type": ["string", "null"] }, + "health": { + "$ref": "#/definitions/AccountHealthScore", + "description": "Query-time account health result derived from configured usage metrics." + }, "id": { "type": "string" }, @@ -754,7 +867,15 @@ "type": "array" } }, - "required": ["id", "name", "accountFields", "relationships", "customProperties", "customPropertyHistory"], + "required": [ + "id", + "name", + "accountFields", + "relationships", + "customProperties", + "customPropertyHistory", + "health" + ], "type": "object" }, "AccountsTableSearchFilter": { diff --git a/frontend/src/queries/schema/schema-general.ts b/frontend/src/queries/schema/schema-general.ts index 6342b4ed9272..2f923220cc85 100644 --- a/frontend/src/queries/schema/schema-general.ts +++ b/frontend/src/queries/schema/schema-general.ts @@ -3003,6 +3003,45 @@ export interface AccountsTableCustomPropertyHistoryPoint { value: number } +/** Health bucket for an account. */ +export type AccountHealthStatus = 'healthy' | 'neutral' | 'at_risk' | 'no_data' + +/** Why an account could not be scored. */ +export type AccountHealthNoDataReason = + | 'missing_external_id' + | 'missing_group_type' + | 'no_metrics' + | 'no_activity' + | 'calculation_error' + +/** One usage metric's contribution to an account health score. */ +export interface AccountHealthFactor { + metricId: string + metricName: string + current: number + previous: number + /** Current usage retained from the previous period, capped at 100. */ + normalizedScore: integer | null + /** Points this factor adds to the overall score after equal weighting. */ + contribution: number | null +} + +/** An explainable account health score calculated from usage metrics. */ +export interface AccountHealthScore { + score: integer | null + status: AccountHealthStatus + factors: AccountHealthFactor[] + lookbackDays: integer + /** @format date-time */ + previousPeriodStart: string + /** @format date-time */ + currentPeriodStart: string + /** @format date-time */ + computedAt: string + isLimited: boolean + noDataReason?: AccountHealthNoDataReason | null +} + export interface AccountsTableRow { id: string name: string @@ -3019,6 +3058,8 @@ export interface AccountsTableRow { customProperties: Record /** Numeric write history keyed by requested custom property definition ID. */ customPropertyHistory: Record + /** Query-time account health result derived from configured usage metrics. */ + health: AccountHealthScore } export type CachedAccountsTableQueryResponse = CachedQueryResponse diff --git a/posthog/schema.py b/posthog/schema.py index 47537fcab76d..3656039b090c 100644 --- a/posthog/schema.py +++ b/posthog/schema.py @@ -13,6 +13,8 @@ from posthog.schema_discriminators import property_filter_discriminator from posthog.schema_enums import ( AccessControlLevel as AccessControlLevel, + AccountHealthNoDataReason as AccountHealthNoDataReason, + AccountHealthStatus as AccountHealthStatus, AccountsTableAccountField as AccountsTableAccountField, AccountsTableAggregation as AccountsTableAggregation, AccountsTableCustomPropertyOperator as AccountsTableCustomPropertyOperator, @@ -3081,6 +3083,39 @@ class AccountCustomPropertyFilter(BaseModel): value: list[str | float | bool] | str | float | bool | None = None +class AccountHealthFactor(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + contribution: float | None = Field( + ..., + description=("Points this factor adds to the overall score after equal weighting."), + ) + current: float + metricId: str + metricName: str + normalizedScore: int | None = Field( + ..., + description="Current usage retained from the previous period, capped at 100.", + ) + previous: float + + +class AccountHealthScore(BaseModel): + model_config = ConfigDict( + extra="forbid", + ) + computedAt: AwareDatetime + currentPeriodStart: AwareDatetime + factors: list[AccountHealthFactor] + isLimited: bool + lookbackDays: int + noDataReason: AccountHealthNoDataReason | None = None + previousPeriodStart: AwareDatetime + score: int | None = None + status: AccountHealthStatus + + class AccountsTableAggregateMetric(BaseModel): model_config = ConfigDict( extra="forbid", @@ -3142,6 +3177,10 @@ class AccountsTableRow(BaseModel): description=("Numeric write history keyed by requested custom property definition ID."), ) externalId: str | None = None + health: AccountHealthScore = Field( + ..., + description=("Query-time account health result derived from configured usage metrics."), + ) id: str name: str noteCount: int | None = Field( diff --git a/posthog/schema_enums.py b/posthog/schema_enums.py index e3b60377bde7..ca04638f7fae 100644 --- a/posthog/schema_enums.py +++ b/posthog/schema_enums.py @@ -30,6 +30,21 @@ class AccessControlLevel(StrEnum): MANAGER = "manager" +class AccountHealthNoDataReason(StrEnum): + MISSING_EXTERNAL_ID = "missing_external_id" + MISSING_GROUP_TYPE = "missing_group_type" + NO_METRICS = "no_metrics" + NO_ACTIVITY = "no_activity" + CALCULATION_ERROR = "calculation_error" + + +class AccountHealthStatus(StrEnum): + HEALTHY = "healthy" + NEUTRAL = "neutral" + AT_RISK = "at_risk" + NO_DATA = "no_data" + + class AccountsTableAccountField(StrEnum): NAME = "name" EXTERNAL_ID = "external_id" diff --git a/products/customer_analytics/backend/hogql_queries/account_health.py b/products/customer_analytics/backend/hogql_queries/account_health.py new file mode 100644 index 000000000000..55886e5ee40a --- /dev/null +++ b/products/customer_analytics/backend/hogql_queries/account_health.py @@ -0,0 +1,300 @@ +import math +from collections.abc import Iterable +from datetime import datetime, timedelta +from functools import cached_property +from zoneinfo import ZoneInfo + +from posthog.schema import ( + AccountHealthFactor, + AccountHealthNoDataReason, + AccountHealthScore, + AccountHealthStatus, + HogQLQueryModifiers, +) + +from posthog.hogql import ast +from posthog.hogql.query import execute_hogql_query +from posthog.hogql.timings import HogQLTimings +from posthog.hogql.visitor import clone_expr + +from posthog.models import Team, User +from posthog.models.group_usage_metric import GroupUsageMetric + +ACCOUNT_HEALTH_LOOKBACK_DAYS = 30 +ACCOUNT_HEALTH_MAX_METRICS = 5 +ACCOUNT_HEALTHY_THRESHOLD = 80 +ACCOUNT_HEALTH_NEUTRAL_THRESHOLD = 50 + + +def _period_boundaries(date_to: datetime) -> tuple[datetime, datetime]: + current_period_start = date_to - timedelta(days=ACCOUNT_HEALTH_LOOKBACK_DAYS) + return current_period_start - timedelta(days=ACCOUNT_HEALTH_LOOKBACK_DAYS), current_period_start + + +def _round_score(value: float) -> int: + return math.floor(value + 0.5) + + +def normalize_health_factor(current: float, previous: float) -> int | None: + if previous > 0: + return _round_score(min(max(current, 0) / previous, 1) * 100) + if current > 0: + return 100 + return None + + +def account_health_status(score: int | None) -> AccountHealthStatus: + if score is None: + return AccountHealthStatus.NO_DATA + if score >= ACCOUNT_HEALTHY_THRESHOLD: + return AccountHealthStatus.HEALTHY + if score >= ACCOUNT_HEALTH_NEUTRAL_THRESHOLD: + return AccountHealthStatus.NEUTRAL + return AccountHealthStatus.AT_RISK + + +def build_account_health( + metric_values: list[tuple[GroupUsageMetric, float, float]], + *, + date_to: datetime, + no_data_reason: AccountHealthNoDataReason | None = None, +) -> AccountHealthScore: + normalized_scores = [normalize_health_factor(current, previous) for _, current, previous in metric_values] + available_scores = [score for score in normalized_scores if score is not None] + overall_score = _round_score(sum(available_scores) / len(available_scores)) if available_scores else None + available_count = len(available_scores) + factors = [ + AccountHealthFactor( + metricId=str(metric.id), + metricName=metric.name, + current=current, + previous=previous, + normalizedScore=normalized_score, + contribution=normalized_score / available_count if normalized_score is not None else None, + ) + for (metric, current, previous), normalized_score in zip(metric_values, normalized_scores, strict=True) + ] + previous_period_start, current_period_start = _period_boundaries(date_to) + return AccountHealthScore( + score=overall_score, + status=account_health_status(overall_score), + factors=factors, + lookbackDays=ACCOUNT_HEALTH_LOOKBACK_DAYS, + previousPeriodStart=previous_period_start, + currentPeriodStart=current_period_start, + computedAt=date_to, + isLimited=bool(metric_values) + and (available_count < len(metric_values) or any(previous <= 0 for _, _, previous in metric_values)), + noDataReason=no_data_reason if overall_score is None else None, + ) + + +def no_data_account_health(reason: AccountHealthNoDataReason, *, date_to: datetime | None = None) -> AccountHealthScore: + return build_account_health([], date_to=date_to or datetime.now(tz=ZoneInfo("UTC")), no_data_reason=reason) + + +class AccountHealthScorer: + def __init__( + self, + *, + team: Team, + timings: HogQLTimings, + modifiers: HogQLQueryModifiers | None, + user: User | None, + ) -> None: + self.team = team + self.timings = timings + self.modifiers = modifiers + self.user = user + + @cached_property + def group_type_index(self) -> int | None: + return self.team.customer_analytics_config.account_group_type_index + + @cached_property + def metrics(self) -> list[GroupUsageMetric]: + metrics = ( + GroupUsageMetric.objects.filter(team_id=self.team.id) + .order_by("name", "id") + .only("id", "name", "filters", "math", "math_property") + ) + return [metric for metric in metrics if not metric.is_data_warehouse] + + @cached_property + def eligible_metrics(self) -> list[tuple[GroupUsageMetric, ast.Expr]]: + eligible: list[tuple[GroupUsageMetric, ast.Expr]] = [] + for metric in self.metrics: + if metric.math == GroupUsageMetric.Math.SUM and not metric.math_property: + continue + filter_expr = metric.get_expr() + if filter_expr == ast.Constant(value=True): + continue + eligible.append((metric, filter_expr)) + if len(eligible) == ACCOUNT_HEALTH_MAX_METRICS: + break + return eligible + + def cache_fingerprint(self) -> dict[str, object]: + return { + "group_type_index": self.group_type_index, + "metrics": [ + (str(metric.id), metric.name, metric.math, metric.math_property or "", str(metric.filters)) + for metric in self.metrics + ], + } + + def score_external_ids(self, external_ids: Iterable[str | None]) -> dict[str, AccountHealthScore]: + account_keys = sorted({external_id for external_id in external_ids if external_id}) + if not account_keys: + return {} + + date_to = datetime.now(tz=ZoneInfo("UTC")) + if self.group_type_index is None: + return { + account_key: no_data_account_health(AccountHealthNoDataReason.MISSING_GROUP_TYPE, date_to=date_to) + for account_key in account_keys + } + if not self.eligible_metrics: + return { + account_key: no_data_account_health(AccountHealthNoDataReason.NO_METRICS, date_to=date_to) + for account_key in account_keys + } + + response = execute_hogql_query( + query_type="account_health", + query=self._query(account_keys, date_to), + team=self.team, + user=self.user, + timings=self.timings, + modifiers=self.modifiers, + ) + values_by_account = self._values_by_account(response.results or []) + return { + account_key: build_account_health( + [ + (metric, *values_by_account.get(account_key, {}).get(str(metric.id), (0.0, 0.0))) + for metric, _ in self.eligible_metrics + ], + date_to=date_to, + no_data_reason=AccountHealthNoDataReason.NO_ACTIVITY, + ) + for account_key in account_keys + } + + def _query(self, account_keys: list[str], date_to: datetime) -> ast.SelectQuery: + previous_period_start, current_period_start = _period_boundaries(date_to) + group_field = ast.Field(chain=["events", "properties", f"$group_{self.group_type_index}"]) + select: list[ast.Expr] = [ast.Alias(alias="account_key", expr=group_field)] + for index, (metric, filter_expr) in enumerate(self.eligible_metrics): + current, previous = self._metric_aggregations( + metric, + filter_expr, + previous_period_start=previous_period_start, + current_period_start=current_period_start, + date_to=date_to, + ) + select.extend( + [ + ast.Alias(alias=f"metric_{index}_current", expr=current), + ast.Alias(alias=f"metric_{index}_previous", expr=previous), + ] + ) + + return ast.SelectQuery( + select=select, + select_from=ast.JoinExpr(table=ast.Field(chain=["events"])), + where=ast.And( + exprs=[ + ast.CompareOperation( + op=ast.CompareOperationOp.In, + left=ast.Field(chain=["events", "properties", f"$group_{self.group_type_index}"]), + right=ast.Array(exprs=[ast.Constant(value=account_key) for account_key in account_keys]), + ), + ast.CompareOperation( + op=ast.CompareOperationOp.GtEq, + left=ast.Field(chain=["timestamp"]), + right=ast.Constant(value=previous_period_start), + ), + ast.CompareOperation( + op=ast.CompareOperationOp.LtEq, + left=ast.Field(chain=["timestamp"]), + right=ast.Constant(value=date_to), + ), + ] + ), + group_by=[ast.Field(chain=["account_key"])], + limit=ast.Constant(value=len(account_keys)), + ) + + @staticmethod + def _metric_aggregations( + metric: GroupUsageMetric, + filter_expr: ast.Expr, + *, + previous_period_start: datetime, + current_period_start: datetime, + date_to: datetime, + ) -> tuple[ast.Expr, ast.Expr]: + current_condition = ast.And( + exprs=[ + clone_expr(filter_expr), + ast.CompareOperation( + op=ast.CompareOperationOp.GtEq, + left=ast.Field(chain=["timestamp"]), + right=ast.Constant(value=current_period_start), + ), + ast.CompareOperation( + op=ast.CompareOperationOp.LtEq, + left=ast.Field(chain=["timestamp"]), + right=ast.Constant(value=date_to), + ), + ] + ) + previous_condition = ast.And( + exprs=[ + clone_expr(filter_expr), + ast.CompareOperation( + op=ast.CompareOperationOp.GtEq, + left=ast.Field(chain=["timestamp"]), + right=ast.Constant(value=previous_period_start), + ), + ast.CompareOperation( + op=ast.CompareOperationOp.Lt, + left=ast.Field(chain=["timestamp"]), + right=ast.Constant(value=current_period_start), + ), + ] + ) + if metric.math == GroupUsageMetric.Math.SUM: + value = ast.Call(name="toFloat", args=[ast.Field(chain=["properties", metric.math_property])]) + return ( + ast.Call( + name="ifNull", + args=[ast.Call(name="sumIf", args=[clone_expr(value), current_condition]), ast.Constant(value=0)], + ), + ast.Call( + name="ifNull", + args=[ast.Call(name="sumIf", args=[clone_expr(value), previous_condition]), ast.Constant(value=0)], + ), + ) + return ( + ast.Call(name="toFloat", args=[ast.Call(name="countIf", args=[current_condition])]), + ast.Call(name="toFloat", args=[ast.Call(name="countIf", args=[previous_condition])]), + ) + + def _values_by_account(self, rows: list[list[object]]) -> dict[str, dict[str, tuple[float, float]]]: + values: dict[str, dict[str, tuple[float, float]]] = {} + for row in rows: + if not row or not isinstance(row[0], str): + continue + metric_values: dict[str, tuple[float, float]] = {} + for index, (metric, _) in enumerate(self.eligible_metrics): + current_index = 1 + index * 2 + previous_index = current_index + 1 + current_value = row[current_index] if len(row) > current_index else 0.0 + previous_value = row[previous_index] if len(row) > previous_index else 0.0 + current = float(current_value) if isinstance(current_value, int | float | str) else 0.0 + previous = float(previous_value) if isinstance(previous_value, int | float | str) else 0.0 + metric_values[str(metric.id)] = (current, previous) + values[row[0]] = metric_values + return values diff --git a/products/customer_analytics/backend/hogql_queries/accounts_table_query_runner.py b/products/customer_analytics/backend/hogql_queries/accounts_table_query_runner.py index 06876f5407ac..0271b98a7066 100644 --- a/products/customer_analytics/backend/hogql_queries/accounts_table_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/accounts_table_query_runner.py @@ -4,6 +4,7 @@ from rest_framework.exceptions import ValidationError from posthog.schema import ( + AccountHealthNoDataReason, AccountsTableAccountFieldColumn, AccountsTableAccountIdFilter, AccountsTableAggregateMetric, @@ -27,12 +28,15 @@ ) from posthog.hogql.constants import get_default_limit_for_context, get_max_limit_for_context +from posthog.hogql.errors import BaseHogQLError +from posthog.errors import InternalCHQueryError from posthog.hogql_queries.query_runner import AnalyticsQueryRunner from posthog.models import User from posthog.rbac.user_access_control import UserAccessControl, UserAccessControlError from products.customer_analytics.backend.facade import api, contracts +from products.customer_analytics.backend.hogql_queries.account_health import AccountHealthScorer, no_data_account_health ACCOUNTS_TABLE_MAX_COLUMNS = 100 ACCOUNTS_TABLE_MAX_FILTERS = 50 @@ -254,6 +258,21 @@ def _calculate(self) -> AccountsTableQueryResponse: except api.InvalidAccountTableColumn as error: raise ValidationError(str(error)) from error + health_scorer = AccountHealthScorer( + team=self.team, + timings=self.timings, + modifiers=self.modifiers, + user=self.user, + ) + try: + health_by_external_id = health_scorer.score_external_ids(row.external_id for row in page.rows) + except (BaseHogQLError, InternalCHQueryError, ValueError): + health_by_external_id = { + row.external_id: no_data_account_health(AccountHealthNoDataReason.CALCULATION_ERROR) + for row in page.rows + if row.external_id is not None + } + return AccountsTableQueryResponse( results=[ AccountsTableRow( @@ -276,6 +295,14 @@ def _calculate(self) -> AccountsTableQueryResponse: ] for definition_id, points in row.custom_property_history.items() }, + health=( + health_by_external_id.get( + row.external_id, + no_data_account_health(AccountHealthNoDataReason.CALCULATION_ERROR), + ) + if row.external_id is not None + else no_data_account_health(AccountHealthNoDataReason.MISSING_EXTERNAL_ID) + ), ) for row in page.rows ], @@ -283,3 +310,14 @@ def _calculate(self) -> AccountsTableQueryResponse: limit=page.limit, offset=page.offset, ) + + def get_cache_payload(self) -> dict: + payload = super().get_cache_payload() + if self.query.metrics is None: + payload["account_health"] = AccountHealthScorer( + team=self.team, + timings=self.timings, + modifiers=self.modifiers, + user=self.user, + ).cache_fingerprint() + return payload diff --git a/products/customer_analytics/backend/hogql_queries/test/test_account_health.py b/products/customer_analytics/backend/hogql_queries/test/test_account_health.py new file mode 100644 index 000000000000..b0ba222c815b --- /dev/null +++ b/products/customer_analytics/backend/hogql_queries/test/test_account_health.py @@ -0,0 +1,221 @@ +from datetime import UTC, datetime, timedelta +from uuid import UUID, uuid4 + +from freezegun import freeze_time +from posthog.test.base import APIBaseTest, ClickhouseTestMixin, _create_event, _create_person, flush_persons_and_events +from unittest.mock import patch + +from parameterized import parameterized + +from posthog.schema import ( + AccountHealthNoDataReason, + AccountsTableAccountField, + AccountsTableAccountFieldColumn, + AccountsTableNoteCountColumn, + AccountsTableQuery, + AccountsTableTagsColumn, +) + +from posthog.hogql.query import execute_hogql_query +from posthog.hogql.timings import HogQLTimings + +from posthog.models import GroupUsageMetric, Team +from posthog.test.persons import create_group, create_group_type_mapping + +from products.customer_analytics.backend.hogql_queries.account_health import ( + ACCOUNT_HEALTH_LOOKBACK_DAYS, + AccountHealthScorer, + build_account_health, + normalize_health_factor, +) +from products.customer_analytics.backend.hogql_queries.accounts_table_query_runner import AccountsTableQueryRunner +from products.customer_analytics.backend.models import Account + + +class TestAccountHealthScoring(APIBaseTest): + @parameterized.expand( + [ + (0.0, 0.0, None), + (5.0, 0.0, 100), + (0.0, 5.0, 0), + (12.0, 10.0, 100), + (2.0, 3.0, 67), + (-1.0, 3.0, 0), + ] + ) + def test_normalizes_factor(self, current: float, previous: float, expected: int | None) -> None: + assert normalize_health_factor(current, previous) == expected + + def test_builds_score_from_available_factors(self) -> None: + metrics = [ + GroupUsageMetric(id=uuid4(), name="Events ingested"), + GroupUsageMetric(id=uuid4(), name="Active users"), + ] + now = datetime(2026, 5, 21, tzinfo=UTC) + + health = build_account_health( + [(metrics[0], 80.0, 100.0), (metrics[1], 0.0, 0.0)], + date_to=now, + ) + + assert health.score == 80 + assert health.status == "healthy" + assert health.isLimited is True + assert [factor.contribution for factor in health.factors] == [80, None] + assert health.currentPeriodStart == now - timedelta(days=ACCOUNT_HEALTH_LOOKBACK_DAYS) + + def test_returns_no_data_for_an_account_without_usage(self) -> None: + metric = GroupUsageMetric(id=uuid4(), name="Events ingested") + + health = build_account_health( + [(metric, 0.0, 0.0)], + date_to=datetime(2026, 5, 21, tzinfo=UTC), + no_data_reason=AccountHealthNoDataReason.NO_ACTIVITY, + ) + + assert health.score is None + assert health.status == "no_data" + assert health.noDataReason == "no_activity" + assert health.factors[0].contribution is None + + def test_metric_changes_invalidate_the_cache_fingerprint(self) -> None: + metric = GroupUsageMetric.objects.create( + team=self.team, + group_type_index=0, + name="Events ingested", + interval=30, + filters={"events": [{"id": "account-event", "type": "events", "order": 0}]}, + ) + original = AccountHealthScorer( + team=self.team, + timings=HogQLTimings(), + modifiers=None, + user=self.user, + ).cache_fingerprint() + + metric.name = "Active accounts" + metric.save(update_fields=["name"]) + updated = AccountHealthScorer( + team=self.team, + timings=HogQLTimings(), + modifiers=None, + user=self.user, + ).cache_fingerprint() + + assert updated != original + + +@freeze_time("2026-05-21 12:00:00") +class TestAccountsTableHealthIntegration(ClickhouseTestMixin, APIBaseTest): + def setUp(self) -> None: + super().setUp() + self.group_type_index = 0 + create_group_type_mapping( + team=self.team, + project_id=self.team.project_id, + group_type="organization", + group_type_index=self.group_type_index, + ) + config = self.team.customer_analytics_config + config.account_group_type_index = self.group_type_index + config.save() + + create_group(team=self.team, group_type_index=self.group_type_index, group_key="acme") + create_group(team=self.team, group_type_index=self.group_type_index, group_key="globex") + Account.objects.for_team(self.team.id).create(team_id=self.team.id, external_id="acme", name="Acme") + Account.objects.for_team(self.team.id).create(team_id=self.team.id, external_id="globex", name="Globex") + Account.objects.for_team(self.team.id).create(team_id=self.team.id, external_id=None, name="Hooli") + + self.events_metric = GroupUsageMetric.objects.create( + team=self.team, + name="Events ingested", + format="numeric", + interval=30, + display="number", + filters={"events": [{"id": "account-event", "type": "events", "order": 0}]}, + group_type_index=self.group_type_index, + ) + self.users_metric = GroupUsageMetric.objects.create( + team=self.team, + name="Active users", + format="numeric", + interval=30, + display="number", + filters={"events": [{"id": "user-event", "type": "events", "order": 0}]}, + group_type_index=self.group_type_index, + ) + + person = _create_person(team_id=self.team.pk, distinct_ids=["person"]) + current_timestamp = datetime(2026, 5, 10) + previous_timestamp = datetime(2026, 4, 10) + self._capture_events(self.team, person.uuid, "person", "acme", "account-event", current_timestamp, 8) + self._capture_events(self.team, person.uuid, "person", "acme", "account-event", previous_timestamp, 10) + self._capture_events(self.team, person.uuid, "person", "acme", "user-event", current_timestamp, 6) + self._capture_events(self.team, person.uuid, "person", "acme", "user-event", previous_timestamp, 10) + self._capture_events(self.team, person.uuid, "person", "globex", "account-event", current_timestamp, 2) + + other_team = Team.objects.create(organization=self.organization) + other_person = _create_person(team_id=other_team.pk, distinct_ids=["other-person"]) + self._capture_events( + other_team, other_person.uuid, "other-person", "acme", "account-event", current_timestamp, 3 + ) + GroupUsageMetric.objects.create( + team=other_team, + group_type_index=self.group_type_index, + name="Other team metric", + format="numeric", + interval=30, + display="number", + filters={"events": [{"id": "account-event", "type": "events", "order": 0}]}, + ) + flush_persons_and_events() + + def _capture_events( + self, + team: Team, + person_id: UUID, + distinct_id: str, + account_id: str, + event: str, + timestamp: datetime, + count: int, + ) -> None: + for index in range(count): + _create_event( + team=team, + event=event, + distinct_id=distinct_id, + person_id=person_id, + timestamp=timestamp + timedelta(seconds=index), + properties={"$group_0": account_id}, + ) + + def test_serializes_deterministic_scores_in_one_clickhouse_query(self) -> None: + query = AccountsTableQuery( + columns=[ + AccountsTableAccountFieldColumn(field=AccountsTableAccountField.NAME), + AccountsTableTagsColumn(), + AccountsTableNoteCountColumn(), + ], + ) + + with patch( + "products.customer_analytics.backend.hogql_queries.account_health.execute_hogql_query", + wraps=execute_hogql_query, + ) as execute_mock: + response = AccountsTableQueryRunner(query=query, team=self.team, user=self.user).calculate() + + assert execute_mock.call_count == 1 + rows = {row.name: row for row in response.results} + assert rows["Acme"].health.score == 70, [ + (factor.metricName, factor.current, factor.previous, factor.normalizedScore) + for factor in rows["Acme"].health.factors + ] + assert rows["Acme"].health.status == "neutral" + assert rows["Globex"].health.score == 100 + assert rows["Globex"].health.isLimited is True + assert rows["Hooli"].health.noDataReason == "missing_external_id" + assert [factor.metricName for factor in rows["Acme"].health.factors] == ["Active users", "Events ingested"] + payload = response.model_dump(mode="json", by_alias=True) + payload_rows = {row["name"]: row for row in payload["results"]} + assert payload_rows["Acme"]["health"]["computedAt"] == "2026-05-21T12:00:00Z" diff --git a/products/customer_analytics/backend/management/commands/seed_customer_analytics_accounts.py b/products/customer_analytics/backend/management/commands/seed_customer_analytics_accounts.py index d76dc965da7d..5168799f705e 100644 --- a/products/customer_analytics/backend/management/commands/seed_customer_analytics_accounts.py +++ b/products/customer_analytics/backend/management/commands/seed_customer_analytics_accounts.py @@ -9,26 +9,35 @@ CSM / account executive / account owner, - adds a few notes (internal notebooks) to a handful of accounts, - points the team's customer-analytics config at group type index 0. +- optionally adds deterministic healthy, neutral, at-risk, and no-data examples. Re-running is safe: existing accounts, pool users, and notes are left alone. Usage: python manage.py seed_customer_analytics_accounts --team-id 1 python manage.py seed_customer_analytics_accounts --team-id 1 --users 5 --accounts-with-notes 5 + python manage.py seed_customer_analytics_accounts --team-id 1 --health-fixtures python manage.py seed_customer_analytics_accounts --team-id 1 --dry-run """ +from datetime import UTC, datetime, timedelta from typing import Any -from uuid import uuid4 +from uuid import NAMESPACE_URL, uuid4, uuid5 from django.core.management.base import BaseCommand, CommandError from django.db import transaction +from posthog.hogql.timings import HogQLTimings + +from posthog.api.capture import capture_batch_internal from posthog.models import OrganizationMembership, Team, User +from posthog.models.group_type_mapping import get_group_types_for_project +from posthog.models.group_usage_metric import GroupUsageMetric from posthog.models.scoping import team_scope from posthog.persons_db import persons_db_connection from products.customer_analytics.backend.facade.api import create_account +from products.customer_analytics.backend.hogql_queries.account_health import AccountHealthScorer from products.customer_analytics.backend.logic import relationships as relationships_logic from products.customer_analytics.backend.models.account import Account, AccountProperties from products.customer_analytics.backend.models.relationship import AccountRelationshipDefinition @@ -36,6 +45,14 @@ from products.notebooks.backend.facade import api as notebooks ACCOUNT_GROUP_TYPE_INDEX = 0 +HEALTH_FIXTURE_EVENT = "customer analytics health fixture" +HEALTH_FIXTURE_METRIC = "Account health fixture" +HEALTH_FIXTURE_ACCOUNTS = ( + ("health-example-healthy", "Health example: Healthy", 9, 10), + ("health-example-neutral", "Health example: Neutral", 6, 10), + ("health-example-at-risk", "Health example: At risk", 3, 10), + ("health-example-no-data", "Health example: No data", 0, 0), +) NOTE_TEMPLATES: list[tuple[str, str]] = [ ("Kickoff call", "Walked the team through onboarding. Main goal is consolidating file storage across departments."), @@ -46,7 +63,7 @@ class Command(BaseCommand): - help = "Seed Customer analytics accounts (with users and notes) from existing group-analytics groups." + help = "Seed Customer analytics accounts from existing group-analytics groups." def add_arguments(self, parser: Any) -> None: parser.add_argument("--team-id", type=int, required=True, help="Team whose groups to read and seed into.") @@ -65,6 +82,11 @@ def add_arguments(self, parser: Any) -> None: parser.add_argument( "--limit", type=int, default=None, help="Cap how many groups become accounts (default: all)." ) + parser.add_argument( + "--health-fixtures", + action="store_true", + help="Add healthy, neutral, at-risk, and no-data account health examples.", + ) parser.add_argument("--dry-run", action="store_true", help="Report what would be created without writing.") def handle(self, *args: Any, **options: Any) -> None: @@ -86,12 +108,17 @@ def handle(self, *args: Any, **options: Any) -> None: f"and add up to {note_account_count * options['notes_per_account']} note(s) " f"across {note_account_count} account(s)." ) + if options["health_fixtures"]: + self.stdout.write("Would also add four deterministic account health examples.") return self._set_config(team) user_pool = self._ensure_user_pool(team, options["users"]) accounts = self._create_accounts(team, groups, user_pool) self._create_notes(team, accounts, user_pool, options["accounts_with_notes"], options["notes_per_account"]) + if options["health_fixtures"]: + health_accounts = self._create_health_fixture_accounts(team, user_pool) + self._create_health_fixture_events(team, health_accounts, self._account_group_type(team)) self.stdout.write(self.style.SUCCESS("Done.")) def _get_team(self, team_id: int) -> Team: @@ -240,6 +267,111 @@ def _create_notes( created += 1 self.stdout.write(f"Created {created} note(s) across up to {len(selected)} account(s).") + @transaction.atomic + def _create_health_fixture_accounts(self, team: Team, user_pool: list[User]) -> list[Account]: + creator = team.organization.members.first() + definitions = self._ensure_role_definitions(team, creator) + accounts: list[Account] = [] + created = 0 + with team_scope(team.pk): + for index, (external_id, name, _, _) in enumerate(HEALTH_FIXTURE_ACCOUNTS): + account = Account.objects.filter(external_id=external_id).first() + if account is None: + account = create_account( + team=team, + name=name, + external_id=external_id, + created_by=creator, + properties=AccountProperties(), + ) + self._assign_roles(team, account, definitions, user_pool, index, creator) + created += 1 + accounts.append(account) + self.stdout.write(f"Ensured four account health examples ({created} created).") + return accounts + + @staticmethod + def _account_group_type(team: Team) -> str: + group_type = next( + ( + mapping["group_type"] + for mapping in get_group_types_for_project( + team.project_id, caller_tag="seed_customer_analytics_accounts" + ) + if mapping["group_type_index"] == ACCOUNT_GROUP_TYPE_INDEX + ), + None, + ) + if not group_type: + raise CommandError("The account group type could not be resolved. Configure group analytics, then rerun.") + return group_type + + def _create_health_fixture_events(self, team: Team, accounts: list[Account], group_type: str) -> None: + metric, _ = GroupUsageMetric.objects.update_or_create( + team=team, + group_type_index=ACCOUNT_GROUP_TYPE_INDEX, + name=HEALTH_FIXTURE_METRIC, + defaults={ + "format": GroupUsageMetric.Format.NUMERIC, + "interval": 30, + "display": GroupUsageMetric.Display.NUMBER, + "filters": {"events": [{"id": HEALTH_FIXTURE_EVENT, "type": "events", "order": 0}]}, + "math": GroupUsageMetric.Math.COUNT, + "math_property": None, + }, + ) + selected_metric_ids = [ + selected_metric.id + for selected_metric, _ in AccountHealthScorer( + team=team, + timings=HogQLTimings(), + modifiers=None, + user=None, + ).eligible_metrics + ] + if metric.id not in selected_metric_ids: + raise CommandError( + "The health fixture metric is outside the first five usage metrics. Remove or rename earlier metrics, then rerun." + ) + + now = datetime.now(UTC) + events: list[dict[str, Any]] = [] + counts_by_external_id = { + external_id: (current_count, previous_count) + for external_id, _, current_count, previous_count in HEALTH_FIXTURE_ACCOUNTS + } + for account in accounts: + current_count, previous_count = counts_by_external_id[account.external_id or ""] + for period, count, timestamp in ( + ("current", current_count, now - timedelta(days=15)), + ("previous", previous_count, now - timedelta(days=45)), + ): + for index in range(count): + events.append( + { + "event": HEALTH_FIXTURE_EVENT, + "distinct_id": f"health-fixture-{account.external_id}", + "timestamp": timestamp + timedelta(seconds=index), + "properties": {"$groups": {group_type: account.external_id}}, + "event_uuid": str( + uuid5( + NAMESPACE_URL, + f"posthog:health-fixture:v2:{team.id}:{account.external_id}:{period}:{index}", + ) + ), + } + ) + capture_batch_internal( + events=events, + token=team.api_token, + event_source="seed_customer_analytics_accounts", + historical_migration=True, + process_person_profile=True, + ).raise_for_status() + self.stdout.write( + f"Sent {len(events)} health fixture events. Scores appear after historical ingestion catches up." + ) + def _paragraph_doc(text: str) -> dict[str, Any]: return {"type": "doc", "content": [{"type": "paragraph", "content": [{"type": "text", "text": text}]}]} diff --git a/products/customer_analytics/backend/management/commands/test/test_seed_customer_analytics_accounts.py b/products/customer_analytics/backend/management/commands/test/test_seed_customer_analytics_accounts.py index 8cbf8dc03b87..d7ffd20a9430 100644 --- a/products/customer_analytics/backend/management/commands/test/test_seed_customer_analytics_accounts.py +++ b/products/customer_analytics/backend/management/commands/test/test_seed_customer_analytics_accounts.py @@ -1,12 +1,14 @@ +from datetime import UTC, datetime, timedelta from io import StringIO import pytest from posthog.test.base import BaseTest +from unittest.mock import Mock, patch from django.core.management import call_command from django.core.management.base import CommandError -from posthog.models import OrganizationMembership, User +from posthog.models import GroupUsageMetric, OrganizationMembership, User from posthog.persons_db import persons_db_connection from posthog.persons_seed import insert_seed_group @@ -101,6 +103,55 @@ def test_is_idempotent(self): ) assert ResourceNotebook.objects.filter(account__team_id=self.team.pk).count() == 2 + @patch( + "products.customer_analytics.backend.management.commands.seed_customer_analytics_accounts.capture_batch_internal" + ) + @patch( + "products.customer_analytics.backend.management.commands.seed_customer_analytics_accounts.get_group_types_for_project", + return_value=[{"group_type": "organization", "group_type_index": 0}], + ) + def test_seeds_deterministic_health_examples( + self, get_group_types_for_project_mock: Mock, capture_batch_internal_mock: Mock + ) -> None: + capture_batch_internal_mock.return_value.raise_for_status.return_value = None + self._make_group("acme-id", "Acme") + + self._run(users=1, accounts_with_notes=0, health_fixtures=True) + + accounts = self._accounts() + assert { + "health-example-healthy", + "health-example-neutral", + "health-example-at-risk", + "health-example-no-data", + }.issubset(accounts) + metric = GroupUsageMetric.objects.get(team=self.team, name="Account health fixture") + assert metric.filters == {"events": [{"id": "customer analytics health fixture", "type": "events", "order": 0}]} + events = capture_batch_internal_mock.call_args.kwargs["events"] + current_counts: dict[str, int] = {} + previous_counts: dict[str, int] = {} + current_period_start = datetime.now(UTC) - timedelta(days=30) + for event in events: + account_id = event["properties"]["$groups"]["organization"] + period_counts = current_counts if event["timestamp"] >= current_period_start else previous_counts + period_counts[account_id] = period_counts.get(account_id, 0) + 1 + assert current_counts == { + "health-example-healthy": 9, + "health-example-neutral": 6, + "health-example-at-risk": 3, + } + assert previous_counts == { + "health-example-healthy": 10, + "health-example-neutral": 10, + "health-example-at-risk": 10, + } + assert len(events) == 48 + assert len({event["event_uuid"] for event in events}) == 48 + assert capture_batch_internal_mock.call_args.kwargs["process_person_profile"] is True + get_group_types_for_project_mock.assert_called_once_with( + self.team.project_id, caller_tag="seed_customer_analytics_accounts" + ) + def test_dry_run_writes_nothing(self): self._make_group("acme-id", "Acme") diff --git a/products/customer_analytics/frontend/components/Accounts/AGENTS.md b/products/customer_analytics/frontend/components/Accounts/AGENTS.md index eaeb56eebb2b..331d5522f32c 100644 --- a/products/customer_analytics/frontend/components/Accounts/AGENTS.md +++ b/products/customer_analytics/frontend/components/Accounts/AGENTS.md @@ -25,7 +25,8 @@ AccountsTabContent ── binds dataNodeLogic(ACCOUNTS_TABLE_DATA_NODE_KEY, acc │ AccountsOverviewTilesButton + AccountsColumnConfigurator on the right ├── AccountsOverviewTiles metric tiles across the filtered set └── AccountsTable the DataTable; keyed row renderers; controlled row expansion - └── AccountNotebooksExpansion expanded row: sidebar (Useful links + active-relationships summary) + LemonTabs(Notes/Users/Relationships/Usage/Spend/Opportunities/Summaries/Support tickets/Email threads/Meetings/Event stream) + └── AccountNotebooksExpansion expanded row: sidebar (Useful links + active-relationships summary) + LemonTabs(Health/Notes/Users/Relationships/Usage/Spend/Opportunities/Summaries/Support tickets/Email threads/Meetings/Event stream) + ├── (health) AccountHealthDetails (deterministic 30-day retained-usage score and factor breakdown from the table row) ├── (notes) paginated/searchable/sortable LemonTable + "New note" button (accountNotebooksLogic, keyed by accountId) ├── (users) AccountRelatedUsersExpansion (accountRelatedUsersLogic, keyed by externalId) ├── (relationships) AccountRelationshipsExpansion (accountRelationshipsLogic, keyed by accountId — full assignment timeline, paginated; assign/end controls + definition filter, current assignments sorted on top) @@ -66,6 +67,7 @@ AccountsTabContent ── binds dataNodeLogic(ACCOUNTS_TABLE_DATA_NODE_KEY, acc Overview tiles run as a separate metrics-only `AccountsTableQuery` (`metrics` set and `columns: []`; `null` when there are no tiles or while definitions are loading). Count, sum, average, minimum, maximum, median, scaling, and threshold counts execute in Postgres. The "My accounts" checkbox resolves to the current user's explicit ID before either query is built, so shared URLs remain viewer-independent. Two cell shapes matter: - **`name` column** (mandatory, `ACCOUNTS_NAME_COLUMN`) — read directly from the keyed row's `id`, `name`, and `externalId`. This is the row's identity: `id` (the account PK) drives expansion/scroll/role updates; `externalId` is the copy-able group key. `getNameCell()` in `AccountsTable.tsx` is the canonical accessor. In `NameCell` the name renders as the prominent (`font-semibold`) primary label: a plain click opens the account details inline (toggles the row's expansion via `accountsExpansionLogic`, firing `AccountOpened` on open), while the `` href points at `urls.customerAnalyticsAccount(id)` so cmd/ctrl-click opens the account's deep-link page in a new tab. The `external_id` sits beneath it as de-emphasized (`text-xs text-muted`) copy-to-clipboard text. +- **`health` column** (fixed after the name) — read from `AccountsTableRow.health`, not from the configurable Postgres column list. The backend scores only the access-filtered page in one ClickHouse query, using up to five event usage metrics over the current and previous 30 days. The cell opens the Health expansion tab, which shows every factor and contribution. Health is deliberately unavailable to filtering and sorting because page-bounded calculation cannot produce a globally correct order across pagination. - **relationship columns** — returned in `AccountsTableRow.relationships` as arrays of active assignee user ids keyed by definition id (`[]` when unassigned). The legacy names `csm`/`account_executive`/`account_owner` remain stored in defaults, saved views, and URL state, then translate into typed relationship columns matched by seeded definition name (`LEGACY_ROLE_COLUMNS`); a legacy column with no matching definition is dropped from the query. Other definitions are pickable from the "Relationships" column group with opaque `rel_` aliases, resolved back via `aliasToRelationshipDefinition`. Cells render `RelationshipCell`: single-holder definitions get an editable `MemberSelect` (writes via the relationships assign/end endpoints — assign auto-ends the previous holder server-side); multi-holder definitions render read-only. User ids resolve to members via `membersLogic` (loaded up front in `accountsLogic.afterMount`). Sorting uses the alias directly (arrays of ids — deterministic but not email order). The `tag_names` cell is editable in place: `TagsCell` renders the editable `ObjectTags` variant (available tags from `tagsModel`), saving through the account PATCH via `accountsLogic.updateAccountTags` — optimistic per-account override (`tagOverrides`, masking the stale fetched cell like `relationshipOverrides`), debounced full-list PATCH (`TAGS_SAVE_DEBOUNCE_MS`, since `ObjectTags` fires per added/removed tag), `savingTags` guard, revert + toast + captured exception on failure. Clicking a tag in the cell adds it to the tags filter, compounding with tags already filtered (`addTagToFilter` — dedupes, and reports through `reportFilterChange('tag')` like the filter control). @@ -110,7 +112,7 @@ Sort safety: removing the sorted column drops the sort (`clearSortIfColumnRemove ## The expanded row -`AccountsTable.useExpandable()` makes expansion **controlled** by `accountsExpansionLogic`: `isRowExpanded` reads `expandedAccountIds`, `onRowExpand`/`onRowCollapse` dispatch `toggleAccountExpanded`. The body is `AccountNotebooksExpansion`, a `LemonTabs` over `notes` / `users` / `relationships` / `usage` / `spend` / `opportunities` / `summaries` / `support_tickets` / `email_threads` / `meetings` / `event_stream` (`AccountExpansionTab`; the `event_stream` tab is gated by the `CUSTOMER_ANALYTICS_CSP` flag) plus the sidebar (Useful links + an active-relationships summary, `ActiveRelationships`, hidden when nothing is assigned). Active tab comes from `activeTabFor(accountId)` (defaults to `notes`). +`AccountsTable.useExpandable()` makes expansion **controlled** by `accountsExpansionLogic`: `isRowExpanded` reads `expandedAccountIds`, `onRowExpand`/`onRowCollapse` dispatch `toggleAccountExpanded`. The body is `AccountNotebooksExpansion`, a `LemonTabs` over `health` / `notes` / `users` / `relationships` / `usage` / `spend` / `opportunities` / `summaries` / `support_tickets` / `email_threads` / `meetings` / `event_stream` (`AccountExpansionTab`; the `event_stream` tab is gated by the `CUSTOMER_ANALYTICS_CSP` flag) plus the sidebar (Useful links + an active-relationships summary, `ActiveRelationships`, hidden when nothing is assigned). Active tab comes from `activeTabFor(accountId)` (defaults to `notes`). The Health tab receives its score from the table row, so opening it does not issue another health query. **Tab data is cached for the row's expanded lifetime, not refetched per tab switch.** `LemonTabs` only renders the active tab's content (keyed by `activeKey`), so a tab's logic would normally unmount the moment you switch away and refetch on return. To avoid that, `AccountNotebooksExpansion` holds a mount reference to each per-tab logic via `useMountedLogic` — `accountRelatedUsersLogic({ externalId })`, `accountRelationshipsLogic({ accountId })`, `accountBillingLogic` for both `kind: 'usage'` and `kind: 'spend'`, `accountOpportunitiesLogic({ accountId })`, `accountSummariesLogic({ accountId })`, `accountSupportTicketsLogic({ accountId })`, `accountEmailThreadsLogic({ accountId })`, and `accountMeetingsLogic({ accountId })`. Since `AccountNotebooksExpansion` stays mounted for as long as the row is expanded (it's the `expandedRowRender` body), those keyed instances survive tab switches and only tear down when the row collapses. The `useMountedLogic` props must stay identical to the `useValues` props in the tab components (`AccountRelatedUsersExpansion`, `AccountRelationshipsExpansion`, `AccountBillingExpansion`, `AccountOpportunitiesExpansion`, `AccountSummariesExpansion`, `AccountSupportTicketsExpansion`, `AccountEmailThreadsExpansion`, `AccountMeetingsExpansion`) so both resolve to the same keyed instance. `accountNotebooksLogic` and `accountLinksLogic` already mount at this level (Notes content and the always-rendered sidebar), so they need no explicit mount reference. Data is loaded once on mount and not auto-refreshed while the row stays open — acceptable since this data needn't be live. @@ -234,6 +236,7 @@ The tool is registered for the page regardless of agent mode. The Customer analy - `products/customer_analytics/backend/models` — the `Account` model (`external_id` = group key). - `products/customer_analytics/backend/hogql_queries/accounts_query_runner.py` — the legacy generic Accounts query runner used by consumers outside the Accounts list. - `products/customer_analytics/backend/hogql_queries/accounts_table_query_runner.py` — the Postgres-only `AccountsTableQuery` runner. It returns keyed rows and applies typed list filters, sorting, and overview metrics. +- `products/customer_analytics/backend/hogql_queries/account_health.py` — deterministic page-bounded account health scoring. It batches the selected page's external IDs and event usage metrics into one ClickHouse query; it does not own account lookup or access control. - `products/customer_analytics/backend/max_tools/` — `OpenAccountTool` and other account Max tools. - `ee/hogai/core/agent_modes/presets/customer_analytics.py` — the Customer analytics agent mode (gated by the `customer-analytics-csp` flag). @@ -244,7 +247,7 @@ Use a full devbox stack when checking account tabs against real product routing 1. Check out the feature branch on the devbox and wait for the app stack. 2. Run `python manage.py sync_feature_flags`. This creates local flags from `frontend/src/lib/constants.tsx`, including `customer-analytics-roadmap`; `sync_feature_flags_from_api` alone does not create that local-only gate. 3. Configure group analytics at index 0 before seeding. The standard demo workspace already maps `project` to index 0; verify with `get_group_types_for_project` when using another workspace. -4. Run `python manage.py seed_customer_analytics_accounts --team-id `. It creates accounts from index-0 groups, configures Customer Analytics for that group type, and adds sample relationships and notes. +4. Run `python manage.py seed_customer_analytics_accounts --team-id --health-fixtures`. It creates accounts from index-0 groups, configures Customer analytics for that group type, adds sample relationships and notes, and sends deterministic healthy, neutral, at-risk, and no-data health examples through historical ingestion. 5. Restart the backend after syncing flags so local flag evaluation reloads them. 6. Open `/project//customer_analytics/accounts` and confirm the seeded group appears as an account. @@ -270,48 +273,48 @@ We track user actions on the Accounts list with `posthog.capture()`. Conventions ### Tracked events -| Event | Fires from | Properties | -| -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `customer analytics accounts list viewed` | `accountsLogic` `afterMount` | _(none — funnel anchor; account count and saved-config aren't loaded at mount)_ | -| `customer analytics accounts filter changed` | `accountsLogic` `reportFilterChange` listener (dispatched by the filter controls) | `filter_type` (`tag` \| `unassigned_only` \| `my_accounts` \| `assigned_to` \| `custom_property`), `value`, `is_cleared`, `active_filter_count`; for `tag` also `tag_count`; for `assigned_to` also `role_count`; for `custom_property` only `filter_count` + `is_cleared` (no `value` — property values can carry customer data) | -| `customer analytics accounts searched` | `accountsLogic` `setSearchInput` listener (post-debounce) | `query_length`, `has_query`, `active_filter_count` | -| `customer analytics accounts refreshed` | `accountsLogic` `refresh` | `has_search`, `active_filter_count`, `sort_column` | -| `customer analytics accounts sorted` | `accountsLogic` `toggleSort` | `column`, `direction` (`asc` \| `desc` \| `cleared`) | -| `customer analytics accounts account opened` | `AccountsTable.tsx` `NameCell` account-name `` `onClick` (only when the row expands, not when it collapses) | _(none — the row's `id` is customer PII-adjacent and kept out)_ | -| ~~`customer analytics accounts columns saved`~~ | _Deprecated — no longer emitted. Column changes are now saved as part of a view (see view events below)._ | _n/a_ | -| `customer analytics accounts overview tiles edited` | `accountsOverviewTilesLogic` editor close (diffed vs open snapshot, only when changed) | `tiles_added`, `tiles_removed`, `tiles_updated`, `reordered`, `tile_count_before`, `tile_count_after` | -| `customer analytics accounts overview tiles localstorage read` | `accountsOverviewTilesLogic` `afterMount` (only when a legacy custom value exists) | `tile_count` — **tombstone**: a legacy localStorage tiles value was read; once this stops firing, no browser still carries one and the read path can be removed | -| `customer analytics accounts view saved` | `accountsViewsLogic` `submitNewViewForm` listener (success) | `visibility` (`private` \| `shared`) | -| `customer analytics accounts view updated` | `accountsViewsLogic` `updateViewSuccess` listener | _(none)_ | -| `customer analytics accounts view selected` | `accountsViewsLogic` `applyView` listener | `visibility` | -| `customer analytics accounts view deleted` | `accountsViewsLogic` `deleteViewSuccess` listener | _(none)_ | -| `customer analytics account role assigned` | `accountsLogic` `updateAccountRole` | `role` (`csm` \| `account_executive` \| `account_owner` for the seeded definitions, else the definition name), `is_assigned`, `assigned_user_id`, `source` (`list_row` from `accountsLogic.updateAccountRole` \| `relationships_tab` from `accountRelationshipsLogic` assign/end) | -| `customer analytics account tags updated` | `accountsLogic` `updateAccountTags` listener (after the debounced PATCH succeeds; dispatched by the tags cell editor) | `tag_count` (tag names kept out — they can name customers) | -| `customer analytics account link clicked` | `AccountNotebooksExpansion.tsx` useful-link `onClick` (incl. the "Copy link to account" row, `link_key: 'copy-account-link'`) | `link_key`, `has_destination` | -| `customer analytics account note clicked` | `AccountNotebooksExpansion.tsx` note `` `onClick` | `notebook_short_id` | -| `customer analytics accounts note created` | `accountNotebooksLogic` `createNoteSuccess` listener | `notebook_short_id` | -| `customer analytics accounts notes searched` | `accountNotebooksLogic` `setSearchTerm` listener (post-debounce) | `has_query`, `query_length` (no raw text) | -| `customer analytics accounts notes sorted` | `accountNotebooksLogic` `setSorting` listener | `column` (`created_at` \| `created_by` \| null), `direction` (`asc` \| `desc` \| `cleared`) | -| `customer analytics account tab viewed` | `accountsExpansionLogic` `setActiveTab` listener (genuine tab clicks only; programmatic `openAccountTab` navigation does not fire it) | `tab` (`notes` \| `users` \| `relationships` \| `usage` \| `spend` \| `opportunities` \| `summaries` \| `support_tickets` \| `email_threads` \| `meetings` \| `event_stream`) | -| `customer analytics account usage series toggled` | `accountBillingLogic` `toggleHiddenSeriesKey` listener (dispatched by the `AccountBillingSeriesToggle` chips) | `kind` (`usage` \| `spend`), `is_hidden` (the post-click state), `series_count` (no series label / no PII) | -| `customer analytics account usage series bulk toggled` | `accountBillingLogic` `setAllSeriesHidden` listener (dispatched by the Select all / Clear all buttons in `AccountBillingSeriesToggle`) | `kind` (`usage` \| `spend`), `is_hidden` (`true` when hiding all, `false` when showing all), `series_count` (no series label / no PII) | -| `customer analytics account related user clicked` | `AccountRelatedUsersExpansion.tsx` user `` `onClick` | _(none — customer end-user PII kept out)_ | -| `customer analytics account opportunity clicked` | `AccountOpportunitiesExpansion.tsx` opportunity name `` `onClick` | _(none — CRM record id/url kept out)_ | -| `customer analytics account summary cadence changed` | `accountSummariesLogic` `setCadence` listener (after the PATCH succeeds) | `cadence` (`daily` \| `weekly` \| `monthly` \| `off`) | -| `customer analytics account summary expanded` | `accountSummariesLogic` `toggleSummaryExpanded` listener (only when the toggle opens a summary, not on collapse) | _(none — summary content/period is customer data and kept out)_ | -| `customer analytics account summaries page changed` | `accountSummariesLogic` `loadSummariesPage` listener (only dispatched by the pagination controls) | `page` | -| `customer analytics account support ticket clicked` | `AccountSupportTicketsExpansion.tsx` ticket `` `onClick` | _(none — ticket number/customer content kept out)_ | -| `customer analytics account meeting matching saved` | `accountMeetingsLogic` `saveMatching` listener (after the PATCH succeeds) | `domain_count`, `email_count` (no raw domains/emails — customer PII kept out) | -| `customer analytics account meetings searched` | `accountMeetingsLogic` `setSearchTerm` listener (post-debounce) | `has_query`, `query_length` (no raw text) | -| `customer analytics account meeting attendee clicked` | `AccountMeetingsExpansion.tsx` attendee `` `onClick` (person links only) | _(none — attendee email/person id is customer PII and kept out)_ | -| `customer analytics account event stream toggled` | `eventStreamLogic` (`../EventStream/`) `setAccountMembership` listener — the "Include in event stream" toggle in the expanded row's Event stream tab | `account_id`, `included`, `member_count` | -| `customer analytics event stream config saved` | `eventStreamLogic` `saveEventStreamSuccess` listener — the Save button in the Event stream settings section | `enabled`, `event_count`, `has_slack_channel`, `member_count` | -| `customer analytics event stream test message sent` | `eventStreamLogic` `sendTestMessageSuccess` listener — the "Send test message" button in the Event stream settings section | `event_count`, `member_count` | -| `customer analytics notes tab viewed` | `accountNotesLogic` (`../AccountNotes/`) `afterMount` — the top-level Notes tab, not the per-account expansion | _(none — funnel anchor)_ | -| `customer analytics notes tab searched` | `accountNotesLogic` `setSearch` listener (post-debounce) | `has_query`, `query_length` (no raw text) | -| `customer analytics notes tab filtered` | `accountNotesLogic` `reportFilterChange` listener (dispatched by the filter controls only, so the "My notes"/"My accounts" cascade doesn't double-fire) | `filter_type` (`created_by` \| `account` \| `my_notes` \| `my_accounts`), `is_cleared`; for `created_by` also `user_count`; for `my_notes`/`my_accounts` also `value` (no account id/name logged) | -| `customer analytics notes tab note clicked` | `AccountNotesTabContent.tsx` note title `` `onClick` (opens side panel) | `notebook_short_id` | -| `customer analytics notes tab account clicked` | `AccountNotesTabContent.tsx` account `` `onClick` | `account_id` | +| Event | Fires from | Properties | +| -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `customer analytics accounts list viewed` | `accountsLogic` `afterMount` | _(none — funnel anchor; account count and saved-config aren't loaded at mount)_ | +| `customer analytics accounts filter changed` | `accountsLogic` `reportFilterChange` listener (dispatched by the filter controls) | `filter_type` (`tag` \| `unassigned_only` \| `my_accounts` \| `assigned_to` \| `custom_property`), `value`, `is_cleared`, `active_filter_count`; for `tag` also `tag_count`; for `assigned_to` also `role_count`; for `custom_property` only `filter_count` + `is_cleared` (no `value` — property values can carry customer data) | +| `customer analytics accounts searched` | `accountsLogic` `setSearchInput` listener (post-debounce) | `query_length`, `has_query`, `active_filter_count` | +| `customer analytics accounts refreshed` | `accountsLogic` `refresh` | `has_search`, `active_filter_count`, `sort_column` | +| `customer analytics accounts sorted` | `accountsLogic` `toggleSort` | `column`, `direction` (`asc` \| `desc` \| `cleared`) | +| `customer analytics accounts account opened` | `AccountsTable.tsx` `NameCell` account-name `` `onClick` (only when the row expands, not when it collapses) | _(none — the row's `id` is customer PII-adjacent and kept out)_ | +| ~~`customer analytics accounts columns saved`~~ | _Deprecated — no longer emitted. Column changes are now saved as part of a view (see view events below)._ | _n/a_ | +| `customer analytics accounts overview tiles edited` | `accountsOverviewTilesLogic` editor close (diffed vs open snapshot, only when changed) | `tiles_added`, `tiles_removed`, `tiles_updated`, `reordered`, `tile_count_before`, `tile_count_after` | +| `customer analytics accounts overview tiles localstorage read` | `accountsOverviewTilesLogic` `afterMount` (only when a legacy custom value exists) | `tile_count` — **tombstone**: a legacy localStorage tiles value was read; once this stops firing, no browser still carries one and the read path can be removed | +| `customer analytics accounts view saved` | `accountsViewsLogic` `submitNewViewForm` listener (success) | `visibility` (`private` \| `shared`) | +| `customer analytics accounts view updated` | `accountsViewsLogic` `updateViewSuccess` listener | _(none)_ | +| `customer analytics accounts view selected` | `accountsViewsLogic` `applyView` listener | `visibility` | +| `customer analytics accounts view deleted` | `accountsViewsLogic` `deleteViewSuccess` listener | _(none)_ | +| `customer analytics account role assigned` | `accountsLogic` `updateAccountRole` | `role` (`csm` \| `account_executive` \| `account_owner` for the seeded definitions, else the definition name), `is_assigned`, `assigned_user_id`, `source` (`list_row` from `accountsLogic.updateAccountRole` \| `relationships_tab` from `accountRelationshipsLogic` assign/end) | +| `customer analytics account tags updated` | `accountsLogic` `updateAccountTags` listener (after the debounced PATCH succeeds; dispatched by the tags cell editor) | `tag_count` (tag names kept out — they can name customers) | +| `customer analytics account link clicked` | `AccountNotebooksExpansion.tsx` useful-link `onClick` (incl. the "Copy link to account" row, `link_key: 'copy-account-link'`) | `link_key`, `has_destination` | +| `customer analytics account note clicked` | `AccountNotebooksExpansion.tsx` note `` `onClick` | `notebook_short_id` | +| `customer analytics accounts note created` | `accountNotebooksLogic` `createNoteSuccess` listener | `notebook_short_id` | +| `customer analytics accounts notes searched` | `accountNotebooksLogic` `setSearchTerm` listener (post-debounce) | `has_query`, `query_length` (no raw text) | +| `customer analytics accounts notes sorted` | `accountNotebooksLogic` `setSorting` listener | `column` (`created_at` \| `created_by` \| null), `direction` (`asc` \| `desc` \| `cleared`) | +| `customer analytics account tab viewed` | `accountsExpansionLogic` `setActiveTab` listener for tab clicks, or `AccountHealthCell` when its score link opens Health; other programmatic `openAccountTab` navigation does not fire it | `tab` (`health` \| `notes` \| `users` \| `relationships` \| `usage` \| `spend` \| `opportunities` \| `summaries` \| `support_tickets` \| `email_threads` \| `meetings` \| `event_stream`) | +| `customer analytics account usage series toggled` | `accountBillingLogic` `toggleHiddenSeriesKey` listener (dispatched by the `AccountBillingSeriesToggle` chips) | `kind` (`usage` \| `spend`), `is_hidden` (the post-click state), `series_count` (no series label / no PII) | +| `customer analytics account usage series bulk toggled` | `accountBillingLogic` `setAllSeriesHidden` listener (dispatched by the Select all / Clear all buttons in `AccountBillingSeriesToggle`) | `kind` (`usage` \| `spend`), `is_hidden` (`true` when hiding all, `false` when showing all), `series_count` (no series label / no PII) | +| `customer analytics account related user clicked` | `AccountRelatedUsersExpansion.tsx` user `` `onClick` | _(none — customer end-user PII kept out)_ | +| `customer analytics account opportunity clicked` | `AccountOpportunitiesExpansion.tsx` opportunity name `` `onClick` | _(none — CRM record id/url kept out)_ | +| `customer analytics account summary cadence changed` | `accountSummariesLogic` `setCadence` listener (after the PATCH succeeds) | `cadence` (`daily` \| `weekly` \| `monthly` \| `off`) | +| `customer analytics account summary expanded` | `accountSummariesLogic` `toggleSummaryExpanded` listener (only when the toggle opens a summary, not on collapse) | _(none — summary content/period is customer data and kept out)_ | +| `customer analytics account summaries page changed` | `accountSummariesLogic` `loadSummariesPage` listener (only dispatched by the pagination controls) | `page` | +| `customer analytics account support ticket clicked` | `AccountSupportTicketsExpansion.tsx` ticket `` `onClick` | _(none — ticket number/customer content kept out)_ | +| `customer analytics account meeting matching saved` | `accountMeetingsLogic` `saveMatching` listener (after the PATCH succeeds) | `domain_count`, `email_count` (no raw domains/emails — customer PII kept out) | +| `customer analytics account meetings searched` | `accountMeetingsLogic` `setSearchTerm` listener (post-debounce) | `has_query`, `query_length` (no raw text) | +| `customer analytics account meeting attendee clicked` | `AccountMeetingsExpansion.tsx` attendee `` `onClick` (person links only) | _(none — attendee email/person id is customer PII and kept out)_ | +| `customer analytics account event stream toggled` | `eventStreamLogic` (`../EventStream/`) `setAccountMembership` listener — the "Include in event stream" toggle in the expanded row's Event stream tab | `account_id`, `included`, `member_count` | +| `customer analytics event stream config saved` | `eventStreamLogic` `saveEventStreamSuccess` listener — the Save button in the Event stream settings section | `enabled`, `event_count`, `has_slack_channel`, `member_count` | +| `customer analytics event stream test message sent` | `eventStreamLogic` `sendTestMessageSuccess` listener — the "Send test message" button in the Event stream settings section | `event_count`, `member_count` | +| `customer analytics notes tab viewed` | `accountNotesLogic` (`../AccountNotes/`) `afterMount` — the top-level Notes tab, not the per-account expansion | _(none — funnel anchor)_ | +| `customer analytics notes tab searched` | `accountNotesLogic` `setSearch` listener (post-debounce) | `has_query`, `query_length` (no raw text) | +| `customer analytics notes tab filtered` | `accountNotesLogic` `reportFilterChange` listener (dispatched by the filter controls only, so the "My notes"/"My accounts" cascade doesn't double-fire) | `filter_type` (`created_by` \| `account` \| `my_notes` \| `my_accounts`), `is_cleared`; for `created_by` also `user_count`; for `my_notes`/`my_accounts` also `value` (no account id/name logged) | +| `customer analytics notes tab note clicked` | `AccountNotesTabContent.tsx` note title `` `onClick` (opens side panel) | `notebook_short_id` | +| `customer analytics notes tab account clicked` | `AccountNotesTabContent.tsx` account `` `onClick` | `account_id` | > **Keep this table up to date.** Whenever you add, rename, or remove a `posthog.capture()` event in the Accounts area — or change its properties — update this table in the same change. An agent reading this file should be able to trust it as the source of truth for what the Accounts list reports. diff --git a/products/customer_analytics/frontend/components/Accounts/AccountHealth.test.tsx b/products/customer_analytics/frontend/components/Accounts/AccountHealth.test.tsx new file mode 100644 index 000000000000..9af66ef131c7 --- /dev/null +++ b/products/customer_analytics/frontend/components/Accounts/AccountHealth.test.tsx @@ -0,0 +1,101 @@ +import '@testing-library/jest-dom' + +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { Provider } from 'kea' + +import type { AccountHealthScore } from '~/queries/schema/schema-general' +import { initKeaTests } from '~/test/init' + +import { AccountHealthCell } from './AccountHealthCell' +import { AccountHealthDetails } from './AccountHealthDetails' +import { accountsExpansionLogic } from './accountsExpansionLogic' + +const BASE_HEALTH: AccountHealthScore = { + score: 80, + status: 'healthy', + isLimited: false, + lookbackDays: 30, + previousPeriodStart: '2026-03-22T00:00:00Z', + currentPeriodStart: '2026-04-21T00:00:00Z', + computedAt: '2026-05-21T00:00:00Z', + factors: [ + { + metricId: 'metric-events', + metricName: 'Events ingested', + current: 80, + previous: 100, + normalizedScore: 80, + contribution: 80, + }, + ], +} + +type HealthStatus = AccountHealthScore['status'] + +const HEALTH_CASES: [HealthStatus, number | null, string][] = [ + ['healthy', 80, 'Healthy'], + ['neutral', 60, 'Neutral'], + ['at_risk', 30, 'At risk'], + ['no_data', null, 'No data'], +] + +describe('AccountHealth', () => { + beforeEach(() => { + initKeaTests() + accountsExpansionLogic.mount() + }) + + afterEach(() => { + cleanup() + accountsExpansionLogic.unmount() + }) + + it.each(HEALTH_CASES)('renders an accessible %s state', (status, score, label) => { + render( + + + + ) + + expect(screen.getByLabelText(new RegExp(label))).toBeInTheDocument() + }) + + it('opens the health explanation from the table cell', () => { + render( + + + + ) + + fireEvent.click(screen.getByLabelText('Health score: 80 out of 100, Healthy. View calculation.')) + + expect(accountsExpansionLogic.values.isAccountExpanded('account-id')).toBe(true) + expect(accountsExpansionLogic.values.activeTabFor('account-id')).toBe('health') + }) + + it('explains the calculation and a limited baseline', () => { + render( + + ) + + expect(screen.getByText('mean(80) = 80')).toBeInTheDocument() + expect(screen.getByText(/limited baseline/)).toBeInTheDocument() + expect(screen.getAllByText('Excluded')).toHaveLength(2) + }) +}) diff --git a/products/customer_analytics/frontend/components/Accounts/AccountHealthCell.tsx b/products/customer_analytics/frontend/components/Accounts/AccountHealthCell.tsx new file mode 100644 index 000000000000..930d85b12efa --- /dev/null +++ b/products/customer_analytics/frontend/components/Accounts/AccountHealthCell.tsx @@ -0,0 +1,52 @@ +import { useActions, useValues } from 'kea' +import posthog from 'posthog-js' + +import { LemonTag } from '@posthog/lemon-ui' + +import { Link } from 'lib/lemon-ui/Link' +import { urls } from 'scenes/urls' + +import type { AccountHealthScore } from '~/queries/schema/schema-general' + +import { ACCOUNT_HEALTH_STATUS_LABEL, accountHealthTagType } from './accountHealth' +import { accountsExpansionLogic } from './accountsExpansionLogic' +import { AccountsEvents } from './constants' + +export function AccountHealthCell({ + accountId, + health, +}: { + accountId: string + health: AccountHealthScore +}): JSX.Element { + const { isAccountExpanded } = useValues(accountsExpansionLogic) + const { openAccountTab } = useActions(accountsExpansionLogic) + const statusLabel = ACCOUNT_HEALTH_STATUS_LABEL[health.status] + const scoreLabel = health.score === null ? 'No score' : `${health.score} out of 100` + + return ( + { + if (event.metaKey || event.ctrlKey || event.shiftKey) { + return + } + event.preventDefault() + event.stopPropagation() + if (!isAccountExpanded(accountId)) { + posthog.capture(AccountsEvents.AccountOpened) + } + openAccountTab(accountId, 'health') + posthog.capture(AccountsEvents.TabViewed, { tab: 'health' }) + }} + > + {health.score === null ? 'No score' : health.score} + + {statusLabel} + + + ) +} diff --git a/products/customer_analytics/frontend/components/Accounts/AccountHealthDetails.tsx b/products/customer_analytics/frontend/components/Accounts/AccountHealthDetails.tsx new file mode 100644 index 000000000000..4f414a073508 --- /dev/null +++ b/products/customer_analytics/frontend/components/Accounts/AccountHealthDetails.tsx @@ -0,0 +1,131 @@ +import { LemonBanner, LemonTable, LemonTableColumns, LemonTag, Link } from '@posthog/lemon-ui' + +import { TZLabel } from 'lib/components/TZLabel' +import { humanFriendlyNumber } from 'lib/utils/numbers' +import { urls } from 'scenes/urls' + +import type { AccountHealthFactor, AccountHealthScore } from '~/queries/schema/schema-general' + +import { ACCOUNT_HEALTH_NO_DATA_MESSAGE, ACCOUNT_HEALTH_STATUS_LABEL, accountHealthTagType } from './accountHealth' + +const USAGE_METRICS_URL = `${urls.customerAnalyticsConfiguration()}?tab=customer-analytics-usage-metrics` + +function formatMetricValue(value: number): string { + return humanFriendlyNumber(value, 2) +} + +export function AccountHealthDetails({ health }: { health: AccountHealthScore }): JSX.Element { + if (health.status === 'no_data') { + const message = health.noDataReason + ? ACCOUNT_HEALTH_NO_DATA_MESSAGE[health.noDataReason] + : 'There is not enough usage data to calculate a score.' + return ( +
+
+

Account health

+ No data +
+

{message}

+ {health.noDataReason === 'no_metrics' ? Set up usage metrics : null} +
+ ) + } + + const columns: LemonTableColumns = [ + { + title: 'Usage metric', + key: 'metricName', + render: (_, factor) => {factor.metricName}, + }, + { + title: `Current ${health.lookbackDays} days`, + key: 'current', + align: 'right', + render: (_, factor) => {formatMetricValue(factor.current)}, + }, + { + title: `Previous ${health.lookbackDays} days`, + key: 'previous', + align: 'right', + render: (_, factor) => {formatMetricValue(factor.previous)}, + }, + { + title: 'Normalized', + key: 'normalizedScore', + align: 'right', + render: (_, factor) => ( + + {factor.normalizedScore == null ? 'Excluded' : `${factor.normalizedScore}/100`} + + ), + }, + { + title: 'Contribution', + key: 'contribution', + align: 'right', + render: (_, factor) => ( + + {factor.contribution == null ? 'Excluded' : `+${factor.contribution.toFixed(1)}`} + + ), + }, + ] + + const includedScores = health.factors + .map((factor) => factor.normalizedScore) + .filter((score): score is number => typeof score === 'number') + const formula = `mean(${includedScores.join(', ')}) = ${health.score}` + const statusLabel = ACCOUNT_HEALTH_STATUS_LABEL[health.status] + + return ( +
+
+
+ {health.score} +
+
+

Account health

+ {statusLabel} +
+ + Current period started . Compared with the prior{' '} + {health.lookbackDays} days. + +
+
+ + Calculated + +
+ {health.isLimited ? ( + + This score has a limited baseline because at least one metric is new or has no activity in one + period. + + ) : null} +

+ Each metric scores current usage divided by previous usage, capped at 100. Metrics with no activity in + either period are excluded. Available metrics have equal weight. +

+ +
+ {formula} + + {statusLabel} because the score is{' '} + {health.status === 'healthy' + ? '80 or higher.' + : health.status === 'neutral' + ? 'between 50 and 79.' + : 'below 50.'} + +
+
+ ) +} diff --git a/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx b/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx index b3282cb7b0b3..e5dfb3545178 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx @@ -32,6 +32,8 @@ import { fullName } from 'lib/utils/strings' import { notebookPanelLogic } from 'scenes/notebooks/NotebookPanel/notebookPanelLogic' import { urls } from 'scenes/urls' +import type { AccountHealthScore } from '~/queries/schema/schema-general' + import type { AccountNotebookApi } from 'products/customer_analytics/frontend/generated/api.schemas' import { AccountEventStreamToggle } from '../EventStream/AccountEventStreamToggle' @@ -39,6 +41,7 @@ import { AccountBillingExpansion } from './AccountBillingExpansion' import { accountBillingLogic } from './accountBillingLogic' import { AccountEmailThreadsExpansion } from './AccountEmailThreadsExpansion' import { accountEmailThreadsLogic } from './accountEmailThreadsLogic' +import { AccountHealthDetails } from './AccountHealthDetails' import { accountLinksLogic } from './accountLinksLogic' import { AccountMeetingsExpansion } from './AccountMeetingsExpansion' import { accountMeetingsLogic } from './accountMeetingsLogic' @@ -166,9 +169,11 @@ function UsefulLinks({ accountId }: { accountId: string }): JSX.Element { export function AccountNotebooksExpansion({ accountId, externalId, + health, }: { accountId: string externalId: string + health: AccountHealthScore }): JSX.Element { const logic = accountNotebooksLogic({ accountId }) const { notebooks, notebooksResponseLoading, createdNoteLoading, searchTerm, sorting, pagination } = @@ -268,6 +273,11 @@ export function AccountNotebooksExpansion({ onChange={(tab) => setActiveTab(accountId, tab)} size="small" tabs={[ + { + key: 'health', + label: 'Health', + content: , + }, { key: 'notes', label: 'Notes', diff --git a/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx b/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx index dbdbebf1461f..0bc7a9076ba6 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx @@ -8,6 +8,7 @@ import { urls } from 'scenes/urls' import { mswDecorator } from '~/mocks/browser' import type { MockResolverInfo } from '~/mocks/utils' +import type { AccountHealthScore } from '~/queries/schema/schema-general' import type { PaginatedAccountEmailThreadListApi } from 'products/customer_analytics/frontend/generated/api.schemas' @@ -35,6 +36,96 @@ type AccountRow = [ AccountRelationshipCell, ] +const HEALTH_PERIOD = { + lookbackDays: 30, + previousPeriodStart: '2026-03-22T00:00:00Z', + currentPeriodStart: '2026-04-21T00:00:00Z', + computedAt: '2026-05-21T00:00:00Z', +} as const + +const HEALTH_BY_ACCOUNT_ID: Record = { + 'acc-1': { + ...HEALTH_PERIOD, + score: 91, + status: 'healthy', + isLimited: false, + factors: [ + { + metricId: 'metric-events', + metricName: 'Events ingested', + current: 920, + previous: 1000, + normalizedScore: 92, + contribution: 46, + }, + { + metricId: 'metric-users', + metricName: 'Active users', + current: 90, + previous: 100, + normalizedScore: 90, + contribution: 45, + }, + ], + }, + 'acc-2': { + ...HEALTH_PERIOD, + score: 60, + status: 'neutral', + isLimited: false, + factors: [ + { + metricId: 'metric-events', + metricName: 'Events ingested', + current: 620, + previous: 1000, + normalizedScore: 62, + contribution: 31, + }, + { + metricId: 'metric-users', + metricName: 'Active users', + current: 58, + previous: 100, + normalizedScore: 58, + contribution: 29, + }, + ], + }, + 'acc-3': { + ...HEALTH_PERIOD, + score: null, + status: 'no_data', + isLimited: false, + noDataReason: 'missing_external_id', + factors: [], + }, + 'acc-4': { + ...HEALTH_PERIOD, + score: 32, + status: 'at_risk', + isLimited: false, + factors: [ + { + metricId: 'metric-events', + metricName: 'Events ingested', + current: 30, + previous: 100, + normalizedScore: 30, + contribution: 15, + }, + { + metricId: 'metric-users', + metricName: 'Active users', + current: 34, + previous: 100, + normalizedScore: 34, + contribution: 17, + }, + ], + }, +} + const RELATIONSHIP_DEFINITIONS = { count: 3, next: null, @@ -78,6 +169,7 @@ function buildAccountsTableQueryResponse(rows: AccountRow[]): Record , + parameters: { + testOptions: { + ...EXPANDED_ROW_TEST_OPTIONS, + waitForSelector: ['[data-attr="accounts-refresh"]', '[data-attr="account-health-detail"]'], + }, + }, + decorators: [ + ...expandedRowDecorators(), + mswDecorator({ + get: { + [ACCOUNT_RETRIEVE_ENDPOINT]: ACCOUNT_WITH_LINKS, + [ACCOUNT_NOTEBOOKS_ENDPOINT]: { count: 0, next: null, previous: null, results: [] }, + }, + post: { + [QUERY_ENDPOINT]: mockAccountsTableQuery(SAMPLE_ROWS), + }, + }), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(await canvas.findByLabelText('Health score: 32 out of 100, At risk. View calculation.')) + await canvas.findByText('mean(30, 34) = 32') + }, +} + export const RowExpandedEmailThreads: Story = { render: () => , parameters: { diff --git a/products/customer_analytics/frontend/components/Accounts/AccountsTable.tsx b/products/customer_analytics/frontend/components/Accounts/AccountsTable.tsx index 7206a4485d8c..91b83f1e7e55 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountsTable.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountsTable.tsx @@ -33,6 +33,8 @@ import type { import { ACCOUNTS_TABLE_DATA_NODE_KEY } from '../../constants' import { formatCustomPropertyValue } from '../../scenes/CustomerAnalyticsConfigurationScene/account/customPropertyTypes' +import { ACCOUNT_HEALTH_COLUMN } from './accountHealth' +import { AccountHealthCell } from './AccountHealthCell' import { AccountNotebooksExpansion } from './AccountNotebooksExpansion' import { AccountColumnDisplayConfig, LEGACY_ROLE_COLUMNS, accountsColumnConfigLogic } from './accountsColumnConfigLogic' import { AccountExpansionTab, accountsExpansionLogic } from './accountsExpansionLogic' @@ -45,6 +47,7 @@ type AccountNameCell = { name: string; external_id: string | null; id: string } const COLUMN_WIDTHS = { name: '240px', + health: '180px', tag_names: '280px', notebook_count: '80px', relationship: '220px', @@ -470,6 +473,14 @@ const KNOWN_COLUMN_TEMPLATES: Record = { width: COLUMN_WIDTHS.notebook_count, render: ({ record }) => , }, + health: { + label: 'Health', + width: COLUMN_WIDTHS.health, + render: ({ record }) => { + const row = isAccountsTableRow(record) ? record : null + return row ? : null + }, + }, } function useContextColumns(): Record { @@ -477,6 +488,12 @@ function useContextColumns(): Record { useValues(accountsColumnConfigLogic) return useMemo(() => { const columns: Record = {} + const healthTemplate = KNOWN_COLUMN_TEMPLATES[ACCOUNT_HEALTH_COLUMN] + columns[ACCOUNT_HEALTH_COLUMN] = { + renderTitle: () => {healthTemplate.label}, + width: healthTemplate.width, + render: healthTemplate.render, + } for (const key of visibleColumnNames) { const definition = aliasToDefinition[key] if (definition) { @@ -536,9 +553,14 @@ function useExpandable(): QueryContext['expandable'] { } }, expandedRowRender: ({ result }) => { - const cell = getNameCell(result) - return cell ? ( - + const row = isAccountsTableRow(result) ? result : null + const cell = getNameCell(row) + return cell && row ? ( + ) : null }, }), @@ -559,6 +581,11 @@ const SKELETON_COLUMNS: LemonTableColumns<{ key: number }> = [ ), }, + { + title: 'Health', + width: COLUMN_WIDTHS.health, + render: () => , + }, { title: 'Tags', width: COLUMN_WIDTHS.tag_names, diff --git a/products/customer_analytics/frontend/components/Accounts/accountHealth.ts b/products/customer_analytics/frontend/components/Accounts/accountHealth.ts new file mode 100644 index 000000000000..43b115343518 --- /dev/null +++ b/products/customer_analytics/frontend/components/Accounts/accountHealth.ts @@ -0,0 +1,33 @@ +import type { LemonTagType } from '@posthog/lemon-ui' + +import type { AccountHealthNoDataReason, AccountHealthStatus } from '~/queries/schema/schema-general' + +export const ACCOUNT_HEALTH_COLUMN = 'health' + +export const ACCOUNT_HEALTH_STATUS_LABEL: Record = { + healthy: 'Healthy', + neutral: 'Neutral', + at_risk: 'At risk', + no_data: 'No data', +} + +export const ACCOUNT_HEALTH_NO_DATA_MESSAGE: Record = { + missing_external_id: "This account isn't linked to product usage.", + missing_group_type: "Customer analytics isn't linked to an account group.", + no_metrics: 'No supported usage metrics are configured.', + no_activity: 'The configured metrics had no activity in either comparison period.', + calculation_error: "The score couldn't be calculated. Refresh the page to try again.", +} + +export function accountHealthTagType(status: AccountHealthStatus): LemonTagType { + if (status === 'healthy') { + return 'success' + } + if (status === 'neutral') { + return 'warning' + } + if (status === 'at_risk') { + return 'danger' + } + return 'muted' +} diff --git a/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts index bcfa2aa225d5..1a3163aec0e2 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts @@ -4,6 +4,7 @@ import posthog from 'posthog-js' import { AccountsEvents } from './constants' export type AccountExpansionTab = + | 'health' | 'notes' | 'users' | 'relationships' @@ -17,6 +18,7 @@ export type AccountExpansionTab = | 'event_stream' export const ACCOUNT_EXPANSION_TABS: AccountExpansionTab[] = [ + 'health', 'notes', 'users', 'relationships', diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts index c0be1205867e..9338e1d8bc1d 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts @@ -39,6 +39,7 @@ import { } from '../../constants' import { customerAnalyticsSceneLogic } from '../../customerAnalyticsSceneLogic' import type { AccountRelationshipDefinitionApi, CustomPropertyDefinitionApi } from '../../generated/api.schemas' +import { ACCOUNT_HEALTH_COLUMN } from './accountHealth' import { accountsColumnConfigLogic, isLegacyRoleColumn } from './accountsColumnConfigLogic' import type { AccountColumnDisplayState } from './accountsColumnConfigLogic' import { @@ -830,7 +831,9 @@ export const accountsLogic = kea([ accountsQuerySource: AccountsTableQuery | null ): DataTableNode => ({ kind: NodeKind.DataTableNode, - columns: accountsTableQueryPlan.columns.map((column) => column.visibleName), + columns: accountsTableQueryPlan.columns.flatMap((column, index) => + index === 0 ? [column.visibleName, ACCOUNT_HEALTH_COLUMN] : [column.visibleName] + ), source: accountsQuerySource ?? accountsTableQueryPlan.query, full: true, allowSorting: true, diff --git a/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.test.ts b/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.test.ts index bc35078f0506..42552a87e2fa 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.test.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.test.ts @@ -1,6 +1,7 @@ import { AccountsTableAccountField, AccountsTableCustomPropertyOperator, + type AccountsTableRow, AccountsTableSortDirection, NodeKind, } from '~/queries/schema/schema-general' @@ -223,7 +224,7 @@ describe('accountsTableQuery', () => { it('reads cells directly from keyed rows', () => { const plan = buildAccountsTableQueryPlan(queryInput()) as AccountsTableQueryPlan - const row = { + const row: AccountsTableRow = { id: 'account-id', name: 'Acme', externalId: 'acme-1', @@ -233,6 +234,16 @@ describe('accountsTableQuery', () => { relationships: { [RELATIONSHIP_ID]: [42] }, customProperties: {}, customPropertyHistory: {}, + health: { + score: 80, + status: 'healthy', + factors: [], + lookbackDays: 30, + previousPeriodStart: '2026-03-22T00:00:00Z', + currentPeriodStart: '2026-04-21T00:00:00Z', + computedAt: '2026-05-21T00:00:00Z', + isLimited: false, + }, } expect(plan.columns.map((column) => accountsTableCell(row, column.visibleName, plan))).toEqual([ diff --git a/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.ts b/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.ts index c0278eae2ef5..6f26b42a558c 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsTableQuery.ts @@ -23,6 +23,7 @@ import type { CustomPropertyDefinitionApi } from 'products/customer_analytics/fr import { CUSTOMER_ANALYTICS_DEFAULT_QUERY_TAGS } from '../../constants' import { isNumericDisplayType } from '../../scenes/CustomerAnalyticsConfigurationScene/account/customPropertyTypes' +import { ACCOUNT_HEALTH_COLUMN } from './accountHealth' import type { AccountColumnDisplayState } from './accountsColumnConfigLogic' import type { AccountSortOrder, RoleFilterValue } from './accountsLogic' import type { TileFilter } from './accountsOverviewTilesLogic' @@ -285,6 +286,9 @@ function accountFieldValue(row: AccountsTableRow, field: AccountsTableAccountFie } export function accountsTableCell(row: AccountsTableRow, visibleName: string, plan: AccountsTableQueryPlan): unknown { + if (visibleName === ACCOUNT_HEALTH_COLUMN) { + return row.health + } const column = plan.columns.find((candidate) => candidate.visibleName === visibleName)?.column if (!column) { return undefined @@ -316,6 +320,7 @@ export function isAccountsTableRow(value: unknown): value is AccountsTableRow { !!row.accountFields && !!row.relationships && !!row.customProperties && - !!row.customPropertyHistory + !!row.customPropertyHistory && + !!row.health ) } diff --git a/products/customer_analytics/health_score.md b/products/customer_analytics/health_score.md new file mode 100644 index 000000000000..ccafc2afb5fd --- /dev/null +++ b/products/customer_analytics/health_score.md @@ -0,0 +1,67 @@ +# Account health score v1 + +## Investigation + +Issue [#57012](https://github.com/PostHog/posthog/issues/57012) asks for a global account health score built from usage and engagement metrics, with a visible breakdown on the expanded account. + +Customer analytics already has the required account-to-product-usage bridge: `Account.external_id` is the configured account group key, and `UsageMetricsQueryRunner` evaluates team-owned `GroupUsageMetric` definitions against that group. Each metric already provides a current value and a previous-period value. + +The Accounts list uses `AccountsTableQuery`, a typed Postgres query that returns one bounded page. Filters, saved views, overview tiles, and globally correct pagination sorting all compile into this query. Account access is fail-closed and team-scoped before rows are returned. Health must preserve that boundary and must not add a separate unscoped account lookup. + +The existing usage metrics are the narrowest product-owned inputs for a prototype. They preserve each team's event definitions and avoid inventing generic events that may not represent customer value. Data warehouse usage metrics are excluded from v1 because their key and timestamp fields differ by source, so combining them into one bounded list query needs a separate performance design. + +`GroupUsageMetric` does not currently assign a metric to a product. V1 therefore exposes one global score. Per-product scores remain deferred until product ownership is explicit instead of inferred from metric names. + +## Scoring contract + +- The score is an integer from 0 to 100. +- The lookback is the last 30 rolling days compared with the previous 30 days. +- The inputs are up to five valid event-based usage metrics, ordered by metric name and ID for deterministic selection. +- Each metric is normalized as `min(current / previous, 1) * 100`. +- A metric with current activity and no previous activity receives 100 normalized points and is marked as having a limited baseline. +- A metric with no activity in either period is unavailable and does not reduce the score. +- Available metrics have equal weight. Missing metrics are excluded and the remaining weights are renormalized. +- The visible contribution for each available factor is its normalized score divided by the number of available factors. +- A score from 80 to 100 is healthy, 50 to 79 is neutral, and 0 to 49 is at risk. These fixed thresholds make the labels mean “retaining at least 80%” and “retaining at least 50%” of the previous period on average. V1 does not add configurable weights or thresholds because Customer analytics has no existing configuration pattern for them. +- The score is calculated deterministically. No LLM is involved. + +### Missing, sparse, and new-account data + +- An account without an external ID has no score because it cannot be joined to product usage. +- A team without a configured account group type has no score. +- A team without supported event-based usage metrics has no score. +- An account with no activity across either 30-day period has no score rather than a zero score. +- An account with activity in only some configured metrics receives a score from those metrics and is marked as having a limited baseline. +- A new account with current activity but no previous-period activity receives a score, but the UI marks the baseline as limited. This keeps new activity visible without presenting it as a mature comparison. +- Calculation failures do not hide the account list. The account receives an unavailable state with retry guidance. + +## UI concepts + +Both concepts use the same deterministic healthy, neutral, at-risk, and no-data fixtures. + +### Concept A: compact status + +The table shows a colored circular number. Hovering shows a short tooltip, and the expanded row repeats the score in the existing narrow sidebar above useful links. + +This is compact, but it makes the score easy to read as decoration. Tooltips are hard to compare, the sidebar cannot show all inputs comfortably, and the calculation is not part of the account's primary content. + +### Concept B: auditable score + +The table shows the number and status together. The cell opens a dedicated Health tab in the expanded account. That tab states the 30-day comparison, formula, included factor count, current and previous values, normalized points, and contribution for every metric. No-data and limited-baseline states explain why the result is unavailable or less certain. + +This concept is selected because every number can be traced from the list to its inputs without relying on hover or color. Color is secondary to visible text, and the score link provides a keyboard-accessible path to the calculation. + +## Computation and query boundary + +V1 computes health at query time for the bounded account page returned by `AccountsTableQuery`. + +- It avoids a model and migration before the score contract has production evidence. +- It issues at most one ClickHouse query for the page, regardless of row count or metric count. +- The ClickHouse query receives only the external IDs from the already team-scoped, access-filtered Postgres page. +- The metrics-only overview query does not calculate per-account health. +- Health filtering and sorting are not offered in v1. Calculating only the visible page cannot support globally correct ordering or filtering, and presenting a page-local approximation would be misleading. +- Persisting or materializing the score is deferred until query cost, update cadence, and global sort/filter requirements are measured. + +## Local verification + +Run `python manage.py seed_customer_analytics_accounts --team-id --health-fixtures` to add four example accounts and a dedicated usage metric. The command sends fixed 90%, 60%, and 30% current-to-previous activity ratios for the healthy, neutral, and at-risk accounts. It sends no activity for the no-data account. Historical ingestion must finish before the scores appear in the Accounts list.