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-08T23:45:52.274487+00:00
Duration: 247116ms

✅ 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 ✅ 514ms
Endpoint And Method.Does Not Use Legacy Endpoints ✅ 511ms
Required Headers.Has Authorization Bearer Header ✅ 510ms
Required Headers.Has Content Type Json ✅ 511ms
Required Headers.Has Posthog Sdk Info Format ✅ 510ms
Required Headers.Has Posthog Attempt Header ✅ 511ms
Required Headers.Has Posthog Request Id ✅ 510ms
Required Headers.Has Posthog Request Timestamp ✅ 510ms
Required Headers.Has User Agent ✅ 512ms
Body Format.Body Has Created At And Batch ✅ 511ms
Body Format.No Api Key In Body ✅ 510ms
Body Format.No Sent At In Body ✅ 511ms
Event Format.Event Has Required Root Fields ✅ 510ms
Event Format.Event Uuid Is Valid ✅ 511ms
Event Format.Event Timestamp Is Rfc3339 ✅ 511ms
Event Format.Non Utc Event Timestamp Is Converted To Utc ✅ 515ms
Event Format.Distinct Id Is String ✅ 509ms
Event Format.Distinct Id At Root Not Properties ✅ 510ms
Event Format.Custom Properties Preserved ✅ 510ms
Event Format.Set Properties Preserved ✅ 509ms
Event Format.Set Once Properties Preserved ✅ 510ms
Event Format.Groups Properties Preserved ✅ 510ms
Event Format.Sdk Generates Uuid If Not Provided ✅ 509ms
Event Format.Event Has Required Root Fields Batch ✅ 513ms
Event Format.Event Uuid Is Valid Batch ✅ 511ms
Event Format.Event Timestamp Is Rfc3339 Batch ✅ 513ms
Event Format.Distinct Id Is String Batch ✅ 513ms
Event Format.Distinct Id At Root Not Properties Batch ✅ 513ms
Event Format.Custom Properties Preserved Batch ✅ 513ms
Event Format.Set Properties Preserved Batch ✅ 513ms
Event Format.Set Once Properties Preserved Batch ✅ 512ms
Event Format.Groups Properties Preserved Batch ✅ 514ms
Event Format.Sdk Generates Uuid If Not Provided Batch ✅ 514ms
Batch Behavior.Multiple Events In Single Batch ✅ 518ms
Batch Behavior.Batch Envelope Smoke ✅ 514ms
Batch Behavior.Flush With No Events Sends Nothing ✅ 507ms
Batch Behavior.Flush At Triggers Batch ✅ 1012ms
Batch Behavior.Created At Reflects Batch Creation Time ✅ 511ms
Deduplication.Generates Unique Uuids ✅ 518ms
Deduplication.Different Events Same Content Different Uuids ✅ 514ms
Deduplication.Preserves Uuid On Retry ✅ 6518ms
Deduplication.Preserves Timestamp On Retry ✅ 6517ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 6523ms
Deduplication.No Duplicate Events In Batch ✅ 520ms
Header Behavior On Retry.Attempt Header Starts At One ✅ 512ms
Header Behavior On Retry.Attempt Header Increments On Retry ✅ 12528ms
Header Behavior On Retry.Request Id Preserved On Retry ✅ 6517ms
Header Behavior On Retry.Different Requests Have Different Request Ids ✅ 3021ms
Header Behavior On Retry.Request Timestamp Changes On Retry ✅ 6520ms
Response Format Validation.Success Response Has Uuid Keyed Results ✅ 513ms
Response Format Validation.Success Response Has Ok For Each Event ✅ 514ms
Response Format Validation.Success No Retry After When All Ok ✅ 512ms
Response Format Validation.Success Retry After Present When Retry Events ✅ 1514ms
Response Format Validation.Success No Retry After When Drop Only ✅ 513ms
Response Format Validation.Response Echoes Request Id ✅ 511ms
Retry Behavior.Retries On 408 ✅ 6519ms
Retry Behavior.Retries On 500 ✅ 6521ms
Retry Behavior.Retries On 503 ✅ 7523ms
Retry Behavior.Retries On 504 ✅ 6522ms
Retry Behavior.Retryable Errors Have Retry After ✅ 3518ms
Retry Behavior.Respects Retry After On Retryable Error ✅ 11524ms
Retry Behavior.Does Not Retry On 400 ✅ 2515ms
Retry Behavior.Does Not Retry On 401 ✅ 2515ms
Retry Behavior.Does Not Retry On 402 ✅ 2516ms
Retry Behavior.Does Not Retry On 413 ✅ 2512ms
Retry Behavior.Does Not Retry On 415 ✅ 2516ms
Retry Behavior.Non Retryable Errors Have No Retry After ✅ 2514ms
Retry Behavior.Implements Backoff ✅ 18538ms
Retry Behavior.Max Retries Respected ✅ 18538ms
Partial Batch Handling.Handles 200 Full Success ✅ 2514ms
Partial Batch Handling.Handles 200 With All Ok ✅ 3517ms
Partial Batch Handling.Does Not Retry Dropped Events ✅ 3517ms
Partial Batch Handling.Does Not Retry Limited Events ✅ 3516ms
Partial Batch Handling.Prunes Ok Events On Partial Retry ✅ 6523ms
Partial Batch Handling.Prunes Dropped Events On Partial Retry ✅ 6522ms
Partial Batch Handling.Retries Only Retry Events From Partial ✅ 6523ms
Partial Batch Handling.Partial Retry Preserves Uuids ✅ 6520ms
Partial Batch Handling.Partial Retry Attempt Header Increments ✅ 6519ms
Partial Batch Handling.Partial Retry Request Id Preserved ✅ 6523ms
Partial Batch Handling.Respects Retry After On Partial ✅ 8523ms
Partial Batch Handling.Unknown Result Treated As Terminal ✅ 3515ms
Partial Batch Handling.Mixed Ok Drop Limited No Retry ✅ 3519ms
Compression.Sends Gzip Content Encoding ✅ 512ms
Compression.No Content Encoding When Disabled ✅ 511ms
Compression.Compressed Body Is Decompressible ✅ 511ms
Error Handling.Does Not Retry On Unknown 4Xx ✅ 2511ms
Event Options.Cookieless Mode Override ✅ 511ms
Event Options.Disable Skew Correction Override ✅ 511ms
Event Options.Process Person Profile Override ✅ 511ms
Event Options.Product Tour Id Override ✅ 510ms
Event Options.Unset Options Omitted ✅ 512ms
Event Options.Options Override In Batch ✅ 514ms
Geoip And Historical Migration.Geoip Disable Injected Into Properties ✅ 510ms
Geoip And Historical Migration.Historical Migration Set In Body ✅ 511ms
Geoip And Historical Migration.Historical Migration Absent By Default ✅ 512ms

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 12ms
Request Payload.Flags Request Uses V2 Query Param ✅ 9ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 8ms
Request Payload.Flags Request Omits Authorization Header ✅ 8ms
Request Payload.Token In Flags Body Matches Init ✅ 8ms
Request Payload.Groups Round Trip ✅ 7ms
Request Payload.Groups Default To Empty Object ✅ 8ms
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 ✅ 8ms
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 ✅ 15ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 9ms
Retry Behavior.Retries Flags On 502 ✅ 312ms
Retry Behavior.Retries Flags On 504 ✅ 314ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 512ms

Feature_Flags_Local_Evaluation Tests

✅ 4/4 tests passed

View Details
Test Status Duration
Versioned Boolean Matching.Matching Version Missing ✅ 60ms
Versioned Boolean Matching.Matching Version 1 ✅ 54ms
Versioned Boolean Matching.Matching Version 2 ✅ 53ms
Versioned Boolean Matching.Version Only Reload 1 2 1 2 Missing ✅ 28ms

@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

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 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

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