You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Track the work left before enabling native JSON event reads and, later, retiring the legacy events table. Every item names work someone has to do and what done looks like. Do not put customer queries or private operational data here.
Where things stand (Sep 29)
Merged: the native schema and base property reads (#91515), correctness fixes (#104783), the retained mutation log (#97215), flag compatibility with the $false sentinel (#91506), the native CI lane (#106280), the HogQL port of point-in-time person properties (#91452), and the UDF wrapper fix (#105651).
In the merge queue: #107043 (moved event properties read from temporary_properties). #106380 (native-only regression tests) is approved and joins the queue when its checks pass. #106274 (the useNewEventsSchema modifier, the rollout switch) left the queue on Sep 29 when a cancelled MCP run failed its batch, and needs to be queued again.
Open: #107546 moves the table to dynamic JSON paths, adds the null-key columns, turns date inference off, and stores dotted keys flat. The production table is re-backfilled after it lands. It conflicts with master, so its CI does not run until master is merged in. #106152 is open and listed below.
Rollout mechanism: staff set useNewEventsSchema in a project's team modifiers in Django admin. An explicit modifier is part of the query cache key, so the switch recomputes on native and clearing it restores the legacy cache entries. The final global flip uses the instance setting. Customers cannot set the key on their project (#106274); the per-query override stays available to API callers during the trial.
Backfill query. Recreate the table with the feat(clickhouse): use dynamic JSON paths in the native events schema #107546 DDL, then copy every event with no retention cutoff, with the insert setting json_type_escape_dots_in_keys = 1, type_json_skip_duplicated_paths = 1, input_format_try_infer_dates = 0 and input_format_try_infer_datetimes = 0, the same four the ingestion view sets. Done when the copy runs with them.
AI prompt and response text. The native cleaner omits $ai_input and $ai_output_choices; the AI events table keeps that text only for its retention window, and older traces read it from the legacy table today. Get the LLM analytics owner's written agreement that older traces lose it, or a replacement source. Done when the answer is recorded here.
Before enabling native reads for any project
Read-side setting as a profile default on the events cluster. ClickHouse restores %2E to a dot only when the reading query sets json_type_escape_dots_in_keys. HogQL and the export templates set it per query (feat(clickhouse): use dynamic JSON paths in the native events schema #107546, 0a9745a), but mutations run with server settings, and ad-hoc readers set nothing. Add it to the default profile on the events cluster. Done when a clickhouse-client session on that cluster prints a dotted key with a dot.
Rebuild nulls in whole-document reads. The JSON column cannot store null, so the cleaner drops those fields and feat(clickhouse): use dynamic JSON paths in the native events schema #107546 records their paths in properties_null_keys, temporary_properties_null_keys and person_properties_null_keys. Nothing reads those arrays yet. Batch exports and the events API put the nulls back. Done when a native export and the events API return null for a fixture that sent one.
Land fix(hogql): read rebuilt flags through every native JSON function #106152, flag reads through JSON functions.JSONExtractRaw(properties, '$feature/x'), arrayJoin(JSONExtractArrayRaw(properties, '$active_feature_flags')), JSONLength, JSONType and toString(properties) do not see the rebuilt flags on native. It also has to decide how these functions tell a string variant named "true" from boolean true. Rebase onto master, drop the inherited legacy-table rewrites, land.
Decide the whole-document flag shape. On native, SELECT properties, the event properties panel and exports show flags as one $feature_flags object; the legacy table shows many $feature/<key> properties. Decide whether whole-document reads rebuild the legacy shape or expose the map, then implement it, including property restrictions and generated column aliases.
Batch exports. Pin exports to the legacy table now: the export source builds its query with the project's default modifiers, so switching a project would move its exports too. Before exports move: custom export columns are persisted as compiled ClickHouse expressions and schemas saved with property restrictions omit their original HogQL, so a saved export must recompile against the table it reads at run time; native exports serialize declared array paths as [] on every event; the native field rewrite relocates $feature/<key> paths but not JSON-function calls on them. Done when an export created before enablement, one created after, a restricted schema and a historical backfill all produce the same rows from both tables apart from the recorded expected differences.
Land chore(tests): prove event queries read native-only fixtures #106380, native-only regression tests. Fixtures that exist only in the native table, with the legacy table asserted empty, so a query that fell back to legacy fails. Approved and submitted to the merge queue on Sep 29, covering the events-list boolean filters and daily trends. Done when it merges, before the first project switches.
Property removal on the native table. Removing one property from all of a project's events is not implemented for the JSON columns. Implement it for properties, temporary_properties and person_properties, including $unparseable_properties. The deletion job reads a dot in a requested name as nesting on both tables; with dotted keys stored flat, the predicate, the verify step and the JSONDropKeys key list need the same %2E encoding to reach a flat dotted key (Sep 29 comment).
Compare and measure. Run the warm insights of the first trial project against both tables with the feat(hogql): add a useNewEventsSchema query modifier #106274 modifier and record the drift here, classified against the expected differences: empty and null values, JSON scalar types, number formatting (1.50 is stored as 1.5), flag arrays and order, omitted properties, deduplication; nulls and empty strings inside $set are dropped; object keys come back sorted; numbers above the signed 64-bit range inside an object read come back as JSON strings; an empty $set or $unset reads as missing; a key sent with a literal %2E reads back with a dot; an event carrying both a.b and a%2Eb keeps the first value; posthog-go before 1.13.2 listed off flags as active. Measure insert and merge cost with up to 1024 dynamic paths per part, and reads of wide objects past a column's dynamic-path budget (200k local synthetic events: a whole-object read of a wide $set took 20 s native against 0.8 s legacy, a $set.email filter read 9 MB against 177 MB). HogQL reads every native path through the generic Dynamic expression, even declared typed paths such as $browser; measure that too. Done when the numbers and the rollback criteria are written here.
Before retiring the legacy events table
Legacy writes stop and storage is dropped only after the items above and these are done.
Move the direct readers. These query the legacy table by name and bypass the schema switch: products/feature_flags/backend/flag_analytics.py, products/growth/backend/temporal/health_checks/sdk_outdated.py, and the MCP analytics metrics in posthog/tasks/usage_report.py (their dedup expression matches the legacy sort key). Move or retire each, then add the semgrep rule against new raw events SQL outside the switch.
Dotted keys flat, application side: feat(clickhouse): use dynamic JSON paths in the native events schema #107546 (0a9745a) sets json_type_escape_dots_in_keys and type_json_skip_duplicated_paths on the ingestion view and fixture inserts, HogQL encodes a dot inside a key segment as %2E on native reads, and native queries and export templates set the read-side setting. The cleaner and cluster parts are above.
Consult this issue first when working on native JSON events. Add an item only when it names work someone has to do, with a code or PR reference and what done looks like. Check an item off when the work is in production, not when a PR merges. Keep merge decisions distinct from permission to enable a project or retire storage.
Track the work left before enabling native JSON event reads and, later, retiring the legacy events table. Every item names work someone has to do and what done looks like. Do not put customer queries or private operational data here.
Where things stand (Sep 29)
Merged: the native schema and base property reads (#91515), correctness fixes (#104783), the retained mutation log (#97215), flag compatibility with the
$falsesentinel (#91506), the native CI lane (#106280), the HogQL port of point-in-time person properties (#91452), and the UDF wrapper fix (#105651).In the merge queue: #107043 (moved event properties read from
temporary_properties). #106380 (native-only regression tests) is approved and joins the queue when its checks pass. #106274 (theuseNewEventsSchemamodifier, the rollout switch) left the queue on Sep 29 when a cancelled MCP run failed its batch, and needs to be queued again.Open: #107546 moves the table to dynamic JSON paths, adds the null-key columns, turns date inference off, and stores dotted keys flat. The production table is re-backfilled after it lands. It conflicts with master, so its CI does not run until master is merged in. #106152 is open and listed below.
Rollout mechanism: staff set
useNewEventsSchemain a project's team modifiers in Django admin. An explicit modifier is part of the query cache key, so the switch recomputes on native and clearing it restores the legacy cache entries. The final global flip uses the instance setting. Customers cannot set the key on their project (#106274); the per-query override stays available to API callers during the trial.Before the backfill starts
%2Ein the null-key paths, sonested.deepand a flat key nameda.bstay distinct. Rebuild the binaries and move the production UDF pin to a build with this, fix(clickhouse): close inherited descriptors in udf wrapper scripts #105651 and the$falsefix from feat(hogql): flag compatibility for native events and a $false sentinel #91506. Done when the pinned build is the one the backfill runs.json_type_escape_dots_in_keys = 1,type_json_skip_duplicated_paths = 1,input_format_try_infer_dates = 0andinput_format_try_infer_datetimes = 0, the same four the ingestion view sets. Done when the copy runs with them.$feature_flag_calledin both paths. The live ingestion view keeps these events; the copy currently drops them. Pick one and make both paths match, coordinated with Stop advertising$feature_flag_calledin taxonomy, autocomplete, and product surfaces #88126.$ai_inputand$ai_output_choices; the AI events table keeps that text only for its retention window, and older traces read it from the legacy table today. Get the LLM analytics owner's written agreement that older traces lose it, or a replacement source. Done when the answer is recorded here.Before enabling native reads for any project
%2Eto a dot only when the reading query setsjson_type_escape_dots_in_keys. HogQL and the export templates set it per query (feat(clickhouse): use dynamic JSON paths in the native events schema #107546, 0a9745a), but mutations run with server settings, and ad-hoc readers set nothing. Add it to the default profile on the events cluster. Done when aclickhouse-clientsession on that cluster prints a dotted key with a dot.null, so the cleaner drops those fields and feat(clickhouse): use dynamic JSON paths in the native events schema #107546 records their paths inproperties_null_keys,temporary_properties_null_keysandperson_properties_null_keys. Nothing reads those arrays yet. Batch exports and the events API put the nulls back. Done when a native export and the events API returnnullfor a fixture that sent one.JSONExtractRaw(properties, '$feature/x'),arrayJoin(JSONExtractArrayRaw(properties, '$active_feature_flags')),JSONLength,JSONTypeandtoString(properties)do not see the rebuilt flags on native. It also has to decide how these functions tell a string variant named"true"from boolean true. Rebase onto master, drop the inherited legacy-table rewrites, land.SELECT properties, the event properties panel and exports show flags as one$feature_flagsobject; the legacy table shows many$feature/<key>properties. Decide whether whole-document reads rebuild the legacy shape or expose the map, then implement it, including property restrictions and generated column aliases.[]on every event; the native field rewrite relocates$feature/<key>paths but not JSON-function calls on them. Done when an export created before enablement, one created after, a restricted schema and a historical backfill all produce the same rows from both tables apart from the recorded expected differences.sharded_events_jsonindeletes_jobon purpose. Re-enable it, then run a catch-up over deletion requests already marked verified, because re-enabling does not revisit them. chore(clickhouse): test adhoc deletes across events clusters #99416 (draft) adds a two-cluster test. See deletion coverage.sharded_events_jsonfrom the weekly squash, so native rows keep the absorbedperson_idand merged users count twice. Add the table back once its cluster resolves reliably, and repair rows stranded in the meantime, for example by copyingperson_idfrom the squashed legacy rows by uuid; the repair needs the legacy table. chore(clickhouse): make squash coverage a checked invariant #98865 records the exclusion.properties,temporary_propertiesandperson_properties, including$unparseable_properties. The deletion job reads a dot in a requested name as nesting on both tables; with dotted keys stored flat, the predicate, the verify step and theJSONDropKeyskey list need the same%2Eencoding to reach a flat dotted key (Sep 29 comment).1.50is stored as1.5), flag arrays and order, omitted properties, deduplication; nulls and empty strings inside$setare dropped; object keys come back sorted; numbers above the signed 64-bit range inside an object read come back as JSON strings; an empty$setor$unsetreads as missing; a key sent with a literal%2Ereads back with a dot; an event carrying botha.banda%2Ebkeeps the first value; posthog-go before 1.13.2 listed off flags as active. Measure insert and merge cost with up to 1024 dynamic paths per part, and reads of wide objects past a column's dynamic-path budget (200k local synthetic events: a whole-object read of a wide$settook 20 s native against 0.8 s legacy, a$set.emailfilter read 9 MB against 177 MB). HogQL reads every native path through the genericDynamicexpression, even declared typed paths such as$browser; measure that too. Done when the numbers and the rollback criteria are written here.Before retiring the legacy events table
Legacy writes stop and storage is dropped only after the items above and these are done.
$sethistory. Point-in-time person properties (fix(persons): build properties_at_time via HogQL instead of raw SQL #91452) and the event details panel read$setand$set_oncefrom old events. On native those live intemporary_propertiesfor 60 days and in the mutation log (feat(clickhouse): retain event person mutation payloads on aux #97215) for 30, so anything older disappears with the legacy table. Choose a durable source or change the product contract, and finish feat(events): show retained person property mutations #97217 against whichever it is.products/feature_flags/backend/flag_analytics.py,products/growth/backend/temporal/health_checks/sdk_outdated.py, and the MCP analytics metrics inposthog/tasks/usage_report.py(their dedup expression matches the legacy sort key). Move or retire each, then add the semgrep rule against new raw events SQL outside the switch.Done
$falsesentinel. Historical rows whose variant was literally"false"are fixed by the re-backfill, which runs every row through the new cleaner..github/new-events-schema-targets.txtagainst the native table on every PR and in the merge queue.json_type_escape_dots_in_keysandtype_json_skip_duplicated_pathson the ingestion view and fixture inserts, HogQL encodes a dot inside a key segment as%2Eon native reads, and native queries and export templates set the read-side setting. The cleaner and cluster parts are above.*_null_keyscolumns. Reading them back is above.Keeping this tracker current
Consult this issue first when working on native JSON events. Add an item only when it names work someone has to do, with a code or PR reference and what done looks like. Check an item off when the work is in production, not when a PR merges. Keep merge decisions distinct from permission to enable a project or retire storage.