Skip to content

feat(customer-analytics): add default pinned account properties - #108076

Draft
arthurdedeus wants to merge 3 commits into
masterfrom
feat/customer-analytics-default-pinned-account-properties
Draft

arthurdedeus wants to merge 3 commits into
masterfrom
feat/customer-analytics-default-pinned-account-properties

Conversation

@arthurdedeus

Copy link
Copy Markdown
Contributor

Problem

Project admins cannot choose the account properties people see before saving a personal sidebar selection.

Every user starts with an empty account sidebar and must configure the same useful properties independently.

Changes

  • Accounts settings now let project admins select, order, clear, and reopen default pinned properties.
  • Users inherit current project defaults until they save a personal selection. An explicit empty selection remains a personal override.
  • The API validates the shared 50-property limit, duplicates, target types, reference kinds, and project ownership.
  • The migration converts historical auto-created empty selections into inherited configurations without rewriting non-empty or legacy selections.

Warning

Existing data cannot distinguish an auto-created empty selection from an intentional clear. Those users may need to clear the defaults once again.

Before this change, Accounts settings had no default-pins control. The configured state now shows the ordered project defaults:

default-pinned-properties

How did you test this code?

  • Backend API tests cover inheritance, dynamic default changes, personal overrides, validation, permissions, and environment canonicalization.
  • The migration test protects empty-row normalization while preserving non-empty, legacy, and already-keyless configurations.
  • Frontend tests cover loading, ordered saves, clearing, stale references, load failures, and non-admin restrictions.
  • Storybook and headless Playwright verified the configured modal at a 1200 × 800 viewport.
  • pnpm --filter=@posthog/frontend typescript:check
  • uv run mypy --cache-fine-grained .
  • hogli product:lint customer_analytics
  • hogli ci:preflight --strict

👉 Stay up-to-date with PostHog coding conventions for a smoother review.

Automatic notifications

  • Publish to changelog?

Docs update

Updated the Customer analytics Accounts table guide and generated API descriptions. The Inkeep docs agent can assess published documentation changes.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: pi, gpt-5.6-sol

  • Duplicate search found no overlapping implementation. #107161 changes account views and tabs, not pinned-property defaults.
  • Skills used: /django-migrations, /improving-drf-endpoints, /writing-tests, /writing-ui-components, /writing-user-facing-copy, /writing-kea-logics, /editing-agents-md, /running-ci-preflight, /write-snapshot-test, /setting-feature-flags-in-storybook, /writing-pr-descriptions, and /ship-it.
  • CodeRabbit CLI was signed out. The user chose to skip the local pass, so the PR opened without one.
  • The implementation uses read-time inheritance rather than copying defaults into user rows. No customer data or private operational material appears in the public artifacts.
  • Session link is unavailable in pi.

@arthurdedeus arthurdedeus self-assigned this Sep 28, 2026
@trunk-io

trunk-io Bot commented Sep 28, 2026

Copy link
Copy Markdown

Merging to master in this repository is managed by Trunk.

  • To merge this pull request, check the box to the left or comment /trunk merge below.

After your PR is submitted to the merge queue, this comment will be automatically updated with its status. If the PR fails, failure details will also be posted here

@arthurdedeus

Copy link
Copy Markdown
Contributor Author

Game on, robots! @veria-ai @greptile-apps @parameterai

@parameterai

parameterai Bot commented Sep 28, 2026

Copy link
Copy Markdown

