Skip to content
Merged
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
16 changes: 16 additions & 0 deletions acceptance/public/capture-ai.feature
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,22 @@ Feature: Capture AI
And the call should return the enqueued event's uuid
And the enqueued event should include a timestamp and uuid

@server
Scenario: AI capture drops null-valued properties on its delivery route
Given the SDK is initialized with token "test-token"
And the SDK supports AI capture with no property-changing hooks or filters
When capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON:
"""json
{"test":null,"nested":{"drop":null},"items":["1",null,2]}
"""
And the SDK is flushed
Then one event named "$ai_generation" should be received on the AI batch endpoint
And its custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,2]}
"""
And the analytics batch endpoint should receive no events

@server
Scenario: capture and capture_ai ride separate routes
Given the SDK is initialized with token "test-token"
Expand Down
17 changes: 17 additions & 0 deletions acceptance/public/capture-exception.feature
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,23 @@ Feature: Capture Exception
| handled | true |
| area | checkout |

@both
Scenario: Exception capture drops null-valued custom properties on the wire
Given the SDK is initialized with token "test-token"
And capture has a valid distinct id and no property-changing hooks or filters
And a valid handled exception
When capture exception is called for the exception with additional custom properties represented by JSON:
"""json
{"test":null,"nested":{"drop":null},"items":["1",null,2]}
"""
And the SDK is flushed
Then one "$exception" event should be received
And its custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,2]}
"""
And the event should still include its SDK-generated exception data

@both
Scenario: Exception capture normalizes non-standard thrown values
Given the SDK is initialized with token "test-token"
Expand Down
131 changes: 131 additions & 0 deletions acceptance/public/capture.feature
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,137 @@ Feature: Capture
| $lib | any |
And the enqueued event should include an event uuid

@both
Scenario: Queued capture drops null-valued properties and preserves array positions
Given the SDK is initialized with token "test-token"
And capture has a valid distinct id and no property-changing hooks or filters
When capture is called with event "Nullable Properties" and custom properties represented by JSON:
"""json
{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}
"""
And the SDK is flushed
Then one event named "Nullable Properties" should be received
And its custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,2,{},[null]],"empty":"","zero":0,"enabled":false,"literal":"null","emptyArray":[]}
"""
And the absent custom property "missing" should remain absent

@both
Scenario: Immediate capture drops null-valued properties and preserves array positions
Given the SDK is initialized with token "test-token"
And the SDK supports immediate delivery
And capture has a valid distinct id and no property-changing hooks or filters
And capture is configured for immediate delivery
When capture is called with event "Nullable Properties" and custom properties represented by JSON:
"""json
{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null},[null]]}
"""
And the immediate send completes
Then one event named "Nullable Properties" should be received
And its custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,2,{},[null]]}
"""

@both
Scenario: All-null custom properties do not drop the event
Given the SDK is initialized with token "test-token"
And capture has a valid distinct id and no property-changing hooks or filters
When capture is called with event "Only Null Properties" and custom properties represented by JSON:
"""json
{"test":null}
"""
And the SDK is flushed
Then one event named "Only Null Properties" should be received
And its custom properties should equal JSON:
"""json
{}
"""
And it should retain its normal SDK metadata

@both
Scenario: Null object properties introduced by before-send are omitted
Given the SDK is initialized with token "test-token"
And the SDK supports before-send with a valid distinct id
And before-send adds custom properties represented by JSON:
"""json
{"hookNull":null,"hookItems":[null,{"drop":null}]}
"""
When capture is called with event "Hook Properties" and no custom properties
And the SDK is flushed
Then one event named "Hook Properties" should be received
And its custom properties should equal JSON:
"""json
{"hookItems":[null,{}]}
"""

