diff --git a/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/.openspec.yaml b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/.openspec.yaml new file mode 100644 index 00000000..25002a8e --- /dev/null +++ b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-11-01 diff --git a/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/design.md b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/design.md new file mode 100644 index 00000000..67902916 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/design.md @@ -0,0 +1,18 @@ +## Context + +The hook is the last application-controlled interception point before delivery. Every SDK but one runs it synchronously inside capture, which makes the ordering question invisible: the event is handed over before the capture call returns, so no later call can overtake it. posthog-flutter accepts a hook that returns a `Future`, which opens a window between "the app captured the event" and "the SDK has the event" in which any other call can land first. + +## Decisions + +**Order over snapshot-and-restore.** The alternative fix is to capture the identity state at hook time and restore it when the event is handed over. Earlier revisions of PostHog/posthog-flutter#672 did that and review kept finding state it did not cover — the cookieless sentinel, consent revoked in another browser tab, the next user's registered properties and `$groups`. Ordered hand-over makes the whole class disappear, so the requirement is written as an ordering guarantee rather than a list of fields to preserve. + +**Getters wait too.** A getter that reads state the pending event has not yet been handed over under would report state the app cannot act on consistently. posthog-flutter holds getters behind the pending event, and the requirement follows it. + +**A bounded wait, and the event is still sent.** A hook that never completes would otherwise stall the SDK for the life of the process. The requirement therefore demands a bound and names posthog-flutter's 10 seconds as the shipped value rather than fixing one cross-SDK number, since only one SDK has the problem. Releasing the queue is not the same as abandoning the event: the event is handed over when the chain finishes, because dropping it would make a slow privacy filter lose data silently. + +**No deadlock.** A hook that calls back into the SDK is legitimate (renaming a `$screen` re-captures). Such a call must not wait for the turn it runs inside. + +## Open questions + +- The convergent answer is a synchronous hook (PostHog/posthog-flutter#686, proposed for 6). This change records the contract for as long as an asynchronous hook is accepted; it does not require SDKs to accept one, and it does not pre-judge whether the Flutter hook stays asynchronous. +- `acceptance/private/before-send-hook.feature` is untouched: the harness has no steps for call ordering or for an asynchronous hook. The scenarios live in `spec.md` only and have not been run against an adapter. diff --git a/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/proposal.md b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/proposal.md new file mode 100644 index 00000000..299bd583 --- /dev/null +++ b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/proposal.md @@ -0,0 +1,26 @@ +## Why + +`before-send-hook` states under **Concurrency & ordering guarantees** that hooks "run synchronously with event preparation for a single event" and that "the hook chain is isolated to that event; it does not batch or reorder events". That is true of every audited SDK except posthog-flutter, whose Dart-layer `beforeSend` may return a `Future`. The spec never said what an SDK owes callers when its hook can complete asynchronously, so the gap was invisible until it produced wrong data. + +In posthog-flutter 5.x, `capture()`, `screen()` and `captureException()` awaited the hook before handing the call to posthog-js or the native SDK. A `reset()`, `identify()`, `register()`, `group()`, `optIn()` or `optOut()` made right after an un-awaited capture therefore reached the SDK first, and the event went out under the *next* user's identity, registered properties, groups and consent. PostHog/posthog-flutter#672 fixed this by handing calls to the underlying SDK in the order the app made them, and its own follow-up notes that the spec is the thing out of date. + +## What Changes + +- Add a `before-send-hook` requirement covering SDKs whose hook may complete asynchronously: calls are handed to the underlying SDK in the order the app made them, a call made from inside a running hook does not wait on it, and a hook that exceeds a bounded wait stops holding later calls back without its event being dropped. +- Record that a synchronous hook chain satisfies the requirement with no queueing, and record posthog-flutter's platform coverage (web, iOS, Android, macOS; not the Dart-only Windows and Linux path) as the current divergence. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `before-send-hook`: specify ordering when a hook can complete asynchronously, and narrow the synchronous-execution prose to the SDKs it describes. + +## Impact + +- **posthog-flutter**: conforms on web, iOS, Android and macOS in 5.x (PostHog/posthog-flutter#672). Windows and Linux do not order calls; PostHog/posthog-flutter#686 proposes making `beforeSend` synchronous in 6, which removes the ordering problem on every platform. +- **posthog-js, posthog-ios, posthog-android, posthog-react-native, posthog-kmp, server SDKs**: synchronous hooks, already conformant, no work. +- No wire format or public API changes. diff --git a/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/specs/before-send-hook/spec.md b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/specs/before-send-hook/spec.md new file mode 100644 index 00000000..d33774fb --- /dev/null +++ b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/specs/before-send-hook/spec.md @@ -0,0 +1,54 @@ +## ADDED Requirements + +### Requirement: Ordered hand-over while a before-send hook can complete asynchronously + +In most SDKs the hook chain runs synchronously inside the capture call, so an event is handed to +delivery before the call returns and no later call can overtake it. Where an SDK accepts a hook +that may complete asynchronously (today only posthog-flutter's Dart-layer `beforeSend`, which may +return a `Future`), the SDK SHALL hand calls to the underlying SDK in the order the application +made them, so an event is sent with the distinct ID, registered properties, groups and consent in +force when it was captured. + +- While an event's hook chain is running, a later event-producing or state-changing call — + `capture`, `screen`, `captureException`, `identify`, `alias`, `reset`, `register`, `unregister`, + `group`, `optIn`, `optOut` — and a later state-reading getter SHALL NOT reach the underlying SDK + until that event has been handed over. +- A call made from inside a running hook SHALL NOT wait on the hook it runs inside, so a hook that + calls back into the SDK cannot deadlock. This includes a hook that re-captures, such as one that + renames a `$screen` event. +- The SDK SHALL bound how long one event holds later calls back (posthog-flutter: 10 seconds, + measured from the capture rather than per hook, so a chain of never-finishing hooks holds later + calls for that bound in total). When the bound expires the SDK SHALL release the later calls and + SHALL still hand the event over when its chain finishes: exceeding the bound is not a drop. +- An SDK whose hook chain is synchronous satisfies this requirement with no queueing of any kind. + +posthog-flutter applies ordered hand-over on web and, through the method channel, on iOS, Android +and macOS. Its Dart-only Windows and Linux path does not. Making the hook synchronous removes the +window this requirement describes, and is the convergent answer where an SDK can take it. + +#### Scenario: An event keeps the user it was captured under +- **GIVEN** the SDK accepts a before-send hook that completes asynchronously +- **AND** the SDK is initialized with token "test-token" and identified as "user-a" +- **AND** before-send is configured with a hook that completes asynchronously +- **WHEN** capture is called with event "Logged Out" without awaiting it +- **AND** reset is called immediately afterwards +- **THEN** the enqueued "Logged Out" event distinct ID should equal "user-a" +- **AND** the reset should take effect only after that event has been handed over + +#### Scenario: A hook that captures does not deadlock +- **GIVEN** the SDK accepts a before-send hook that completes asynchronously +- **AND** the SDK is initialized with token "test-token" +- **AND** before-send is configured with a hook that itself calls capture with event "From Hook" +- **WHEN** capture is called with event "Checkout Started" +- **THEN** one event named "Checkout Started" should be enqueued +- **AND** one event named "From Hook" should be enqueued + +#### Scenario: A hook past the bound releases later calls and still sends its event +- **GIVEN** the SDK accepts a before-send hook that completes asynchronously +- **AND** the SDK is initialized with token "test-token" +- **AND** before-send is configured with a hook that has not completed after the SDK's bound +- **WHEN** capture is called with event "Slow Event" without awaiting it +- **AND** capture is called with event "Later Event" +- **THEN** the "Later Event" event should be handed over once the bound has expired +- **AND** the "Slow Event" event should be handed over when its hook completes +- **AND** the "Slow Event" event should not be dropped diff --git a/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/tasks.md b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/tasks.md new file mode 100644 index 00000000..3ba4a95b --- /dev/null +++ b/openspec/changes/archive/2026-11-01-order-calls-around-async-before-send/tasks.md @@ -0,0 +1,9 @@ +## 1. Spec delta + +- [x] 1.1 Add the **Ordered hand-over while a before-send hook can complete asynchronously** requirement to `before-send-hook` +- [x] 1.2 Narrow the **Concurrency & ordering guarantees** prose so synchronous execution describes the SDKs it is true of + +## 2. Validation + +- [x] 2.1 Run `openspec validate --specs --strict` and resolve any errors +- [x] 2.2 Run `openspec archive` to sync the delta into `specs/before-send-hook/spec.md` and archive the change diff --git a/openspec/specs/before-send-hook/spec.md b/openspec/specs/before-send-hook/spec.md index 75bf5f58..b441bf19 100644 --- a/openspec/specs/before-send-hook/spec.md +++ b/openspec/specs/before-send-hook/spec.md @@ -68,9 +68,13 @@ Usually none directly. The hook returns a transformed event object that higher l ## Concurrency & ordering guarantees -- Hooks run synchronously with event preparation for a single event. +- Hooks run synchronously with event preparation for a single event, except in wrapper SDKs that + accept an asynchronous hook: Flutter's Dart-layer `beforeSend` may return a `Future`, and the + wrapper then hands calls to the underlying SDK in the order the app made them (see + **Ordered hand-over while a before-send hook can complete asynchronously**). - For a given event, hooks execute in deterministic registration order. -- The hook chain is isolated to that event; it does not batch or reorder events. +- The hook chain is isolated to that event; it does not batch or reorder events. Where the hook can + complete asynchronously, later calls wait behind the pending event rather than overtaking it. - If the hook mutates timestamps/uuids/event names, downstream queueing observes those modified values. ## Interactions @@ -122,3 +126,56 @@ If any before-send hook throws, the SDK SHALL catch the exception, record a warn - **AND** no network request should be sent - **AND** the final before-send hook should not be invoked - **AND** the SDK should record a before-send warning + +### Requirement: Ordered hand-over while a before-send hook can complete asynchronously + +In most SDKs the hook chain runs synchronously inside the capture call, so an event is handed to +delivery before the call returns and no later call can overtake it. Where an SDK accepts a hook +that may complete asynchronously (today only posthog-flutter's Dart-layer `beforeSend`, which may +return a `Future`), the SDK SHALL hand calls to the underlying SDK in the order the application +made them, so an event is sent with the distinct ID, registered properties, groups and consent in +force when it was captured. + +- While an event's hook chain is running, a later event-producing or state-changing call — + `capture`, `screen`, `captureException`, `identify`, `alias`, `reset`, `register`, `unregister`, + `group`, `optIn`, `optOut` — and a later state-reading getter SHALL NOT reach the underlying SDK + until that event has been handed over. +- A call made from inside a running hook SHALL NOT wait on the hook it runs inside, so a hook that + calls back into the SDK cannot deadlock. This includes a hook that re-captures, such as one that + renames a `$screen` event. +- The SDK SHALL bound how long one event holds later calls back (posthog-flutter: 10 seconds, + measured from the capture rather than per hook, so a chain of never-finishing hooks holds later + calls for that bound in total). When the bound expires the SDK SHALL release the later calls and + SHALL still hand the event over when its chain finishes: exceeding the bound is not a drop. +- An SDK whose hook chain is synchronous satisfies this requirement with no queueing of any kind. + +posthog-flutter applies ordered hand-over on web and, through the method channel, on iOS, Android +and macOS. Its Dart-only Windows and Linux path does not. Making the hook synchronous removes the +window this requirement describes, and is the convergent answer where an SDK can take it. + +#### Scenario: An event keeps the user it was captured under +- **GIVEN** the SDK accepts a before-send hook that completes asynchronously +- **AND** the SDK is initialized with token "test-token" and identified as "user-a" +- **AND** before-send is configured with a hook that completes asynchronously +- **WHEN** capture is called with event "Logged Out" without awaiting it +- **AND** reset is called immediately afterwards +- **THEN** the enqueued "Logged Out" event distinct ID should equal "user-a" +- **AND** the reset should take effect only after that event has been handed over + +#### Scenario: A hook that captures does not deadlock +- **GIVEN** the SDK accepts a before-send hook that completes asynchronously +- **AND** the SDK is initialized with token "test-token" +- **AND** before-send is configured with a hook that itself calls capture with event "From Hook" +- **WHEN** capture is called with event "Checkout Started" +- **THEN** one event named "Checkout Started" should be enqueued +- **AND** one event named "From Hook" should be enqueued + +#### Scenario: A hook past the bound releases later calls and still sends its event +- **GIVEN** the SDK accepts a before-send hook that completes asynchronously +- **AND** the SDK is initialized with token "test-token" +- **AND** before-send is configured with a hook that has not completed after the SDK's bound +- **WHEN** capture is called with event "Slow Event" without awaiting it +- **AND** capture is called with event "Later Event" +- **THEN** the "Later Event" event should be handed over once the bound has expired +- **AND** the "Slow Event" event should be handed over when its hook completes +- **AND** the "Slow Event" event should not be dropped