Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-11-01
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
61 changes: 59 additions & 2 deletions openspec/specs/before-send-hook/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Loading