@both
Scenario: JavaScript null and undefined object properties are omitted
Given a JavaScript SDK is initialized with token "test-token"
And capture has a valid distinct id and no property-changing hooks or filters
When capture is called with event "Null And Undefined" and JavaScript properties:
"""javascript
{ test: null, missing: undefined, items: ["1", null, 2] }
"""
And the SDK is flushed
Then one event named "Null And Undefined" should be received
And its custom properties should equal JSON:
"""json
{"items":["1",null,2]}
"""

@both
Scenario: Existing nullable capture inputs remain supported
Given the SDK's existing public capture API accepts null or undefined property values
And an existing caller supplies those values using the supported public property types
When the SDK adopts the serialization cleanup rule
Then the same caller code should remain accepted by the public API, including compilation/type-checking where enforced
And capture should not reject the call or require caller-side filtering solely because of those values
And serializing the captured event should omit null/undefined-valued object members while preserving array positions

@both
Scenario: Disk-backed event serialization omits null object properties
Given the SDK is initialized with token "test-token"
And the SDK has disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters
And network delivery is paused
When capture is called with event "Persisted Properties" and custom properties represented by JSON:
"""json
{"test":null,"nested":{"drop":null},"items":["1",null,2,{"drop":null}]}
"""
And the SDK serializes the queued event to disk
Then the persisted event's decoded custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,2,{}]}
"""
And no network request should have been sent
When the persisted event is restored and delivered
Then the received event's custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,2,{}]}
"""