Took a look at the diff — the new default_pinned_properties path is admin-gated the same way as the rest of customer_analytics_config (it's part of TEAM_CONFIG_FIELDS_SET but not TEAM_CONFIG_MEMBER_FIELDS_SET, so posthog/api/team.py enforces "only project admins can modify these settings" via TEAM_CONFIG_ADMIN_FIELDS_SET), and validate_pinned_properties in account_property_pins.py correctly scopes custom-property/relationship lookups to resolve_effective_team_id(team_id) before checking references, so you can't pin an object belonging to another team. Nothing jumped out as a security issue in this pass — happy to do a deeper pass if you want one specifically.

@github-actions

github-actions Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

⚠️ Trunk lane — backend Python lane

This PR is assigned to the backend Python lane. It runs backend Python tests and may merge in parallel with PRs in other lanes.

⚠️ Complexity (TypeScript) — 5 functions above the limit (max 18)

Cyclomatic complexity above the limit in changed typescript files (10 for production files, 15 for test files). Warn only: worth simplifying when you next touch these functions.

Function Location Complexity Limit
errors products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/customPropertyDefinitionsLogic.ts:950 18 10
submit products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/customPropertyDefinitionsLogic.ts:984 17 10
openEditModal products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/customPropertyDefinitionsLogic.ts:1311 15 10
DefaultPinnedAccountProperties products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/DefaultPinnedAccountProperties.tsx:12 14 10
loadSelectedTableColumns products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/customPropertyDefinitionsLogic.ts:845 12 10
✅ Duplication (Python) — clean

New Python code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

✅ Duplication (TypeScript) — clean

New TypeScript code duplication introduced by this branch. Fails at 70+ tokens in app code, or 150+ tokens when both copies live in test files. Advisory while the gate proves itself: extract a shared helper instead of copying.

⚠️ Bundle size — 🔺 +9.5 KiB (+0.0%)

Uncompressed size of every built .js bundle, compared against the base branch.

Total: 68.88 MiB · 🔺 +9.5 KiB (+0.0%)

File Size Δ vs base
posthog-app/_parent/products/signals/frontend/inbox/InboxScene.js 474.9 KiB 🔺 +7.4 KiB (+1.6%)
render-query/src/render-query/render-query.js 20.16 MiB 🔺 +1.5 KiB (+0.0%)

Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report

✅ Eager graph — within budget

How much code each root ships on the eager path — downloaded and parsed before the surface is interactive. Measured from the esbuild output chunks (post-tree-shake, static imports only); lazy import() / React.lazy chunks are not counted.

Root Eager (shipped) Δ vs base Budget
entry (logged-out pages, app bootstrap)
src/index.tsx
1.57 MiB · 22 files 🔺 +33 B (+0.0%) █████████░ 85.5% of 1.84 MiB
logged-out boot: index + App + bootApp (preloaded by every page, including /login)
src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
3.51 MiB · 629 files 🔺 +200 B (+0.0%) █████████░ 87.2% of 4.03 MiB
authenticated shell (every logged-in page)
src/scenes/AuthenticatedShell.tsx
7.33 MiB · 2,332 files 🔺 +1.3 KiB (+0.0%) █████████░ 87.9% of 8.34 MiB

🟢 node_modules/monaco-editor/ stays out of src/index.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 [object Object] stays out of src/index.tsx
🟢 node_modules/monaco-editor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/layout/navigation-3000/navigationLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/scenes/dashboard/dashboardLogic.tsx stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/lemon-ui/LemonMarkdown/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/RichContentEditor/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/lib/components/CodeSnippet/ stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 src/taxonomy/core-filter-definitions-by-group.json stays out of src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
🟢 node_modules/monaco-editor/ stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/lib/components/ActivityLog/describers stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 src/scenes/session-recordings/player/sessionRecordingPlayerLogic.ts stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx
🟢 [object Object] stays out of src/scenes/AuthenticatedShell.tsx

Largest files eagerly shipped from src/index.tsx
Size File
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
24.6 KiB ../node_modules/.pnpm/buffer@6.0.3/node_modules/buffer/index.js
6.3 KiB ../node_modules/.pnpm/react@18.3.1/node_modules/react/cjs/react.production.min.js
4.5 KiB ../node_modules/.pnpm/@jspm+core@2.1.0/node_modules/@jspm/core/nodelibs/browser/process.js
3.9 KiB ../node_modules/.pnpm/scheduler@0.23.2/node_modules/scheduler/cjs/scheduler.production.min.js
1.4 KiB ../node_modules/.pnpm/base64-js@1.5.1/node_modules/base64-js/index.js
1.3 KiB src/index.tsx
1.3 KiB src/RootErrorBoundary.tsx
912 B ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js
854 B src/scenes/ChunkLoadErrorBoundary.tsx
Largest files eagerly shipped from src/index.tsx + src/scenes/App.tsx + src/scenes/bootApp.ts
Size File
301.8 KiB ../node_modules/.pnpm/posthog-js@1.434.14_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
216.0 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
100.5 KiB src/lib/api.ts
88.4 KiB src/products.tsx
69.4 KiB src/lib/lemon-ui/icons/icons.tsx
40.1 KiB src/lib/utils/eventUsageLogic.ts
38.7 KiB ../node_modules/.pnpm/@dnd-kit+core@6.0.8_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@dnd-kit/core/dist/core.esm.js
33.9 KiB ../node_modules/.pnpm/kea@4.0.0-pre.6_patch_hash=139b8d1f1304f9d9da452a9a1244c94ea679dbcb85687d8999563146879fb6f5_react@18.3.1/node_modules/kea/lib/index.cjs.js
28.4 KiB src/scenes/scenes.ts
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
Size File
301.8 KiB ../node_modules/.pnpm/posthog-js@1.434.14_@types+react@18.3.27_react@18.3.1/node_modules/posthog-js/dist/module.mjs
271.7 KiB src/taxonomy/core-filter-definitions-by-group.json
216.0 KiB ../node_modules/.pnpm/@posthog+icons@0.38.0_react-dom@18.3.1_react@18.3.1__react@18.3.1/node_modules/@posthog/icons/dist/posthog-icons.es.js
153.7 KiB ../node_modules/.pnpm/re2js@0.4.1/node_modules/re2js/build/index.esm.js
126.8 KiB ../node_modules/.pnpm/react-dom@18.3.1_react@18.3.1/node_modules/react-dom/cjs/react-dom.production.min.js
100.5 KiB src/lib/api.ts
98.5 KiB ../packages/quill/packages/quill/dist/index.js
93.3 KiB ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js
90.6 KiB ../node_modules/.pnpm/@tiptap+core@3.20.6_@tiptap+pm@3.20.6/node_modules/@tiptap/core/dist/index.js
88.4 KiB src/products.tsx

Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479

✅ Toolbar bundle — eager 2.16 MiB within budget

What the toolbar ships to customer pages, measured from the esbuild output (minified, post-tree-shake). The eager set is the entry plus everything statically imported from it — fetched before any feature runs; deferred chunks load lazily. The eager guardrail is 5.72 MiB. Each output file must also stay below 10 MB, where CloudFront stops compressing it. The module boundary is enforced separately by check-toolbar-graph.

Metric Size Δ vs base Budget
Eager (shipped)
entry + static imports
2.16 MiB · 19 files 🔺 +91 B (+0.0%) ████░░░░░░ 37.7% of 5.72 MiB
Deferred (lazy) 2.10 MiB · 44 files no change n/a — loads on demand
Loader dist/toolbar.js 1.2 KiB no change █░░░░░░░░░ 6.0% of 19.5 KiB
Largest eagerly-shipped chunks
Size File
800.5 KiB dist/toolbar/toolbar-app-QUJ43CJ4.css
651.6 KiB dist/toolbar/chunk-chunk-ZPCK2O6G.js
259.4 KiB dist/toolbar/chunk-chunk-CV2VU6SQ.js
138.3 KiB dist/toolbar/chunk-chunk-DYPTRYMF.js
131.8 KiB dist/toolbar/chunk-chunk-FDH2IBXT.js
75.2 KiB dist/toolbar/toolbar-app-4HYNQ5KU.js
69.0 KiB dist/toolbar/chunk-chunk-TSAL54PB.js
35.6 KiB dist/toolbar/chunk-chunk-HOKNU4ZL.js
21.0 KiB dist/toolbar/chunk-chunk-Z5ELNJKM.js
6.8 KiB dist/toolbar/chunk-chunk-DV7IWQNF.js

Posted automatically by check-toolbar-size · sizes are toolbar output bytes (shipped, post-tree-shake) from the esbuild metafile

✅ Dist folder size — 🔺 +249.8 KiB (+0.0%)

Total size of the built frontend/dist folder (all assets), compared against the base branch.

Total: 946.31 MiB · 🔺 +249.8 KiB (+0.0%)

ℹ️ MCP UI apps size — 33 app(s), 17631.0 KB JS

Built size of each MCP UI app (main.js + styles.css).

App JS CSS
debug 597.9 KB 196.2 KB
action 454.1 KB 196.2 KB
action-list 564.2 KB 196.2 KB
cohort 453.1 KB 196.2 KB
cohort-list 563.2 KB 196.2 KB
email-template 452.9 KB 196.2 KB
error-details 469.6 KB 196.2 KB
error-issue 453.8 KB 196.2 KB
error-issue-list 564.1 KB 196.2 KB
experiment 561.3 KB 196.2 KB
experiment-list 564.9 KB 196.2 KB
experiment-results 566.3 KB 196.2 KB
feature-flag 566.8 KB 196.2 KB
feature-flag-list 570.5 KB 196.2 KB
feature-flag-testing 457.3 KB 196.2 KB
inline-scan 453.6 KB 196.2 KB
insight-actors 562.3 KB 196.2 KB
invite-email-preview 452.3 KB 196.2 KB
llm-costs 559.3 KB 196.2 KB
session-recording 455.3 KB 196.2 KB
survey 454.7 KB 196.2 KB
survey-global-stats 561.9 KB 196.2 KB
survey-list 564.9 KB 196.2 KB
survey-stats 561.9 KB 196.2 KB
trace-span 453.5 KB 196.2 KB
trace-span-list 564.1 KB 196.2 KB
vision-observation-list 563.3 KB 196.2 KB
workflow 453.4 KB 196.2 KB
workflow-list 563.5 KB 196.2 KB
loops-review 457.8 KB 196.2 KB
query-results 774.1 KB 196.2 KB
render-ui 857.4 KB 196.2 KB
visual-review-snapshots 457.9 KB 196.2 KB
⚠️ MCP snapshots — 1 updated (1 modified, 0 added, 0 deleted)

Snapshots: MCP unit test snapshots updated

Changes: 1 snapshots (1 modified, 0 added, 0 deleted)

What this means:

  • Snapshots have been automatically updated to match current output

Next steps:

  • Review the changes to ensure they're intentional
  • If unexpected, investigate what caused the output to change

Review snapshot changes →

⚠️ Django migration SQL — 2 new migrations to review

We've detected new migrations on this PR. Review the SQL output for each migration:

products/customer_analytics/backend/migrations/0059_teamcustomeranalyticsconfig_default_pinned_properties.py

/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/anyio/from_thread.py:119: SyntaxWarning: 'return' in a 'finally' block
  return result
/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/structlog/stdlib.py:1166: UserWarning: Remove `format_exc_info` from your processor chain if you want pretty exceptions.
  ed = p(logger, meth_name, ed)  # type: ignore[arg-type]
2026-09-28T23:44:52.831402Z [error    ] Path must be a valid database or directory containing databases. [posthog.exceptions_capture] pid=8032 tid=140669969460096
Traceback (most recent call last):
  File "/home/runner/work/posthog/posthog/posthog/geoip.py", line 15, in <module>
    geoip: Optional[GeoIP2] = GeoIP2(cache=8)
                              ~~~~~~^^^^^^^^^
  File "/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/django/contrib/gis/geoip2.py", line 116, in __init__
    raise GeoIP2Exception(
        "Path must be a valid database or directory containing databases."
    )
django.contrib.gis.geoip2.GeoIP2Exception: Path must be a valid database or directory containing databases.
/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/sshtunnel.py:1040: SyntaxWarning: 'return' in a 'finally' block
  return (ssh_host,
/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/langchain_core/_api/deprecation.py:27: UserWarning: Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater.
  from pydantic.v1.fields import FieldInfo as FieldInfoV1
System check identified some issues:

WARNINGS:
?: (axes.W001) You are using the django-axes cache handler for login attempt tracking. Your cache configuration is however invalid and will not work correctly with django-axes. This can leave security holes in your login systems as attempts are not tracked correctly. Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation.
?: (staticfiles.W004) The directory '/home/runner/work/posthog/posthog/frontend/dist' in the STATICFILES_DIRS setting does not exist.
BEGIN;
--
-- Add field default_pinned_properties to teamcustomeranalyticsconfig
--
ALTER TABLE "customer_analytics_teamcustomeranalyticsconfig" ADD COLUMN "default_pinned_properties" jsonb DEFAULT '[]'::jsonb NOT NULL;
ALTER TABLE "customer_analytics_teamcustomeranalyticsconfig" ALTER COLUMN "default_pinned_properties" DROP DEFAULT;
COMMIT;

products/customer_analytics/backend/migrations/0060_inherit_default_pinned_properties.py

/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/structlog/stdlib.py:1166: UserWarning: Remove `format_exc_info` from your processor chain if you want pretty exceptions.
  ed = p(logger, meth_name, ed)  # type: ignore[arg-type]
2026-09-28T23:45:19.046127Z [error    ] Path must be a valid database or directory containing databases. [posthog.exceptions_capture] pid=8897 tid=140426605357952
Traceback (most recent call last):
  File "/home/runner/work/posthog/posthog/posthog/geoip.py", line 15, in <module>
    geoip: Optional[GeoIP2] = GeoIP2(cache=8)
                              ~~~~~~^^^^^^^^^
  File "/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/django/contrib/gis/geoip2.py", line 116, in __init__
    raise GeoIP2Exception(
        "Path must be a valid database or directory containing databases."
    )
django.contrib.gis.geoip2.GeoIP2Exception: Path must be a valid database or directory containing databases.
/opt/hostedtoolcache/Python/3.14.7/x64/lib/python3.14/site-packages/langchain_core/_api/deprecation.py:27: UserWarning: Core Pydantic V1 functionality isn't compatible with Python 3.14 or greater.
  from pydantic.v1.fields import FieldInfo as FieldInfoV1
System check identified some issues:

WARNINGS:
?: (axes.W001) You are using the django-axes cache handler for login attempt tracking. Your cache configuration is however invalid and will not work correctly with django-axes. This can leave security holes in your login systems as attempts are not tracked correctly. Reconfigure settings.AXES_CACHE and settings.CACHES per django-axes configuration documentation.
?: (staticfiles.W004) The directory '/home/runner/work/posthog/posthog/frontend/dist' in the STATICFILES_DIRS setting does not exist.
BEGIN;
--
-- Raw SQL operation
--

                UPDATE customer_analytics_usercustomeranalyticsconfig
                SET properties = properties - 'pinned_properties'
                WHERE properties -> 'pinned_properties' = '[]'::jsonb
                  AND cardinality(pinned_custom_property_definition_ids) = 0
            ;
COMMIT;

Last updated: 2026-09-28 23:45 UTC (eed080b)

❌ Django migration risk — blocked migration detected

We've analyzed your migrations for potential risks.

Summary: 0 Safe | 1 Needs Review | 1 Blocked

❌ Blocked

Causes locks or breaks compatibility

customer_analytics.0060_inherit_default_pinned_properties
  └─ #1 ❌ RunSQL: RunSQL with UPDATE/DELETE needs careful review for locking

⚠️ Needs Review

May have performance impact

customer_analytics.0059_teamcustomeranalyticsconfig_default_pinned_properties
  └─ #1 ⚠️ AddField
     Adding NOT NULL field with callable default (list) - verify it's stable
     model: teamcustomeranalyticsconfig, field: default_pinned_properties, default: list

📚 How to Deploy These Changes Safely

RunSQL:

Break large updates into batches to avoid long locks:

  • Batch size: 1,000-10,000 rows per batch
  • Add pauses between batches
  • Use WHERE clauses to limit scope
  • Consider background jobs for very large updates (millions of rows)

See the migration safety guide

Last updated: 2026-09-28 23:45 UTC (eed080b)

@greptile-apps

greptile-apps Bot commented Sep 28, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[High risk] Adds database schema and API for default pinned account properties.

The PR is not ready to merge until deployment-overlap inheritance and paginated relationship loading are addressed.

Reviews (1) · Last reviewed commit: "chore(customer-analytics): add default p..."

Comment on lines +14 to +19
sql="""
UPDATE customer_analytics_usercustomeranalyticsconfig
SET properties = properties - 'pinned_properties'
WHERE properties -> 'pinned_properties' = '[]'::jsonb
AND cardinality(pinned_custom_property_definition_ids) = 0
""",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Empty rows miss inheritance If an old application instance creates a user configuration after this migration runs, it can still write pinned_properties: []. The new read path treats that list as a personal override, so the user will not inherit project defaults. Make normalization safe for rows created during the deployment overlap.

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/customer_analytics/backend/migrations/0060_inherit_default_pinned_properties.py
Line: 14-19

Comment:
**Empty rows miss inheritance** If an old application instance creates a user configuration after this migration runs, it can still write `pinned_properties: []`. The new read path treats that list as a personal override, so the user will not inherit project defaults. Make normalization safe for rows created during the deployment overlap.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +147 to +152
relationshipDefinitionsLogic,
[
'definitions as relationshipDefinitions',
'definitionsLoading as relationshipDefinitionsLoading',
'definitionsLoadFailed as relationshipDefinitionsLoadFailed',
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Later relationships disappear When a project has more relationships than fit on the first API page, the connected loader provides only that page. Existing pins from later pages appear unavailable, and saving the draft can remove them. Load all relationship pages before resolving or editing defaults.

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/defaultPinnedAccountPropertiesLogic.ts
Line: 147-152

Comment:
**Later relationships disappear** When a project has more relationships than fit on the first API page, the connected loader provides only that page. Existing pins from later pages appear unavailable, and saving the draft can remove them. Load all relationship pages before resolving or editing defaults.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment on lines +13 to +19
migrations.RunSQL(
sql="""
UPDATE customer_analytics_usercustomeranalyticsconfig
SET properties = properties - 'pinned_properties'
WHERE properties -> 'pinned_properties' = '[]'::jsonb
AND cardinality(pinned_custom_property_definition_ids) = 0
""",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unbounded migration update On a populated user-configuration table, this single transactional UPDATE can hold row locks and delay configuration writes while it scans and rewrites historical rows. Batch the normalization or otherwise bound its lock and transaction impact.

Prompt To Fix With AI
This is a comment left during a code review.
Path: products/customer_analytics/backend/migrations/0060_inherit_default_pinned_properties.py
Line: 13-19

Comment:
**Unbounded migration update** On a populated user-configuration table, this single transactional UPDATE can hold row locks and delay configuration writes while it scans and rewrites historical rows. Batch the normalization or otherwise bound its lock and transaction impact.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

📝 Walkthrough

Walkthrough

The change adds ordered project defaults for pinned account properties and relationships. The team API validates and stores these defaults, and user reads return them when no personal pin list is stored. Migrations remove legacy empty pin lists to enable inheritance. An admin interface lets project admins configure defaults, reorder pins, and save an empty list.

Priority: ➖ Normal

Merge Risk: 🟡 Moderate · up to eed08

This change adds project-default pinned properties. Before merging, resolve the blocked migration and its interaction with rolling deploys. Also fix the partial writes on invalid input, the possible loss of valid relationship defaults when saving, and child-environment defaults that users may never see.

Security Architecture Review

Security architecture risk: 🟡 Moderate · up to eed08

Project admins gain shared defaults, but the migration can turn some users’ previously empty personal selections into inherited defaults. The change has a bounded permission model, while that preference change and its rollback remain material design risks.

Retained concerns

  • Medium · architecture · observed: Migration 0060 removes the marker for every matching empty personal selection, including an intentional clear indistinguishable from a historical auto-created empty row. Affected users then inherit current project defaults; migration reversal does not restore their selection. Whether deployment ordering also permits newly written empty overrides to be converted is unknown.
Security review details

Security Blast Radius

  • inferred — An authorized change to project defaults can change the pins inherited by users of that project who lack a surviving personal override. The observed contract does not grant the setting writer cross-project references.

Security Findings and Attack Paths

  • inferred — The examined project-setting path did not establish a non-admin or cross-project write path. The migration can defeat an affected user’s previous empty-display preference, but the evidence does not establish unauthorized access to property values.

Trust Boundaries and Controls

  • observed — The shared-setting serializer validates submitted references for its existing team instance; the personal endpoint derives the user ID from the request rather than accepting a target user ID in the replacement payload.

Resilience and Maintainability Implications

  • inferred — The migration’s single-statement update limits per-row interruption states, but its no-op reversal does not recover lost opt-out intent. Mixed-version writer ordering is not established by the available deployment evidence.

Hardening Proposals

  • proposed — Preserve or establish provenance for intentional empty overrides before normalization, and verify migration-before-writer ordering or provide a compensating recovery path for affected selections.
🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Description check ✅ Passed The description clearly explains the problem, user-visible changes, validation, migration behavior, testing, documentation, and agent context. It includes a screenshot and required agent details. The …
✨ Finishing Touches 💡 1
🛠️ Fix failing CI checks 💡
  • Commit to this branch
  • Create a new PR
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Note

Quiet mode is enabled, so only the most important comments were posted inline. Other review comments are grouped below.

🟡 Other comments (3)
posthog/api/team.py-1142-1143 (1)

1142-1143: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Bind customer analytics validation before other config writes.

When a PATCH includes valid revenue or marketing settings and invalid default pins, TeamSerializer.update writes the earlier settings before _update_customer_analytics_config raises a validation error. The project endpoint does not add an atomic wrapper, and ATOMIC_REQUESTS is disabled. The API can therefore return HTTP 400 after persisting part of the request.

Bind the nested serializer to the existing customer analytics config during outer validation:

🐛 Suggested fix
-    @staticmethod
-    def validate_customer_analytics_config(value):
+    def validate_customer_analytics_config(self, value):
         if value is None:
             return None
 
-        serializer = TeamCustomerAnalyticsConfigSerializer(data=value)
+        instance = self.instance.customer_analytics_config if self.instance is not None else None
+        serializer = TeamCustomerAnalyticsConfigSerializer(instance=instance, data=value)
         if not serializer.is_valid():
             raise exceptions.ValidationError(_format_serializer_errors(serializer.errors))
         return serializer.validated_data
products/customer_analytics/backend/migrations/0060_inherit_default_pinned_properties.py-14-19 (1)

14-19: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

The migration risk check is blocking this RunSQL UPDATE.

The UPDATE scans customer_analytics_usercustomeranalyticsconfig and rewrites the matching rows in one transaction. This table has one row per user per project, so the row count grows with both. Take one of these actions to pass the check:

  • Confirm that the table is small and record the size in a comment that the check accepts.
  • Run the update in batches by primary key.
  • Move the update to a background job.

Source: Pipeline failures

products/customer_analytics/backend/logic/user_customer_analytics_config.py-72-77 (1)

72-77: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Write customer analytics defaults to the canonical team row.

get_or_create_config canonicalizes child team IDs, so read_pinned_properties reads the parent team's defaults. TeamSerializer._update_customer_analytics_config saves instance.customer_analytics_config directly. A PATCH for a child environment can therefore write the child row, while reads use the parent row. Resolve the canonical team before the write or before the default read.


ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository: PostHog/posthog/.coderabbit.yaml

Review profile: QUIET

Plan: Enterprise

Run ID: 4a46d752-d5fa-401b-9528-63b9ecb821b8

📥 Commits

Reviewing files that changed from the base of the PR and between ba079f0 and eed080b.

⛔ Files ignored due to path filters (5)
  • frontend/src/generated/core/api.schemas.ts is excluded by !**/generated/**
  • products/customer_analytics/frontend/generated/api.schemas.ts is excluded by !**/generated/**
  • products/customer_analytics/frontend/generated/api.ts is excluded by !**/generated/**
  • products/experiments/frontend/generated/api.schemas.ts is excluded by !**/generated/**
  • services/mcp/src/generated/core/api.ts is excluded by !**/generated/**
📒 Files selected for processing (33)
  • docs/internal/customer-analytics/accounts-table.md
  • frontend/src/queries/schema.json
  • frontend/src/queries/schema/schema-general.ts
  • posthog/api/team.py
  • posthog/api/test/test_project.py
  • posthog/schema.py
  • posthog/schema_enums.py
  • products/customer_analytics/backend/facade/account_property_pins.py
  • products/customer_analytics/backend/facade/api.py
  • products/customer_analytics/backend/facade/contracts.py
  • products/customer_analytics/backend/facade/enums.py
  • products/customer_analytics/backend/logic/account_property_pins.py
  • products/customer_analytics/backend/logic/user_customer_analytics_config.py
  • products/customer_analytics/backend/migrations/0059_teamcustomeranalyticsconfig_default_pinned_properties.py
  • products/customer_analytics/backend/migrations/0060_inherit_default_pinned_properties.py
  • products/customer_analytics/backend/migrations/max_migration.txt
  • products/customer_analytics/backend/models/team_customer_analytics_config.py
  • products/customer_analytics/backend/presentation/views/serializers.py
  • products/customer_analytics/backend/presentation/views/views.py
  • products/customer_analytics/backend/test/test_default_pinned_properties_migration.py
  • products/customer_analytics/backend/test/test_user_customer_analytics_config_api.py
  • products/customer_analytics/frontend/components/Accounts/AGENTS.md
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsAccountScene/components/AccountPropertyConfigurator.tsx
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/CustomerAnalyticsAccountConfig.tsx
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/DefaultPinnedAccountProperties.stories.tsx
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/DefaultPinnedAccountProperties.test.tsx
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/DefaultPinnedAccountProperties.tsx
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/customPropertyDefinitionsLogic.ts
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/defaultPinnedAccountPropertiesLogic.test.ts
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/defaultPinnedAccountPropertiesLogic.ts
  • products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/relationshipDefinitionsLogic.ts
  • services/mcp/src/api/generated.ts
  • services/mcp/tests/unit/__snapshots__/tool-schemas/project-settings-update.json

Included review availability: This review used your included allowance. Your plan provides up to 12 included reviews per hour; 10 remain after this review.

class AccountPropertyPinKind(str, Enum):
CUSTOM_PROPERTY = "custom_property"
RELATIONSHIP = "relationship"
class AccountPropertyPinKind(models.TextChoices):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Keep Django choices out of the facade enum.

AccountPropertyPinKind now depends on models.TextChoices. Keep this enum as a standard-library string enum and construct Django or DRF choices at the API boundary. As per coding guidelines, facade enums.py files allow “No Django imports (from django.*).”

Source: Coding guidelines

Comment on lines +13 to +21
migrations.RunSQL(
sql="""
UPDATE customer_analytics_usercustomeranalyticsconfig
SET properties = properties - 'pinned_properties'
WHERE properties -> 'pinned_properties' = '[]'::jsonb
AND cardinality(pinned_custom_property_definition_ids) = 0
""",
reverse_sql=migrations.RunSQL.noop,
),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Old application servers can undo this migration during a rolling deploy.

Migrations run before all old servers stop. The old get_or_create_config writes {"pinned_properties": []} in two cases: when it creates a row through defaults, and when a row has no legacy IDs. Each GET that an old server handles after this UPDATE adds the empty key back. Those users then have an explicit empty override and do not inherit project defaults. The PR already accepts some data loss here, and this timing gap adds more.

Fix: move this cleanup to a follow-up migration that ships after the code in this PR is fully deployed. The new code no longer writes the empty key. As an alternative, run the cleanup again as a post-deploy step.

Comment on lines +232 to +245
resolvedPinnedPropertyKeys: [
(s) => [s.defaultPinnedProperties, s.propertyOptions],
(references: PinnedAccountPropertyApi[], options: AccountPropertyOption[]): string[] => {
const availableKeys = new Set(options.map(({ key }) => key))
return references.map(pinnedPropertyToConfiguratorKey).filter((key) => availableKeys.has(key))
},
],
stalePinnedProperties: [
(s) => [s.defaultPinnedProperties, s.propertyOptions],
(references: PinnedAccountPropertyApi[], options: AccountPropertyOption[]): PinnedAccountPropertyApi[] => {
const availableKeys = new Set(options.map(({ key }) => key))
return references.filter((reference) => !availableKeys.has(pinnedPropertyToConfiguratorKey(reference)))
},
],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🔎 Supported by static analysis

🏁 Script executed:

#!/bin/bash
rg -n -C6 'loadDefinitions:\s*async' products/customer_analytics/frontend/scenes/CustomerAnalyticsConfigurationScene/account/
rg -n -C3 'page_size|PAGE_SIZE|pagination_class' products/customer_analytics/backend/presentation/views/views.py | head -60

Repository: PostHog/posthog

Length of output: 7477


Paginate relationship definitions before resolving pinned properties.

relationshipDefinitionsLogic.loadDefinitions returns only response.results from accountRelationshipDefinitionsList. If the API response is paginated, a default that points to a later-page relationship is classified as stale, omitted from the configurator draft, and removed on the next save. Make the relationship loader fetch all definition pages before using its results to classify defaults.

@trunk-io

trunk-io Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Static Badge   Static Badge   Static Badge

Failed Test Failure Summary Logs
Customer Analytics/Default pinned properties Configured play-test The test timed out while waiting for the element with data attribute 'account-pinned-properties-list' to become visible. Logs ↗︎
Scenes-App/SidePanels SidePanelNotebooks smoke-test The test timed out while waiting for a loading indicator or spinner to disappear. Logs ↗︎

View Full Report ↗︎ ⋅ Docs

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant