Skip to content

feat!: super_options, context options and personless as the lowest option layer - #1024

Open
eli-r-ph wants to merge 5 commits into
v1-capture-optionsfrom
v1-capture-option-layers
Open

eli-r-ph wants to merge 5 commits into
v1-capture-optionsfrom
v1-capture-option-layers

Conversation

@eli-r-ph

@eli-r-ph eli-r-ph commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

The previous PR in this stack added per-event options. This PR adds the layers below them, so every capture option and property follows one order:

  1. per-event options / properties
  2. context: set_context_option() / tag(), filling only what is still unset
  3. global: super_options / super_properties, filling only what is still unset
  4. values the SDK adds ($is_server, $geoip_disable, system context such as $os, personless, $release_id from POSTHOG_RELEASE_ID), filling only what is still unset
  5. before_send, which sees all of the above and has the final say
  6. legacy properties fill unset options and are removed, once

A property is unset when its key is missing. An option is unset when it is missing or None. $set, $set_once, $groups and $group_set fill one level deep when both values are dicts.

posthog-go (PostHog/posthog-go#359) applies the same order. posthog-rs (PostHog/posthog-rs#279) has no context or global layer, and already removes legacy properties.

New API:

  • super_options on Client, AsyncPosthog and the module (posthog.super_options = {...} before setup()). It works the same way as super_properties.
  • set_context_option(key, value) / get_context_options() on the module, posthog.contexts and Client. They work the same way as tag() / get_tags(): child contexts inherit them unless fresh=True. Context options apply to capture, capture_ai, capture_exception, set and set_once, the same paths that context tags reach.

Behavior changes:

  • Context and global values fill only unset keys, before before_send. before_send sees them, as in 7.x, and can change or remove them.
  • $set, $set_once, $groups and $group_set fill one level deep. In 7.x a super_properties value replaced the event's whole dict. Now the event wins key by key.
  • The set() / set_once() values win over a $set / $set_once in properties. The v1 relocation used to let the properties copy win, so a super $set beat the call. Ingestion merges them with the call winning, and the relocation now does the same.
  • A None option is filled by context, global and derived values.
  • Per-event and context properties now beat super_properties. Before, {**properties, **super_properties} let a global value overwrite the caller's per-event value. A side effect: super_properties can no longer override $lib or $lib_version.
  • Values the SDK adds are defaults the caller overrides. $is_server, $geoip_disable and system context ($os, $python_version and so on) fill last, after super_properties, before before_send. In 7.x the SDK overwrote an event's value for them, and $is_server also beat super_properties. Now super_properties={"$geoip_disable": False} turns GeoIP on for events even with disable_geoip=True. Flag requests still follow disable_geoip.
  • groups= merges into a $groups property and wins key by key, in Client, AsyncPosthog and posthog.mcp events. It used to replace the property.
  • Personless is now the last fill step. An event without a distinct ID gets options.process_person_profile = false, not the $process_person_profile property. Any per-event, before_send, context or global option overrides it.
  • A legacy $process_person_profile property no longer overrides personless. The personless option is set, and an option beats its legacy property. To opt a personless event in, set the option.

Not in this PR: AI wrappers and MCP moving their personless override to a per-event option (next PR).

💚 How did you test it?

  • Fill before before_send, sync, async and capture_immediate: the hook sees context and global values, its change beats them, and it can remove a super property.
  • Nested fill, sync and async: an event $set and $groups merge with super $set / $groups key by key, groups= wins over the event's $groups key by key, and a list $unset is not merged. An MCP identity's groups win over a custom $groups the same way.
  • SDK values, sync and async: an event, context or super $is_server: False, $geoip_disable: False and $os beat the SDK's values. Super values beat them on every capture path. The hook sees $is_server, $geoip_disable and $os, and can remove $is_server.
  • set() / set_once() vs a super $set / $set_once: the call wins key by key. The _to_v1_event unit test for the collision is flipped.
  • Late options replace and remove legacy properties: a context option beats an event's legacy property, and a super option beats a legacy super property. Both legacy properties are gone from the wire event.
  • Layer matrix, sync and async:
    • a None event option is filled by a super option
    • personless alone
    • identified events send no option
    • super beats personless
    • context beats super
    • event beats context
    • event beats every layer
    • layers merge by key
  • Every path: super_options on every sync method; context options on capture, set and set_once.
  • Properties, sync and async: event and context properties beat super_properties. The old test that pinned "super overrides $session_id" is inverted.
  • Personless vs legacy: the personless option beats a legacy $process_person_profile: true. A legacy super property still fills the option when nothing else sets it.
  • Context inheritance: inherit, override, fresh=True isolation, parent unchanged.
  • Module: setup() passes super_options to the client.
  • test_release_id and the minimal $feature_flag_called tests read sent events at upload.
  • Break-on-purpose: restoring the forced $is_server or $geoip_disable (sync and async), the system-context overwrite (sync and async), putting the SDK values above super_properties, and the groups= overwrite (sync, async and MCP) each fail the new tests. Moving the sync or async fill back after the hook, turning off the nested fill, and restoring the old relocation each fail the intended tests. Earlier, 12 reversions, each failed by the intended test:
    • sync and async layer order (super above event, personless above super)
    • context options dropped on capture, set / set_once and async capture
    • super_properties winning again, sync and async
    • no personless option, sync and async
    • context options ignoring the parent
    • setup() dropping super_options
  • I ran ruff, mypy (baseline), the strict type smoke, the full pytest suite, the adapter tests, python -W error -c "import posthog", the public API snapshot and uv lock --check.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran sampo add to generate a changeset file

Covered by the existing capture-v1-major changeset; the migration guide lands later in this stack.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Written with Cursor (Claude Opus) under the direction of the assignee.

Agreed before implementation:

  • the layer order above, with super_options and set_context_option / get_context_options as the names. The fill before before_send, the nested fill and the relocation fix were agreed for every v1 SDK.
  • context fills before global; the values the SDK adds ($is_server, $geoip_disable, system context, personless, $release_id) fill last, so every caller layer overrides them
  • a None option counts as unset; a property key blocks the fill whatever its value
  • set() / set_once() keep merging context tags into $set
  • personless is set only without a real distinct ID
  • an option beats its legacy property at every layer

Agent calls worth review:

  • MCP events now add the identity's groups after the custom properties, so the groups win key by key over a custom $groups. The other built-in MCP properties keep their order.
  • A legacy $process_person_profile in super_properties no longer opts a personless event in: the personless option is set, and an option beats its legacy property. Opting in takes super_options={"process_person_profile": True}.
  • AsyncPosthog has no set_context_option method; the module-level function works with it, as tag() does today.
  • Context options do not reach group_identify or alias, matching context tags.

@eli-r-ph eli-r-ph self-assigned this Oct 7, 2026
@github-actions

github-actions Bot commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

posthog-python Compliance Report

Date: 2026-10-10T23:40:31.991317+00:00
Duration: 246887ms

✅ All Tests Passed!

116/116 tests passed


Capture_V1 Tests

✅ 95/95 tests passed

View Details
Test Status Duration
Endpoint And Method.Targets V1 Endpoint ✅ 515ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 508ms
Required Headers.Has Authorization Bearer Header ✅ 509ms
Required Headers.Has Content Type Json ✅ 508ms
Required Headers.Has Posthog Sdk Info Format ✅ 509ms
Required Headers.Has Posthog Attempt Header ✅ 508ms
Required Headers.Has Posthog Request Id ✅ 509ms
Required Headers.Has Posthog Request Timestamp ✅ 508ms
Required Headers.Has User Agent ✅ 510ms
Body Format.Body Has Created At And Batch ✅ 508ms
Body Format.No Api Key In Body ✅ 508ms
Body Format.No Sent At In Body ✅ 509ms
Event Format.Event Has Required Root Fields ✅ 508ms
Event Format.Event Uuid Is Valid ✅ 509ms
Event Format.Event Timestamp Is Rfc3339 ✅ 509ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 512ms
Event Format.Distinct Id Is String ✅ 509ms
Event Format.Distinct Id At Root Not Properties ✅ 508ms
Event Format.Custom Properties Preserved ✅ 508ms
Event Format.Set Properties Preserved ✅ 509ms
Event Format.Set Once Properties Preserved ✅ 508ms
Event Format.Groups Properties Preserved ✅ 508ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 508ms
Event Format.Event Has Required Root Fields Batch ✅ 512ms
Event Format.Event Uuid Is Valid Batch ✅ 511ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 511ms
Event Format.Distinct Id Is String Batch ✅ 511ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 511ms
Event Format.Custom Properties Preserved Batch ✅ 510ms
Event Format.Set Properties Preserved Batch ✅ 511ms
Event Format.Set Once Properties Preserved Batch ✅ 511ms
Event Format.Groups Properties Preserved Batch ✅ 513ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 511ms
Batch Behavior.Multiple Events In Single Batch ✅ 514ms
Batch Behavior.Batch Envelope Smoke ✅ 512ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 506ms
Batch Behavior.Flush At Triggers Batch ✅ 1009ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 509ms
Deduplication.Generates Unique Uuids ✅ 514ms
Deduplication.Different Events Same Content Different Uuids ✅ 510ms
Deduplication.Preserves Uuid On Retry ✅ 6517ms
Deduplication.Preserves Timestamp On Retry ✅ 6518ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6520ms
Deduplication.No Duplicate Events In Batch ✅ 515ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 509ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 12521ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6514ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3018ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6514ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 509ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 512ms
Response Format Validation.Success No Retry After When All Ok ✅ 510ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1513ms
Response Format Validation.Success No Retry After When Drop Only ✅ 511ms
Response Format Validation.Response Echoes Request Id ✅ 509ms
Retry Behavior.Retries On 408 ✅ 6514ms
Retry Behavior.Retries On 500 ✅ 6517ms
Retry Behavior.Retries On 503 ✅ 7520ms
Retry Behavior.Retries On 504 ✅ 6518ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3514ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11521ms
Retry Behavior.Does Not Retry On 400 ✅ 2512ms
Retry Behavior.Does Not Retry On 401 ✅ 2512ms
Retry Behavior.Does Not Retry On 402 ✅ 2512ms
Retry Behavior.Does Not Retry On 413 ✅ 2511ms
Retry Behavior.Does Not Retry On 415 ✅ 2512ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2510ms
Retry Behavior.Implements Backoff ✅ 18534ms
Retry Behavior.Max Retries Respected ✅ 18534ms
Partial Batch Handling.Handles 200 Full Success ✅ 2512ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3516ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3511ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3513ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6519ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6519ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6517ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6519ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6519ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6518ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8518ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3512ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3515ms
Compression.Sends Gzip Content Encoding ✅ 509ms
Compression.No Content Encoding When Disabled ✅ 508ms
Compression.Compressed Body Is Decompressible ✅ 509ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2510ms
Event Options.Cookieless Mode Override ✅ 509ms
Event Options.Disable Skew Correction Override ✅ 509ms
Event Options.Process Person Profile Override ✅ 508ms
Event Options.Product Tour Id Override ✅ 509ms
Event Options.Unset Options Omitted ✅ 508ms
Event Options.Options Override In Batch ✅ 511ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 509ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 508ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 509ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 9ms
Request Payload.Flags Request Uses V2 Query Param ✅ 7ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 7ms
Request Payload.Flags Request Omits Authorization Header ✅ 7ms
Request Payload.Token In Flags Body Matches Init ✅ 7ms
Request Payload.Groups Round Trip ✅ 8ms
Request Payload.Groups Default To Empty Object ✅ 7ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 7ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 7ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 7ms
Request Lifecycle.No Flags Request On Init Alone ✅ 3ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 508ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 11ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 7ms
Retry Behavior.Retries Flags On 502 ✅ 311ms
Retry Behavior.Retries Flags On 504 ✅ 310ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 510ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 53ms
Versioned Boolean Matching.Matching Version 1 ✅ 52ms
Versioned Boolean Matching.Matching Version 2 ✅ 50ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 27ms

@eli-r-ph eli-r-ph mentioned this pull request Oct 7, 2026
3 of 20 tasks
@eli-r-ph
eli-r-ph force-pushed the v1-capture-options branch from 891b4c8 to a515200 Compare October 7, 2026 02:03
@eli-r-ph
eli-r-ph force-pushed the v1-capture-option-layers branch from 816a97e to 0f828c1 Compare October 7, 2026 02:03
@eli-r-ph
eli-r-ph force-pushed the v1-capture-options branch from a515200 to ea1d279 Compare October 7, 2026 17:43
@eli-r-ph
eli-r-ph force-pushed the v1-capture-option-layers branch from 0f828c1 to 4753ddb Compare October 7, 2026 17:43
@eli-r-ph

eli-r-ph commented Oct 7, 2026

Copy link
Copy Markdown
Contributor Author

@greptileai review

@eli-r-ph
eli-r-ph marked this pull request as ready for review October 7, 2026 20:43
@eli-r-ph
eli-r-ph requested a review from a team as a code owner October 7, 2026 20:44
@eli-r-ph
eli-r-ph force-pushed the v1-capture-options branch from ea1d279 to 9705c81 Compare October 8, 2026 16:25
@eli-r-ph
eli-r-ph force-pushed the v1-capture-option-layers branch from 4753ddb to 89a4dfb Compare October 8, 2026 16:25
Comment thread posthog/capture_event.py Outdated
@veria-ai

veria-ai Bot commented Oct 8, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@eli-r-ph
eli-r-ph force-pushed the v1-capture-option-layers branch from f595725 to 62ac4ab Compare October 8, 2026 23:40
@eli-r-ph
eli-r-ph force-pushed the v1-capture-options branch from 9705c81 to 965fbe6 Compare October 8, 2026 23:40
Context tags, context options, super_properties, super_options, the
derived personless option and the environment $release_id now fill only
keys an event leaves unset, after before_send runs. before_send sees
only the event's own values and SDK enrichment, and its changes beat
every default. A None option counts as unset and is filled. Legacy
properties still fill only unset options and are always removed.
Context tags, context options, super_properties, super_options, the
derived personless option and the environment $release_id now fill
before before_send runs, still only into keys the event leaves unset.
before_send sees every value and has the final say, including removing
a super property. $set, $set_once, $groups and $group_set fill one
level deep when both values are dicts. The set() and set_once() values
now win key by key over a $set or $set_once in properties, as ingestion
merges them. Hoisting still runs once, after the hook.
$is_server, $geoip_disable and system context now fill last, only keys the
event, context tags and super_properties left unset, before before_send. A
super property $geoip_disable: False now wins over disable_geoip=True.

The groups argument merges into a $groups property key by key and wins,
in Client, AsyncClient and posthog.mcp events.
@eli-r-ph
eli-r-ph force-pushed the v1-capture-option-layers branch from 62ac4ab to 4120a8b Compare October 10, 2026 23:35

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