diff --git a/migration/yaml-parity-v1/README.md b/migration/yaml-parity-v1/README.md index bfd041fb..77e30942 100644 --- a/migration/yaml-parity-v1/README.md +++ b/migration/yaml-parity-v1/README.md @@ -21,8 +21,16 @@ from the harness's step bindings, not a separate operation inventory. Capture API declarations are independent of client/server SDK type: -- `capture_ai_v0`: AI capture delivery, including the ordinary-capture no-reroute - case. That case accepts either legacy batch or analytics-v1 ordinary traffic. +- `capture_ai_v0`: AI capture delivery on the legacy `/i/v0/ai/batch/` route, including + the ordinary-capture no-reroute case. That case accepts either legacy batch or + analytics-v1 ordinary traffic. +- `capture_ai_v1`: AI capture delivery on the capture v1 `/i/v1/ai/events` route; its + no-reroute case expects analytics-v1 ordinary traffic. `capture-ai-v1-delivery.feature` + adds misroute, lane isolation, ceiling and batching cases under the same capability. +- `capture_ai_returns_uuid`: the AI method returns the admitted event's UUID + (posthog-python, posthog-node, posthog-ios, posthog-android; not posthog-go or + posthog-rs). `event_uuid_override`: the capture and AI methods accept a + caller-supplied `uuid` (server SDKs; not posthog-ios or posthog-android). - `capture_v1`: analytics-v1 delivery through ordinary capture. - `capture_v0`: legacy capture; `capture_v0_batch` additionally selects the batch format checks, while `capture_v0_event` selects the event format checks. diff --git a/migration/yaml-parity-v1/capture-ai-v1-delivery.feature b/migration/yaml-parity-v1/capture-ai-v1-delivery.feature new file mode 100644 index 00000000..57d1b7dd --- /dev/null +++ b/migration/yaml-parity-v1/capture-ai-v1-delivery.feature @@ -0,0 +1,53 @@ +@public @acceptance @capture-ai @api_capture_ai_v1 @requires:capture_ai_v1 +Feature: AI capture v1 delivery + Misroute reporting, lane isolation, the per-event ceiling and byte batching on the capture v1 AI lane. + The step phrases are the capture-ai spec's; the harness binds them with its capture v1 suite update. + + Background: + Given an isolated SDK with empty persistent storage + And the mock PostHog server is reset + + Scenario: A non-AI event on the AI method is sent and its misroute is reported + Given the SDK is initialized with token "phc_test_key" and flush threshold 1 + And the mock serves these ordered AI responses: + | status | body | + | 200 | {"results":{"*":{"result":"drop","details":"misrouted_event"}}} | + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"button_clicked"} + """ + And pending captures are flushed + Then exactly 1 capture request should have been received + And every capture request path should be one of: + | path | + | /i/v1/ai/events | + And the SDK should report the event "button_clicked" as dropped with details "misrouted_event" + + Scenario: A failing AI lane does not delay analytics delivery + Given the SDK is initialized with token "phc_test_key" and flush threshold 1 + And the mock delays every request to "/i/v1/ai/events" until released + When capture_ai is called with distinct id "test_user" and event "$ai_generation" + And capture is called with distinct id "test_user" and event "button_clicked" + And 2000 milliseconds elapse without a public SDK call + Then the analytics batch endpoint should have received "button_clicked" + When the delayed AI request is released and succeeds + And pending captures are flushed + Then the AI batch endpoint should have received "$ai_generation" + + Scenario: An event at the ceiling is sent and one over it is dropped with a content-free log + Given the SDK is initialized with token "phc_test_key" and flush threshold 2 + When capture_ai is called with event "$ai_generation" named "at-ceiling" whose properties serialize to exactly 8388608 bytes + And capture_ai is called with event "$ai_generation" named "over-ceiling" whose properties serialize to 9437184 bytes and contain the marker "SECRET-MARKER" + And pending captures are flushed + Then the AI batch endpoint should receive exactly 1 event + And the received event should be "at-ceiling" + And the SDK should log the event name and byte size of "over-ceiling" + And the log output should not contain "SECRET-MARKER" + + Scenario: AI batches close before the byte target and keep order + Given the SDK is initialized with token "phc_test_key" and flush threshold 10 + When capture_ai is called sequentially 3 times with an event named "gen-{index}" whose properties serialize to about 3 MiB + And pending captures are flushed + Then at least 2 capture request should have been received on the AI batch endpoint + And no received request body should exceed the SDK's byte target plus envelope overhead + And the events "gen-0", "gen-1" and "gen-2" should have been received in that order across the requests diff --git a/migration/yaml-parity-v1/capture-ai-v1.feature b/migration/yaml-parity-v1/capture-ai-v1.feature new file mode 100644 index 00000000..1ee594a5 --- /dev/null +++ b/migration/yaml-parity-v1/capture-ai-v1.feature @@ -0,0 +1,85 @@ +@public @acceptance @capture-ai @api_capture_ai_v1 @requires:capture_ai_v1 +Feature: AI capture v1 YAML parity + AI capture on capture v1 is declared independently of runtime and identity model. + + Background: + Given an isolated SDK with empty persistent storage + And the mock PostHog server is reset + And the SDK is initialized with token "phc_test_key" and flush threshold 1 + + Scenario: AI capture uses the capture v1 AI endpoint + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation","properties":{"$ai_model":"gpt"}} + """ + And pending captures are flushed + Then exactly 1 capture request should have been received + And every capture request path should be one of: + | path | + | /i/v1/ai/events | + And the first received event field "event" should equal "$ai_generation" + And the first request should authenticate with bearer token "phc_test_key" + + Scenario: AI capture avoids the legacy AI endpoint + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation"} + """ + And pending captures are flushed + Then exactly 1 capture request should have been received + And no capture request should use "/i/v0/ai/batch/" + And no capture request should use "/batch" + + Scenario: Ordinary capture does not reroute AI-named events + When capture is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation"} + """ + And pending captures are flushed + Then exactly 1 capture request should have been received + And every capture request path should be one of: + | path | + | /i/v1/analytics/events | + And no capture request should use "/i/v1/ai/events" + + @requires:capture_ai_returns_uuid + Scenario: AI capture returns the generated wire UUID + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation"} + """ + Then AI capture should return an admitted event UUID + When pending captures are flushed + Then the first received event UUID should be valid + And the AI capture return should equal the first received event UUID + + @requires:capture_ai_returns_uuid @requires:event_uuid_override + Scenario: AI capture preserves a supplied UUID in its return and on the wire + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation","uuid":"0198c0de-0000-7000-8000-000000000abc"} + """ + Then AI capture should return an admitted event UUID + And the AI capture return should equal "0198c0de-0000-7000-8000-000000000abc" + When pending captures are flushed + Then the first received event field "uuid" should equal "0198c0de-0000-7000-8000-000000000abc" + And the AI capture return should equal the first received event UUID + + Scenario: AI timestamps normalize the instant without rewriting property strings + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation","timestamp":"2025-01-02T08:34:05+05:30","properties":{"timestamp_like":"2025-01-02T08:34:05+05:30"}} + """ + And pending captures are flushed + Then every event in the first capture request should have UTC timestamp "2025-01-02T03:04:05Z" + And the first received event property "timestamp_like" should equal "2025-01-02T08:34:05+05:30" + + Scenario: AI capture sends options and strips the legacy option properties + When capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation","properties":{"$process_person_profile":false},"options":{"custom_flag":true}} + """ + And pending captures are flushed + Then the first received event option "custom_flag" should equal JSON true + And the first received event option "process_person_profile" should equal JSON false + And the first received event property "$process_person_profile" should be absent diff --git a/openspec/changes/archive/2026-10-25-add-capture-ai-v1/.openspec.yaml b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/.openspec.yaml new file mode 100644 index 00000000..7ccf7c36 --- /dev/null +++ b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-10-25 diff --git a/openspec/changes/archive/2026-10-25-add-capture-ai-v1/design.md b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/design.md new file mode 100644 index 00000000..fa44659f --- /dev/null +++ b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/design.md @@ -0,0 +1,50 @@ +## Context + +Capture's AI lane is a separate deployment that serves `POST /i/v1/ai/events` with the same request and response contract as `/i/v1/analytics/events` (`rust/capture/src/v1/analytics/router.rs` `ai_routes` in PostHog/posthog). It accepts larger bodies, applies an 8 MiB ceiling to each event's `properties`, drops a non-`$ai_` event with `misrouted_event`, and shares the AI byte budget and quota with the analytics endpoint. The analytics endpoint still accepts `$ai_*` events and forwards them to the AI topic; PostHog plans to stop that once every shipped SDK has an AI method. + +The four server SDKs implement one AI lane on their next-major branches: + +- posthog-python `v1`: `capture_ai` and `capture_ai_immediate`; `capture_ai_compression`, `capture_ai_max_queue_size`, `capture_ai_timeout`, `capture_ai_max_event_bytes` +- posthog-go `v1`: `EnqueueAI` / `EnqueueAIWithContext`; `CaptureAICompression`, `CaptureAIMaxQueueSize`, `CaptureAIBatchUploadTimeout` +- posthog-rs `v1`: `capture_ai`, `capture_ai_batch` and their immediate variants; `capture_ai_compression`, `capture_ai_max_queue_size` +- posthog-node capture v1 series: `captureAi` / `captureAiImmediate`; `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiRequestTimeoutMs` + +The current spec predates all four: it names only Python and Node, ties wrapper routing to `enable_full_ai_capture`, and lists `/i/v0/ai/batch/` as the transport. + +## Goals / Non-Goals + +**Goals:** +- State the capture v1 AI lane so the next SDK adds AI capture the same way. +- Fix the wrapper contract: the AI method is the only route for PostHog's wrappers, with no fallback to `capture`. +- Give the harness a `capture_ai_v1` capability and cases. + +**Non-Goals:** +- The analytics wire, headers, retries and layer order, which `add-capture-v1` covers and this change references. +- The OpenTelemetry AI bridge requirement, unchanged. +- The client-SDK persistence model beyond the "Client SDKs:" notes (durable queue, disk cap); posthog-ios 4.0, posthog-android 4.0 and posthog-flutter 6.0 are in scope through those notes. + +## Decisions + +**The AI method is the only route for wrappers, whatever the flag says.** On legacy capture the wrappers sent through `capture` unless `enable_full_ai_capture` was set, and the spec made that the contract. The SDK owners reversed it during the ports: an AI event can be megabytes, and the analytics endpoint will stop accepting `$ai_*` events once every SDK has an AI method, so a wrapper that still falls back to `capture` would start losing events on that day. The flag now governs truncation and media redaction only. Alternative rejected: keep the fallback for a client object that lacks the AI method. Only a user's stand-in or mock can lack it on a v1 client, and the fallback would route those events to the lane that is going away; the wrapper logs one error at construction and turns its own capture off instead. + +**Lane configuration is required; the per-event cap stays optional and lower-only.** Compression, queue capacity and request timeout are public on all four SDKs with the same defaults (none, 1000, 30 s), so they are a SHALL. Only Python exposes the per-event cap, and only to lower it, so that stays a MAY. Draft #118 also required rejecting invalid values at construction; posthog-go substitutes the default for a non-positive AI queue size, so this change asks for validation of the effective values and leaves the failure mode to the SDK. + +**Node's gzip default is recorded, not overruled.** posthog-node 5.x gzips every request by default and the series keeps that for both lanes; the other three default to no compression. The requirement states the default as the SDK's choice and names both. + +**Size is measured where the server measures it, or with headroom.** Capture measures `properties` only. posthog-rs and posthog-node measure `properties` exactly; posthog-python and posthog-go measure the whole event and add 64 KiB of headroom. The requirement fixes the observable rule (never refuse an event capture accepts; never send one it refuses) and allows either mechanism. + +**The harness feature mirrors the v0 one.** `capture-ai-v1.feature` reuses the registered step phrases of `capture-ai.feature` with the v1 path; its one new phrase, property absence, is bound in the harness with the capture v1 suite update. It is selected by `@requires:capture_ai_v1`; the v0 feature keeps `capture_ai_v0`. + +## Risks / Trade-offs + +- [The analytics endpoint's acceptance of `$ai_*` events ends on a date PostHog has not set] → the requirement says "temporary" and that SDKs never rely on it, as the public docs do. +- [The posthog-node series is unmerged] → every rule here is also in Python, Go or Rust; Node appears only in option names and the compression variation. +- [Draft #118 and this change both touch the Configuration section] → #118 is superseded; its author is the PostHog bot, so closing it is a maintainer call. + +## Migration Plan + +Spec-only. Archive on this branch after review. SDK adapters declare `capture_ai_v1` when they move to the v1 AI endpoint. + +## Open Questions + +- Resolved 2026-10-27: capture v1 SDKs retry `404` on the AI endpoint (a deployment without the AI lane) the same way as on analytics, per `retry-queue`. diff --git a/openspec/changes/archive/2026-10-25-add-capture-ai-v1/proposal.md b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/proposal.md new file mode 100644 index 00000000..c4dc7709 --- /dev/null +++ b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/proposal.md @@ -0,0 +1,30 @@ +## Why + +The `capture-ai` spec still describes the AI route as a Python and Node beta on `/i/v0/ai/batch/`, with routing of PostHog's own AI wrappers tied to the `enable_full_ai_capture` flag. Four server SDKs now ship AI capture on capture v1 (`/i/v1/ai/events`) in their next majors, posthog-go and posthog-rs among them, and the wrappers always use the AI method. The harness has no `capture_ai_v1` capability, so the AI parity cases cannot select a capture v1 SDK at all. + +## What Changes + +- Extend applicability to posthog-go v2 (`EnqueueAI`) and posthog-rs 1.0 (`capture_ai`), next to posthog-python 8.0 and posthog-node 6.0. +- Add the capture v1 AI lane: `POST /i/v1/ai/events`, the same body, headers and per-event results as analytics capture, `misrouted_event` for a non-AI event on that endpoint, and the analytics endpoint's temporary acceptance of `$ai_*` events. +- **BREAKING** for the wrapper contract: PostHog's AI wrapper libraries always send through the SDK's AI method; `enable_full_ai_capture` controls only truncation and media redaction. A wrapper given a client without the AI method turns its own capture off instead of falling back to `capture`. +- Add the AI lane's configuration surface (compression, queue capacity, request timeout, an optional lower-only per-event cap) with its defaults and the rule that analytics settings never reach the AI lane. This supersedes draft #118. +- Add the AI size rules: an 8 MiB ceiling on `properties`, a 5 MiB request target, size-aware batching that keeps order, and the oversize log line that names only the event and its size. +- Replace the transport section's v0 values with capture v1. +- Add `migration/yaml-parity-v1/capture-ai-v1.feature` under a new `capture_ai_v1` capability, mirroring the v0 cases on the v1 endpoint, and document the capability in the suite README. + +## Capabilities + +### New Capabilities + +_None._ + +### Modified Capabilities + +- `capture-ai`: capture v1 AI lane, wrapper routing, lane configuration, size rules, extended applicability. + +## Impact + +- posthog-python `v1`, posthog-go `v1`, posthog-rs `v1` and the posthog-node capture v1 series conform, with one recorded variation: posthog-node 6 defaults the AI lane's compression to gzip, the other three to none. +- Draft #118 covers the lane configuration; this change absorbs it and drops its "rejected at construction" scenario, which posthog-go does not satisfy (a non-positive AI queue size takes the default). +- SDKs without AI capture (Ruby, PHP, .NET, Elixir, Java, the client SDKs) are unaffected until they add it. +- The harness adapters for the four SDKs can declare `capture_ai_v1`; the existing `capture_ai_v0` feature stays for the legacy route. diff --git a/openspec/changes/archive/2026-10-25-add-capture-ai-v1/specs/capture-ai/spec.md b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/specs/capture-ai/spec.md new file mode 100644 index 00000000..7b2c8b08 --- /dev/null +++ b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/specs/capture-ai/spec.md @@ -0,0 +1,194 @@ +## Narrative alignment + +When syncing this delta into the canonical `capture-ai` spec: + +- In Purpose, replace "PostHog's own AI wrapper libraries route through it when the client opts in" with: PostHog's own AI wrapper libraries always send through it; users call it directly for manual AI capture. Drop the "public beta" status line and say the surface is the capture v1 AI lane. +- In Applicability, list the implementations: posthog-python 8.0 (`capture_ai`), posthog-node 6.0 (`captureAi`), posthog-go v2 (`EnqueueAI`), posthog-rs 1.0 (`capture_ai`). Keep the sentence that other server SDKs adopt the spec when they add AI support. +- In Public signatures, add the Go and Rust shapes: `client.EnqueueAI(msg)` / `EnqueueAIWithContext(ctx, msg)` accepting any message type; `client.capture_ai(event)`, `capture_ai_batch(events, historical_migration)`, and the `*_immediate` variants returning the per-event summary. +- In Configuration, rewrite the content-capture flag text so items 1 to 3 become: skip string truncation, and pass media through unredacted. Remove "route their events through the dedicated AI path", and add the AI lane delivery section from **AI lane configuration is scoped to the AI lane**. +- In Behavior step 1, replace "`$lib`/`$lib_version`" with "the header-derived library identity" and point at `capture`'s layer order. +- In Behavior step 4, keep at-least-once delivery, and add that on capture v1 only `retry` results are resent. +- Replace the Transport (non-normative) section with the capture v1 values: endpoint `/i/v1/ai/events`, request body up to 20 MiB compressed, an 8 MiB ceiling on `properties`, a 5 MiB batch target, per-event results, and the retry rules of `retry-queue`. + +## ADDED Requirements + +### Requirement: AI events travel on their own lane to the capture v1 AI endpoint + +An SDK that delivers events through capture v1 and supports AI capture SHALL send events captured through its AI method with `POST {host}/i/v1/ai/events`, using the request body, headers, per-event results and retry rules defined for analytics capture in `capture` and `retry-queue`. The AI method SHALL send the event as given, with the same event-building order as `capture`, and SHALL NOT reroute by event name: a non-`$ai_` event captured through the AI method is sent to the AI endpoint, where capture drops it with `details: misrouted_event`, and an `$ai_*` event captured through `capture` is sent to the analytics endpoint. The SDK MAY log at debug level when the AI method receives a non-`$ai_` name. + +The AI lane SHALL have its own queue, flush cycle, retry state and in-flight request, started lazily on the first AI event, so that a slow or failing AI lane never delays, drops or resends analytics events, and the reverse. `flush()` and `shutdown()` SHALL signal both lanes before waiting on either, under one deadline. Client SDKs: the AI queue is durable (on disk), starts at launch so events left from an earlier launch are sent, and `flush()` and `close()` signal both lanes without blocking. + +Every platform of an SDK that delivers events through capture v1 SHALL ship this lane and the AI method; a wrapper SDK SHALL NOT leave a capture v1 platform without them. On a platform still on legacy capture (posthog-flutter on the web, which sends through posthog-js), the AI method SHALL send nothing, log that AI capture is unsupported there, and return the SDK's not-admitted value, and SHALL NOT fall back to `capture`. + +Capture currently accepts `$ai_*` events on the analytics endpoint and forwards them to the AI pipeline. That acceptance is temporary; the SDK and its built-in integrations SHALL NOT rely on it. + +Legacy capture: the AI route was `/i/v0/ai/batch/`, which rejected a whole batch containing a non-AI event. + +#### Scenario: AI capture uses the capture v1 AI endpoint (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **WHEN** capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation","properties":{"$ai_model":"gpt"}} + """ +- **AND** pending captures are flushed +- **THEN** exactly 1 capture request should have been received +- **AND** every capture request path should be one of: + | path | + | /i/v1/ai/events | +- **AND** the first received event field "event" should equal "$ai_generation" +- **AND** the first request should authenticate with bearer token "phc_test_key" + +#### Scenario: Ordinary capture does not reroute AI-named events (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **WHEN** capture is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation"} + """ +- **AND** pending captures are flushed +- **THEN** exactly 1 capture request should have been received +- **AND** every capture request path should be one of: + | path | + | /i/v1/analytics/events | +- **AND** no capture request should use "/i/v1/ai/events" + +#### Scenario: A non-AI event on the AI method is sent and its misroute is reported (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **AND** the mock serves these ordered AI responses: + | status | body | + | 200 | {"results":{"*":{"result":"drop","details":"misrouted_event"}}} | +- **WHEN** capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"button_clicked"} + """ +- **AND** pending captures are flushed +- **THEN** exactly 1 capture request should have been received +- **AND** every capture request path should be one of: + | path | + | /i/v1/ai/events | +- **AND** the SDK should report the event "button_clicked" as dropped with details "misrouted_event" + +#### Scenario: A failing AI lane does not delay analytics delivery (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **AND** the mock delays every request to "/i/v1/ai/events" until released +- **WHEN** capture_ai is called with distinct id "test_user" and event "$ai_generation" +- **AND** capture is called with distinct id "test_user" and event "button_clicked" +- **AND** 2000 milliseconds elapse without a public SDK call +- **THEN** the analytics batch endpoint should have received "button_clicked" +- **WHEN** the delayed AI request is released and succeeds +- **AND** pending captures are flushed +- **THEN** the AI batch endpoint should have received "$ai_generation" + +### Requirement: Built-in AI integrations always use the AI method + +PostHog's own AI wrapper libraries and instrumentation that emit `$ai_*` events through the SDK (provider wrappers, the OpenTelemetry AI bridge where it captures events, MCP add-ons) SHALL send them through the client's AI method on every capture v1 SDK, regardless of `enable_full_ai_capture` or any other setting. They SHALL set `process_person_profile: false` as an option when they substitute a trace or session id for a missing user id, and SHALL never write a legacy option property. A wrapper given a client object that lacks the AI method (a stand-in or mock) SHALL log one error when it is constructed and turn its own AI capture off; it SHALL NOT fall back to `capture` and SHALL NOT raise into the host application. + +Legacy capture: wrappers sent through `capture` unless `enable_full_ai_capture` was set. + +#### Scenario: A wrapper event rides the AI lane with the flag unset (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **AND** a PostHog AI wrapper is attached to the client with the full-capture flag unset +- **WHEN** the wrapper records one generation +- **AND** pending captures are flushed +- **THEN** the AI batch endpoint should receive "$ai_generation" +- **AND** the analytics batch endpoint should receive no events + +#### Scenario: A client without the AI method disables the wrapper's capture without raising (@server) +- **GIVEN** a client object that exposes capture but not the AI method +- **WHEN** a PostHog AI wrapper is constructed around it +- **AND** the wrapper records one generation +- **THEN** the construction and the recording should not throw +- **AND** the wrapper should log one error about the missing AI method +- **AND** no event should be sent through capture + +### Requirement: AI lane configuration is scoped to the AI lane + +An SDK that delivers events through capture v1 and supports AI capture SHALL expose the AI lane's compression mode, queue capacity and request timeout as public options named after the lane (for example `capture_ai_compression`, `capture_ai_max_queue_size` and `capture_ai_timeout` in posthog-python; `CaptureAICompression`, `CaptureAIMaxQueueSize` and `CaptureAIBatchUploadTimeout` in posthog-go; `capture_ai_compression` and `capture_ai_max_queue_size` in posthog-rs, whose request timeout is shared; `captureAiCompression`, `captureAiMaxQueueSize` and `captureAiRequestTimeoutMs` in posthog-node). The defaults SHALL be a queue capacity of 1000 events and a request timeout of 30 seconds on server SDKs. Client SDKs: posthog-ios 4.0 and posthog-android 4.0 default to 100 events and a 60 second timeout, with a fixed 50 MiB disk cap that drops the oldest events. The default compression is the SDK's choice and SHALL be documented: posthog-python, posthog-go and posthog-rs default to none; posthog-node, posthog-ios and posthog-android to gzip. The SDK SHALL offer only the codecs its runtime provides and SHALL validate the effective lane values after defaults are applied. + +The analytics lane's compression and queue capacity, including environment variables that set analytics compression, SHALL NOT change AI uploads, and the reverse. Batching settings such as the flush threshold, flush interval and batch size, and the request timeout where the SDK has one shared value, MAY be shared between the lanes. The 5 MiB batch target SHALL NOT be configurable. The per-event size cap MAY be exposed (`capture_ai_max_event_bytes` in posthog-python) and then only lowered: a configured value above the SDK's ceiling SHALL NOT take effect, and the SDK SHALL either refuse it (posthog-python raises) or clamp it to the ceiling with a log (posthog-ios and posthog-android). + +#### Scenario: Analytics compression does not reach the AI lane (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key", flush threshold 1, and analytics compression "gzip" +- **AND** no AI lane compression is configured +- **WHEN** capture is called with distinct id "test_user" and event "button_clicked" +- **AND** capture_ai is called with distinct id "test_user" and event "$ai_generation" +- **AND** pending captures are flushed +- **THEN** the request received on the analytics batch endpoint should declare "Content-Encoding" "gzip" +- **AND** the request received on the AI batch endpoint should carry the SDK's documented AI compression default + +#### Scenario: The per-event cap can be lowered but not raised (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK exposes a per-event size cap for the AI lane +- **WHEN** the SDK is initialized with a per-event cap below the SDK's ceiling +- **AND** capture_ai is called with an event whose properties exceed the configured cap but not the ceiling +- **AND** pending captures are flushed +- **THEN** no event should reach the AI batch endpoint +- **AND** the SDK should report the event as dropped locally +- **WHEN** the SDK is initialized with a per-event cap above the SDK's ceiling +- **THEN** the SDK should not use a cap above its ceiling + +### Requirement: AI event size and batching on capture v1 + +An SDK that delivers events through capture v1 and supports AI capture SHALL NOT send an AI event it measures over the 8 MiB ceiling on `properties`, and SHALL NOT refuse one at or under that size. The SDK SHALL either measure `properties` exactly or measure the whole serialized event against the ceiling plus a fixed envelope headroom (64 KiB in posthog-python and posthog-go); an event the whole-event measure admits but capture rejects comes back as `details: ai_event_too_big` and is surfaced like any other drop. An event over the SDK's measure SHALL be dropped on every send path, including immediate and synchronous ones, and reported through the SDK's error surface or a log line that names the event and its byte size and never its content. + +The AI lane SHALL batch by bytes with a 5 MiB target, checked before appending: when the batch is non-empty and the next event would exceed the target, the batch is sent first and the event starts the next one, so order is kept and an event over the target ships alone. + +Capture drops an AI event over its own ceiling with `details: ai_event_too_big` and one over the project's AI byte budget with `details: ai_byte_rate_limited`; the SDK SHALL surface both as opaque `details` strings like any other drop. + +#### Scenario: An event at the ceiling is sent and one over it is dropped with a content-free log (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 2 +- **WHEN** capture_ai is called with event "$ai_generation" named "at-ceiling" whose properties serialize to exactly 8388608 bytes +- **AND** capture_ai is called with event "$ai_generation" named "over-ceiling" whose properties serialize to 9437184 bytes and contain the marker "SECRET-MARKER" +- **AND** pending captures are flushed +- **THEN** the AI batch endpoint should receive exactly 1 event +- **AND** the received event should be "at-ceiling" +- **AND** the SDK should log the event name and byte size of "over-ceiling" +- **AND** the log output should not contain "SECRET-MARKER" + +#### Scenario: AI batches close before the byte target and keep order (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 10 +- **WHEN** capture_ai is called sequentially 3 times with an event named "gen-{index}" whose properties serialize to about 3 MiB +- **AND** pending captures are flushed +- **THEN** at least 2 capture request should have been received on the AI batch endpoint +- **AND** no received request body should exceed the SDK's byte target plus envelope overhead +- **AND** the events "gen-0", "gen-1" and "gen-2" should have been received in that order across the requests + +## MODIFIED Requirements + +### Requirement: Full AI capture flag + +The SDK SHALL expose a single boolean option (default false), named +`enable_full_ai_capture` adapted to platform convention, that governs what the +SDK's AI wrapper libraries capture. When true, the wrappers skip string truncation and pass media through unredacted; when false, they truncate long strings and replace media with a type-aware placeholder. The flag SHALL NOT change which route the wrappers use: on a capture v1 SDK they always send through the AI method (see **Built-in AI integrations always use the AI method**). It SHALL NOT alter payloads passed to +`capture_ai` directly. + +#### Scenario: wrapper media is redacted by default and passed through when enabled (@server) +- **GIVEN** an AI wrapper capturing a payload containing base64 media +- **WHEN** the client has the flag unset +- **THEN** the captured content should replace the media with a type-aware placeholder +- **AND** the event should be sent through the AI method +- **WHEN** the client has the flag set to true +- **THEN** the captured content should contain the media unmodified +- **AND** no string truncation should be applied to the captured content +- **AND** the event should be sent through the AI method + +#### Scenario: privacy mode wins over the flag (@server) +- **GIVEN** an AI wrapper capturing a payload containing base64 media +- **AND** the client has privacy mode enabled and the flag set to true +- **THEN** the captured event should carry no input or output content diff --git a/openspec/changes/archive/2026-10-25-add-capture-ai-v1/tasks.md b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/tasks.md new file mode 100644 index 00000000..35fd99d2 --- /dev/null +++ b/openspec/changes/archive/2026-10-25-add-capture-ai-v1/tasks.md @@ -0,0 +1,20 @@ +## 1. Source confirmation + +- [x] 1.1 Confirm the AI lane on PostHog/posthog `master` (`rust/capture/src/v1/analytics/router.rs` `ai_routes`, `v1/analytics/constants.rs` `DETAIL_MISROUTED_EVENT` and `DETAIL_AI_EVENT_TOO_BIG`, `v1/analytics/process.rs` `apply_ai_event_size_limit`) and the production ceiling of 8 MiB on `properties`. +- [x] 1.2 Confirm the AI method, lane isolation, configuration and size handling on posthog-python `v1` (`client.py` `capture_ai`, `consumer.py` `AI_MAX_MSG_SIZE`, `BATCH_SIZE_LIMIT`), posthog-go `v1` (`posthog.go` `EnqueueAI`, `capture_lane.go` `aiLaneConfig`, `aiMaxEventBytes`), posthog-rs `v1` (`async_client.rs` `capture_ai*`, `transport.rs` `AI_MAX_EVENT_BYTES`, `AI_BATCH_BYTES_TARGET`) and the posthog-node series (`client.ts` `captureAi`, `capture-lane/limits.ts`, `capture-lane/settings.ts`). +- [x] 1.3 Confirm the wrapper rule in posthog-python `posthog/ai/*` (always the AI method, disabled wrapper when the client lacks it) and the posthog-node `@posthog/ai` capability check. + +## 2. Spec delta and harness + +- [x] 2.1 Draft the `capture-ai` delta: the capture v1 AI lane, wrapper routing, lane configuration, size and batching; modify the full-capture flag requirement. +- [x] 2.2 Add `migration/yaml-parity-v1/capture-ai-v1.feature` under `@requires:capture_ai_v1`, mirroring the v0 cases on `/i/v1/ai/events` with the registered step phrases. +- [x] 2.3 Document `capture_ai_v1` in `migration/yaml-parity-v1/README.md`. + +## 3. Validation and archive + +- [x] 3.1 Run `openspec validate add-capture-ai-v1 --type change --strict`. +- [x] 3.2 Archive the change on this branch (`openspec archive add-capture-ai-v1 --yes`), then apply the "Narrative alignment" notes to the canonical spec by hand. +- [x] 3.3 Run `openspec validate --specs --strict --no-interactive` and compare the totals with `main` (67 passed, 0 failed on 2026-10-25). +- [x] 3.4 Close or rebase draft #118, which this change supersedes (closed 2026-10-27). + +Validation note: the harness feature reuses phrases registered for `capture-ai.feature`, except the property-absence step, which the harness capture v1 suite update binds; no adapter declares `capture_ai_v1` yet, so the cases are discoverable but unselected until an SDK adapter adds the capability. diff --git a/openspec/specs/capture-ai/spec.md b/openspec/specs/capture-ai/spec.md index 581818e5..1501dfbd 100644 --- a/openspec/specs/capture-ai/spec.md +++ b/openspec/specs/capture-ai/spec.md @@ -7,22 +7,28 @@ exactly the same arguments as the host SDK's `capture` but delivers the event on an isolated route to PostHog's dedicated AI ingestion endpoint, which accepts much larger payloads than analytics capture. PostHog's own AI wrapper -libraries route through it when the client opts in; users call it directly for -manual AI capture. +libraries always send through it; users call it directly for manual AI +capture. `capture_ai` is a pipe: it sends the payload as given. Redaction, truncation, and media handling are wrapper concerns governed by configuration — never applied to manual `capture_ai` payloads. -Status: public beta. The surface described here is frozen; guarantees may -strengthen, never weaken, before GA. +On capture v1 the AI route is `POST /i/v1/ai/events`, the AI lane of the same +API that serves `/i/v1/analytics/events`; see `capture` for the shared wire +contract, headers, per-event results and event-building order. ## Applicability -`server` — currently implemented by posthog-python and posthog-node. Other -server SDKs (Rust, Go, Ruby, ...) adopt this spec if and when they add AI -support; until then their AI events ride plain `capture` under its standard -limits. The **AI instrumentation attributes events to the active context** +`server`, and client SDKs on capture v1 that ship an AI method — implemented +by posthog-python 8.0 (`capture_ai`), posthog-node 6.0 (`captureAi`), posthog-go +v2 (`EnqueueAI`), posthog-rs 1.0 (`capture_ai`), posthog-ios 4.0 and +posthog-android 4.0 (`captureAi`), and posthog-flutter 6.0 (`captureAi`: native +on iOS, macOS and Android, its own Dart lane on Windows and Linux), all on +capture v1. Where a client SDK differs, the requirement says so in a +"Client SDKs:" note. Other server SDKs (Ruby, PHP, .NET, ...) adopt this spec if +and when they add AI support; until then their AI events ride plain `capture` +under its standard limits. The **AI instrumentation attributes events to the active context** requirement binds any SDK shipping the OpenTelemetry AI bridge — today posthog-js (`@posthog/ai`), posthog-python, and posthog-go (`otel`) — whether or not that SDK exposes `capture_ai`. @@ -50,6 +56,29 @@ Mirrors the host SDK's `capture`/`captureImmediate` pair. Returns the event UUID, or `undefined` when the client is disabled. The immediate variant resolves only after the network send completes (the serverless use case). +### posthog-go + +```go +func (c Client) EnqueueAI(msg Message) error +func (c *client) EnqueueAIWithContext(ctx context.Context, msg Message) error +``` + +Accepts any message type, like `Enqueue`. Returns an error only for a local +problem (closed or disabled client, validation); delivery results arrive +through `Config.Callback`. + +### posthog-rs + +```rust +fn capture_ai(&self, event: Event) +fn capture_ai_batch(&self, events: Vec, historical_migration: bool) +async fn capture_ai_immediate(&self, event: Event) -> Result +async fn capture_ai_batch_immediate(&self, events: Vec, historical_migration: bool) -> Result +``` + +The blocking client has the same names, synchronous. The queued methods are +fire-and-forget; the immediate variants return the per-event summary. + ### Surface-parity rule `capture_ai` mirrors the host SDK's public `capture` surface: the same @@ -64,20 +93,43 @@ Python — constructor kwarg and module attribute; `enableFullAiCapture` in Node — client option), default false. When true, PostHog's AI wrapper libraries: -1. route their events through the dedicated AI path instead of analytics - `capture`, -2. skip string truncation (a cut through base64 corrupts media; large text +1. skip string truncation (a cut through base64 corrupts media; large text contexts stay intact), and -3. pass media (raw bytes, base64, data URIs) through unredacted instead of +2. pass media (raw bytes, base64, data URIs) through unredacted instead of replacing it with type-aware placeholders. +The flag does not choose the route: on capture v1 the wrappers always send +through the AI method (see **Built-in AI integrations always use the AI +method**). On legacy capture they sent through analytics `capture` unless the +flag was set. + Privacy mode always takes precedence: with it on, content properties are stripped regardless of this flag. +### AI lane delivery + +The AI lane has its own delivery configuration, separate from the analytics +lane's: compression, queue capacity and request timeout, named after the lane +(`capture_ai_compression`, `capture_ai_max_queue_size`, `capture_ai_timeout` +in posthog-python; `CaptureAICompression`, `CaptureAIMaxQueueSize`, +`CaptureAIBatchUploadTimeout` in posthog-go; `capture_ai_compression`, +`capture_ai_max_queue_size` in posthog-rs, whose request timeout is shared; +`captureAiCompression`, `captureAiMaxQueueSize`, `captureAiRequestTimeoutMs` +in posthog-node). Server SDK defaults are a 1000-event queue and a 30 second timeout, +longer than analytics because AI payloads are much larger. The compression +default is the SDK's: none in posthog-python, posthog-go and posthog-rs, gzip +in posthog-node. The analytics lane's compression and queue capacity never +reach the AI lane; batching settings may be shared. The per-event cap is +optionally exposed (`capture_ai_max_event_bytes` in posthog-python) and can +only be lowered; the batch target is an internal constant. See **AI +lane configuration is scoped to the AI lane**. + ## Behavior 1. **Admission.** Same enrichment pipeline as `capture` (distinct id, - timestamp, `$lib`/`$lib_version`, `before_send`), differing only in the + timestamp, the layer order, `before_send`, hoisting of the legacy option + properties; on capture v1 the library identity comes from the + `PostHog-Sdk-Info` header, not from properties), differing only in the delivery route. 2. **UUID.** The SDK attaches a client-generated UUID to every event on the AI path when the caller supplies no UUID, and returns the attached UUID to @@ -85,8 +137,9 @@ stripped regardless of this flag. (replace or reject). 3. **Isolation.** AI events ride their own queue/buffer with independent size caps, batching, and retry policy; they never share a send cycle with - analytics events. The queue starts lazily — clients that never capture AI - events pay no overhead. + analytics events. On server SDKs the queue starts lazily, so clients that + never capture AI events pay no overhead; client SDKs start their durable + queue at launch. 4. **Delivery.** At-least-once delivery is permitted (retries may duplicate): a retry MUST resend the event with its entire original envelope unchanged — timestamp, distinct id, event name, and UUID, all stamped once @@ -94,14 +147,18 @@ stripped regardless of this flag. full envelope (`[timestamp, distinct_id, event, uuid]`), which is what makes at-least-once delivery safe: duplicates collapse to one stored event. At-most-once (drop after retry exhaustion) is equally compliant. - The invariant is the stable envelope, not the delivery count. + The invariant is the stable envelope, not the delivery count. On capture + v1 the server answers with a per-event verdict and the SDK resends only the + events marked `retry` (see `capture` and `retry-queue`). ## Error handling - `capture_ai` never throws under normal operation, matching `capture`. -- Events exceeding the operational per-event size cap are dropped in the - consumer, logging the event name and byte size only — never payload - content, which may be unredacted multimodal data. +- Events exceeding the operational per-event size cap are dropped locally, + logging the event name and byte size only — never payload content, which + may be unredacted multimodal data. An SDK that stops measuring once an + event passes the cap logs "over N bytes" instead of the full size. Client + SDKs MAY log this drop only in debug mode, like their other drop lines. - Delivery failures surface through the SDK's existing error hooks (`on_error` and equivalents), not through the return value. @@ -116,13 +173,16 @@ promise. Reference forms (e.g. `phaiblob://`) may join the schema additively. ## Transport (non-normative) -These are current operational values, explicitly NOT part of this contract, -and may change without notice: endpoint `/i/v0/ai/batch/` (v0 batch wire -shape), 8 MiB per-event cap, ~5 MiB batch target, compression per SDK default -(Node: transport gzip on unless disabled; Python: gzip opt-in), bounded -retries with backoff. Capture v1 re-implements the same contract on -`/i/v1/ai/...` with per-event outcomes; that cutover must not change anything -in the Requirements below. +These are current operational values and may change without notice. Capture +v1: endpoint `/i/v1/ai/events`; the request body limit is larger than the +analytics endpoint's (20 MiB compressed and 100 MiB decompressed today); +capture drops an event whose `properties` exceed 8 MiB +(`ai_event_too_big`) and an event over the project's AI byte budget +(`ai_byte_rate_limited`); SDKs target about 5 MiB per request; retries follow +`retry-queue`. Legacy capture: endpoint `/i/v0/ai/batch/` with the v0 batch +wire shape, where a non-AI event rejected the whole batch. The exposure of the +per-event cap (lower-only) and the lane configuration are normative (see the +Requirements); the cap's value and the batch target are not. ## Requirements ### Requirement: Canonical capture_ai behavior @@ -188,18 +248,21 @@ the scenarios below. ### Requirement: Full AI capture flag -The SDK SHALL expose a single boolean option (default false), named -`enable_full_ai_capture` adapted to platform convention, that governs what the -SDK's AI wrapper libraries capture. It SHALL NOT alter payloads passed to +An SDK that ships PostHog AI wrapper libraries SHALL expose a single boolean +option (default false), named `enable_full_ai_capture` adapted to platform +convention (today posthog-python and posthog-node), that governs what the +SDK's AI wrapper libraries capture. When true, the wrappers skip string truncation and pass media through unredacted; when false, they truncate long strings and replace media with a type-aware placeholder. The flag SHALL NOT change which route the wrappers use: on a capture v1 SDK they always send through the AI method (see **Built-in AI integrations always use the AI method**). It SHALL NOT alter payloads passed to `capture_ai` directly. #### Scenario: wrapper media is redacted by default and passed through when enabled (@server) - **GIVEN** an AI wrapper capturing a payload containing base64 media - **WHEN** the client has the flag unset - **THEN** the captured content should replace the media with a type-aware placeholder +- **AND** the event should be sent through the AI method - **WHEN** the client has the flag set to true - **THEN** the captured content should contain the media unmodified - **AND** no string truncation should be applied to the captured content +- **AND** the event should be sent through the AI method #### Scenario: privacy mode wins over the flag (@server) - **GIVEN** an AI wrapper capturing a payload containing base64 media @@ -294,3 +357,161 @@ Where it applies: - **GIVEN** an active context with properties that include a null value, an empty string, and a non-finite number - **WHEN** an AI span started inside that context ends - **THEN** the exported AI event should omit those keys rather than export empty or non-finite values + +### Requirement: AI events travel on their own lane to the capture v1 AI endpoint + +An SDK that delivers events through capture v1 and supports AI capture SHALL send events captured through its AI method with `POST {host}/i/v1/ai/events`, using the request body, headers, per-event results and retry rules defined for analytics capture in `capture` and `retry-queue`. The AI method SHALL send the event as given, with the same event-building order as `capture`, and SHALL NOT reroute by event name: a non-`$ai_` event captured through the AI method is sent to the AI endpoint, where capture drops it with `details: misrouted_event`, and an `$ai_*` event captured through `capture` is sent to the analytics endpoint. The SDK MAY log at debug level when the AI method receives a non-`$ai_` name. + +The AI lane SHALL have its own queue, flush cycle, retry state and in-flight request, started lazily on the first AI event, so that a slow or failing AI lane never delays, drops or resends analytics events, and the reverse. `flush()` and `shutdown()` SHALL signal both lanes before waiting on either, under one deadline. Client SDKs: the AI queue is durable (on disk), starts at launch so events left from an earlier launch are sent, and `flush()` and `close()` signal both lanes without blocking. + +Every platform of an SDK that delivers events through capture v1 SHALL ship this lane and the AI method; a wrapper SDK SHALL NOT leave a capture v1 platform without them. On a platform still on legacy capture (posthog-flutter on the web, which sends through posthog-js), the AI method SHALL send nothing, log that AI capture is unsupported there, and return the SDK's not-admitted value, and SHALL NOT fall back to `capture`. + +Capture currently accepts `$ai_*` events on the analytics endpoint and forwards them to the AI pipeline. That acceptance is temporary; the SDK and its built-in integrations SHALL NOT rely on it. + +Legacy capture: the AI route was `/i/v0/ai/batch/`, which rejected a whole batch containing a non-AI event. + +#### Scenario: AI capture uses the capture v1 AI endpoint (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **WHEN** capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation","properties":{"$ai_model":"gpt"}} + """ +- **AND** pending captures are flushed +- **THEN** exactly 1 capture request should have been received +- **AND** every capture request path should be one of: + | path | + | /i/v1/ai/events | +- **AND** the first received event field "event" should equal "$ai_generation" +- **AND** the first request should authenticate with bearer token "phc_test_key" + +#### Scenario: Ordinary capture does not reroute AI-named events (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **WHEN** capture is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"$ai_generation"} + """ +- **AND** pending captures are flushed +- **THEN** exactly 1 capture request should have been received +- **AND** every capture request path should be one of: + | path | + | /i/v1/analytics/events | +- **AND** no capture request should use "/i/v1/ai/events" + +#### Scenario: A non-AI event on the AI method is sent and its misroute is reported (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **AND** the mock serves these ordered AI responses: + | status | body | + | 200 | {"results":{"*":{"result":"drop","details":"misrouted_event"}}} | +- **WHEN** capture_ai is called with JSON arguments: + """application/json + {"distinct_id":"test_user","event":"button_clicked"} + """ +- **AND** pending captures are flushed +- **THEN** exactly 1 capture request should have been received +- **AND** every capture request path should be one of: + | path | + | /i/v1/ai/events | +- **AND** the SDK should report the event "button_clicked" as dropped with details "misrouted_event" + +#### Scenario: A failing AI lane does not delay analytics delivery (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **AND** the mock delays every request to "/i/v1/ai/events" until released +- **WHEN** capture_ai is called with distinct id "test_user" and event "$ai_generation" +- **AND** capture is called with distinct id "test_user" and event "button_clicked" +- **AND** 2000 milliseconds elapse without a public SDK call +- **THEN** the analytics batch endpoint should have received "button_clicked" +- **WHEN** the delayed AI request is released and succeeds +- **AND** pending captures are flushed +- **THEN** the AI batch endpoint should have received "$ai_generation" + +### Requirement: Built-in AI integrations always use the AI method + +PostHog's own AI wrapper libraries and instrumentation that emit `$ai_*` events through the SDK (provider wrappers, the OpenTelemetry AI bridge where it captures events, MCP add-ons) SHALL send them through the client's AI method on every capture v1 SDK, regardless of `enable_full_ai_capture` or any other setting. They SHALL set `process_person_profile: false` as an option when they substitute a trace or session id for a missing user id, and SHALL never write a legacy option property. A wrapper given a client object that lacks the AI method (a stand-in or mock) SHALL log one error when it is constructed and turn its own AI capture off; it SHALL NOT fall back to `capture` and SHALL NOT raise into the host application. + +Legacy capture: wrappers sent through `capture` unless `enable_full_ai_capture` was set. + +#### Scenario: A wrapper event rides the AI lane with the flag unset (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 1 +- **AND** a PostHog AI wrapper is attached to the client with the full-capture flag unset +- **WHEN** the wrapper records one generation +- **AND** pending captures are flushed +- **THEN** the AI batch endpoint should receive "$ai_generation" +- **AND** the analytics batch endpoint should receive no events + +#### Scenario: A client without the AI method disables the wrapper's capture without raising (@server) +- **GIVEN** a client object that exposes capture but not the AI method +- **WHEN** a PostHog AI wrapper is constructed around it +- **AND** the wrapper records one generation +- **THEN** the construction and the recording should not throw +- **AND** the wrapper should log one error about the missing AI method +- **AND** no event should be sent through capture + +### Requirement: AI lane configuration is scoped to the AI lane + +An SDK that delivers events through capture v1 and supports AI capture SHALL expose the AI lane's compression mode, queue capacity and request timeout as public options named after the lane (for example `capture_ai_compression`, `capture_ai_max_queue_size` and `capture_ai_timeout` in posthog-python; `CaptureAICompression`, `CaptureAIMaxQueueSize` and `CaptureAIBatchUploadTimeout` in posthog-go; `capture_ai_compression` and `capture_ai_max_queue_size` in posthog-rs, whose request timeout is shared; `captureAiCompression`, `captureAiMaxQueueSize` and `captureAiRequestTimeoutMs` in posthog-node; `captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` in posthog-ios, posthog-android and the posthog-flutter `PostHogConfig`). The defaults SHALL be a queue capacity of 1000 events and a request timeout of 30 seconds on server SDKs. Client SDKs: posthog-ios 4.0, posthog-android 4.0 and the posthog-flutter 6.0 Windows and Linux lane default to 100 events and a 60 second timeout, which is an idle read and write timeout, not a deadline for the whole request, with a fixed 50 MiB disk cap that drops the oldest events. The default compression is the SDK's choice and SHALL be documented: posthog-python, posthog-go and posthog-rs default to none; posthog-node, posthog-ios, posthog-android and the posthog-flutter 6.0 Windows and Linux lane to gzip. The SDK SHALL offer only the codecs its runtime provides and SHALL validate the effective lane values after defaults are applied. + +The analytics lane's compression and queue capacity, including environment variables that set analytics compression, SHALL NOT change AI uploads, and the reverse. Batching settings such as the flush threshold, flush interval and batch size, and the request timeout where the SDK has one shared value, MAY be shared between the lanes. The 5 MiB batch target SHALL NOT be configurable. The per-event size cap MAY be exposed (`capture_ai_max_event_bytes` in posthog-python) and then only lowered: a configured value above the SDK's ceiling SHALL NOT take effect, and the SDK SHALL either refuse it (posthog-python raises) or clamp it to the ceiling with a log (posthog-ios and posthog-android). The cap measures what the SDK's size check measures (see **AI event size and batching on capture v1**): where that is the whole serialized event, the cap's ceiling is the 8 MiB properties limit plus the SDK's envelope headroom (64 KiB in posthog-python, posthog-ios and posthog-android). + +#### Scenario: Analytics compression does not reach the AI lane (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key", flush threshold 1, and analytics compression "gzip" +- **AND** no AI lane compression is configured +- **WHEN** capture is called with distinct id "test_user" and event "button_clicked" +- **AND** capture_ai is called with distinct id "test_user" and event "$ai_generation" +- **AND** pending captures are flushed +- **THEN** the request received on the analytics batch endpoint should declare "Content-Encoding" "gzip" +- **AND** the request received on the AI batch endpoint should carry the SDK's documented AI compression default + +#### Scenario: The per-event cap can be lowered but not raised (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK exposes a per-event size cap for the AI lane +- **WHEN** the SDK is initialized with a per-event cap below the SDK's ceiling +- **AND** capture_ai is called with an event whose properties exceed the configured cap but not the ceiling +- **AND** pending captures are flushed +- **THEN** no event should reach the AI batch endpoint +- **AND** the SDK should report the event as dropped locally +- **WHEN** the SDK is initialized with a per-event cap above the SDK's ceiling +- **THEN** the SDK should not use a cap above its ceiling + +### Requirement: AI event size and batching on capture v1 + +An SDK that delivers events through capture v1 and supports AI capture SHALL NOT send an AI event it measures over the 8 MiB ceiling on `properties`. With the default cap and an exact `properties` measure, it SHALL NOT refuse one at or under that size; a lowered cap or the whole-event measure below MAY refuse a smaller event. The SDK SHALL either measure `properties` exactly or measure the whole serialized event against the ceiling plus a fixed envelope headroom (64 KiB in posthog-python and posthog-go); an event the whole-event measure admits but capture rejects comes back as `details: ai_event_too_big` and is surfaced like any other drop. An event over the SDK's measure SHALL be dropped on every send path, including immediate and synchronous ones, and reported through the SDK's error surface or a log line that names the event and its byte size (or "over N bytes" when the SDK stops measuring at the cap) and never its content. + +The AI lane SHALL batch by bytes with a 5 MiB target, checked before appending: when the batch is non-empty and the next event would exceed the target, the batch is sent first and the event starts the next one, so order is kept and an event over the target ships alone. + +Capture drops an AI event over its own ceiling with `details: ai_event_too_big` and one over the project's AI byte budget with `details: ai_byte_rate_limited`; the SDK SHALL surface both as opaque `details` strings like any other drop. + +#### Scenario: An event at the ceiling is sent and one over it is dropped with a content-free log (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 2 +- **WHEN** capture_ai is called with event "$ai_generation" named "at-ceiling" whose properties serialize to exactly 8388608 bytes +- **AND** capture_ai is called with event "$ai_generation" named "over-ceiling" whose properties serialize to 9437184 bytes and contain the marker "SECRET-MARKER" +- **AND** pending captures are flushed +- **THEN** the AI batch endpoint should receive exactly 1 event +- **AND** the received event should be "at-ceiling" +- **AND** the SDK should log the event name and byte size of "over-ceiling" +- **AND** the log output should not contain "SECRET-MARKER" + +#### Scenario: AI batches close before the byte target and keep order (@server) +- **GIVEN** an isolated SDK with empty persistent storage +- **AND** the mock PostHog server is reset +- **AND** the SDK is initialized with token "phc_test_key" and flush threshold 10 +- **WHEN** capture_ai is called sequentially 3 times with an event named "gen-{index}" whose properties serialize to about 3 MiB +- **AND** pending captures are flushed +- **THEN** at least 2 capture request should have been received on the AI batch endpoint +- **AND** no received request body should exceed the SDK's byte target plus envelope overhead +- **AND** the events "gen-0", "gen-1" and "gen-2" should have been received in that order across the requests +