@both
Scenario: JavaScript disk serialization omits undefined object members without compacting arrays
Given a JavaScript SDK is initialized with token "test-token"
And the SDK has disk-backed event persistence, a valid distinct id, and no property-changing hooks or filters
And network delivery is paused
When capture is called with event "Persisted Undefined" and JavaScript properties:
"""javascript
{ test: null, missing: undefined, nested: { missing: undefined }, items: ["1", undefined, null, 2], literal: "undefined" }
"""
And the SDK serializes the queued event to disk
Then the persisted event's decoded custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,null,2],"literal":"undefined"}
"""
And no network request should have been sent
When the persisted event is restored and delivered
Then the received event's custom properties should equal JSON:
"""json
{"nested":{},"items":["1",null,null,2],"literal":"undefined"}
"""

@both
Scenario: Capture honors opt-out state
Given the SDK is initialized with token "test-token"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-09-08
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
## Context

The capture contract needs a consistent serialization policy that distinguishes object members from array elements without breaking existing public input types. Backend object-null removal is planned and still in progress, not universally deployed.

Android already drops null-valued map entries but also drops null list elements, so it does not fully match this target. Python PR #926 proposes top-level omission. SDK implementation and rollout are separate from this specification change.

## Goals / Non-Goals

**Goals:** Omit null/undefined-valued custom object members when serializing events for the wire or disk. Preserve array positions and existing public API acceptance across ordinary, immediate, AI, and exception capture.

**Non-Goals:** SDK/backend implementation, widening currently non-nullable APIs, adding persistence, changing generic storage/feature-flag caches, or changing reserved-field validation and OTLP encoding.

## Decisions

- Define one shared capture requirement and reference it from AI and exception capture.
- Clean object members recursively, including objects inside arrays. Do not compact arrays or discard objects made empty by cleanup. Preserve `false`, `0`, empty strings, and literal strings such as `"null"` and `"undefined"`.
- Preserve null array elements. JavaScript undefined array entries follow normal JSON serialization as null without shifting positions.
- Preserve existing signatures, property value types, and runtime acceptance. Callers must not need casts, filtering, or a different API solely because of this normalization. In-memory events may retain null/undefined until serialization.
- Apply cleanup during serialization for network delivery and disk-backed event queues/caches. Do not change hook timing; the wire payload must still satisfy cleanup after enrichment and before-send processing.
- Treat AI normalization as a specific exception to payload pass-through, not permission for manual-payload redaction, truncation, or media processing.
- Validate disk contents before delivery, then validate the restored event on the wire. An event whose custom properties are all removed must still be delivered if otherwise admissible.

## Risks / Trade-offs

- Existing serialized output changes → require per-SDK compatibility review and rollout without narrowing supported public inputs.
- Backend work is unfinished → document it as planned behavior, not an existing universal guarantee.
- Recursive cleanup can accidentally compact arrays → fixtures include null positions and objects emptied inside arrays.
- Event normalization could affect unrelated caches → limit persistence cleanup to captured events.
- Some SDKs lack nullable inputs or disk queues → condition relevant scenarios on existing support rather than require new APIs or persistence.
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
## Why

SDKs should omit null/undefined-valued object properties when serializing captured events, while preserving array positions and existing public APIs that accept those values. The owner selected object-member omission to align with planned backend removal; that backend work is still in progress.

## What Changes

- Omit null/undefined-valued custom object members recursively during event serialization for wire delivery and disk-backed event queues/caches.
- Preserve null array elements, array positions, non-null values, and objects made empty by cleanup.
- Preserve public signatures, property types, and runtime acceptance for existing nullable/undefined inputs. Do not require callers to pre-filter their properties.
- Apply the shared contract to queued, immediate, AI, and exception capture, including values introduced by `before_send`.
- Add ten acceptance scenarios covering wire output, existing API compatibility, and event persistence/restore.
- **BREAKING** serialized-output change for SDKs currently retaining null-valued object members. SDK compatibility review and rollout remain separate; public inputs must not be narrowed.

## Capabilities

### New Capabilities

None.

### Modified Capabilities

- `capture`: Define object-member omission at serialization boundaries, preserve array positions, and protect existing public input APIs.
- `capture-ai`: Apply the shared contract to the AI route.
- `capture-exception`: Apply the shared contract to additional custom properties without changing SDK-owned metadata rules.

## Impact

Specifications and acceptance scenarios only. No SDK or backend implementation changes. This does not require widening currently non-nullable APIs, adding disk persistence, or changing unrelated caches, privacy controls, reserved-field validation, or OTLP logs/traces rules.

Context: https://github.com/PostHog/posthog-python/pull/926
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
## ADDED Requirements

### Requirement: AI capture drops null-valued object properties

`capture_ai` and its immediate/awaitable variants SHALL follow capture's "Capture drops null-valued object properties" requirement for caller-supplied custom properties, including nested objects and objects inside arrays. Null array elements SHALL retain their positions. This is a specific normalization exception to manual AI capture's payload pass-through promise, not permission to add redaction, truncation, or media processing.

#### Scenario: AI capture drops null-valued properties on its delivery route (@server)
- **GIVEN** an initialized SDK supporting AI capture with no property-changing hooks or filters
- **WHEN** capture_ai is called with distinct id "user-123", event "$ai_generation", and custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2]}`
- **AND** the SDK is flushed
- **THEN** one event named "$ai_generation" should be received on the AI batch endpoint
- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2]}`
- **AND** the analytics batch endpoint should receive no events
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
## ADDED Requirements

### Requirement: Exception capture drops null-valued custom object properties

`capture_exception` / `captureException` SHALL follow capture's "Capture drops null-valued object properties" requirement for caller-supplied additional custom event properties, including nested objects and objects inside arrays. Null array elements SHALL retain their positions. This MUST NOT change exception-input validation or field-specific rules for SDK-owned exception metadata.

#### Scenario: Exception capture drops null-valued custom properties on the wire (@both)
- **GIVEN** an initialized SDK with a valid distinct id and no property-changing hooks or filters
- **AND** a valid handled exception
- **WHEN** capture exception is called for the exception with additional custom properties represented by JSON `{"test":null,"nested":{"drop":null},"items":["1",null,2]}`
- **AND** the SDK is flushed
- **THEN** one "$exception" event should be received
- **AND** its custom properties should equal JSON `{"nested":{},"items":["1",null,2]}`
- **AND** the event should still include its SDK-generated exception data
Loading
Loading