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
95 changes: 74 additions & 21 deletions .github/workflows/sdk-compliance.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ name: SDK Compliance Tests

permissions:
contents: read
packages: read
pull-requests: write

on:
pull_request:
Expand All @@ -23,26 +21,81 @@ jobs:
with:
python-version: "3.12"
- name: Install adapter and test dependencies
run: python -m pip install -e . -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio
run: python -m pip install -e ".[zstd]" -r sdk_compliance_adapter/requirements.txt pytest pytest-timeout pytest-asyncio
- name: Test adapter protocol
run: python -m pytest sdk_compliance_adapter/test_adapter.py --timeout=30

compliance:
name: PostHog SDK compliance tests (capture v0)
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile"
adapter-context: "."
test-harness-version: "1.1.1"
continue-on-error: false
report-name: "sdk-compliance-report-v0"

compliance-v1:
name: PostHog SDK compliance tests (capture v1)
uses: PostHog/posthog-sdk-test-harness/.github/workflows/test-sdk-action.yml@4593de8b423f61fa222115da592e5c18dc82ad3c # 1.11.0
with:
adapter-dockerfile: "sdk_compliance_adapter/Dockerfile.v1"
adapter-context: "."
test-harness-version: "1.1.1"
continue-on-error: false
report-name: "sdk-compliance-report-v1"
# The harness has one enable_compression flag but a test per codec, so each
# job runs the adapter with one codec. The reusable harness workflow cannot
# pass the COMPRESSION env var, so this job runs the containers directly.
# The SDK has no brotli support, so there is no br job.
name: PostHog SDK compliance tests (${{ matrix.compression }})
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
compression: [gzip, deflate, zstd]
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}-${{ matrix.compression }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
HARNESS_IMAGE: ghcr.io/posthog/sdk-test-harness:1.13.1
steps:
- name: Check out source code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

- name: Pull test harness
run: docker pull "$HARNESS_IMAGE"

- name: Build SDK adapter
run: docker build -f sdk_compliance_adapter/Dockerfile -t sdk-adapter:test .

- name: Start SDK adapter
run: |
docker network create test-network
docker run -d \
--name sdk-adapter \
--network test-network \
-e COMPRESSION=${{ matrix.compression }} \
sdk-adapter:test

- name: Run compliance suite
run: |
mkdir -p report
docker run --rm \
--name test-harness \
--network test-network \
-v "${GITHUB_WORKSPACE}/report:/report" \
"$HARNESS_IMAGE" \
run \
--adapter-url http://sdk-adapter:8080 \
--mock-url http://test-harness:8081 \
--output text \
--report /report/sdk-compliance-report.md \
--sdk-type server

- name: Publish report to the job summary
if: always()
run: |
if [ -f report/sdk-compliance-report.md ]; then
cat report/sdk-compliance-report.md >> "$GITHUB_STEP_SUMMARY"
fi

- name: Upload compliance report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sdk-compliance-report-${{ matrix.compression }}
path: report/sdk-compliance-report.md

- name: Show adapter logs on failure
if: failure()
run: docker logs sdk-adapter || true

- name: Stop containers
if: always()
run: |
docker rm -f sdk-adapter || true
docker network rm test-network || true
5 changes: 5 additions & 0 deletions .sampo/changesets/capture-v1-major.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
pypi/posthog: major
---

Capture v1 is the only capture path. Events and AI events send to the capture v1 endpoints, and the legacy v0 capture path is removed. See the migration guide for breaking changes.
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ Follow [Public API changes](./CONTRIBUTING.md#public-api-changes). As an agent,

Before changing capture configuration, serialization, routing, or retries, read the relevant implementation and tests.

Preserve v0 defaults/compatibility; strictly typed v1 options and `$set`/`$set_once` relocation; v1-only compression (zlib-wrapped deflate, optional zstd); partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`.
Capture v1 is the only capture protocol (`capture` posts to `/i/v1/analytics/events`, `capture_ai` to `/i/v1/ai/events`); strictly typed v1 options and `$set`/`$set_once` relocation; compression (gzip, zlib-wrapped deflate, optional zstd, default none), set per lane by `capture_compression` and `capture_ai_compression`; partial-only per-event retries with stable identity; accumulated drop reporting even on 2xx; terminal v1 `429`; `Retry-After` as a minimum bounded by the shared 30s ceiling; and inline blocking retries with `sync_mode=True`.

## Mirror and build safety

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ uv sync --extra dev --extra test

## CI-aligned checks

Run the smallest relevant tests first, for example `pytest posthog/test/test_capture_v1.py --timeout=30` for v1 transport changes. Then run these core CI-aligned checks from the repository root in the activated `.venv` populated by the setup commands above:
Run the smallest relevant tests first, for example `pytest posthog/test/test_capture_send.py --timeout=30` for v1 transport changes. Then run these core CI-aligned checks from the repository root in the activated `.venv` populated by the setup commands above:

```bash
ruff format --check .
Expand Down
88 changes: 76 additions & 12 deletions posthog/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
OptionalSetArgs,
)
from posthog.capture_compression import CaptureCompression as CaptureCompression
from posthog.capture_mode import CaptureMode as CaptureMode
from posthog.capture_send import CaptureError as CaptureError
from posthog.client import Client
from posthog.tracing.span import Span as Span
from posthog.async_client import AsyncClient as AsyncClient
Expand Down Expand Up @@ -52,6 +52,12 @@
from posthog.contexts import (
get_tags as inner_get_tags,
)
from posthog.contexts import (
set_context_option as inner_set_context_option,
)
from posthog.contexts import (
get_context_options as inner_get_context_options,
)
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_DETECT_SECRETS,
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
Expand Down Expand Up @@ -299,6 +305,44 @@ def get_tags() -> Dict[str, Any]:
return inner_get_tags()


def set_context_option(key: str, value: Any) -> None:
"""
Set a capture option for every event captured within the current context.

Context options fill options an event leaves unset, before ``before_send``
runs, so the hook sees them. They override ``super_options``. An event's
own ``options`` override them, and ``before_send`` can change them.

Args:
key: The option name, such as ``"process_person_profile"``
value: The option value, sent as given

Examples:
```python
from posthog import new_context, set_context_option
with new_context():
set_context_option("process_person_profile", False)
```

Category:
Contexts
"""
return inner_set_context_option(key, value)


def get_context_options() -> Dict[str, Any]:
"""
Get all capture options from the current context.

Returns:
Dict of all capture options in the current context

Category:
Contexts
"""
return inner_get_context_options()


"""Settings.

These module-level settings configure the legacy global PostHog client used by
Expand All @@ -313,8 +357,11 @@ def get_tags() -> Dict[str, Any]:
disabled no-op global client.
host: PostHog ingestion host. Defaults to the US ingestion endpoint when not
set.
on_error: Optional callback invoked by background consumers when event upload
fails. Keep it short and non-blocking. Lifecycle methods can be called
on_error: Optional callback ``(error, batch)`` invoked when event upload
fails: by background consumers, or on the calling thread in ``sync_mode``.
Capture failures arrive as ``CaptureError``. Without it, each failed batch
logs one aggregate line. A capture that fails inside the callback logs that
line instead of calling it again. Keep it short and non-blocking. Lifecycle methods can be called
directly and will be deferred, but the callback must not wait for another
thread or task that calls ``flush()``, ``join()``, or ``shutdown()``.
debug: Enable verbose SDK logging and re-raise errors from public APIs.
Expand All @@ -337,7 +384,18 @@ def get_tags() -> Dict[str, Any]:
feature_flags_request_max_retries: Number of retries for feature flag
requests after network, transport, or timeout failures. Defaults to 1.
Set to 0 to disable retries.
super_properties: Properties merged into every captured event.
super_properties: Properties for every captured event. They fill only
keys the event leaves unset, before ``before_send`` runs. ``$set``,
``$set_once``, ``$groups`` and ``$group_set`` fill one level deep. An
event's own properties and context tags override them, and
``before_send`` can change or remove them.
super_options: Capture options for every captured event, such as
``{"cookieless_mode": True}``. They fill only options the event leaves
unset, before ``before_send`` runs. An event's own ``options`` and
context options override them, and ``before_send`` can change them. They also
win over an event's legacy property for the same key, such as
``$cookieless_mode``, so pass per-event overrides of that key as
``options``.
metrics: Config dict for the ``client.metrics`` API (``service_name``,
``service_version``, ``environment``, ``flush_interval``, ...). Applied
when ``setup()`` builds the global client, or on a later ``setup()``
Expand All @@ -361,7 +419,9 @@ def get_tags() -> Dict[str, Any]:
project_root: Root path used to determine in-app exception stack frames.
privacy_mode: Capture AI usage metadata without prompt inputs or outputs.
before_send: Optional callback that can modify or drop events before upload.
Return ``None`` to drop an event.
Return ``None`` to drop an event. Context tags, context options,
``super_properties`` and ``super_options`` fill in before it runs, so
it can change or remove them.
enable_local_evaluation: Whether to poll feature flag definitions for local
evaluation when a personal API key is configured.
flag_definition_cache_provider: Optional external cache provider for sharing
Expand Down Expand Up @@ -411,6 +471,7 @@ def get_tags() -> Dict[str, Any]:
feature_flags_request_timeout_seconds = 3 # type: int
feature_flags_request_max_retries = 1 # type: int
super_properties = None # type: Optional[Dict]
super_options = None # type: Optional[Dict]
metrics = None # type: Optional[Dict]
traces = None # type: Optional[Dict]
enable_exception_autocapture = False # type: bool
Expand All @@ -424,11 +485,8 @@ def get_tags() -> Dict[str, Any]:
# We recommend setting this to False if you are only using the personalApiKey for evaluating remote config payloads via `get_remote_config_payload` and not using local evaluation.
enable_local_evaluation = True # type: bool
flag_definition_cache_provider = None # type: Optional[FlagDefinitionCacheProvider]
# Capture wire protocol for the global client. None defers to POSTHOG_CAPTURE_MODE
# then CaptureMode.V0. See posthog.capture_mode.CaptureMode.
capture_mode = None # type: Optional[CaptureMode]
# Routes AI SDK wrapper events through the dedicated AI capture lane, skips
# truncation, and passes media unredacted. `privacy_mode` always wins.
# Captures full AI content in AI SDK wrapper events: skips truncation and
# passes media unredacted. `privacy_mode` always wins.
enable_full_ai_capture = False # type: bool
# Deprecated aliases for `enable_full_ai_capture`.
_use_ai_lane = False # type: bool
Expand Down Expand Up @@ -638,6 +696,7 @@ def group_identify(
uuid: Optional[str] = None,
disable_geoip: Optional[bool] = None,
distinct_id: Optional[ID_TYPES] = None,
options: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""
Set properties on a group.
Expand All @@ -653,6 +712,7 @@ def group_identify(
uuid: Optional UUID for the event
disable_geoip: Whether to disable GeoIP lookup
distinct_id: Optional distinct ID of the user performing the action
options: Optional capture options for the event, sent as given

Examples:
```python
Expand All @@ -676,6 +736,7 @@ def group_identify(
uuid=uuid,
disable_geoip=disable_geoip,
distinct_id=distinct_id,
options=options,
)


Expand All @@ -685,6 +746,7 @@ def alias(
timestamp: Optional[Union[datetime.datetime, str]] = None,
uuid: Optional[str] = None,
disable_geoip: Optional[bool] = None,
options: Optional[Dict[str, Any]] = None,
) -> Optional[str]:
"""
Associate user behaviour before and after they e.g. register, login, or perform some other identifying action.
Expand All @@ -696,6 +758,7 @@ def alias(
datetimes and parseable ISO timestamp strings are converted to UTC.
uuid: Optional UUID for the event
disable_geoip: Whether to disable GeoIP lookup
options: Optional capture options for the event, sent as given

Details:
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?". Particularly useful for associating user behaviour before and after they e.g. register, login, or perform some other identifying action.
Expand All @@ -717,6 +780,7 @@ def alias(
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
options=options,
)


Expand All @@ -730,7 +794,7 @@ def capture_exception(
Args:
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
**kwargs: Optional capture arguments including distinct_id, properties,
timestamp, uuid, groups, flags, send_feature_flags, and disable_geoip.
timestamp, uuid, groups, flags, send_feature_flags, disable_geoip, and options.

Details:
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
Expand Down Expand Up @@ -1400,6 +1464,7 @@ def setup() -> Client:
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
feature_flags_request_max_retries=feature_flags_request_max_retries,
super_properties=super_properties,
super_options=super_options,
metrics=metrics,
traces=traces,
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)
Expand All @@ -1423,7 +1488,6 @@ def setup() -> Client:
exception_autocapture_bucket_size=exception_autocapture_bucket_size,
exception_autocapture_refill_rate=exception_autocapture_refill_rate,
exception_autocapture_refill_interval_seconds=exception_autocapture_refill_interval_seconds,
capture_mode=capture_mode,
)

# Always set in case user changes it. Preserve Client's auto-disabled state
Expand Down
Loading