feat(marketing-analytics): add granular conversion goal write endpoints#73012
feat(marketing-analytics): add granular conversion goal write endpoints#73012jabahamondes wants to merge 2 commits into
Conversation
🤖 CI report✅ Bundle size — no changeUncompressed size of every built Total: 64.22 MiB · no change No file changed by more than 1000 B. Posted automatically by build-bundle-size-report · uncompressed bytes from dist-report ✅ Eager graph — within budgetHow 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
🟢 Largest files eagerly shipped from
|
| 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/RootErrorBoundary.tsx |
| 912 B | ../node_modules/.pnpm/ieee754@1.2.1/node_modules/ieee754/index.js |
| 789 B | src/scenes/ChunkLoadErrorBoundary.tsx |
| 762 B | src/index.tsx |
Largest files eagerly shipped from src/scenes/AuthenticatedShell.tsx
| Size | File |
|---|---|
| 281.3 KiB | ../node_modules/.pnpm/posthog-js@1.404.0/node_modules/posthog-js/dist/rrweb.js |
| 267.7 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 |
| 235.5 KiB | src/taxonomy/core-filter-definitions-by-group.json |
| 223.2 KiB | ../node_modules/.pnpm/posthog-js@1.404.0/node_modules/posthog-js/dist/module.js |
| 164.0 KiB | src/queries/validators.js |
| 154.3 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 |
| 105.8 KiB | src/lib/api.ts |
| 93.3 KiB | ../node_modules/.pnpm/prosemirror-view@1.40.1/node_modules/prosemirror-view/dist/index.js |
| 93.2 KiB | ../packages/quill/packages/quill/dist/index.js |
Posted automatically by check-eager-graph · sizes are eager output bytes (shipped, post-tree-shake) from the esbuild metafile · part of #32479
✅ Dist folder size — no change
Total size of the built frontend/dist folder (all assets), compared against the base branch.
Total: 1341.59 MiB · no change
ℹ️ MCP UI apps size — 31 app(s), 16602.7 KB JS
Built size of each MCP UI app (main.js + styles.css).
| App | JS | CSS |
|---|---|---|
| debug | 599.5 KB | 187.4 KB |
| action | 457.8 KB | 187.4 KB |
| action-list | 564.3 KB | 187.4 KB |
| cohort | 456.8 KB | 187.4 KB |
| cohort-list | 563.3 KB | 187.4 KB |
| email-template | 456.6 KB | 187.4 KB |
| error-details | 472.4 KB | 187.4 KB |
| error-issue | 457.5 KB | 187.4 KB |
| error-issue-list | 564.2 KB | 187.4 KB |
| experiment | 561.5 KB | 187.4 KB |
| experiment-list | 565.1 KB | 187.4 KB |
| experiment-results | 563.2 KB | 187.4 KB |
| feature-flag | 567.1 KB | 187.4 KB |
| feature-flag-list | 570.9 KB | 187.4 KB |
| feature-flag-testing | 461.0 KB | 187.4 KB |
| insight-actors | 562.1 KB | 187.4 KB |
| invite-email-preview | 456.0 KB | 187.4 KB |
| llm-costs | 559.5 KB | 187.4 KB |
| session-recording | 458.6 KB | 187.4 KB |
| session-summary | 463.9 KB | 187.4 KB |
| survey | 458.4 KB | 187.4 KB |
| survey-global-stats | 562.2 KB | 187.4 KB |
| survey-list | 565.0 KB | 187.4 KB |
| survey-stats | 562.2 KB | 187.4 KB |
| trace-span | 457.2 KB | 187.4 KB |
| trace-span-list | 564.2 KB | 187.4 KB |
| workflow | 457.1 KB | 187.4 KB |
| workflow-list | 563.7 KB | 187.4 KB |
| query-results | 744.8 KB | 187.4 KB |
| render-ui | 825.3 KB | 187.4 KB |
| visual-review-snapshots | 461.6 KB | 187.4 KB |
Adds create, update and delete for a single conversion goal, scoped by marketing_analytics:write. Until now the only way to change a goal was to PATCH the whole config blob, which overwrites concurrent edits and gives an API client no way to touch one goal. Validates against the schema models and takes a row lock so two writers can't clobber each other.
2c6c258 to
8d34356
Compare
Note
Fourth in a stack, on top of #72996 → #72798 → #72301.
Problem
There is no way to change one conversion goal. The only write path is
PATCH /api/projects/:id/with the entiremarketing_analytics_config, which means:project:write, which also grants every other project setting.Changes
Three actions on the existing marketing analytics viewset, all behind
marketing_analytics:write(a scope that already existed and had no endpoints using it):POSTconversion_goals/createPATCHconversion_goals/{id}/updateDELETEconversion_goals/{id}/deleteEach returns the written goal plus the full list after the write, so a client can reconcile without a second request.
Three things worth reviewing:
Row lock. Every write runs in a transaction with
select_for_update()on the config row. The goals live in a JSON column, so a concurrent read-modify-write would otherwise drop one of the two edits. This is the main reason the endpoint exists.Validation against the schema. The body is validated with a pydantic
TypeAdapterover the threeConversionGoalFiltershapes, so a malformed goal is a 400 instead of a corrupt row that only explodes later at query time. Union errors are collapsed into one readable message per field, since raw pydantic reports one error per union member.It writes the column, not the setter. Flagging this because it looks wrong at first glance:
The
conversion_goalssetter runsvalidate_conversion_goals, which predates the schema models and requires a top-levelnameon every goal. The schema treatsnameas optional (conversion_goal_nameis the required one), so the legacy validator rejects goals that are perfectly valid per the schema. Rather than degrade the new endpoint to satisfy a stale rule, this path validates against the schema itself, which is strictly stronger. Realigningvalidate_conversion_goalswith the schema is worth doing and belongs in its own PR - it affects the existing PATCH path and could reject configs that are live today, so it needs its own blast-radius discussion.The route names carry the
/create,/updateand/deletesuffixes because the collection pathconversion_goalsis already taken by the existing read action, and DRF routers cannot dispatch two actions that differ only by HTTP method on the same path.How did you test this code?
13 new tests in
products/marketing_analytics/backend/test_api_conversion_goals.py, all passing, plus the existing config suite (8 passed) andspectacular --fail-on-warn(exit 0). All run by me (Claude); I did not click through this in the UI.What each group is actually guarding, since a test that does not name its regression should not be here:
Two cases I wrote and then deleted, because they asserted my assumptions rather than real behavior:
kindhas a default so omitting it is valid, and pydantic coerces"yes"toTruein lax mode. Both were my error, not the code's.Not covered: concurrent writes racing on the lock. Exercising it needs committed transaction boundaries and a second connection, which means
TransactionTestCaseand real threads - expensive and flaky for a guarantee Postgres provides. The lock is one line and visible in review.Automatic notifications
Docs update
Nothing to update yet. Once the MCP write tools land on top of this, the marketing analytics docs should mention that goals are writable via API.
🤖 Agent context
Autonomy: Human-driven (agent-assisted)
I directed this, Claude wrote it. Invoked
/improving-drf-endpoints(schema annotations,help_text, where to register team-nested routes) and/writing-tests(which is what forced the "name the regression" pass on the test list above, and cut two tests that did not earn it).Design notes. A dedicated
ConversionGoalViewSetunder its own route was the first idea, rejected because this product still registers through the legacy dual-route helper and a nested prefix would have risked colliding with it; actions on the existing viewset keep the routing boring. The response returns both the single goal and the full list because the caller usually wants to re-render the whole list, and a second round trip to fetch it would be wasted.The
_conversion_goalswrite is the one deliberate wart, explained above. Next step in this line is the MCP write tools sitting on these endpoints.