From b4b2c84f95268cd4abd25c4dc54890cdda725dc7 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 08:50:05 +0000 Subject: [PATCH 1/4] feat(customer-analytics): add account health score prototype Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../hogql_queries/account_health_score.py | 477 ++++++++++++++++++ .../hogql_queries/accounts_query_runner.py | 56 +- .../test/test_accounts_query_runner.py | 205 +++++++- .../backend/max_tools/open_account.py | 16 +- .../backend/max_tools/test_open_account.py | 4 +- .../frontend/components/Accounts/AGENTS.md | 15 +- .../Accounts/AccountHealthScore.test.tsx | 58 +++ .../Accounts/AccountHealthScore.tsx | 146 ++++++ .../Accounts/AccountNotebooksExpansion.tsx | 9 + .../Accounts/AccountsHogQLTable.tsx | 43 +- .../components/Accounts/AccountsMaxTools.tsx | 9 +- .../Accounts/AccountsTab.stories.tsx | 136 ++++- .../Accounts/accountsColumnConfigLogic.ts | 9 +- .../Accounts/accountsExpansionLogic.ts | 4 +- .../components/Accounts/accountsLogic.test.ts | 7 + .../components/Accounts/accountsLogic.ts | 9 + 16 files changed, 1170 insertions(+), 33 deletions(-) create mode 100644 products/customer_analytics/backend/hogql_queries/account_health_score.py create mode 100644 products/customer_analytics/frontend/components/Accounts/AccountHealthScore.test.tsx create mode 100644 products/customer_analytics/frontend/components/Accounts/AccountHealthScore.tsx diff --git a/products/customer_analytics/backend/hogql_queries/account_health_score.py b/products/customer_analytics/backend/hogql_queries/account_health_score.py new file mode 100644 index 000000000000..950659903ffc --- /dev/null +++ b/products/customer_analytics/backend/hogql_queries/account_health_score.py @@ -0,0 +1,477 @@ +import math +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Literal, TypedDict +from zoneinfo import ZoneInfo + +from django.core.exceptions import ObjectDoesNotExist +from django.utils import timezone + +from posthog.schema import HogQLQueryModifiers + +from posthog.hogql import ast +from posthog.hogql.parser import parse_select +from posthog.hogql.query import execute_hogql_query +from posthog.hogql.timings import HogQLTimings + +from posthog.cdp.filters import hog_function_filters_to_expr +from posthog.models.team import Team +from posthog.models.user import User + +from products.customer_analytics.backend.constants import DEFAULT_ACTIVITY_EVENT +from products.customer_analytics.backend.models.team_customer_analytics_config import TeamCustomerAnalyticsConfig + +ACCOUNT_HEALTH_SCORE_COLUMN = "health_score" +ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS = 30 + +AccountHealthStatus = Literal["healthy", "neutral", "at_risk", "no_data"] + + +class AccountHealthFactor(TypedDict): + key: str + label: str + value: float | int | str | None + previousValue: float | int | str | None + score: int | None + weight: float + description: str + reason: str | None + + +class AccountHealthScore(TypedDict): + score: int | None + status: AccountHealthStatus + lookbackDays: int + activityEvent: str + factors: list[AccountHealthFactor] + noDataReason: str | None + lastActivityAt: str | None + + +@dataclass(frozen=True) +class AccountActivityMetrics: + current_count: float + previous_count: float + active_users: float + active_days: float + last_activity_at: datetime | None + + +@dataclass(frozen=True) +class AccountHealthBaseline: + p90_activity_count: float + p90_active_users: float + + +def no_data_health_score(reason: str, activity_event: str = "Activity") -> AccountHealthScore: + return { + "score": None, + "status": "no_data", + "lookbackDays": ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS, + "activityEvent": activity_event, + "factors": [], + "noDataReason": reason, + "lastActivityAt": None, + } + + +def score_account_health( + metrics: AccountActivityMetrics, + baseline: AccountHealthBaseline, + *, + activity_event: str, + date_to: datetime, + lookback_days: int = ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS, +) -> AccountHealthScore: + if metrics.current_count == 0 and metrics.previous_count == 0: + return no_data_health_score( + f"No {activity_event} activity in the current or previous {lookback_days}-day window.", + activity_event, + ) + + factors: list[AccountHealthFactor] = [ + _factor( + key="activity", + label="Activity volume", + value=metrics.current_count, + previous_value=metrics.previous_count, + score=_normalize_against_baseline(metrics.current_count, baseline.p90_activity_count), + weight=0.35, + description=( + f"{activity_event} events in the last {lookback_days} days, normalized against the team's active-account p90." + ), + ), + _factor( + key="active_users", + label="Active users", + value=metrics.active_users, + previous_value=None, + score=_normalize_against_baseline(metrics.active_users, baseline.p90_active_users), + weight=0.25, + description=f"Distinct users with {activity_event} activity, normalized against the team's active-account p90.", + ), + _factor( + key="frequency", + label="Active days", + value=metrics.active_days, + previous_value=None, + score=_normalize_against_baseline(metrics.active_days, lookback_days), + weight=0.2, + description=f"Days with {activity_event} activity during the {lookback_days}-day window.", + ), + _factor( + key="recency", + label="Recency", + value=_days_since(metrics.last_activity_at, date_to) if metrics.last_activity_at else None, + previous_value=None, + score=_recency_score(metrics.last_activity_at, date_to, lookback_days), + weight=0.1, + description=f"How recently this account had {activity_event} activity.", + ), + _factor( + key="trend", + label="Trend", + value=metrics.current_count, + previous_value=metrics.previous_count, + score=_trend_score(metrics.current_count, metrics.previous_count), + weight=0.1, + description=f"Current {lookback_days}-day activity compared with the previous {lookback_days} days.", + reason="No previous activity to compare." if metrics.previous_count == 0 else None, + ), + ] + + scored_factors = [factor for factor in factors if factor["score"] is not None] + if not scored_factors: + return no_data_health_score( + f"Not enough {activity_event} activity to calculate a health score.", + activity_event, + ) + + total_weight = sum(factor["weight"] for factor in scored_factors) + score = round(sum((factor["score"] or 0) * factor["weight"] for factor in scored_factors) / total_weight) + + return { + "score": score, + "status": _status_for_score(score), + "lookbackDays": lookback_days, + "activityEvent": activity_event, + "factors": factors, + "noDataReason": None, + "lastActivityAt": metrics.last_activity_at.isoformat() if metrics.last_activity_at else None, + } + + +def _factor( + *, + key: str, + label: str, + value: float | int | str | None, + previous_value: float | int | str | None, + score: int | None, + weight: float, + description: str, + reason: str | None = None, +) -> AccountHealthFactor: + return { + "key": key, + "label": label, + "value": value, + "previousValue": previous_value, + "score": score, + "weight": weight, + "description": description, + "reason": reason, + } + + +def _normalize_against_baseline(value: float, baseline: float) -> int | None: + if baseline <= 0: + return None + return round(min(max(value, 0) / baseline, 1) * 100) + + +def _trend_score(current_count: float, previous_count: float) -> int | None: + if previous_count == 0: + return None + return round(min(max(current_count, 0) / previous_count, 1) * 100) + + +def _recency_score(last_activity_at: datetime | None, date_to: datetime, lookback_days: int) -> int | None: + if last_activity_at is None: + return None + days_since = _days_since(last_activity_at, date_to) + return round(max(0, min(1, (lookback_days - days_since) / lookback_days)) * 100) + + +def _days_since(last_activity_at: datetime, date_to: datetime) -> float: + if timezone.is_naive(last_activity_at): + last_activity_at = timezone.make_aware(last_activity_at, ZoneInfo("UTC")) + if timezone.is_naive(date_to): + date_to = timezone.make_aware(date_to, ZoneInfo("UTC")) + return max((date_to - last_activity_at).total_seconds() / 86400, 0) + + +def _status_for_score(score: int) -> AccountHealthStatus: + if score >= 75: + return "healthy" + if score >= 40: + return "neutral" + return "at_risk" + + +def _activity_event_to_filters(activity_event: dict[str, Any]) -> dict[str, Any] | None: + kind = activity_event.get("kind") + if kind == "EventsNode": + event_filter: dict[str, Any] = { + "id": activity_event.get("event"), + "name": activity_event.get("name") or activity_event.get("event") or "All events", + "type": "events", + "order": 0, + } + if activity_event.get("properties"): + event_filter["properties"] = activity_event["properties"] + return {"events": [event_filter]} + if kind == "ActionsNode": + action_filter = { + "id": activity_event.get("id"), + "name": activity_event.get("name") or str(activity_event.get("id")), + "type": "actions", + "order": 0, + } + if activity_event.get("properties"): + action_filter["properties"] = activity_event["properties"] + return {"actions": [action_filter]} + return None + + +def _activity_event_label(activity_event: dict[str, Any]) -> str: + raw_name = activity_event.get("name") or activity_event.get("event") + if isinstance(raw_name, str) and raw_name: + return raw_name + return "Activity" + + +class AccountHealthScoreCalculator: + def __init__( + self, + *, + team: Team, + user: User | None, + timings: HogQLTimings | None = None, + modifiers: HogQLQueryModifiers | None = None, + ) -> None: + self.team = team + self.user = user + self.timings = timings + self.modifiers = modifiers + + def score_accounts(self, external_ids_by_account_id: dict[str, str | None]) -> dict[str, AccountHealthScore]: + config = self._config() + activity_event = self._activity_event(config) + activity_label = _activity_event_label(activity_event) + + group_type_index = self._account_group_type_index(config) + if group_type_index is None: + return { + account_id: no_data_health_score( + "Customer analytics is not connected to an account group type.", + activity_label, + ) + for account_id in external_ids_by_account_id + } + + filters = _activity_event_to_filters(activity_event) + if filters is None: + return { + account_id: no_data_health_score( + "The configured activity source is not supported by health scoring yet.", + activity_label, + ) + for account_id in external_ids_by_account_id + } + + accounts_with_external_ids = { + account_id: external_id + for account_id, external_id in external_ids_by_account_id.items() + if isinstance(external_id, str) and external_id + } + scores: dict[str, AccountHealthScore] = { + account_id: no_data_health_score("This account has no external ID.", activity_label) + for account_id, external_id in external_ids_by_account_id.items() + if not external_id + } + if not accounts_with_external_ids: + return scores + + date_to = timezone.now() + date_from = date_to - timedelta(days=ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS) + previous_date_from = date_to - timedelta(days=ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS * 2) + activity_filter = hog_function_filters_to_expr(filters, self.team, {}) + + baseline = self._load_baseline( + group_type_index=group_type_index, + date_from=date_from, + date_to=date_to, + activity_filter=activity_filter, + ) + metrics_by_external_id = self._load_account_metrics( + group_type_index=group_type_index, + group_keys=list(accounts_with_external_ids.values()), + date_from=date_from, + previous_date_from=previous_date_from, + date_to=date_to, + activity_filter=activity_filter, + ) + + for account_id, external_id in accounts_with_external_ids.items(): + metrics = metrics_by_external_id.get(external_id) + if metrics is None: + scores[account_id] = no_data_health_score( + f"No {activity_label} activity in the current or previous {ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS}-day window.", + activity_label, + ) + continue + scores[account_id] = score_account_health( + metrics, + baseline, + activity_event=activity_label, + date_to=date_to, + ) + return scores + + def _account_group_type_index(self, config: TeamCustomerAnalyticsConfig | None) -> int | None: + if config is None: + return None + index = config.account_group_type_index + return index if isinstance(index, int) and 0 <= index <= 4 else None + + def _activity_event(self, config: TeamCustomerAnalyticsConfig | None) -> dict[str, Any]: + if config is None: + return DEFAULT_ACTIVITY_EVENT + event = config.activity_event + return event if isinstance(event, dict) and event else DEFAULT_ACTIVITY_EVENT + + def _config(self) -> TeamCustomerAnalyticsConfig | None: + try: + return TeamCustomerAnalyticsConfig.objects.get(team_id=self.team.id) + except ObjectDoesNotExist: + return None + + def _load_baseline( + self, + *, + group_type_index: int, + date_from: datetime, + date_to: datetime, + activity_filter: ast.Expr, + ) -> AccountHealthBaseline: + group_expr = f"toString($group_{group_type_index})" + current_condition = "timestamp >= {date_from} AND timestamp < {date_to}" + query = parse_select( + f""" + SELECT + quantileExact(0.9)(current_count) AS p90_activity_count, + quantileExact(0.9)(active_users) AS p90_active_users + FROM ( + SELECT + {group_expr} AS group_key, + countIf({current_condition}) AS current_count, + uniqIf(person_id, {current_condition}) AS active_users + FROM events + WHERE timestamp >= {{date_from}} + AND timestamp < {{date_to}} + AND notEmpty({group_expr}) + AND {{activity_filter}} + GROUP BY group_key + ) + """, + { + "date_from": ast.Constant(value=date_from), + "date_to": ast.Constant(value=date_to), + "activity_filter": activity_filter, + }, + ) + response = execute_hogql_query( + query_type="AccountsHealthBaselineQuery", + query=query, + team=self.team, + user=self.user, + timings=self.timings, + modifiers=self.modifiers, + ) + row = response.results[0] if response.results else [0, 0] + return AccountHealthBaseline( + p90_activity_count=_read_float(row[0] if len(row) > 0 else 0), + p90_active_users=_read_float(row[1] if len(row) > 1 else 0), + ) + + def _load_account_metrics( + self, + *, + group_type_index: int, + group_keys: list[str], + date_from: datetime, + previous_date_from: datetime, + date_to: datetime, + activity_filter: ast.Expr, + ) -> dict[str, AccountActivityMetrics]: + group_expr = f"toString($group_{group_type_index})" + current_condition = "timestamp >= {date_from} AND timestamp < {date_to}" + previous_condition = "timestamp >= {previous_date_from} AND timestamp < {date_from}" + query = parse_select( + f""" + SELECT + {group_expr} AS group_key, + countIf({current_condition}) AS current_count, + countIf({previous_condition}) AS previous_count, + uniqIf(person_id, {current_condition}) AS active_users, + countDistinctIf(toDate(timestamp), {current_condition}) AS active_days, + max(timestamp) AS last_activity_at + FROM events + WHERE timestamp >= {{previous_date_from}} + AND timestamp < {{date_to}} + AND notEmpty({group_expr}) + AND {group_expr} IN {{group_keys}} + AND {{activity_filter}} + GROUP BY group_key + """, + { + "date_from": ast.Constant(value=date_from), + "previous_date_from": ast.Constant(value=previous_date_from), + "date_to": ast.Constant(value=date_to), + "group_keys": ast.Constant(value=group_keys), + "activity_filter": activity_filter, + }, + ) + response = execute_hogql_query( + query_type="AccountsHealthMetricsQuery", + query=query, + team=self.team, + user=self.user, + timings=self.timings, + modifiers=self.modifiers, + ) + metrics: dict[str, AccountActivityMetrics] = {} + for row in response.results: + if not row: + continue + group_key = str(row[0]) + metrics[group_key] = AccountActivityMetrics( + current_count=_read_float(row[1] if len(row) > 1 else 0), + previous_count=_read_float(row[2] if len(row) > 2 else 0), + active_users=_read_float(row[3] if len(row) > 3 else 0), + active_days=_read_float(row[4] if len(row) > 4 else 0), + last_activity_at=row[5] if len(row) > 5 and isinstance(row[5], datetime) else None, + ) + return metrics + + +def _read_float(value: Any) -> float: + if isinstance(value, (int, float)): + numeric_value = float(value) + return numeric_value if math.isfinite(numeric_value) else 0.0 + try: + numeric_value = float(value) + except (TypeError, ValueError): + return 0.0 + return numeric_value if math.isfinite(numeric_value) else 0.0 diff --git a/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py b/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py index 22d67ac5a83b..aec7643cf44e 100644 --- a/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py @@ -11,9 +11,14 @@ from posthog.models import User from posthog.rbac.user_access_control import UserAccessControl +from products.customer_analytics.backend.hogql_queries.account_health_score import ( + ACCOUNT_HEALTH_SCORE_COLUMN, + AccountHealthScoreCalculator, +) + NAME_COLUMN = "name" -DEFAULT_COLUMNS = (NAME_COLUMN, "created_at") +DEFAULT_COLUMNS = (NAME_COLUMN, ACCOUNT_HEALTH_SCORE_COLUMN, "created_at") DEFAULT_ORDER_BY = "created_at DESC" @@ -26,6 +31,11 @@ def _normalize_order_clause(raw: str) -> str: return stripped +def _is_health_score_order_clause(raw: str) -> bool: + parts = _normalize_order_clause(raw).split(None, 1) + return bool(parts) and parts[0] == ACCOUNT_HEALTH_SCORE_COLUMN + + # Account-properties JSON keys for the three assignable roles. The # `allRolesUnassigned` filter ("Unassigned only") requires every one of these to # be empty. @@ -77,6 +87,10 @@ def validate_query_runner_access(self, user: User) -> bool: def _resolve_column(self, raw: str) -> tuple[str, ast.Expr]: if raw == NAME_COLUMN: return NAME_COLUMN, self._name_tuple_expr() + if raw == ACCOUNT_HEALTH_SCORE_COLUMN: + return ACCOUNT_HEALTH_SCORE_COLUMN, ast.Alias( + alias=ACCOUNT_HEALTH_SCORE_COLUMN, expr=ast.Constant(value=None) + ) expr = parse_expr(raw) column_name = expr.alias if isinstance(expr, ast.Alias) else raw return column_name, expr @@ -127,7 +141,10 @@ def _build_where_exprs(self) -> list[ast.Expr]: def to_query(self) -> ast.SelectQuery: where_exprs = self._build_where_exprs() - order_clauses = self.query.orderBy or [DEFAULT_ORDER_BY] + raw_order_clauses = self.query.orderBy or [DEFAULT_ORDER_BY] + order_clauses = [clause for clause in raw_order_clauses if not _is_health_score_order_clause(clause)] or [ + DEFAULT_ORDER_BY + ] return ast.SelectQuery( select=self._select_exprs, @@ -238,12 +255,20 @@ def _calculate(self) -> AccountsQueryResponse: ] for row in self.paginator.results ] + if ACCOUNT_HEALTH_SCORE_COLUMN in self.columns and results: + self._add_health_scores(results, name_index) + + types = [t for _, t in response.types] if response.types else [] + if ACCOUNT_HEALTH_SCORE_COLUMN in self.columns: + while len(types) < len(self.columns): + types.append("Nullable(String)") + types[self.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN)] = "JSON" return AccountsQueryResponse( kind="AccountsQuery", columns=list(self.columns), results=results, - types=[t for _, t in response.types] if response.types else [], + types=types, metricsResults=metrics_results, hogql=response.hogql or "", timings=response.timings, @@ -251,6 +276,31 @@ def _calculate(self) -> AccountsQueryResponse: **self.paginator.response_params(), ) + def _add_health_scores(self, results: list[list], name_index: int) -> None: + health_index = self.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + external_ids_by_account_id: dict[str, str | None] = {} + for row in results: + if len(row) <= name_index: + continue + cell = row[name_index] + if not isinstance(cell, dict) or not isinstance(cell.get("id"), str): + continue + external_id = cell.get("external_id") + external_ids_by_account_id[cell["id"]] = external_id if isinstance(external_id, str) else None + + scores = AccountHealthScoreCalculator( + team=self.team, + user=self.user, + timings=self.timings, + modifiers=self.modifiers, + ).score_accounts(external_ids_by_account_id) + for row in results: + if len(row) <= max(name_index, health_index): + continue + cell = row[name_index] + if isinstance(cell, dict) and isinstance(cell.get("id"), str): + row[health_index] = scores.get(cell["id"]) + def _compute_metrics_results(self, metrics: list[str]) -> list[float | int | None]: try: response = self._execute_metrics_query(metrics) diff --git a/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py b/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py index e4fa85598b6f..19514b112d5b 100644 --- a/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py @@ -1,4 +1,10 @@ -from posthog.test.base import ClickhouseTestMixin, NonAtomicBaseTest +from datetime import datetime, timedelta +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +from freezegun import freeze_time +from posthog.test.base import ClickhouseTestMixin, NonAtomicBaseTest, _create_event, flush_persons_and_events +from unittest.mock import patch from django.test import override_settings from django.utils import timezone @@ -15,7 +21,16 @@ from posthog.models.team import Team from posthog.rbac.user_access_control import UserAccessControlError +from products.customer_analytics.backend.hogql_queries.account_health_score import ( + ACCOUNT_HEALTH_SCORE_COLUMN, + AccountActivityMetrics, + AccountHealthBaseline, + AccountHealthScoreCalculator, + no_data_health_score, + score_account_health, +) from products.customer_analytics.backend.hogql_queries.accounts_query_runner import AccountsQueryRunner +from products.customer_analytics.backend.models.team_customer_analytics_config import TeamCustomerAnalyticsConfig from products.customer_analytics.backend.test.factories import create_account from products.notebooks.backend.models import Notebook, ResourceNotebook @@ -56,6 +71,85 @@ def test_default_ordering_is_created_at_desc(self): newer = create_account(team_id=self.team.id, name="Newer") self.assertEqual(self._ids(), [str(newer.id), str(older.id)]) + def test_health_score_ordering_falls_back_to_default_ordering(self): + with timezone.override("UTC"): + older = create_account(team_id=self.team.id, name="Older") + newer = create_account(team_id=self.team.id, name="Newer") + self.assertEqual(self._ids(orderBy=[ACCOUNT_HEALTH_SCORE_COLUMN]), [str(newer.id), str(older.id)]) + + def test_score_account_health_normalizes_weighted_factors(self): + date_to = datetime(2026, 6, 1, 12, 0, tzinfo=ZoneInfo("UTC")) + score = score_account_health( + AccountActivityMetrics( + current_count=50, + previous_count=25, + active_users=10, + active_days=15, + last_activity_at=date_to - timedelta(days=3), + ), + AccountHealthBaseline(p90_activity_count=100, p90_active_users=20), + activity_event="$pageview", + date_to=date_to, + ) + self.assertEqual(score["score"], 59) + self.assertEqual(score["status"], "neutral") + self.assertEqual( + [factor["key"] for factor in score["factors"]], + ["activity", "active_users", "frequency", "recency", "trend"], + ) + + def test_score_account_health_returns_no_data_for_empty_current_and_previous_windows(self): + score = score_account_health( + AccountActivityMetrics( + current_count=0, + previous_count=0, + active_users=0, + active_days=0, + last_activity_at=None, + ), + AccountHealthBaseline(p90_activity_count=0, p90_active_users=0), + activity_event="$pageview", + date_to=datetime(2026, 6, 1, 12, 0, tzinfo=ZoneInfo("UTC")), + ) + self.assertEqual(score["status"], "no_data") + self.assertEqual(score["score"], None) + self.assertIn("No $pageview activity", score["noDataReason"]) + + def test_default_columns_include_health_score(self): + create_account(team_id=self.team.id, name="A") + runner, response = self._run_query() + self.assertEqual(runner.columns[:2], ["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + self.assertEqual(response.columns[:2], ["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + + def test_health_score_is_not_calculated_when_column_is_not_selected(self): + account = create_account(team_id=self.team.id, name="A", external_id="org-a") + with patch( + "products.customer_analytics.backend.hogql_queries.accounts_query_runner.AccountHealthScoreCalculator.score_accounts", + return_value={str(account.id): no_data_health_score("No data")}, + ) as score_accounts: + self._run_query(select=["name"]) + score_accounts.assert_not_called() + + self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + score_accounts.assert_called_once_with({str(account.id): "org-a"}) + + def test_health_score_column_serializes_no_data_for_missing_external_id(self): + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "EventsNode", "event": "$pageview", "name": "$pageview"}, + }, + ) + account = create_account(team_id=self.team.id, name="A", external_id=None) + runner, response = self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + health_idx = runner.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + name_idx = runner.columns.index("name") + self.assertEqual(response.results[0][name_idx]["id"], str(account.id)) + self.assertEqual(response.results[0][health_idx]["status"], "no_data") + self.assertEqual(response.results[0][health_idx]["score"], None) + self.assertIn("no external ID", response.results[0][health_idx]["noDataReason"]) + @parameterized.expand( [ ("name_exact", "Acme Corp", ["Acme Corp"]), @@ -274,6 +368,115 @@ def test_ordering_by_name_desc(self): banana = create_account(team_id=self.team.id, name="Banana") self.assertEqual(self._ids(orderBy=["-name"]), [str(banana.id), str(apple.id)]) + @freeze_time("2026-06-01T12:00:00Z") + def test_health_score_uses_activity_metrics_and_respects_team_isolation(self): + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "EventsNode", "event": "$pageview", "name": "$pageview"}, + }, + ) + create_account(team_id=self.team.id, name="Healthy", external_id="org-healthy") + create_account(team_id=self.team.id, name="At risk", external_id="org-at-risk") + create_account(team_id=self.team.id, name="No data", external_id="org-no-data") + + other_team = Team.objects.create(organization=self.organization) + create_account(team_id=other_team.id, name="Other", external_id="org-healthy") + + now = timezone.now() + for index in range(10): + _create_event( + event="$pageview", + team=self.team, + distinct_id=f"healthy-{index % 5}", + person_id=f"00000000-0000-4000-8000-00000000000{index % 5}", + timestamp=now - timedelta(days=index + 1), + properties={"$group_0": "org-healthy"}, + ) + for index in range(5): + _create_event( + event="$pageview", + team=self.team, + distinct_id=f"healthy-prev-{index}", + person_id=f"00000000-0000-4000-8000-00000000001{index}", + timestamp=now - timedelta(days=35 + index), + properties={"$group_0": "org-healthy"}, + ) + _create_event( + event="$pageview", + team=self.team, + distinct_id="at-risk-prev", + person_id="00000000-0000-4000-8000-000000000020", + timestamp=now - timedelta(days=40), + properties={"$group_0": "org-at-risk"}, + ) + for index in range(20): + _create_event( + event="$pageview", + team=other_team, + distinct_id=f"other-{index}", + person_id=f"00000000-0000-4000-8000-00000000010{index % 10}", + timestamp=now - timedelta(days=1), + properties={"$group_0": "org-healthy"}, + ) + flush_persons_and_events() + + runner, response = self._run_query( + select=["name", ACCOUNT_HEALTH_SCORE_COLUMN], + orderBy=["name"], + ) + name_idx = runner.columns.index("name") + health_idx = runner.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + scores_by_name = {row[name_idx]["name"]: row[health_idx] for row in response.results} + + self.assertEqual(scores_by_name["Healthy"]["status"], "healthy") + self.assertGreaterEqual(scores_by_name["Healthy"]["score"], 75) + self.assertEqual(scores_by_name["At risk"]["status"], "at_risk") + self.assertLess(scores_by_name["At risk"]["score"], 40) + self.assertEqual(scores_by_name["No data"]["status"], "no_data") + self.assertEqual(scores_by_name["No data"]["score"], None) + self.assertTrue(all(factor["key"] for factor in scores_by_name["Healthy"]["factors"])) + + def test_health_score_unsupported_activity_source_returns_no_data(self): + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "DataWarehouseNode", "name": "Billing usage"}, + }, + ) + create_account(team_id=self.team.id, name="A", external_id="org-a") + runner, response = self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + health_idx = runner.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + self.assertEqual(response.results[0][health_idx]["status"], "no_data") + self.assertIn("not supported", response.results[0][health_idx]["noDataReason"]) + + def test_health_score_calculator_uses_batched_baseline_and_metrics_queries(self): + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "EventsNode", "event": "$pageview", "name": "$pageview"}, + }, + ) + with patch( + "products.customer_analytics.backend.hogql_queries.account_health_score.execute_hogql_query", + side_effect=[ + SimpleNamespace(results=[[10, 5]]), + SimpleNamespace(results=[["org-a", 4, 2, 2, 3, timezone.now()]]), + ], + ) as execute: + scores = AccountHealthScoreCalculator(team=self.team, user=self.user).score_accounts( + {"account-a": "org-a", "account-b": None} + ) + + self.assertEqual(execute.call_count, 2) + self.assertEqual(execute.call_args_list[0].kwargs["query_type"], "AccountsHealthBaselineQuery") + self.assertEqual(execute.call_args_list[1].kwargs["query_type"], "AccountsHealthMetricsQuery") + self.assertEqual(scores["account-a"]["status"], "neutral") + self.assertEqual(scores["account-b"]["status"], "no_data") + def _link_notebooks(self, account, count: int) -> None: for i in range(count): notebook = Notebook.objects.create( diff --git a/products/customer_analytics/backend/max_tools/open_account.py b/products/customer_analytics/backend/max_tools/open_account.py index 5ed6fb74a491..bf8bb6cb46b6 100644 --- a/products/customer_analytics/backend/max_tools/open_account.py +++ b/products/customer_analytics/backend/max_tools/open_account.py @@ -13,19 +13,19 @@ from ee.hogai.tool import MaxTool OPEN_ACCOUNT_TOOL_DESCRIPTION = dedent(""" - Open an account in the Accounts list and jump to one of its tabs — Notes, Users, or Usage. + Open an account in the Accounts list and jump to one of its tabs — Health, Notes, Users, or Usage. - Use this to show the user an account's existing usage (the Usage tab) instead of building a new - insight, or to surface its notes or related users. Identify the account by name or external id; - `tab` defaults to usage. The account must be in the list the user is currently viewing. + Use this to show the user an account's health score breakdown, existing usage, notes, or related + users. Identify the account by name or external id; `tab` defaults to health. The account must be + in the list the user is currently viewing. """).strip() class OpenAccountToolArgs(BaseModel): account: str = Field(description="The account to open — its name or external id.") - tab: Literal["notes", "users", "usage"] = Field( - default="usage", - description="Which tab to open: notes, users, or usage. Defaults to usage.", + tab: Literal["health", "notes", "users", "usage"] = Field( + default="health", + description="Which tab to open: health, notes, users, or usage. Defaults to health.", ) @@ -37,7 +37,7 @@ class OpenAccountTool(MaxTool): def get_required_resource_access(self) -> list[tuple[APIScopeObject, AccessControlLevel]]: return [("account", "viewer")] - async def _arun_impl(self, account: str, tab: str = "usage") -> tuple[str, dict[str, Any]]: + async def _arun_impl(self, account: str, tab: str = "health") -> tuple[str, dict[str, Any]]: resolved = await self._resolve_account(account) if resolved is None: return f"Couldn't find an account matching '{account}'.", {"error": "account_not_found"} diff --git a/products/customer_analytics/backend/max_tools/test_open_account.py b/products/customer_analytics/backend/max_tools/test_open_account.py index 2949f61363b3..edc2720979d4 100644 --- a/products/customer_analytics/backend/max_tools/test_open_account.py +++ b/products/customer_analytics/backend/max_tools/test_open_account.py @@ -20,7 +20,7 @@ def _tool(self) -> OpenAccountTool: @pytest.mark.django_db @pytest.mark.asyncio - async def test_resolves_by_external_id_defaulting_to_usage_tab(self): + async def test_resolves_by_external_id_defaulting_to_health_tab(self): account = await sync_to_async(Account.objects.unscoped().create)( team=self.team, name="Acme Corp", external_id="acme-123" ) @@ -30,7 +30,7 @@ async def test_resolves_by_external_id_defaulting_to_usage_tab(self): assert "Acme Corp" in content assert artifact["account_id"] == str(account.id) assert artifact["external_id"] == "acme-123" - assert artifact["tab"] == "usage" + assert artifact["tab"] == "health" @pytest.mark.django_db @pytest.mark.asyncio diff --git a/products/customer_analytics/frontend/components/Accounts/AGENTS.md b/products/customer_analytics/frontend/components/Accounts/AGENTS.md index 9dc1223875b0..5bb35c23b79e 100644 --- a/products/customer_analytics/frontend/components/Accounts/AGENTS.md +++ b/products/customer_analytics/frontend/components/Accounts/AGENTS.md @@ -23,10 +23,12 @@ AccountsTabContent ── binds dataNodeLogic(ACCOUNTS_HOGQL_DATA_NODE_KEY, acc │ "my accounts" on the left, AccountsOverviewTilesButton + AccountsColumnConfigurator on the right ├── AccountsOverviewTiles metric tiles across the filtered set └── AccountsHogQLTable the DataTable; per-column renderers; controlled row expansion - └── AccountNotebooksExpansion expanded row: Useful links + LemonTabs(Notes/Users/Usage) + └── AccountNotebooksExpansion expanded row: Useful links + LemonTabs(Health/Notes/Users/Usage/Spend) + ├── (health) AccountHealthScoreExplanation query-time global health score breakdown ├── (notes) in-place LemonTable of linked notebooks (accountNotebooksLogic, keyed by accountId) ├── (users) AccountRelatedUsersExpansion (accountRelatedUsersLogic, keyed by externalId) - └── (usage) AccountBillingExpansion kind="usage" (accountBillingLogic — a saved billing-usage insight) + ├── (usage) AccountBillingExpansion kind="usage" (accountBillingLogic — a saved billing-usage insight) + └── (spend) AccountBillingExpansion kind="spend" (accountBillingLogic — saved billing-spend insights) ``` ### Logics and what each owns @@ -47,15 +49,16 @@ AccountsTabContent ── binds dataNodeLogic(ACCOUNTS_HOGQL_DATA_NODE_KEY, acc `accountsLogic.hogqlQuery` builds a `DataTableNode` wrapping an `AccountsQuery` (`select`, plus optional `search`, `tagNames`, `allRolesUnassigned`, `assignedToUserIds`, `filterExpression`, `metrics`, `orderBy`). The backend runner (`accounts_query_runner`) returns **rows as arrays** aligned to `visibleColumnNames`. `assignedToUserIds` is the "assigned to" filter — a list of user ids the runner expands into `csm IN ids OR account_executive IN ids` (the single user-facing role filter; there are no separate per-role CSM/AE/owner filters). The `allRolesUnassigned` flag (the "Unassigned only" option, surfaced inside the "Assigned to" picker — mutually exclusive with picking people via the cascade in `accountsLogic` listeners) restricts to accounts with no csm/AE/owner. The "My accounts" checkbox is a client-side shortcut: `accountsLogic` resolves it to `[currentUserId]` (from `userLogic`) before the query is sent, so the backend only ever receives explicit ids and a shared URL resolves to the same accounts for every viewer. Two cell shapes matter: - **`name` column** (mandatory, `ACCOUNTS_NAME_COLUMN`) — emitted as `tuple(name, external_id, id)`, read as `{ name, external_id, id }`. This is the row's identity: `id` (the account PK) drives expansion/scroll/role updates; `external_id` is the copy-able group key. `getNameCell()` in `AccountsHogQLTable.tsx` is the canonical accessor; never assume a column index. +- **`health_score` column** — a synthetic query-time cell emitted only when selected. It is not a `system.accounts` field: the backend fetches the visible page, batches a 30-day activity baseline + per-account metrics query, then replaces the placeholder cell with `{ score, status, factors, noDataReason, ... }`. It is visible by default and in the column picker, but intentionally **not sortable or filterable** because the score is page-row post-processing, not a globally ordered account column. - **role columns** (`csm`, `account_executive`, `account_owner`) — emitted as `tuple(id, email)`, rendered with `MemberSelect`. Sorting these uses `tupleElement(col, 2)` (email) so visual order matches. -Default columns (`ACCOUNTS_HOGQL_DEFAULT_SELECT`): `name`, `tag_names`, `notebook_count`, `csm`, `account_executive`, `account_owner`. The name column is force-kept (`ensureNameColumn`) — removing it breaks identity, scroll, and role edits. Extra columns come from account properties, lazy/virtual-table joins under `system.accounts`, data-warehouse joins, or freeform SQL — all assembled by `buildAccountColumnGroups`. +Default columns (`ACCOUNTS_HOGQL_DEFAULT_SELECT`): `name`, `health_score`, `tag_names`, `notebook_count`, `csm`, `account_executive`, `account_owner`. The name column is force-kept (`ensureNameColumn`) — removing it breaks identity, scroll, and role edits. Extra columns come from account health, account properties, lazy/virtual-table joins under `system.accounts`, data-warehouse joins, or freeform SQL — all assembled by `buildAccountColumnGroups`. Sort safety: removing the sorted column drops the sort (`clearSortIfColumnRemoved`), else the backend gets an `orderBy` referencing a missing alias. ## The expanded row -`AccountsHogQLTable.useExpandable()` makes expansion **controlled** by `accountsExpansionLogic`: `isRowExpanded` reads `expandedAccountIds`, `onRowExpand`/`onRowCollapse` dispatch `toggleAccountExpanded`. The body is `AccountNotebooksExpansion`, a `LemonTabs` over `notes` / `users` / `usage` (`AccountExpansionTab`) plus the Useful links sidebar. Active tab comes from `activeTabFor(accountId)` (defaults to `notes`). +`AccountsHogQLTable.useExpandable()` makes expansion **controlled** by `accountsExpansionLogic`: `isRowExpanded` reads `expandedAccountIds`, `onRowExpand`/`onRowCollapse` dispatch `toggleAccountExpanded`. The body is `AccountNotebooksExpansion`, a `LemonTabs` over `health` / `notes` / `users` / `usage` / `spend` (`AccountExpansionTab`) plus the Useful links sidebar. Active tab comes from `activeTabFor(accountId)` (defaults to `health`, so the health score is auditable immediately after expanding a row). The Usage tab renders an existing saved billing-usage insight — **point users to it, don't rebuild usage as a new insight.** @@ -79,7 +82,7 @@ The tool is registered for the page regardless of agent mode. The Customer analy ### Backend touchpoints - `products/customer_analytics/backend/models` — the `Account` model (`external_id` = group key). -- `products/customer_analytics/backend/` — `accounts_query_runner` (builds the list rows + cell tuples). +- `products/customer_analytics/backend/` — `accounts_query_runner` (builds the list rows + cell tuples) and `account_health_score` (30-day query-time score contract). - `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). @@ -115,7 +118,7 @@ We track user actions on the Accounts list with `posthog.capture()`. Conventions | `customer analytics account role assigned` | `accountsLogic` `updateAccountRole` | `role` (`csm` \| `account_executive` \| `account_owner`), `is_assigned`, `assigned_user_id`, `source` (always `list_row` today) | | `customer analytics account link clicked` | `AccountNotebooksExpansion.tsx` useful-link `onClick` | `link_key`, `has_destination` | | `customer analytics account note clicked` | `AccountNotebooksExpansion.tsx` note `` `onClick` | `notebook_short_id` | -| `customer analytics account tab viewed` | `accountsExpansionLogic` `setActiveTab` listener (genuine tab clicks only; programmatic `openAccountTab` navigation does not fire it) | `tab` (`notes` \| `users` \| `usage`) | +| `customer analytics account tab viewed` | `accountsExpansionLogic` `setActiveTab` listener (genuine tab clicks only; programmatic `openAccountTab` navigation does not fire it) | `tab` (`health` \| `notes` \| `users` \| `usage` \| `spend`) | | `customer analytics account related user clicked` | `AccountRelatedUsersExpansion.tsx` user `` `onClick` | _(none — customer end-user PII kept out)_ | > **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/AccountHealthScore.test.tsx b/products/customer_analytics/frontend/components/Accounts/AccountHealthScore.test.tsx new file mode 100644 index 000000000000..32314cd30382 --- /dev/null +++ b/products/customer_analytics/frontend/components/Accounts/AccountHealthScore.test.tsx @@ -0,0 +1,58 @@ +import '@testing-library/jest-dom' + +import { render, screen } from '@testing-library/react' + +import { AccountHealthScoreBadge, AccountHealthScoreExplanation, parseAccountHealthScore } from './AccountHealthScore' +import type { AccountHealthScore } from './AccountHealthScore' + +const SCORE: AccountHealthScore = { + score: 82, + status: 'healthy', + lookbackDays: 30, + activityEvent: '$pageview', + noDataReason: null, + lastActivityAt: '2026-06-01T12:00:00Z', + factors: [ + { + key: 'activity', + label: 'Activity volume', + value: 120, + previousValue: 80, + score: 90, + weight: 0.35, + description: 'Activity events normalized against the account baseline.', + reason: null, + }, + ], +} + +describe('AccountHealthScore', () => { + it('parses valid serialized health score cells', () => { + expect(parseAccountHealthScore(SCORE)).toEqual(SCORE) + expect(parseAccountHealthScore({ status: 'healthy' })).toBeNull() + expect(parseAccountHealthScore(null)).toBeNull() + }) + + it('renders an accessible table badge', () => { + render() + expect(screen.getByLabelText('Account health: 82 Healthy')).toBeInTheDocument() + }) + + it('renders auditable factor details', () => { + render() + expect(screen.getByText('Health score')).toBeInTheDocument() + expect(screen.getByText('Activity volume')).toBeInTheDocument() + expect(screen.getByText('90/100')).toBeInTheDocument() + expect(screen.getByText('35%')).toBeInTheDocument() + }) + + it('explains no-data scores', () => { + render( + + ) + expect(screen.getByText('No score yet')).toBeInTheDocument() + expect(screen.getByText('No activity yet.')).toBeInTheDocument() + }) +}) diff --git a/products/customer_analytics/frontend/components/Accounts/AccountHealthScore.tsx b/products/customer_analytics/frontend/components/Accounts/AccountHealthScore.tsx new file mode 100644 index 000000000000..88b71d498f09 --- /dev/null +++ b/products/customer_analytics/frontend/components/Accounts/AccountHealthScore.tsx @@ -0,0 +1,146 @@ +export type AccountHealthStatus = 'healthy' | 'neutral' | 'at_risk' | 'no_data' + +export type AccountHealthFactor = { + key: string + label: string + value: number | string | null + previousValue: number | string | null + score: number | null + weight: number + description: string + reason: string | null +} + +export type AccountHealthScore = { + score: number | null + status: AccountHealthStatus + lookbackDays: number + activityEvent: string + factors: AccountHealthFactor[] + noDataReason: string | null + lastActivityAt: string | null +} + +const STATUS_LABELS: Record = { + healthy: 'Healthy', + neutral: 'Neutral', + at_risk: 'At risk', + no_data: 'No data', +} + +const STATUS_CLASSES: Record = { + healthy: 'border-success bg-success-highlight text-success', + neutral: 'border-warning bg-warning-highlight text-warning', + at_risk: 'border-danger bg-danger-highlight text-danger', + no_data: 'border-border bg-bg-light text-muted', +} + +function isNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value) +} + +export function parseAccountHealthScore(value: unknown): AccountHealthScore | null { + if (!value || typeof value !== 'object') { + return null + } + const candidate = value as Partial + if ( + !['healthy', 'neutral', 'at_risk', 'no_data'].includes(candidate.status ?? '') || + !isNumber(candidate.lookbackDays) || + typeof candidate.activityEvent !== 'string' || + !Array.isArray(candidate.factors) + ) { + return null + } + if (candidate.score !== null && candidate.score !== undefined && !isNumber(candidate.score)) { + return null + } + return candidate as AccountHealthScore +} + +function formatValue(value: number | string | null): string { + if (value === null || value === undefined || value === '') { + return '—' + } + if (typeof value === 'number') { + return Number.isInteger(value) ? value.toLocaleString() : value.toFixed(1) + } + return value +} + +function factorScoreLabel(score: number | null): string { + return score === null ? 'Not scored' : `${score}/100` +} + +export function AccountHealthScoreBadge({ score }: { score: AccountHealthScore | null }): JSX.Element { + if (!score) { + return + } + const label = STATUS_LABELS[score.status] + return ( + + {score.score ?? '—'} + {label} + + ) +} + +export function AccountHealthScoreExplanation({ score }: { score: AccountHealthScore | null }): JSX.Element { + if (!score || score.status === 'no_data') { + return ( +
+

Health score

+
No score yet
+

+ {score?.noDataReason ?? + 'This account does not have enough connected account activity to calculate a score.'} +

+
+ ) + } + + return ( +
+
+

Health score

+
+ {score.score} + {STATUS_LABELS[score.status]} +
+

+ Last {score.lookbackDays} days of {score.activityEvent} activity. Query-time prototype; no LLMs, no + persisted score. +

+
+
+ {score.factors.map((factor) => ( +
+
+
{factor.label}
+
{factorScoreLabel(factor.score)}
+
+
{factor.description}
+
+
+
Value
+
{formatValue(factor.value)}
+
+
+
Previous
+
{formatValue(factor.previousValue)}
+
+
+
Weight
+
{Math.round(factor.weight * 100)}%
+
+
+ {factor.reason ?
{factor.reason}
: null} +
+ ))} +
+
+ ) +} diff --git a/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx b/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx index d2c21985556e..f57a90288089 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountNotebooksExpansion.tsx @@ -20,6 +20,8 @@ import { urls } from 'scenes/urls' import type { AccountNotebookApi } from 'products/customer_analytics/frontend/generated/api.schemas' import { AccountBillingExpansion } from './AccountBillingExpansion' +import { AccountHealthScoreExplanation } from './AccountHealthScore' +import type { AccountHealthScore } from './AccountHealthScore' import { accountLinksLogic } from './accountLinksLogic' import { accountNotebooksLogic } from './accountNotebooksLogic' import { AccountRelatedUsersExpansion } from './AccountRelatedUsersExpansion' @@ -89,9 +91,11 @@ function UsefulLinks({ accountId }: { accountId: string }): JSX.Element { export function AccountNotebooksExpansion({ accountId, externalId, + healthScore, }: { accountId: string externalId: string + healthScore: AccountHealthScore | null }): JSX.Element { const logic = accountNotebooksLogic({ accountId }) const { notebooks, notebooksLoading } = useValues(logic) @@ -168,6 +172,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/AccountsHogQLTable.tsx b/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx index 80586e1f1804..92c8d4471ca8 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx @@ -15,10 +15,15 @@ import { DataTableNode } from '~/queries/schema/schema-general' import { QueryContext, QueryContextColumn, QueryContextColumnComponent } from '~/queries/types' import { ACCOUNTS_HOGQL_DATA_NODE_KEY } from '../../constants' +import { AccountHealthScoreBadge, parseAccountHealthScore } from './AccountHealthScore' import { AccountNotebooksExpansion } from './AccountNotebooksExpansion' -import { ACCOUNTS_NAME_COLUMN, accountsColumnConfigLogic } from './accountsColumnConfigLogic' +import { + ACCOUNTS_HEALTH_SCORE_COLUMN, + ACCOUNTS_NAME_COLUMN, + accountsColumnConfigLogic, +} from './accountsColumnConfigLogic' import { accountsExpansionLogic } from './accountsExpansionLogic' -import { AccountRoleKey, accountsLogic } from './accountsLogic' +import { AccountRoleKey, accountsLogic, isAccountsColumnSortable } from './accountsLogic' type AccountAssignment = { id: number; email: string } | null @@ -33,6 +38,7 @@ const ROLE_LABELS: Record = { const COLUMN_WIDTHS = { name: '240px', + health_score: '160px', tag_names: '280px', notebook_count: '80px', csm: '220px', @@ -111,6 +117,10 @@ function NotebookCountCell({ record }: { record: unknown }): JSX.Element { return count > 0 ? {count} : } +function HealthScoreCell({ value }: { value: unknown }): JSX.Element { + return +} + function RoleAssignmentCell({ record, role }: { record: unknown; role: AccountRoleKey }): JSX.Element { const { isRoleSaving, accountOverrides } = useValues(accountsLogic) const { visibleColumnNames } = useValues(accountsColumnConfigLogic) @@ -189,6 +199,7 @@ type KnownColumnTemplate = { label?: string width?: string render?: QueryContextColumnComponent + sortable?: boolean } const KNOWN_COLUMN_TEMPLATES: Record = { @@ -197,6 +208,12 @@ const KNOWN_COLUMN_TEMPLATES: Record = { width: COLUMN_WIDTHS.name, render: ({ record }) => , }, + health_score: { + label: 'Health', + width: COLUMN_WIDTHS.health_score, + render: ({ value }) => , + sortable: false, + }, tag_names: { label: 'Tags', width: COLUMN_WIDTHS.tag_names, @@ -231,8 +248,14 @@ function useContextColumns(): Record { for (const key of visibleColumnNames) { const template = KNOWN_COLUMN_TEMPLATES[key] const label = template?.label ?? key + const sortable = template?.sortable ?? isAccountsColumnSortable(key) columns[key] = { - renderTitle: () => , + renderTitle: () => + sortable ? ( + + ) : ( + {label} + ), width: template?.width, render: template?.render, } @@ -267,8 +290,15 @@ function useExpandable(): QueryContext['expandable'] { }, expandedRowRender: ({ result }) => { const cell = getNameCell(result, visibleColumnNames) + const healthScore = parseAccountHealthScore( + getCellAt(result, visibleColumnNames, ACCOUNTS_HEALTH_SCORE_COLUMN) + ) return cell ? ( - + ) : null }, }), @@ -289,6 +319,11 @@ const SKELETON_COLUMNS: LemonTableColumns<{ key: number }> = [ ), }, + { + title: 'Health', + width: COLUMN_WIDTHS.health_score, + render: () => , + }, { title: 'Tags', width: COLUMN_WIDTHS.tag_names, diff --git a/products/customer_analytics/frontend/components/Accounts/AccountsMaxTools.tsx b/products/customer_analytics/frontend/components/Accounts/AccountsMaxTools.tsx index fdbd1fa69289..ef4e5d2acd02 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountsMaxTools.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountsMaxTools.tsx @@ -3,7 +3,7 @@ import { useActions } from 'kea' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { useMaxTool } from 'scenes/max/useMaxTool' -import { AccountExpansionTab } from './accountsExpansionLogic' +import type { AccountExpansionTab } from './accountsExpansionLogic' import { accountsLogic } from './accountsLogic' interface OpenAccountResult { @@ -25,7 +25,12 @@ export function AccountsMaxTools(): JSX.Element | null { lemonToast.error("Couldn't open that account.") return } - openAccount(result.account_id, result.external_id ?? null, result.account_name ?? '', result.tab ?? 'usage') + openAccount( + result.account_id, + result.external_id ?? null, + result.account_name ?? '', + result.tab ?? 'health' + ) }, }) diff --git a/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx b/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx index dd59607bd0a7..d9ec04f064f2 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountsTab.stories.tsx @@ -8,6 +8,8 @@ import { urls } from 'scenes/urls' import { mswDecorator } from '~/mocks/browser' +import type { AccountHealthScore } from './AccountHealthScore' + const QUERY_ENDPOINT = '/api/environments/:team_id/query/:kind/' const ACCOUNT_RETRIEVE_ENDPOINT = 'api/projects/:team_id/accounts/:account_id/' const ACCOUNT_NOTEBOOKS_ENDPOINT = 'api/projects/:team_id/accounts/:account_id/notebooks/' @@ -16,12 +18,20 @@ const INSIGHTS_ENDPOINT = 'api/environments/:team_id/insights/' type AccountNameCell = { name: string; external_id: string | null; id: string } type AccountRoleCell = [number, string] | null -type AccountRow = [AccountNameCell, string[], number, AccountRoleCell, AccountRoleCell, AccountRoleCell] +type AccountRow = [ + AccountNameCell, + AccountHealthScore, + string[], + number, + AccountRoleCell, + AccountRoleCell, + AccountRoleCell, +] function buildAccountsQueryResponse(rows: AccountRow[]): Record { return { kind: 'AccountsQuery', - columns: ['name', 'tag_names', 'notebook_count', 'csm', 'account_executive', 'account_owner'], + columns: ['name', 'health_score', 'tag_names', 'notebook_count', 'csm', 'account_executive', 'account_owner'], results: rows, types: [], hogql: '', @@ -33,18 +43,112 @@ function buildAccountsQueryResponse(rows: AccountRow[]): Record } } +function healthScore(score: number | null, status: AccountHealthScore['status']): AccountHealthScore { + if (score === null) { + return { + score: null, + status: 'no_data', + lookbackDays: 30, + activityEvent: '$pageview', + factors: [], + noDataReason: 'No $pageview activity in the current or previous 30-day window.', + lastActivityAt: null, + } + } + return { + score, + status, + lookbackDays: 30, + activityEvent: '$pageview', + lastActivityAt: '2026-05-20T12:00:00Z', + noDataReason: null, + factors: [ + { + key: 'activity', + label: 'Activity volume', + value: score >= 75 ? 820 : score >= 40 ? 240 : 18, + previousValue: score >= 75 ? 640 : score >= 40 ? 260 : 220, + score: score >= 75 ? 100 : score >= 40 ? 54 : 8, + weight: 0.35, + description: "$pageview events in the last 30 days, normalized against the team's active-account p90.", + reason: null, + }, + { + key: 'active_users', + label: 'Active users', + value: score >= 75 ? 41 : score >= 40 ? 12 : 2, + previousValue: null, + score: score >= 75 ? 91 : score >= 40 ? 47 : 10, + weight: 0.25, + description: + "Distinct users with $pageview activity, normalized against the team's active-account p90.", + reason: null, + }, + { + key: 'frequency', + label: 'Active days', + value: score >= 75 ? 27 : score >= 40 ? 13 : 1, + previousValue: null, + score: score >= 75 ? 90 : score >= 40 ? 43 : 3, + weight: 0.2, + description: 'Days with $pageview activity during the 30-day window.', + reason: null, + }, + { + key: 'recency', + label: 'Recency', + value: score >= 75 ? 1 : score >= 40 ? 5 : 24, + previousValue: null, + score: score >= 75 ? 97 : score >= 40 ? 83 : 20, + weight: 0.1, + description: 'How recently this account had $pageview activity.', + reason: null, + }, + { + key: 'trend', + label: 'Trend', + value: score >= 75 ? 820 : score >= 40 ? 240 : 18, + previousValue: score >= 75 ? 640 : score >= 40 ? 260 : 220, + score: score >= 75 ? 100 : score >= 40 ? 92 : 8, + weight: 0.1, + description: 'Current 30-day activity compared with the previous 30 days.', + reason: null, + }, + ], + } +} + const SAMPLE_ROWS: AccountRow[] = [ [ { name: 'Acme Inc', external_id: 'cust_acme_001', id: 'acc-1' }, + healthScore(92, 'healthy'), ['enterprise', 'priority'], 0, [1, 'alice@posthog.com'], [2, 'bob@posthog.com'], null, ], - [{ name: 'Globex', external_id: 'cust_globex_002', id: 'acc-2' }, [], 0, null, null, null], [ - { name: 'Hooli', external_id: null, id: 'acc-3' }, + { name: 'Globex', external_id: 'cust_globex_002', id: 'acc-2' }, + healthScore(58, 'neutral'), + [], + 0, + null, + null, + null, + ], + [ + { name: 'Initech', external_id: 'cust_initech_003', id: 'acc-3' }, + healthScore(18, 'at_risk'), + [], + 0, + null, + null, + null, + ], + [ + { name: 'Hooli', external_id: null, id: 'acc-4' }, + healthScore(null, 'no_data'), ['scaleup'], 0, [1, 'alice@posthog.com'], @@ -56,6 +160,7 @@ const SAMPLE_ROWS: AccountRow[] = [ const SINGLE_ROW: AccountRow[] = [ [ { name: 'Acme Inc', external_id: 'cust_acme_001', id: 'acc-1' }, + healthScore(92, 'healthy'), ['enterprise', 'priority'], 1, [1, 'alice@posthog.com'], @@ -187,6 +292,7 @@ async function expandFirstRow(canvasElement: HTMLElement, notesLoadedText: strin await userEvent.click(await canvas.findByTitle('Show more')) await canvas.findByText('Useful links') await canvas.findByText('Organization') + await userEvent.click(await canvas.findByRole('tab', { name: 'Notes' })) await canvas.findByText(notesLoadedText) } @@ -282,6 +388,28 @@ export const RowExpandedEmpty: Story = { }, } +export const RowExpandedHealthScore: Story = { + render: () => , + decorators: [ + mswDecorator({ + get: { + [ACCOUNT_RETRIEVE_ENDPOINT]: ACCOUNT_WITH_LINKS, + [ACCOUNT_NOTEBOOKS_ENDPOINT]: { count: 0, next: null, previous: null, results: [] }, + }, + post: { + [QUERY_ENDPOINT]: mockAccountsQuery(SAMPLE_ROWS), + }, + }), + ], + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(await canvas.findByTitle('Show more')) + await canvas.findByText('Health score') + await canvas.findByText('Activity volume') + await canvas.findByText('No LLMs, no persisted score', { exact: false }) + }, +} + export const RowExpandedWithNote: Story = { render: () => , decorators: [ diff --git a/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts index bd489c1281a6..0b0af6d15282 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts @@ -21,9 +21,11 @@ import { AccountsEvents } from './constants' // Mandatory — the backend emits it as `tuple(name, external_id, id)` so the // row identity (id) and copy-able external_id ride along with the display name. export const ACCOUNTS_NAME_COLUMN = 'name' +export const ACCOUNTS_HEALTH_SCORE_COLUMN = 'health_score' export const ACCOUNTS_HOGQL_DEFAULT_SELECT: string[] = [ ACCOUNTS_NAME_COLUMN, + ACCOUNTS_HEALTH_SCORE_COLUMN, 'accounts.tags.names AS tag_names', 'accounts.notebooks.count AS notebook_count', 'csm', @@ -62,7 +64,7 @@ export const ACCOUNTS_ACCOUNTS_TABLE_NAME = 'system.accounts' // of which name the backend hands us. const ACCOUNTS_JOIN_SOURCE_TABLE_NAMES = new Set(['accounts', ACCOUNTS_ACCOUNTS_TABLE_NAME]) -export type AccountColumnGroupKey = 'account_properties' | 'sql_expression' | `accounts.${string}` +export type AccountColumnGroupKey = 'account_health' | 'account_properties' | 'sql_expression' | `accounts.${string}` export type AccountColumnOption = { name: string @@ -176,6 +178,11 @@ export function buildAccountColumnGroups( } return [ + { + key: 'account_health', + label: 'Account health', + options: [{ name: 'Health score', expression: ACCOUNTS_HEALTH_SCORE_COLUMN, type: 'json' }], + }, { key: 'account_properties', label: 'Account properties', options: directOptions }, ...joinGroups, { key: 'sql_expression', label: 'SQL expression', options: [], isFreeform: true }, diff --git a/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts index 35c8c7ca7215..c7eef5d1b958 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsExpansionLogic.ts @@ -4,9 +4,9 @@ import posthog from 'posthog-js' import type { accountsExpansionLogicType } from './accountsExpansionLogicType' import { AccountsEvents } from './constants' -export type AccountExpansionTab = 'notes' | 'users' | 'usage' | 'spend' +export type AccountExpansionTab = 'health' | 'notes' | 'users' | 'usage' | 'spend' -export const DEFAULT_ACCOUNT_TAB: AccountExpansionTab = 'notes' +export const DEFAULT_ACCOUNT_TAB: AccountExpansionTab = 'health' export const accountsExpansionLogic = kea([ path(['scenes', 'customerAnalytics', 'accounts', 'accountsExpansionLogic']), diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts index 8876c266375e..a81a85afa34b 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts @@ -14,6 +14,7 @@ import { accountsPartialUpdate, accountsRetrieve } from 'products/customer_analy import type { AccountApi } from 'products/customer_analytics/frontend/generated/api.schemas' import { + ACCOUNTS_HEALTH_SCORE_COLUMN, ACCOUNTS_HOGQL_DEFAULT_SELECT, ACCOUNTS_NAME_COLUMN, accountsColumnConfigLogic, @@ -252,6 +253,12 @@ describe('accountsLogic', () => { logic.actions.toggleSort('name') expect(orderByOf(logic.values.hogqlQuery.source)).toEqual(['name DESC']) }) + + it('does not sort by query-time health score', () => { + logic.actions.toggleSort(ACCOUNTS_HEALTH_SCORE_COLUMN) + expect(logic.values.sortOrder).toBeNull() + expect(orderByOf(logic.values.hogqlQuery.source)).toBeUndefined() + }) }) describe('selectColumns', () => { diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts index da6f21de1882..75aca8344cc4 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts @@ -20,6 +20,7 @@ import type { import { ACCOUNTS_HOGQL_DATA_NODE_KEY, CUSTOMER_ANALYTICS_DEFAULT_QUERY_TAGS } from '../../constants' import { + ACCOUNTS_HEALTH_SCORE_COLUMN, ACCOUNTS_HOGQL_DEFAULT_SELECT, ACCOUNTS_NAME_COLUMN, accountsColumnConfigLogic, @@ -86,6 +87,11 @@ export type AccountSortOrder = { column: AccountSortableColumn; direction: Accou // Columns that are HogQL `Tuple(id, email)` — sort by the `email` element so the // order matches what the user sees on screen rather than the opaque user id. const TUPLE_SORT_COLUMNS = new Set(['csm', 'account_executive', 'account_owner']) +const NON_SORTABLE_COLUMNS = new Set([ACCOUNTS_HEALTH_SCORE_COLUMN]) + +export function isAccountsColumnSortable(column: string): boolean { + return !NON_SORTABLE_COLUMNS.has(column) +} // Resolve the HogQL expression to use in ORDER BY for a sortable column. // HogQL ORDER BY resolves SELECT aliases by name, so the visible column name @@ -429,6 +435,9 @@ export const accountsLogic = kea([ } }, toggleSort: ({ column }) => { + if (!isAccountsColumnSortable(column)) { + return + } const current = values.sortOrder let next: AccountSortOrder if (!current || current.column !== column) { From 2381fc43db80d830c024fbaa8a6f3f1adf220c92 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:07:41 +0000 Subject: [PATCH 2/4] fix(customer-analytics): harden account health scoring Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../hogql_queries/account_health_score.py | 85 ++++++++++++++++-- .../hogql_queries/accounts_query_runner.py | 28 ++++-- .../test/test_accounts_query_runner.py | 88 +++++++++++++++++++ .../Accounts/AccountsHogQLTable.tsx | 31 ++++--- .../Accounts/accountsColumnConfigLogic.ts | 19 +++- .../components/Accounts/accountsLogic.test.ts | 30 ++++++- .../components/Accounts/accountsLogic.ts | 12 ++- 7 files changed, 258 insertions(+), 35 deletions(-) diff --git a/products/customer_analytics/backend/hogql_queries/account_health_score.py b/products/customer_analytics/backend/hogql_queries/account_health_score.py index 950659903ffc..512dbeaffd7f 100644 --- a/products/customer_analytics/backend/hogql_queries/account_health_score.py +++ b/products/customer_analytics/backend/hogql_queries/account_health_score.py @@ -1,9 +1,12 @@ +import json import math +import hashlib from dataclasses import dataclass from datetime import datetime, timedelta from typing import Any, Literal, TypedDict from zoneinfo import ZoneInfo +from django.core.cache import cache from django.core.exceptions import ObjectDoesNotExist from django.utils import timezone @@ -18,11 +21,13 @@ from posthog.models.team import Team from posthog.models.user import User +from products.actions.backend.models.action import Action from products.customer_analytics.backend.constants import DEFAULT_ACTIVITY_EVENT from products.customer_analytics.backend.models.team_customer_analytics_config import TeamCustomerAnalyticsConfig ACCOUNT_HEALTH_SCORE_COLUMN = "health_score" ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS = 30 +ACCOUNT_HEALTH_BASELINE_CACHE_TTL_SECONDS = 60 * 60 AccountHealthStatus = Literal["healthy", "neutral", "at_risk", "no_data"] @@ -63,11 +68,13 @@ class AccountHealthBaseline: p90_active_users: float -def no_data_health_score(reason: str, activity_event: str = "Activity") -> AccountHealthScore: +def no_data_health_score( + reason: str, activity_event: str = "Activity", lookback_days: int = ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS +) -> AccountHealthScore: return { "score": None, "status": "no_data", - "lookbackDays": ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS, + "lookbackDays": lookback_days, "activityEvent": activity_event, "factors": [], "noDataReason": reason, @@ -87,6 +94,7 @@ def score_account_health( return no_data_health_score( f"No {activity_event} activity in the current or previous {lookback_days}-day window.", activity_event, + lookback_days, ) factors: list[AccountHealthFactor] = [ @@ -145,6 +153,7 @@ def score_account_health( return no_data_health_score( f"Not enough {activity_event} activity to calculate a health score.", activity_event, + lookback_days, ) total_weight = sum(factor["weight"] for factor in scored_factors) @@ -251,6 +260,18 @@ def _activity_event_label(activity_event: dict[str, Any]) -> str: return "Activity" +def _baseline_cache_key( + *, team_id: int, group_type_index: int, activity_event: dict[str, Any], date_to: datetime, lookback_days: int +) -> str: + if timezone.is_naive(date_to): + date_to = timezone.make_aware(date_to, ZoneInfo("UTC")) + event_hash = hashlib.sha256( + json.dumps(activity_event, sort_keys=True, default=str, separators=(",", ":")).encode() + ).hexdigest()[:16] + bucket = date_to.astimezone(ZoneInfo("UTC")).strftime("%Y%m%d%H") + return f"customer_analytics:account_health_baseline:v1:{team_id}:{group_type_index}:{lookback_days}:{bucket}:{event_hash}" + + class AccountHealthScoreCalculator: def __init__( self, @@ -290,6 +311,16 @@ def score_accounts(self, external_ids_by_account_id: dict[str, str | None]) -> d for account_id in external_ids_by_account_id } + actions = self._actions_for_activity_event(activity_event) + if actions is None: + return { + account_id: no_data_health_score( + "The configured activity source is no longer available.", + activity_label, + ) + for account_id in external_ids_by_account_id + } + accounts_with_external_ids = { account_id: external_id for account_id, external_id in external_ids_by_account_id.items() @@ -306,13 +337,20 @@ def score_accounts(self, external_ids_by_account_id: dict[str, str | None]) -> d date_to = timezone.now() date_from = date_to - timedelta(days=ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS) previous_date_from = date_to - timedelta(days=ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS * 2) - activity_filter = hog_function_filters_to_expr(filters, self.team, {}) + activity_filter = hog_function_filters_to_expr(filters, self.team, actions) baseline = self._load_baseline( group_type_index=group_type_index, date_from=date_from, date_to=date_to, activity_filter=activity_filter, + cache_key=_baseline_cache_key( + team_id=self.team.id, + group_type_index=group_type_index, + activity_event=activity_event, + date_to=date_to, + lookback_days=ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS, + ), ) metrics_by_external_id = self._load_account_metrics( group_type_index=group_type_index, @@ -351,6 +389,19 @@ def _activity_event(self, config: TeamCustomerAnalyticsConfig | None) -> dict[st event = config.activity_event return event if isinstance(event, dict) and event else DEFAULT_ACTIVITY_EVENT + def _actions_for_activity_event(self, activity_event: dict[str, Any]) -> dict[int, Action] | None: + if activity_event.get("kind") != "ActionsNode": + return {} + try: + action_id = int(activity_event["id"]) + except (KeyError, TypeError, ValueError): + return None + try: + action = Action.objects.get(id=action_id, team__project_id=self.team.project_id) + except ObjectDoesNotExist: + return None + return {action_id: action} + def _config(self) -> TeamCustomerAnalyticsConfig | None: try: return TeamCustomerAnalyticsConfig.objects.get(team_id=self.team.id) @@ -364,9 +415,16 @@ def _load_baseline( date_from: datetime, date_to: datetime, activity_filter: ast.Expr, + cache_key: str, ) -> AccountHealthBaseline: + cached = cache.get(cache_key) + if isinstance(cached, dict): + return AccountHealthBaseline( + p90_activity_count=_read_float(cached.get("p90_activity_count")), + p90_active_users=_read_float(cached.get("p90_active_users")), + ) + group_expr = f"toString($group_{group_type_index})" - current_condition = "timestamp >= {date_from} AND timestamp < {date_to}" query = parse_select( f""" SELECT @@ -375,8 +433,8 @@ def _load_baseline( FROM ( SELECT {group_expr} AS group_key, - countIf({current_condition}) AS current_count, - uniqIf(person_id, {current_condition}) AS active_users + count() AS current_count, + uniq(person_id) AS active_users FROM events WHERE timestamp >= {{date_from}} AND timestamp < {{date_to}} @@ -399,11 +457,20 @@ def _load_baseline( timings=self.timings, modifiers=self.modifiers, ) - row = response.results[0] if response.results else [0, 0] - return AccountHealthBaseline( - p90_activity_count=_read_float(row[0] if len(row) > 0 else 0), + row = response.results[0] if response.results else [] + baseline = AccountHealthBaseline( + p90_activity_count=_read_float(row[0] if row else 0), p90_active_users=_read_float(row[1] if len(row) > 1 else 0), ) + cache.set( + cache_key, + { + "p90_activity_count": baseline.p90_activity_count, + "p90_active_users": baseline.p90_active_users, + }, + ACCOUNT_HEALTH_BASELINE_CACHE_TTL_SECONDS, + ) + return baseline def _load_account_metrics( self, diff --git a/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py b/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py index aec7643cf44e..bf830779d123 100644 --- a/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py @@ -1,3 +1,5 @@ +import structlog + from posthog.schema import AccountsQuery, AccountsQueryResponse, CachedAccountsQueryResponse from posthog.hogql import ast @@ -14,8 +16,11 @@ from products.customer_analytics.backend.hogql_queries.account_health_score import ( ACCOUNT_HEALTH_SCORE_COLUMN, AccountHealthScoreCalculator, + no_data_health_score, ) +logger = structlog.get_logger(__name__) + NAME_COLUMN = "name" DEFAULT_COLUMNS = (NAME_COLUMN, ACCOUNT_HEALTH_SCORE_COLUMN, "created_at") @@ -288,12 +293,23 @@ def _add_health_scores(self, results: list[list], name_index: int) -> None: external_id = cell.get("external_id") external_ids_by_account_id[cell["id"]] = external_id if isinstance(external_id, str) else None - scores = AccountHealthScoreCalculator( - team=self.team, - user=self.user, - timings=self.timings, - modifiers=self.modifiers, - ).score_accounts(external_ids_by_account_id) + try: + scores = AccountHealthScoreCalculator( + team=self.team, + user=self.user, + timings=self.timings, + modifiers=self.modifiers, + ).score_accounts(external_ids_by_account_id) + except Exception: + logger.exception( + "account_health_score.enrichment_failed", + team_id=self.team.id, + account_count=len(external_ids_by_account_id), + ) + scores = { + account_id: no_data_health_score("Health score could not be calculated for this page.") + for account_id in external_ids_by_account_id + } for row in results: if len(row) <= max(name_index, health_index): continue diff --git a/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py b/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py index 19514b112d5b..955e8d7a5aa9 100644 --- a/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py @@ -6,6 +6,7 @@ from posthog.test.base import ClickhouseTestMixin, NonAtomicBaseTest, _create_event, flush_persons_and_events from unittest.mock import patch +from django.core.cache import cache from django.test import override_settings from django.utils import timezone @@ -21,6 +22,7 @@ from posthog.models.team import Team from posthog.rbac.user_access_control import UserAccessControlError +from products.actions.backend.models.action import Action from products.customer_analytics.backend.hogql_queries.account_health_score import ( ACCOUNT_HEALTH_SCORE_COLUMN, AccountActivityMetrics, @@ -113,8 +115,26 @@ def test_score_account_health_returns_no_data_for_empty_current_and_previous_win ) self.assertEqual(score["status"], "no_data") self.assertEqual(score["score"], None) + self.assertEqual(score["lookbackDays"], 30) self.assertIn("No $pageview activity", score["noDataReason"]) + def test_score_account_health_preserves_custom_no_data_lookback(self): + score = score_account_health( + AccountActivityMetrics( + current_count=0, + previous_count=0, + active_users=0, + active_days=0, + last_activity_at=None, + ), + AccountHealthBaseline(p90_activity_count=0, p90_active_users=0), + activity_event="$pageview", + date_to=datetime(2026, 6, 1, 12, 0, tzinfo=ZoneInfo("UTC")), + lookback_days=14, + ) + self.assertEqual(score["lookbackDays"], 14) + self.assertIn("14-day window", score["noDataReason"]) + def test_default_columns_include_health_score(self): create_account(team_id=self.team.id, name="A") runner, response = self._run_query() @@ -452,7 +472,48 @@ def test_health_score_unsupported_activity_source_returns_no_data(self): self.assertEqual(response.results[0][health_idx]["status"], "no_data") self.assertIn("not supported", response.results[0][health_idx]["noDataReason"]) + def test_health_score_invalid_action_source_returns_no_data(self): + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "ActionsNode", "id": "not-an-action", "name": "Deleted action"}, + }, + ) + create_account(team_id=self.team.id, name="A", external_id="org-a") + runner, response = self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + health_idx = runner.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + self.assertEqual(response.results[0][health_idx]["status"], "no_data") + self.assertIn("no longer available", response.results[0][health_idx]["noDataReason"]) + + def test_health_score_valid_action_source_uses_resolved_action_cache(self): + action = Action.objects.create(team=self.team, name="Viewed docs") + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "ActionsNode", "id": action.id, "name": action.name}, + }, + ) + calculator = AccountHealthScoreCalculator(team=self.team, user=self.user) + self.assertEqual( + calculator._actions_for_activity_event({"kind": "ActionsNode", "id": action.id}), {action.id: action} + ) + + def test_health_score_enrichment_failure_keeps_account_list_usable(self): + create_account(team_id=self.team.id, name="A", external_id="org-a") + with patch( + "products.customer_analytics.backend.hogql_queries.accounts_query_runner.AccountHealthScoreCalculator.score_accounts", + side_effect=RuntimeError("health query timed out"), + ): + runner, response = self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + + health_idx = runner.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + self.assertEqual(response.results[0][health_idx]["status"], "no_data") + self.assertIn("could not be calculated", response.results[0][health_idx]["noDataReason"]) + def test_health_score_calculator_uses_batched_baseline_and_metrics_queries(self): + cache.clear() TeamCustomerAnalyticsConfig.objects.update_or_create( team=self.team, defaults={ @@ -477,6 +538,33 @@ def test_health_score_calculator_uses_batched_baseline_and_metrics_queries(self) self.assertEqual(scores["account-a"]["status"], "neutral") self.assertEqual(scores["account-b"]["status"], "no_data") + def test_health_score_calculator_caches_full_team_baseline(self): + cache.clear() + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "EventsNode", "event": "$pageview", "name": "$pageview"}, + }, + ) + with freeze_time("2026-06-01T12:00:00Z"): + with patch( + "products.customer_analytics.backend.hogql_queries.account_health_score.execute_hogql_query", + side_effect=[ + SimpleNamespace(results=[[10, 5]]), + SimpleNamespace(results=[["org-a", 4, 2, 2, 3, timezone.now()]]), + SimpleNamespace(results=[["org-a", 5, 4, 3, 4, timezone.now()]]), + ], + ) as execute: + calculator = AccountHealthScoreCalculator(team=self.team, user=self.user) + calculator.score_accounts({"account-a": "org-a"}) + calculator.score_accounts({"account-a": "org-a"}) + + self.assertEqual( + [call.kwargs["query_type"] for call in execute.call_args_list], + ["AccountsHealthBaselineQuery", "AccountsHealthMetricsQuery", "AccountsHealthMetricsQuery"], + ) + def _link_notebooks(self, account, count: int) -> None: for i in range(count): notebook = Notebook.objects.create( diff --git a/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx b/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx index 92c8d4471ca8..4c981b1ba6b0 100644 --- a/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx +++ b/products/customer_analytics/frontend/components/Accounts/AccountsHogQLTable.tsx @@ -55,12 +55,12 @@ function getCellAt(record: unknown, names: string[], column: string): unknown { } function useGetCell(): (record: unknown, column: string) => unknown { - const { visibleColumnNames } = useValues(accountsColumnConfigLogic) - return (record, column) => getCellAt(record, visibleColumnNames, column) + const { queryColumnNames } = useValues(accountsLogic) + return (record, column) => getCellAt(record, queryColumnNames, column) } -function getNameCell(record: unknown, visibleColumnNames: string[]): AccountNameCell | undefined { - const value = getCellAt(record, visibleColumnNames, ACCOUNTS_NAME_COLUMN) +function getNameCell(record: unknown, columnNames: string[]): AccountNameCell | undefined { + const value = getCellAt(record, columnNames, ACCOUNTS_NAME_COLUMN) if (!value || typeof value !== 'object') { return undefined } @@ -84,8 +84,8 @@ function tupleToAssignment(value: unknown): AccountAssignment { } function NameCell({ record }: { record: unknown }): JSX.Element { - const { visibleColumnNames } = useValues(accountsColumnConfigLogic) - const cell = getNameCell(record, visibleColumnNames) + const { queryColumnNames } = useValues(accountsLogic) + const cell = getNameCell(record, queryColumnNames) const name = cell?.name ?? '' const externalId = cell?.external_id ?? '' return ( @@ -122,11 +122,10 @@ function HealthScoreCell({ value }: { value: unknown }): JSX.Element { } function RoleAssignmentCell({ record, role }: { record: unknown; role: AccountRoleKey }): JSX.Element { - const { isRoleSaving, accountOverrides } = useValues(accountsLogic) - const { visibleColumnNames } = useValues(accountsColumnConfigLogic) + const { isRoleSaving, accountOverrides, queryColumnNames } = useValues(accountsLogic) const { updateAccountRole } = useActions(accountsLogic) const getCell = useGetCell() - const accountId = getNameCell(record, visibleColumnNames)?.id ?? '' + const accountId = getNameCell(record, queryColumnNames)?.id ?? '' const overrideProperties = accountId ? accountOverrides[accountId]?.properties : undefined const overrideRole = overrideProperties != null ? overrideProperties[role] : undefined const assignment: AccountAssignment = @@ -265,7 +264,7 @@ function useContextColumns(): Record { } function useExpandable(): QueryContext['expandable'] { - const { visibleColumnNames } = useValues(accountsColumnConfigLogic) + const { queryColumnNames } = useValues(accountsLogic) const { expandedAccountIds } = useValues(accountsExpansionLogic) const { toggleAccountExpanded } = useActions(accountsExpansionLogic) return useMemo( @@ -273,25 +272,25 @@ function useExpandable(): QueryContext['expandable'] { noIndent: true, expandedRowClassName: '[&>td]:overflow-visible!', isRowExpanded: ({ result }) => { - const cell = getNameCell(result, visibleColumnNames) + const cell = getNameCell(result, queryColumnNames) return !!cell && expandedAccountIds.includes(cell.id) }, onRowExpand: ({ result }) => { - const cell = getNameCell(result, visibleColumnNames) + const cell = getNameCell(result, queryColumnNames) if (cell) { toggleAccountExpanded(cell.id) } }, onRowCollapse: ({ result }) => { - const cell = getNameCell(result, visibleColumnNames) + const cell = getNameCell(result, queryColumnNames) if (cell) { toggleAccountExpanded(cell.id) } }, expandedRowRender: ({ result }) => { - const cell = getNameCell(result, visibleColumnNames) + const cell = getNameCell(result, queryColumnNames) const healthScore = parseAccountHealthScore( - getCellAt(result, visibleColumnNames, ACCOUNTS_HEALTH_SCORE_COLUMN) + getCellAt(result, queryColumnNames, ACCOUNTS_HEALTH_SCORE_COLUMN) ) return cell ? ( ['expandable'] { ) : null }, }), - [visibleColumnNames, expandedAccountIds, toggleAccountExpanded] + [queryColumnNames, expandedAccountIds, toggleAccountExpanded] ) } diff --git a/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts index 0b0af6d15282..ca9f8541eb37 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts @@ -37,6 +37,23 @@ function ensureNameColumn(columns: string[]): string[] { return columns.includes(ACCOUNTS_NAME_COLUMN) ? columns : [ACCOUNTS_NAME_COLUMN, ...columns] } +export function ensureAccountQueryColumns(columns: string[]): string[] { + const columnsWithName = ensureNameColumn(columns) + if (columnsWithName.includes(ACCOUNTS_HEALTH_SCORE_COLUMN)) { + return columnsWithName + } + const nameIndex = columnsWithName.indexOf(ACCOUNTS_NAME_COLUMN) + return [ + ...columnsWithName.slice(0, nameIndex + 1), + ACCOUNTS_HEALTH_SCORE_COLUMN, + ...columnsWithName.slice(nameIndex + 1), + ] +} + +export function accountColumnDisplayNames(columns: string[]): string[] { + return columns.map((column) => extractDisplayLabel(column)) +} + export function diffColumnConfiguration( previous: string[], next: string[] @@ -271,7 +288,7 @@ export const accountsColumnConfigLogic = kea([ selectors({ visibleColumnNames: [ (s) => [s.selectColumns], - (selectColumns: string[]): string[] => selectColumns.map((c) => extractDisplayLabel(c)), + (selectColumns: string[]): string[] => accountColumnDisplayNames(selectColumns), ], accountsColumnGroups: [ (s) => [s.allTablesMap, s.warehouseJoins], diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts index a81a85afa34b..ae5db15b6cc4 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts @@ -30,6 +30,21 @@ jest.mock('products/customer_analytics/frontend/generated/api', () => ({ accountsPartialUpdate: jest.fn(), })) +jest.mock('lib/api', () => ({ + __esModule: true, + default: { + query: () => Promise.resolve({ tables: [], fields: [], saved_queries: [] }), + columnConfigurations: { + list: () => Promise.resolve({ results: [] }), + create: () => Promise.resolve({ id: 'saved-1', columns: [] }), + update: () => Promise.resolve({ id: 'saved-1', columns: [] }), + }, + dataWarehouseViewLinks: { + list: () => Promise.resolve({ results: [] }), + }, + }, +})) + const mockAccountsRetrieve = accountsRetrieve as jest.MockedFunction const mockAccountsPartialUpdate = accountsPartialUpdate as jest.MockedFunction @@ -268,10 +283,21 @@ describe('accountsLogic', () => { expect(config?.values.selectColumns).toContain(ACCOUNTS_NAME_COLUMN) }) - it('hogqlQuery.source.select equals selectColumns verbatim — no pinned aliases', () => { + it('keeps health score in the query when hidden from visible columns', () => { const config = accountsColumnConfigLogic.findMounted() + config?.actions.setSelectColumns([ACCOUNTS_NAME_COLUMN, 'csm']) + + const source = logic.values.hogqlQuery.source as AccountsQuery + expect(config?.values.selectColumns).toEqual([ACCOUNTS_NAME_COLUMN, 'csm']) + expect(source.select).toEqual([ACCOUNTS_NAME_COLUMN, ACCOUNTS_HEALTH_SCORE_COLUMN, 'csm']) + expect(logic.values.hogqlQuery.hiddenColumns).toEqual([ACCOUNTS_HEALTH_SCORE_COLUMN]) + expect(logic.values.queryColumnNames).toEqual([ACCOUNTS_NAME_COLUMN, ACCOUNTS_HEALTH_SCORE_COLUMN, 'csm']) + }) + + it('does not hide health score when it is selected', () => { const source = logic.values.hogqlQuery.source as AccountsQuery - expect(source.select).toEqual(config?.values.selectColumns) + expect(source.select).toEqual(ACCOUNTS_HOGQL_DEFAULT_SELECT) + expect(logic.values.hogqlQuery.hiddenColumns).toBeUndefined() }) it('refuses to remove the name column via unselectColumn', () => { diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts index 75aca8344cc4..f3e6bc407fc0 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts @@ -23,7 +23,9 @@ import { ACCOUNTS_HEALTH_SCORE_COLUMN, ACCOUNTS_HOGQL_DEFAULT_SELECT, ACCOUNTS_NAME_COLUMN, + accountColumnDisplayNames, accountsColumnConfigLogic, + ensureAccountQueryColumns, } from './accountsColumnConfigLogic' import { AccountExpansionTab, accountsExpansionLogic } from './accountsExpansionLogic' import type { accountsLogicType } from './accountsLogicType' @@ -316,6 +318,10 @@ export const accountsLogic = kea([ return state }, ], + queryColumnNames: [ + (s) => [s.selectColumns], + (selectColumns: string[]): string[] => accountColumnDisplayNames(ensureAccountQueryColumns(selectColumns)), + ], hogqlQuery: [ (s) => [ s.searchQuery, @@ -337,9 +343,10 @@ export const accountsLogic = kea([ sortOrder: AccountSortOrder, selectColumns: string[] ): DataTableNode => { + const querySelectColumns = ensureAccountQueryColumns(selectColumns) const source: AccountsQuery = { kind: NodeKind.AccountsQuery, - select: selectColumns, + select: querySelectColumns, tags: { ...CUSTOMER_ANALYTICS_DEFAULT_QUERY_TAGS, name: 'customer_analytics_accounts_list' }, } if (overviewMetrics.length > 0) { @@ -369,6 +376,9 @@ export const accountsLogic = kea([ kind: NodeKind.DataTableNode, source, full: true, + hiddenColumns: selectColumns.includes(ACCOUNTS_HEALTH_SCORE_COLUMN) + ? undefined + : [ACCOUNTS_HEALTH_SCORE_COLUMN], // Suppress DataTable's built-in sort indicator on column // headers — our `SortableColumnHeader` renders its own (and // correctly reflects sorts where the orderBy expression From d11409a1591e8204730c7a5671028b73bae04a37 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:37:37 +0000 Subject: [PATCH 3/4] fix(customer-analytics): resolve account health review blockers Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../hogql_queries/account_health_score.py | 46 ++++++++----- .../hogql_queries/accounts_query_runner.py | 2 + .../test/test_accounts_query_runner.py | 69 +++++++++++++++++++ .../Accounts/accountsColumnConfigLogic.ts | 7 +- .../components/Accounts/accountsLogic.test.ts | 16 +++++ 5 files changed, 122 insertions(+), 18 deletions(-) diff --git a/products/customer_analytics/backend/hogql_queries/account_health_score.py b/products/customer_analytics/backend/hogql_queries/account_health_score.py index 512dbeaffd7f..e69fc695b5eb 100644 --- a/products/customer_analytics/backend/hogql_queries/account_health_score.py +++ b/products/customer_analytics/backend/hogql_queries/account_health_score.py @@ -261,12 +261,23 @@ def _activity_event_label(activity_event: dict[str, Any]) -> str: def _baseline_cache_key( - *, team_id: int, group_type_index: int, activity_event: dict[str, Any], date_to: datetime, lookback_days: int + *, + team_id: int, + group_type_index: int, + activity_event: dict[str, Any], + actions: dict[int, Action], + date_to: datetime, + lookback_days: int, ) -> str: if timezone.is_naive(date_to): date_to = timezone.make_aware(date_to, ZoneInfo("UTC")) + cache_payload: dict[str, Any] = {"activity_event": activity_event} + if actions: + cache_payload["actions"] = [ + {"id": action_id, "updated_at": actions[action_id].updated_at} for action_id in sorted(actions) + ] event_hash = hashlib.sha256( - json.dumps(activity_event, sort_keys=True, default=str, separators=(",", ":")).encode() + json.dumps(cache_payload, sort_keys=True, default=str, separators=(",", ":")).encode() ).hexdigest()[:16] bucket = date_to.astimezone(ZoneInfo("UTC")).strftime("%Y%m%d%H") return f"customer_analytics:account_health_baseline:v1:{team_id}:{group_type_index}:{lookback_days}:{bucket}:{event_hash}" @@ -348,6 +359,7 @@ def score_accounts(self, external_ids_by_account_id: dict[str, str | None]) -> d team_id=self.team.id, group_type_index=group_type_index, activity_event=activity_event, + actions=actions, date_to=date_to, lookback_days=ACCOUNT_HEALTH_SCORE_LOOKBACK_DAYS, ), @@ -397,7 +409,7 @@ def _actions_for_activity_event(self, activity_event: dict[str, Any]) -> dict[in except (KeyError, TypeError, ValueError): return None try: - action = Action.objects.get(id=action_id, team__project_id=self.team.project_id) + action = Action.objects.get(id=action_id, team__project_id=self.team.project_id, deleted=False) except ObjectDoesNotExist: return None return {action_id: action} @@ -424,7 +436,7 @@ def _load_baseline( p90_active_users=_read_float(cached.get("p90_active_users")), ) - group_expr = f"toString($group_{group_type_index})" + group_expr = f"toString(e.$group_{group_type_index})" query = parse_select( f""" SELECT @@ -434,10 +446,10 @@ def _load_baseline( SELECT {group_expr} AS group_key, count() AS current_count, - uniq(person_id) AS active_users - FROM events - WHERE timestamp >= {{date_from}} - AND timestamp < {{date_to}} + uniq(e.person_id) AS active_users + FROM events AS e + WHERE e.timestamp >= {{date_from}} + AND e.timestamp < {{date_to}} AND notEmpty({group_expr}) AND {{activity_filter}} GROUP BY group_key @@ -482,21 +494,21 @@ def _load_account_metrics( date_to: datetime, activity_filter: ast.Expr, ) -> dict[str, AccountActivityMetrics]: - group_expr = f"toString($group_{group_type_index})" - current_condition = "timestamp >= {date_from} AND timestamp < {date_to}" - previous_condition = "timestamp >= {previous_date_from} AND timestamp < {date_from}" + group_expr = f"toString(e.$group_{group_type_index})" + current_condition = "e.timestamp >= {date_from} AND e.timestamp < {date_to}" + previous_condition = "e.timestamp >= {previous_date_from} AND e.timestamp < {date_from}" query = parse_select( f""" SELECT {group_expr} AS group_key, countIf({current_condition}) AS current_count, countIf({previous_condition}) AS previous_count, - uniqIf(person_id, {current_condition}) AS active_users, - countDistinctIf(toDate(timestamp), {current_condition}) AS active_days, - max(timestamp) AS last_activity_at - FROM events - WHERE timestamp >= {{previous_date_from}} - AND timestamp < {{date_to}} + uniqIf(e.person_id, {current_condition}) AS active_users, + countDistinctIf(toDate(e.timestamp), {current_condition}) AS active_days, + max(e.timestamp) AS last_activity_at + FROM events AS e + WHERE e.timestamp >= {{previous_date_from}} + AND e.timestamp < {{date_to}} AND notEmpty({group_expr}) AND {group_expr} IN {{group_keys}} AND {{activity_filter}} diff --git a/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py b/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py index bf830779d123..787dd1e547df 100644 --- a/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/accounts_query_runner.py @@ -98,6 +98,8 @@ def _resolve_column(self, raw: str) -> tuple[str, ast.Expr]: ) expr = parse_expr(raw) column_name = expr.alias if isinstance(expr, ast.Alias) else raw + if column_name == ACCOUNT_HEALTH_SCORE_COLUMN: + column_name = raw return column_name, expr def _name_tuple_expr(self) -> ast.Expr: diff --git a/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py b/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py index 955e8d7a5aa9..6e3467fb1134 100644 --- a/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py +++ b/products/customer_analytics/backend/hogql_queries/test/test_accounts_query_runner.py @@ -14,6 +14,7 @@ from posthog.schema import AccountsQuery, AccountsQueryResponse +from posthog.hogql import ast from posthog.hogql.errors import ExposedHogQLError from posthog.api.tagged_item import set_tags_on_object @@ -153,6 +154,18 @@ def test_health_score_is_not_calculated_when_column_is_not_selected(self): self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) score_accounts.assert_called_once_with({str(account.id): "org-a"}) + def test_health_score_alias_collision_keeps_user_expression_separate(self): + runner = AccountsQueryRunner( + query=AccountsQuery(select=["name", "properties.foo AS health_score", ACCOUNT_HEALTH_SCORE_COLUMN]), + team=self.team, + user=self.user, + ) + + self.assertEqual( + runner.columns, + ["name", "properties.foo AS health_score", ACCOUNT_HEALTH_SCORE_COLUMN], + ) + def test_health_score_column_serializes_no_data_for_missing_external_id(self): TeamCustomerAnalyticsConfig.objects.update_or_create( team=self.team, @@ -486,6 +499,21 @@ def test_health_score_invalid_action_source_returns_no_data(self): self.assertEqual(response.results[0][health_idx]["status"], "no_data") self.assertIn("no longer available", response.results[0][health_idx]["noDataReason"]) + def test_health_score_soft_deleted_action_source_returns_no_data(self): + action = Action.objects.create(team=self.team, name="Deleted docs action", deleted=True) + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "ActionsNode", "id": action.id, "name": action.name}, + }, + ) + create_account(team_id=self.team.id, name="A", external_id="org-a") + runner, response = self._run_query(select=["name", ACCOUNT_HEALTH_SCORE_COLUMN]) + health_idx = runner.columns.index(ACCOUNT_HEALTH_SCORE_COLUMN) + self.assertEqual(response.results[0][health_idx]["status"], "no_data") + self.assertIn("no longer available", response.results[0][health_idx]["noDataReason"]) + def test_health_score_valid_action_source_uses_resolved_action_cache(self): action = Action.objects.create(team=self.team, name="Viewed docs") TeamCustomerAnalyticsConfig.objects.update_or_create( @@ -565,6 +593,47 @@ def test_health_score_calculator_caches_full_team_baseline(self): ["AccountsHealthBaselineQuery", "AccountsHealthMetricsQuery", "AccountsHealthMetricsQuery"], ) + def test_health_score_action_baseline_cache_tracks_action_updates(self): + cache.clear() + action = Action.objects.create(team=self.team, name="Viewed docs") + TeamCustomerAnalyticsConfig.objects.update_or_create( + team=self.team, + defaults={ + "account_group_type_index": 0, + "activity_event": {"kind": "ActionsNode", "id": action.id, "name": action.name}, + }, + ) + with freeze_time("2026-06-01T12:00:00Z"): + with ( + patch( + "products.customer_analytics.backend.hogql_queries.account_health_score.hog_function_filters_to_expr", + return_value=ast.Constant(value=True), + ), + patch( + "products.customer_analytics.backend.hogql_queries.account_health_score.execute_hogql_query", + side_effect=[ + SimpleNamespace(results=[[10, 5]]), + SimpleNamespace(results=[["org-a", 4, 2, 2, 3, timezone.now()]]), + SimpleNamespace(results=[[20, 10]]), + SimpleNamespace(results=[["org-a", 5, 4, 3, 4, timezone.now()]]), + ], + ) as execute, + ): + calculator = AccountHealthScoreCalculator(team=self.team, user=self.user) + calculator.score_accounts({"account-a": "org-a"}) + Action.objects.filter(id=action.id).update(updated_at=timezone.now() + timedelta(minutes=1)) + calculator.score_accounts({"account-a": "org-a"}) + + self.assertEqual( + [call.kwargs["query_type"] for call in execute.call_args_list], + [ + "AccountsHealthBaselineQuery", + "AccountsHealthMetricsQuery", + "AccountsHealthBaselineQuery", + "AccountsHealthMetricsQuery", + ], + ) + def _link_notebooks(self, account, count: int) -> None: for i in range(count): notebook = Notebook.objects.create( diff --git a/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts index ca9f8541eb37..83961e3d797a 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsColumnConfigLogic.ts @@ -51,7 +51,12 @@ export function ensureAccountQueryColumns(columns: string[]): string[] { } export function accountColumnDisplayNames(columns: string[]): string[] { - return columns.map((column) => extractDisplayLabel(column)) + return columns.map((column) => { + const displayName = extractDisplayLabel(column) + return column !== ACCOUNTS_HEALTH_SCORE_COLUMN && displayName === ACCOUNTS_HEALTH_SCORE_COLUMN + ? column + : displayName + }) } export function diffColumnConfiguration( diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts index ae5db15b6cc4..a64c206d6c73 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts @@ -294,6 +294,22 @@ describe('accountsLogic', () => { expect(logic.values.queryColumnNames).toEqual([ACCOUNTS_NAME_COLUMN, ACCOUNTS_HEALTH_SCORE_COLUMN, 'csm']) }) + it('does not collide with user expressions aliased as health_score', () => { + const config = accountsColumnConfigLogic.findMounted() + const userExpression = 'properties.plan AS health_score' + config?.actions.setSelectColumns([ACCOUNTS_NAME_COLUMN, userExpression]) + + const source = logic.values.hogqlQuery.source as AccountsQuery + expect(config?.values.visibleColumnNames).toEqual([ACCOUNTS_NAME_COLUMN, userExpression]) + expect(source.select).toEqual([ACCOUNTS_NAME_COLUMN, ACCOUNTS_HEALTH_SCORE_COLUMN, userExpression]) + expect(logic.values.hogqlQuery.hiddenColumns).toEqual([ACCOUNTS_HEALTH_SCORE_COLUMN]) + expect(logic.values.queryColumnNames).toEqual([ + ACCOUNTS_NAME_COLUMN, + ACCOUNTS_HEALTH_SCORE_COLUMN, + userExpression, + ]) + }) + it('does not hide health score when it is selected', () => { const source = logic.values.hogqlQuery.source as AccountsQuery expect(source.select).toEqual(ACCOUNTS_HOGQL_DEFAULT_SELECT) From 8c8e0a25486be1a9cfba387905f50b28215a3832 Mon Sep 17 00:00:00 2001 From: lordspline <74811063+lordspline@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:44:35 +0000 Subject: [PATCH 4/4] fix(customer-analytics): resolve health alias sorting Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- .../components/Accounts/accountsLogic.test.ts | 6 ++++-- .../frontend/components/Accounts/accountsLogic.ts | 14 +++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts index a64c206d6c73..2eb8a0f79308 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.test.ts @@ -224,7 +224,7 @@ describe('accountsLogic', () => { it('toggleSort on a fresh column starts ascending', () => { logic.actions.toggleSort('notebook_count') expect(logic.values.sortOrder).toEqual({ column: 'notebook_count', direction: 'asc' }) - expect(orderByOf(logic.values.hogqlQuery.source)).toEqual(['notebook_count']) + expect(orderByOf(logic.values.hogqlQuery.source)).toEqual(['accounts.notebooks.count']) }) it('toggleSort cycles asc -> desc -> null on repeated clicks', () => { @@ -232,7 +232,7 @@ describe('accountsLogic', () => { expect(logic.values.sortOrder?.direction).toBe('asc') logic.actions.toggleSort('notebook_count') expect(logic.values.sortOrder).toEqual({ column: 'notebook_count', direction: 'desc' }) - expect(orderByOf(logic.values.hogqlQuery.source)).toEqual(['notebook_count DESC']) + expect(orderByOf(logic.values.hogqlQuery.source)).toEqual(['accounts.notebooks.count DESC']) logic.actions.toggleSort('notebook_count') expect(logic.values.sortOrder).toBeNull() expect(orderByOf(logic.values.hogqlQuery.source)).toBeUndefined() @@ -298,10 +298,12 @@ describe('accountsLogic', () => { const config = accountsColumnConfigLogic.findMounted() const userExpression = 'properties.plan AS health_score' config?.actions.setSelectColumns([ACCOUNTS_NAME_COLUMN, userExpression]) + logic.actions.toggleSort(userExpression) const source = logic.values.hogqlQuery.source as AccountsQuery expect(config?.values.visibleColumnNames).toEqual([ACCOUNTS_NAME_COLUMN, userExpression]) expect(source.select).toEqual([ACCOUNTS_NAME_COLUMN, ACCOUNTS_HEALTH_SCORE_COLUMN, userExpression]) + expect(orderByOf(source)).toEqual(['properties.plan']) expect(logic.values.hogqlQuery.hiddenColumns).toEqual([ACCOUNTS_HEALTH_SCORE_COLUMN]) expect(logic.values.queryColumnNames).toEqual([ ACCOUNTS_NAME_COLUMN, diff --git a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts index f3e6bc407fc0..21fde9fcd76f 100644 --- a/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts +++ b/products/customer_analytics/frontend/components/Accounts/accountsLogic.ts @@ -9,6 +9,7 @@ import { urls } from 'scenes/urls' import { userLogic } from 'scenes/userLogic' import { dataNodeLogic } from '~/queries/nodes/DataNode/dataNodeLogic' +import { orderByForSelectKey } from '~/queries/nodes/DataTable/utils' import { AccountsQuery, DataTableNode, NodeKind } from '~/queries/schema/schema-general' import type { UserBasicType } from '~/types' @@ -96,15 +97,14 @@ export function isAccountsColumnSortable(column: string): boolean { } // Resolve the HogQL expression to use in ORDER BY for a sortable column. -// HogQL ORDER BY resolves SELECT aliases by name, so the visible column name -// (which is the alias for aliased entries, or the bare expression otherwise) -// works directly — except for tuple-shaped role columns, where we sort by -// the email element so the visual order matches the rendered cell. -export function deriveAccountsOrderByExpr(column: string): string { +// Resolve the visible column key back to the selected HogQL expression, except +// for tuple-shaped role columns where we sort by the email element so the +// visual order matches the rendered cell. +export function deriveAccountsOrderByExpr(column: string, selectColumns: readonly string[]): string { if (TUPLE_SORT_COLUMNS.has(column)) { return `tupleElement(${column}, 2)` } - return column + return orderByForSelectKey(column, selectColumns) } const ROLE_LABELS: Record = { @@ -369,7 +369,7 @@ export const accountsLogic = kea([ source.filterExpression = tileFilter.expression } if (sortOrder) { - const expr = deriveAccountsOrderByExpr(sortOrder.column) + const expr = deriveAccountsOrderByExpr(sortOrder.column, querySelectColumns) source.orderBy = [sortOrder.direction === 'asc' ? expr : `${expr} DESC`] } return {