Compare commits

..
83 Commits
Author SHA1 Message Date
4e4cd18574 feat: llma / error tracking integration (#376)
* feat: llma / error tracking integration

* capture all metadata in llm event

* instrument with contexts

* bump version

* indentation

* linting

* tests

* raise

* test: add exception capture integration tests for langchain

Add 6 tests covering the new LLMA + error tracking integration:
- capture_exception called on span/generation errors
- $exception_event_id added to AI events
- No capture when autocapture disabled
- AI properties passed to exception event
- Handles None return from capture_exception

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: pass context tags to capture() for test compatibility

- Export get_tags() from posthog module
- Explicitly pass context tags to capture() in AI utils
- Fix $ai_model fallback to extract from response.model
- Fix ruff formatting in langchain test_callbacks.py

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: disable auto-capture exceptions in LLM context

The new_context() defaults to capture_exceptions=True which would
auto-capture any exception regardless of enable_exception_autocapture
setting. This was inconsistent with LangChain callbacks which
explicitly check the setting.

Pass capture_exceptions=False to let exception handling be controlled
explicitly by the enable_exception_autocapture setting.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: isolate LLM context with fresh=True to avoid tag inheritance

Use fresh=True to start with a clean context for each LLM call.
This avoids inheriting $ai_* tags from parent contexts which could
cause mismatched AI metadata due to the tag merge order bug in
contexts.py (parent tags incorrectly override child tags).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: correct tag merge order so child tags take precedence

The collect_tags() method had a bug where parent tags would overwrite
child tags, despite the comment saying the opposite. This fix ensures
child context tags properly override parent tags.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: remove fresh=True now that tag merge order is fixed

With the collect_tags() bug fixed, child tags properly override parent
tags. LLM events can now inherit useful parent context tags (request_id,
user info, etc.) while still having their $ai_* tags take precedence.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add test for child tags overriding parent tags

Verifies that in non-fresh contexts, child tags properly override
parent tags with the same key while still inheriting other parent tags.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: add TODO for OpenAI/Anthropic/Gemini exception capture

Document that exception capture needs to be added for the direct SDK
wrappers, similar to how it's implemented in LangChain callbacks.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: David Newell <david@Mac.communityfibre.co.uk>
Co-authored-by: Andrew Maguire <andrewm4894@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-07 13:11:20 +00:00
AndersandGitHub c1548c40ef fix(release): restore GitHub release creation with gh CLI (#401)
The "Create GitHub release" step was broken in PR #386, which removed
the GITHUB_TOKEN env var from the actions/create-release action. The
action requires the token to be passed explicitly, so releases were
being published to PyPI but GitHub tags/releases were not created.

This replaces the archived actions/create-release@v1 with the gh CLI,
which is already used elsewhere in this workflow. The gh CLI properly
uses GH_TOKEN for authentication.
2026-01-06 14:27:28 +01:00
Carlos MarchalandGitHub f1c6da2da2 fix: double counting anthropic langchain (#399) 2026-01-05 16:56:17 +01:00
Hugues PouillotandGitHub 7ac63e1615 feat: add in_app configuration for python SDK (#396)
* add in_app configuration for python SDK

* bump version

* add in_app_modules to init script as well
2025-12-22 12:02:48 +01:00
Andrew MaguireGitHubClaude Opus 4.5greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
14d1d0b99c fix(llma): extract model from response for OpenAI stored prompts (#395)
* fix: extract model from response for OpenAI stored prompts

When using OpenAI stored prompts, the model is defined in the OpenAI
dashboard rather than passed in the API request. This change adds a
fallback to extract the model from the response object when not
provided in kwargs.

Fixes PostHog/posthog#42861

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Apply suggestion from @greptile-apps[bot]

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* test: add tests for model extraction fallback and bump to 7.4.1

- Add 8 tests covering model extraction from response for stored prompts
- Fix utils.py to add 'unknown' fallback for consistency
- Bump version to 7.4.1
- Update CHANGELOG.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* style: format utils.py with ruff

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove 'unknown' fallback from non-streaming to match original behavior

Non-streaming originally returned None when model wasn't in kwargs.
Streaming keeps "unknown" fallback as that was the original behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: add test for None model fallback in non-streaming

Verifies that non-streaming returns None (not "unknown") when model
is not available in kwargs or response, matching original behavior.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-12-21 21:17:32 +00:00
Dustin ByrneandGitHub 80e6e432b4 fix: Respect send_feature_flags setting and deprecate send_feature_flag_events in get_feature_flag_payload (#391)
* fix: Respect `send_feature_flags` when local eval is enabled

* fix: Deprecate `send_feature_flag_events` in `get_feature_flag_payload`
2025-12-17 13:04:10 -05:00
Phil HaackandGitHub 5d0bae1b22 feat(flags): Add retry support for feature flag requests (#392)
* Add urllib3-based retry for feature flag requests

Use urllib3's built-in Retry mechanism for feature flag POST requests
instead of application-level retry logic. This is simpler and leverages
well-tested library code.

Key changes:
- Add `RETRY_STATUS_FORCELIST` = [408, 500, 502, 503, 504]
- Add `_build_flags_session()` with POST retries and `status_forcelist`
- Update `flags()` to use dedicated flags session
- Add tests for retry configuration and session usage

The flags session retries on:
- Network failures (connect/read errors)
- Transient server errors (408, 500, 502, 503, 504)

It does NOT retry on:
- 429 (rate limit) - need to wait, not hammer
- 402 (quota limit) - won't resolve with retries

* Make examples run without requiring personal api key

* Add integration tests for network retry behavior

Add tests that verify actual retry behavior, not just configuration:

- test_retries_on_503_then_succeeds: Spins up a local HTTP server that
  returns 503 twice then 200, verifying 3 requests are made
- test_connection_errors_are_retried: Verifies connection errors trigger
  retries by measuring elapsed time with backoff

Both tests use dynamically allocated ports for CI safety.

* Bump version to 7.4.0
2025-12-16 23:41:38 +00:00
Phil HaackandGitHub b17928075a feature: Add $feature_flag_error property to track flag evaluation failures (#390)
* Add $feature_flag_error property to track flag evaluation failures

Track errors in feature flag evaluation by adding a `$feature_flag_error` property to the `$feature_flag_called` event.

* Refactor requests exception imports through request.py

Export RequestsTimeout and RequestsConnectionError from posthog/request.py
to keep all requests library imports in one place and avoid mypy issues.

* Address PR review feedback

- Fix fallback logic to only trigger on actual exceptions, not when
  errors_while_computing or flag_missing is set from a successful API response
- Change log.exception() to log.warning() for expected operational errors
  (quota limits, timeouts, connection errors, API errors) to reduce log noise
- Keep log.exception() only for truly unexpected errors (unknown_error)
- Extract stale cache fallback into _get_stale_flag_fallback() helper method

* Add tests for stale cache fallback and error absence

- Add TestFeatureFlagErrorWithStaleCacheFallback test class with 4 tests:
  - test_timeout_error_returns_stale_cached_value
  - test_connection_error_returns_stale_cached_value
  - test_api_error_returns_stale_cached_value
  - test_error_without_cache_returns_none

- Add negative assertions to verify $feature_flag_error is absent on success:
  - test_get_feature_flag_result_boolean_local_evaluation
  - test_get_feature_flag_result_variant_local_evaluation
  - test_get_feature_flag_result_boolean_decide
  - test_get_feature_flag_result_variant_decide

* Report combined errors when both errors_while_computing and flag_missing

When the server returns errorsWhileComputingFlags=true AND the requested
flag is not in the response, report both conditions as a comma-separated
string: "errors_while_computing_flags,flag_missing"

This provides better debugging context when both conditions occur.

* Add FeatureFlagError constants class for error type values

- Add FeatureFlagError class to types.py with constants:
  - ERRORS_WHILE_COMPUTING, FLAG_MISSING, QUOTA_LIMITED
  - TIMEOUT, CONNECTION_ERROR, UNKNOWN_ERROR
  - api_error(status) static method for dynamic error strings

- Update client.py to use FeatureFlagError constants instead of
  magic strings

- Update all tests to use constants for maintainability

This improves maintainability by:
- Single source of truth for error values
- IDE autocomplete and typo detection
- Documentation of analytics-stable values

* Remove print statements from test failure handlers

* Fix mypy type error in FeatureFlagError.api_error method

Accept Union[int, str] to match APIError.status type.
2025-12-15 17:06:24 -08:00
Dustin ByrneandGitHub b6dbff1cb7 feat: Add FlagDefinitionCacheProvider interface (#387)
* feat: Add FlagDefinitionCacheProvider interface

* feat: Add a Redis example for FlagDefinitionCacheProvider

* style: ruff format

* style: ruff format

* refactor: clean up mypy errors

* chore: mypy-baseline sync

* fix: Type Redis as Redis[str]

* fix: Adhere to strict typing

The defined types don't leave room for missing or optional keys. We'll
use the types as they're defined.
2025-12-12 11:31:55 -05:00
Tom PiccirelloandGitHub 9f8faf70a1 Publish to PyPI using Trusted Publisher (#388)
Twine [supports Trusted Publisher](https://github.com/pypa/twine/pull/1194/), but their documentation is a bit sparse.
2025-12-11 13:10:18 -08:00
Tom PiccirelloandGitHub 440651d90d Replace PAT with default GITHUB_TOKEN (#386)
* Replace PAT with default GITHUB_TOKEN

A PAT isn't needed for either of these Actions.

* Only expose env vars to step that needs them
2025-12-11 12:39:38 -08:00
Carlos MarchalandGitHub da8653305f feat(llma): multimodal-capture (#378) 2025-12-11 15:00:53 +00:00
Aleksander BłaszkiewiczandGitHub 88a7c5ec84 fix: remove unused $exception_message and $exception_type (#383)
* fix: remove unused

* fix: remove exception type

* fix: wip
2025-12-10 11:33:14 +00:00
Dustin ByrneandGitHub d72e89adab feat: Allow customization of socket options (#385)
* feat: Allow customization of socket options

This allows clients to configure (e.g.) socket keep alive probes

* test: Prevent leaking modified _session

* chore: Add a type ignore

* feat: Add `disable_connection_reuse` config method

Disables connection pooling

* set_socket_options is idempotent
2025-12-09 01:52:24 -05:00
Aleksander BłaszkiewiczandGitHub ce38fb2a49 feat: mask values (#382)
* feat: mask values

* feat: wip

* fix: ruff

* feat: wip

* feat: wip

* fix: ruff

* fix: ruff

* feat: wip

* feat: wip

* feat: version bump
2025-12-05 19:35:56 +01:00
Dustin ByrneandGitHub 103a7ad933 fix: capture enriches with local eval when enabled (#380)
Captured events now use local evaluation results when
`send_feature_flags` is `True` and local evaluation is enabled.
2025-12-04 15:07:02 -05:00
Phil HaackandGitHub fff9992fe9 feat(flags): Add ETag support for local evaluation polling (#381)
* Add ETag support for local evaluation polling

Add support for HTTP conditional requests using ETags to reduce bandwidth
when polling for feature flag definitions. When flag definitions haven't
changed, the server returns 304 Not Modified and the SDK skips processing.

- Add GetResponse dataclass to encapsulate response data, ETag, and status
- Update get() to send If-None-Match header and handle 304 responses
- Store ETag in client and pass it on subsequent polling requests
- Skip flag processing when 304 Not Modified is received

* Use _session rather than requests

Benefits:

1. Reuses TCP connections via keep-alive
2. 2 retries on connect/read errors
3. Faster handshakes

* Add unit tests for get() function

Test HTTP-level behavior including:
- ETag extraction from response headers
- If-None-Match header sent when etag provided
- 304 Not Modified response handling
- Fallback when 304 has no ETag header
- Error response handling (APIError)
- Authorization and User-Agent headers
- Timeout and URL construction

* Add defensive null check for response.data

Guard against unexpected None data in non-304 responses to prevent
TypeError when accessing dictionary keys.

* Clear stored ETag when server stops sending one

If the server stops including ETag headers in responses, clear the
stored ETag so we don't keep sending a stale If-None-Match header.

* Mask API tokens in log messages

Keep first 10 chars visible for identification while hiding the rest.
Addresses CodeQL security warning about logging sensitive data.

* Ran ruff format
2025-12-02 13:11:38 -08:00
Dylan MartinandGitHub c253e418c3 feat(flags): included evaluated_at properties in $feature_flag_called events (#374)
* format

* update tests

* bump version
2025-12-01 22:02:09 -05:00
Radu RaiceaandGitHub 285597740e feat(llma): add Gemini async (#375) 2025-11-27 11:35:54 -05:00
Carlos MarchalandGitHub 7c7f5293af feat: add python 3.14 support (#373) 2025-11-25 16:43:34 +01:00
github-actions[bot] 494c78675d Update generated references 2025-11-15 12:44:50 +00:00
Aleksander BłaszkiewiczandGitHub f75c5efeec feat: use repr in code variables (#372) 2025-11-15 13:43:55 +01:00
Tue HaulundandGitHub 65785b892e fix: avoid overwriting consumer list when using more than one consumer (#370)
fix: avoid overwriting consumer list when using more than one consumer thread
2025-11-12 13:05:05 +01:00
github-actions[bot] 6dde2bf9e5 Update generated references 2025-11-11 18:13:36 +00:00
Carlos MarchalandGitHub 48203364a9 chore(llma): update SDKs (#367) 2025-11-11 18:12:33 +00:00
github-actions[bot] 805c308841 Update generated references 2025-11-11 17:57:26 +00:00
Alessandro PogliaghiandGitHub 898654a174 feat(ph-ai): PostHog properties dict in GenerationMetadata (#366) 2025-11-11 17:56:32 +00:00
github-actions[bot] 499d54570c Update generated references 2025-11-11 08:57:51 +00:00
Carlos MarchalandGitHub 6c815dffc3 fix(llma): Langchain cache token double subtraction for non-Anthropic providers (#369) 2025-11-11 09:56:57 +01:00
Julian BezandGitHub 3a1b8e49cd fix: add ruff check to CI and fix all lint errors (#360)
- Add 'Lint with ruff' step to CI workflow (was only running format check)
- Fix F401: Add explicit re-exports for public API types in __init__.py
- Fix F811: Rename duplicate test_openai_reasoning_tokens to test_openai_reasoning_tokens_o4_mini
- Fix E731: Convert lambda to def function in test_middleware.py
- Fix unused imports in client.py and test files (auto-fixed)

Without ruff check in CI, lint errors were accumulating on master undetected.
2025-11-10 12:45:52 +01:00
github-actions[bot] f3e5d7132f Update generated references 2025-11-07 15:57:55 +00:00
Aleksander BłaszkiewiczandGitHub 88f606994c fix: code variables without client (#368)
* fix: pass variables from init to client

* chore: version+changelog
2025-11-07 16:57:01 +01:00
Luke BeltonandGitHub a155e1dfd6 fix docstring for set (#364) 2025-11-06 16:02:49 +00:00
github-actions[bot] f648a5dfd7 Update generated references 2025-11-06 15:13:17 +00:00
Aleksander BłaszkiewiczandGitHub e309bd7149 feat: add code variables capture (#365)
* feat: add code variables capture

* feat: bump version and add changelog
2025-11-06 16:12:17 +01:00
github-actions[bot] 0cfd678857 Update generated references 2025-11-04 19:44:04 +00:00
Radu RaiceaandGitHub 3d8825fc67 feat(llma): send number of web searches (#359)
* feat(llma): send number of web searches

* feat(llma): add more tests

* chore(llma): bump version

* fix(llma): feedback

* fix(llma): fix Gemini

* fix(llma): fix OpenAI's Chat Completions streaming
2025-11-04 19:43:11 +00:00
github-actions[bot] 3e52e7feda Update generated references 2025-11-03 12:29:46 +00:00
Julian BezandGitHub 98e322695d fix(django): handle request.user in async middleware context (#358) 2025-11-03 12:28:57 +00:00
github-actions[bot] 700c922baf Update generated references 2025-11-02 18:58:55 +00:00
69293f5198 fix(llma): cache cost calculation in the LangChain callback (#346)
* fix(llma): cache cost calculation in the LangChain callback

* fix: format

* Update posthog/ai/langchain/callbacks.py

Co-authored-by: Radu Raicea <radu@raicea.com>

* Bump version to 6.7.13

Master has already released 6.7.12 with other fixes, so this PR will be 6.7.13

---------

Co-authored-by: Radu Raicea <radu@raicea.com>
Co-authored-by: Andrew Maguire <andrewm4894@gmail.com>
2025-11-02 18:58:04 +00:00
github-actions[bot] 46589f93d5 Update generated references 2025-11-02 17:10:28 +00:00
Andrew MaguireandGitHub 57546d29e6 fix(llma): LangChain 1.0+ compatibility for CallbackHandler (#363)
* fix: Add LangChain 1.0+ compatibility for CallbackHandler imports

- Use try/except to import from langchain_core first (LangChain 1.0+)
- Fall back to legacy langchain imports for older versions
- Maintains backward compatibility with LangChain 0.x
- All existing tests pass (45 passed)

Fixes #362

* test: Add regression test for AgentAction/AgentFinish imports

- Tests that AgentAction and AgentFinish can be imported
- Tests on_agent_action and on_agent_finish callbacks with mock data
- Ensures compatibility with both LangChain 0.x and 1.0+
- Catches the import issue that was previously only tested with API keys

This addresses a test coverage gap identified during code review.

* chore: Add CHANGELOG entry for LangChain 1.0+ compatibility fix

* fix: Remove unused type: ignore comments for mypy

The type: ignore comments were only needed when the except block
executes, but CI runs with LangChain 1.0+ so the try block succeeds.
Mypy flags these as unused-ignore errors.

* chore: bump version to 6.7.12 for langchain 1.0 compatibility
2025-11-02 17:09:33 +00:00
Julian BezandGitHub 50b0c7170a fix(django): restore process_exception to capture view exceptions (#350)
Restores the process_exception method that was removed in v6.7.5 (PR #328),
which broke exception capture from Django views and downstream middleware.

Django converts view exceptions into responses before they propagate through
the middleware stack's __call__ method, so the context manager's exception
handler never sees them. Django provides these exceptions via the
process_exception hook instead.

Changes:
- Add process_exception method to capture exceptions from views and downstream
  middleware with proper request context and tags
- Add tests verifying process_exception behavior and settings (capture_exceptions,
  request_filter)
2025-10-29 10:40:08 +00:00
github-actions[bot] f719c3dadf Update generated references 2025-10-28 13:06:45 +00:00
Andrew MaguireandGitHub 105090a6ba chore: bump version to 6.7.11 for AI framework feature (#354)
Update version and changelog for PR #347
2025-10-28 13:05:50 +00:00
edfadcc6a8 feat(ai): Add $ai_framework property for framework integrations (#347)
* Add $ai_lib_metadata to AI integrations

Adds framework identification metadata to all AI events for easier filtering
and analytics. Each integration now includes a $ai_lib_metadata property with
schema version and framework name.

- LangChain: Hardcoded to "langchain"
- Native wrappers (Anthropic, OpenAI, Gemini): Uses provider name
- Ready for future frameworks (pydantic-ai, crewai, llamaindex)

This enables PostHog queries to easily distinguish between:
- Direct SDK wrapper usage
- Framework-mediated usage (LangChain, etc.)
- Different framework types

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add \$ai_lib_metadata to sync/async paths and tests

- Added \$ai_lib_metadata to call_llm_and_track_usage (sync)
- Added \$ai_lib_metadata to call_llm_and_track_usage_async (async)
- Added test assertion in test_basic_completion
- Placed metadata at end of properties for consistency

All tests pass successfully.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Refactor: use unified utility function for $ai_lib_metadata

Creates a single `get_ai_lib_metadata(framework)` utility function to generate
the $ai_lib_metadata object, replacing inline implementations across the
codebase.

Changes:
- Add get_ai_lib_metadata() utility to utils.py
- Update LangChain callbacks to use utility function
- Update call_llm_and_track_usage() to use utility function
- Update call_llm_and_track_usage_async() to use utility function
- Update capture_streaming_event() to use utility function

Benefits:
- Consistency across all integrations
- Single source of truth for metadata structure
- Easier to extend with version detection later

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Add $ai_lib_metadata assertions to provider tests

Add missing $ai_lib_metadata assertions to Anthropic, Gemini, and LangChain tests to match the validation already present in OpenAI tests. Each test now verifies the metadata field contains the correct schema version and framework name.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* Simplify to $ai_framework property, only for actual frameworks

Changes:
- Replace complex $ai_lib_metadata object with simple $ai_framework string
- Only include $ai_framework when using actual framework (LangChain)
- Remove $ai_framework from direct provider calls (OpenAI, Anthropic, Gemini)
- Update all tests to reflect new behavior

Before: {"schema": "v1", "frameworks": [{"name": "langchain"}]}
After: "langchain" (only when using LangChain framework)

This eliminates wasteful redundancy where framework=provider for direct calls.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-28 09:59:20 +00:00
Julian BezandGitHub 13184e2e16 chore: standardize workflow extensions to .yml (#349)
Rename workflow files from .yaml to .yml for consistency with existing
workflows (ci.yml, call-flags-project-board.yml).

This resolves naming confusion and standardizes all GitHub Actions
workflow files to use the .yml extension.
2025-10-24 14:17:27 +00:00
webjunkieandgithub-actions[bot] 1b8642331f Update generated references 2025-10-24 14:16:57 +00:00
Julian BezandGitHub 6af129f414 fix(django): make middleware truly hybrid-compatible with sync and async Django stacks (#348)
Address code review feedback and critical issues from PR #328.

Changes:
- Keep __call__ as sync method that conditionally routes to __acall__ for async paths
- Use markcoroutinefunction() to properly mark instances when async is detected
- Detect async/sync at init time via iscoroutinefunction(get_response)
- Remove process_exception method - it was non-functional (Django doesn't call it on new-style middleware without MiddlewareMixin)
- Fix markcoroutinefunction fallback to be a simple no-op instead of accessing private API
- Exception capture works correctly via contexts.new_context() which has built-in exception handling
- Add comprehensive test coverage for sync, async, and hybrid middleware behavior
- Add async exception capture tests
- Refactor tests to use proper middleware initialization

This implementation follows Django's recommended hybrid middleware pattern where
both sync_capable and async_capable are True, allowing Django to pass requests
without conversion while the middleware adapts based on the detected mode.

The sync path behavior is identical to version 6.7.4 (pre-async), ensuring perfect
backward compatibility for WSGI deployments.

Addresses #329
Related to #328
2025-10-24 15:50:21 +02:00
Phil HaackandGitHub 02e82a6050 Bump version to 6.7.9 (#345) 2025-10-22 20:53:07 +00:00
Phil HaackandGitHub 9a05db8b20 fix(flags): multi-condition flags with static cohorts returning wrong variants (#343)
* Fix multi-condition flags with static cohorts returning wrong variants

When a feature flag has multiple conditions and one contains a static
cohort, the SDK now correctly falls back to the API instead of
evaluating subsequent conditions locally and returning incorrect variants.

Introduce RequiresServerEvaluation exception to distinguish between:
- Missing server-side data (static cohorts) → immediate API fallback
- Evaluation errors (bad regex, missing properties) → try next condition

Changes:
- Add RequiresServerEvaluation exception class
- Update match_cohort() to throw RequiresServerEvaluation for static cohorts
- Update match_property_group() to propagate RequiresServerEvaluation
- Update match_feature_flag_properties() to handle both exception types
- Update client.py to catch both exceptions for API fallback
- Export RequiresServerEvaluation in __init__.py
- Add test for multi-condition static cohort scenario

All 84 feature flag tests pass.

* Add unit test for payloads

* ruff format
2025-10-21 13:40:36 -07:00
Radu RaiceaandGitHub e06830e068 fix(llma): missing await in OpenAI's streaming implementation (#342)
* fix(llma): missing async for OpenAI async

* chore(llma): bump version

* chore(llma): bump version
2025-10-16 14:46:26 +00:00
gewenyu99andgithub-actions[bot] 465baea6f8 Update generated references 2025-10-16 00:02:49 +00:00
Vincent (Wen Yu) GeandGitHub 2bd6e9eaf1 fix: Check for references directory and generate 6.7.7 specs (#341)
* Fix check for directory and generate 6.7.7 specs

* Delete references
2025-10-15 20:02:16 -04:00
Vincent (Wen Yu) GeandGitHub e6fe39a0dd Run SDK generation after release job (#340)
* run after release job

* use bot pat

* Fix token placement

* Run this with a gh cli command
2025-10-15 19:45:33 -04:00
Manoel Aranda NetoandGitHub 67f68c00fe fix: remove deprecated attribute from exception events (#338) 2025-10-14 10:33:49 +00:00
Tom PiccirelloandGitHub 6156e51f8f chore: switch to fine-grained PAT (#337) 2025-10-13 10:19:12 -07:00
Vincent (Wen Yu) GeGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
461c45772a Add workflow to create and save versioned references (#332)
* Updates script to persist references

* Workflow to generate references to a folder

* Get rid of references, to be generated

* Update .github/workflows/generate-references.yaml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Update .github/workflows/generate-references.yaml

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>

* Review comments

* Update .github/workflows/generate-references.yaml

* Pin hashes and only run on releases

* Pin uv

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-09-30 17:02:57 -04:00
Carlos MarchalandGitHub a221bffb52 feat: auto update llm sdks (#333)
* feat: auto update llm sdks

* fix: apply PR comments
2025-09-25 13:40:42 +02:00
Andy ZhaoandGitHub 26cfd818af fix: don't sort condition sets with variant overrides to the top (#330)
* fix: don't sort condition sets with variant overrides to the top

* fix test

* update test

* update version and change log
2025-09-22 14:10:43 -04:00
Dustin ByrneandGitHub e868e23dcb fix: Prevent core Client methods from raising exceptions (#327)
* fix: Prevent core Client methods from raising exceptions

The goal is to ensure that our client doesn't cause a panic in an
end-user application. This change updates
capture/set/set_once/group_identify/alias to swallow and log any
exceptions that occur. Note that this won't prevent errors from
propagating via the `on_error` callback if an error occurs while
processing the queue.

* test: Remove assertions that capture raises

These tests were broken anyways. Capture would only raise because it was
being called with no arguments, not because api_key or host are None.
2025-09-17 15:47:07 -04:00
Oliver BrowneandGitHub 0bb6342472 feat(err): add __acall__ to django middleware (#328)
* add __acall__

* fix types
2025-09-16 15:40:11 +03:00
Carlos MarchalandGitHub d76bfe6e5b fix/system prompt sometimes missing (#326)
* fix: always capture system prompt

* chore: bump version

* fix: gemini system prompt capture

* chore: imports at top

* fix: test

The mock we were passing from this test
reporetd that it had a `system instruction` field,
breaking assumptions

* chore: lint

* fix: better code organization

* chore: lint
2025-09-05 17:28:55 +02:00
Radu RaiceaandGitHub b3e21c1c0e fix(llma): gemini missing cached and reasoning tokens (#323)
* fix(llma): Gemini missing cached and reasoning tokens

* chore(llma): bump version

* chore(llma): run ruff
2025-09-04 14:21:32 -04:00
Radu RaiceaandGitHub 08b11cbf9b fix(llma): streaming providers with tool calls (#319)
* fix(llma): tool calls in streaming Anthropic

* fix(llma): Gemini content

* fix(llma): extract converters for providers

* fix(llma): continuation of DRY refactoring

* fix(llma): add $ai_tools to streaming Gemini

* fix(llma): tool calls in streaming Gemini

* fix(llma): tool calls in streaming OpenAI Chat Completions

* fix(llma): fix test

* fix(llma): run ruff

* fix(llma): fix types

* fix(llma): run ruff

* chore(llma): run mypy baseline sync

* chore(llma): bump version

* fix(llma): fix test

* chore(llma): update CHANGELOG

* fix(llma): Responses API streaming tokens

* fix(llma): run ruff

* fix(llma): run ruff
2025-09-03 20:02:38 +00:00
Dylan MartinandGitHub cee26bb3dc technically incorrect (#321) 2025-09-02 17:02:41 -07:00
Carlos MarchalandGitHub 9f370675d4 feat(llma): redact base64 images (#318) 2025-09-01 09:13:07 +02:00
Phil HaackandGitHub 6e00d573f3 Bump version to 6.7.0 (#317) 2025-08-26 22:32:04 +00:00
Phil HaackandGitHub a91a20876e fix(flags): flag dependency evaluation for multivariate flags (#316) 2025-08-25 14:20:45 -07:00
Vincent (Wen Yu) GeGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
10472e721d Add categories to doc specs (#313)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-21 11:48:54 -04:00
Juraj MajerikandGitHub fb38447869 chore: bump version to 6.6.1 (#314) 2025-08-21 16:14:27 +02:00
Juraj MajerikandGitHub ae97131107 Fix NoneType error when group_properties is None (#312) 2025-08-19 12:01:12 -07:00
Phil HaackandGitHub 675dea16a6 feat(flags): implement local evaluation of flag dependency filters (#311) 2025-08-19 09:43:46 -07:00
Phil HaackGitHubCopilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
6a3e7ef3ad chore: Improvements to example.py (#310)
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
2025-08-18 21:46:58 -07:00
Dylan MartinandGitHub 20b8825bd2 feat(flags): support passing in lists of flag keys to the /flags endpoint instead of evaluating every flag every time we fall back (#307) 2025-08-18 16:10:34 -07:00
David NewellandGitHub 818edc2811 feat: we should capture which properties were added as tags (#304) 2025-08-08 11:44:34 +01:00
Vincent (Wen Yu) GeandGitHub 05074351a3 Remove placeholder for params, waste space (#298) 2025-08-07 15:20:27 -04:00
Phil HaackandGitHub d25fae383c fix(flags): Pass project API key in remote_config requests (#303) 2025-08-06 21:17:30 +00:00
Radu RaiceaandGitHub 68e78c877d feat(llmo): support Vertex AI (#302)
* feat(llmo): support Vertex AI

* chore(llmo): run formatter

* fix(llmo): fix types error

* chore(llmo): run formatter

* chore(llmo): bump version
2025-08-05 15:33:10 -04:00
Radu RaiceaandGitHub 07cf32bb04 fix(llmo): tool calls are broken for most providers (#299)
* fix(llmo): set the $ai_tools properly for all providers

* fix(llmo): remove privacy mode from $ai_tools

* chore(llmo): bump version

* chore(llmo): run formatter

* fix(llmo): properly set tool calls in $ai_output_choices

* chore(llmo): bump version

* chore(llmo): run formatter

* fix(llmo): fix types error

* feat(llmo): change $ai_output_choices to have an array of content

* chore(llmo): run formatter

* feat(llmo): create text type object

* chore(llmo): update CHANGELOG.md
2025-08-05 14:02:37 -04:00
Phil HaackGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
0076b66b75 feat: Expose get_feature_flag_result method in public API (#284)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-08-05 10:14:29 -07:00
Dylan MartinandGitHub 09dad8117f fix (#300) 2025-08-01 17:32:58 -07:00
89 changed files with 54012 additions and 4119 deletions
+11
View File
@@ -0,0 +1,11 @@
# PostHog API Configuration
# Copy this file to .env and update with your actual values
# Your project API key (found on the /setup page in PostHog)
POSTHOG_PROJECT_API_KEY=phc_your_project_api_key_here
# Your personal API key (for local evaluation and other advanced features)
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here
# PostHog host URL (remove this line if using posthog.com)
POSTHOG_HOST=http://localhost:8000
+36
View File
@@ -0,0 +1,36 @@
version: 2
updates:
- package-ecosystem: "pip"
directory: "/"
schedule:
interval: "daily"
time: "10:00"
timezone: "UTC"
groups:
ai-providers:
patterns:
- "openai"
- "anthropic"
- "google-genai"
- "langchain-core"
- "langchain-community"
- "langchain-openai"
- "langchain-anthropic"
- "langgraph"
allow:
- dependency-name: "openai"
- dependency-name: "anthropic"
- dependency-name: "google-genai"
- dependency-name: "langchain-core"
- dependency-name: "langchain-community"
- dependency-name: "langchain-openai"
- dependency-name: "langchain-anthropic"
- dependency-name: "langgraph"
open-pull-requests-limit: 1
reviewers:
- "PostHog/team-llm-analytics"
# Uncomment below to enable auto-merge for minor updates when CI passes
# pull-request-branch-name:
# separator: "/"
# assignees:
# - "PostHog/ai-team"
+39 -1
View File
@@ -3,6 +3,9 @@ name: CI
on:
- pull_request
permissions:
contents: read
jobs:
code-quality:
name: Code quality checks
@@ -33,6 +36,10 @@ jobs:
run: |
ruff format --check .
- name: Lint with ruff
run: |
ruff check .
- name: Check types with mypy
run: |
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
@@ -42,7 +49,7 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
python-version: ['3.10', '3.11', '3.12', '3.13', '3.14']
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
@@ -68,3 +75,34 @@ jobs:
- name: Run posthog tests
run: |
pytest --verbose --timeout=30
django5-integration:
name: Django 5 integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 1
- name: Set up Python 3.12
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.12
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'integration_tests/django5/pyproject.toml'
- name: Install Django 5 test project dependencies
shell: bash
working-directory: integration_tests/django5
run: |
UV_PROJECT_ENVIRONMENT=$pythonLocation uv sync
- name: Run Django 5 middleware integration tests
working-directory: integration_tests/django5
run: |
uv run pytest test_middleware.py test_exception_capture.py --verbose
+49
View File
@@ -0,0 +1,49 @@
name: "Generate References"
on:
workflow_dispatch:
jobs:
docs-generation:
name: Generate references
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
with:
python-version: 3.11.11
- name: Install uv
uses: astral-sh/setup-uv@0c5e2b8115b80b4c7c5ddf6ffdd634974642d182 # v5.4.1
with:
enable-cache: true
pyproject-file: 'pyproject.toml'
- name: Generate references
run: |
uv run bin/docs generate-references
- name: Check for changes in references
id: changes
run: |
if [ -n "$(git status --porcelain references/)" ]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "New references generated in references directory:"
git status --porcelain references/
else
echo "changed=false" >> $GITHUB_OUTPUT
echo "No new references generated in references directory"
fi
- uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0
if: steps.changes.outputs.changed == 'true'
with:
commit_message: "Update generated references"
file_pattern: references/
@@ -12,15 +12,14 @@ jobs:
release:
name: Publish release
runs-on: ubuntu-latest
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
permissions:
contents: write
id-token: write
steps:
- name: Checkout the repository
uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@8d9ed9ac5c53483de85588cdf95a591a75ab9f55
@@ -40,12 +39,20 @@ jobs:
run: uv sync --extra dev
- name: Push releases to PyPI
env:
TWINE_USERNAME: __token__
run: uv run make release && uv run make release_analytics
- name: Create GitHub release
uses: actions/create-release@0cb9c9b65d5d1901c1f53e5e66eaf4afd303e70e # v1
env:
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh release create "v${{ env.REPO_VERSION }}" \
--title "${{ env.REPO_VERSION }}" \
--generate-notes
- name: Dispatch generate-references for posthog-python
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh workflow run generate-references.yml --ref master
+1
View File
@@ -19,3 +19,4 @@ pyrightconfig.json
.env
.DS_Store
posthog-python-references.json
.claude/settings.local.json
+182
View File
@@ -1,3 +1,185 @@
# 7.5.0 - 2026-01-06
feat: Capture Langchain, OpenAI and Anthropic errors as exceptions (if exception autocapture is enabled)
feat: Add reference to exception in LLMA trace and span events
# 7.4.3 - 2026-01-02
Fixes cache creation cost for Langchain with Anthropic
# 7.4.2 - 2025-12-22
feat: add `in_app_modules` option to control code variables capturing
# 7.4.1 - 2025-12-19
fix: extract model from response for OpenAI stored prompts
When using OpenAI stored prompts, the model is defined in the OpenAI dashboard rather than passed in the API request. This fix adds a fallback to extract the model from the response object when not provided in kwargs, ensuring generations show up with the correct model and enabling cost calculations.
# 7.4.0 - 2025-12-16
feat: Add automatic retries for feature flag requests
Feature flag API requests now automatically retry on transient failures:
- Network errors (connection refused, DNS failures, timeouts)
- Server errors (500, 502, 503, 504)
- Up to 2 retries with exponential backoff (0.5s, 1s delays)
Rate limit (429) and quota (402) errors are not retried.
# 7.3.1 - 2025-12-06
fix: remove unused $exception_message and $exception_type
# 7.3.0 - 2025-12-05
feat: improve code variables capture masking
# 7.2.0 - 2025-12-01
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
# 7.1.0 - 2025-11-26
Add support for the async version of Gemini.
# 7.0.2 - 2025-11-18
Add support for Python 3.14.
Projects upgrading to Python 3.14 should ensure any Pydantic models passed into the SDK use Pydantic v2, as Pydantic v1 is not compatible with Python 3.14.
# 7.0.1 - 2025-11-15
Try to use repr() when formatting code variables
# 7.0.0 - 2025-11-11
NB Python 3.9 is no longer supported
- chore(llma): update LLM provider SDKs to latest major versions
- openai: 1.102.0 → 2.7.1
- anthropic: 0.64.0 → 0.72.0
- google-genai: 1.32.0 → 1.49.0
- langchain-core: 0.3.75 → 1.0.3
- langchain-openai: 0.3.32 → 1.0.2
- langchain-anthropic: 0.3.19 → 1.0.1
- langchain-community: 0.3.29 → 0.4.1
- langgraph: 0.6.6 → 1.0.2
# 6.9.3 - 2025-11-10
- feat(ph-ai): PostHog properties dict in GenerationMetadata
# 6.9.2 - 2025-11-10
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs
# 6.9.1 - 2025-11-07
- fix(error-tracking): pass code variables config from init to client
# 6.9.0 - 2025-11-06
- feat(error-tracking): add local variables capture
# 6.8.0 - 2025-11-03
- feat(llma): send web search calls to be used for LLM cost calculations
# 6.7.14 - 2025-11-03
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior
# 6.7.13 - 2025-11-02
- fix(llma): cache cost calculation in the LangChain callback
# 6.7.12 - 2025-11-02
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
- fix(ai/langchain): Add LangChain 1.0+ compatibility for CallbackHandler imports (fixes #362)
# 6.7.11 - 2025-10-28
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
# 6.7.10 - 2025-10-24
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
# 6.7.9 - 2025-10-22
- fix(flags): multi-condition flags with static cohorts returning wrong variants
# 6.7.8 - 2025-10-16
- fix(llma): missing async for OpenAI's streaming implementation
# 6.7.7 - 2025-10-14
- fix: remove deprecated attribute $exception_personURL from exception events
# 6.7.6 - 2025-09-16
- fix: don't sort condition sets with variant overrides to the top
- fix: Prevent core Client methods from raising exceptions
# 6.7.5 - 2025-09-16
- feat: Django middleware now supports async request handling.
# 6.7.4 - 2025-09-05
- fix: Missing system prompts for some providers
# 6.7.3 - 2025-09-04
- fix: missing usage tokens in Gemini
# 6.7.2 - 2025-09-03
- fix: tool call results in streaming providers
# 6.7.1 - 2025-09-01
- fix: Add base64 inline image sanitization
# 6.7.0 - 2025-08-26
- feat: Add support for feature flag dependencies
# 6.6.1 - 2025-08-21
- fix: Prevent `NoneType` error when `group_properties` is `None`
# 6.6.0 - 2025-08-15
- feat: Add `flag_keys_to_evaluate` parameter to optimize feature flag evaluation performance by only evaluating specified flags
- feat: Add `flag_keys_filter` option to `send_feature_flags` for selective flag evaluation in capture events
# 6.5.0 - 2025-08-08
- feat: Add `$context_tags` to an event to know which properties were included as tags
# 6.4.1 - 2025-08-06
- fix: Always pass project API key in `remote_config` requests for deterministic project routing
# 6.4.0 - 2025-08-05
- feat: support Vertex AI for Gemini
# 6.3.4 - 2025-08-04
- fix: set `$ai_tools` for all providers and `$ai_output_choices` for all non-streaming provider flows properly
# 6.3.3 - 2025-08-01
- fix: `get_feature_flag_result` now correctly returns FeatureFlagResult when payload is empty string instead of None
# 6.3.2 - 2025-07-31
- fix: Anthropic's tool calls are now handled properly
+2 -2
View File
@@ -30,8 +30,8 @@ We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
## PostHog recommends `uv` so...
```bash
uv python install 3.9.19
uv python pin 3.9.19
uv python install 3.12
uv python pin 3.12
uv venv
source env/bin/activate
uv sync --extra dev --extra test
+5 -43
View File
@@ -3,50 +3,11 @@ Constants for PostHog Python SDK documentation generation.
"""
from typing import Dict, Union
# Types that are built-in to Python and don't need to be documented
NO_DOCS_TYPES = [
"Client",
"any",
"int",
"float",
"bool",
"dict",
"list",
"str",
"tuple",
"set",
"frozenset",
"bytes",
"bytearray",
"memoryview",
"range",
"slice",
"complex",
"Union",
"Optional",
"Any",
"Callable",
"Type",
"TypeVar",
"Generic",
"Literal",
"ClassVar",
"Final",
"Annotated",
"NotRequired",
"Required",
"None",
"NoneType",
"object",
"Unpack",
"BaseException",
"Exception",
]
from posthog.version import VERSION
# Documentation generation metadata
DOCUMENTATION_METADATA = {
"hogRef": "0.1",
"hogRef": "0.3",
"slugPrefix": "posthog-python",
"specUrl": "https://github.com/PostHog/posthog-python",
}
@@ -67,8 +28,9 @@ DOCSTRING_PATTERNS = {
# Output file configuration
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
"output_dir": ".",
"filename": "posthog-python-references.json",
"output_dir": "./references",
"filename": f"posthog-python-references-{VERSION}.json",
"filename_latest": "posthog-python-references-latest.json",
"indent": 2,
}
+36 -10
View File
@@ -11,7 +11,6 @@ from dataclasses import is_dataclass, fields
from typing import get_origin, get_args, Union
from textwrap import dedent
from doc_constant import (
NO_DOCS_TYPES,
DOCUMENTATION_METADATA,
DOCSTRING_PATTERNS,
OUTPUT_CONFIG,
@@ -187,7 +186,7 @@ def analyze_parameter(param: inspect.Parameter, docstring: str = "") -> dict:
param_type = get_type_name(type(param.default))
# Extract parameter description from Args section
param_description = f"Parameter: {param.name}"
param_description = ""
if docstring:
# Look for Args section and extract description for this parameter
args_section_match = re.search(
@@ -378,6 +377,14 @@ def generate_sdk_documentation():
except Exception as e:
print(f"Error analyzing type {name}: {e}")
# Clean types of empty types
# Remove types that have no properties and no examples
# Remove types that have no properties and no examples
types_list = [
t for t in types_list if len(t["properties"]) > 0 or t["example"] != ""
]
# Collect classes
classes_list = []
@@ -420,14 +427,28 @@ def generate_sdk_documentation():
}
)
# Collect categories from functions
categories = ["Initialization", "Identification", "Capture"]
seen_categories = set(categories)
for class_info in classes_list:
if "functions" in class_info:
for func in class_info["functions"]:
if (
"category" in func
and func["category"] not in seen_categories
and func["category"]
):
categories.append(func["category"])
seen_categories.add(func["category"])
# Create the final structure
result = {
"id": "posthog-python",
"hogRef": DOCUMENTATION_METADATA["hogRef"],
"info": sdk_info,
"noDocsTypes": NO_DOCS_TYPES,
"types": types_list,
"classes": classes_list,
"categories": categories,
}
return result
@@ -439,12 +460,23 @@ if __name__ == "__main__":
try:
documentation = generate_sdk_documentation()
# Write to file
# Ensure output directory exists
output_dir = str(OUTPUT_CONFIG["output_dir"])
os.makedirs(output_dir, exist_ok=True)
output_file = os.path.join(
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename"])
)
output_file_latest = os.path.join(
str(OUTPUT_CONFIG["output_dir"]), str(OUTPUT_CONFIG["filename_latest"])
)
# Write to current version
with open(output_file, "w") as f:
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
# Write to latest
with open(output_file_latest, "w") as f:
json.dump(documentation, f, indent=int(OUTPUT_CONFIG["indent"]))
print(f"✓ Generated {output_file}")
@@ -459,12 +491,6 @@ if __name__ == "__main__":
print(f"{classes_count} classes documented")
print(f"{total_functions} functions documented")
no_docs = documentation["noDocsTypes"]
if no_docs:
print(
f"{len(no_docs)} types without documentation: {', '.join(no_docs[:5])}{'...' if len(no_docs) > 5 else ''}"
)
except Exception as e:
print(f"❌ Error generating documentation: {e}")
import traceback
+2 -4
View File
@@ -6,9 +6,7 @@ set_source_and_root_dir
ensure_virtual_env
if [[ "$1" == "--check" ]]; then
black --check .
isort --check-only .
ruff format --check .
else
black .
isort .
ruff format .
fi
+478 -145
View File
@@ -1,175 +1,508 @@
# PostHog Python library example
import argparse
#
# This script demonstrates various PostHog Python SDK capabilities including:
# - Basic event capture and user identification
# - Feature flag local evaluation
# - Feature flag payloads
# - Context management and tagging
#
# Setup:
# 1. Copy .env.example to .env and fill in your PostHog credentials
# 2. Run this script and choose from the interactive menu
import os
import posthog
# Add argument parsing
parser = argparse.ArgumentParser(description="PostHog Python library example")
parser.add_argument(
"--flag",
default="person-on-events-enabled",
help="Feature flag key to check (default: person-on-events-enabled)",
)
args = parser.parse_args()
posthog.debug = True
def load_env_file():
"""Load environment variables from .env file if it exists."""
env_path = os.path.join(os.path.dirname(__file__), ".env")
if os.path.exists(env_path):
with open(env_path, "r") as f:
for line in f:
line = line.strip()
if line and not line.startswith("#") and "=" in line:
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip())
# You can find this key on the /setup page in PostHog
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
posthog.host = "http://localhost:8000"
# Load .env file if it exists
load_env_file()
# Get configuration
project_key = os.getenv("POSTHOG_PROJECT_API_KEY", "")
personal_api_key = os.getenv("POSTHOG_PERSONAL_API_KEY", "")
host = os.getenv("POSTHOG_HOST", "http://localhost:8000")
# Check if project key is provided (required)
if not project_key:
print("❌ Missing PostHog project API key!")
print(" Please set POSTHOG_PROJECT_API_KEY environment variable")
print(" or copy .env.example to .env and fill in your values")
exit(1)
# Configure PostHog with credentials
posthog.debug = False
posthog.api_key = project_key
posthog.project_api_key = project_key
posthog.host = host
posthog.poll_interval = 10
print(
posthog.feature_enabled(
args.flag, # Use the flag from command line arguments
"12345",
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
group_properties={
"organization": {
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
"created_at": "2022-06-30 11:44:52.984121+00:00",
}
},
# Check if personal API key is available for local evaluation
local_eval_available = bool(personal_api_key)
if personal_api_key:
posthog.personal_api_key = personal_api_key
print("🔑 PostHog Configuration:")
print(f" Project API Key: {project_key[:9]}...")
if local_eval_available:
print(" Personal API Key: [SET]")
else:
print(" Personal API Key: [NOT SET] - Local evaluation examples will be skipped")
print(f" Host: {host}\n")
# Display menu and get user choice
print("🚀 PostHog Python SDK Demo - Choose an example to run:\n")
print("1. Identify and capture examples")
local_eval_note = "" if local_eval_available else " [requires personal API key]"
print(f"2. Feature flag local evaluation examples{local_eval_note}")
print("3. Feature flag payload examples")
print(f"4. Flag dependencies examples{local_eval_note}")
print("5. Context management and tagging examples")
print("6. Run all examples")
print("7. Exit")
choice = input("\nEnter your choice (1-7): ").strip()
if choice == "1":
print("\n" + "=" * 60)
print("IDENTIFY AND CAPTURE EXAMPLES")
print("=" * 60)
posthog.debug = True
# Capture an event
print("📊 Capturing events...")
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
# Alias a previous distinct id with a new one
print("🔗 Creating alias...")
posthog.alias("distinct_id", "new_distinct_id")
posthog.capture(
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
posthog.capture(
"event-with-groups",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
groups={"company": "id:5"},
)
# Add properties to the person
print("👤 Identifying user...")
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Add properties to a group
print("🏢 Identifying group...")
posthog.group_identify("company", "id:5", {"employees": 11})
# Properties set only once to the person
print("🔒 Setting properties once...")
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": True}
)
# This will not change the property (because it was already set)
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
)
print("🔄 Updating properties...")
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
)
elif choice == "2":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
posthog.shutdown()
exit(1)
print("\n" + "=" * 60)
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
print("=" * 60)
posthog.debug = True
print("🏁 Testing basic feature flags...")
print(
f"beta-feature for 'distinct_id': {posthog.feature_enabled('beta-feature', 'distinct_id')}"
)
print(
f"beta-feature for 'new_distinct_id': {posthog.feature_enabled('beta-feature', 'new_distinct_id')}"
)
print(
f"beta-feature with groups: {posthog.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
)
print("\n🌍 Testing location-based flags...")
# Assume test-flag has `City Name = Sydney` as a person property set
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
print(
f"Sydney user (local only): {posthog.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
)
print("\n📋 Getting all flags...")
print(f"All flags: {posthog.get_all_flags('distinct_id_random_22')}")
print(
f"All flags (local): {posthog.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
)
print(
f"All flags with properties: {posthog.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
)
elif choice == "3":
print("\n" + "=" * 60)
print("FEATURE FLAG PAYLOAD EXAMPLES")
print("=" * 60)
posthog.debug = True
print("📦 Testing feature flag payloads...")
print(
f"beta-feature payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}"
)
print(
f"All flags and payloads: {posthog.get_all_flags_and_payloads('distinct_id')}"
)
print(
f"Remote config payload: {posthog.get_remote_config_payload('encrypted_payload_flag_key')}"
)
# Get feature flag result with all details (enabled, variant, payload, key, reason)
print("\n🔍 Getting detailed flag result...")
result = posthog.get_feature_flag_result("beta-feature", "distinct_id")
if result:
print(f"Flag key: {result.key}")
print(f"Flag enabled: {result.enabled}")
print(f"Variant: {result.variant}")
print(f"Payload: {result.payload}")
print(f"Reason: {result.reason}")
# get_value() returns the variant if it exists, otherwise the enabled value
print(f"Value (variant or enabled): {result.get_value()}")
elif choice == "4":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
posthog.shutdown()
exit(1)
print("\n" + "=" * 60)
print("FLAG DEPENDENCIES EXAMPLES")
print("=" * 60)
print("🔗 Testing flag dependencies with local evaluation...")
print(
" Flag structure: 'test-flag-dependency' depends on 'beta-feature' being enabled"
)
print("")
print("📋 Required setup (if 'test-flag-dependency' doesn't exist):")
print(" 1. Create feature flag 'beta-feature':")
print(" - Condition: email contains '@example.com'")
print(" - Rollout: 100%")
print(" 2. Create feature flag 'test-flag-dependency':")
print(" - Condition: flag 'beta-feature' is enabled")
print(" - Rollout: 100%")
print("")
posthog.debug = True
# Test @example.com user (should satisfy dependency if flags exist)
result1 = posthog.feature_enabled(
"test-flag-dependency",
"example_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
)
print(f"✅ @example.com user (test-flag-dependency): {result1}")
# Capture an event
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
print(
posthog.feature_enabled(
"beta-feature-groups", "distinct_id", groups={"company": "id:5"}
)
)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
# get payload
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
print(posthog.get_all_flags_and_payloads("distinct_id"))
exit()
# # Alias a previous distinct id with a new one
posthog.alias("distinct_id", "new_distinct_id")
posthog.capture(
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
posthog.capture(
"event-with-groups",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
groups={"company": "id:5"},
)
# # Add properties to the person
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Add properties to a group
posthog.group_identify("company", "id:5", {"employees": 11})
# properties set only once to the person
posthog.set_once(distinct_id="new_distinct_id", properties={"self_serve_signup": True})
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
) # this will not change the property (because it was already set)
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Firefox"})
# #############################################################################
# Make sure you have a personal API key for the examples below
# Local Evaluation
# If flag has City=Sydney, this call doesn't go to `/decide`
print(
posthog.feature_enabled(
"test-flag",
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
)
)
print(
posthog.feature_enabled(
"test-flag",
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
# Test non-example.com user (dependency should not be satisfied)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"regular_user",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
)
print(f"❌ Regular user (test-flag-dependency): {result2}")
print(posthog.get_all_flags("distinct_id_random_22"))
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
print(
posthog.get_all_flags(
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
# Test beta-feature directly for comparison
beta1 = posthog.feature_enabled(
"beta-feature",
"example_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
)
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
beta2 = posthog.feature_enabled(
"beta-feature",
"regular_user",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"📊 Beta feature comparison - @example.com: {beta1}, regular: {beta2}")
print("\n🎯 Results Summary:")
print(
f" - Flag dependencies evaluated locally: {'✅ YES' if result1 != result2 else '❌ NO'}"
)
print(" - Zero API calls needed: ✅ YES (all evaluated locally)")
print(" - Python SDK supports flag dependencies: ✅ YES")
# You can add tags to a context, and these are automatically added to any events (including exceptions) captured
# within that context.
print("\n" + "-" * 60)
print("PRODUCTION-STYLE MULTIVARIATE DEPENDENCY CHAIN")
print("-" * 60)
print("🔗 Testing complex multivariate flag dependencies...")
print(
" Structure: multivariate-root-flag -> multivariate-intermediate-flag -> multivariate-leaf-flag"
)
print("")
print("📋 Required setup (if flags don't exist):")
print(
" 1. Create 'multivariate-leaf-flag' with fruit variants (pineapple, mango, papaya, kiwi)"
)
print(" - pineapple: email = 'pineapple@example.com'")
print(" - mango: email = 'mango@example.com'")
print(
" 2. Create 'multivariate-intermediate-flag' with color variants (blue, red)"
)
print(" - blue: depends on multivariate-leaf-flag = 'pineapple'")
print(" - red: depends on multivariate-leaf-flag = 'mango'")
print(
" 3. Create 'multivariate-root-flag' with show variants (breaking-bad, the-wire)"
)
print(" - breaking-bad: depends on multivariate-intermediate-flag = 'blue'")
print(" - the-wire: depends on multivariate-intermediate-flag = 'red'")
print("")
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
# the new context inherits tags from the parent context.
with posthog.new_context():
posthog.tag("transaction_id", "abc123")
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
# Test pineapple -> blue -> breaking-bad chain
dependent_result3 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "pineapple@example.com"},
only_evaluate_locally=True,
)
if str(dependent_result3) != "breaking-bad":
print(
f" ❌ Something went wrong evaluating 'multivariate-root-flag' with pineapple@example.com. Expected 'breaking-bad', got '{dependent_result3}'"
)
else:
print("'multivariate-root-flag' with email pineapple@example.com succeeded")
# This event will be captured with the tags set above
posthog.capture("order_processed")
# This exception will be captured with the tags set above
raise Exception("Order processing failed")
# Test mango -> red -> the-wire chain
dependent_result4 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "mango@example.com"},
only_evaluate_locally=True,
)
if str(dependent_result4) != "the-wire":
print(
f" ❌ Something went wrong evaluating multivariate-root-flag with mango@example.com. Expected 'the-wire', got '{dependent_result4}'"
)
else:
print("'multivariate-root-flag' with email mango@example.com succeeded")
# Show the complete chain evaluation
print("\n🔍 Complete dependency chain evaluation:")
for email, expected_chain in [
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
("mango@example.com", ["mango", "red", "the-wire"]),
]:
leaf = posthog.get_feature_flag(
"multivariate-leaf-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
intermediate = posthog.get_feature_flag(
"multivariate-intermediate-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
root = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
# Use fresh=True to start with a clean context (no inherited tags)
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
raise Exception("Session handling failed")
actual_chain = [str(leaf), str(intermediate), str(root)]
chain_success = actual_chain == expected_chain
print(f" 📧 {email}:")
print(f" Expected: {' -> '.join(map(str, expected_chain))}")
print(f" Actual: {' -> '.join(map(str, actual_chain))}")
print(f" Status: {'✅ SUCCESS' if chain_success else '❌ FAILED'}")
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@posthog.scoped()
def process_order(order_id):
posthog.tag("order_id", order_id)
# Exception will be captured and tagged automatically
raise Exception("Order processing failed")
print("\n🎯 Multivariate Chain Summary:")
print(" - Complex dependency chains: ✅ SUPPORTED")
print(" - Multivariate flag dependencies: ✅ SUPPORTED")
print(" - Local evaluation of chains: ✅ WORKING")
elif choice == "5":
print("\n" + "=" * 60)
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
print("=" * 60)
# Use fresh=True to start with a clean context (no inherited tags)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
# Only payment_id tag will be present, no inherited tags
raise Exception("Payment processing failed")
posthog.debug = True
print("🏷️ Testing context management...")
print(
"You can add tags to a context, and these are automatically added to any events captured within that context."
)
# You can enter a new context using a with statement. Any exceptions thrown in the context will be captured,
# and tagged with the context tags. Other events captured will also be tagged with the context tags. By default,
# the new context inherits tags from the parent context.
try:
with posthog.new_context():
posthog.tag("transaction_id", "abc123")
posthog.tag("some_arbitrary_value", {"tags": "can be dicts"})
# This event will be captured with the tags set above
posthog.capture("order_processed")
print("✅ Event captured with inherited context tags")
# This exception will be captured with the tags set above
# raise Exception("Order processing failed")
except Exception as e:
print(f"Exception captured: {e}")
# Use fresh=True to start with a clean context (no inherited tags)
try:
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
posthog.capture("session_event")
print("✅ Event captured with fresh context tags")
# raise Exception("Session handling failed")
except Exception as e:
print(f"Exception captured: {e}")
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@posthog.scoped()
def process_order(order_id):
posthog.tag("order_id", order_id)
posthog.capture("order_step_completed")
print(f"✅ Order {order_id} processed with scoped context")
# Exception will be captured and tagged automatically
# raise Exception("Order processing failed")
# Use fresh=True to start with a clean context (no inherited tags)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
posthog.tag("payment_id", payment_id)
posthog.capture("payment_processed")
print(f"✅ Payment {payment_id} processed with fresh scoped context")
# Only payment_id tag will be present, no inherited tags
# raise Exception("Payment processing failed")
process_order("12345")
process_payment("67890")
elif choice == "6":
print("\n🔄 Running all examples...")
if not local_eval_available:
print(" (Skipping local evaluation examples - no personal API key set)\n")
# Run example 1
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
posthog.debug = True
print("📊 Capturing events...")
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
print("🔗 Creating alias...")
posthog.alias("distinct_id", "new_distinct_id")
print("👤 Identifying user...")
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Run example 2 (requires local evaluation)
if local_eval_available:
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
# Run example 3
print(f"\n{'🔸' * 20} PAYLOADS {'🔸' * 20}")
print("📦 Testing payloads...")
print(f"Payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}")
# Run example 4 (requires local evaluation)
if local_eval_available:
print(f"\n{'🔸' * 20} FLAG DEPENDENCIES {'🔸' * 20}")
print("🔗 Testing flag dependencies...")
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
only_evaluate_locally=True,
)
print(f"✅ @example.com user: {result1}, regular user: {result2}")
# Run example 5
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
print("🏷️ Testing context management...")
with posthog.new_context():
posthog.tag("demo_run", "all_examples")
posthog.capture("demo_completed")
print("✅ Demo completed with context tags")
elif choice == "7":
print("👋 Goodbye!")
posthog.shutdown()
exit()
else:
print("❌ Invalid choice. Please run again and select 1-7.")
posthog.shutdown()
exit()
print("\n" + "=" * 60)
print("✅ Example completed!")
print("=" * 60)
posthog.shutdown()
+144
View File
@@ -0,0 +1,144 @@
"""
Redis-based distributed cache for PostHog feature flag definitions.
This example demonstrates how to implement a FlagDefinitionCacheProvider
using Redis for multi-instance deployments (leader election pattern).
Usage:
import redis
from posthog import Posthog
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
cache = RedisFlagCache(redis_client, service_key="my-service")
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
flag_definition_cache_provider=cache,
)
Requirements:
pip install redis
"""
import json
import uuid
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
from redis import Redis
from typing import Optional
class RedisFlagCache(FlagDefinitionCacheProvider):
"""
A distributed cache for PostHog feature flag definitions using Redis.
In a multi-instance deployment (e.g., multiple serverless functions or containers),
we want only ONE instance to poll PostHog for flag updates, while all instances
share the cached results. This prevents N instances from making N redundant API calls.
The implementation uses leader election:
- One instance "wins" and becomes responsible for fetching
- Other instances read from the shared cache
- If the leader dies, the lock expires (TTL) and another instance takes over
Uses Lua scripts for atomic operations, following Redis distributed lock best practices:
https://redis.io/docs/latest/develop/clients/patterns/distributed-locks/
"""
LOCK_TTL_MS = 60 * 1000 # 60 seconds, should be longer than the flags poll interval
CACHE_TTL_SECONDS = 60 * 60 * 24 # 24 hours
# Lua script: acquire lock if free, or extend if we own it
_LUA_TRY_LEAD = """
local current = redis.call('GET', KEYS[1])
if current == false then
redis.call('SET', KEYS[1], ARGV[1], 'PX', ARGV[2])
return 1
elseif current == ARGV[1] then
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1
end
return 0
"""
# Lua script: release lock only if we own it
_LUA_STOP_LEAD = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
def __init__(self, redis: Redis[str], service_key: str):
"""
Initialize the Redis flag cache.
Args:
redis: A redis-py client instance. Must be configured with
decode_responses=True for correct string handling.
service_key: A unique identifier for this service/environment.
Used to scope Redis keys, allowing multiple services
or environments to share the same Redis instance.
Examples: "my-api-prod", "checkout-service", "staging".
Redis Keys Created:
- posthog:flags:{service_key} - Cached flag definitions (JSON)
- posthog:flags:{service_key}:lock - Leader election lock
Example:
redis_client = redis.Redis(
host='localhost',
port=6379,
decode_responses=True
)
cache = RedisFlagCache(redis_client, service_key="my-api-prod")
"""
self._redis = redis
self._cache_key = f"posthog:flags:{service_key}"
self._lock_key = f"posthog:flags:{service_key}:lock"
self._instance_id = str(uuid.uuid4())
self._try_lead = self._redis.register_script(self._LUA_TRY_LEAD)
self._stop_lead = self._redis.register_script(self._LUA_STOP_LEAD)
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
"""
Retrieve cached flag definitions from Redis.
Returns:
Cached flag definitions if available, None otherwise.
"""
cached = self._redis.get(self._cache_key)
return json.loads(cached) if cached else None
def should_fetch_flag_definitions(self) -> bool:
"""
Determines if this instance should fetch flag definitions from PostHog.
Atomically either:
- Acquires the lock if no one holds it, OR
- Extends the lock TTL if we already hold it
Returns:
True if this instance is the leader and should fetch, False otherwise.
"""
result = self._try_lead(
keys=[self._lock_key],
args=[self._instance_id, self.LOCK_TTL_MS],
)
return result == 1
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
"""
Store fetched flag definitions in Redis.
Args:
data: The flag definitions to cache.
"""
self._redis.set(self._cache_key, json.dumps(data), ex=self.CACHE_TTL_SECONDS)
def shutdown(self) -> None:
"""
Release leadership if we hold it. Safe to call even if not the leader.
"""
self._stop_lead(keys=[self._lock_key], args=[self._instance_id])
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env python3
"""
Simple test script for PostHog remote config endpoint.
"""
import posthog
# Initialize PostHog client
posthog.api_key = "phc_..."
posthog.personal_api_key = "phs_..." # or "phx_..."
posthog.host = "http://localhost:8000" # or "https://us.posthog.com"
posthog.debug = True
def test_remote_config():
"""Test remote config payload retrieval."""
print("Testing remote config endpoint...")
# Test feature flag key - replace with an actual flag key from your project
flag_key = "unencrypted-remote-config-setting"
try:
# Get remote config payload
payload = posthog.get_remote_config_payload(flag_key)
print(f"✅ Success! Remote config payload for '{flag_key}': {payload}")
except Exception as e:
print(f"❌ Error getting remote config: {e}")
if __name__ == "__main__":
test_remote_config()
+4
View File
@@ -0,0 +1,4 @@
db.sqlite3
*.pyc
__pycache__/
.pytest_cache/
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "test-django5"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"django~=5.2.7",
"uvicorn[standard]~=0.38.0",
"posthog",
"pytest~=8.4.2",
"pytest-asyncio~=1.2.0",
"pytest-django~=4.11.1",
"httpx~=0.28.1",
]
[tool.uv]
required-version = ">=0.5"
[tool.uv.sources]
posthog = { path = "../..", editable = true }
@@ -0,0 +1,111 @@
"""
Test that verifies exception capture functionality.
These tests verify that exceptions are actually captured to PostHog, not just that
500 responses are returned.
Without process_exception(), view exceptions are NOT captured to PostHog (v6.7.11 and earlier).
With process_exception(), Django calls this method to capture exceptions before
converting them to 500 responses.
"""
import os
import django
# Setup Django before importing anything else
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
django.setup()
import pytest # noqa: E402
from httpx import AsyncClient, ASGITransport # noqa: E402
from django.core.asgi import get_asgi_application # noqa: E402
@pytest.fixture(scope="session")
def asgi_app():
"""Shared ASGI application for all tests."""
return get_asgi_application()
@pytest.mark.asyncio
async def test_async_exception_is_captured(asgi_app):
"""
Test that async view exceptions are captured to PostHog.
The middleware's process_exception() method ensures exceptions are captured.
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
"""
from unittest.mock import patch
# Track captured exceptions
captured = []
def mock_capture(exception, **kwargs):
"""Mock capture_exception to record calls."""
captured.append(
{
"exception": exception,
"type": type(exception).__name__,
"message": str(exception),
}
)
# Patch at the posthog module level where middleware imports from
with patch("posthog.capture_exception", side_effect=mock_capture):
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/async-exception")
# Django returns 500
assert response.status_code == 500
# CRITICAL: Verify PostHog captured the exception
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
# Verify it's the right exception
exception_data = captured[0]
assert exception_data["type"] == "ValueError"
assert "Test exception from Django 5 async view" in exception_data["message"]
@pytest.mark.asyncio
async def test_sync_exception_is_captured(asgi_app):
"""
Test that sync view exceptions are captured to PostHog.
The middleware's process_exception() method ensures exceptions are captured.
Without it (v6.7.11 and earlier), exceptions are NOT captured even though 500 is returned.
"""
from unittest.mock import patch
# Track captured exceptions
captured = []
def mock_capture(exception, **kwargs):
"""Mock capture_exception to record calls."""
captured.append(
{
"exception": exception,
"type": type(exception).__name__,
"message": str(exception),
}
)
# Patch at the posthog module level where middleware imports from
with patch("posthog.capture_exception", side_effect=mock_capture):
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/sync-exception")
# Django returns 500
assert response.status_code == 500
# CRITICAL: Verify PostHog captured the exception
assert len(captured) > 0, "Exception was NOT captured to PostHog!"
# Verify it's the right exception
exception_data = captured[0]
assert exception_data["type"] == "ValueError"
assert "Test exception from Django 5 sync view" in exception_data["message"]
@@ -0,0 +1,170 @@
"""
Tests for PostHog Django middleware in async context.
These tests verify that the middleware correctly handles:
1. Async user access (request.auser() in Django 5)
2. Exception capture in both sync and async views
3. No SynchronousOnlyOperation errors in async context
Tests run directly against the ASGI application without needing a server.
"""
import os
import django
# Setup Django before importing anything else
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
django.setup()
import pytest # noqa: E402
from httpx import AsyncClient, ASGITransport # noqa: E402
from django.core.asgi import get_asgi_application # noqa: E402
@pytest.fixture(scope="session")
def asgi_app():
"""Shared ASGI application for all tests."""
return get_asgi_application()
@pytest.mark.asyncio
async def test_async_user_access(asgi_app):
"""
Test that middleware can access request.user in async context.
In Django 5, this requires using await request.auser() instead of request.user
to avoid SynchronousOnlyOperation error.
Without authentication, request.user is AnonymousUser which doesn't
trigger the lazy loading bug. This test verifies the middleware works
in the common case.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/async-user")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert "django_version" in data
@pytest.mark.django_db(transaction=True)
@pytest.mark.asyncio
async def test_async_authenticated_user_access(asgi_app):
"""
Test that middleware can access an authenticated user in async context.
This is the critical test that triggers the SynchronousOnlyOperation bug
in v6.7.11. When AuthenticationMiddleware sets request.user to a
SimpleLazyObject wrapping a database query, accessing user.pk or user.email
in async context causes the error.
In v6.7.11, extract_request_user() does getattr(user, "is_authenticated", False)
which triggers the lazy object evaluation synchronously.
The fix uses await request.auser() instead to avoid this.
"""
from django.contrib.auth import get_user_model
from django.test import Client
from asgiref.sync import sync_to_async
from django.test import override_settings
# Create a test user (must use sync_to_async since we're in async test)
User = get_user_model()
@sync_to_async
def create_or_get_user():
user, created = User.objects.get_or_create(
username="testuser",
defaults={
"email": "test@example.com",
},
)
if created:
user.set_password("testpass123")
user.save()
return user
user = await create_or_get_user()
# Create a session with authenticated user (sync operation)
@sync_to_async
def create_session():
client = Client()
client.force_login(user)
return client.cookies.get("sessionid")
session_cookie = await create_session()
if not session_cookie:
pytest.skip("Could not create authenticated session")
# Make request with session cookie - this should trigger the bug in v6.7.11
# Disable exception capture to see the SynchronousOnlyOperation clearly
with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False):
async with AsyncClient(
transport=ASGITransport(app=asgi_app),
base_url="http://testserver",
cookies={"sessionid": session_cookie.value},
) as ac:
response = await ac.get("/test/async-user")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
assert data["user_authenticated"]
@pytest.mark.asyncio
async def test_sync_user_access(asgi_app):
"""
Test that middleware works with sync views.
This should always work regardless of middleware version.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/sync-user")
assert response.status_code == 200
data = response.json()
assert data["status"] == "success"
@pytest.mark.asyncio
async def test_async_exception_capture(asgi_app):
"""
Test that middleware handles exceptions from async views.
The middleware's process_exception() method captures view exceptions to PostHog
before Django converts them to 500 responses. This test verifies the exception
causes a 500 response. See test_exception_capture.py for tests that verify
actual exception capture to PostHog.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/async-exception")
# Django returns 500 for unhandled exceptions
assert response.status_code == 500
@pytest.mark.asyncio
async def test_sync_exception_capture(asgi_app):
"""
Test that middleware handles exceptions from sync views.
The middleware's process_exception() method captures view exceptions to PostHog.
This test verifies the exception causes a 500 response.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
) as ac:
response = await ac.get("/test/sync-exception")
# Django returns 500 for unhandled exceptions
assert response.status_code == 500
@@ -0,0 +1,16 @@
"""
ASGI config for testdjango project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
application = get_asgi_application()
@@ -0,0 +1,129 @@
"""
Django settings for testdjango project.
Generated by 'django-admin startproject' using Django 5.2.7.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.2/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-q5(&wfw@_lb)noyowbfl$2ls8c82hl__0f9s5(mohlh2)aas#3"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ["*"]
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"posthog.integrations.django.PosthogContextMiddleware", # Test PostHog middleware
]
ROOT_URLCONF = "testdjango.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "testdjango.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.2/howto/static-files/
STATIC_URL = "static/"
# Default primary key field type
# https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# PostHog settings for testing
POSTHOG_API_KEY = "test-key"
POSTHOG_HOST = "https://app.posthog.com"
POSTHOG_MW_CAPTURE_EXCEPTIONS = True
@@ -0,0 +1,28 @@
"""
URL configuration for testdjango project.
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/5.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
from testdjango import views
urlpatterns = [
path("admin/", admin.site.urls),
path("test/async-user", views.test_async_user),
path("test/sync-user", views.test_sync_user),
path("test/async-exception", views.test_async_exception),
path("test/sync-exception", views.test_sync_exception),
]
@@ -0,0 +1,50 @@
"""
Test views for validating PostHog middleware with Django 5 ASGI.
"""
from django.http import JsonResponse
async def test_async_user(request):
"""
Async view that tests middleware with request.user access.
The middleware will access request.user (SimpleLazyObject) via auser()
in async context. Without the fix, this causes SynchronousOnlyOperation.
"""
# The middleware has already accessed request.user via auser()
# If we got here, the fix works!
user = await request.auser()
return JsonResponse(
{
"status": "success",
"message": "Django 5 async middleware test passed!",
"django_version": "5.x",
"user_authenticated": user.is_authenticated if user else False,
"note": "Middleware used await request.auser() successfully",
}
)
def test_sync_user(request):
"""Sync view for comparison."""
return JsonResponse(
{
"status": "success",
"message": "Sync view works",
"user_authenticated": request.user.is_authenticated
if hasattr(request, "user")
else False,
}
)
async def test_async_exception(request):
"""Async view that raises an exception for testing exception capture."""
raise ValueError("Test exception from Django 5 async view")
def test_sync_exception(request):
"""Sync view that raises an exception for testing exception capture."""
raise ValueError("Test exception from Django 5 sync view")
@@ -0,0 +1,16 @@
"""
WSGI config for testdjango project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "testdjango.settings")
application = get_wsgi_application()
+674
View File
@@ -0,0 +1,674 @@
version = 1
revision = 3
requires-python = ">=3.12"
[[package]]
name = "anyio"
version = "4.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
]
[[package]]
name = "asgiref"
version = "3.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/46/08/4dfec9b90758a59acc6be32ac82e98d1fbfc321cb5cfa410436dbacf821c/asgiref-3.10.0.tar.gz", hash = "sha256:d89f2d8cd8b56dada7d52fa7dc8075baa08fb836560710d38c292a7a3f78c04e", size = 37483, upload-time = "2025-10-05T09:15:06.557Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/9c/fc2331f538fbf7eedba64b2052e99ccf9ba9d6888e2f41441ee28847004b/asgiref-3.10.0-py3-none-any.whl", hash = "sha256:aef8a81283a34d0ab31630c9b7dfe70c812c95eba78171367ca8745e88124734", size = 24050, upload-time = "2025-10-05T09:15:05.11Z" },
]
[[package]]
name = "backoff"
version = "2.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/47/d7/5bbeb12c44d7c4f2fb5b56abce497eb5ed9f34d85701de869acedd602619/backoff-2.2.1.tar.gz", hash = "sha256:03f829f5bb1923180821643f8753b0502c3b682293992485b0eef2807afa5cba", size = 17001, upload-time = "2022-10-05T19:19:32.061Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/73/b6e24bd22e6720ca8ee9a85a0c4a2971af8497d8f3193fa05390cbd46e09/backoff-2.2.1-py3-none-any.whl", hash = "sha256:63579f9a0628e06278f7e47b7d7d5b6ce20dc65c5e96a6f3ca99a6adca0396e8", size = 15148, upload-time = "2022-10-05T19:19:30.546Z" },
]
[[package]]
name = "certifi"
version = "2025.10.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/85/1637cd4af66fa687396e757dec650f28025f2a2f5a5531a3208dc0ec43f2/charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394", size = 208425, upload-time = "2025-10-14T04:40:53.353Z" },
{ url = "https://files.pythonhosted.org/packages/9d/6a/04130023fef2a0d9c62d0bae2649b69f7b7d8d24ea5536feef50551029df/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25", size = 148162, upload-time = "2025-10-14T04:40:54.558Z" },
{ url = "https://files.pythonhosted.org/packages/78/29/62328d79aa60da22c9e0b9a66539feae06ca0f5a4171ac4f7dc285b83688/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef", size = 144558, upload-time = "2025-10-14T04:40:55.677Z" },
{ url = "https://files.pythonhosted.org/packages/86/bb/b32194a4bf15b88403537c2e120b817c61cd4ecffa9b6876e941c3ee38fe/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d", size = 161497, upload-time = "2025-10-14T04:40:57.217Z" },
{ url = "https://files.pythonhosted.org/packages/19/89/a54c82b253d5b9b111dc74aca196ba5ccfcca8242d0fb64146d4d3183ff1/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8", size = 159240, upload-time = "2025-10-14T04:40:58.358Z" },
{ url = "https://files.pythonhosted.org/packages/c0/10/d20b513afe03acc89ec33948320a5544d31f21b05368436d580dec4e234d/charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86", size = 153471, upload-time = "2025-10-14T04:40:59.468Z" },
{ url = "https://files.pythonhosted.org/packages/61/fa/fbf177b55bdd727010f9c0a3c49eefa1d10f960e5f09d1d887bf93c2e698/charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a", size = 150864, upload-time = "2025-10-14T04:41:00.623Z" },
{ url = "https://files.pythonhosted.org/packages/05/12/9fbc6a4d39c0198adeebbde20b619790e9236557ca59fc40e0e3cebe6f40/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f", size = 150647, upload-time = "2025-10-14T04:41:01.754Z" },
{ url = "https://files.pythonhosted.org/packages/ad/1f/6a9a593d52e3e8c5d2b167daf8c6b968808efb57ef4c210acb907c365bc4/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc", size = 145110, upload-time = "2025-10-14T04:41:03.231Z" },
{ url = "https://files.pythonhosted.org/packages/30/42/9a52c609e72471b0fc54386dc63c3781a387bb4fe61c20231a4ebcd58bdd/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf", size = 162839, upload-time = "2025-10-14T04:41:04.715Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5b/c0682bbf9f11597073052628ddd38344a3d673fda35a36773f7d19344b23/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15", size = 150667, upload-time = "2025-10-14T04:41:05.827Z" },
{ url = "https://files.pythonhosted.org/packages/e4/24/a41afeab6f990cf2daf6cb8c67419b63b48cf518e4f56022230840c9bfb2/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9", size = 160535, upload-time = "2025-10-14T04:41:06.938Z" },
{ url = "https://files.pythonhosted.org/packages/2a/e5/6a4ce77ed243c4a50a1fecca6aaaab419628c818a49434be428fe24c9957/charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0", size = 154816, upload-time = "2025-10-14T04:41:08.101Z" },
{ url = "https://files.pythonhosted.org/packages/a8/ef/89297262b8092b312d29cdb2517cb1237e51db8ecef2e9af5edbe7b683b1/charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26", size = 99694, upload-time = "2025-10-14T04:41:09.23Z" },
{ url = "https://files.pythonhosted.org/packages/3d/2d/1e5ed9dd3b3803994c155cd9aacb60c82c331bad84daf75bcb9c91b3295e/charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525", size = 107131, upload-time = "2025-10-14T04:41:10.467Z" },
{ url = "https://files.pythonhosted.org/packages/d0/d9/0ed4c7098a861482a7b6a95603edce4c0d9db2311af23da1fb2b75ec26fc/charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3", size = 100390, upload-time = "2025-10-14T04:41:11.915Z" },
{ url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" },
{ url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" },
{ url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" },
{ url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" },
{ url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" },
{ url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" },
{ url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" },
{ url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" },
{ url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" },
{ url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" },
{ url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" },
{ url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" },
{ url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" },
{ url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" },
{ url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" },
{ url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" },
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
]
[[package]]
name = "click"
version = "8.3.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "distro"
version = "1.9.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fc/f8/98eea607f65de6527f8a2e8885fc8015d3e6f5775df186e443e0964a11c3/distro-1.9.0.tar.gz", hash = "sha256:2fa77c6fd8940f116ee1d6b94a2f90b13b5ea8d019b98bc8bafdcabcdd9bdbed", size = 60722, upload-time = "2023-12-24T09:54:32.31Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" },
]
[[package]]
name = "django"
version = "5.2.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "asgiref" },
{ name = "sqlparse" },
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/96/bd84e2bb997994de8bcda47ae4560991084e86536541d7214393880f01a8/django-5.2.7.tar.gz", hash = "sha256:e0f6f12e2551b1716a95a63a1366ca91bbcd7be059862c1b18f989b1da356cdd", size = 10865812, upload-time = "2025-10-01T14:22:12.081Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/53/7f/403e5d787dc4942316e515e949b0c8a013d84078a915910e9f391ba9b3ed/httptools-0.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:38e0c83a2ea9746ebbd643bdfb521b9aa4a91703e2cd705c20443405d2fd16a5", size = 206280, upload-time = "2025-10-10T03:54:39.274Z" },
{ url = "https://files.pythonhosted.org/packages/2a/0d/7f3fd28e2ce311ccc998c388dd1c53b18120fda3b70ebb022b135dc9839b/httptools-0.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f25bbaf1235e27704f1a7b86cd3304eabc04f569c828101d94a0e605ef7205a5", size = 110004, upload-time = "2025-10-10T03:54:40.403Z" },
{ url = "https://files.pythonhosted.org/packages/84/a6/b3965e1e146ef5762870bbe76117876ceba51a201e18cc31f5703e454596/httptools-0.7.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c15f37ef679ab9ecc06bfc4e6e8628c32a8e4b305459de7cf6785acd57e4d03", size = 517655, upload-time = "2025-10-10T03:54:41.347Z" },
{ url = "https://files.pythonhosted.org/packages/11/7d/71fee6f1844e6fa378f2eddde6c3e41ce3a1fb4b2d81118dd544e3441ec0/httptools-0.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7fe6e96090df46b36ccfaf746f03034e5ab723162bc51b0a4cf58305324036f2", size = 511440, upload-time = "2025-10-10T03:54:42.452Z" },
{ url = "https://files.pythonhosted.org/packages/22/a5/079d216712a4f3ffa24af4a0381b108aa9c45b7a5cc6eb141f81726b1823/httptools-0.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f72fdbae2dbc6e68b8239defb48e6a5937b12218e6ffc2c7846cc37befa84362", size = 495186, upload-time = "2025-10-10T03:54:43.937Z" },
{ url = "https://files.pythonhosted.org/packages/e9/9e/025ad7b65278745dee3bd0ebf9314934c4592560878308a6121f7f812084/httptools-0.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e99c7b90a29fd82fea9ef57943d501a16f3404d7b9ee81799d41639bdaae412c", size = 499192, upload-time = "2025-10-10T03:54:45.003Z" },
{ url = "https://files.pythonhosted.org/packages/6d/de/40a8f202b987d43afc4d54689600ff03ce65680ede2f31df348d7f368b8f/httptools-0.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:3e14f530fefa7499334a79b0cf7e7cd2992870eb893526fb097d51b4f2d0f321", size = 86694, upload-time = "2025-10-10T03:54:45.923Z" },
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.11"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "packaging"
version = "25.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "posthog"
source = { editable = "../" }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "six" },
{ name = "typing-extensions" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", marker = "extra == 'test'" },
{ name = "backoff", specifier = ">=1.10.0" },
{ name = "coverage", marker = "extra == 'test'" },
{ name = "distro", specifier = ">=1.5.0" },
{ name = "django", marker = "extra == 'test'" },
{ name = "django-stubs", marker = "extra == 'dev'" },
{ name = "freezegun", marker = "extra == 'test'", specifier = "==1.5.1" },
{ name = "google-genai", marker = "extra == 'test'" },
{ name = "langchain", marker = "extra == 'langchain'", specifier = ">=0.2.0" },
{ name = "langchain-anthropic", marker = "extra == 'test'", specifier = ">=0.3.15" },
{ name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.3.25" },
{ name = "langchain-core", marker = "extra == 'test'", specifier = ">=0.3.65" },
{ name = "langchain-openai", marker = "extra == 'test'", specifier = ">=0.3.22" },
{ name = "langgraph", marker = "extra == 'test'", specifier = ">=0.4.8" },
{ name = "lxml", marker = "extra == 'dev'" },
{ name = "mock", marker = "extra == 'test'", specifier = ">=2.0.0" },
{ name = "mypy", marker = "extra == 'dev'" },
{ name = "mypy-baseline", marker = "extra == 'dev'" },
{ name = "openai", marker = "extra == 'test'" },
{ name = "packaging", marker = "extra == 'dev'" },
{ name = "parameterized", marker = "extra == 'test'", specifier = ">=0.8.1" },
{ name = "pre-commit", marker = "extra == 'dev'" },
{ name = "pydantic", marker = "extra == 'dev'" },
{ name = "pydantic", marker = "extra == 'test'" },
{ name = "pytest", marker = "extra == 'test'" },
{ name = "pytest-asyncio", marker = "extra == 'test'" },
{ name = "pytest-timeout", marker = "extra == 'test'" },
{ name = "python-dateutil", specifier = ">=2.2" },
{ name = "requests", specifier = ">=2.7,<3.0" },
{ name = "ruff", marker = "extra == 'dev'" },
{ name = "setuptools", marker = "extra == 'dev'" },
{ name = "six", specifier = ">=1.5" },
{ name = "tomli", marker = "extra == 'dev'" },
{ name = "tomli-w", marker = "extra == 'dev'" },
{ name = "twine", marker = "extra == 'dev'" },
{ name = "types-mock", marker = "extra == 'dev'" },
{ name = "types-python-dateutil", marker = "extra == 'dev'" },
{ name = "types-requests", marker = "extra == 'dev'" },
{ name = "types-setuptools", marker = "extra == 'dev'" },
{ name = "types-six", marker = "extra == 'dev'" },
{ name = "typing-extensions", specifier = ">=4.2.0" },
{ name = "wheel", marker = "extra == 'dev'" },
]
provides-extras = ["langchain", "dev", "test"]
[[package]]
name = "pygments"
version = "2.19.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
]
[[package]]
name = "pytest"
version = "8.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
]
[[package]]
name = "pytest-asyncio"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
]
[[package]]
name = "pytest-django"
version = "4.11.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pytest" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/fb/55d580352db26eb3d59ad50c64321ddfe228d3d8ac107db05387a2fadf3a/pytest_django-4.11.1.tar.gz", hash = "sha256:a949141a1ee103cb0e7a20f1451d355f83f5e4a5d07bdd4dcfdd1fd0ff227991", size = 86202, upload-time = "2025-04-03T18:56:09.338Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/ac/bd0608d229ec808e51a21044f3f2f27b9a37e7a0ebaca7247882e67876af/pytest_django-4.11.1-py3-none-any.whl", hash = "sha256:1b63773f648aa3d8541000c26929c1ea63934be1cfa674c76436966d73fe6a10", size = 25281, upload-time = "2025-04-03T18:56:07.678Z" },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f0/26/19cadc79a718c5edbec86fd4919a6b6d3f681039a2f6d66d14be94e75fb9/python_dotenv-1.2.1.tar.gz", hash = "sha256:42667e897e16ab0d66954af0e60a9caa94f0fd4ecf3aaf6d2d260eec1aa36ad6", size = 44221, upload-time = "2025-10-26T15:12:10.434Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "requests"
version = "2.32.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlparse"
version = "0.5.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/e5/40/edede8dd6977b0d3da179a342c198ed100dd2aba4be081861ee5911e4da4/sqlparse-0.5.3.tar.gz", hash = "sha256:09f67787f56a0b16ecdbde1bfc7f5d9c3371ca683cfeaa8e6ff60b4807ec9272", size = 84999, upload-time = "2024-12-10T12:05:30.728Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
]
[[package]]
name = "test-django5"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "django" },
{ name = "httpx" },
{ name = "posthog" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-django" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.metadata]
requires-dist = [
{ name = "django", specifier = "~=5.2.7" },
{ name = "httpx", specifier = "~=0.28.1" },
{ name = "posthog", editable = "../" },
{ name = "pytest", specifier = "~=8.4.2" },
{ name = "pytest-asyncio", specifier = "~=1.2.0" },
{ name = "pytest-django", specifier = "~=4.11.1" },
{ name = "uvicorn", extras = ["standard"], specifier = "~=0.38.0" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
]
[[package]]
name = "tzdata"
version = "2025.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
]
[[package]]
name = "urllib3"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
]
[[package]]
name = "uvicorn"
version = "0.38.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
]
[package.optional-dependencies]
standard = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3d/ff/7f72e8170be527b4977b033239a83a68d5c881cc4775fca255c677f7ac5d/uvloop-0.22.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:fe94b4564e865d968414598eea1a6de60adba0c040ba4ed05ac1300de402cd42", size = 1359936, upload-time = "2025-10-16T22:16:29.436Z" },
{ url = "https://files.pythonhosted.org/packages/c3/c6/e5d433f88fd54d81ef4be58b2b7b0cea13c442454a1db703a1eea0db1a59/uvloop-0.22.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:51eb9bd88391483410daad430813d982010f9c9c89512321f5b60e2cddbdddd6", size = 752769, upload-time = "2025-10-16T22:16:30.493Z" },
{ url = "https://files.pythonhosted.org/packages/24/68/a6ac446820273e71aa762fa21cdcc09861edd3536ff47c5cd3b7afb10eeb/uvloop-0.22.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:700e674a166ca5778255e0e1dc4e9d79ab2acc57b9171b79e65feba7184b3370", size = 4317413, upload-time = "2025-10-16T22:16:31.644Z" },
{ url = "https://files.pythonhosted.org/packages/5f/6f/e62b4dfc7ad6518e7eff2516f680d02a0f6eb62c0c212e152ca708a0085e/uvloop-0.22.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b5b1ac819a3f946d3b2ee07f09149578ae76066d70b44df3fa990add49a82e4", size = 4426307, upload-time = "2025-10-16T22:16:32.917Z" },
{ url = "https://files.pythonhosted.org/packages/90/60/97362554ac21e20e81bcef1150cb2a7e4ffdaf8ea1e5b2e8bf7a053caa18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e047cc068570bac9866237739607d1313b9253c3051ad84738cbb095be0537b2", size = 4131970, upload-time = "2025-10-16T22:16:34.015Z" },
{ url = "https://files.pythonhosted.org/packages/99/39/6b3f7d234ba3964c428a6e40006340f53ba37993f46ed6e111c6e9141d18/uvloop-0.22.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:512fec6815e2dd45161054592441ef76c830eddaad55c8aa30952e6fe1ed07c0", size = 4296343, upload-time = "2025-10-16T22:16:35.149Z" },
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
]
[[package]]
name = "watchfiles"
version = "1.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/74/d5/f039e7e3c639d9b1d09b07ea412a6806d38123f0508e5f9b48a87b0a76cc/watchfiles-1.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:8c89f9f2f740a6b7dcc753140dd5e1ab9215966f7a3530d0c0705c83b401bd7d", size = 404745, upload-time = "2025-10-14T15:04:46.731Z" },
{ url = "https://files.pythonhosted.org/packages/a5/96/a881a13aa1349827490dab2d363c8039527060cfcc2c92cc6d13d1b1049e/watchfiles-1.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:bd404be08018c37350f0d6e34676bd1e2889990117a2b90070b3007f172d0610", size = 391769, upload-time = "2025-10-14T15:04:48.003Z" },
{ url = "https://files.pythonhosted.org/packages/4b/5b/d3b460364aeb8da471c1989238ea0e56bec24b6042a68046adf3d9ddb01c/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8526e8f916bb5b9a0a777c8317c23ce65de259422bba5b31325a6fa6029d33af", size = 449374, upload-time = "2025-10-14T15:04:49.179Z" },
{ url = "https://files.pythonhosted.org/packages/b9/44/5769cb62d4ed055cb17417c0a109a92f007114a4e07f30812a73a4efdb11/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2edc3553362b1c38d9f06242416a5d8e9fe235c204a4072e988ce2e5bb1f69f6", size = 459485, upload-time = "2025-10-14T15:04:50.155Z" },
{ url = "https://files.pythonhosted.org/packages/19/0c/286b6301ded2eccd4ffd0041a1b726afda999926cf720aab63adb68a1e36/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:30f7da3fb3f2844259cba4720c3fc7138eb0f7b659c38f3bfa65084c7fc7abce", size = 488813, upload-time = "2025-10-14T15:04:51.059Z" },
{ url = "https://files.pythonhosted.org/packages/c7/2b/8530ed41112dd4a22f4dcfdb5ccf6a1baad1ff6eed8dc5a5f09e7e8c41c7/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8979280bdafff686ba5e4d8f97840f929a87ed9cdf133cbbd42f7766774d2aa", size = 594816, upload-time = "2025-10-14T15:04:52.031Z" },
{ url = "https://files.pythonhosted.org/packages/ce/d2/f5f9fb49489f184f18470d4f99f4e862a4b3e9ac2865688eb2099e3d837a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcc5c24523771db3a294c77d94771abcfcb82a0e0ee8efd910c37c59ec1b31bb", size = 475186, upload-time = "2025-10-14T15:04:53.064Z" },
{ url = "https://files.pythonhosted.org/packages/cf/68/5707da262a119fb06fbe214d82dd1fe4a6f4af32d2d14de368d0349eb52a/watchfiles-1.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db5d7ae38ff20153d542460752ff397fcf5c96090c1230803713cf3147a6803", size = 456812, upload-time = "2025-10-14T15:04:55.174Z" },
{ url = "https://files.pythonhosted.org/packages/66/ab/3cbb8756323e8f9b6f9acb9ef4ec26d42b2109bce830cc1f3468df20511d/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:28475ddbde92df1874b6c5c8aaeb24ad5be47a11f87cde5a28ef3835932e3e94", size = 630196, upload-time = "2025-10-14T15:04:56.22Z" },
{ url = "https://files.pythonhosted.org/packages/78/46/7152ec29b8335f80167928944a94955015a345440f524d2dfe63fc2f437b/watchfiles-1.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:36193ed342f5b9842edd3532729a2ad55c4160ffcfa3700e0d54be496b70dd43", size = 622657, upload-time = "2025-10-14T15:04:57.521Z" },
{ url = "https://files.pythonhosted.org/packages/0a/bf/95895e78dd75efe9a7f31733607f384b42eb5feb54bd2eb6ed57cc2e94f4/watchfiles-1.1.1-cp312-cp312-win32.whl", hash = "sha256:859e43a1951717cc8de7f4c77674a6d389b106361585951d9e69572823f311d9", size = 272042, upload-time = "2025-10-14T15:04:59.046Z" },
{ url = "https://files.pythonhosted.org/packages/87/0a/90eb755f568de2688cb220171c4191df932232c20946966c27a59c400850/watchfiles-1.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:91d4c9a823a8c987cce8fa2690923b069966dabb196dd8d137ea2cede885fde9", size = 288410, upload-time = "2025-10-14T15:05:00.081Z" },
{ url = "https://files.pythonhosted.org/packages/36/76/f322701530586922fbd6723c4f91ace21364924822a8772c549483abed13/watchfiles-1.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:a625815d4a2bdca61953dbba5a39d60164451ef34c88d751f6c368c3ea73d404", size = 278209, upload-time = "2025-10-14T15:05:01.168Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
]
[[package]]
name = "websockets"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
-11
View File
@@ -26,21 +26,10 @@ posthog/client.py:0: error: Incompatible types in assignment (expression has typ
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: "None" has no attribute "__iter__" (not iterable) [attr-defined]
posthog/client.py:0: error: Statement is unreachable [unreachable]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Any | dict[Any, Any]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "dict[Never, Never]", variable has type "None") [assignment]
posthog/client.py:0: error: Right operand of "and" is never evaluated [unreachable]
posthog/client.py:0: error: Incompatible types in assignment (expression has type "Poller", variable has type "None") [assignment]
posthog/client.py:0: error: "None" has no attribute "start" [attr-defined]
posthog/client.py:0: error: "None" has no attribute "get" [attr-defined]
posthog/client.py:0: error: Statement is unreachable [unreachable]
posthog/client.py:0: error: Statement is unreachable [unreachable]
example.py:0: error: Statement is unreachable [unreachable]
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/client.py:0: error: Name "urlparse" already defined (possibly by an import) [no-redef]
posthog/client.py:0: error: Name "parse_qs" already defined (possibly by an import) [no-redef]
+175 -39
View File
@@ -1,17 +1,64 @@
import datetime # noqa: F401
from typing import Callable, Dict, Optional, Any # noqa: F401
from typing import Any, Callable, Dict, Optional # noqa: F401
from typing_extensions import Unpack
from posthog.args import OptionalCaptureArgs, OptionalSetArgs, ExceptionArg
from posthog.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.client import Client
from posthog.contexts import (
new_context as inner_new_context,
scoped as inner_scoped,
tag as inner_tag,
set_context_session as inner_set_context_session,
identify_context as inner_identify_context,
)
from posthog.types import FeatureFlag, FlagsAndPayloads
from posthog.contexts import (
new_context as inner_new_context,
)
from posthog.contexts import (
scoped as inner_scoped,
)
from posthog.contexts import (
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
)
from posthog.contexts import (
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
)
from posthog.contexts import (
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
)
from posthog.contexts import (
set_context_session as inner_set_context_session,
)
from posthog.contexts import (
tag as inner_tag,
)
from posthog.contexts import (
get_tags as inner_get_tags,
)
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
)
from posthog.feature_flags import (
InconclusiveMatchError as InconclusiveMatchError,
)
from posthog.feature_flags import (
RequiresServerEvaluation as RequiresServerEvaluation,
)
from posthog.flag_definition_cache import (
FlagDefinitionCacheData as FlagDefinitionCacheData,
FlagDefinitionCacheProvider as FlagDefinitionCacheProvider,
)
from posthog.request import (
disable_connection_reuse as disable_connection_reuse,
enable_keep_alive as enable_keep_alive,
set_socket_options as set_socket_options,
SocketOptions as SocketOptions,
)
from posthog.types import (
FeatureFlag,
FlagsAndPayloads,
)
from posthog.types import (
FeatureFlagResult as FeatureFlagResult,
)
from posthog.version import VERSION
__version__ = VERSION
@@ -19,13 +66,14 @@ __version__ = VERSION
"""Context management."""
def new_context(fresh=False, capture_exceptions=True):
def new_context(fresh=False, capture_exceptions=True, client=None):
"""
Create a new context scope that will be active for the duration of the with block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture exceptions raised within the context (default: True)
client: Optional Posthog client instance to use for this context (default: None)
Examples:
```python
@@ -38,7 +86,9 @@ def new_context(fresh=False, capture_exceptions=True):
Category:
Contexts
"""
return inner_new_context(fresh=fresh, capture_exceptions=capture_exceptions)
return inner_new_context(
fresh=fresh, capture_exceptions=capture_exceptions, client=client
)
def scoped(fresh=False, capture_exceptions=True):
@@ -102,6 +152,27 @@ def identify_context(distinct_id: str):
return inner_identify_context(distinct_id)
def set_capture_exception_code_variables_context(enabled: bool):
"""
Set whether code variables are captured for the current context.
"""
return inner_set_capture_exception_code_variables_context(enabled)
def set_code_variables_mask_patterns_context(mask_patterns: list):
"""
Variable names matching these patterns will be masked with *** when capturing code variables.
"""
return inner_set_code_variables_mask_patterns_context(mask_patterns)
def set_code_variables_ignore_patterns_context(ignore_patterns: list):
"""
Variable names matching these patterns will be ignored completely when capturing code variables.
"""
return inner_set_code_variables_ignore_patterns_context(ignore_patterns)
def tag(name: str, value: Any):
"""
Add a tag to the current context.
@@ -122,6 +193,19 @@ def tag(name: str, value: Any):
return inner_tag(name, value)
def get_tags() -> Dict[str, Any]:
"""
Get all tags from the current context.
Returns:
Dict of all tags in the current context
Category:
Contexts
"""
return inner_get_tags()
"""Settings."""
api_key = None # type: Optional[str]
host = None # type: Optional[str]
@@ -149,6 +233,11 @@ enable_local_evaluation = True # type: bool
default_client = None # type: Optional[Client]
capture_exception_code_variables = False
code_variables_mask_patterns = DEFAULT_CODE_VARIABLES_MASK_PATTERNS
code_variables_ignore_patterns = DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS
in_app_modules = None # type: Optional[list[str]]
# NOTE - this and following functions take unpacked kwargs because we needed to make
# it impossible to write `posthog.capture(distinct-id, event-name)` - basically, to enforce
@@ -388,9 +477,9 @@ def capture_exception(
def feature_enabled(
key, # type: str
distinct_id, # type: str
groups={}, # type: dict
person_properties={}, # type: dict
group_properties={}, # type: dict
groups=None, # type: Optional[dict]
person_properties=None, # type: Optional[dict]
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
@@ -427,9 +516,9 @@ def feature_enabled(
"feature_enabled",
key=key,
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
groups=groups or {},
person_properties=person_properties or {},
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
@@ -439,9 +528,9 @@ def feature_enabled(
def get_feature_flag(
key, # type: str
distinct_id, # type: str
groups={}, # type: dict
person_properties={}, # type: dict
group_properties={}, # type: dict
groups=None, # type: Optional[dict]
person_properties=None, # type: Optional[dict]
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
@@ -477,9 +566,9 @@ def get_feature_flag(
"get_feature_flag",
key=key,
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
groups=groups or {},
person_properties=person_properties or {},
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
@@ -488,9 +577,9 @@ def get_feature_flag(
def get_all_flags(
distinct_id, # type: str
groups={}, # type: dict
person_properties={}, # type: dict
group_properties={}, # type: dict
groups=None, # type: Optional[dict]
person_properties=None, # type: Optional[dict]
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False, # type: bool
disable_geoip=None, # type: Optional[bool]
) -> Optional[dict[str, FeatureFlag]]:
@@ -520,21 +609,64 @@ def get_all_flags(
return _proxy(
"get_all_flags",
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
groups=groups or {},
person_properties=person_properties or {},
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
def get_feature_flag_result(
key,
distinct_id,
groups=None, # type: Optional[dict]
person_properties=None, # type: Optional[dict]
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Optional[FeatureFlagResult]
"""
Get a FeatureFlagResult object which contains the flag result and payload.
This method evaluates a feature flag and returns a FeatureFlagResult object containing:
- enabled: Whether the flag is enabled
- variant: The variant value if the flag has variants
- payload: The payload associated with the flag (automatically deserialized from JSON)
- key: The flag key
- reason: Why the flag was enabled/disabled
Example:
```python
result = posthog.get_feature_flag_result('beta-feature', 'distinct_id')
if result and result.enabled:
# Use the variant and payload
print(f"Variant: {result.variant}")
print(f"Payload: {result.payload}")
```
"""
return _proxy(
"get_feature_flag_result",
key=key,
distinct_id=distinct_id,
groups=groups or {},
person_properties=person_properties or {},
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
def get_feature_flag_payload(
key,
distinct_id,
match_value=None,
groups={},
person_properties={},
group_properties={},
groups=None, # type: Optional[dict]
person_properties=None, # type: Optional[dict]
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
@@ -544,9 +676,9 @@ def get_feature_flag_payload(
key=key,
distinct_id=distinct_id,
match_value=match_value,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
groups=groups or {},
person_properties=person_properties or {},
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
@@ -575,18 +707,18 @@ def get_remote_config_payload(
def get_all_flags_and_payloads(
distinct_id,
groups={},
person_properties={},
group_properties={},
groups=None, # type: Optional[dict]
person_properties=None, # type: Optional[dict]
group_properties=None, # type: Optional[dict]
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
) -> FlagsAndPayloads:
return _proxy(
"get_all_flags_and_payloads",
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
groups=groups or {},
person_properties=person_properties or {},
group_properties=group_properties or {},
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
@@ -700,6 +832,10 @@ def setup() -> Client:
enable_exception_autocapture=enable_exception_autocapture,
log_captured_exceptions=log_captured_exceptions,
enable_local_evaluation=enable_local_evaluation,
capture_exception_code_variables=capture_exception_code_variables,
code_variables_mask_patterns=code_variables_mask_patterns,
code_variables_ignore_patterns=code_variables_ignore_patterns,
in_app_modules=in_app_modules,
)
# always set incase user changes it
+10
View File
@@ -6,6 +6,12 @@ from .anthropic_providers import (
AsyncAnthropicBedrock,
AsyncAnthropicVertex,
)
from .anthropic_converter import (
format_anthropic_response,
format_anthropic_input,
extract_anthropic_tools,
format_anthropic_streaming_content,
)
__all__ = [
"Anthropic",
@@ -14,4 +20,8 @@ __all__ = [
"AsyncAnthropicBedrock",
"AnthropicVertex",
"AsyncAnthropicVertex",
"format_anthropic_response",
"format_anthropic_input",
"extract_anthropic_tools",
"format_anthropic_streaming_content",
]
+92 -62
View File
@@ -8,14 +8,21 @@ except ImportError:
import time
import uuid
from typing import Any, Dict, Optional, cast
from typing import Any, Dict, List, Optional
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
merge_system_prompt,
with_privacy_mode,
merge_usage_stats,
)
from posthog.ai.anthropic.anthropic_converter import (
extract_anthropic_usage_from_event,
handle_anthropic_content_block_start,
handle_anthropic_text_delta,
handle_anthropic_tool_delta,
finalize_anthropic_tool_input,
)
from posthog.ai.sanitization import sanitize_anthropic
from posthog.client import Client as PostHogClient
from posthog import setup
@@ -61,6 +68,7 @@ class WrappedMessages(Messages):
posthog_groups: Optional group analytics properties
**kwargs: Arguments passed to Anthropic's messages.create
"""
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -118,35 +126,66 @@ class WrappedMessages(Messages):
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
accumulated_content = []
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
accumulated_content = ""
content_blocks: List[StreamingContentBlock] = []
tools_in_progress: Dict[str, ToolInProgress] = {}
current_text_block: Optional[StreamingContentBlock] = None
response = super().create(**kwargs)
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_content
nonlocal content_blocks
nonlocal tools_in_progress
nonlocal current_text_block
try:
for event in response:
if hasattr(event, "usage") and event.usage:
usage_stats = {
k: getattr(event.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
]
}
# Extract usage stats from event
event_usage = extract_anthropic_usage_from_event(event)
merge_usage_stats(usage_stats, event_usage)
if hasattr(event, "content") and event.content:
accumulated_content.append(event.content)
# Handle content block start events
if hasattr(event, "type") and event.type == "content_block_start":
block, tool = handle_anthropic_content_block_start(event)
if block:
content_blocks.append(block)
if block.get("type") == "text":
current_text_block = block
else:
current_text_block = None
if tool:
tool_id = tool["block"].get("id")
if tool_id:
tools_in_progress[tool_id] = tool
# Handle text delta events
delta_text = handle_anthropic_text_delta(event, current_text_block)
if delta_text:
accumulated_content += delta_text
# Handle tool input delta events
handle_anthropic_tool_delta(
event, content_blocks, tools_in_progress
)
# Handle content block stop events
if hasattr(event, "type") and event.type == "content_block_stop":
current_text_block = None
finalize_anthropic_tool_input(
event, content_blocks, tools_in_progress
)
yield event
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
self._capture_streaming_event(
posthog_distinct_id,
@@ -157,7 +196,8 @@ class WrappedMessages(Messages):
kwargs,
usage_stats,
latency,
output,
content_blocks,
accumulated_content,
)
return generator()
@@ -170,49 +210,39 @@ class WrappedMessages(Messages):
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: str,
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from posthog.ai.utils import capture_streaming_event
event_properties = {
"$ai_provider": "anthropic",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
merge_system_prompt(kwargs, "anthropic"),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_cache_creation_input_tokens": usage_stats.get(
"cache_creation_input_tokens", 0
),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
sanitized_input = sanitize_anthropic(formatted_input)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
event_data = StreamingEventData(
provider="anthropic",
model=kwargs.get("model", "unknown"),
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
formatted_output=format_anthropic_streaming_output_complete(
content_blocks, accumulated_content
),
usage_stats=usage_stats,
latency=latency,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
# Use the common capture function
capture_streaming_event(self._client._ph_client, event_data)
+92 -62
View File
@@ -8,15 +8,22 @@ except ImportError:
import time
import uuid
from typing import Any, Dict, Optional
from typing import Any, Dict, List, Optional
from posthog import setup
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from posthog.ai.utils import (
call_llm_and_track_usage_async,
get_model_params,
merge_system_prompt,
with_privacy_mode,
merge_usage_stats,
)
from posthog.ai.anthropic.anthropic_converter import (
extract_anthropic_usage_from_event,
handle_anthropic_content_block_start,
handle_anthropic_text_delta,
handle_anthropic_tool_delta,
finalize_anthropic_tool_input,
)
from posthog.ai.sanitization import sanitize_anthropic
from posthog.client import Client as PostHogClient
@@ -61,6 +68,7 @@ class AsyncWrappedMessages(AsyncMessages):
posthog_groups: Optional group analytics properties
**kwargs: Arguments passed to Anthropic's messages.create
"""
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -118,35 +126,66 @@ class AsyncWrappedMessages(AsyncMessages):
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
accumulated_content = []
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
accumulated_content = ""
content_blocks: List[StreamingContentBlock] = []
tools_in_progress: Dict[str, ToolInProgress] = {}
current_text_block: Optional[StreamingContentBlock] = None
response = await super().create(**kwargs)
async def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_content
nonlocal content_blocks
nonlocal tools_in_progress
nonlocal current_text_block
try:
async for event in response:
if hasattr(event, "usage") and event.usage:
usage_stats = {
k: getattr(event.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
]
}
# Extract usage stats from event
event_usage = extract_anthropic_usage_from_event(event)
merge_usage_stats(usage_stats, event_usage)
if hasattr(event, "content") and event.content:
accumulated_content.append(event.content)
# Handle content block start events
if hasattr(event, "type") and event.type == "content_block_start":
block, tool = handle_anthropic_content_block_start(event)
if block:
content_blocks.append(block)
if block.get("type") == "text":
current_text_block = block
else:
current_text_block = None
if tool:
tool_id = tool["block"].get("id")
if tool_id:
tools_in_progress[tool_id] = tool
# Handle text delta events
delta_text = handle_anthropic_text_delta(event, current_text_block)
if delta_text:
accumulated_content += delta_text
# Handle tool input delta events
handle_anthropic_tool_delta(
event, content_blocks, tools_in_progress
)
# Handle content block stop events
if hasattr(event, "type") and event.type == "content_block_stop":
current_text_block = None
finalize_anthropic_tool_input(
event, content_blocks, tools_in_progress
)
yield event
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
await self._capture_streaming_event(
posthog_distinct_id,
@@ -157,7 +196,8 @@ class AsyncWrappedMessages(AsyncMessages):
kwargs,
usage_stats,
latency,
output,
content_blocks,
accumulated_content,
)
return generator()
@@ -170,49 +210,39 @@ class AsyncWrappedMessages(AsyncMessages):
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: str,
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from posthog.ai.utils import capture_streaming_event
event_properties = {
"$ai_provider": "anthropic",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
merge_system_prompt(kwargs, "anthropic"),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_cache_creation_input_tokens": usage_stats.get(
"cache_creation_input_tokens", 0
),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
sanitized_input = sanitize_anthropic(formatted_input)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
event_data = StreamingEventData(
provider="anthropic",
model=kwargs.get("model", "unknown"),
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
formatted_output=format_anthropic_streaming_output_complete(
content_blocks, accumulated_content
),
usage_stats=usage_stats,
latency=latency,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
# Use the common capture function
capture_streaming_event(self._client._ph_client, event_data)
+443
View File
@@ -0,0 +1,443 @@
"""
Anthropic-specific conversion utilities.
This module handles the conversion of Anthropic API responses and inputs
into standardized formats for PostHog tracking.
"""
import json
from typing import Any, Dict, List, Optional, Tuple
from posthog.ai.types import (
FormattedContentItem,
FormattedFunctionCall,
FormattedMessage,
FormattedTextContent,
StreamingContentBlock,
TokenUsage,
ToolInProgress,
)
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
"""
Format an Anthropic response into standardized message format.
Args:
response: The response object from Anthropic API
Returns:
List of formatted messages with role and content
"""
output: List[FormattedMessage] = []
if response is None:
return output
content: List[FormattedContentItem] = []
# Process content blocks from the response
if hasattr(response, "content"):
for choice in response.content:
if (
hasattr(choice, "type")
and choice.type == "text"
and hasattr(choice, "text")
and choice.text
):
text_content: FormattedTextContent = {
"type": "text",
"text": choice.text,
}
content.append(text_content)
elif (
hasattr(choice, "type")
and choice.type == "tool_use"
and hasattr(choice, "name")
and hasattr(choice, "id")
):
function_call: FormattedFunctionCall = {
"type": "function",
"id": choice.id,
"function": {
"name": choice.name,
"arguments": getattr(choice, "input", {}),
},
}
content.append(function_call)
if content:
message: FormattedMessage = {
"role": "assistant",
"content": content,
}
output.append(message)
return output
def format_anthropic_input(
messages: List[Dict[str, Any]], system: Optional[str] = None
) -> List[FormattedMessage]:
"""
Format Anthropic input messages with optional system prompt.
Args:
messages: List of message dictionaries
system: Optional system prompt to prepend
Returns:
List of formatted messages
"""
formatted_messages: List[FormattedMessage] = []
# Add system message if provided
if system is not None:
formatted_messages.append({"role": "system", "content": system})
# Add user messages
if messages:
for msg in messages:
# Messages are already in the correct format, just ensure type safety
formatted_msg: FormattedMessage = {
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
}
formatted_messages.append(formatted_msg)
return formatted_messages
def extract_anthropic_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
"""
Extract tool definitions from Anthropic API kwargs.
Args:
kwargs: Keyword arguments passed to Anthropic API
Returns:
Tool definitions if present, None otherwise
"""
return kwargs.get("tools", None)
def format_anthropic_streaming_content(
content_blocks: List[StreamingContentBlock],
) -> List[FormattedContentItem]:
"""
Format content blocks from Anthropic streaming response.
Used by streaming handlers to format accumulated content blocks.
Args:
content_blocks: List of content block dictionaries from streaming
Returns:
List of formatted content items
"""
formatted: List[FormattedContentItem] = []
for block in content_blocks:
if block.get("type") == "text":
formatted.append(
{
"type": "text",
"text": block.get("text") or "",
}
)
elif block.get("type") == "function":
formatted.append(
{
"type": "function",
"id": block.get("id"),
"function": block.get("function") or {},
}
)
return formatted
def extract_anthropic_web_search_count(response: Any) -> int:
"""
Extract web search count from Anthropic response.
Anthropic provides exact web search counts via usage.server_tool_use.web_search_requests.
Args:
response: The response from Anthropic API
Returns:
Number of web search requests (0 if none)
"""
if not hasattr(response, "usage"):
return 0
if not hasattr(response.usage, "server_tool_use"):
return 0
server_tool_use = response.usage.server_tool_use
if hasattr(server_tool_use, "web_search_requests"):
return max(0, int(getattr(server_tool_use, "web_search_requests", 0)))
return 0
def extract_anthropic_usage_from_response(response: Any) -> TokenUsage:
"""
Extract usage from a full Anthropic response (non-streaming).
Args:
response: The complete response from Anthropic API
Returns:
TokenUsage with standardized usage
"""
if not hasattr(response, "usage"):
return TokenUsage(input_tokens=0, output_tokens=0)
result = TokenUsage(
input_tokens=getattr(response.usage, "input_tokens", 0),
output_tokens=getattr(response.usage, "output_tokens", 0),
)
if hasattr(response.usage, "cache_read_input_tokens"):
cache_read = response.usage.cache_read_input_tokens
if cache_read and cache_read > 0:
result["cache_read_input_tokens"] = cache_read
if hasattr(response.usage, "cache_creation_input_tokens"):
cache_creation = response.usage.cache_creation_input_tokens
if cache_creation and cache_creation > 0:
result["cache_creation_input_tokens"] = cache_creation
web_search_count = extract_anthropic_web_search_count(response)
if web_search_count > 0:
result["web_search_count"] = web_search_count
return result
def extract_anthropic_usage_from_event(event: Any) -> TokenUsage:
"""
Extract usage statistics from an Anthropic streaming event.
Args:
event: Streaming event from Anthropic API
Returns:
Dictionary of usage statistics
"""
usage: TokenUsage = TokenUsage()
# Handle usage stats from message_start event
if hasattr(event, "type") and event.type == "message_start":
if hasattr(event, "message") and hasattr(event.message, "usage"):
usage["input_tokens"] = getattr(event.message.usage, "input_tokens", 0)
usage["cache_creation_input_tokens"] = getattr(
event.message.usage, "cache_creation_input_tokens", 0
)
usage["cache_read_input_tokens"] = getattr(
event.message.usage, "cache_read_input_tokens", 0
)
# Handle usage stats from message_delta event
if hasattr(event, "usage") and event.usage:
usage["output_tokens"] = getattr(event.usage, "output_tokens", 0)
# Extract web search count from usage
if hasattr(event.usage, "server_tool_use"):
server_tool_use = event.usage.server_tool_use
if hasattr(server_tool_use, "web_search_requests"):
web_search_count = int(
getattr(server_tool_use, "web_search_requests", 0)
)
if web_search_count > 0:
usage["web_search_count"] = web_search_count
return usage
def handle_anthropic_content_block_start(
event: Any,
) -> Tuple[Optional[StreamingContentBlock], Optional[ToolInProgress]]:
"""
Handle content block start event from Anthropic streaming.
Args:
event: Content block start event
Returns:
Tuple of (content_block, tool_in_progress)
"""
if not (hasattr(event, "type") and event.type == "content_block_start"):
return None, None
if not hasattr(event, "content_block"):
return None, None
block = event.content_block
if not hasattr(block, "type"):
return None, None
if block.type == "text":
content_block: StreamingContentBlock = {"type": "text", "text": ""}
return content_block, None
elif block.type == "tool_use":
tool_block: StreamingContentBlock = {
"type": "function",
"id": getattr(block, "id", ""),
"function": {"name": getattr(block, "name", ""), "arguments": {}},
}
tool_in_progress: ToolInProgress = {"block": tool_block, "input_string": ""}
return tool_block, tool_in_progress
return None, None
def handle_anthropic_text_delta(
event: Any, current_block: Optional[StreamingContentBlock]
) -> Optional[str]:
"""
Handle text delta event from Anthropic streaming.
Args:
event: Delta event
current_block: Current text block being accumulated
Returns:
Text delta if present
"""
if hasattr(event, "delta") and hasattr(event.delta, "text"):
delta_text = event.delta.text or ""
if current_block is not None and current_block.get("type") == "text":
text_val = current_block.get("text")
if text_val is not None:
current_block["text"] = text_val + delta_text
else:
current_block["text"] = delta_text
return delta_text
return None
def handle_anthropic_tool_delta(
event: Any,
content_blocks: List[StreamingContentBlock],
tools_in_progress: Dict[str, ToolInProgress],
) -> None:
"""
Handle tool input delta event from Anthropic streaming.
Args:
event: Tool delta event
content_blocks: List of content blocks
tools_in_progress: Dictionary tracking tools being accumulated
"""
if not (hasattr(event, "type") and event.type == "content_block_delta"):
return
if not (
hasattr(event, "delta")
and hasattr(event.delta, "type")
and event.delta.type == "input_json_delta"
):
return
if hasattr(event, "index") and event.index < len(content_blocks):
block = content_blocks[event.index]
if block.get("type") == "function" and block.get("id") in tools_in_progress:
tool = tools_in_progress[block["id"]]
partial_json = getattr(event.delta, "partial_json", "")
tool["input_string"] += partial_json
def finalize_anthropic_tool_input(
event: Any,
content_blocks: List[StreamingContentBlock],
tools_in_progress: Dict[str, ToolInProgress],
) -> None:
"""
Finalize tool input when content block stops.
Args:
event: Content block stop event
content_blocks: List of content blocks
tools_in_progress: Dictionary tracking tools being accumulated
"""
if not (hasattr(event, "type") and event.type == "content_block_stop"):
return
if hasattr(event, "index") and event.index < len(content_blocks):
block = content_blocks[event.index]
if block.get("type") == "function" and block.get("id") in tools_in_progress:
tool = tools_in_progress[block["id"]]
try:
block["function"]["arguments"] = json.loads(tool["input_string"])
except (json.JSONDecodeError, Exception):
# Keep empty dict if parsing fails
pass
del tools_in_progress[block["id"]]
def format_anthropic_streaming_input(kwargs: Dict[str, Any]) -> Any:
"""
Format Anthropic streaming input using system prompt merging.
Args:
kwargs: Keyword arguments passed to Anthropic API
Returns:
Formatted input ready for PostHog tracking
"""
from posthog.ai.utils import merge_system_prompt
return merge_system_prompt(kwargs, "anthropic")
def format_anthropic_streaming_output_complete(
content_blocks: List[StreamingContentBlock], accumulated_content: str
) -> List[FormattedMessage]:
"""
Format complete Anthropic streaming output.
Combines existing logic for formatting content blocks with fallback to accumulated content.
Args:
content_blocks: List of content blocks accumulated during streaming
accumulated_content: Raw accumulated text content as fallback
Returns:
Formatted messages ready for PostHog tracking
"""
formatted_content = format_anthropic_streaming_content(content_blocks)
if formatted_content:
return [{"role": "assistant", "content": formatted_content}]
else:
# Fallback to accumulated content if no blocks
return [
{
"role": "assistant",
"content": [{"type": "text", "text": accumulated_content}],
}
]
+15 -1
View File
@@ -1,11 +1,25 @@
from .gemini import Client
from .gemini_async import AsyncClient
from .gemini_converter import (
format_gemini_input,
format_gemini_response,
extract_gemini_tools,
)
# Create a genai-like module for perfect drop-in replacement
class _GenAI:
Client = Client
AsyncClient = AsyncClient
genai = _GenAI()
__all__ = ["Client", "genai"]
__all__ = [
"Client",
"AsyncClient",
"genai",
"format_gemini_input",
"format_gemini_response",
"extract_gemini_tools",
]
+127 -78
View File
@@ -3,6 +3,9 @@ import time
import uuid
from typing import Any, Dict, Optional
from posthog.ai.types import TokenUsage, StreamingEventData
from posthog.ai.utils import merge_system_prompt
try:
from google import genai
except ImportError:
@@ -13,9 +16,15 @@ except ImportError:
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
with_privacy_mode,
capture_streaming_event,
merge_usage_stats,
)
from posthog.ai.gemini.gemini_converter import (
extract_gemini_usage_from_chunk,
extract_gemini_content_from_chunk,
format_gemini_streaming_output,
)
from posthog.ai.sanitization import sanitize_gemini
from posthog.client import Client as PostHogClient
@@ -42,6 +51,12 @@ class Client:
def __init__(
self,
api_key: Optional[str] = None,
vertexai: Optional[bool] = None,
credentials: Optional[Any] = None,
project: Optional[str] = None,
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
@@ -51,7 +66,13 @@ class Client:
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
vertexai: Whether to use Vertex AI authentication
credentials: Vertex AI credentials object
project: GCP project ID for Vertex AI
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
posthog_properties: Default properties for all calls (can be overridden per call)
@@ -59,6 +80,7 @@ class Client:
posthog_groups: Default groups for all calls (can be overridden per call)
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = posthog_client or setup()
if self._ph_client is None:
@@ -66,6 +88,12 @@ class Client:
self.models = Models(
api_key=api_key,
vertexai=vertexai,
credentials=credentials,
project=project,
location=location,
debug_config=debug_config,
http_options=http_options,
posthog_client=self._ph_client,
posthog_distinct_id=posthog_distinct_id,
posthog_properties=posthog_properties,
@@ -85,6 +113,12 @@ class Models:
def __init__(
self,
api_key: Optional[str] = None,
vertexai: Optional[bool] = None,
credentials: Optional[Any] = None,
project: Optional[str] = None,
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
@@ -94,7 +128,13 @@ class Models:
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
vertexai: Whether to use Vertex AI authentication
credentials: Vertex AI credentials object
project: GCP project ID for Vertex AI
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls
posthog_properties: Default properties for all calls
@@ -102,6 +142,7 @@ class Models:
posthog_groups: Default groups for all calls
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = posthog_client or setup()
if self._ph_client is None:
@@ -113,16 +154,46 @@ class Models:
self._default_privacy_mode = posthog_privacy_mode
self._default_groups = posthog_groups
# Handle API key - try parameter first, then environment variables
if api_key is None:
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
# Build genai.Client arguments
client_args: Dict[str, Any] = {}
if api_key is None:
raise ValueError(
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
)
# Add Vertex AI parameters if provided
if vertexai is not None:
client_args["vertexai"] = vertexai
self._client = genai.Client(api_key=api_key)
if credentials is not None:
client_args["credentials"] = credentials
if project is not None:
client_args["project"] = project
if location is not None:
client_args["location"] = location
if debug_config is not None:
client_args["debug_config"] = debug_config
if http_options is not None:
client_args["http_options"] = http_options
# Handle API key authentication
if vertexai:
# For Vertex AI, api_key is optional
if api_key is not None:
client_args["api_key"] = api_key
else:
# For non-Vertex AI mode, api_key is required (backwards compatibility)
if api_key is None:
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
if api_key is None:
raise ValueError(
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
)
client_args["api_key"] = api_key
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_posthog_params(
@@ -134,6 +205,7 @@ class Models:
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = (
call_distinct_id
@@ -149,6 +221,7 @@ class Models:
# Merge properties: default properties + call properties (call properties override)
properties = dict(self._default_properties)
if call_properties:
properties.update(call_properties)
@@ -184,6 +257,7 @@ class Models:
posthog_groups: Group analytics properties (overrides client default)
**kwargs: Arguments passed to Gemini's generate_content
"""
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_posthog_params(
@@ -222,7 +296,7 @@ class Models:
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
accumulated_content = []
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
@@ -230,28 +304,27 @@ class Models:
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_content
try:
for chunk in response:
if hasattr(chunk, "usage_metadata") and chunk.usage_metadata:
usage_stats = {
"input_tokens": getattr(
chunk.usage_metadata, "prompt_token_count", 0
),
"output_tokens": getattr(
chunk.usage_metadata, "candidates_token_count", 0
),
}
# Extract usage stats from chunk
chunk_usage = extract_gemini_usage_from_chunk(chunk)
if hasattr(chunk, "text") and chunk.text:
accumulated_content.append(chunk.text)
if chunk_usage:
# Gemini reports cumulative totals, not incremental values
merge_usage_stats(usage_stats, chunk_usage, mode="cumulative")
# Extract content from chunk (now returns content blocks)
content_block = extract_gemini_content_from_chunk(chunk)
if content_block is not None:
accumulated_content.append(content_block)
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
self._capture_streaming_event(
model,
@@ -264,7 +337,7 @@ class Models:
kwargs,
usage_stats,
latency,
output,
accumulated_content,
)
return generator()
@@ -279,63 +352,39 @@ class Models:
privacy_mode: bool,
groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: str,
output: Any,
):
if trace_id is None:
trace_id = str(uuid.uuid4())
# Prepare standardized event data
formatted_input = self._format_input(contents, **kwargs)
sanitized_input = sanitize_gemini(formatted_input)
event_properties = {
"$ai_provider": "gemini",
"$ai_model": model,
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._ph_client,
privacy_mode,
self._format_input(contents),
),
"$ai_output_choices": with_privacy_mode(
self._ph_client,
privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": trace_id,
"$ai_base_url": self._base_url,
**(properties or {}),
}
event_data = StreamingEventData(
provider="gemini",
model=model,
base_url=self._base_url,
kwargs=kwargs,
formatted_input=sanitized_input,
formatted_output=format_gemini_streaming_output(output),
usage_stats=usage_stats,
latency=latency,
distinct_id=distinct_id,
trace_id=trace_id,
properties=properties,
privacy_mode=privacy_mode,
groups=groups,
)
if distinct_id is None:
event_properties["$process_person_profile"] = False
# Use the common capture function
capture_streaming_event(self._ph_client, event_data)
if hasattr(self._ph_client, "capture"):
self._ph_client.capture(
distinct_id=distinct_id,
event="$ai_generation",
properties=event_properties,
groups=groups,
)
def _format_input(self, contents):
def _format_input(self, contents, **kwargs):
"""Format input contents for PostHog tracking"""
if isinstance(contents, str):
return [{"role": "user", "content": contents}]
elif isinstance(contents, list):
formatted = []
for item in contents:
if isinstance(item, str):
formatted.append({"role": "user", "content": item})
elif hasattr(item, "text"):
formatted.append({"role": "user", "content": item.text})
else:
formatted.append({"role": "user", "content": str(item)})
return formatted
else:
return [{"role": "user", "content": str(contents)}]
# Create kwargs dict with contents for merge_system_prompt
input_kwargs = {"contents": contents, **kwargs}
return merge_system_prompt(input_kwargs, "gemini")
def generate_content_stream(
self,
+423
View File
@@ -0,0 +1,423 @@
import os
import time
import uuid
from typing import Any, Dict, Optional
from posthog.ai.types import TokenUsage, StreamingEventData
from posthog.ai.utils import merge_system_prompt
try:
from google import genai
except ImportError:
raise ModuleNotFoundError(
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
)
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
capture_streaming_event,
merge_usage_stats,
)
from posthog.ai.gemini.gemini_converter import (
extract_gemini_usage_from_chunk,
extract_gemini_content_from_chunk,
format_gemini_streaming_output,
)
from posthog.ai.sanitization import sanitize_gemini
from posthog.client import Client as PostHogClient
class AsyncClient:
"""
An async drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
Usage:
client = AsyncClient(
api_key="your_api_key",
posthog_client=posthog_client,
posthog_distinct_id="default_user", # Optional defaults
posthog_properties={"team": "ai"} # Optional defaults
)
response = await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello world"],
posthog_distinct_id="specific_user" # Override default
)
"""
_ph_client: PostHogClient
def __init__(
self,
api_key: Optional[str] = None,
vertexai: Optional[bool] = None,
credentials: Optional[Any] = None,
project: Optional[str] = None,
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
vertexai: Whether to use Vertex AI authentication
credentials: Vertex AI credentials object
project: GCP project ID for Vertex AI
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls (can be overridden per call)
posthog_properties: Default properties for all calls (can be overridden per call)
posthog_privacy_mode: Default privacy mode for all calls (can be overridden per call)
posthog_groups: Default groups for all calls (can be overridden per call)
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
self.models = AsyncModels(
api_key=api_key,
vertexai=vertexai,
credentials=credentials,
project=project,
location=location,
debug_config=debug_config,
http_options=http_options,
posthog_client=self._ph_client,
posthog_distinct_id=posthog_distinct_id,
posthog_properties=posthog_properties,
posthog_privacy_mode=posthog_privacy_mode,
posthog_groups=posthog_groups,
**kwargs,
)
class AsyncModels:
"""
Async Models interface that mimics genai.Client().aio.models with PostHog tracking.
"""
_ph_client: PostHogClient # Not None after __init__ validation
def __init__(
self,
api_key: Optional[str] = None,
vertexai: Optional[bool] = None,
credentials: Optional[Any] = None,
project: Optional[str] = None,
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
posthog_client: Optional[PostHogClient] = None,
posthog_distinct_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs,
):
"""
Args:
api_key: Google AI API key. If not provided, will use GOOGLE_API_KEY or API_KEY environment variable (not required for Vertex AI)
vertexai: Whether to use Vertex AI authentication
credentials: Vertex AI credentials object
project: GCP project ID for Vertex AI
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
posthog_client: PostHog client for tracking usage
posthog_distinct_id: Default distinct ID for all calls
posthog_properties: Default properties for all calls
posthog_privacy_mode: Default privacy mode for all calls
posthog_groups: Default groups for all calls
**kwargs: Additional arguments (for future compatibility)
"""
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("posthog_client is required for PostHog tracking")
# Store default PostHog settings
self._default_distinct_id = posthog_distinct_id
self._default_properties = posthog_properties or {}
self._default_privacy_mode = posthog_privacy_mode
self._default_groups = posthog_groups
# Build genai.Client arguments
client_args: Dict[str, Any] = {}
# Add Vertex AI parameters if provided
if vertexai is not None:
client_args["vertexai"] = vertexai
if credentials is not None:
client_args["credentials"] = credentials
if project is not None:
client_args["project"] = project
if location is not None:
client_args["location"] = location
if debug_config is not None:
client_args["debug_config"] = debug_config
if http_options is not None:
client_args["http_options"] = http_options
# Handle API key authentication
if vertexai:
# For Vertex AI, api_key is optional
if api_key is not None:
client_args["api_key"] = api_key
else:
# For non-Vertex AI mode, api_key is required (backwards compatibility)
if api_key is None:
api_key = os.environ.get("GOOGLE_API_KEY") or os.environ.get("API_KEY")
if api_key is None:
raise ValueError(
"API key must be provided either as parameter or via GOOGLE_API_KEY/API_KEY environment variable"
)
client_args["api_key"] = api_key
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_posthog_params(
self,
call_distinct_id: Optional[str],
call_trace_id: Optional[str],
call_properties: Optional[Dict[str, Any]],
call_privacy_mode: Optional[bool],
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = (
call_distinct_id
if call_distinct_id is not None
else self._default_distinct_id
)
privacy_mode = (
call_privacy_mode
if call_privacy_mode is not None
else self._default_privacy_mode
)
groups = call_groups if call_groups is not None else self._default_groups
# Merge properties: default properties + call properties (call properties override)
properties = dict(self._default_properties)
if call_properties:
properties.update(call_properties)
if call_trace_id is None:
call_trace_id = str(uuid.uuid4())
return distinct_id, call_trace_id, properties, privacy_mode, groups
async def generate_content(
self,
model: str,
contents,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Generate content using Gemini's API while tracking usage in PostHog.
This method signature exactly matches genai.Client().aio.models.generate_content()
with additional PostHog tracking parameters.
Args:
model: The model to use (e.g., 'gemini-2.0-flash')
contents: The input content for generation
posthog_distinct_id: ID to associate with the usage event (overrides client default)
posthog_trace_id: Trace UUID for linking events (auto-generated if not provided)
posthog_properties: Extra properties to include in the event (merged with client defaults)
posthog_privacy_mode: Whether to redact sensitive information (overrides client default)
posthog_groups: Group analytics properties (overrides client default)
**kwargs: Arguments passed to Gemini's generate_content
"""
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
kwargs_with_contents = {"model": model, "contents": contents, **kwargs}
return await call_llm_and_track_usage_async(
distinct_id,
self._ph_client,
"gemini",
trace_id,
properties,
privacy_mode,
groups,
self._base_url,
self._client.aio.models.generate_content,
**kwargs_with_contents,
)
async def _generate_content_streaming(
self,
model: str,
contents,
distinct_id: Optional[str],
trace_id: Optional[str],
properties: Optional[Dict[str, Any]],
privacy_mode: bool,
groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
usage_stats: TokenUsage = TokenUsage(input_tokens=0, output_tokens=0)
accumulated_content = []
kwargs_without_stream = {"model": model, "contents": contents, **kwargs}
response = await self._client.aio.models.generate_content_stream(
**kwargs_without_stream
)
async def async_generator():
nonlocal usage_stats
nonlocal accumulated_content
try:
async for chunk in response:
# Extract usage stats from chunk
chunk_usage = extract_gemini_usage_from_chunk(chunk)
if chunk_usage:
# Gemini reports cumulative totals, not incremental values
merge_usage_stats(usage_stats, chunk_usage, mode="cumulative")
# Extract content from chunk (now returns content blocks)
content_block = extract_gemini_content_from_chunk(chunk)
if content_block is not None:
accumulated_content.append(content_block)
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
self._capture_streaming_event(
model,
contents,
distinct_id,
trace_id,
properties,
privacy_mode,
groups,
kwargs,
usage_stats,
latency,
accumulated_content,
)
return async_generator()
def _capture_streaming_event(
self,
model: str,
contents,
distinct_id: Optional[str],
trace_id: Optional[str],
properties: Optional[Dict[str, Any]],
privacy_mode: bool,
groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
output: Any,
):
# Prepare standardized event data
formatted_input = self._format_input(contents, **kwargs)
sanitized_input = sanitize_gemini(formatted_input)
event_data = StreamingEventData(
provider="gemini",
model=model,
base_url=self._base_url,
kwargs=kwargs,
formatted_input=sanitized_input,
formatted_output=format_gemini_streaming_output(output),
usage_stats=usage_stats,
latency=latency,
distinct_id=distinct_id,
trace_id=trace_id,
properties=properties,
privacy_mode=privacy_mode,
groups=groups,
)
# Use the common capture function
capture_streaming_event(self._ph_client, event_data)
def _format_input(self, contents, **kwargs):
"""Format input contents for PostHog tracking"""
# Create kwargs dict with contents for merge_system_prompt
input_kwargs = {"contents": contents, **kwargs}
return merge_system_prompt(input_kwargs, "gemini")
async def generate_content_stream(
self,
model: str,
contents,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: Optional[bool] = None,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
return await self._generate_content_streaming(
model,
contents,
distinct_id,
trace_id,
properties,
privacy_mode,
groups,
**kwargs,
)
+652
View File
@@ -0,0 +1,652 @@
"""
Gemini-specific conversion utilities.
This module handles the conversion of Gemini API responses and inputs
into standardized formats for PostHog tracking.
"""
from typing import Any, Dict, List, Optional, TypedDict, Union
from posthog.ai.types import (
FormattedContentItem,
FormattedMessage,
TokenUsage,
)
class GeminiPart(TypedDict, total=False):
"""Represents a part in a Gemini message."""
text: str
class GeminiMessage(TypedDict, total=False):
"""Represents a Gemini message with various possible fields."""
role: str
parts: List[Union[GeminiPart, Dict[str, Any]]]
content: Union[str, List[Any]]
text: str
def _format_parts_as_content_blocks(parts: List[Any]) -> List[FormattedContentItem]:
"""
Format Gemini parts array into structured content blocks.
Preserves structure for multimodal content (text + images) instead of
concatenating everything into a string.
Args:
parts: List of parts that may contain text, inline_data, etc.
Returns:
List of formatted content blocks
"""
content_blocks: List[FormattedContentItem] = []
for part in parts:
# Handle dict with text field
if isinstance(part, dict) and "text" in part:
content_blocks.append({"type": "text", "text": part["text"]})
# Handle string parts
elif isinstance(part, str):
content_blocks.append({"type": "text", "text": part})
# Handle dict with inline_data (images, documents, etc.)
elif isinstance(part, dict) and "inline_data" in part:
inline_data = part["inline_data"]
mime_type = inline_data.get("mime_type", "")
content_type = "image" if mime_type.startswith("image/") else "document"
content_blocks.append(
{
"type": content_type,
"inline_data": inline_data,
}
)
# Handle object with text attribute
elif hasattr(part, "text"):
text_value = getattr(part, "text", "")
if text_value:
content_blocks.append({"type": "text", "text": text_value})
# Handle object with inline_data attribute
elif hasattr(part, "inline_data"):
inline_data = part.inline_data
# Convert to dict if needed
if hasattr(inline_data, "mime_type") and hasattr(inline_data, "data"):
# Determine type based on mime_type
mime_type = inline_data.mime_type
content_type = "image" if mime_type.startswith("image/") else "document"
content_blocks.append(
{
"type": content_type,
"inline_data": {
"mime_type": mime_type,
"data": inline_data.data,
},
}
)
else:
content_blocks.append(
{
"type": "image",
"inline_data": inline_data,
}
)
return content_blocks
def _format_dict_message(item: Dict[str, Any]) -> FormattedMessage:
"""
Format a dictionary message into standardized format.
Args:
item: Dictionary containing message data
Returns:
Formatted message with role and content
"""
# Handle dict format with parts array (Gemini-specific format)
if "parts" in item and isinstance(item["parts"], list):
content_blocks = _format_parts_as_content_blocks(item["parts"])
return {"role": item.get("role", "user"), "content": content_blocks}
# Handle dict with content field
if "content" in item:
content = item["content"]
if isinstance(content, list):
# If content is a list, format it as content blocks
content_blocks = _format_parts_as_content_blocks(content)
return {"role": item.get("role", "user"), "content": content_blocks}
elif not isinstance(content, str):
content = str(content)
return {"role": item.get("role", "user"), "content": content}
# Handle dict with text field
if "text" in item:
return {"role": item.get("role", "user"), "content": item["text"]}
# Fallback to string representation
return {"role": "user", "content": str(item)}
def _format_object_message(item: Any) -> FormattedMessage:
"""
Format an object (with attributes) into standardized format.
Args:
item: Object that may have text or parts attributes
Returns:
Formatted message with role and content
"""
# Handle object with parts attribute
if hasattr(item, "parts") and hasattr(item.parts, "__iter__"):
content_blocks = _format_parts_as_content_blocks(list(item.parts))
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
# Ensure role is a string
if not isinstance(role, str):
role = "user"
return {"role": role, "content": content_blocks}
# Handle object with text attribute
if hasattr(item, "text"):
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
# Ensure role is a string
if not isinstance(role, str):
role = "user"
return {"role": role, "content": item.text}
# Handle object with content attribute
if hasattr(item, "content"):
role = getattr(item, "role", "user") if hasattr(item, "role") else "user"
# Ensure role is a string
if not isinstance(role, str):
role = "user"
content = item.content
if isinstance(content, list):
content_blocks = _format_parts_as_content_blocks(content)
return {"role": role, "content": content_blocks}
elif not isinstance(content, str):
content = str(content)
return {"role": role, "content": content}
# Fallback to string representation
return {"role": "user", "content": str(item)}
def format_gemini_response(response: Any) -> List[FormattedMessage]:
"""
Format a Gemini response into standardized message format.
Args:
response: The response object from Gemini API
Returns:
List of formatted messages with role and content
"""
output: List[FormattedMessage] = []
if response is None:
return output
if hasattr(response, "candidates") and response.candidates:
for candidate in response.candidates:
if hasattr(candidate, "content") and candidate.content:
content: List[FormattedContentItem] = []
if hasattr(candidate.content, "parts") and candidate.content.parts:
for part in candidate.content.parts:
if hasattr(part, "text") and part.text:
content.append(
{
"type": "text",
"text": part.text,
}
)
elif hasattr(part, "function_call") and part.function_call:
function_call = part.function_call
content.append(
{
"type": "function",
"function": {
"name": function_call.name,
"arguments": function_call.args,
},
}
)
elif hasattr(part, "inline_data") and part.inline_data:
# Handle audio/media inline data
import base64
inline_data = part.inline_data
mime_type = getattr(inline_data, "mime_type", "audio/pcm")
raw_data = getattr(inline_data, "data", b"")
# Encode binary data as base64 string for JSON serialization
if isinstance(raw_data, bytes):
data = base64.b64encode(raw_data).decode("utf-8")
else:
# Already a string (base64)
data = raw_data
content.append(
{
"type": "audio",
"mime_type": mime_type,
"data": data,
}
)
if content:
output.append(
{
"role": "assistant",
"content": content,
}
)
elif hasattr(candidate, "text") and candidate.text:
output.append(
{
"role": "assistant",
"content": [{"type": "text", "text": candidate.text}],
}
)
elif hasattr(response, "text") and response.text:
output.append(
{
"role": "assistant",
"content": [{"type": "text", "text": response.text}],
}
)
return output
def extract_gemini_system_instruction(config: Any) -> Optional[str]:
"""
Extract system instruction from Gemini config parameter.
Args:
config: Config object or dict that may contain system instruction
Returns:
System instruction string if present, None otherwise
"""
if config is None:
return None
# Handle different config formats
if hasattr(config, "system_instruction"):
return config.system_instruction
elif isinstance(config, dict) and "system_instruction" in config:
return config["system_instruction"]
elif isinstance(config, dict) and "systemInstruction" in config:
return config["systemInstruction"]
return None
def extract_gemini_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
"""
Extract tool definitions from Gemini API kwargs.
Args:
kwargs: Keyword arguments passed to Gemini API
Returns:
Tool definitions if present, None otherwise
"""
if "config" in kwargs and hasattr(kwargs["config"], "tools"):
return kwargs["config"].tools
return None
def format_gemini_input_with_system(
contents: Any, config: Any = None
) -> List[FormattedMessage]:
"""
Format Gemini input contents into standardized message format, including system instruction handling.
Args:
contents: Input contents in various possible formats
config: Config object or dict that may contain system instruction
Returns:
List of formatted messages with role and content fields, with system message prepended if needed
"""
formatted_messages = format_gemini_input(contents)
# Check if system instruction is provided in config parameter
system_instruction = extract_gemini_system_instruction(config)
if system_instruction is not None:
has_system = any(msg.get("role") == "system" for msg in formatted_messages)
if not has_system:
from posthog.ai.types import FormattedMessage
system_message: FormattedMessage = {
"role": "system",
"content": system_instruction,
}
formatted_messages = [system_message] + list(formatted_messages)
return formatted_messages
def format_gemini_input(contents: Any) -> List[FormattedMessage]:
"""
Format Gemini input contents into standardized message format for PostHog tracking.
This function handles various input formats:
- String inputs
- List of strings, dicts, or objects
- Single dict or object
- Gemini-specific format with parts array
Args:
contents: Input contents in various possible formats
Returns:
List of formatted messages with role and content fields
"""
# Handle string input
if isinstance(contents, str):
return [{"role": "user", "content": contents}]
# Handle list input
if isinstance(contents, list):
formatted: List[FormattedMessage] = []
for item in contents:
if isinstance(item, str):
formatted.append({"role": "user", "content": item})
elif isinstance(item, dict):
formatted.append(_format_dict_message(item))
else:
formatted.append(_format_object_message(item))
return formatted
# Handle single dict input
if isinstance(contents, dict):
return [_format_dict_message(contents)]
# Handle single object input
return [_format_object_message(contents)]
def extract_gemini_web_search_count(response: Any) -> int:
"""
Extract web search count from Gemini response.
Gemini bills per request that uses grounding, not per query.
Returns 1 if grounding_metadata is present with actual search data, 0 otherwise.
Args:
response: The response from Gemini API
Returns:
1 if web search/grounding was used, 0 otherwise
"""
# Check for grounding_metadata in candidates
if hasattr(response, "candidates"):
for candidate in response.candidates:
if (
hasattr(candidate, "grounding_metadata")
and candidate.grounding_metadata
):
grounding_metadata = candidate.grounding_metadata
# Check if web_search_queries exists and is non-empty
if hasattr(grounding_metadata, "web_search_queries"):
queries = grounding_metadata.web_search_queries
if queries is not None and len(queries) > 0:
return 1
# Check if grounding_chunks exists and is non-empty
if hasattr(grounding_metadata, "grounding_chunks"):
chunks = grounding_metadata.grounding_chunks
if chunks is not None and len(chunks) > 0:
return 1
# Also check for google_search or grounding in function call names
if hasattr(candidate, "content") and candidate.content:
if hasattr(candidate.content, "parts") and candidate.content.parts:
for part in candidate.content.parts:
if hasattr(part, "function_call") and part.function_call:
function_name = getattr(
part.function_call, "name", ""
).lower()
if (
"google_search" in function_name
or "grounding" in function_name
):
return 1
return 0
def _extract_usage_from_metadata(metadata: Any) -> TokenUsage:
"""
Common logic to extract usage from Gemini metadata.
Used by both streaming and non-streaming paths.
Args:
metadata: usage_metadata from Gemini response or chunk
Returns:
TokenUsage with standardized usage
"""
usage = TokenUsage(
input_tokens=getattr(metadata, "prompt_token_count", 0),
output_tokens=getattr(metadata, "candidates_token_count", 0),
)
# Add cache tokens if present (don't add if 0)
if hasattr(metadata, "cached_content_token_count"):
cache_tokens = metadata.cached_content_token_count
if cache_tokens and cache_tokens > 0:
usage["cache_read_input_tokens"] = cache_tokens
# Add reasoning tokens if present (don't add if 0)
if hasattr(metadata, "thoughts_token_count"):
reasoning_tokens = metadata.thoughts_token_count
if reasoning_tokens and reasoning_tokens > 0:
usage["reasoning_tokens"] = reasoning_tokens
return usage
def extract_gemini_usage_from_response(response: Any) -> TokenUsage:
"""
Extract usage statistics from a full Gemini response (non-streaming).
Args:
response: The complete response from Gemini API
Returns:
TokenUsage with standardized usage statistics
"""
if not hasattr(response, "usage_metadata") or not response.usage_metadata:
return TokenUsage(input_tokens=0, output_tokens=0)
usage = _extract_usage_from_metadata(response.usage_metadata)
# Add web search count if present
web_search_count = extract_gemini_web_search_count(response)
if web_search_count > 0:
usage["web_search_count"] = web_search_count
return usage
def extract_gemini_usage_from_chunk(chunk: Any) -> TokenUsage:
"""
Extract usage statistics from a Gemini streaming chunk.
Args:
chunk: Streaming chunk from Gemini API
Returns:
TokenUsage with standardized usage statistics
"""
usage: TokenUsage = TokenUsage()
# Extract web search count from the chunk before checking for usage_metadata
# Web search indicators can appear on any chunk, not just those with usage data
web_search_count = extract_gemini_web_search_count(chunk)
if web_search_count > 0:
usage["web_search_count"] = web_search_count
if not hasattr(chunk, "usage_metadata") or not chunk.usage_metadata:
return usage
usage_from_metadata = _extract_usage_from_metadata(chunk.usage_metadata)
# Merge the usage from metadata with any web search count we found
usage.update(usage_from_metadata)
return usage
def extract_gemini_content_from_chunk(chunk: Any) -> Optional[Dict[str, Any]]:
"""
Extract content (text or function call) from a Gemini streaming chunk.
Args:
chunk: Streaming chunk from Gemini API
Returns:
Content block dictionary if present, None otherwise
"""
# Check for text content
if hasattr(chunk, "text") and chunk.text:
return {"type": "text", "text": chunk.text}
# Check for function calls in candidates
if hasattr(chunk, "candidates") and chunk.candidates:
for candidate in chunk.candidates:
if hasattr(candidate, "content") and candidate.content:
if hasattr(candidate.content, "parts") and candidate.content.parts:
for part in candidate.content.parts:
# Check for function_call part
if hasattr(part, "function_call") and part.function_call:
function_call = part.function_call
return {
"type": "function",
"function": {
"name": function_call.name,
"arguments": function_call.args,
},
}
# Also check for text in parts
elif hasattr(part, "text") and part.text:
return {"type": "text", "text": part.text}
return None
def format_gemini_streaming_output(
accumulated_content: Union[str, List[Any]],
) -> List[FormattedMessage]:
"""
Format the final output from Gemini streaming.
Args:
accumulated_content: Accumulated content from streaming (string, list of strings, or list of content blocks)
Returns:
List of formatted messages
"""
# Handle legacy string input (backward compatibility)
if isinstance(accumulated_content, str):
return [
{
"role": "assistant",
"content": [{"type": "text", "text": accumulated_content}],
}
]
# Handle list input
if isinstance(accumulated_content, list):
content: List[FormattedContentItem] = []
text_parts = []
for item in accumulated_content:
if isinstance(item, str):
# Legacy support: accumulate strings
text_parts.append(item)
elif isinstance(item, dict):
# New format: content blocks
if item.get("type") == "text":
text_parts.append(item.get("text", ""))
elif item.get("type") == "function":
# If we have accumulated text, add it first
if text_parts:
content.append(
{
"type": "text",
"text": "".join(text_parts),
}
)
text_parts = []
# Add the function call
content.append(
{
"type": "function",
"function": item.get("function", {}),
}
)
# Add any remaining text
if text_parts:
content.append(
{
"type": "text",
"text": "".join(text_parts),
}
)
# If we have content, return it
if content:
return [{"role": "assistant", "content": content}]
# Fallback for empty or unexpected input
return [{"role": "assistant", "content": [{"type": "text", "text": ""}]}]
+107 -27
View File
@@ -1,8 +1,8 @@
try:
import langchain # noqa: F401
import langchain_core # noqa: F401
except ImportError:
raise ModuleNotFoundError(
"Please install LangChain to use this feature: 'pip install langchain'"
"Please install LangChain to use this feature: 'pip install langchain-core'"
)
import json
@@ -20,8 +20,14 @@ from typing import (
)
from uuid import UUID
from langchain.callbacks.base import BaseCallbackHandler
from langchain.schema.agent import AgentAction, AgentFinish
try:
# LangChain 1.0+ and modern 0.x with langchain-core
from langchain_core.agents import AgentAction, AgentFinish
from langchain_core.callbacks.base import BaseCallbackHandler
except (ImportError, ModuleNotFoundError):
# Fallback for older LangChain versions
from langchain.callbacks.base import BaseCallbackHandler
from langchain.schema.agent import AgentAction, AgentFinish
from langchain_core.documents import Document
from langchain_core.messages import (
AIMessage,
@@ -29,13 +35,14 @@ from langchain_core.messages import (
FunctionMessage,
HumanMessage,
SystemMessage,
ToolMessage,
ToolCall,
ToolMessage,
)
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
from posthog import setup
from posthog.ai.sanitization import sanitize_langchain
from posthog.ai.utils import get_model_params, with_privacy_mode
from posthog.client import Client
@@ -72,6 +79,8 @@ class GenerationMetadata(SpanMetadata):
"""Base URL of the provider's API used in the run."""
tools: Optional[List[Dict[str, Any]]] = None
"""Tools provided to the model."""
posthog_properties: Optional[Dict[str, Any]] = None
"""PostHog properties of the run."""
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -413,6 +422,8 @@ class CallbackHandler(BaseCallbackHandler):
generation.model = model
if provider := metadata.get("ls_provider"):
generation.provider = provider
generation.posthog_properties = metadata.get("posthog_properties")
try:
base_url = serialized["kwargs"]["openai_api_base"]
if base_url is not None:
@@ -480,11 +491,12 @@ class CallbackHandler(BaseCallbackHandler):
event_properties = {
"$ai_trace_id": trace_id,
"$ai_input_state": with_privacy_mode(
self._ph_client, self._privacy_mode, run.input
self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
),
"$ai_latency": run.latency,
"$ai_span_name": run.name,
"$ai_span_id": run_id,
"$ai_framework": "langchain",
}
if parent_run_id is not None:
event_properties["$ai_parent_id"] = parent_run_id
@@ -494,6 +506,14 @@ class CallbackHandler(BaseCallbackHandler):
if isinstance(outputs, BaseException):
event_properties["$ai_error"] = _stringify_exception(outputs)
event_properties["$ai_is_error"] = True
event_properties = _capture_exception_and_update_properties(
self._ph_client,
outputs,
self._distinct_id,
self._groups,
event_properties,
)
elif outputs is not None:
event_properties["$ai_output_state"] = with_privacy_mode(
self._ph_client, self._privacy_mode, outputs
@@ -550,26 +570,41 @@ class CallbackHandler(BaseCallbackHandler):
"$ai_model": run.model,
"$ai_model_parameters": run.model_params,
"$ai_input": with_privacy_mode(
self._ph_client, self._privacy_mode, run.input
self._ph_client, self._privacy_mode, sanitize_langchain(run.input)
),
"$ai_http_status": 200,
"$ai_latency": run.latency,
"$ai_base_url": run.base_url,
"$ai_framework": "langchain",
}
if isinstance(run.posthog_properties, dict):
event_properties.update(run.posthog_properties)
if run.tools:
event_properties["$ai_tools"] = with_privacy_mode(
self._ph_client,
self._privacy_mode,
run.tools,
)
event_properties["$ai_tools"] = run.tools
if self._properties:
event_properties.update(self._properties)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
if isinstance(output, BaseException):
event_properties["$ai_http_status"] = _get_http_status(output)
event_properties["$ai_error"] = _stringify_exception(output)
event_properties["$ai_is_error"] = True
event_properties = _capture_exception_and_update_properties(
self._ph_client,
output,
self._distinct_id,
self._groups,
event_properties,
)
else:
# Add usage
usage = _parse_usage(output)
usage = _parse_usage(output, run.provider, run.model)
event_properties["$ai_input_tokens"] = usage.input_tokens
event_properties["$ai_output_tokens"] = usage.output_tokens
event_properties["$ai_cache_creation_input_tokens"] = (
@@ -587,18 +622,13 @@ class CallbackHandler(BaseCallbackHandler):
]
else:
completions = [
_extract_raw_esponse(generation) for generation in generation_result
_extract_raw_response(generation)
for generation in generation_result
]
event_properties["$ai_output_choices"] = with_privacy_mode(
self._ph_client, self._privacy_mode, completions
)
if self._properties:
event_properties.update(self._properties)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._ph_client.capture(
distinct_id=self._distinct_id or trace_id,
event="$ai_generation",
@@ -618,7 +648,7 @@ class CallbackHandler(BaseCallbackHandler):
)
def _extract_raw_esponse(last_response):
def _extract_raw_response(last_response):
"""Extract the response from the last response of the LLM call."""
# We return the text of the response if not empty
if last_response.text is not None and last_response.text.strip() != "":
@@ -689,6 +719,8 @@ class ModelUsage:
def _parse_usage_model(
usage: Union[BaseModel, dict],
provider: Optional[str] = None,
model: Optional[str] = None,
) -> ModelUsage:
if isinstance(usage, BaseModel):
usage = usage.__dict__
@@ -751,15 +783,38 @@ def _parse_usage_model(
"cache_read": "cache_read_tokens",
"reasoning": "reasoning_tokens",
}
return ModelUsage(
normalized_usage = ModelUsage(
**{
dataclass_key: parsed_usage.get(mapped_key) or 0
for mapped_key, dataclass_key in field_mapping.items()
},
)
# For Anthropic providers, LangChain reports input_tokens as the sum of all input tokens.
# Our cost calculation expects them to be separate for Anthropic, so we subtract cache tokens.
# Both cache_read and cache_write tokens should be subtracted since Anthropic's raw API
# reports input_tokens as tokens NOT read from or used to create a cache.
# For other providers (OpenAI, etc.), input_tokens already excludes cache tokens as expected.
# Match logic consistent with plugin-server: exact match on provider OR substring match on model
is_anthropic = False
if provider and provider.lower() == "anthropic":
is_anthropic = True
elif model and "anthropic" in model.lower():
is_anthropic = True
if is_anthropic and normalized_usage.input_tokens:
cache_tokens = (normalized_usage.cache_read_tokens or 0) + (
normalized_usage.cache_write_tokens or 0
)
if cache_tokens > 0:
normalized_usage.input_tokens = max(
normalized_usage.input_tokens - cache_tokens, 0
)
return normalized_usage
def _parse_usage(response: LLMResult) -> ModelUsage:
def _parse_usage(
response: LLMResult, provider: Optional[str] = None, model: Optional[str] = None
) -> ModelUsage:
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: ModelUsage = ModelUsage(
@@ -773,13 +828,15 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
llm_usage = _parse_usage_model(response.llm_output[key])
llm_usage = _parse_usage_model(
response.llm_output[key], provider, model
)
break
if hasattr(response, "generations"):
for generation in response.generations:
if "usage" in generation:
llm_usage = _parse_usage_model(generation["usage"])
llm_usage = _parse_usage_model(generation["usage"], provider, model)
break
for generation_chunk in generation:
@@ -787,7 +844,9 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
"usage_metadata" in generation_chunk.generation_info
):
llm_usage = _parse_usage_model(
generation_chunk.generation_info["usage_metadata"]
generation_chunk.generation_info["usage_metadata"],
provider,
model,
)
break
@@ -814,12 +873,33 @@ def _parse_usage(response: LLMResult) -> ModelUsage:
bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
)
if chunk_usage:
llm_usage = _parse_usage_model(chunk_usage)
llm_usage = _parse_usage_model(chunk_usage, provider, model)
break
return llm_usage
def _capture_exception_and_update_properties(
client: Client,
exception: BaseException,
distinct_id: Optional[Union[str, int, UUID]],
groups: Optional[Dict[str, Any]],
event_properties: Dict[str, Any],
):
if client.enable_exception_autocapture:
exception_id = client.capture_exception(
exception,
distinct_id=distinct_id,
groups=groups,
properties=event_properties,
)
if exception_id:
event_properties["$exception_event_id"] = exception_id
return event_properties
def _get_http_status(error: BaseException) -> int:
# OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
# Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
+16 -1
View File
@@ -1,5 +1,20 @@
from .openai import OpenAI
from .openai_async import AsyncOpenAI
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
from .openai_converter import (
format_openai_response,
format_openai_input,
extract_openai_tools,
format_openai_streaming_content,
)
__all__ = ["OpenAI", "AsyncOpenAI", "AzureOpenAI", "AsyncAzureOpenAI"]
__all__ = [
"OpenAI",
"AsyncOpenAI",
"AzureOpenAI",
"AsyncAzureOpenAI",
"format_openai_response",
"format_openai_input",
"extract_openai_tools",
"format_openai_streaming_content",
]
+134 -170
View File
@@ -2,6 +2,8 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from posthog.ai.types import TokenUsage
try:
import openai
except ImportError:
@@ -11,9 +13,17 @@ except ImportError:
from posthog.ai.utils import (
call_llm_and_track_usage,
get_model_params,
extract_available_tool_calls,
merge_usage_stats,
with_privacy_mode,
)
from posthog.ai.openai.openai_converter import (
extract_openai_usage_from_chunk,
extract_openai_content_from_chunk,
extract_openai_tool_calls_from_chunk,
accumulate_openai_tool_calls,
)
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
from posthog.client import Client as PostHogClient
from posthog import setup
@@ -32,6 +42,7 @@ class OpenAI(openai.OpenAI):
posthog_client: If provided, events will be captured via this client instead of the global `posthog`.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client or setup()
@@ -111,45 +122,36 @@ class WrappedResponses:
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {}
usage_stats: TokenUsage = TokenUsage()
final_content = []
model_from_response: Optional[str] = None
response = self._original.create(**kwargs)
def generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
nonlocal model_from_response
try:
for chunk in response:
if hasattr(chunk, "type") and chunk.type == "response.completed":
res = chunk.response
if res.output and len(res.output) > 0:
final_content.append(res.output[0])
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
# Extract model from response object in chunk (for stored prompts)
if hasattr(chunk, "response") and chunk.response:
if model_from_response is None and hasattr(
chunk.response, "model"
):
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
model_from_response = chunk.response.model
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
chunk.usage.input_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = (
chunk.usage.input_tokens_details.cached_tokens
)
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
if chunk_usage:
merge_usage_stats(usage_stats, chunk_usage)
# Extract content from chunk
content = extract_openai_content_from_chunk(chunk, "responses")
if content is not None:
final_content.append(content)
yield chunk
@@ -167,6 +169,8 @@ class WrappedResponses:
usage_stats,
latency,
output,
None, # Responses API doesn't have tools
model_from_response,
)
return generator()
@@ -179,56 +183,44 @@ class WrappedResponses:
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
format_openai_streaming_input,
format_openai_streaming_output,
)
from posthog.ai.utils import capture_streaming_event
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
output,
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
# Prepare standardized event data
formatted_input = format_openai_streaming_input(kwargs, "responses")
sanitized_input = sanitize_openai_response(formatted_input)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
event_data = StreamingEventData(
provider="openai",
model=model,
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
formatted_output=format_openai_streaming_output(output, "responses"),
usage_stats=usage_stats,
latency=latency,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
# Use the common capture function
capture_streaming_event(self._client._ph_client, event_data)
def parse(
self,
@@ -339,9 +331,10 @@ class WrappedCompletions:
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {}
usage_stats: TokenUsage = TokenUsage()
accumulated_content = []
accumulated_tools = {}
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
model_from_response: Optional[str] = None
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
@@ -350,70 +343,47 @@ class WrappedCompletions:
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
nonlocal accumulated_tool_calls
nonlocal model_from_response
try:
for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"prompt_tokens",
"completion_tokens",
"total_tokens",
]
}
# Extract model from chunk (Chat Completions chunks have model field)
if model_from_response is None and hasattr(chunk, "model"):
model_from_response = chunk.model
# Add support for cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = (
chunk.usage.prompt_tokens_details.cached_tokens
)
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
if chunk_usage:
merge_usage_stats(usage_stats, chunk_usage)
if (
hasattr(chunk, "choices")
and chunk.choices
and len(chunk.choices) > 0
):
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
# Extract content from chunk
content = extract_openai_content_from_chunk(chunk, "chat")
# Process tool calls
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
index = tool_call.index
if index not in accumulated_tools:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(
tool_call.function, "arguments"
):
accumulated_tools[
index
].function.arguments += (
tool_call.function.arguments
)
if content is not None:
accumulated_content.append(content)
# Extract and accumulate tool calls from chunk
chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
if chunk_tool_calls:
accumulate_openai_tool_calls(
accumulated_tool_calls, chunk_tool_calls
)
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
tools = list(accumulated_tools.values()) if accumulated_tools else None
# Convert accumulated tool calls dict to list
tool_calls_list = (
list(accumulated_tool_calls.values())
if accumulated_tool_calls
else None
)
self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
@@ -423,8 +393,10 @@ class WrappedCompletions:
kwargs,
usage_stats,
latency,
output,
tools,
accumulated_content,
tool_calls_list,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return generator()
@@ -437,56 +409,45 @@ class WrappedCompletions:
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
format_openai_streaming_input,
format_openai_streaming_output,
)
from posthog.ai.utils import capture_streaming_event
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
# Prepare standardized event data
formatted_input = format_openai_streaming_input(kwargs, "chat")
sanitized_input = sanitize_openai(formatted_input)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
event_data = StreamingEventData(
provider="openai",
model=model,
base_url=str(self._client.base_url),
kwargs=kwargs,
formatted_input=sanitized_input,
formatted_output=format_openai_streaming_output(output, "chat", tool_calls),
usage_stats=usage_stats,
latency=latency,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
# Use the common capture function
capture_streaming_event(self._client._ph_client, event_data)
class WrappedEmbeddings:
@@ -523,6 +484,7 @@ class WrappedEmbeddings:
Returns:
The response from OpenAI's embeddings.create call.
"""
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -545,7 +507,9 @@ class WrappedEmbeddings:
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
self._client._ph_client,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
+133 -115
View File
@@ -1,6 +1,8 @@
import time
import uuid
from typing import Any, Dict, List, Optional, cast
from typing import Any, Dict, List, Optional
from posthog.ai.types import TokenUsage
try:
import openai
@@ -12,9 +14,19 @@ except ImportError:
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
extract_available_tool_calls,
get_model_params,
merge_usage_stats,
with_privacy_mode,
)
from posthog.ai.openai.openai_converter import (
extract_openai_usage_from_chunk,
extract_openai_content_from_chunk,
extract_openai_tool_calls_from_chunk,
accumulate_openai_tool_calls,
format_openai_streaming_output,
)
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
from posthog.client import Client as PostHogClient
@@ -33,6 +45,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
of the global posthog.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client or setup()
@@ -65,6 +78,7 @@ class WrappedResponses:
def __getattr__(self, name):
"""Fallback to original responses object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def create(
@@ -112,45 +126,36 @@ class WrappedResponses:
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {}
usage_stats: TokenUsage = TokenUsage()
final_content = []
model_from_response: Optional[str] = None
response = await self._original.create(**kwargs)
async def async_generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
nonlocal model_from_response
try:
async for chunk in response:
if hasattr(chunk, "type") and chunk.type == "response.completed":
res = chunk.response
if res.output and len(res.output) > 0:
final_content.append(res.output[0])
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
# Extract model from response object in chunk (for stored prompts)
if hasattr(chunk, "response") and chunk.response:
if model_from_response is None and hasattr(
chunk.response, "model"
):
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
model_from_response = chunk.response.model
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
chunk.usage.input_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = (
chunk.usage.input_tokens_details.cached_tokens
)
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "responses")
if chunk_usage:
merge_usage_stats(usage_stats, chunk_usage)
# Extract content from chunk
content = extract_openai_content_from_chunk(chunk, "responses")
if content is not None:
final_content.append(content)
yield chunk
@@ -158,6 +163,7 @@ class WrappedResponses:
end_time = time.time()
latency = end_time - start_time
output = final_content
await self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
@@ -168,6 +174,8 @@ class WrappedResponses:
usage_stats,
latency,
output,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return async_generator()
@@ -180,25 +188,31 @@ class WrappedResponses:
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model": model,
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
self._client._ph_client,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
output,
format_openai_streaming_output(output, "responses"),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
@@ -213,12 +227,17 @@ class WrappedResponses:
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
# Add web search count if present
web_search_count = usage_stats.get("web_search_count")
if (
web_search_count is not None
and isinstance(web_search_count, int)
and web_search_count > 0
):
event_properties["$ai_web_search_count"] = web_search_count
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -342,9 +361,10 @@ class WrappedCompletions:
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {}
usage_stats: TokenUsage = TokenUsage()
accumulated_content = []
accumulated_tools = {}
accumulated_tool_calls: Dict[int, Dict[str, Any]] = {}
model_from_response: Optional[str] = None
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
@@ -354,70 +374,45 @@ class WrappedCompletions:
async def async_generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
nonlocal accumulated_tool_calls
nonlocal model_from_response
try:
async for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"prompt_tokens",
"completion_tokens",
"total_tokens",
]
}
# Extract model from chunk (Chat Completions chunks have model field)
if model_from_response is None and hasattr(chunk, "model"):
model_from_response = chunk.model
# Add support for cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = (
chunk.usage.prompt_tokens_details.cached_tokens
)
# Extract usage stats from chunk
chunk_usage = extract_openai_usage_from_chunk(chunk, "chat")
if chunk_usage:
merge_usage_stats(usage_stats, chunk_usage)
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = (
chunk.usage.output_tokens_details.reasoning_tokens
)
# Extract content from chunk
content = extract_openai_content_from_chunk(chunk, "chat")
if content is not None:
accumulated_content.append(content)
if (
hasattr(chunk, "choices")
and chunk.choices
and len(chunk.choices) > 0
):
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
# Process tool calls
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
index = tool_call.index
if index not in accumulated_tools:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(
tool_call.function, "arguments"
):
accumulated_tools[
index
].function.arguments += (
tool_call.function.arguments
)
# Extract and accumulate tool calls from chunk
chunk_tool_calls = extract_openai_tool_calls_from_chunk(chunk)
if chunk_tool_calls:
accumulate_openai_tool_calls(
accumulated_tool_calls, chunk_tool_calls
)
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
tools = list(accumulated_tools.values()) if accumulated_tools else None
# Convert accumulated tool calls dict to list
tool_calls_list = (
list(accumulated_tool_calls.values())
if accumulated_tool_calls
else None
)
await self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
@@ -427,8 +422,10 @@ class WrappedCompletions:
kwargs,
usage_stats,
latency,
output,
tools,
accumulated_content,
tool_calls_list,
extract_available_tool_calls("openai", kwargs),
model_from_response,
)
return async_generator()
@@ -441,29 +438,36 @@ class WrappedCompletions:
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: Dict[str, int],
usage_stats: TokenUsage,
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# Use model from kwargs, fallback to model from response
model = kwargs.get("model") or model_from_response or "unknown"
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model": model,
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")
self._client._ph_client,
posthog_privacy_mode,
sanitize_openai(kwargs.get("messages")),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
format_openai_streaming_output(output, "chat", tool_calls),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get(
"cache_read_input_tokens", 0
),
@@ -474,12 +478,18 @@ class WrappedCompletions:
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
# Add web search count if present
web_search_count = usage_stats.get("web_search_count")
if (
web_search_count is not None
and isinstance(web_search_count, int)
and web_search_count > 0
):
event_properties["$ai_web_search_count"] = web_search_count
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
@@ -502,6 +512,7 @@ class WrappedEmbeddings:
def __getattr__(self, name):
"""Fallback to original embeddings object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def create(
@@ -527,6 +538,7 @@ class WrappedEmbeddings:
Returns:
The response from OpenAI's embeddings.create call.
"""
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
@@ -535,12 +547,13 @@ class WrappedEmbeddings:
end_time = time.time()
# Extract usage statistics if available
usage_stats = {}
usage_stats: TokenUsage = TokenUsage()
if hasattr(response, "usage") and response.usage:
usage_stats = {
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
"total_tokens": getattr(response.usage, "total_tokens", 0),
}
usage_stats = TokenUsage(
input_tokens=getattr(response.usage, "prompt_tokens", 0),
output_tokens=getattr(response.usage, "completion_tokens", 0),
)
latency = end_time - start_time
@@ -549,10 +562,12 @@ class WrappedEmbeddings:
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(
self._client._ph_client, posthog_privacy_mode, kwargs.get("input")
self._client._ph_client,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
@@ -583,6 +598,7 @@ class WrappedBeta:
def __getattr__(self, name):
"""Fallback to original beta object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
@@ -599,6 +615,7 @@ class WrappedBetaChat:
def __getattr__(self, name):
"""Fallback to original beta chat object for any methods we don't explicitly handle."""
return getattr(self._original, name)
@property
@@ -615,6 +632,7 @@ class WrappedBetaCompletions:
def __getattr__(self, name):
"""Fallback to original beta completions object for any methods we don't explicitly handle."""
return getattr(self._original, name)
async def parse(
+741
View File
@@ -0,0 +1,741 @@
"""
OpenAI-specific conversion utilities.
This module handles the conversion of OpenAI API responses and inputs
into standardized formats for PostHog tracking. It supports both
Chat Completions API and Responses API formats.
"""
from typing import Any, Dict, List, Optional
from posthog.ai.types import (
FormattedContentItem,
FormattedFunctionCall,
FormattedImageContent,
FormattedMessage,
FormattedTextContent,
TokenUsage,
)
def format_openai_response(response: Any) -> List[FormattedMessage]:
"""
Format an OpenAI response into standardized message format.
Handles both Chat Completions API and Responses API formats.
Args:
response: The response object from OpenAI API
Returns:
List of formatted messages with role and content
"""
output: List[FormattedMessage] = []
if response is None:
return output
# Handle Chat Completions response format
if hasattr(response, "choices"):
content: List[FormattedContentItem] = []
role = "assistant"
for choice in response.choices:
if hasattr(choice, "message") and choice.message:
if choice.message.role:
role = choice.message.role
if choice.message.content:
content.append(
{
"type": "text",
"text": choice.message.content,
}
)
if hasattr(choice.message, "tool_calls") and choice.message.tool_calls:
for tool_call in choice.message.tool_calls:
content.append(
{
"type": "function",
"id": tool_call.id,
"function": {
"name": tool_call.function.name,
"arguments": tool_call.function.arguments,
},
}
)
# Handle audio output (gpt-4o-audio-preview)
if hasattr(choice.message, "audio") and choice.message.audio:
# Convert Pydantic model to dict to capture all fields from OpenAI
audio_dict = choice.message.audio.model_dump()
content.append({"type": "audio", **audio_dict})
if content:
output.append(
{
"role": role,
"content": content,
}
)
# Handle Responses API format
if hasattr(response, "output"):
content = []
role = "assistant"
for item in response.output:
if item.type == "message":
role = item.role
if hasattr(item, "content") and isinstance(item.content, list):
for content_item in item.content:
if (
hasattr(content_item, "type")
and content_item.type == "output_text"
and hasattr(content_item, "text")
):
content.append(
{
"type": "text",
"text": content_item.text,
}
)
elif hasattr(content_item, "text"):
content.append({"type": "text", "text": content_item.text})
elif (
hasattr(content_item, "type")
and content_item.type == "input_image"
and hasattr(content_item, "image_url")
):
image_content: FormattedImageContent = {
"type": "image",
"image": content_item.image_url,
}
content.append(image_content)
elif hasattr(item, "content"):
text_content = {"type": "text", "text": str(item.content)}
content.append(text_content)
elif hasattr(item, "type") and item.type == "function_call":
content.append(
{
"type": "function",
"id": getattr(item, "call_id", getattr(item, "id", "")),
"function": {
"name": item.name,
"arguments": getattr(item, "arguments", {}),
},
}
)
if content:
output.append(
{
"role": role,
"content": content,
}
)
return output
def format_openai_input(
messages: Optional[List[Dict[str, Any]]] = None, input_data: Optional[Any] = None
) -> List[FormattedMessage]:
"""
Format OpenAI input messages.
Handles both messages parameter (Chat Completions) and input parameter (Responses API).
Args:
messages: List of message dictionaries for Chat Completions API
input_data: Input data for Responses API
Returns:
List of formatted messages
"""
formatted_messages: List[FormattedMessage] = []
# Handle Chat Completions API format
if messages is not None:
for msg in messages:
formatted_messages.append(
{
"role": msg.get("role", "user"),
"content": msg.get("content", ""),
}
)
# Handle Responses API format
if input_data is not None:
if isinstance(input_data, list):
for item in input_data:
role = "user"
content = ""
if isinstance(item, dict):
role = item.get("role", "user")
content = item.get("content", "")
elif isinstance(item, str):
content = item
else:
content = str(item)
formatted_messages.append({"role": role, "content": content})
elif isinstance(input_data, str):
formatted_messages.append({"role": "user", "content": input_data})
else:
formatted_messages.append({"role": "user", "content": str(input_data)})
return formatted_messages
def extract_openai_tools(kwargs: Dict[str, Any]) -> Optional[Any]:
"""
Extract tool definitions from OpenAI API kwargs.
Args:
kwargs: Keyword arguments passed to OpenAI API
Returns:
Tool definitions if present, None otherwise
"""
# Check for tools parameter (newer API)
if "tools" in kwargs:
return kwargs["tools"]
# Check for functions parameter (older API)
if "functions" in kwargs:
return kwargs["functions"]
return None
def format_openai_streaming_content(
accumulated_content: str, tool_calls: Optional[List[Dict[str, Any]]] = None
) -> List[FormattedContentItem]:
"""
Format content from OpenAI streaming response.
Used by streaming handlers to format accumulated content.
Args:
accumulated_content: Accumulated text content from streaming
tool_calls: Optional list of tool calls accumulated during streaming
Returns:
List of formatted content items
"""
formatted: List[FormattedContentItem] = []
# Add text content if present
if accumulated_content:
text_content: FormattedTextContent = {
"type": "text",
"text": accumulated_content,
}
formatted.append(text_content)
# Add tool calls if present
if tool_calls:
for tool_call in tool_calls:
function_call: FormattedFunctionCall = {
"type": "function",
"id": tool_call.get("id"),
"function": tool_call.get("function", {}),
}
formatted.append(function_call)
return formatted
def extract_openai_web_search_count(response: Any) -> int:
"""
Extract web search count from OpenAI response.
Uses a two-tier detection strategy:
1. Priority 1 (exact count): Check for output[].type == "web_search_call" (Responses API)
2. Priority 2 (binary detection): Check for various web search indicators:
- Root-level citations, search_results, or usage.search_context_size (Perplexity)
- Annotations with type "url_citation" in choices/output (including delta for streaming)
Args:
response: The response from OpenAI API
Returns:
Number of web search requests (exact count or binary 1/0)
"""
# Priority 1: Check for exact count in Responses API output
if hasattr(response, "output"):
web_search_count = 0
for item in response.output:
if hasattr(item, "type") and item.type == "web_search_call":
web_search_count += 1
web_search_count = max(0, web_search_count)
if web_search_count > 0:
return web_search_count
# Priority 2: Binary detection (returns 1 or 0)
# Check root-level indicators (Perplexity)
if hasattr(response, "citations"):
citations = getattr(response, "citations")
if citations and len(citations) > 0:
return 1
if hasattr(response, "search_results"):
search_results = getattr(response, "search_results")
if search_results and len(search_results) > 0:
return 1
if hasattr(response, "usage") and hasattr(response.usage, "search_context_size"):
if response.usage.search_context_size:
return 1
# Check for url_citation annotations in choices (Chat Completions)
if hasattr(response, "choices"):
for choice in response.choices:
# Check message.annotations (non-streaming or final chunk)
if hasattr(choice, "message") and hasattr(choice.message, "annotations"):
annotations = choice.message.annotations
if annotations:
for annotation in annotations:
# Support both dict and object formats
annotation_type = (
annotation.get("type")
if isinstance(annotation, dict)
else getattr(annotation, "type", None)
)
if annotation_type == "url_citation":
return 1
# Check delta.annotations (streaming chunks)
if hasattr(choice, "delta") and hasattr(choice.delta, "annotations"):
annotations = choice.delta.annotations
if annotations:
for annotation in annotations:
# Support both dict and object formats
annotation_type = (
annotation.get("type")
if isinstance(annotation, dict)
else getattr(annotation, "type", None)
)
if annotation_type == "url_citation":
return 1
# Check for url_citation annotations in output (Responses API)
if hasattr(response, "output"):
for item in response.output:
if hasattr(item, "content") and isinstance(item.content, list):
for content_item in item.content:
if hasattr(content_item, "annotations"):
annotations = content_item.annotations
if annotations:
for annotation in annotations:
# Support both dict and object formats
annotation_type = (
annotation.get("type")
if isinstance(annotation, dict)
else getattr(annotation, "type", None)
)
if annotation_type == "url_citation":
return 1
return 0
def extract_openai_usage_from_response(response: Any) -> TokenUsage:
"""
Extract usage statistics from a full OpenAI response (non-streaming).
Handles both Chat Completions and Responses API.
Args:
response: The complete response from OpenAI API
Returns:
TokenUsage with standardized usage statistics
"""
if not hasattr(response, "usage"):
return TokenUsage(input_tokens=0, output_tokens=0)
cached_tokens = 0
input_tokens = 0
output_tokens = 0
reasoning_tokens = 0
# Responses API format
if hasattr(response.usage, "input_tokens"):
input_tokens = response.usage.input_tokens
if hasattr(response.usage, "output_tokens"):
output_tokens = response.usage.output_tokens
if hasattr(response.usage, "input_tokens_details") and hasattr(
response.usage.input_tokens_details, "cached_tokens"
):
cached_tokens = response.usage.input_tokens_details.cached_tokens
if hasattr(response.usage, "output_tokens_details") and hasattr(
response.usage.output_tokens_details, "reasoning_tokens"
):
reasoning_tokens = response.usage.output_tokens_details.reasoning_tokens
# Chat Completions format
if hasattr(response.usage, "prompt_tokens"):
input_tokens = response.usage.prompt_tokens
if hasattr(response.usage, "completion_tokens"):
output_tokens = response.usage.completion_tokens
if hasattr(response.usage, "prompt_tokens_details") and hasattr(
response.usage.prompt_tokens_details, "cached_tokens"
):
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
if hasattr(response.usage, "completion_tokens_details") and hasattr(
response.usage.completion_tokens_details, "reasoning_tokens"
):
reasoning_tokens = response.usage.completion_tokens_details.reasoning_tokens
result = TokenUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
)
if cached_tokens > 0:
result["cache_read_input_tokens"] = cached_tokens
if reasoning_tokens > 0:
result["reasoning_tokens"] = reasoning_tokens
web_search_count = extract_openai_web_search_count(response)
if web_search_count > 0:
result["web_search_count"] = web_search_count
return result
def extract_openai_usage_from_chunk(
chunk: Any, provider_type: str = "chat"
) -> TokenUsage:
"""
Extract usage statistics from an OpenAI streaming chunk.
Handles both Chat Completions and Responses API formats.
Args:
chunk: Streaming chunk from OpenAI API
provider_type: Either "chat" or "responses" to handle different API formats
Returns:
Dictionary of usage statistics
"""
usage: TokenUsage = TokenUsage()
if provider_type == "chat":
# Extract web search count from the chunk before checking for usage
# Web search indicators (citations, annotations) can appear on any chunk,
# not just those with usage data
web_search_count = extract_openai_web_search_count(chunk)
if web_search_count > 0:
usage["web_search_count"] = web_search_count
if not hasattr(chunk, "usage") or not chunk.usage:
return usage
# Chat Completions API uses prompt_tokens and completion_tokens
# Standardize to input_tokens and output_tokens
usage["input_tokens"] = getattr(chunk.usage, "prompt_tokens", 0)
usage["output_tokens"] = getattr(chunk.usage, "completion_tokens", 0)
# Handle cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage["cache_read_input_tokens"] = (
chunk.usage.prompt_tokens_details.cached_tokens
)
# Handle reasoning tokens
if hasattr(chunk.usage, "completion_tokens_details") and hasattr(
chunk.usage.completion_tokens_details, "reasoning_tokens"
):
usage["reasoning_tokens"] = (
chunk.usage.completion_tokens_details.reasoning_tokens
)
elif provider_type == "responses":
# For Responses API, usage is only in chunk.response.usage for completed events
if hasattr(chunk, "type") and chunk.type == "response.completed":
if (
hasattr(chunk, "response")
and hasattr(chunk.response, "usage")
and chunk.response.usage
):
response_usage = chunk.response.usage
usage["input_tokens"] = getattr(response_usage, "input_tokens", 0)
usage["output_tokens"] = getattr(response_usage, "output_tokens", 0)
# Handle cached tokens
if hasattr(response_usage, "input_tokens_details") and hasattr(
response_usage.input_tokens_details, "cached_tokens"
):
usage["cache_read_input_tokens"] = (
response_usage.input_tokens_details.cached_tokens
)
# Handle reasoning tokens
if hasattr(response_usage, "output_tokens_details") and hasattr(
response_usage.output_tokens_details, "reasoning_tokens"
):
usage["reasoning_tokens"] = (
response_usage.output_tokens_details.reasoning_tokens
)
# Extract web search count from the complete response
if hasattr(chunk, "response"):
web_search_count = extract_openai_web_search_count(chunk.response)
if web_search_count > 0:
usage["web_search_count"] = web_search_count
return usage
def extract_openai_content_from_chunk(
chunk: Any, provider_type: str = "chat"
) -> Optional[str]:
"""
Extract content from an OpenAI streaming chunk.
Handles both Chat Completions and Responses API formats.
Args:
chunk: Streaming chunk from OpenAI API
provider_type: Either "chat" or "responses" to handle different API formats
Returns:
Text content if present, None otherwise
"""
if provider_type == "chat":
# Chat Completions API format
if (
hasattr(chunk, "choices")
and chunk.choices
and len(chunk.choices) > 0
and chunk.choices[0].delta
and chunk.choices[0].delta.content
):
return chunk.choices[0].delta.content
elif provider_type == "responses":
# Responses API format
if hasattr(chunk, "type") and chunk.type == "response.completed":
if hasattr(chunk, "response") and chunk.response:
res = chunk.response
if res.output and len(res.output) > 0:
# Return the full output for responses
return res.output[0]
return None
def extract_openai_tool_calls_from_chunk(chunk: Any) -> Optional[List[Dict[str, Any]]]:
"""
Extract tool calls from an OpenAI streaming chunk.
Args:
chunk: Streaming chunk from OpenAI API
Returns:
List of tool call deltas if present, None otherwise
"""
if (
hasattr(chunk, "choices")
and chunk.choices
and len(chunk.choices) > 0
and chunk.choices[0].delta
and hasattr(chunk.choices[0].delta, "tool_calls")
and chunk.choices[0].delta.tool_calls
):
tool_calls = []
for tool_call in chunk.choices[0].delta.tool_calls:
tc_dict = {
"index": getattr(tool_call, "index", None),
}
if hasattr(tool_call, "id") and tool_call.id:
tc_dict["id"] = tool_call.id
if hasattr(tool_call, "type") and tool_call.type:
tc_dict["type"] = tool_call.type
if hasattr(tool_call, "function") and tool_call.function:
function_dict = {}
if hasattr(tool_call.function, "name") and tool_call.function.name:
function_dict["name"] = tool_call.function.name
if (
hasattr(tool_call.function, "arguments")
and tool_call.function.arguments
):
function_dict["arguments"] = tool_call.function.arguments
tc_dict["function"] = function_dict
tool_calls.append(tc_dict)
return tool_calls
return None
def accumulate_openai_tool_calls(
accumulated_tool_calls: Dict[int, Dict[str, Any]],
chunk_tool_calls: List[Dict[str, Any]],
) -> None:
"""
Accumulate tool calls from streaming chunks.
OpenAI sends tool calls incrementally:
- First chunk has id, type, function.name and partial function.arguments
- Subsequent chunks have more function.arguments
Args:
accumulated_tool_calls: Dictionary mapping index to accumulated tool call data
chunk_tool_calls: List of tool call deltas from current chunk
"""
for tool_call_delta in chunk_tool_calls:
index = tool_call_delta.get("index")
if index is None:
continue
# Initialize tool call if first time seeing this index
if index not in accumulated_tool_calls:
accumulated_tool_calls[index] = {
"id": "",
"type": "function",
"function": {
"name": "",
"arguments": "",
},
}
# Update with new data from delta
tc = accumulated_tool_calls[index]
if "id" in tool_call_delta and tool_call_delta["id"]:
tc["id"] = tool_call_delta["id"]
if "type" in tool_call_delta and tool_call_delta["type"]:
tc["type"] = tool_call_delta["type"]
if "function" in tool_call_delta:
func_delta = tool_call_delta["function"]
if "name" in func_delta and func_delta["name"]:
tc["function"]["name"] = func_delta["name"]
if "arguments" in func_delta and func_delta["arguments"]:
# Arguments are sent incrementally, concatenate them
tc["function"]["arguments"] += func_delta["arguments"]
def format_openai_streaming_output(
accumulated_content: Any,
provider_type: str = "chat",
tool_calls: Optional[List[Dict[str, Any]]] = None,
) -> List[FormattedMessage]:
"""
Format the final output from OpenAI streaming.
Args:
accumulated_content: Accumulated content from streaming (string for chat, list for responses)
provider_type: Either "chat" or "responses" to handle different API formats
tool_calls: Optional list of accumulated tool calls
Returns:
List of formatted messages
"""
if provider_type == "chat":
content_items: List[FormattedContentItem] = []
# Add text content if present
if isinstance(accumulated_content, str) and accumulated_content:
content_items.append({"type": "text", "text": accumulated_content})
elif isinstance(accumulated_content, list):
# If it's a list of strings, join them
text = "".join(str(item) for item in accumulated_content if item)
if text:
content_items.append({"type": "text", "text": text})
# Add tool calls if present
if tool_calls:
for tool_call in tool_calls:
if "function" in tool_call:
function_call: FormattedFunctionCall = {
"type": "function",
"id": tool_call.get("id", ""),
"function": tool_call["function"],
}
content_items.append(function_call)
# Return formatted message with content
if content_items:
return [{"role": "assistant", "content": content_items}]
else:
# Empty response
return [{"role": "assistant", "content": []}]
elif provider_type == "responses":
# Responses API: accumulated_content is a list of output items
if isinstance(accumulated_content, list) and accumulated_content:
# The output is already formatted, just return it
return accumulated_content
elif isinstance(accumulated_content, str):
return [
{
"role": "assistant",
"content": [{"type": "text", "text": accumulated_content}],
}
]
# Fallback for any other format
return [
{
"role": "assistant",
"content": [{"type": "text", "text": str(accumulated_content)}],
}
]
def format_openai_streaming_input(
kwargs: Dict[str, Any], api_type: str = "chat"
) -> Any:
"""
Format OpenAI streaming input based on API type.
Args:
kwargs: Keyword arguments passed to OpenAI API
api_type: Either "chat" or "responses"
Returns:
Formatted input ready for PostHog tracking
"""
from posthog.ai.utils import merge_system_prompt
return merge_system_prompt(kwargs, "openai")
+248
View File
@@ -0,0 +1,248 @@
import os
import re
from typing import Any
from urllib.parse import urlparse
REDACTED_IMAGE_PLACEHOLDER = "[base64 image redacted]"
def _is_multimodal_enabled() -> bool:
"""Check if multimodal capture is enabled via environment variable."""
return os.environ.get("_INTERNAL_LLMA_MULTIMODAL", "").lower() in (
"true",
"1",
"yes",
)
def is_base64_data_url(text: str) -> bool:
return re.match(r"^data:([^;]+);base64,", text) is not None
def is_valid_url(text: str) -> bool:
try:
result = urlparse(text)
return bool(result.scheme and result.netloc)
except Exception:
pass
return text.startswith(("/", "./", "../"))
def is_raw_base64(text: str) -> bool:
if is_valid_url(text):
return False
return len(text) > 20 and re.match(r"^[A-Za-z0-9+/]+=*$", text) is not None
def redact_base64_data_url(value: Any) -> Any:
if _is_multimodal_enabled():
return value
if not isinstance(value, str):
return value
if is_base64_data_url(value):
return REDACTED_IMAGE_PLACEHOLDER
if is_raw_base64(value):
return REDACTED_IMAGE_PLACEHOLDER
return value
def process_messages(messages: Any, transform_content_func) -> Any:
if not messages:
return messages
def process_content(content: Any) -> Any:
if isinstance(content, str):
return content
if not content:
return content
if isinstance(content, list):
return [transform_content_func(item) for item in content]
return transform_content_func(content)
def process_message(msg: Any) -> Any:
if not isinstance(msg, dict) or "content" not in msg:
return msg
return {**msg, "content": process_content(msg["content"])}
if isinstance(messages, list):
return [process_message(msg) for msg in messages]
return process_message(messages)
def sanitize_openai_image(item: Any) -> Any:
if not isinstance(item, dict):
return item
if (
item.get("type") == "image_url"
and isinstance(item.get("image_url"), dict)
and "url" in item["image_url"]
):
return {
**item,
"image_url": {
**item["image_url"],
"url": redact_base64_data_url(item["image_url"]["url"]),
},
}
if item.get("type") == "audio" and "data" in item:
if _is_multimodal_enabled():
return item
return {**item, "data": REDACTED_IMAGE_PLACEHOLDER}
return item
def sanitize_openai_response_image(item: Any) -> Any:
if not isinstance(item, dict):
return item
if item.get("type") == "input_image" and "image_url" in item:
return {
**item,
"image_url": redact_base64_data_url(item["image_url"]),
}
return item
def sanitize_anthropic_image(item: Any) -> Any:
if _is_multimodal_enabled():
return item
if not isinstance(item, dict):
return item
if (
item.get("type") == "image"
and isinstance(item.get("source"), dict)
and item["source"].get("type") == "base64"
and "data" in item["source"]
):
return {
**item,
"source": {
**item["source"],
"data": REDACTED_IMAGE_PLACEHOLDER,
},
}
return item
def sanitize_gemini_part(part: Any) -> Any:
if _is_multimodal_enabled():
return part
if not isinstance(part, dict):
return part
if (
"inline_data" in part
and isinstance(part["inline_data"], dict)
and "data" in part["inline_data"]
):
return {
**part,
"inline_data": {
**part["inline_data"],
"data": REDACTED_IMAGE_PLACEHOLDER,
},
}
return part
def process_gemini_item(item: Any) -> Any:
if not isinstance(item, dict):
return item
if "parts" in item and item["parts"]:
parts = item["parts"]
if isinstance(parts, list):
parts = [sanitize_gemini_part(part) for part in parts]
else:
parts = sanitize_gemini_part(parts)
return {**item, "parts": parts}
return item
def sanitize_langchain_image(item: Any) -> Any:
if not isinstance(item, dict):
return item
if (
item.get("type") == "image_url"
and isinstance(item.get("image_url"), dict)
and "url" in item["image_url"]
):
return {
**item,
"image_url": {
**item["image_url"],
"url": redact_base64_data_url(item["image_url"]["url"]),
},
}
if item.get("type") == "image" and "data" in item:
return {**item, "data": redact_base64_data_url(item["data"])}
if (
item.get("type") == "image"
and isinstance(item.get("source"), dict)
and "data" in item["source"]
):
if _is_multimodal_enabled():
return item
return {
**item,
"source": {
**item["source"],
"data": REDACTED_IMAGE_PLACEHOLDER,
},
}
if item.get("type") == "media" and "data" in item:
return {**item, "data": redact_base64_data_url(item["data"])}
return item
def sanitize_openai(data: Any) -> Any:
return process_messages(data, sanitize_openai_image)
def sanitize_openai_response(data: Any) -> Any:
return process_messages(data, sanitize_openai_response_image)
def sanitize_anthropic(data: Any) -> Any:
return process_messages(data, sanitize_anthropic_image)
def sanitize_gemini(data: Any) -> Any:
if not data:
return data
if isinstance(data, list):
return [process_gemini_item(item) for item in data]
return process_gemini_item(data)
def sanitize_langchain(data: Any) -> Any:
return process_messages(data, sanitize_langchain_image)
+125
View File
@@ -0,0 +1,125 @@
"""
Common type definitions for PostHog AI SDK.
These types are used for formatting messages and responses across different AI providers
(Anthropic, OpenAI, Gemini, etc.) to ensure consistency in tracking and data structure.
"""
from typing import Any, Dict, List, Optional, TypedDict, Union
class FormattedTextContent(TypedDict):
"""Formatted text content item."""
type: str # Literal["text"]
text: str
class FormattedFunctionCall(TypedDict, total=False):
"""Formatted function/tool call content item."""
type: str # Literal["function"]
id: Optional[str]
function: Dict[str, Any] # Contains 'name' and 'arguments'
class FormattedImageContent(TypedDict):
"""Formatted image content item."""
type: str # Literal["image"]
image: str
# Union type for all formatted content items
FormattedContentItem = Union[
FormattedTextContent,
FormattedFunctionCall,
FormattedImageContent,
Dict[str, Any], # Fallback for unknown content types
]
class FormattedMessage(TypedDict):
"""
Standardized message format for PostHog tracking.
Used across all providers to ensure consistent message structure
when sending events to PostHog.
"""
role: str
content: Union[str, List[FormattedContentItem], Any]
class TokenUsage(TypedDict, total=False):
"""
Token usage information for AI model responses.
Different providers may populate different fields.
"""
input_tokens: int
output_tokens: int
cache_read_input_tokens: Optional[int]
cache_creation_input_tokens: Optional[int]
reasoning_tokens: Optional[int]
web_search_count: Optional[int]
class ProviderResponse(TypedDict, total=False):
"""
Standardized provider response format.
Used for consistent response formatting across all providers.
"""
messages: List[FormattedMessage]
usage: TokenUsage
error: Optional[str]
class StreamingContentBlock(TypedDict, total=False):
"""
Content block used during streaming to accumulate content.
Used for tracking text and function calls as they stream in.
"""
type: str # "text" or "function"
text: Optional[str]
id: Optional[str]
function: Optional[Dict[str, Any]]
class ToolInProgress(TypedDict):
"""
Tracks a tool/function call being accumulated during streaming.
Used by Anthropic to accumulate JSON input for tools.
"""
block: StreamingContentBlock
input_string: str
class StreamingEventData(TypedDict):
"""
Standardized data for streaming events across all providers.
This type ensures consistent data structure when capturing streaming events,
with all provider-specific formatting already completed.
"""
provider: str # "openai", "anthropic", "gemini"
model: str
base_url: str
kwargs: Dict[str, Any] # Original kwargs for tool extraction and special handling
formatted_input: Any # Provider-formatted input ready for tracking
formatted_output: Any # Provider-formatted output ready for tracking
usage_stats: TokenUsage
latency: float
distinct_id: Optional[str]
trace_id: Optional[str]
properties: Optional[Dict[str, Any]]
privacy_mode: bool
groups: Optional[Dict[str, Any]]
+480 -425
View File
File diff suppressed because it is too large Load Diff
+496 -156
View File
File diff suppressed because it is too large Load Diff
+86 -6
View File
@@ -22,6 +22,9 @@ class ContextScope:
self.session_id: Optional[str] = None
self.distinct_id: Optional[str] = None
self.tags: Dict[str, Any] = {}
self.capture_exception_code_variables: Optional[bool] = None
self.code_variables_mask_patterns: Optional[list] = None
self.code_variables_ignore_patterns: Optional[list] = None
def set_session_id(self, session_id: str):
self.session_id = session_id
@@ -32,6 +35,15 @@ class ContextScope:
def add_tag(self, key: str, value: Any):
self.tags[key] = value
def set_capture_exception_code_variables(self, enabled: bool):
self.capture_exception_code_variables = enabled
def set_code_variables_mask_patterns(self, mask_patterns: list):
self.code_variables_mask_patterns = mask_patterns
def set_code_variables_ignore_patterns(self, ignore_patterns: list):
self.code_variables_ignore_patterns = ignore_patterns
def get_parent(self):
return self.parent
@@ -50,14 +62,34 @@ class ContextScope:
return None
def collect_tags(self) -> Dict[str, Any]:
tags = self.tags.copy()
if self.parent and not self.fresh:
# We want child tags to take precedence over parent tags,
# so we can't use a simple update here, instead collecting
# the parent tags and then updating with the child tags.
new_tags = self.parent.collect_tags()
tags.update(new_tags)
return tags
# so collect parent tags first, then update with child tags.
tags = self.parent.collect_tags()
tags.update(self.tags)
return tags
return self.tags.copy()
def get_capture_exception_code_variables(self) -> Optional[bool]:
if self.capture_exception_code_variables is not None:
return self.capture_exception_code_variables
if self.parent is not None and not self.fresh:
return self.parent.get_capture_exception_code_variables()
return None
def get_code_variables_mask_patterns(self) -> Optional[list]:
if self.code_variables_mask_patterns is not None:
return self.code_variables_mask_patterns
if self.parent is not None and not self.fresh:
return self.parent.get_code_variables_mask_patterns()
return None
def get_code_variables_ignore_patterns(self) -> Optional[list]:
if self.code_variables_ignore_patterns is not None:
return self.code_variables_ignore_patterns
if self.parent is not None and not self.fresh:
return self.parent.get_code_variables_ignore_patterns()
return None
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
@@ -243,6 +275,54 @@ def get_context_distinct_id() -> Optional[str]:
return None
def set_capture_exception_code_variables_context(enabled: bool) -> None:
"""
Set whether code variables are captured for the current context.
"""
current_context = _get_current_context()
if current_context:
current_context.set_capture_exception_code_variables(enabled)
def set_code_variables_mask_patterns_context(mask_patterns: list) -> None:
"""
Variable names matching these patterns will be masked with *** when capturing code variables.
"""
current_context = _get_current_context()
if current_context:
current_context.set_code_variables_mask_patterns(mask_patterns)
def set_code_variables_ignore_patterns_context(ignore_patterns: list) -> None:
"""
Variable names matching these patterns will be ignored completely when capturing code variables.
"""
current_context = _get_current_context()
if current_context:
current_context.set_code_variables_ignore_patterns(ignore_patterns)
def get_capture_exception_code_variables_context() -> Optional[bool]:
current_context = _get_current_context()
if current_context:
return current_context.get_capture_exception_code_variables()
return None
def get_code_variables_mask_patterns_context() -> Optional[list]:
current_context = _get_current_context()
if current_context:
return current_context.get_code_variables_mask_patterns()
return None
def get_code_variables_ignore_patterns_context() -> Optional[list]:
current_context = _get_current_context()
if current_context:
return current_context.get_code_variables_ignore_patterns()
return None
F = TypeVar("F", bound=Callable[..., Any])
+250 -2
View File
@@ -5,6 +5,7 @@
# 💖open source (under MIT License)
# We want to keep payloads as similar to Sentry as possible for easy interoperability
import json
import linecache
import os
import re
@@ -13,22 +14,23 @@ import types
from datetime import datetime
from types import FrameType, TracebackType # noqa: F401
from typing import ( # noqa: F401
TYPE_CHECKING,
Any,
Dict,
Iterator,
List,
Literal,
Optional,
Pattern,
Set,
Tuple,
TypedDict,
TypeVar,
Union,
cast,
TYPE_CHECKING,
)
from posthog.args import ExcInfo, ExceptionArg # noqa: F401
from posthog.args import ExceptionArg, ExcInfo # noqa: F401
try:
# Python 3.11
@@ -40,6 +42,46 @@ except ImportError:
DEFAULT_MAX_VALUE_LENGTH = 1024
DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
r"(?i).*password.*",
r"(?i).*secret.*",
r"(?i).*passwd.*",
r"(?i).*pwd.*",
r"(?i).*api_key.*",
r"(?i).*apikey.*",
r"(?i).*auth.*",
r"(?i).*credentials.*",
r"(?i).*privatekey.*",
r"(?i).*private_key.*",
r"(?i).*token.*",
r"(?i).*aws_access_key_id.*",
r"(?i).*_pass",
r"(?i)sk_.*",
r"(?i).*jwt.*",
]
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT = 20 * 1024
class VariableSizeLimiter:
def __init__(self, max_size=DEFAULT_TOTAL_VARIABLES_SIZE_LIMIT):
self.max_size = max_size
self.current_size = 0
def can_add(self, size):
return self.current_size + size <= self.max_size
def add(self, size):
self.current_size += size
def get_remaining_space(self):
return self.max_size - self.current_size
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
Event = TypedDict(
@@ -884,3 +926,209 @@ def strip_string(value, max_length=None):
"rem": [["!limit", "x", max_length - 3, max_length]],
},
)
def _compile_patterns(patterns):
compiled = []
for pattern in patterns:
try:
compiled.append(re.compile(pattern))
except Exception:
pass
return compiled
def _pattern_matches(name, patterns):
for pattern in patterns:
if pattern.search(name):
return True
return False
def _mask_sensitive_data(value, compiled_mask):
if not compiled_mask:
return value
if isinstance(value, dict):
result = {}
for k, v in value.items():
key_str = str(k) if not isinstance(k, str) else k
if _pattern_matches(key_str, compiled_mask):
result[k] = CODE_VARIABLES_REDACTED_VALUE
else:
result[k] = _mask_sensitive_data(v, compiled_mask)
return result
elif isinstance(value, (list, tuple)):
masked_items = [_mask_sensitive_data(item, compiled_mask) for item in value]
return type(value)(masked_items)
elif isinstance(value, str):
if _pattern_matches(value, compiled_mask):
return CODE_VARIABLES_REDACTED_VALUE
return value
else:
return value
def _serialize_variable_value(value, limiter, max_length=1024, compiled_mask=None):
try:
if value is None:
result = "None"
elif isinstance(value, bool):
result = str(value)
elif isinstance(value, (int, float)):
result_size = len(str(value))
if not limiter.can_add(result_size):
return None
limiter.add(result_size)
return value
elif isinstance(value, str):
if compiled_mask and _pattern_matches(value, compiled_mask):
result = CODE_VARIABLES_REDACTED_VALUE
else:
result = value
else:
masked_value = _mask_sensitive_data(value, compiled_mask)
result = json.dumps(masked_value)
if len(result) > max_length:
result = result[: max_length - 3] + "..."
result_size = len(result)
if not limiter.can_add(result_size):
return None
limiter.add(result_size)
return result
except Exception:
try:
result = repr(value)
if len(result) > max_length:
result = result[: max_length - 3] + "..."
result_size = len(result)
if not limiter.can_add(result_size):
return None
limiter.add(result_size)
return result
except Exception:
try:
fallback = f"<{type(value).__name__}>"
fallback_size = len(fallback)
if not limiter.can_add(fallback_size):
return None
limiter.add(fallback_size)
return fallback
except Exception:
fallback = "<unserializable object>"
fallback_size = len(fallback)
if not limiter.can_add(fallback_size):
return None
limiter.add(fallback_size)
return fallback
def _is_simple_type(value):
return isinstance(value, (type(None), bool, int, float, str))
def serialize_code_variables(
frame, limiter, mask_patterns=None, ignore_patterns=None, max_length=1024
):
if mask_patterns is None:
mask_patterns = []
if ignore_patterns is None:
ignore_patterns = []
compiled_mask = _compile_patterns(mask_patterns)
compiled_ignore = _compile_patterns(ignore_patterns)
try:
local_vars = frame.f_locals.copy()
except Exception:
return {}
simple_vars = {}
complex_vars = {}
for name, value in local_vars.items():
if _pattern_matches(name, compiled_ignore):
continue
if _is_simple_type(value):
simple_vars[name] = value
else:
complex_vars[name] = value
result = {}
all_vars = {**simple_vars, **complex_vars}
ordered_names = list(sorted(simple_vars.keys())) + list(sorted(complex_vars.keys()))
for name in ordered_names:
value = all_vars[name]
if _pattern_matches(name, compiled_mask):
redacted_value = CODE_VARIABLES_REDACTED_VALUE
redacted_size = len(redacted_value)
if not limiter.can_add(redacted_size):
break
limiter.add(redacted_size)
result[name] = redacted_value
else:
serialized = _serialize_variable_value(
value, limiter, max_length, compiled_mask
)
if serialized is None:
break
result[name] = serialized
return result
def try_attach_code_variables_to_frames(
all_exceptions, exc_info, mask_patterns, ignore_patterns
):
try:
attach_code_variables_to_frames(
all_exceptions, exc_info, mask_patterns, ignore_patterns
)
except Exception:
pass
def attach_code_variables_to_frames(
all_exceptions, exc_info, mask_patterns, ignore_patterns
):
exc_type, exc_value, traceback = exc_info
if traceback is None:
return
tb_frames = list(iter_stacks(traceback))
if not tb_frames:
return
limiter = VariableSizeLimiter()
for exception in all_exceptions:
stacktrace = exception.get("stacktrace")
if not stacktrace or "frames" not in stacktrace:
continue
serialized_frames = stacktrace["frames"]
for serialized_frame, tb_item in zip(serialized_frames, tb_frames):
if not serialized_frame.get("in_app"):
continue
variables = serialize_code_variables(
tb_item.tb_frame,
limiter,
mask_patterns=mask_patterns,
ignore_patterns=ignore_patterns,
max_length=1024,
)
if variables:
serialized_frame["code_variables"] = variables
+256 -30
View File
@@ -22,6 +22,18 @@ class InconclusiveMatchError(Exception):
pass
class RequiresServerEvaluation(Exception):
"""
Raised when feature flag evaluation requires server-side data that is not
available locally (e.g., static cohorts, experience continuity).
This error should propagate immediately to trigger API fallback, unlike
InconclusiveMatchError which allows trying other conditions.
"""
pass
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
# Given the same distinct_id and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
@@ -55,8 +67,161 @@ def variant_lookup_table(feature_flag):
return lookup_table
def evaluate_flag_dependency(
property, flags_by_key, evaluation_cache, distinct_id, properties, cohort_properties
):
"""
Evaluate a flag dependency property according to the dependency chain algorithm.
Args:
property: Flag property with type="flag" and dependency_chain
flags_by_key: Dictionary of all flags by their key
evaluation_cache: Cache for storing evaluation results
distinct_id: The distinct ID being evaluated
properties: Person properties for evaluation
cohort_properties: Cohort properties for evaluation
Returns:
bool: True if all dependencies in the chain evaluate to True, False otherwise
"""
if flags_by_key is None or evaluation_cache is None:
# Cannot evaluate flag dependencies without required context
raise InconclusiveMatchError(
f"Cannot evaluate flag dependency on '{property.get('key', 'unknown')}' without flags_by_key and evaluation_cache"
)
# Check if dependency_chain is present - it should always be provided for flag dependencies
if "dependency_chain" not in property:
# Missing dependency_chain indicates malformed server data
raise InconclusiveMatchError(
f"Flag dependency property for '{property.get('key', 'unknown')}' is missing required 'dependency_chain' field"
)
dependency_chain = property["dependency_chain"]
# Handle circular dependency (empty chain means circular)
if len(dependency_chain) == 0:
log.debug(f"Circular dependency detected for flag: {property.get('key')}")
raise InconclusiveMatchError(
f"Circular dependency detected for flag '{property.get('key', 'unknown')}'"
)
# Evaluate all dependencies in the chain order
for dep_flag_key in dependency_chain:
if dep_flag_key not in evaluation_cache:
# Need to evaluate this dependency first
dep_flag = flags_by_key.get(dep_flag_key)
if not dep_flag:
# Missing flag dependency - cannot evaluate locally
evaluation_cache[dep_flag_key] = None
raise InconclusiveMatchError(
f"Cannot evaluate flag dependency '{dep_flag_key}' - flag not found in local flags"
)
else:
# Check if the flag is active (same check as in client._compute_flag_locally)
if not dep_flag.get("active"):
evaluation_cache[dep_flag_key] = False
else:
# Recursively evaluate the dependency
try:
dep_result = match_feature_flag_properties(
dep_flag,
distinct_id,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
)
evaluation_cache[dep_flag_key] = dep_result
except InconclusiveMatchError as e:
# If we can't evaluate a dependency, store None and propagate the error
evaluation_cache[dep_flag_key] = None
raise InconclusiveMatchError(
f"Cannot evaluate flag dependency '{dep_flag_key}': {e}"
) from e
# Check the cached result
cached_result = evaluation_cache[dep_flag_key]
if cached_result is None:
# Previously inconclusive - raise error again
raise InconclusiveMatchError(
f"Flag dependency '{dep_flag_key}' was previously inconclusive"
)
elif not cached_result:
# Definitive False result - dependency failed
return False
# All dependencies in the chain have been evaluated successfully
# Now check if the final flag value matches the expected value in the property
flag_key = property.get("key")
expected_value = property.get("value")
operator = property.get("operator", "exact")
if flag_key and expected_value is not None:
# Get the actual value of the flag we're checking
actual_value = evaluation_cache.get(flag_key)
if actual_value is None:
# Flag wasn't evaluated - this shouldn't happen if dependency chain is correct
raise InconclusiveMatchError(
f"Flag '{flag_key}' was not evaluated despite being in dependency chain"
)
# For flag dependencies, we need to compare the actual flag result with expected value
# using the flag_evaluates_to operator logic
if operator == "flag_evaluates_to":
return matches_dependency_value(expected_value, actual_value)
else:
# This should never happen, but just to be defensive.
raise InconclusiveMatchError(
f"Flag dependency property for '{property.get('key', 'unknown')}' has invalid operator '{operator}'"
)
# If no value check needed, return True (all dependencies passed)
return True
def matches_dependency_value(expected_value, actual_value):
"""
Check if the actual flag value matches the expected dependency value.
This follows the same logic as the C# MatchesDependencyValue function:
- String variant case: check for exact match or boolean true
- Boolean case: must match expected boolean value
Args:
expected_value: The expected value from the property
actual_value: The actual value returned by the flag evaluation
Returns:
bool: True if the values match according to flag dependency rules
"""
# String variant case - check for exact match or boolean true
if isinstance(actual_value, str) and len(actual_value) > 0:
if isinstance(expected_value, bool):
# Any variant matches boolean true
return expected_value
elif isinstance(expected_value, str):
# variants are case-sensitive, hence our comparison is too
return actual_value == expected_value
else:
return False
# Boolean case - must match expected boolean value
elif isinstance(actual_value, bool) and isinstance(expected_value, bool):
return actual_value == expected_value
# Default case
return False
def match_feature_flag_properties(
flag, distinct_id, properties, cohort_properties=None
flag,
distinct_id,
properties,
cohort_properties=None,
flags_by_key=None,
evaluation_cache=None,
) -> FlagValue:
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
@@ -67,19 +232,18 @@ def match_feature_flag_properties(
) or []
valid_variant_keys = [variant["key"] for variant in flag_variants]
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
# evaluated first, and the variant override is applied to the first matching condition.
sorted_flag_conditions = sorted(
flag_conditions,
key=lambda condition: 0 if condition.get("variant") else 1,
)
for condition in sorted_flag_conditions:
for condition in flag_conditions:
try:
# if any one condition resolves to True, we can shortcircuit and return
# the matching variant
if is_condition_match(
flag, distinct_id, condition, properties, cohort_properties
flag,
distinct_id,
condition,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
@@ -87,7 +251,12 @@ def match_feature_flag_properties(
else:
variant = get_matching_variant(flag, distinct_id)
return variant or True
except RequiresServerEvaluation:
# Static cohort or other missing server-side data - must fallback to API
raise
except InconclusiveMatchError:
# Evaluation error (bad regex, invalid date, missing property, etc.)
# Track that we had an inconclusive match, but try other conditions
is_inconclusive = True
if is_inconclusive:
@@ -101,22 +270,36 @@ def match_feature_flag_properties(
def is_condition_match(
feature_flag, distinct_id, condition, properties, cohort_properties
feature_flag,
distinct_id,
condition,
properties,
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
for prop in condition.get("properties"):
property_type = prop.get("type")
if property_type == "cohort":
matches = match_cohort(prop, properties, cohort_properties)
matches = match_cohort(
prop,
properties,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
elif property_type == "flag":
log.warning(
"Flag dependency filters are not supported in local evaluation. "
"Skipping condition for flag '%s' with dependency on flag '%s'",
feature_flag.get("key", "unknown"),
prop.get("key", "unknown"),
matches = evaluate_flag_dependency(
prop,
flags_by_key,
evaluation_cache,
distinct_id,
properties,
cohort_properties,
)
continue
else:
matches = match_property(prop, properties)
if not matches:
@@ -264,7 +447,14 @@ def match_property(property, property_values) -> bool:
raise InconclusiveMatchError(f"Unknown operator {operator}")
def match_cohort(property, property_values, cohort_properties) -> bool:
def match_cohort(
property,
property_values,
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
) -> bool:
# Cohort properties are in the form of property groups like this:
# {
# "cohort_id": {
@@ -276,15 +466,29 @@ def match_cohort(property, property_values, cohort_properties) -> bool:
# }
cohort_id = str(property.get("value"))
if cohort_id not in cohort_properties:
raise InconclusiveMatchError(
"can't match cohort without a given cohort property value"
raise RequiresServerEvaluation(
f"cohort {cohort_id} not found in local cohorts - likely a static cohort that requires server evaluation"
)
property_group = cohort_properties[cohort_id]
return match_property_group(property_group, property_values, cohort_properties)
return match_property_group(
property_group,
property_values,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
def match_property_group(property_group, property_values, cohort_properties) -> bool:
def match_property_group(
property_group,
property_values,
cohort_properties,
flags_by_key=None,
evaluation_cache=None,
distinct_id=None,
) -> bool:
if not property_group:
return True
@@ -301,7 +505,14 @@ def match_property_group(property_group, property_values, cohort_properties) ->
# a nested property group
for prop in properties:
try:
matches = match_property_group(prop, property_values, cohort_properties)
matches = match_property_group(
prop,
property_values,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
if property_group_type == "AND":
if not matches:
return False
@@ -309,6 +520,9 @@ def match_property_group(property_group, property_values, cohort_properties) ->
# OR group
if matches:
return True
except RequiresServerEvaluation:
# Immediately propagate - this condition requires server-side data
raise
except InconclusiveMatchError as e:
log.debug(f"Failed to compute property {prop} locally: {e}")
error_matching_locally = True
@@ -324,14 +538,23 @@ def match_property_group(property_group, property_values, cohort_properties) ->
for prop in properties:
try:
if prop.get("type") == "cohort":
matches = match_cohort(prop, property_values, cohort_properties)
matches = match_cohort(
prop,
property_values,
cohort_properties,
flags_by_key,
evaluation_cache,
distinct_id,
)
elif prop.get("type") == "flag":
log.warning(
"Flag dependency filters are not supported in local evaluation. "
"Skipping condition with dependency on flag '%s'",
prop.get("key", "unknown"),
matches = evaluate_flag_dependency(
prop,
flags_by_key,
evaluation_cache,
distinct_id,
property_values,
cohort_properties,
)
continue
else:
matches = match_property(prop, property_values)
@@ -349,6 +572,9 @@ def match_property_group(property_group, property_values, cohort_properties) ->
return True
if not matches and negation:
return True
except RequiresServerEvaluation:
# Immediately propagate - this condition requires server-side data
raise
except InconclusiveMatchError as e:
log.debug(f"Failed to compute property {prop} locally: {e}")
error_matching_locally = True
+127
View File
@@ -0,0 +1,127 @@
"""
Flag Definition Cache Provider interface for multi-worker environments.
EXPERIMENTAL: This API may change in future minor version bumps.
This module provides an interface for external caching of feature flag definitions,
enabling multi-worker environments (Kubernetes, load-balanced servers, serverless
functions) to share flag definitions and reduce API calls.
Usage:
from posthog import Posthog
from posthog.flag_definition_cache import FlagDefinitionCacheProvider
cache = RedisFlagDefinitionCache(redis_client, "my-team")
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
flag_definition_cache_provider=cache,
)
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
from typing_extensions import Required, TypedDict
class FlagDefinitionCacheData(TypedDict):
"""
Data structure for cached flag definitions.
Attributes:
flags: List of feature flag definition dictionaries from the API.
group_type_mapping: Mapping of group type indices to group names.
cohorts: Dictionary of cohort definitions for local evaluation.
"""
flags: Required[List[Dict[str, Any]]]
group_type_mapping: Required[Dict[str, str]]
cohorts: Required[Dict[str, Any]]
@runtime_checkable
class FlagDefinitionCacheProvider(Protocol):
"""
Interface for external caching of feature flag definitions.
Enables multi-worker environments to share flag definitions, reducing API
calls while ensuring all workers have consistent data.
EXPERIMENTAL: This API may change in future minor version bumps.
The four methods handle the complete lifecycle of flag definition caching:
1. `should_fetch_flag_definitions()` - Called before each poll to determine
if this worker should fetch new definitions. Use for distributed lock
coordination to ensure only one worker fetches at a time.
2. `get_flag_definitions()` - Called when `should_fetch_flag_definitions()`
returns False. Returns cached definitions if available.
3. `on_flag_definitions_received()` - Called after successfully fetching
new definitions from the API. Store the data in your external cache
and release any locks.
4. `shutdown()` - Called when the PostHog client shuts down. Release any
distributed locks and clean up resources.
Error Handling:
All methods are wrapped in try/except. Errors will be logged but will
never break flag evaluation. On error:
- `should_fetch_flag_definitions()` errors default to fetching (fail-safe)
- `get_flag_definitions()` errors fall back to API fetch
- `on_flag_definitions_received()` errors are logged but flags remain in memory
- `shutdown()` errors are logged but shutdown continues
"""
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
"""
Retrieve cached flag definitions.
Returns:
Cached flag definitions if available and valid, None otherwise.
Returning None will trigger a fetch from the API if this worker
has no flags loaded yet.
"""
...
def should_fetch_flag_definitions(self) -> bool:
"""
Determine whether this instance should fetch new flag definitions.
Use this for distributed lock coordination. Only one worker should
return True to avoid thundering herd problems. A typical implementation
uses a distributed lock (e.g., Redis SETNX) that expires after the
poll interval.
Returns:
True if this instance should fetch from the API, False otherwise.
When False, the client will call `get_flag_definitions()` to
retrieve cached data instead.
"""
...
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
"""
Called after successfully receiving new flag definitions from PostHog.
Use this to store the data in your external cache and release any
distributed locks acquired in `should_fetch_flag_definitions()`.
Args:
data: The flag definitions to cache, containing flags,
group_type_mapping, and cohorts.
"""
...
def shutdown(self) -> None:
"""
Called when the PostHog client shuts down.
Use this to release any distributed locks and clean up resources.
This method is called even if `should_fetch_flag_definitions()`
returned False, so implementations should handle the case where
no lock was acquired.
"""
...
+157 -19
View File
@@ -1,10 +1,24 @@
from typing import TYPE_CHECKING, cast
from posthog import contexts, capture_exception
from posthog import contexts
from posthog.client import Client
try:
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
except ImportError:
# Fallback for older Django versions without asgiref
import asyncio
iscoroutinefunction = asyncio.iscoroutinefunction
# No-op fallback for markcoroutinefunction
# Older Django versions without asgiref typically don't support async middleware anyway
def markcoroutinefunction(func):
return func
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse # noqa: F401
from typing import Callable, Dict, Any, Optional # noqa: F401
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
class PosthogContextMiddleware:
@@ -31,11 +45,24 @@ class PosthogContextMiddleware:
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
"""
sync_capable = True
async_capable = True
def __init__(self, get_response):
# type: (Callable[[HttpRequest], HttpResponse]) -> None
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
self.get_response = get_response
self._is_coroutine = iscoroutinefunction(get_response)
# Mark this instance as a coroutine function if get_response is async
# This is required for Django to correctly detect async middleware
if self._is_coroutine:
markcoroutinefunction(self)
from django.conf import settings
@@ -85,9 +112,18 @@ class PosthogContextMiddleware:
def extract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
tags = {}
"""Extract tags from request in sync context."""
user_id, user_email = self.extract_request_user(request)
return self._build_tags(request, user_id, user_email)
(user_id, user_email) = self.extract_request_user(request)
def _build_tags(self, request, user_id, user_email):
# type: (HttpRequest, Optional[str], Optional[str]) -> Dict[str, Any]
"""
Build tags dict from request and user info.
Centralized tag extraction logic used by both sync and async paths.
"""
tags = {}
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
@@ -139,43 +175,145 @@ class PosthogContextMiddleware:
return tags
def extract_request_user(self, request):
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
"""Extract user ID and email from request in sync context."""
user = getattr(request, "user", None)
return self._resolve_user_details(user)
async def aextract_tags(self, request):
# type: (HttpRequest) -> Dict[str, Any]
"""
Async version of extract_tags for use in async request handling.
Uses await request.auser() instead of request.user to avoid
SynchronousOnlyOperation in async context.
Follows Django's naming convention for async methods (auser, asave, etc.).
"""
user_id, user_email = await self.aextract_request_user(request)
return self._build_tags(request, user_id, user_email)
async def aextract_request_user(self, request):
# type: (HttpRequest) -> tuple[Optional[str], Optional[str]]
"""
Async version of extract_request_user for use in async request handling.
Uses await request.auser() instead of request.user to avoid
SynchronousOnlyOperation in async context.
Follows Django's naming convention for async methods (auser, asave, etc.).
"""
auser = getattr(request, "auser", None)
if callable(auser):
try:
user = await auser()
return self._resolve_user_details(user)
except Exception:
# If auser() fails, return empty - don't break the request
# Real errors (permissions, broken auth) will be logged by Django
return None, None
# Fallback for test requests without auser
return None, None
def _resolve_user_details(self, user):
# type: (Any) -> tuple[Optional[str], Optional[str]]
"""
Extract user ID and email from a user object.
Handles both authenticated and unauthenticated users, as well as
legacy Django where is_authenticated was a method.
"""
user_id = None
email = None
user = getattr(request, "user", None)
if user is None:
return user_id, email
if user and getattr(user, "is_authenticated", False):
try:
user_id = str(user.pk)
except Exception:
pass
# Handle is_authenticated (property in modern Django, method in legacy)
is_authenticated = getattr(user, "is_authenticated", False)
if callable(is_authenticated):
is_authenticated = is_authenticated()
try:
email = str(user.email)
except Exception:
pass
if not is_authenticated:
return user_id, email
# Extract user primary key
user_pk = getattr(user, "pk", None)
if user_pk is not None:
user_id = str(user_pk)
# Extract user email
user_email = getattr(user, "email", None)
if user_email:
email = str(user_email)
return user_id, email
def __call__(self, request):
# type: (HttpRequest) -> HttpResponse
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
"""
Unified entry point for both sync and async request handling.
When sync_capable and async_capable are both True, Django passes requests
without conversion. This method detects the mode and routes accordingly.
"""
if self._is_coroutine:
return self.__acall__(request)
else:
# Synchronous path
if self.request_filter and not self.request_filter(request):
return self.get_response(request)
with contexts.new_context(self.capture_exceptions, client=self.client):
for k, v in self.extract_tags(request).items():
contexts.tag(k, v)
return self.get_response(request)
async def __acall__(self, request):
# type: (HttpRequest) -> Awaitable[HttpResponse]
"""
Asynchronous entry point for async request handling.
This method is called when the middleware chain is async.
Uses aextract_tags() which calls request.auser() to avoid
SynchronousOnlyOperation when accessing user in async context.
"""
if self.request_filter and not self.request_filter(request):
return self.get_response(request)
return await self.get_response(request)
with contexts.new_context(self.capture_exceptions, client=self.client):
for k, v in self.extract_tags(request).items():
for k, v in (await self.aextract_tags(request)).items():
contexts.tag(k, v)
return self.get_response(request)
return await self.get_response(request)
def process_exception(self, request, exception):
# type: (HttpRequest, Exception) -> None
"""
Process exceptions from views and downstream middleware.
Django calls this WHILE still inside the context created by __call__,
so request tags have already been extracted and set. This method just
needs to capture the exception directly.
Django converts view exceptions into responses before they propagate through
the middleware stack, so the context manager in __call__/__acall__ never sees them.
Note: Django's process_exception is always synchronous, even for async views.
"""
if self.request_filter and not self.request_filter(request):
return
if not self.capture_exceptions:
return
# Context and tags already set by __call__ or __acall__
# Just capture the exception
if self.client:
self.client.capture_exception(exception)
else:
from posthog import capture_exception
capture_exception(exception)
+209 -25
View File
@@ -1,28 +1,163 @@
import json
import logging
import re
import socket
from dataclasses import dataclass
from datetime import date, datetime
from gzip import GzipFile
from io import BytesIO
from typing import Any, Optional, Union
from typing import Any, List, Optional, Tuple, Union
import requests
from dateutil.tz import tzutc
from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
from urllib3.connection import HTTPConnection
from urllib3.util.retry import Retry
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION
# Retry on both connect and read errors
# by default read errors will only retry idempotent HTTP methods (so not POST)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=2,
connect=2,
read=2,
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
KEEPALIVE_IDLE_SECONDS = 60
KEEPALIVE_INTERVAL_SECONDS = 60
KEEPALIVE_PROBE_COUNT = 3
# TCP keepalive probes idle connections to prevent them from being dropped.
# SO_KEEPALIVE is cross-platform, but timing options vary:
# - Linux: TCP_KEEPIDLE, TCP_KEEPINTVL, TCP_KEEPCNT
# - macOS: only SO_KEEPALIVE (uses system defaults)
# - Windows: TCP_KEEPIDLE, TCP_KEEPINTVL (since Windows 10 1709)
KEEP_ALIVE_SOCKET_OPTIONS: SocketOptions = list(
HTTPConnection.default_socket_options
) + [
(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1),
]
for attr, value in [
("TCP_KEEPIDLE", KEEPALIVE_IDLE_SECONDS),
("TCP_KEEPINTVL", KEEPALIVE_INTERVAL_SECONDS),
("TCP_KEEPCNT", KEEPALIVE_PROBE_COUNT),
]:
if hasattr(socket, attr):
KEEP_ALIVE_SOCKET_OPTIONS.append((socket.SOL_TCP, getattr(socket, attr), value))
# Status codes that indicate transient server errors worth retrying
RETRY_STATUS_FORCELIST = [408, 500, 502, 503, 504]
def _mask_tokens_in_url(url: str) -> str:
"""Mask token values in URLs for safe logging, keeping first 10 chars visible."""
return re.sub(r"(token=)([^&]{10})[^&]*", r"\1\2...", url)
@dataclass
class GetResponse:
"""Response from a GET request with ETag support."""
data: Any
etag: Optional[str] = None
not_modified: bool = False
class HTTPAdapterWithSocketOptions(HTTPAdapter):
"""HTTPAdapter with configurable socket options."""
def __init__(self, *args, socket_options: Optional[SocketOptions] = None, **kwargs):
self.socket_options = socket_options
super().__init__(*args, **kwargs)
def init_poolmanager(self, *args, **kwargs):
if self.socket_options is not None:
kwargs["socket_options"] = self.socket_options
super().init_poolmanager(*args, **kwargs)
def _build_session(socket_options: Optional[SocketOptions] = None) -> requests.Session:
"""Build a session for general requests (batch, decide, etc.)."""
adapter = HTTPAdapterWithSocketOptions(
max_retries=Retry(
total=2,
connect=2,
read=2,
),
socket_options=socket_options,
)
)
_session = requests.sessions.Session()
_session.mount("https://", adapter)
session = requests.Session()
session.mount("https://", adapter)
return session
def _build_flags_session(
socket_options: Optional[SocketOptions] = None,
) -> requests.Session:
"""
Build a session for feature flag requests with POST retries.
Feature flag requests are idempotent (read-only), so retrying POST
requests is safe. This session retries on transient server errors
(408, 5xx) and network failures with exponential backoff
(0.5s, 1s delays between retries).
"""
adapter = HTTPAdapterWithSocketOptions(
max_retries=Retry(
total=2,
connect=2,
read=2,
backoff_factor=0.5,
status_forcelist=RETRY_STATUS_FORCELIST,
allowed_methods=["POST"],
),
socket_options=socket_options,
)
session = requests.Session()
session.mount("https://", adapter)
return session
_session = _build_session()
_flags_session = _build_flags_session()
_socket_options: Optional[SocketOptions] = None
_pooling_enabled = True
def _get_session() -> requests.Session:
if _pooling_enabled:
return _session
return _build_session(_socket_options)
def _get_flags_session() -> requests.Session:
if _pooling_enabled:
return _flags_session
return _build_flags_session(_socket_options)
def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
"""
Configure socket options for all HTTP connections.
Example:
from posthog import set_socket_options
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
"""
global _session, _flags_session, _socket_options
if socket_options == _socket_options:
return
_socket_options = socket_options
_session = _build_session(socket_options)
_flags_session = _build_flags_session(socket_options)
def enable_keep_alive() -> None:
"""Enable TCP keepalive to prevent idle connections from being dropped."""
set_socket_options(KEEP_ALIVE_SOCKET_OPTIONS)
def disable_connection_reuse() -> None:
"""Disable connection reuse, creating a fresh connection for each request."""
global _pooling_enabled
_pooling_enabled = False
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
@@ -48,6 +183,7 @@ def post(
path=None,
gzip: bool = False,
timeout: int = 15,
session: Optional[requests.Session] = None,
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the API"""
@@ -68,7 +204,9 @@ def post(
gz.write(data.encode("utf-8"))
data = buf.getvalue()
res = _session.post(url, data=data, headers=headers, timeout=timeout)
res = (session or _get_session()).post(
url, data=data, headers=headers, timeout=timeout
)
if res.status_code == 200:
log.debug("data uploaded successfully")
@@ -124,23 +262,36 @@ def flags(
timeout: int = 15,
**kwargs,
) -> Any:
"""Post the `kwargs to the flags API endpoint"""
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
"""Post the kwargs to the flags API endpoint with automatic retries."""
res = post(
api_key,
host,
"/flags/?v=2",
gzip,
timeout,
session=_get_flags_session(),
**kwargs,
)
return _process_response(
res, success_message="Feature flags evaluated successfully"
)
def remote_config(
personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15
personal_api_key: str,
project_api_key: str,
host: Optional[str] = None,
key: str = "",
timeout: int = 15,
) -> Any:
"""Get remote config flag value from remote_config API endpoint"""
return get(
response = get(
personal_api_key,
f"/api/projects/@current/feature_flags/{key}/remote_config/",
f"/api/projects/@current/feature_flags/{key}/remote_config?token={project_api_key}",
host,
timeout,
)
return response.data
def batch_post(
@@ -158,15 +309,42 @@ def batch_post(
def get(
api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None
) -> requests.Response:
url = remove_trailing_slash(host or DEFAULT_HOST) + url
res = requests.get(
url,
headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT},
timeout=timeout,
api_key: str,
url: str,
host: Optional[str] = None,
timeout: Optional[int] = None,
etag: Optional[str] = None,
) -> GetResponse:
"""
Make a GET request with optional ETag support.
If an etag is provided, sends If-None-Match header. Returns GetResponse with:
- not_modified=True and data=None if server returns 304
- not_modified=False and data=response if server returns 200
"""
log = logging.getLogger("posthog")
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
if etag:
headers["If-None-Match"] = etag
res = _get_session().get(full_url, headers=headers, timeout=timeout)
masked_url = _mask_tokens_in_url(full_url)
# Handle 304 Not Modified
if res.status_code == 304:
log.debug(f"GET {masked_url} returned 304 Not Modified")
response_etag = res.headers.get("ETag")
return GetResponse(data=None, etag=response_etag or etag, not_modified=True)
# Handle normal response
data = _process_response(
res, success_message=f"GET {masked_url} completed successfully"
)
return _process_response(res, success_message=f"GET {url} completed successfully")
response_etag = res.headers.get("ETag")
return GetResponse(data=data, etag=response_etag, not_modified=False)
class APIError(Exception):
@@ -183,6 +361,12 @@ class QuotaLimitError(APIError):
pass
# Re-export requests exceptions for use in client.py
# This keeps all requests library imports centralized in this module
RequestsTimeout = requests.exceptions.Timeout
RequestsConnectionError = requests.exceptions.ConnectionError
class DatetimeSerializer(json.JSONEncoder):
def default(self, obj: Any):
if isinstance(obj, (date, datetime)):
File diff suppressed because it is too large Load Diff
+793 -5
View File
@@ -31,6 +31,9 @@ def mock_gemini_response():
mock_usage = MagicMock()
mock_usage.prompt_token_count = 20
mock_usage.candidates_token_count = 10
# Ensure cache and reasoning tokens are not present (not MagicMock)
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
mock_candidate = MagicMock()
@@ -56,6 +59,91 @@ def mock_google_genai_client():
yield mock_client_instance
@pytest.fixture
def mock_gemini_response_with_function_calls():
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 25
mock_usage.candidates_token_count = 15
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock function call
mock_function_call = MagicMock()
mock_function_call.name = "get_current_weather"
mock_function_call.args = {"location": "San Francisco"}
# Mock text part 1
mock_text_part1 = MagicMock()
mock_text_part1.text = "I'll check the weather for you."
# Make hasattr(part, "text") return True
type(mock_text_part1).text = mock_text_part1.text
# Mock text part 2
mock_text_part2 = MagicMock()
mock_text_part2.text = " Let me look that up."
type(mock_text_part2).text = mock_text_part2.text
# Mock function call part - need to ensure hasattr() works correctly
mock_function_part = MagicMock()
mock_function_part.function_call = mock_function_call
# Make hasattr(part, "function_call") return True
type(mock_function_part).function_call = mock_function_part.function_call
# Ensure hasattr(part, "text") returns False for the function part
del mock_function_part.text
# Mock content with 2 text parts and 1 function call part
mock_content = MagicMock()
mock_content.parts = [mock_text_part1, mock_text_part2, mock_function_part]
# Mock candidate
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
@pytest.fixture
def mock_gemini_response_function_calls_only():
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 30
mock_usage.candidates_token_count = 12
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock function call
mock_function_call = MagicMock()
mock_function_call.name = "get_current_weather"
mock_function_call.args = {"location": "New York", "unit": "fahrenheit"}
# Mock function call part (no text part) - need to ensure hasattr() works correctly
mock_function_part = MagicMock()
mock_function_part.function_call = mock_function_call
# Make hasattr(part, "function_call") return True
type(mock_function_part).function_call = mock_function_part.function_call
# Ensure hasattr(part, "text") returns False for the function part
del mock_function_part.text
# Mock content with only function call part
mock_content = MagicMock()
mock_content.parts = [mock_function_part]
# Mock candidate
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
def test_new_client_basic_generation(
mock_client, mock_google_genai_client, mock_gemini_response
):
@@ -99,6 +187,8 @@ def test_new_client_streaming_with_generate_content_stream(
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 10
mock_usage1.candidates_token_count = 5
mock_usage1.cached_content_token_count = 0
mock_usage1.thoughts_token_count = 0
mock_chunk1.usage_metadata = mock_usage1
mock_chunk2 = MagicMock()
@@ -106,6 +196,8 @@ def test_new_client_streaming_with_generate_content_stream(
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 10
mock_usage2.candidates_token_count = 10
mock_usage2.cached_content_token_count = 0
mock_usage2.thoughts_token_count = 0
mock_chunk2.usage_metadata = mock_usage2
yield mock_chunk1
@@ -145,6 +237,91 @@ def test_new_client_streaming_with_generate_content_stream(
assert isinstance(props["$ai_latency"], float)
def test_new_client_streaming_with_tools(mock_client, mock_google_genai_client):
"""Test that tools are captured in streaming mode"""
def mock_streaming_response():
mock_chunk1 = MagicMock()
mock_chunk1.text = "I'll check "
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 15
mock_usage1.candidates_token_count = 5
mock_usage1.cached_content_token_count = 0
mock_usage1.thoughts_token_count = 0
mock_chunk1.usage_metadata = mock_usage1
mock_chunk2 = MagicMock()
mock_chunk2.text = "the weather"
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 15
mock_usage2.candidates_token_count = 10
mock_usage2.cached_content_token_count = 0
mock_usage2.thoughts_token_count = 0
mock_chunk2.usage_metadata = mock_usage2
yield mock_chunk1
yield mock_chunk2
# Mock the generate_content_stream method
mock_google_genai_client.models.generate_content_stream.return_value = (
mock_streaming_response()
)
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
mock_tool.function_declarations = [
MagicMock(
name="get_current_weather",
description="Gets the current weather for a given location.",
parameters=MagicMock(
type="OBJECT",
properties={
"location": MagicMock(
type="STRING",
description="The city and state, e.g. San Francisco, CA",
)
},
required=["location"],
),
)
]
mock_config = MagicMock()
mock_config.tools = [mock_tool]
response = client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["What's the weather in SF?"],
config=mock_config,
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming_with_tools"},
)
chunks = list(response)
assert len(chunks) == 2
assert chunks[0].text == "I'll check "
assert chunks[1].text == "the weather"
# Check that the streaming event was captured with tools
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.0-flash"
assert props["$ai_input_tokens"] == 15
assert props["$ai_output_tokens"] == 10
assert props["feature"] == "streaming_with_tools"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property in streaming mode
assert props["$ai_tools"] == [mock_tool]
def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test groups functionality with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
@@ -221,12 +398,42 @@ def test_new_client_different_input_formats(
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
# Test list input
mock_client.capture.reset_mock()
mock_part = MagicMock()
mock_part.text = "List item"
# Test Gemini-specific format with parts array (like in the screenshot)
mock_client.reset_mock()
client.models.generate_content(
model="gemini-2.0-flash", contents=[mock_part], posthog_distinct_id="test-id"
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [
{"role": "user", "content": [{"type": "text", "text": "hey"}]}
]
# Test multiple parts in the parts array
mock_client.reset_mock()
client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world"},
],
}
]
# Test list input with string
mock_client.capture.reset_mock()
client.models.generate_content(
model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -318,3 +525,584 @@ def test_new_client_override_defaults(
assert props["team"] == "ai" # from defaults
assert props["feature"] == "chat" # from call
assert props["urgent"] is True # from call
def test_vertex_ai_parameters_passed_through(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test that Vertex AI parameters are properly passed to genai.Client"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
# Mock credentials object
mock_credentials = MagicMock()
mock_debug_config = MagicMock()
mock_http_options = MagicMock()
# Create client with Vertex AI parameters
Client(
vertexai=True,
credentials=mock_credentials,
project="test-project",
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
google_genai.Client.assert_called_once_with(
vertexai=True,
credentials=mock_credentials,
project="test-project",
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
)
def test_api_key_mode(mock_client, mock_google_genai_client):
"""Test API key authentication mode"""
# Create client with just API key (traditional mode)
Client(
api_key="test-api-key",
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
google_genai.Client.assert_called_once_with(api_key="test-api-key")
def test_vertex_ai_mode_with_optional_api_key(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test Vertex AI mode with optional API key"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
mock_credentials = MagicMock()
# Create client with Vertex AI + API key
Client(
vertexai=True,
api_key="test-api-key",
credentials=mock_credentials,
project="test-project",
posthog_client=mock_client,
)
# Verify genai.Client was called with both Vertex AI params and API key
google_genai.Client.assert_called_once_with(
vertexai=True,
api_key="test-api-key",
credentials=mock_credentials,
project="test-project",
)
def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_response):
"""Test that tools defined in config are captured in $ai_tools property"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
mock_tool.function_declarations = [
MagicMock(
name="get_current_weather",
description="Gets the current weather for a given location.",
parameters=MagicMock(
type="OBJECT",
properties={
"location": MagicMock(
type="STRING",
description="The city and state, e.g. San Francisco, CA",
)
},
required=["location"],
),
)
]
mock_config = MagicMock()
mock_config.tools = [mock_tool]
# Explicitly specify this config doesn't have system_instruction
del mock_config.system_instruction
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["hey"],
config=mock_config,
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [{"type": "text", "text": "Test response from Gemini"}],
}
]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == [mock_tool]
def test_function_calls_in_output_choices(
mock_client, mock_google_genai_client, mock_gemini_response_with_function_calls
):
"""Test that function calls are properly included in $ai_output_choices"""
mock_google_genai_client.models.generate_content.return_value = (
mock_gemini_response_with_function_calls
)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["What's the weather in San Francisco?"],
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_calls
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll check the weather for you."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_function_calls_only_no_content(
mock_client, mock_google_genai_client, mock_gemini_response_function_calls_only
):
"""Test function calls without text content in $ai_output_choices"""
mock_google_genai_client.models.generate_content.return_value = (
mock_gemini_response_function_calls_only
)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents=["Get weather for New York"],
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_function_calls_only
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": {"location": "New York", "unit": "fahrenheit"},
},
}
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 12
assert props["$ai_http_status"] == 200
def test_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
"""Test that cache and reasoning tokens are properly extracted"""
# Create a mock response with cache and reasoning tokens
mock_response = MagicMock()
mock_response.text = "Test response with cache"
mock_usage = MagicMock()
mock_usage.prompt_token_count = 100
mock_usage.candidates_token_count = 50
mock_usage.cached_content_token_count = 30 # Cache tokens
mock_usage.thoughts_token_count = 10 # Reasoning tokens
mock_response.usage_metadata = mock_usage
# Mock candidates
mock_candidate = MagicMock()
mock_candidate.text = "Test response with cache"
mock_response.candidates = [mock_candidate]
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-pro",
contents="Test with cache",
posthog_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Check that all token types are present
assert props["$ai_input_tokens"] == 100
assert props["$ai_output_tokens"] == 50
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 10
def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
"""Test that cache and reasoning tokens are properly extracted in streaming"""
# Create mock chunks with cache and reasoning tokens
chunk1 = MagicMock()
chunk1.text = "Hello "
chunk1_usage = MagicMock()
chunk1_usage.prompt_token_count = 100
chunk1_usage.candidates_token_count = 5
chunk1_usage.cached_content_token_count = 30 # Cache tokens
chunk1_usage.thoughts_token_count = 0
chunk1.usage_metadata = chunk1_usage
chunk2 = MagicMock()
chunk2.text = "world!"
chunk2_usage = MagicMock()
chunk2_usage.prompt_token_count = 100
chunk2_usage.candidates_token_count = 10
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
chunk2.usage_metadata = chunk2_usage
mock_stream = iter([chunk1, chunk2])
mock_google_genai_client.models.generate_content_stream.return_value = mock_stream
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.5-pro",
contents="Test streaming with cache",
posthog_distinct_id="test-id",
)
# Consume the stream
result = list(response)
assert len(result) == 2
# Check PostHog capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Check that all token types are present (should use final chunk's usage)
assert props["$ai_input_tokens"] == 100
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 5
def test_web_search_grounding(mock_client, mock_google_genai_client):
"""Test web search detection via grounding_metadata."""
# Create mock response with grounding metadata
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 60
mock_usage.candidates_token_count = 40
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock grounding metadata
mock_grounding_chunk = MagicMock()
mock_grounding_chunk.uri = "https://example.com"
mock_grounding_metadata = MagicMock()
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
# Mock text part
mock_text_part = MagicMock()
mock_text_part.text = "According to search results..."
type(mock_text_part).text = mock_text_part.text
# Mock content with parts
mock_content = MagicMock()
mock_content.parts = [mock_text_part]
# Mock candidate with grounding metadata
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_candidate.grounding_metadata = mock_grounding_metadata
type(mock_candidate).grounding_metadata = mock_candidate.grounding_metadata
mock_response.candidates = [mock_candidate]
mock_response.text = "According to search results..."
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="What's the latest news?",
posthog_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is detected (binary for grounding)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 60
assert props["$ai_output_tokens"] == 40
def test_streaming_with_web_search(mock_client, mock_google_genai_client):
"""Test that web search count is properly captured in streaming mode."""
def mock_streaming_response():
# Create chunk 1 with grounding metadata
mock_chunk1 = MagicMock()
mock_chunk1.text = "According to "
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 30
mock_usage1.candidates_token_count = 5
mock_usage1.cached_content_token_count = 0
mock_usage1.thoughts_token_count = 0
mock_chunk1.usage_metadata = mock_usage1
# Add grounding metadata to first chunk
mock_grounding_chunk = MagicMock()
mock_grounding_chunk.uri = "https://example.com"
mock_grounding_metadata = MagicMock()
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
mock_candidate1 = MagicMock()
mock_candidate1.grounding_metadata = mock_grounding_metadata
type(mock_candidate1).grounding_metadata = mock_candidate1.grounding_metadata
mock_chunk1.candidates = [mock_candidate1]
# Create chunk 2
mock_chunk2 = MagicMock()
mock_chunk2.text = "search results..."
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 30
mock_usage2.candidates_token_count = 15
mock_usage2.cached_content_token_count = 0
mock_usage2.thoughts_token_count = 0
mock_chunk2.usage_metadata = mock_usage2
mock_candidate2 = MagicMock()
mock_chunk2.candidates = [mock_candidate2]
yield mock_chunk1
yield mock_chunk2
# Mock the generate_content_stream method
mock_google_genai_client.models.generate_content_stream.return_value = (
mock_streaming_response()
)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.5-flash",
contents="What's the latest news?",
posthog_distinct_id="test-id",
)
chunks = list(response)
assert len(chunks) == 2
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is detected (binary for grounding)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 15
def test_empty_grounding_metadata_no_web_search(mock_client, mock_google_genai_client):
"""Test that empty grounding_metadata (all null fields) does not count as web search."""
# Create mock response with empty grounding metadata (all null fields)
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 10
mock_usage.candidates_token_count = 10
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock empty grounding metadata (all fields are None)
mock_grounding_metadata = MagicMock()
mock_grounding_metadata.web_search_queries = None
mock_grounding_metadata.grounding_chunks = None
mock_grounding_metadata.grounding_supports = None
mock_grounding_metadata.retrieval_metadata = None
mock_grounding_metadata.retrieval_queries = None
mock_grounding_metadata.search_entry_point = None
# Mock text part
mock_text_part = MagicMock()
mock_text_part.text = "Hey there! How can I help you today?"
type(mock_text_part).text = mock_text_part.text
# Mock content with parts
mock_content = MagicMock()
mock_content.parts = [mock_text_part]
# Mock candidate with empty grounding metadata
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_candidate.grounding_metadata = mock_grounding_metadata
type(mock_candidate).grounding_metadata = mock_candidate.grounding_metadata
mock_response.candidates = [mock_candidate]
mock_response.text = "Hey there! How can I help you today?"
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Hello",
posthog_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is 0 (not present in properties when 0)
assert "$ai_web_search_count" not in props
assert props["$ai_input_tokens"] == 10
assert props["$ai_output_tokens"] == 10
def test_empty_array_grounding_metadata_no_web_search(
mock_client, mock_google_genai_client
):
"""Test that grounding_metadata with empty arrays does not count as web search."""
# Create mock response with grounding metadata having empty arrays
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 15
mock_usage.candidates_token_count = 12
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock grounding metadata with empty arrays
mock_grounding_metadata = MagicMock()
mock_grounding_metadata.web_search_queries = []
mock_grounding_metadata.grounding_chunks = []
mock_grounding_metadata.grounding_supports = []
# Mock text part
mock_text_part = MagicMock()
mock_text_part.text = "I can help with that."
type(mock_text_part).text = mock_text_part.text
# Mock content with parts
mock_content = MagicMock()
mock_content.parts = [mock_text_part]
# Mock candidate with grounding metadata containing empty arrays
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_candidate.grounding_metadata = mock_grounding_metadata
type(mock_candidate).grounding_metadata = mock_candidate.grounding_metadata
mock_response.candidates = [mock_candidate]
mock_response.text = "I can help with that."
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="What can you do?",
posthog_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is 0 (not present in properties when 0)
assert "$ai_web_search_count" not in props
assert props["$ai_input_tokens"] == 15
assert props["$ai_output_tokens"] == 12
+853
View File
@@ -0,0 +1,853 @@
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
try:
from google import genai as google_genai
from posthog.ai.gemini import AsyncClient
GEMINI_AVAILABLE = True
except ImportError:
GEMINI_AVAILABLE = False
pytestmark = [
pytest.mark.skipif(
not GEMINI_AVAILABLE, reason="Google Gemini package is not available"
),
pytest.mark.asyncio,
]
@pytest.fixture
def mock_client():
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@pytest.fixture
def mock_gemini_response():
mock_response = MagicMock()
mock_response.text = "Test response from Gemini"
mock_usage = MagicMock()
mock_usage.prompt_token_count = 20
mock_usage.candidates_token_count = 10
# Ensure cache and reasoning tokens are not present (not MagicMock)
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
mock_candidate = MagicMock()
mock_candidate.text = "Test response from Gemini"
mock_content = MagicMock()
mock_part = MagicMock()
mock_part.text = "Test response from Gemini"
mock_content.parts = [mock_part]
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
@pytest.fixture
def mock_google_genai_client():
"""Mock for the google-genai Client with async support"""
with patch.object(google_genai, "Client") as mock_client_class:
mock_client_instance = MagicMock()
mock_models = MagicMock()
mock_aio = MagicMock()
mock_aio_models = MagicMock()
mock_client_instance.models = mock_models
mock_client_instance.aio = mock_aio
mock_aio.models = mock_aio_models
mock_client_class.return_value = mock_client_instance
yield mock_client_instance
@pytest.fixture
def mock_gemini_response_with_function_calls():
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 25
mock_usage.candidates_token_count = 15
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock function call
mock_function_call = MagicMock()
mock_function_call.name = "get_current_weather"
mock_function_call.args = {"location": "San Francisco"}
# Mock text part 1
mock_text_part1 = MagicMock()
mock_text_part1.text = "I'll check the weather for you."
type(mock_text_part1).text = mock_text_part1.text
# Mock text part 2
mock_text_part2 = MagicMock()
mock_text_part2.text = " Let me look that up."
type(mock_text_part2).text = mock_text_part2.text
# Mock function call part
mock_function_part = MagicMock()
mock_function_part.function_call = mock_function_call
type(mock_function_part).function_call = mock_function_part.function_call
del mock_function_part.text
# Mock content with 2 text parts and 1 function call part
mock_content = MagicMock()
mock_content.parts = [mock_text_part1, mock_text_part2, mock_function_part]
# Mock candidate
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_response.candidates = [mock_candidate]
return mock_response
async def test_async_client_basic_generation(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test the async Client/AsyncModels API structure"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Tell me a fun fact about hedgehogs"],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.0-flash"
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["foo"] == "bar"
assert "$ai_trace_id" in props
assert props["$ai_latency"] > 0
async def test_async_client_streaming_with_generate_content_stream(
mock_client, mock_google_genai_client
):
"""Test the async generate_content_stream method"""
async def mock_streaming_response():
mock_chunk1 = MagicMock()
mock_chunk1.text = "Hello "
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 10
mock_usage1.candidates_token_count = 5
mock_usage1.cached_content_token_count = 0
mock_usage1.thoughts_token_count = 0
mock_chunk1.usage_metadata = mock_usage1
yield mock_chunk1
mock_chunk2 = MagicMock()
mock_chunk2.text = "world!"
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 10
mock_usage2.candidates_token_count = 10
mock_usage2.cached_content_token_count = 0
mock_usage2.thoughts_token_count = 0
mock_chunk2.usage_metadata = mock_usage2
yield mock_chunk2
# Mock the async generate_content_stream method
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["Write a short story"],
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming"},
)
chunks = []
async for chunk in response:
chunks.append(chunk)
assert len(chunks) == 2
assert chunks[0].text == "Hello "
assert chunks[1].text == "world!"
# Check that the streaming event was captured
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.0-flash"
assert props["$ai_input_tokens"] == 10
assert props["$ai_output_tokens"] == 10
assert props["feature"] == "streaming"
assert isinstance(props["$ai_latency"], float)
async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_client):
"""Test that tools are captured in async streaming mode"""
async def mock_streaming_response():
mock_chunk1 = MagicMock()
mock_chunk1.text = "I'll check "
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 15
mock_usage1.candidates_token_count = 5
mock_usage1.cached_content_token_count = 0
mock_usage1.thoughts_token_count = 0
mock_chunk1.usage_metadata = mock_usage1
yield mock_chunk1
mock_chunk2 = MagicMock()
mock_chunk2.text = "the weather"
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 15
mock_usage2.candidates_token_count = 10
mock_usage2.cached_content_token_count = 0
mock_usage2.thoughts_token_count = 0
mock_chunk2.usage_metadata = mock_usage2
yield mock_chunk2
# Mock the async generate_content_stream method
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
mock_tool.function_declarations = [
MagicMock(
name="get_current_weather",
description="Gets the current weather for a given location.",
parameters=MagicMock(
type="OBJECT",
properties={
"location": MagicMock(
type="STRING",
description="The city and state, e.g. San Francisco, CA",
)
},
required=["location"],
),
)
]
mock_config = MagicMock()
mock_config.tools = [mock_tool]
response = await client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["What's the weather in SF?"],
config=mock_config,
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming_with_tools"},
)
chunks = []
async for chunk in response:
chunks.append(chunk)
assert len(chunks) == 2
assert chunks[0].text == "I'll check "
assert chunks[1].text == "the weather"
# Check that the streaming event was captured with tools
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.0-flash"
assert props["$ai_input_tokens"] == 15
assert props["$ai_output_tokens"] == 10
assert props["feature"] == "streaming_with_tools"
assert isinstance(props["$ai_latency"], float)
# Verify that tools are captured in the $ai_tools property in streaming mode
assert props["$ai_tools"] == [mock_tool]
async def test_async_client_groups(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test groups functionality with async Client API"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
posthog_groups={"company": "company_123"},
)
call_args = mock_client.capture.call_args[1]
assert call_args["groups"] == {"company": "company_123"}
async def test_async_client_privacy_mode_local(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test local privacy mode with async Client API"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
async def test_async_client_privacy_mode_global(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test global privacy mode with async Client API"""
mock_client.privacy_mode = True
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
async def test_async_client_different_input_formats(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test different input formats with async Client API"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
# Test string input
await client.models.generate_content(
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
# Test Gemini-specific format with parts array
mock_client.reset_mock()
await client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [
{"role": "user", "content": [{"type": "text", "text": "hey"}]}
]
# Test multiple parts in the parts array
mock_client.reset_mock()
await client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [
{
"role": "user",
"content": [
{"type": "text", "text": "Hello "},
{"type": "text", "text": "world"},
],
}
]
# Test list input with string
mock_client.capture.reset_mock()
await client.models.generate_content(
model="gemini-2.0-flash", contents=["List item"], posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "user", "content": "List item"}]
async def test_async_client_model_parameters(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test model parameters with async Client API"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="test-id",
temperature=0.7,
max_tokens=100,
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_model_parameters"]["temperature"] == 0.7
assert props["$ai_model_parameters"]["max_tokens"] == 100
async def test_async_client_default_settings(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test async client with default PostHog settings"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(
api_key="test-key",
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Call without overriding defaults
await client.models.generate_content(model="gemini-2.0-flash", contents=["Hello"])
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "default_user"
assert call_args["groups"] == {"company": "acme_corp"}
assert props["team"] == "ai"
async def test_async_client_override_defaults(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test overriding async client defaults per call"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
client = AsyncClient(
api_key="test-key",
posthog_client=mock_client,
posthog_distinct_id="default_user",
posthog_properties={"team": "ai"},
posthog_privacy_mode=False,
posthog_groups={"company": "acme_corp"},
)
# Override defaults in call
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
posthog_distinct_id="specific_user",
posthog_properties={"feature": "chat", "urgent": True},
posthog_privacy_mode=True,
posthog_groups={"organization": "special_org"},
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Check overrides
assert call_args["distinct_id"] == "specific_user"
assert call_args["groups"] == {"organization": "special_org"}
assert props["$ai_input"] is None # privacy mode was overridden
# Check merged properties (defaults + call-specific)
assert props["team"] == "ai" # from defaults
assert props["feature"] == "chat" # from call
assert props["urgent"] is True # from call
async def test_async_vertex_ai_parameters_passed_through(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test that Vertex AI parameters are properly passed to genai.Client"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response
)
# Mock credentials object
mock_credentials = MagicMock()
mock_debug_config = MagicMock()
mock_http_options = MagicMock()
# Create client with Vertex AI parameters
AsyncClient(
vertexai=True,
credentials=mock_credentials,
project="test-project",
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
google_genai.Client.assert_called_once_with(
vertexai=True,
credentials=mock_credentials,
project="test-project",
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
)
async def test_async_api_key_mode(mock_client, mock_google_genai_client):
"""Test API key authentication mode with async client"""
# Create async client with just API key (traditional mode)
AsyncClient(
api_key="test-api-key",
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
google_genai.Client.assert_called_once_with(api_key="test-api-key")
async def test_async_function_calls_in_output_choices(
mock_client, mock_google_genai_client, mock_gemini_response_with_function_calls
):
"""Test that function calls are properly included in $ai_output_choices with async"""
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_gemini_response_with_function_calls
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.5-flash",
contents=["What's the weather in San Francisco?"],
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_calls
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "gemini"
assert props["$ai_model"] == "gemini-2.5-flash"
assert props["$ai_output_choices"] == [
{
"role": "assistant",
"content": [
{"type": "text", "text": "I'll check the weather for you."},
{"type": "text", "text": " Let me look that up."},
{
"type": "function",
"function": {
"name": "get_current_weather",
"arguments": {"location": "San Francisco"},
},
},
],
}
]
# Check token usage
assert props["$ai_input_tokens"] == 25
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
async def test_async_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
"""Test that cache and reasoning tokens are properly extracted with async"""
# Create a mock response with cache and reasoning tokens
mock_response = MagicMock()
mock_response.text = "Test response with cache"
mock_usage = MagicMock()
mock_usage.prompt_token_count = 100
mock_usage.candidates_token_count = 50
mock_usage.cached_content_token_count = 30 # Cache tokens
mock_usage.thoughts_token_count = 10 # Reasoning tokens
mock_response.usage_metadata = mock_usage
# Mock candidates
mock_candidate = MagicMock()
mock_candidate.text = "Test response with cache"
mock_response.candidates = [mock_candidate]
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.5-pro",
contents="Test with cache",
posthog_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Check that all token types are present
assert props["$ai_input_tokens"] == 100
assert props["$ai_output_tokens"] == 50
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 10
async def test_async_streaming_cache_and_reasoning_tokens(
mock_client, mock_google_genai_client
):
"""Test that cache and reasoning tokens are properly extracted in async streaming"""
async def mock_streaming_response():
# Create mock chunks with cache and reasoning tokens
chunk1 = MagicMock()
chunk1.text = "Hello "
chunk1_usage = MagicMock()
chunk1_usage.prompt_token_count = 100
chunk1_usage.candidates_token_count = 5
chunk1_usage.cached_content_token_count = 30 # Cache tokens
chunk1_usage.thoughts_token_count = 0
chunk1.usage_metadata = chunk1_usage
yield chunk1
chunk2 = MagicMock()
chunk2.text = "world!"
chunk2_usage = MagicMock()
chunk2_usage.prompt_token_count = 100
chunk2_usage.candidates_token_count = 10
chunk2_usage.cached_content_token_count = 30 # Same cache tokens
chunk2_usage.thoughts_token_count = 5 # Reasoning tokens
chunk2.usage_metadata = chunk2_usage
yield chunk2
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content_stream(
model="gemini-2.5-pro",
contents="Test streaming with cache",
posthog_distinct_id="test-id",
)
# Consume the stream
result = []
async for chunk in response:
result.append(chunk)
assert len(result) == 2
# Check PostHog capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Check that all token types are present (should use final chunk's usage)
assert props["$ai_input_tokens"] == 100
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 30
assert props["$ai_reasoning_tokens"] == 5
async def test_async_web_search_grounding(mock_client, mock_google_genai_client):
"""Test async web search detection via grounding_metadata."""
# Create mock response with grounding metadata
mock_response = MagicMock()
# Mock usage metadata
mock_usage = MagicMock()
mock_usage.prompt_token_count = 60
mock_usage.candidates_token_count = 40
mock_usage.cached_content_token_count = 0
mock_usage.thoughts_token_count = 0
mock_response.usage_metadata = mock_usage
# Mock grounding metadata
mock_grounding_chunk = MagicMock()
mock_grounding_chunk.uri = "https://example.com"
mock_grounding_metadata = MagicMock()
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
# Mock text part
mock_text_part = MagicMock()
mock_text_part.text = "According to search results..."
type(mock_text_part).text = mock_text_part.text
# Mock content with parts
mock_content = MagicMock()
mock_content.parts = [mock_text_part]
# Mock candidate with grounding metadata
mock_candidate = MagicMock()
mock_candidate.content = mock_content
mock_candidate.grounding_metadata = mock_grounding_metadata
type(mock_candidate).grounding_metadata = mock_candidate.grounding_metadata
mock_response.candidates = [mock_candidate]
mock_response.text = "According to search results..."
# Mock the async generate_content method
mock_google_genai_client.aio.models.generate_content = AsyncMock(
return_value=mock_response
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content(
model="gemini-2.5-flash",
contents="What's the latest news?",
posthog_distinct_id="test-id",
)
assert response == mock_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is detected (binary for grounding)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 60
assert props["$ai_output_tokens"] == 40
async def test_async_streaming_with_web_search(mock_client, mock_google_genai_client):
"""Test that web search count is properly captured in async streaming mode."""
async def mock_streaming_response():
# Create chunk 1 with grounding metadata
mock_chunk1 = MagicMock()
mock_chunk1.text = "According to "
mock_usage1 = MagicMock()
mock_usage1.prompt_token_count = 30
mock_usage1.candidates_token_count = 5
mock_usage1.cached_content_token_count = 0
mock_usage1.thoughts_token_count = 0
mock_chunk1.usage_metadata = mock_usage1
# Add grounding metadata to first chunk
mock_grounding_chunk = MagicMock()
mock_grounding_chunk.uri = "https://example.com"
mock_grounding_metadata = MagicMock()
mock_grounding_metadata.grounding_chunks = [mock_grounding_chunk]
mock_candidate1 = MagicMock()
mock_candidate1.grounding_metadata = mock_grounding_metadata
type(mock_candidate1).grounding_metadata = mock_candidate1.grounding_metadata
mock_chunk1.candidates = [mock_candidate1]
yield mock_chunk1
# Create chunk 2
mock_chunk2 = MagicMock()
mock_chunk2.text = "search results..."
mock_usage2 = MagicMock()
mock_usage2.prompt_token_count = 30
mock_usage2.candidates_token_count = 15
mock_usage2.cached_content_token_count = 0
mock_usage2.thoughts_token_count = 0
mock_chunk2.usage_metadata = mock_usage2
mock_candidate2 = MagicMock()
mock_chunk2.candidates = [mock_candidate2]
yield mock_chunk2
# Mock the async generate_content_stream method
mock_google_genai_client.aio.models.generate_content_stream = AsyncMock(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
response = await client.models.generate_content_stream(
model="gemini-2.5-flash",
contents="What's the latest news?",
posthog_distinct_id="test-id",
)
chunks = []
async for chunk in response:
chunks.append(chunk)
assert len(chunks) == 2
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
# Verify web search count is detected (binary for grounding)
assert props["$ai_web_search_count"] == 1
assert props["$ai_input_tokens"] == 30
assert props["$ai_output_tokens"] == 15
+1 -1
View File
@@ -1,5 +1,5 @@
import pytest
pytest.importorskip("langchain")
pytest.importorskip("langchain_core")
pytest.importorskip("langchain_community")
pytest.importorskip("langgraph")
+868 -14
View File
@@ -5,7 +5,7 @@ import os
import time
import uuid
from typing import List, Literal, Optional, TypedDict, Union
from unittest.mock import patch
from unittest.mock import patch, MagicMock
import pytest
@@ -113,6 +113,7 @@ def test_metadata_capture(mock_client):
base_url="https://us.posthog.com",
name="test",
end_time=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -204,6 +205,7 @@ def test_basic_chat_chain(mock_client, stream):
# Generation is second
assert generation_args["event"] == "$ai_generation"
assert "distinct_id" in generation_args
assert generation_props["$ai_framework"] == "langchain"
assert "$ai_model" in generation_props
assert "$ai_provider" in generation_props
assert generation_props["$ai_input"] == [
@@ -1123,9 +1125,9 @@ def test_anthropic_chain(mock_client):
)
chain = prompt | ChatAnthropic(
api_key=ANTHROPIC_API_KEY,
model="claude-3-opus-20240229",
model="claude-sonnet-4-5-20250929",
temperature=0,
max_tokens=1,
max_tokens=1024,
)
callbacks = CallbackHandler(
mock_client,
@@ -1148,12 +1150,12 @@ def test_anthropic_chain(mock_client):
assert gen_args["event"] == "$ai_generation"
assert gen_props["$ai_trace_id"] == "test-trace-id"
assert gen_props["$ai_provider"] == "anthropic"
assert gen_props["$ai_model"] == "claude-3-opus-20240229"
assert gen_props["$ai_model"] == "claude-sonnet-4-5-20250929"
assert gen_props["foo"] == "bar"
assert gen_props["$ai_model_parameters"] == {
"temperature": 0.0,
"max_tokens": 1,
"max_tokens": 1024,
"streaming": False,
}
assert gen_props["$ai_input"] == [
@@ -1169,7 +1171,7 @@ def test_anthropic_chain(mock_client):
<= approximate_latency
)
assert gen_props["$ai_input_tokens"] == 17
assert gen_props["$ai_output_tokens"] == 1
assert gen_props["$ai_output_tokens"] == 4
assert trace_args["event"] == "$ai_trace"
assert trace_props["$ai_input_state"] == {}
@@ -1186,9 +1188,9 @@ async def test_async_anthropic_streaming(mock_client):
)
chain = prompt | ChatAnthropic(
api_key=ANTHROPIC_API_KEY,
model="claude-3-opus-20240229",
model="claude-sonnet-4-5-20250929",
temperature=0,
max_tokens=1,
max_tokens=1024,
streaming=True,
stream_usage=True,
)
@@ -1268,6 +1270,7 @@ def test_metadata_tools(mock_client):
name="test",
tools=tools,
end_time=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1564,9 +1567,9 @@ def test_anthropic_cache_write_and_read_tokens(mock_client):
AIMessage(
content="Using cached analysis to provide quick response.",
usage_metadata={
"input_tokens": 200,
"input_tokens": 1200,
"output_tokens": 30,
"total_tokens": 1030,
"total_tokens": 1230,
"cache_read_input_tokens": 800, # Anthropic cache read
},
)
@@ -1583,13 +1586,147 @@ def test_anthropic_cache_write_and_read_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 200
assert (
generation_props["$ai_input_tokens"] == 1200
) # No provider metadata, no subtraction
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_creation_input_tokens"] == 0
assert generation_props["$ai_cache_read_input_tokens"] == 800
assert generation_props["$ai_reasoning_tokens"] == 0
def test_anthropic_provider_subtracts_cache_tokens(mock_client):
"""Test that Anthropic provider correctly subtracts cache tokens from input tokens."""
from langchain_core.outputs import LLMResult, ChatGeneration
from langchain_core.messages import AIMessage
from uuid import uuid4
cb = CallbackHandler(mock_client)
run_id = uuid4()
# Set up with Anthropic provider
cb._set_llm_metadata(
serialized={},
run_id=run_id,
messages=[{"role": "user", "content": "test"}],
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
)
# Response with cache tokens: 1200 input (includes 800 cached)
response = LLMResult(
generations=[
[
ChatGeneration(
message=AIMessage(content="Response"),
generation_info={
"usage_metadata": {
"input_tokens": 1200,
"output_tokens": 50,
"cache_read_input_tokens": 800,
}
},
)
]
],
llm_output={},
)
cb._pop_run_and_capture_generation(run_id, None, response)
generation_args = mock_client.capture.call_args_list[0][1]
assert generation_args["properties"]["$ai_input_tokens"] == 400 # 1200 - 800
assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800
def test_anthropic_provider_subtracts_cache_write_tokens(mock_client):
"""Test that Anthropic provider correctly subtracts cache write tokens from input tokens."""
from langchain_core.outputs import LLMResult, ChatGeneration
from langchain_core.messages import AIMessage
from uuid import uuid4
cb = CallbackHandler(mock_client)
run_id = uuid4()
# Set up with Anthropic provider
cb._set_llm_metadata(
serialized={},
run_id=run_id,
messages=[{"role": "user", "content": "test"}],
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
)
# Response with cache creation: 1000 input (includes 800 being written to cache)
response = LLMResult(
generations=[
[
ChatGeneration(
message=AIMessage(content="Response"),
generation_info={
"usage_metadata": {
"input_tokens": 1000,
"output_tokens": 50,
"cache_creation_input_tokens": 800,
}
},
)
]
],
llm_output={},
)
cb._pop_run_and_capture_generation(run_id, None, response)
generation_args = mock_client.capture.call_args_list[0][1]
assert generation_args["properties"]["$ai_input_tokens"] == 200 # 1000 - 800
assert generation_args["properties"]["$ai_cache_creation_input_tokens"] == 800
def test_anthropic_provider_subtracts_both_cache_read_and_write_tokens(mock_client):
"""Test that Anthropic provider correctly subtracts both cache read and write tokens."""
from langchain_core.outputs import LLMResult, ChatGeneration
from langchain_core.messages import AIMessage
from uuid import uuid4
cb = CallbackHandler(mock_client)
run_id = uuid4()
# Set up with Anthropic provider
cb._set_llm_metadata(
serialized={},
run_id=run_id,
messages=[{"role": "user", "content": "test"}],
metadata={"ls_provider": "anthropic", "ls_model_name": "claude-3-sonnet"},
)
# Response with both cache read and creation
response = LLMResult(
generations=[
[
ChatGeneration(
message=AIMessage(content="Response"),
generation_info={
"usage_metadata": {
"input_tokens": 2000,
"output_tokens": 50,
"cache_read_input_tokens": 800,
"cache_creation_input_tokens": 500,
}
},
)
]
],
llm_output={},
)
cb._pop_run_and_capture_generation(run_id, None, response)
generation_args = mock_client.capture.call_args_list[0][1]
# 2000 - 800 (read) - 500 (write) = 700
assert generation_args["properties"]["$ai_input_tokens"] == 700
assert generation_args["properties"]["$ai_cache_read_input_tokens"] == 800
assert generation_args["properties"]["$ai_cache_creation_input_tokens"] == 500
def test_openai_cache_read_tokens(mock_client):
"""Test that OpenAI cache read tokens are captured correctly."""
prompt = ChatPromptTemplate.from_messages(
@@ -1625,7 +1762,7 @@ def test_openai_cache_read_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_input_tokens"] == 150 # No subtraction for OpenAI
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
assert generation_props["$ai_cache_creation_input_tokens"] == 0
@@ -1707,7 +1844,7 @@ def test_combined_reasoning_and_cache_tokens(mock_client):
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
assert generation_props["$ai_input_tokens"] == 500
assert generation_props["$ai_input_tokens"] == 500 # No subtraction for OpenAI
assert generation_props["$ai_output_tokens"] == 100
assert generation_props["$ai_cache_read_input_tokens"] == 300
assert generation_props["$ai_cache_creation_input_tokens"] == 0
@@ -1715,7 +1852,7 @@ def test_combined_reasoning_and_cache_tokens(mock_client):
@pytest.mark.skipif(not OPENAI_API_KEY, reason="OPENAI_API_KEY is not set")
def test_openai_reasoning_tokens(mock_client):
def test_openai_reasoning_tokens_o4_mini(mock_client):
model = ChatOpenAI(
api_key=OPENAI_API_KEY, model="o4-mini", max_completion_tokens=10
)
@@ -1790,3 +1927,720 @@ def test_convert_message_to_dict_tool_calls():
},
}
]
def test_tool_definition(mock_client):
"""Test that tools defined in invocation parameters are captured in $ai_tools property"""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
# Define tools to be passed to the invocation parameters
tools = [
{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get the current weather for a specific location",
"parameters": {
"type": "object",
"properties": {
"location": {
"type": "string",
"description": "The city or location name to get weather for",
}
},
"required": ["location"],
},
},
}
]
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com/v1"}},
run_id,
messages=[{"role": "user", "content": "hey"}],
invocation_params={"temperature": 0.7, "tools": tools},
metadata={"ls_model_name": "gpt-4o-mini", "ls_provider": "openai"},
name="test",
)
expected = GenerationMetadata(
model="gpt-4o-mini",
input=[{"role": "user", "content": "hey"}],
start_time=1234567890,
model_params={"temperature": 0.7},
provider="openai",
base_url="https://api.openai.com/v1",
name="test",
tools=tools,
end_time=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
run = callbacks._pop_run_metadata(run_id)
expected.end_time = 1234567891
assert run == expected
assert callbacks._runs == {}
# Now test that the tools are properly captured in the PostHog event
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
callbacks._capture_generation(
trace_id=run_id,
run_id=run_id,
run=run,
output=mock_response,
parent_run_id=None,
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == run_id
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4o-mini"
assert props["$ai_input"] == [{"role": "user", "content": "hey"}]
assert props["$ai_model_parameters"] == {"temperature": 0.7}
assert props["$ai_base_url"] == "https://api.openai.com/v1"
assert props["$ai_span_name"] == "test"
assert props["$ai_span_id"] == run_id
assert props["$ai_trace_id"] == run_id
assert props["$ai_latency"] == 1.0
# Verify that tools are captured in the $ai_tools property
assert props["$ai_tools"] == tools
def test_cache_read_tokens_subtraction_from_input_tokens(mock_client):
"""Test that cache_read_tokens are properly subtracted from input_tokens.
This tests the logic in callbacks.py lines 757-758:
if normalized_usage.input_tokens and normalized_usage.cache_read_tokens:
normalized_usage.input_tokens = max(normalized_usage.input_tokens - normalized_usage.cache_read_tokens, 0)
"""
prompt = ChatPromptTemplate.from_messages(
[("user", "Use the cached prompt for this request")]
)
# Scenario 1: input_tokens includes cache_read_tokens (typical case)
# input_tokens=150 includes 100 cache_read tokens, so actual input is 50
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response using cached prompt context.",
usage_metadata={
"input_tokens": 150, # Total includes cache reads
"output_tokens": 40,
"total_tokens": 190,
"cache_read_input_tokens": 100, # 100 tokens read from cache
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response using cached prompt context."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens not reduced without provider metadata
assert generation_props["$ai_input_tokens"] == 150
assert generation_props["$ai_output_tokens"] == 40
assert generation_props["$ai_cache_read_input_tokens"] == 100
def test_cache_read_tokens_subtraction_prevents_negative(mock_client):
"""Test that cache_read_tokens subtraction doesn't result in negative input_tokens.
This tests the max(..., 0) part of the logic in callbacks.py lines 757-758.
"""
prompt = ChatPromptTemplate.from_messages(
[("user", "Edge case with large cache read")]
)
# Edge case: cache_read_tokens >= input_tokens
# This could happen in some API responses where accounting differs
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response with edge case token counts.",
usage_metadata={
"input_tokens": 80,
"output_tokens": 20,
"total_tokens": 100,
"cache_read_input_tokens": 100, # More than input_tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response with edge case token counts."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens not reduced without provider metadata
assert generation_props["$ai_input_tokens"] == 80
assert generation_props["$ai_output_tokens"] == 20
assert generation_props["$ai_cache_read_input_tokens"] == 100
def test_no_cache_read_tokens_no_subtraction(mock_client):
"""Test that when there are no cache_read_tokens, input_tokens remain unchanged.
This tests the conditional check before the subtraction in callbacks.py line 757.
"""
prompt = ChatPromptTemplate.from_messages(
[("user", "Normal request without cache")]
)
# No cache usage - input_tokens should remain as-is
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response without cache.",
usage_metadata={
"input_tokens": 100,
"output_tokens": 30,
"total_tokens": 130,
# No cache_read_input_tokens
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response without cache."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens should remain unchanged at 100
assert generation_props["$ai_input_tokens"] == 100
assert generation_props["$ai_output_tokens"] == 30
assert generation_props["$ai_cache_read_input_tokens"] == 0
def test_zero_input_tokens_with_cache_read(mock_client):
"""Test edge case where input_tokens is 0 but cache_read_tokens exist.
This tests the falsy check in the conditional (line 757).
"""
prompt = ChatPromptTemplate.from_messages([("user", "Edge case query")])
# Edge case: input_tokens is 0 (falsy), should skip subtraction
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Response.",
usage_metadata={
"input_tokens": 0,
"output_tokens": 10,
"total_tokens": 10,
"cache_read_input_tokens": 50,
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Response."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens should remain 0 (no subtraction because input_tokens is falsy)
assert generation_props["$ai_input_tokens"] == 0
assert generation_props["$ai_output_tokens"] == 10
assert generation_props["$ai_cache_read_input_tokens"] == 50
def test_non_anthropic_cache_write_tokens_not_subtracted_from_input(mock_client):
"""Test that cache_creation_input_tokens do NOT affect input_tokens for non-Anthropic providers.
When no provider metadata is set (or for non-Anthropic providers), cache tokens should
NOT be subtracted from input_tokens. This is because different providers report tokens
differently - only Anthropic's LangChain integration requires subtraction.
"""
prompt = ChatPromptTemplate.from_messages([("user", "Create cache")])
# Cache creation without cache read
model = FakeMessagesListChatModel(
responses=[
AIMessage(
content="Creating cache.",
usage_metadata={
"input_tokens": 1000,
"output_tokens": 20,
"total_tokens": 1020,
"cache_creation_input_tokens": 800, # Cache write, not read
},
)
]
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
result = chain.invoke({}, config={"callbacks": callbacks})
assert result.content == "Creating cache."
assert mock_client.capture.call_count == 3
generation_args = mock_client.capture.call_args_list[1][1]
generation_props = generation_args["properties"]
assert generation_args["event"] == "$ai_generation"
# Input tokens should NOT be reduced by cache_creation_input_tokens
assert generation_props["$ai_input_tokens"] == 1000
assert generation_props["$ai_output_tokens"] == 20
assert generation_props["$ai_cache_creation_input_tokens"] == 800
assert generation_props["$ai_cache_read_input_tokens"] == 0
def test_agent_action_and_finish_imports():
"""
Regression test for LangChain 1.0+ compatibility (Issue #362).
Verifies that AgentAction and AgentFinish can be imported and used.
This test ensures the imports work with both LangChain 0.x and 1.0+.
"""
# Import the types that caused the compatibility issue
try:
from langchain_core.agents import AgentAction, AgentFinish
except (ImportError, ModuleNotFoundError):
from langchain.schema.agent import AgentAction, AgentFinish # type: ignore
# Verify they're available in the callbacks module
from posthog.ai.langchain.callbacks import CallbackHandler
# Test on_agent_action with mock data
mock_client = MagicMock()
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
parent_run_id = uuid.uuid4()
# Create mock AgentAction
action = AgentAction(tool="test_tool", tool_input="test_input", log="test_log")
# Should not raise an exception
callbacks.on_agent_action(action, run_id=run_id, parent_run_id=parent_run_id)
# Verify parent was set
assert run_id in callbacks._parent_tree
assert callbacks._parent_tree[run_id] == parent_run_id
# Test on_agent_finish with mock data
finish = AgentFinish(return_values={"output": "test_output"}, log="finish_log")
# Should not raise an exception
callbacks.on_agent_finish(finish, run_id=run_id, parent_run_id=parent_run_id)
# Verify capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
assert call_args["event"] == "$ai_span"
def test_posthog_properties_field_in_generation_metadata(mock_client):
"""Test that posthog_properties is properly stored in GenerationMetadata."""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
# Test with billable=True
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
run_id,
messages=[{"role": "user", "content": "Test message"}],
invocation_params={"temperature": 0.5},
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"posthog_properties": {"$ai_billable": True},
},
name="test",
)
expected = GenerationMetadata(
model="gpt-4o",
input=[{"role": "user", "content": "Test message"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="openai",
base_url="https://api.openai.com",
name="test",
posthog_properties={"$ai_billable": True},
end_time=None,
)
assert callbacks._runs[run_id] == expected
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
callbacks._pop_run_metadata(run_id)
# Test with billable=False (explicit)
run_id2 = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
run_id2,
messages=[{"role": "user", "content": "Test message"}],
invocation_params={"temperature": 0.5},
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"posthog_properties": {"$ai_billable": False},
},
name="test",
)
assert callbacks._runs[run_id2].posthog_properties == {"$ai_billable": False}
callbacks._pop_run_metadata(run_id2)
# Test when posthog_properties not provided
run_id3 = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{"kwargs": {"openai_api_base": "https://api.openai.com"}},
run_id3,
messages=[{"role": "user", "content": "Test message"}],
invocation_params={"temperature": 0.5},
metadata={"ls_model_name": "gpt-4o", "ls_provider": "openai"},
name="test",
)
assert callbacks._runs[run_id3].posthog_properties is None
def test_billable_property_in_generation_event(mock_client):
"""Test that the billable property is captured in the $ai_generation event."""
callbacks = CallbackHandler(mock_client)
# We need to test the _set_llm_metadata directly since FakeMessagesListChatModel
# doesn't support metadata in the same way as real models
run_id = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
{},
run_id,
messages=[{"role": "user", "content": "Test"}],
metadata={
"posthog_properties": {"$ai_billable": True},
"ls_model_name": "test-model",
},
invocation_params={},
)
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
with patch("time.time", return_value=1234567891):
run = callbacks._pop_run_metadata(run_id)
callbacks._capture_generation(
trace_id=run_id,
run_id=run_id,
run=run,
output=mock_response,
parent_run_id=None,
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["event"] == "$ai_generation"
assert props["$ai_billable"] is True
def test_billable_defaults_to_false_in_event(mock_client):
"""Test that $ai_billable is not present when not specified."""
prompt = ChatPromptTemplate.from_messages([("user", "Test query")])
model = FakeMessagesListChatModel(
responses=[AIMessage(content="Test response")],
)
callbacks = [CallbackHandler(mock_client)]
chain = prompt | model
chain.invoke({}, config={"callbacks": callbacks})
generation_call = None
for call in mock_client.capture.call_args_list:
if call[1]["event"] == "$ai_generation":
generation_call = call
break
assert generation_call is not None
props = generation_call[1]["properties"]
assert "$ai_billable" not in props
def test_billable_with_real_chain(mock_client):
"""Test billable tracking through a complete chain execution with mocked metadata."""
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
with patch("time.time", return_value=1000.0):
callbacks._set_llm_metadata(
{},
run_id,
messages=[{"role": "user", "content": "What's the weather?"}],
metadata={
"ls_model_name": "fake-model",
"ls_provider": "fake",
"posthog_properties": {"$ai_billable": True},
},
invocation_params={"temperature": 0.7},
)
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
with patch("time.time", return_value=1001.0):
run = callbacks._pop_run_metadata(run_id)
callbacks._capture_generation(
trace_id=run_id,
run_id=run_id,
run=run,
output=mock_response,
parent_run_id=None,
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["event"] == "$ai_generation"
assert props["$ai_billable"] is True
assert props["$ai_model"] == "fake-model"
assert props["$ai_provider"] == "fake"
# Exception Capture Integration Tests
def test_exception_autocapture_on_span_error():
"""Test that capture_exception is called when a span errors and autocapture is enabled."""
mock_client = MagicMock()
mock_client.privacy_mode = False
mock_client.enable_exception_autocapture = True
mock_client.capture_exception.return_value = "exception-uuid-123"
def failing_span(_):
raise ValueError("test error")
callbacks = [CallbackHandler(mock_client)]
chain = RunnableLambda(failing_span)
try:
chain.invoke({}, config={"callbacks": callbacks})
except ValueError:
pass
# Verify capture_exception was called
assert mock_client.capture_exception.call_count == 1
exception_call = mock_client.capture_exception.call_args
assert isinstance(exception_call[0][0], ValueError)
assert str(exception_call[0][0]) == "test error"
def test_exception_autocapture_adds_exception_id_to_span_event():
"""Test that $exception_event_id is added to the span event properties."""
mock_client = MagicMock()
mock_client.privacy_mode = False
mock_client.enable_exception_autocapture = True
mock_client.capture_exception.return_value = "exception-uuid-456"
def failing_span(_):
raise ValueError("test error")
callbacks = [CallbackHandler(mock_client)]
chain = RunnableLambda(failing_span)
try:
chain.invoke({}, config={"callbacks": callbacks})
except ValueError:
pass
# Find the span event (should have $ai_is_error=True)
span_calls = [
call
for call in mock_client.capture.call_args_list
if call[1].get("properties", {}).get("$ai_is_error") is True
]
assert len(span_calls) >= 1
span_props = span_calls[0][1]["properties"]
assert span_props["$exception_event_id"] == "exception-uuid-456"
assert span_props["$ai_error"] == "ValueError: test error"
def test_exception_autocapture_disabled_does_not_capture():
"""Test that capture_exception is NOT called when autocapture is disabled."""
mock_client = MagicMock()
mock_client.privacy_mode = False
mock_client.enable_exception_autocapture = False
def failing_span(_):
raise ValueError("test error")
callbacks = [CallbackHandler(mock_client)]
chain = RunnableLambda(failing_span)
try:
chain.invoke({}, config={"callbacks": callbacks})
except ValueError:
pass
# Verify capture_exception was NOT called
assert mock_client.capture_exception.call_count == 0
# But the span event should still have error info
span_calls = [
call
for call in mock_client.capture.call_args_list
if call[1].get("properties", {}).get("$ai_is_error") is True
]
assert len(span_calls) >= 1
span_props = span_calls[0][1]["properties"]
assert "$exception_event_id" not in span_props
assert span_props["$ai_error"] == "ValueError: test error"
def test_exception_autocapture_on_llm_generation_error(mock_client):
"""Test that capture_exception is called when an LLM generation fails."""
mock_client.privacy_mode = False
mock_client.enable_exception_autocapture = True
mock_client.capture_exception.return_value = "exception-uuid-789"
callbacks = CallbackHandler(mock_client)
run_id = uuid.uuid4()
# Simulate LLM start
callbacks.on_llm_start(
serialized={"kwargs": {"openai_api_base": "https://api.openai.com"}},
prompts=["Hello"],
run_id=run_id,
)
# Simulate LLM error
error = Exception("API rate limit exceeded")
callbacks.on_llm_error(error, run_id=run_id)
# Verify capture_exception was called
assert mock_client.capture_exception.call_count == 1
exception_call = mock_client.capture_exception.call_args
assert exception_call[0][0] is error
# Verify the generation event has $exception_event_id
generation_calls = [
call
for call in mock_client.capture.call_args_list
if call[1].get("event") == "$ai_generation"
]
assert len(generation_calls) == 1
gen_props = generation_calls[0][1]["properties"]
assert gen_props["$exception_event_id"] == "exception-uuid-789"
assert gen_props["$ai_is_error"] is True
def test_exception_autocapture_passes_ai_properties_to_exception():
"""Test that AI properties are passed to the exception event."""
mock_client = MagicMock()
mock_client.privacy_mode = False
mock_client.enable_exception_autocapture = True
mock_client.capture_exception.return_value = "exception-uuid-abc"
callbacks = CallbackHandler(
mock_client,
distinct_id="user-123",
properties={"custom_prop": "custom_value"},
)
run_id = uuid.uuid4()
# Simulate LLM start
callbacks.on_llm_start(
serialized={"kwargs": {"openai_api_base": "https://api.openai.com"}},
prompts=["Hello"],
run_id=run_id,
)
# Simulate LLM error
error = Exception("API error")
callbacks.on_llm_error(error, run_id=run_id)
# Verify capture_exception received the properties
exception_call = mock_client.capture_exception.call_args
props = exception_call[1]["properties"]
# Should have AI-related properties
assert "$ai_trace_id" in props
assert "$ai_is_error" in props
assert props["$ai_is_error"] is True
# Should have distinct_id passed through
assert exception_call[1]["distinct_id"] == "user-123"
def test_exception_autocapture_none_return_no_exception_id():
"""Test that when capture_exception returns None, no $exception_event_id is added."""
mock_client = MagicMock()
mock_client.privacy_mode = False
mock_client.enable_exception_autocapture = True
mock_client.capture_exception.return_value = (
None # e.g., exception already captured
)
def failing_span(_):
raise ValueError("test error")
callbacks = [CallbackHandler(mock_client)]
chain = RunnableLambda(failing_span)
try:
chain.invoke({}, config={"callbacks": callbacks})
except ValueError:
pass
# capture_exception was called but returned None
assert mock_client.capture_exception.call_count == 1
# Span event should NOT have $exception_event_id
span_calls = [
call
for call in mock_client.capture.call_args_list
if call[1].get("properties", {}).get("$ai_is_error") is True
]
assert len(span_calls) >= 1
span_props = span_calls[0][1]["properties"]
assert "$exception_event_id" not in span_props
File diff suppressed because it is too large Load Diff
+522
View File
@@ -0,0 +1,522 @@
import os
import unittest
from posthog.ai.sanitization import (
redact_base64_data_url,
sanitize_openai,
sanitize_openai_response,
sanitize_anthropic,
sanitize_gemini,
sanitize_langchain,
is_base64_data_url,
is_raw_base64,
REDACTED_IMAGE_PLACEHOLDER,
)
class TestSanitization(unittest.TestCase):
def setUp(self):
self.sample_base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
self.sample_base64_png = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUA..."
self.regular_url = "https://example.com/image.jpg"
self.raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
def test_is_base64_data_url(self):
self.assertTrue(is_base64_data_url(self.sample_base64_image))
self.assertTrue(is_base64_data_url(self.sample_base64_png))
self.assertFalse(is_base64_data_url(self.regular_url))
self.assertFalse(is_base64_data_url("regular text"))
def test_is_raw_base64(self):
self.assertTrue(is_raw_base64(self.raw_base64))
self.assertFalse(is_raw_base64("short"))
self.assertFalse(is_raw_base64(self.regular_url))
self.assertFalse(is_raw_base64("/path/to/file"))
def test_redact_base64_data_url(self):
self.assertEqual(
redact_base64_data_url(self.sample_base64_image), REDACTED_IMAGE_PLACEHOLDER
)
self.assertEqual(
redact_base64_data_url(self.sample_base64_png), REDACTED_IMAGE_PLACEHOLDER
)
self.assertEqual(redact_base64_data_url(self.regular_url), self.regular_url)
self.assertEqual(redact_base64_data_url(None), None)
self.assertEqual(redact_base64_data_url(123), 123)
def test_sanitize_openai(self):
input_data = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image_url",
"image_url": {
"url": self.sample_base64_image,
"detail": "high",
},
},
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
self.assertEqual(
result[0]["content"][1]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
)
self.assertEqual(result[0]["content"][1]["image_url"]["detail"], "high")
def test_sanitize_openai_preserves_regular_urls(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": self.regular_url},
}
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(result[0]["content"][0]["image_url"]["url"], self.regular_url)
def test_sanitize_openai_response(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "input_image",
"image_url": self.sample_base64_image,
}
],
}
]
result = sanitize_openai_response(input_data)
self.assertEqual(
result[0]["content"][0]["image_url"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_anthropic(self):
input_data = [
{
"role": "user",
"content": [
{"type": "text", "text": "What is in this image?"},
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "base64data",
},
},
],
}
]
result = sanitize_anthropic(input_data)
self.assertEqual(result[0]["content"][0]["text"], "What is in this image?")
self.assertEqual(
result[0]["content"][1]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
self.assertEqual(result[0]["content"][1]["source"]["type"], "base64")
self.assertEqual(result[0]["content"][1]["source"]["media_type"], "image/jpeg")
def test_sanitize_gemini(self):
input_data = [
{
"parts": [
{"text": "What is in this image?"},
{
"inline_data": {
"mime_type": "image/jpeg",
"data": "base64data",
}
},
]
}
]
result = sanitize_gemini(input_data)
self.assertEqual(result[0]["parts"][0]["text"], "What is in this image?")
self.assertEqual(
result[0]["parts"][1]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
self.assertEqual(
result[0]["parts"][1]["inline_data"]["mime_type"], "image/jpeg"
)
def test_sanitize_langchain_openai_style(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": self.sample_base64_image},
}
],
}
]
result = sanitize_langchain(input_data)
self.assertEqual(
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_langchain_anthropic_style(self):
input_data = [
{
"role": "user",
"content": [
{
"type": "image",
"source": {"data": "base64data"},
}
],
}
]
result = sanitize_langchain(input_data)
self.assertEqual(
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_with_data_url_format(self):
# Test that data URLs are properly detected and redacted across providers
data_url = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD"
# OpenAI format
openai_data = [
{
"role": "user",
"content": [{"type": "image_url", "image_url": {"url": data_url}}],
}
]
result = sanitize_openai(openai_data)
self.assertEqual(
result[0]["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
)
# Anthropic format
anthropic_data = [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": data_url,
},
}
],
}
]
result = sanitize_anthropic(anthropic_data)
self.assertEqual(
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
# LangChain format
langchain_data = [
{"role": "user", "content": [{"type": "image", "data": data_url}]}
]
result = sanitize_langchain(langchain_data)
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
def test_sanitize_with_raw_base64(self):
# Test that raw base64 strings (without data URL prefix) are detected
raw_base64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUl=="
# Test with Anthropic format
anthropic_data = [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/png",
"data": raw_base64,
},
}
],
}
]
result = sanitize_anthropic(anthropic_data)
self.assertEqual(
result[0]["content"][0]["source"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
# Test with Gemini format
gemini_data = [
{"parts": [{"inline_data": {"mime_type": "image/png", "data": raw_base64}}]}
]
result = sanitize_gemini(gemini_data)
self.assertEqual(
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
def test_sanitize_preserves_regular_content(self):
# Ensure non-base64 content is preserved across all providers
regular_url = "https://example.com/image.jpg"
text_content = "What do you see?"
# OpenAI
openai_data = [
{
"role": "user",
"content": [
{"type": "text", "text": text_content},
{"type": "image_url", "image_url": {"url": regular_url}},
],
}
]
result = sanitize_openai(openai_data)
self.assertEqual(result[0]["content"][0]["text"], text_content)
self.assertEqual(result[0]["content"][1]["image_url"]["url"], regular_url)
# Anthropic
anthropic_data = [
{
"role": "user",
"content": [
{"type": "text", "text": text_content},
{"type": "image", "source": {"type": "url", "url": regular_url}},
],
}
]
result = sanitize_anthropic(anthropic_data)
self.assertEqual(result[0]["content"][0]["text"], text_content)
# URL-based images should remain unchanged
self.assertEqual(result[0]["content"][1]["source"]["url"], regular_url)
def test_sanitize_handles_non_dict_content(self):
input_data = [{"role": "user", "content": "Just text"}]
result = sanitize_openai(input_data)
self.assertEqual(result, input_data)
def test_sanitize_handles_none_input(self):
self.assertIsNone(sanitize_openai(None))
self.assertIsNone(sanitize_anthropic(None))
self.assertIsNone(sanitize_gemini(None))
self.assertIsNone(sanitize_langchain(None))
def test_sanitize_handles_single_message(self):
input_data = {
"role": "user",
"content": [
{
"type": "image_url",
"image_url": {"url": self.sample_base64_image},
}
],
}
result = sanitize_openai(input_data)
self.assertEqual(
result["content"][0]["image_url"]["url"], REDACTED_IMAGE_PLACEHOLDER
)
class TestAIMultipartRequest(unittest.TestCase):
"""Test that _INTERNAL_LLMA_MULTIMODAL environment variable controls sanitization."""
def tearDown(self):
# Clean up environment variable after each test
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
def test_multimodal_disabled_redacts_images(self):
"""When _INTERNAL_LLMA_MULTIMODAL is not set, images should be redacted."""
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
result = redact_base64_data_url(base64_image)
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
def test_multimodal_enabled_preserves_images(self):
"""When _INTERNAL_LLMA_MULTIMODAL is true, images should be preserved."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
result = redact_base64_data_url(base64_image)
self.assertEqual(result, base64_image)
def test_multimodal_enabled_with_1(self):
"""_INTERNAL_LLMA_MULTIMODAL=1 should enable multimodal."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "1"
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
result = redact_base64_data_url(base64_image)
self.assertEqual(result, base64_image)
def test_multimodal_enabled_with_yes(self):
"""_INTERNAL_LLMA_MULTIMODAL=yes should enable multimodal."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "yes"
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
result = redact_base64_data_url(base64_image)
self.assertEqual(result, base64_image)
def test_multimodal_false_redacts_images(self):
"""_INTERNAL_LLMA_MULTIMODAL=false should still redact."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "false"
base64_image = "data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQ..."
result = redact_base64_data_url(base64_image)
self.assertEqual(result, REDACTED_IMAGE_PLACEHOLDER)
def test_anthropic_multimodal_enabled(self):
"""Anthropic images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
input_data = [
{
"role": "user",
"content": [
{
"type": "image",
"source": {
"type": "base64",
"media_type": "image/jpeg",
"data": "base64data",
},
}
],
}
]
result = sanitize_anthropic(input_data)
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
def test_gemini_multimodal_enabled(self):
"""Gemini images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
input_data = [
{
"parts": [
{"inline_data": {"mime_type": "image/jpeg", "data": "base64data"}}
]
}
]
result = sanitize_gemini(input_data)
self.assertEqual(result[0]["parts"][0]["inline_data"]["data"], "base64data")
def test_langchain_anthropic_style_multimodal_enabled(self):
"""LangChain Anthropic-style images should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
input_data = [
{
"role": "user",
"content": [
{
"type": "image",
"source": {"data": "base64data"},
}
],
}
]
result = sanitize_langchain(input_data)
self.assertEqual(result[0]["content"][0]["source"]["data"], "base64data")
def test_openai_audio_redacted_by_default(self):
"""OpenAI audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
input_data = [
{
"role": "assistant",
"content": [
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(result[0]["content"][0]["data"], REDACTED_IMAGE_PLACEHOLDER)
self.assertEqual(result[0]["content"][0]["id"], "audio_123")
def test_openai_audio_preserved_with_flag(self):
"""OpenAI audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
input_data = [
{
"role": "assistant",
"content": [
{"type": "audio", "data": "base64audiodata", "id": "audio_123"}
],
}
]
result = sanitize_openai(input_data)
self.assertEqual(result[0]["content"][0]["data"], "base64audiodata")
def test_gemini_audio_redacted_by_default(self):
"""Gemini audio should be redacted when _INTERNAL_LLMA_MULTIMODAL is not set."""
if "_INTERNAL_LLMA_MULTIMODAL" in os.environ:
del os.environ["_INTERNAL_LLMA_MULTIMODAL"]
input_data = [
{
"parts": [
{
"inline_data": {
"mime_type": "audio/L16;codec=pcm;rate=24000",
"data": "base64audiodata",
}
}
]
}
]
result = sanitize_gemini(input_data)
self.assertEqual(
result[0]["parts"][0]["inline_data"]["data"], REDACTED_IMAGE_PLACEHOLDER
)
def test_gemini_audio_preserved_with_flag(self):
"""Gemini audio should be preserved when _INTERNAL_LLMA_MULTIMODAL is enabled."""
os.environ["_INTERNAL_LLMA_MULTIMODAL"] = "true"
input_data = [
{
"parts": [
{
"inline_data": {
"mime_type": "audio/L16;codec=pcm;rate=24000",
"data": "base64audiodata",
}
}
]
}
]
result = sanitize_gemini(input_data)
self.assertEqual(
result[0]["parts"][0]["inline_data"]["data"], "base64audiodata"
)
if __name__ == "__main__":
unittest.main()
+363
View File
@@ -0,0 +1,363 @@
"""
Tests for system prompt capture across all LLM providers.
This test suite ensures that system prompts are correctly captured in analytics
regardless of how they're passed to the providers:
- As first message in messages/contents array (standard format)
- As separate system parameter (Anthropic, OpenAI)
- As instructions parameter (OpenAI Responses API)
- As system_instruction parameter (Gemini)
"""
import time
import unittest
from unittest.mock import MagicMock, patch
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestSystemPromptCapture(unittest.TestCase):
"""Test system prompt capture for all providers."""
def setUp(self):
super().setUp()
self.test_system_prompt = "You are a helpful AI assistant."
self.test_user_message = "Hello, how are you?"
self.test_response = "I'm doing well, thank you!"
# Create mock PostHog client
self.client = Client(FAKE_TEST_API_KEY)
self.client._enqueue = MagicMock()
self.client.privacy_mode = False
def _assert_system_prompt_captured(self, captured_input):
"""Helper to assert system prompt is correctly captured."""
self.assertEqual(
len(captured_input), 2, "Should have 2 messages (system + user)"
)
self.assertEqual(
captured_input[0]["role"], "system", "First message should be system"
)
self.assertEqual(
captured_input[0]["content"],
self.test_system_prompt,
"System content should match",
)
self.assertEqual(
captured_input[1]["role"], "user", "Second message should be user"
)
self.assertEqual(
captured_input[1]["content"],
self.test_user_message,
"User content should match",
)
# OpenAI Tests
def test_openai_messages_array_system_prompt(self):
"""Test OpenAI with system prompt in messages array."""
try:
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
mock_response = ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content=self.test_response, role="assistant"
),
)
],
usage=CompletionUsage(
completion_tokens=10, prompt_tokens=20, total_tokens=30
),
)
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [
{"role": "system", "content": self.test_system_prompt},
{"role": "user", "content": self.test_user_message},
]
client.chat.completions.create(
model="gpt-4", messages=messages, posthog_distinct_id="test-user"
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_openai_separate_system_parameter(self):
"""Test OpenAI with system prompt as separate parameter."""
try:
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
mock_response = ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content=self.test_response, role="assistant"
),
)
],
usage=CompletionUsage(
completion_tokens=10, prompt_tokens=20, total_tokens=30
),
)
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
client.chat.completions.create(
model="gpt-4",
messages=messages,
system=self.test_system_prompt,
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_openai_streaming_system_parameter(self):
"""Test OpenAI streaming with system parameter."""
try:
from openai.types.chat.chat_completion_chunk import (
ChatCompletionChunk,
ChoiceDelta,
)
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.completion_usage import CompletionUsage
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
chunk1 = ChatCompletionChunk(
id="test",
model="gpt-4",
object="chat.completion.chunk",
created=int(time.time()),
choices=[
ChoiceChunk(
finish_reason=None,
index=0,
delta=ChoiceDelta(content="Hello", role="assistant"),
)
],
)
chunk2 = ChatCompletionChunk(
id="test",
model="gpt-4",
object="chat.completion.chunk",
created=int(time.time()),
choices=[
ChoiceChunk(
finish_reason="stop",
index=0,
delta=ChoiceDelta(content=" there!", role=None),
)
],
usage=CompletionUsage(
completion_tokens=10, prompt_tokens=20, total_tokens=30
),
)
with patch(
"openai.resources.chat.completions.Completions.create",
return_value=[chunk1, chunk2],
):
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
response_generator = client.chat.completions.create(
model="gpt-4",
messages=messages,
system=self.test_system_prompt,
stream=True,
posthog_distinct_id="test-user",
)
list(response_generator) # Consume generator
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
# Anthropic Tests
def test_anthropic_messages_array_system_prompt(self):
"""Test Anthropic with system prompt in messages array."""
try:
from posthog.ai.anthropic import Anthropic
except ImportError:
self.skipTest("Anthropic package not available")
with patch("anthropic.resources.messages.Messages.create") as mock_create:
mock_response = MagicMock()
mock_response.usage.input_tokens = 20
mock_response.usage.output_tokens = 10
mock_response.usage.cache_read_input_tokens = None
mock_response.usage.cache_creation_input_tokens = None
mock_create.return_value = mock_response
client = Anthropic(posthog_client=self.client, api_key="test")
messages = [
{"role": "system", "content": self.test_system_prompt},
{"role": "user", "content": self.test_user_message},
]
client.messages.create(
model="claude-3-5-sonnet-20241022",
messages=messages,
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_anthropic_separate_system_parameter(self):
"""Test Anthropic with system prompt as separate parameter."""
try:
from posthog.ai.anthropic import Anthropic
except ImportError:
self.skipTest("Anthropic package not available")
with patch("anthropic.resources.messages.Messages.create") as mock_create:
mock_response = MagicMock()
mock_response.usage.input_tokens = 20
mock_response.usage.output_tokens = 10
mock_response.usage.cache_read_input_tokens = None
mock_response.usage.cache_creation_input_tokens = None
mock_create.return_value = mock_response
client = Anthropic(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
client.messages.create(
model="claude-3-5-sonnet-20241022",
messages=messages,
system=self.test_system_prompt,
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
# Gemini Tests
def test_gemini_contents_array_system_prompt(self):
"""Test Gemini with system prompt in contents array."""
try:
from posthog.ai.gemini import Client
except ImportError:
self.skipTest("Gemini package not available")
with patch("google.genai.Client") as mock_genai_class:
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = self.test_response
mock_response.usage_metadata.prompt_token_count = 20
mock_response.usage_metadata.candidates_token_count = 10
mock_response.usage_metadata.cached_content_token_count = None
mock_response.usage_metadata.thoughts_token_count = None
mock_client_instance = MagicMock()
mock_models_instance = MagicMock()
mock_models_instance.generate_content.return_value = mock_response
mock_client_instance.models = mock_models_instance
mock_genai_class.return_value = mock_client_instance
client = Client(posthog_client=self.client, api_key="test")
contents = [
{"role": "system", "content": self.test_system_prompt},
{"role": "user", "content": self.test_user_message},
]
client.models.generate_content(
model="gemini-2.0-flash",
contents=contents,
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
def test_gemini_system_instruction_parameter(self):
"""Test Gemini with system_instruction in config parameter."""
try:
from posthog.ai.gemini import Client
except ImportError:
self.skipTest("Gemini package not available")
with patch("google.genai.Client") as mock_genai_class:
mock_response = MagicMock()
mock_response.candidates = [MagicMock()]
mock_response.candidates[0].content.parts = [MagicMock()]
mock_response.candidates[0].content.parts[0].text = self.test_response
mock_response.usage_metadata.prompt_token_count = 20
mock_response.usage_metadata.candidates_token_count = 10
mock_response.usage_metadata.cached_content_token_count = None
mock_response.usage_metadata.thoughts_token_count = None
mock_client_instance = MagicMock()
mock_models_instance = MagicMock()
mock_models_instance.generate_content.return_value = mock_response
mock_client_instance.models = mock_models_instance
mock_genai_class.return_value = mock_client_instance
client = Client(posthog_client=self.client, api_key="test")
contents = [{"role": "user", "content": self.test_user_message}]
config = {"system_instruction": self.test_system_prompt}
client.models.generate_content(
model="gemini-2.0-flash",
contents=contents,
config=config,
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
properties = self.client._enqueue.call_args_list[0][0][0]["properties"]
self._assert_system_prompt_captured(properties["$ai_input"])
+608 -8
View File
@@ -4,7 +4,21 @@ from posthog.contexts import (
get_context_distinct_id,
)
import unittest
from unittest.mock import Mock
from unittest.mock import Mock, patch
import asyncio
# Configure Django settings before importing middleware
import django
from django.conf import settings
if not settings.configured:
settings.configure(
DEBUG=True,
SECRET_KEY="test-secret-key",
INSTALLED_APPS=[],
MIDDLEWARE=[],
)
django.setup()
from posthog.integrations.django import PosthogContextMiddleware
@@ -38,14 +52,33 @@ class TestPosthogContextMiddleware(unittest.TestCase):
request_filter=None,
tag_map=None,
capture_exceptions=True,
get_response=None,
):
"""Helper to create middleware instance without calling __init__"""
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = Mock()
middleware.extra_tags = extra_tags
middleware.request_filter = request_filter
middleware.tag_map = tag_map
middleware.capture_exceptions = capture_exceptions
"""Helper to create middleware instance with mock Django settings"""
if get_response is None:
get_response = Mock()
with patch("django.conf.settings") as mock_settings:
# Configure mock settings
mock_settings.POSTHOG_MW_EXTRA_TAGS = extra_tags
mock_settings.POSTHOG_MW_REQUEST_FILTER = request_filter
mock_settings.POSTHOG_MW_TAG_MAP = tag_map
mock_settings.POSTHOG_MW_CAPTURE_EXCEPTIONS = capture_exceptions
mock_settings.POSTHOG_MW_CLIENT = None
# Make hasattr work correctly
def mock_hasattr(obj, name):
return name in [
"POSTHOG_MW_EXTRA_TAGS",
"POSTHOG_MW_REQUEST_FILTER",
"POSTHOG_MW_TAG_MAP",
"POSTHOG_MW_CAPTURE_EXCEPTIONS",
"POSTHOG_MW_CLIENT",
]
with patch("builtins.hasattr", side_effect=mock_hasattr):
middleware = PosthogContextMiddleware(get_response)
return middleware
def test_extract_tags_basic(self):
@@ -168,6 +201,573 @@ class TestPosthogContextMiddleware(unittest.TestCase):
self.assertEqual(tags["$request_method"], "PATCH")
def test_process_exception_called_during_view_exception(self):
"""
Unit test verifying process_exception captures exceptions per Django's contract.
Since this is a library test (no Django runtime), we simulate how Django
would invoke our middleware in production:
1. Middleware.__call__ creates context with request tags
2. View raises exception inside get_response
3. Django's BaseHandler catches it, calls process_exception, returns error response
4. Exception never propagates to middleware's context manager
We manually call process_exception to simulate Django's behavior - this is
the only way to test the hook without a full Django integration test.
"""
mock_client = Mock()
view_exception = ValueError("View raised this error")
error_response = Mock(status_code=500)
def mock_get_response(request):
# Simulate Django's exception handling: catches view exception,
# calls process_exception hook if it exists, returns error response
if hasattr(middleware, "process_exception"):
middleware.process_exception(request, view_exception)
return error_response
middleware = self.create_middleware(get_response=mock_get_response)
middleware.client = mock_client
request = MockRequest(
headers={"X-POSTHOG-DISTINCT-ID": "test-user"},
method="POST",
path="/api/endpoint",
)
response = middleware(request)
self.assertEqual(response.status_code, 500)
mock_client.capture_exception.assert_called_once_with(view_exception)
def test_process_exception_respects_capture_exceptions_false(self):
"""Verify process_exception respects capture_exceptions=False setting"""
mock_client = Mock()
view_exception = ValueError("Should not be captured")
def mock_get_response(request):
if hasattr(middleware, "process_exception"):
middleware.process_exception(request, view_exception)
return Mock(status_code=500)
middleware = self.create_middleware(
capture_exceptions=False, get_response=mock_get_response
)
middleware.client = mock_client
request = MockRequest()
middleware(request)
mock_client.capture_exception.assert_not_called()
def test_process_exception_respects_request_filter(self):
"""Verify process_exception respects request_filter setting"""
mock_client = Mock()
view_exception = ValueError("Should be filtered")
def mock_get_response(request):
if hasattr(middleware, "process_exception"):
middleware.process_exception(request, view_exception)
return Mock(status_code=500)
middleware = self.create_middleware(
request_filter=lambda req: False,
capture_exceptions=True,
get_response=mock_get_response,
)
middleware.client = mock_client
request = MockRequest()
middleware(request)
mock_client.capture_exception.assert_not_called()
class TestPosthogContextMiddlewareSync(unittest.TestCase):
"""Test synchronous middleware behavior"""
def test_sync_middleware_call(self):
"""Test that sync middleware correctly processes requests"""
mock_response = Mock()
get_response = Mock(return_value=mock_response)
# Create middleware with sync get_response
middleware = PosthogContextMiddleware(get_response)
# Verify sync mode detected
self.assertFalse(middleware._is_coroutine)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "test-session"},
method="GET",
path="/test",
)
with new_context():
response = middleware(request)
# Verify response returned
self.assertEqual(response, mock_response)
get_response.assert_called_once_with(request)
def test_sync_middleware_with_filter(self):
"""Test sync middleware respects request filter"""
mock_response = Mock()
get_response = Mock(return_value=mock_response)
# Create middleware with request filter that filters all requests
def request_filter(req):
return False
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = get_response
middleware._is_coroutine = False
middleware.request_filter = request_filter
middleware.capture_exceptions = True
middleware.client = None
request = MockRequest()
# Should skip context creation and return response directly
response = middleware(request)
self.assertEqual(response, mock_response)
get_response.assert_called_once_with(request)
def test_view_exceptions_only_captured_via_process_exception(self):
"""
Demonstrates that process_exception is required to capture view exceptions.
In production Django, view exceptions don't propagate to middleware's context
manager because Django's BaseHandler catches them first and converts them to
error responses. Django provides the exception via process_exception hook instead.
This unit test proves:
1. Context manager in __call__ never sees view exceptions (Django intercepts)
2. Only process_exception can capture them
3. Without process_exception, exceptions are silently lost (v6.7.5 regression)
We manually call process_exception to verify the hook works - in production,
Django's BaseHandler would call it when a view raises.
"""
mock_client = Mock()
get_response = Mock(return_value=Mock(status_code=500))
middleware = PosthogContextMiddleware(get_response)
middleware.client = mock_client
def get_response_simulating_django(request):
# Simulates Django behavior: view exception converted to error response,
# never propagates to middleware's context manager
return Mock(status_code=500)
middleware._sync_get_response = get_response_simulating_django
request = MockRequest()
response = middleware(request)
self.assertEqual(response.status_code, 500)
# Context manager didn't capture anything - exception was intercepted by Django
mock_client.capture_exception.assert_not_called()
# Verify process_exception hook exists and captures exceptions when called
if hasattr(middleware, "process_exception"):
exception = ValueError("View error")
middleware.process_exception(request, exception)
mock_client.capture_exception.assert_called_once_with(exception)
else:
self.fail(
"process_exception missing - view exceptions will not be captured!"
)
class TestPosthogContextMiddlewareAsync(unittest.TestCase):
"""Test asynchronous middleware behavior"""
def test_async_middleware_detection(self):
"""Test that async get_response is correctly detected"""
async def async_get_response(request):
return Mock()
middleware = PosthogContextMiddleware(async_get_response)
# Verify async mode detected
self.assertTrue(middleware._is_coroutine)
def test_async_middleware_call(self):
"""Test that async middleware correctly processes requests"""
async def run_test():
mock_response = Mock()
async def async_get_response(request):
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "async-session"},
method="POST",
path="/async-test",
)
with new_context():
# Call should return the coroutine from __acall__
result = middleware(request)
# Verify it's a coroutine
self.assertTrue(asyncio.iscoroutine(result))
# Await the result
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_with_filter(self):
"""Test async middleware respects request filter"""
async def run_test():
mock_response = Mock()
async def async_get_response(request):
return mock_response
# Properly initialize middleware
middleware = PosthogContextMiddleware(async_get_response)
# Override request filter after initialization
middleware.request_filter = lambda req: False
request = MockRequest()
# Should skip context creation and return response directly
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_context_propagation(self):
"""Test that async middleware properly propagates context"""
async def run_test():
mock_response = Mock()
async def async_get_response(request):
# Verify context is available during async processing
session_id = get_context_session_id()
self.assertEqual(session_id, "async-session-123")
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "async-session-123"},
method="GET",
)
with new_context():
result = middleware(request)
await result
asyncio.run(run_test())
def test_async_middleware_exception_capture(self):
"""Test that async middleware captures exceptions during request processing"""
async def run_test():
mock_client = Mock()
# Make async_get_response raise an exception
async def raise_exception(request):
raise ValueError("Async test exception")
# Properly initialize middleware
middleware = PosthogContextMiddleware(raise_exception)
middleware.client = mock_client # Override with mock client
request = MockRequest()
# Should capture exception and re-raise
with self.assertRaises(ValueError):
result = middleware(request)
await result
# Verify exception was captured by middleware
mock_client.capture_exception.assert_called_once()
captured_exception = mock_client.capture_exception.call_args[0][0]
self.assertIsInstance(captured_exception, ValueError)
self.assertEqual(str(captured_exception), "Async test exception")
asyncio.run(run_test())
def test_async_middleware_with_authenticated_user(self):
"""
Test that async middleware correctly extracts user info in async context.
Django's request.user is a SimpleLazyObject that defers DB access.
In async context, accessing it directly raises SynchronousOnlyOperation.
The middleware should use request.auser() instead.
This tests the fix for issue #355.
"""
async def run_test():
mock_response = Mock()
mock_user = Mock()
mock_user.is_authenticated = True
mock_user.pk = 123
mock_user.email = "test@example.com"
async def async_get_response(request):
# Verify user info was extracted and set as distinct_id
distinct_id = get_context_distinct_id()
self.assertEqual(distinct_id, "123")
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser() to return authenticated user
async def mock_auser():
return mock_user
request.auser = mock_auser
with new_context():
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_with_unauthenticated_user(self):
"""
Test that async middleware handles unauthenticated users correctly.
"""
async def run_test():
mock_response = Mock()
mock_user = Mock()
mock_user.is_authenticated = False # Not authenticated
async def async_get_response(request):
# Verify no distinct_id was set (no user)
distinct_id = get_context_distinct_id()
self.assertIsNone(distinct_id)
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
async def mock_auser():
return mock_user
request.auser = mock_auser
with new_context():
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_without_user_attribute(self):
"""
Test that async middleware handles requests without user attribute (no auth middleware).
"""
async def run_test():
mock_response = Mock()
async def async_get_response(request):
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
# Request without auser method (no auth middleware)
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
with new_context():
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_with_extra_tags(self):
"""
Test that async middleware works with extra_tags callback.
"""
async def run_test():
mock_response = Mock()
def extra_tags_callback(request):
# Simple sync callback - should work
return {"custom_tag": "custom_value"}
async def async_get_response(request):
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
middleware.extra_tags = extra_tags_callback
middleware.client = Mock()
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser for no user
async def mock_auser():
return None
request.auser = mock_auser
with new_context():
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_with_tag_map(self):
"""
Test that async middleware works with tag_map callback.
"""
async def run_test():
mock_response = Mock()
def tag_map_callback(tags):
# Simple sync callback - should work
tags["mapped"] = "yes"
return tags
async def async_get_response(request):
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
middleware.tag_map = tag_map_callback
middleware.client = Mock()
request = MockRequest(
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser for no user
async def mock_auser():
return None
request.auser = mock_auser
with new_context():
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
def test_async_middleware_user_extraction_with_all_headers(self):
"""
Test async middleware extracts all request info correctly.
"""
async def run_test():
mock_response = Mock()
mock_user = Mock()
mock_user.is_authenticated = True
mock_user.pk = 456
mock_user.email = "async@test.com"
async def async_get_response(request):
# Verify all context was set correctly
distinct_id = get_context_distinct_id()
session_id = get_context_session_id()
self.assertEqual(distinct_id, "456")
self.assertEqual(session_id, "async-sess-123")
return mock_response
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={
"X-POSTHOG-SESSION-ID": "async-sess-123",
"X-Forwarded-For": "192.168.1.1",
"User-Agent": "TestAgent/1.0",
},
method="POST",
path="/api/test",
)
async def mock_auser():
return mock_user
request.auser = mock_auser
with new_context():
result = middleware(request)
response = await result
self.assertEqual(response, mock_response)
asyncio.run(run_test())
class TestPosthogContextMiddlewareHybrid(unittest.TestCase):
"""Test hybrid middleware behavior with mixed sync/async chains"""
def test_hybrid_flags_set(self):
"""Test that both capability flags are set"""
self.assertTrue(PosthogContextMiddleware.sync_capable)
self.assertTrue(PosthogContextMiddleware.async_capable)
def test_sync_to_async_routing(self):
"""Test that __call__ routes to __acall__ when async"""
async def run_test():
async def async_get_response(request):
return Mock()
middleware = PosthogContextMiddleware(async_get_response)
# Verify routing happens
request = MockRequest()
result = middleware(request)
# Should be a coroutine from __acall__
self.assertTrue(asyncio.iscoroutine(result))
await result # Clean up
asyncio.run(run_test())
def test_sync_path_direct_return(self):
"""Test that sync path returns directly without coroutine"""
mock_response = Mock()
def sync_get_response(request):
return mock_response
middleware = PosthogContextMiddleware(sync_get_response)
request = MockRequest()
result = middleware(request)
# Should NOT be a coroutine
self.assertFalse(asyncio.iscoroutine(result))
self.assertEqual(result, mock_response)
if __name__ == "__main__":
unittest.main()
+433 -28
View File
@@ -2,17 +2,18 @@ import time
import unittest
from datetime import datetime
from uuid import uuid4
from posthog.contexts import get_context_session_id, set_context_session, new_context
import mock
import six
from parameterized import parameterized
from posthog.client import Client
from posthog.request import APIError
from posthog.contexts import get_context_session_id, new_context, set_context_session
from posthog.request import APIError, GetResponse
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, LegacyFlagMetadata
from posthog.version import VERSION
from posthog.contexts import tag
class TestClient(unittest.TestCase):
@@ -197,12 +198,6 @@ class TestClient(unittest.TestCase):
print(capture_call)
self.assertEqual(capture_call[1]["distinct_id"], "distinct_id")
self.assertEqual(capture_call[0][0], "$exception")
self.assertEqual(
capture_call[1]["properties"]["$exception_type"], "Exception"
)
self.assertEqual(
capture_call[1]["properties"]["$exception_message"], "test exception"
)
self.assertEqual(
capture_call[1]["properties"]["$exception_list"][0]["mechanism"][
"type"
@@ -414,7 +409,9 @@ class TestClient(unittest.TestCase):
)
client.feature_flags = [multivariate_flag, basic_flag, false_flag]
msg_uuid = client.capture("python test event", distinct_id="distinct_id")
msg_uuid = client.capture(
"python test event", distinct_id="distinct_id", send_feature_flags=True
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
@@ -570,6 +567,7 @@ class TestClient(unittest.TestCase):
"python test event",
distinct_id="distinct_id",
properties={"$feature/beta-feature-local": "my-custom-variant"},
send_feature_flags=True,
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
@@ -647,8 +645,8 @@ class TestClient(unittest.TestCase):
timeout=3,
distinct_id="distinct_id",
groups={},
person_properties=None,
group_properties=None,
person_properties={},
group_properties={},
geoip_disable=True,
)
@@ -711,8 +709,8 @@ class TestClient(unittest.TestCase):
timeout=12,
distinct_id="distinct_id",
groups={},
person_properties=None,
group_properties=None,
person_properties={},
group_properties={},
geoip_disable=False,
)
@@ -751,6 +749,178 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_flags(
self, patch_flags
):
"""Test that send_feature_flags=False with local evaluation enabled does NOT send flags"""
patch_flags.return_value = {"featureFlags": {"beta-feature": "remote-variant"}}
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"rollout_percentage": 100,
},
],
"multivariate": {
"variants": [
{
"key": "first-variant",
"name": "First Variant",
"rollout_percentage": 50,
},
{
"key": "second-variant",
"name": "Second Variant",
"rollout_percentage": 50,
},
]
},
},
}
simple_flag = {
"id": 2,
"name": "Simple Flag",
"key": "simple-flag",
"active": True,
"filters": {
"groups": [
{
"rollout_percentage": 100,
}
],
},
}
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)
client.feature_flags = [multivariate_flag, simple_flag]
msg_uuid = client.capture(
"python test event",
distinct_id="distinct_id",
send_feature_flags=False,
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
# Get the enqueued message from the mock
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["distinct_id"], "distinct_id")
# CRITICAL: Verify local flags are NOT included in the event
self.assertNotIn("$feature/beta-feature-local", msg["properties"])
self.assertNotIn("$feature/simple-flag", msg["properties"])
self.assertNotIn("$active_feature_flags", msg["properties"])
# CRITICAL: Verify the /flags API was NOT called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
self, patch_flags
):
"""Test that send_feature_flags=True with local evaluation enabled uses local flags without API call"""
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-variant"}}
multivariate_flag = {
"id": 1,
"name": "Beta Feature",
"key": "beta-feature-local",
"active": True,
"rollout_percentage": 100,
"filters": {
"groups": [
{
"rollout_percentage": 100,
},
],
"multivariate": {
"variants": [
{
"key": "first-variant",
"name": "First Variant",
"rollout_percentage": 50,
},
{
"key": "second-variant",
"name": "Second Variant",
"rollout_percentage": 50,
},
]
},
},
}
simple_flag = {
"id": 2,
"name": "Simple Flag",
"key": "simple-flag",
"active": True,
"filters": {
"groups": [
{
"rollout_percentage": 100,
}
],
},
}
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)
client.feature_flags = [multivariate_flag, simple_flag]
msg_uuid = client.capture(
"python test event",
distinct_id="distinct_id",
send_feature_flags=True,
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
# Get the enqueued message from the mock
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["distinct_id"], "distinct_id")
# Verify local flags are included in the event
self.assertIn("$feature/beta-feature-local", msg["properties"])
self.assertIn("$feature/simple-flag", msg["properties"])
self.assertEqual(msg["properties"]["$feature/simple-flag"], True)
# Verify active feature flags are set correctly
active_flags = msg["properties"]["$active_feature_flags"]
self.assertIn("beta-feature-local", active_flags)
self.assertIn("simple-flag", active_flags)
# The remote flag should NOT be included since we used local evaluation
self.assertNotIn("$feature/remote-flag", msg["properties"])
# CRITICAL: Verify the /flags API was NOT called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
self, patch_flags
@@ -1741,6 +1911,7 @@ class TestClient(unittest.TestCase):
person_properties={"distinct_id": "some_id"},
group_properties={},
geoip_disable=True,
flag_keys_to_evaluate=["random_key"],
)
patch_flags.reset_mock()
client.feature_enabled(
@@ -1755,6 +1926,7 @@ class TestClient(unittest.TestCase):
person_properties={"distinct_id": "feature_enabled_distinct_id"},
group_properties={},
geoip_disable=True,
flag_keys_to_evaluate=["random_key"],
)
patch_flags.reset_mock()
client.get_all_flags_and_payloads("all_flags_payloads_id")
@@ -1815,6 +1987,7 @@ class TestClient(unittest.TestCase):
"instance": {"$group_key": "app.posthog.com"},
},
geoip_disable=False,
flag_keys_to_evaluate=["random_key"],
)
patch_flags.reset_mock()
@@ -1841,6 +2014,7 @@ class TestClient(unittest.TestCase):
"instance": {"$group_key": "app.posthog.com"},
},
geoip_disable=False,
flag_keys_to_evaluate=["random_key"],
)
patch_flags.reset_mock()
@@ -2057,7 +2231,7 @@ class TestClient(unittest.TestCase):
def test_set_context_session_override_in_capture(self):
"""Test that explicit session ID overrides context session ID in capture"""
from posthog.contexts import set_context_session, new_context
from posthog.contexts import new_context, set_context_session
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
@@ -2090,13 +2264,21 @@ class TestClient(unittest.TestCase):
self, patch_get, patch_poller
):
"""Test that when enable_local_evaluation=False, the poller is not started"""
patch_get.return_value = {
"flags": [
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
],
"group_type_mapping": {},
"cohorts": {},
}
patch_get.return_value = GetResponse(
data={
"flags": [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"active": True,
}
],
"group_type_mapping": {},
"cohorts": {},
},
etag='"test-etag"',
)
client = Client(
FAKE_TEST_API_KEY,
@@ -2118,13 +2300,21 @@ class TestClient(unittest.TestCase):
@mock.patch("posthog.client.get")
def test_enable_local_evaluation_true_starts_poller(self, patch_get, patch_poller):
"""Test that when enable_local_evaluation=True (default), the poller is started"""
patch_get.return_value = {
"flags": [
{"id": 1, "name": "Beta Feature", "key": "beta-feature", "active": True}
],
"group_type_mapping": {},
"cohorts": {},
}
patch_get.return_value = GetResponse(
data={
"flags": [
{
"id": 1,
"name": "Beta Feature",
"key": "beta-feature",
"active": True,
}
],
"group_type_mapping": {},
"cohorts": {},
},
etag='"test-etag"',
)
client = Client(
FAKE_TEST_API_KEY,
@@ -2158,6 +2348,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(result, {"test": "payload"})
patch_remote_config.assert_called_once_with(
"test-personal-key",
FAKE_TEST_API_KEY,
client.host,
"test-flag",
timeout=client.feature_flags_request_timeout_seconds,
@@ -2185,6 +2376,7 @@ class TestClient(unittest.TestCase):
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
"flag_keys_filter": None,
}
self.assertEqual(result, expected)
@@ -2195,6 +2387,7 @@ class TestClient(unittest.TestCase):
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
"flag_keys_filter": None,
}
self.assertEqual(result, expected)
@@ -2210,6 +2403,7 @@ class TestClient(unittest.TestCase):
"only_evaluate_locally": True,
"person_properties": {"plan": "premium"},
"group_properties": {"company": {"type": "enterprise"}},
"flag_keys_filter": None,
}
self.assertEqual(result, expected)
@@ -2221,6 +2415,7 @@ class TestClient(unittest.TestCase):
"only_evaluate_locally": None,
"person_properties": {"user_id": "123"},
"group_properties": None,
"flag_keys_filter": None,
}
self.assertEqual(result, expected)
@@ -2231,6 +2426,7 @@ class TestClient(unittest.TestCase):
"only_evaluate_locally": None,
"person_properties": None,
"group_properties": None,
"flag_keys_filter": None,
}
self.assertEqual(result, expected)
@@ -2246,3 +2442,212 @@ class TestClient(unittest.TestCase):
with self.assertRaises(TypeError) as cm:
client._parse_send_feature_flags(None)
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_flag_keys_filter(self, patch_flags):
"""Test that SendFeatureFlagsOptions with flag_keys_filter only evaluates specified flags"""
# When flag_keys_to_evaluate is provided, the API should only return the requested flags
patch_flags.return_value = {
"featureFlags": {
"flag1": "value1",
"flag3": "value3",
}
}
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
personal_api_key=FAKE_TEST_API_KEY,
sync_mode=True,
)
send_options = {
"flag_keys_filter": ["flag1", "flag3"],
"person_properties": {"subscription": "pro"},
}
msg_uuid = client.capture(
"test event", distinct_id="distinct_id", send_feature_flags=send_options
)
self.assertIsNotNone(msg_uuid)
self.assertFalse(self.failed)
# Verify flags() was called with flag_keys_to_evaluate
patch_flags.assert_called_once()
call_args = patch_flags.call_args[1]
self.assertEqual(call_args["flag_keys_to_evaluate"], ["flag1", "flag3"])
self.assertEqual(call_args["person_properties"], {"subscription": "pro"})
# Check the message includes only the filtered flags
mock_post.assert_called_once()
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]
self.assertEqual(msg["properties"]["$feature/flag1"], "value1")
self.assertEqual(msg["properties"]["$feature/flag3"], "value3")
# flag2 should not be included since it wasn't requested
self.assertNotIn("$feature/flag2", msg["properties"])
@mock.patch("posthog.client.batch_post")
def test_get_feature_flag_result_with_empty_string_payload(self, patch_batch_post):
"""Test that get_feature_flag_result returns a FeatureFlagResult when payload is empty string"""
client = Client(
FAKE_TEST_API_KEY,
personal_api_key="test_personal_api_key",
sync_mode=True,
)
# Set up local evaluation with a flag that has empty string payload
client.feature_flags = [
{
"id": 1,
"name": "Test flag",
"key": "test-flag",
"is_simple_flag": False,
"active": True,
"rollout_percentage": None,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": None,
"variant": "empty-variant",
}
],
"multivariate": {
"variants": [
{
"key": "empty-variant",
"name": "Empty Variant",
"rollout_percentage": 100,
}
]
},
"payloads": {"empty-variant": ""}, # Empty string payload
},
}
]
# Test get_feature_flag_result
result = client.get_feature_flag_result(
"test-flag", "test-user", only_evaluate_locally=True
)
# Should return a FeatureFlagResult, not None
self.assertIsNotNone(result)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.get_value(), "empty-variant")
self.assertEqual(result.payload, "") # Should be empty string, not None
@mock.patch("posthog.client.batch_post")
def test_get_all_flags_and_payloads_with_empty_string(self, patch_batch_post):
"""Test that get_all_flags_and_payloads includes flags with empty string payloads"""
client = Client(
FAKE_TEST_API_KEY,
personal_api_key="test_personal_api_key",
sync_mode=True,
)
# Set up multiple flags with different payload types
client.feature_flags = [
{
"id": 1,
"name": "Flag with empty payload",
"key": "empty-payload-flag",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [{"properties": [], "variant": "variant1"}],
"multivariate": {
"variants": [{"key": "variant1", "rollout_percentage": 100}]
},
"payloads": {"variant1": ""}, # Empty string
},
},
{
"id": 2,
"name": "Flag with normal payload",
"key": "normal-payload-flag",
"is_simple_flag": False,
"active": True,
"filters": {
"groups": [{"properties": [], "variant": "variant2"}],
"multivariate": {
"variants": [{"key": "variant2", "rollout_percentage": 100}]
},
"payloads": {"variant2": "normal payload"},
},
},
]
result = client.get_all_flags_and_payloads(
"test-user", only_evaluate_locally=True
)
# Check that both flags are included
self.assertEqual(result["featureFlags"]["empty-payload-flag"], "variant1")
self.assertEqual(result["featureFlags"]["normal-payload-flag"], "variant2")
# Check that empty string payload is included (not filtered out)
self.assertIn("empty-payload-flag", result["featureFlagPayloads"])
self.assertEqual(result["featureFlagPayloads"]["empty-payload-flag"], "")
self.assertEqual(
result["featureFlagPayloads"]["normal-payload-flag"], "normal payload"
)
def test_context_tags_added(self):
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
with new_context():
tag("random_tag", 12345)
client.capture("python test event", distinct_id="distinct_id")
batch_data = mock_post.call_args[1]["batch"]
msg = batch_data[0]
self.assertEqual(msg["properties"]["$context_tags"], ["random_tag"])
@mock.patch(
"posthog.client.Client._enqueue", side_effect=Exception("Unexpected error")
)
def test_methods_handle_exceptions(self, mock_enqueue):
"""Test that all decorated methods handle exceptions gracefully."""
client = Client("test-key")
test_cases = [
("capture", ["test_event"], {}),
("set", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
("set_once", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
("group_identify", ["group-type", "group-key"], {}),
("alias", ["some-id", "new-id"], {}),
]
for method_name, args, kwargs in test_cases:
with self.subTest(method=method_name):
method = getattr(client, method_name)
result = method(*args, **kwargs)
self.assertEqual(result, None)
@mock.patch(
"posthog.client.Client._enqueue", side_effect=Exception("Expected error")
)
def test_debug_flag_re_raises_exceptions(self, mock_enqueue):
"""Test that methods re-raise exceptions when debug=True."""
client = Client("test-key", debug=True)
test_cases = [
("capture", ["test_event"], {}),
("set", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
("set_once", [], {"distinct_id": "some-id", "properties": {"a": "b"}}),
("group_identify", ["group-type", "group-key"], {}),
("alias", ["some-id", "new-id"], {}),
]
for method_name, args, kwargs in test_cases:
with self.subTest(method=method_name):
method = getattr(client, method_name)
with self.assertRaises(Exception) as cm:
method(*args, **kwargs)
self.assertEqual(str(cm.exception), "Expected error")
+26
View File
@@ -191,6 +191,32 @@ class TestContexts(unittest.TestCase):
assert get_context_distinct_id() == "user123"
assert get_context_session_id() == "session456"
def test_child_tags_override_parent_tags_in_non_fresh_context(self):
with new_context(fresh=True):
tag("shared_key", "parent_value")
tag("parent_only", "parent")
with new_context(fresh=False):
# Child should inherit parent tags
assert get_tags()["parent_only"] == "parent"
# Child sets same key - should override parent
tag("shared_key", "child_value")
tag("child_only", "child")
tags = get_tags()
# Child value should win for shared key
assert tags["shared_key"] == "child_value"
# Both parent and child tags should be present
assert tags["parent_only"] == "parent"
assert tags["child_only"] == "child"
# Parent context should be unchanged
parent_tags = get_tags()
assert parent_tags["shared_key"] == "parent_value"
assert parent_tags["parent_only"] == "parent"
assert "child_only" not in parent_tags
def test_scoped_decorator_with_context_ids(self):
@scoped()
def function_with_context():
+418
View File
@@ -32,3 +32,421 @@ def test_excepthook(tmpdir):
b'"$exception_list": [{"mechanism": {"type": "generic", "handled": true}, "module": null, "type": "ZeroDivisionError", "value": "division by zero", "stacktrace": {"frames": [{"platform": "python", "filename": "app.py", "abs_path"'
in output
)
def test_code_variables_capture(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
class UnserializableObject:
pass
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_string = "hello world"
my_number = 42
my_bool = True
my_dict = {"name": "test", "value": 123}
my_sensitive_dict = {
"safe_key": "safe_value",
"password": "secret123", # key matches pattern -> should be masked
"other_key": "contains_password_here", # value matches pattern -> should be masked
}
my_nested_dict = {
"level1": {
"level2": {
"api_key": "nested_secret", # deeply nested key matches
"data": "contains_token_here", # deeply nested value matches
"safe": "visible",
}
}
}
my_list = ["safe_item", "has_password_inside", "another_safe"]
my_tuple = ("tuple_safe", "secret_in_value", "tuple_also_safe")
my_list_of_dicts = [
{"id": 1, "password": "list_dict_secret"},
{"id": 2, "value": "safe_value"},
]
my_obj = UnserializableObject()
my_password = "secret123" # Should be masked by default (name matches)
my_innocent_var = "contains_password_here" # Should be masked by default (value matches)
__should_be_ignored = "hidden" # Should be ignored by default
1/0 # Trigger exception
def intermediate_function():
request_id = "abc-123"
user_count = 100
is_active = True
trigger_error()
def process_data():
batch_size = 50
retry_count = 3
intermediate_function()
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"code_variables" in output
# Variables from trigger_error frame
assert b"'my_string': 'hello world'" in output
assert b"'my_number': 42" in output
assert b"'my_bool': 'True'" in output
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
assert (
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}'
in output
)
assert (
b'{\\"level1\\": {\\"level2\\": {\\"api_key\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
in output
)
assert (
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
in output
)
assert (
b'[\\"tuple_safe\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
in output
)
assert (
b'[{\\"id\\": 1, \\"password\\": \\"$$_posthog_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
in output
)
assert b"<__main__.UnserializableObject object at" in output
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert (
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
)
assert b"'__should_be_ignored':" not in output
# Variables from intermediate_function frame
assert b"'request_id': 'abc-123'" in output
assert b"'user_count': 100" in output
assert b"'is_active': 'True'" in output
# Variables from process_data frame
assert b"'batch_size': 50" in output
assert b"'retry_count': 3" in output
def test_code_variables_context_override(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
import posthog
from posthog import Posthog
posthog_client = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def process_data():
bank = "should_be_masked"
__dunder_var = "should_be_visible"
1/0
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(True)
posthog.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
posthog.set_code_variables_ignore_patterns_context([])
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"code_variables" in output
assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert b"'__dunder_var': 'should_be_visible'" in output
def test_code_variables_size_limiter(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
var_a = "a" * 2000
var_b = "b" * 2000
var_c = "c" * 2000
var_d = "d" * 2000
var_e = "e" * 2000
var_f = "f" * 2000
var_g = "g" * 2000
1/0
def intermediate_function():
var_h = "h" * 2000
var_i = "i" * 2000
var_j = "j" * 2000
var_k = "k" * 2000
var_l = "l" * 2000
var_m = "m" * 2000
var_n = "n" * 2000
trigger_error()
def process_data():
var_o = "o" * 2000
var_p = "p" * 2000
var_q = "q" * 2000
var_r = "r" * 2000
var_s = "s" * 2000
var_t = "t" * 2000
var_u = "u" * 2000
intermediate_function()
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "code_variables" in output
captured_vars = []
for var_name in [
"var_a",
"var_b",
"var_c",
"var_d",
"var_e",
"var_f",
"var_g",
"var_h",
"var_i",
"var_j",
"var_k",
"var_l",
"var_m",
"var_n",
"var_o",
"var_p",
"var_q",
"var_r",
"var_s",
"var_t",
"var_u",
]:
if f"'{var_name}'" in output:
captured_vars.append(var_name)
assert len(captured_vars) > 0
assert len(captured_vars) < 21
def test_code_variables_disabled_capture(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
from posthog import Posthog
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_string = "hello world"
my_number = 42
my_bool = True
1/0
trigger_error()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "'code_variables':" not in output
assert '"code_variables":' not in output
assert "'my_string'" not in output
assert "'my_number'" not in output
def test_code_variables_enabled_then_disabled_in_context(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
import posthog
from posthog import Posthog
posthog_client = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def process_data():
my_var = "should not be captured"
important_value = 123
1/0
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(False)
process_data()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "'code_variables':" not in output
assert '"code_variables":' not in output
assert "'my_var'" not in output
assert "'important_value'" not in output
def test_code_variables_repr_fallback(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
import os
import re
from datetime import datetime, timedelta
from decimal import Decimal
from fractions import Fraction
from posthog import Posthog
class CustomReprClass:
def __repr__(self):
return '<CustomReprClass: custom representation>'
posthog = Posthog(
'phc_x',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
project_root=os.path.dirname(os.path.abspath(__file__))
)
def trigger_error():
my_regex = re.compile(r'\\d+')
my_datetime = datetime(2024, 1, 15, 10, 30, 45)
my_timedelta = timedelta(days=5, hours=3)
my_decimal = Decimal('123.456')
my_fraction = Fraction(3, 4)
my_set = {1, 2, 3}
my_frozenset = frozenset([4, 5, 6])
my_bytes = b'hello bytes'
my_bytearray = bytearray(b'mutable bytes')
my_memoryview = memoryview(b'memory view')
my_complex = complex(3, 4)
my_range = range(10)
my_custom = CustomReprClass()
my_lambda = lambda x: x * 2
my_function = trigger_error
1/0
trigger_error()
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output.decode("utf-8")
assert "ZeroDivisionError" in output
assert "code_variables" in output
assert "re.compile(" in output and "\\\\d+" in output
assert "datetime.datetime(2024, 1, 15, 10, 30, 45)" in output
assert "datetime.timedelta(days=5, seconds=10800)" in output
assert "Decimal('123.456')" in output
assert "Fraction(3, 4)" in output
assert "{1, 2, 3}" in output
assert "frozenset({4, 5, 6})" in output
assert "b'hello bytes'" in output
assert "bytearray(b'mutable bytes')" in output
assert "<memory at" in output
assert "(3+4j)" in output
assert "range(0, 10)" in output
assert "<CustomReprClass: custom representation>" in output
assert "<lambda>" in output
assert "<function trigger_error at" in output
+441 -2
View File
@@ -4,7 +4,13 @@ import mock
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
FlagMetadata,
FlagReason,
)
class TestFeatureFlagResult(unittest.TestCase):
@@ -189,7 +195,6 @@ class TestGetFeatureFlagResult(unittest.TestCase):
def set_fail(self, e, batch):
"""Mark the failure handler"""
print("FAIL", e, batch) # noqa: T201
self.failed = True
def setUp(self):
@@ -241,6 +246,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
groups={},
disable_geoip=None,
)
# Verify error property is NOT present on successful evaluation
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
@@ -295,6 +303,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
groups={},
disable_geoip=None,
)
# Verify error property is NOT present on successful evaluation
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
another_flag_result = self.client.get_feature_flag_result(
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
@@ -360,6 +371,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
groups={},
disable_geoip=None,
)
# Verify error property is NOT present on successful evaluation
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
@@ -403,6 +417,9 @@ class TestGetFeatureFlagResult(unittest.TestCase):
groups={},
disable_geoip=None,
)
# Verify error property is NOT present on successful evaluation
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
@@ -438,6 +455,428 @@ class TestGetFeatureFlagResult(unittest.TestCase):
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/no-person-flag": None,
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_with_errors_while_computing_flags(
self, patch_capture, patch_flags
):
"""Test that errors_while_computing_flags is included in the $feature_flag_called event.
When the server returns errorsWhileComputingFlags=true, it indicates that there
was an error computing one or more flags. We include this in the event so users
can identify and debug flag evaluation issues.
"""
patch_flags.return_value = {
"flags": {
"my-flag": {
"key": "my-flag",
"enabled": True,
"variant": None,
"reason": {"description": "Matched condition set 1"},
"metadata": {"id": 1, "version": 1, "payload": None},
},
},
"requestId": "test-request-id-789",
"errorsWhileComputingFlags": True,
}
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
self.assertEqual(flag_result.enabled, True)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": True,
"locally_evaluated": False,
"$feature/my-flag": True,
"$feature_flag_request_id": "test-request-id-789",
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 1,
"$feature_flag_version": 1,
"$feature_flag_error": FeatureFlagError.ERRORS_WHILE_COMPUTING,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_flag_not_in_response(
self, patch_capture, patch_flags
):
"""Test that when a flag is not in the API response, we capture flag_missing error.
This happens when a flag doesn't exist or the user doesn't match any conditions.
"""
patch_flags.return_value = {
"flags": {
"other-flag": {
"key": "other-flag",
"enabled": True,
"variant": None,
"reason": {"description": "Matched condition set 1"},
"metadata": {"id": 1, "version": 1, "payload": None},
},
},
"requestId": "test-request-id-456",
}
flag_result = self.client.get_feature_flag_result(
"missing-flag", "some-distinct-id"
)
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "missing-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/missing-flag": None,
"$feature_flag_request_id": "test-request-id-456",
"$feature_flag_error": FeatureFlagError.FLAG_MISSING,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_errors_computing_and_flag_missing(
self, patch_capture, patch_flags
):
"""Test that both errors are reported when errorsWhileComputingFlags=true AND flag is missing.
This can happen when the server encounters errors computing flags AND the requested
flag is not in the response. Both conditions should be reported for debugging.
"""
patch_flags.return_value = {
"flags": {}, # Flag is missing
"requestId": "test-request-id-999",
"errorsWhileComputingFlags": True, # But errors also occurred
}
flag_result = self.client.get_feature_flag_result(
"missing-flag", "some-distinct-id"
)
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "missing-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/missing-flag": None,
"$feature_flag_request_id": "test-request-id-999",
"$feature_flag_error": f"{FeatureFlagError.ERRORS_WHILE_COMPUTING},{FeatureFlagError.FLAG_MISSING}",
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_unknown_error(self, patch_capture, patch_flags):
"""Test that unexpected exceptions are captured as unknown_error."""
patch_flags.side_effect = Exception("Unexpected error")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/my-flag": None,
"$feature_flag_error": FeatureFlagError.UNKNOWN_ERROR,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_timeout_error(self, patch_capture, patch_flags):
"""Test that timeout errors are captured specifically."""
from posthog.request import RequestsTimeout
patch_flags.side_effect = RequestsTimeout("Request timed out")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/my-flag": None,
"$feature_flag_error": FeatureFlagError.TIMEOUT,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_connection_error(self, patch_capture, patch_flags):
"""Test that connection errors are captured specifically."""
from posthog.request import RequestsConnectionError
patch_flags.side_effect = RequestsConnectionError("Connection refused")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/my-flag": None,
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_api_error(self, patch_capture, patch_flags):
"""Test that API errors include the status code."""
from posthog.request import APIError
patch_flags.side_effect = APIError(500, "Internal server error")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/my-flag": None,
"$feature_flag_error": FeatureFlagError.api_error(500),
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_quota_limited(self, patch_capture, patch_flags):
"""Test that quota limit errors are captured specifically."""
from posthog.request import QuotaLimitError
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/my-flag": None,
"$feature_flag_error": FeatureFlagError.QUOTA_LIMITED,
},
groups={},
disable_geoip=None,
)
class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
"""Tests for stale cache fallback behavior when flag evaluation fails.
When the PostHog API is unavailable (timeout, connection error, etc.), the SDK
falls back to stale cached flag values if available. These tests verify that:
1. The stale cached value is returned when an error occurs
2. The $feature_flag_error property is still set (for debugging)
3. The response reflects the cached value, not None
"""
def set_fail(self, e, batch):
"""Mark the failure handler"""
self.failed = True
def setUp(self):
self.failed = False
# Create client with memory-based flag cache enabled
self.client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
flag_fallback_cache_url="memory://local/?ttl=300&size=10000",
)
def _populate_stale_cache(self, distinct_id, flag_key, flag_result):
"""Pre-populate the flag cache with a value that will be used for stale fallback."""
self.client.flag_cache.set_cached_flag(
distinct_id,
flag_key,
flag_result,
flag_definition_version=self.client.flag_definition_version,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_timeout_error_returns_stale_cached_value(self, patch_capture, patch_flags):
"""Test that timeout errors return stale cached value when available."""
from posthog.request import RequestsTimeout
# Pre-populate cache with a flag result
cached_result = FeatureFlagResult.from_value_and_payload(
"my-flag", "cached-variant", '{"from": "cache"}'
)
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
# Simulate timeout error
patch_flags.side_effect = RequestsTimeout("Request timed out")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
# Should return the stale cached value
self.assertIsNotNone(flag_result)
self.assertEqual(flag_result.variant, "cached-variant")
self.assertEqual(flag_result.payload, {"from": "cache"})
# Error should still be tracked for debugging
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": "cached-variant",
"locally_evaluated": False,
"$feature/my-flag": "cached-variant",
"$feature_flag_payload": {"from": "cache"},
"$feature_flag_error": FeatureFlagError.TIMEOUT,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_connection_error_returns_stale_cached_value(
self, patch_capture, patch_flags
):
"""Test that connection errors return stale cached value when available."""
from posthog.request import RequestsConnectionError
# Pre-populate cache with a boolean flag result
cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
# Simulate connection error
patch_flags.side_effect = RequestsConnectionError("Connection refused")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
# Should return the stale cached value
self.assertIsNotNone(flag_result)
self.assertEqual(flag_result.enabled, True)
self.assertIsNone(flag_result.variant)
# Error should still be tracked
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": True,
"locally_evaluated": False,
"$feature/my-flag": True,
"$feature_flag_error": FeatureFlagError.CONNECTION_ERROR,
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_api_error_returns_stale_cached_value(self, patch_capture, patch_flags):
"""Test that API errors return stale cached value when available."""
from posthog.request import APIError
# Pre-populate cache
cached_result = FeatureFlagResult.from_value_and_payload(
"my-flag", "control", None
)
self._populate_stale_cache("some-distinct-id", "my-flag", cached_result)
# Simulate API error
patch_flags.side_effect = APIError(503, "Service unavailable")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
# Should return the stale cached value
self.assertIsNotNone(flag_result)
self.assertEqual(flag_result.variant, "control")
# Error should still be tracked with status code
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": "control",
"locally_evaluated": False,
"$feature/my-flag": "control",
"$feature_flag_error": FeatureFlagError.api_error(503),
},
groups={},
disable_geoip=None,
)
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_error_without_cache_returns_none(self, patch_capture, patch_flags):
"""Test that errors return None when no stale cache is available."""
from posthog.request import RequestsTimeout
# Do NOT populate cache - no fallback available
patch_flags.side_effect = RequestsTimeout("Request timed out")
flag_result = self.client.get_feature_flag_result("my-flag", "some-distinct-id")
# Should return None since no cache available
self.assertIsNone(flag_result)
# Error should still be tracked
patch_capture.assert_called_with(
"$feature_flag_called",
distinct_id="some-distinct-id",
properties={
"$feature_flag": "my-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/my-flag": None,
"$feature_flag_error": FeatureFlagError.TIMEOUT,
},
groups={},
disable_geoip=None,
File diff suppressed because it is too large Load Diff
+612
View File
@@ -0,0 +1,612 @@
"""
Tests for FlagDefinitionCacheProvider functionality.
These tests follow the patterns from the TypeScript implementation in posthog-js/packages/node.
"""
import threading
import unittest
from typing import Optional
from unittest import mock
from posthog.client import Client
from posthog.flag_definition_cache import (
FlagDefinitionCacheData,
FlagDefinitionCacheProvider,
)
from posthog.request import GetResponse
from posthog.test.test_utils import FAKE_TEST_API_KEY
class MockCacheProvider:
"""A mock implementation of FlagDefinitionCacheProvider for testing."""
def __init__(self):
self.stored_data: Optional[FlagDefinitionCacheData] = None
self.should_fetch_return_value = True
self.get_call_count = 0
self.should_fetch_call_count = 0
self.on_received_call_count = 0
self.shutdown_call_count = 0
self.should_fetch_error: Optional[Exception] = None
self.get_error: Optional[Exception] = None
self.on_received_error: Optional[Exception] = None
self.shutdown_error: Optional[Exception] = None
def get_flag_definitions(self) -> Optional[FlagDefinitionCacheData]:
self.get_call_count += 1
if self.get_error:
raise self.get_error
return self.stored_data
def should_fetch_flag_definitions(self) -> bool:
self.should_fetch_call_count += 1
if self.should_fetch_error:
raise self.should_fetch_error
return self.should_fetch_return_value
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
self.on_received_call_count += 1
if self.on_received_error:
raise self.on_received_error
self.stored_data = data
def shutdown(self) -> None:
self.shutdown_call_count += 1
if self.shutdown_error:
raise self.shutdown_error
class TestFlagDefinitionCacheProvider(unittest.TestCase):
"""Tests for the FlagDefinitionCacheProvider protocol."""
@classmethod
def setUpClass(cls):
# Prevent real HTTP requests
cls.client_post_patcher = mock.patch("posthog.client.batch_post")
cls.consumer_post_patcher = mock.patch("posthog.consumer.batch_post")
cls.client_post_patcher.start()
cls.consumer_post_patcher.start()
@classmethod
def tearDownClass(cls):
cls.client_post_patcher.stop()
cls.consumer_post_patcher.stop()
def setUp(self):
self.cache_provider = MockCacheProvider()
self.sample_flags_data: FlagDefinitionCacheData = {
"flags": [
{"key": "test-flag", "active": True, "filters": {}},
{"key": "another-flag", "active": False, "filters": {}},
],
"group_type_mapping": {"0": "company", "1": "project"},
"cohorts": {"1": {"properties": []}},
}
def tearDown(self):
# Ensure client cleanup
pass
def _create_client_with_cache(self) -> Client:
"""Create a client with the mock cache provider."""
return Client(
FAKE_TEST_API_KEY,
personal_api_key="test-personal-key",
flag_definition_cache_provider=self.cache_provider,
sync_mode=True,
enable_local_evaluation=False, # Disable poller for tests
)
class TestCacheInitialization(TestFlagDefinitionCacheProvider):
"""Tests for cache initialization behavior."""
@mock.patch("posthog.client.get")
def test_uses_cached_data_when_should_fetch_returns_false(self, mock_get):
"""When should_fetch returns False and cache has data, use cached data."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = self.sample_flags_data
client = self._create_client_with_cache()
client._load_feature_flags()
# Should not call API
mock_get.assert_not_called()
# Should have called cache methods
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
self.assertEqual(self.cache_provider.get_call_count, 1)
# Flags should be loaded from cache
self.assertEqual(len(client.feature_flags), 2)
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
client.join()
@mock.patch("posthog.client.get")
def test_fetches_from_api_when_should_fetch_returns_true(self, mock_get):
"""When should_fetch returns True, fetch from API."""
self.cache_provider.should_fetch_return_value = True
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Should call API
mock_get.assert_called_once()
# Should have called should_fetch but not get
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
self.assertEqual(self.cache_provider.get_call_count, 0)
# Should have called on_received to store in cache
self.assertEqual(self.cache_provider.on_received_call_count, 1)
client.join()
@mock.patch("posthog.client.get")
def test_emergency_fallback_when_cache_empty_and_no_flags(self, mock_get):
"""When should_fetch=False but cache is empty and no flags loaded, fetch anyway."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = None # Empty cache
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Should call API due to emergency fallback
mock_get.assert_called_once()
# Should have called on_received
self.assertEqual(self.cache_provider.on_received_call_count, 1)
client.join()
@mock.patch("posthog.client.get")
def test_preserves_existing_flags_when_cache_returns_none(self, mock_get):
"""When cache returns None but client has flags, preserve existing flags."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = None # Empty cache
client = self._create_client_with_cache()
# Pre-load flags (simulating a previous successful fetch)
client.feature_flags = self.sample_flags_data["flags"]
client.group_type_mapping = self.sample_flags_data["group_type_mapping"]
client.cohorts = self.sample_flags_data["cohorts"]
client._load_feature_flags()
# Should NOT call API since we already have flags
mock_get.assert_not_called()
# Existing flags should be preserved
self.assertEqual(len(client.feature_flags), 2)
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
client.join()
class TestFetchCoordination(TestFlagDefinitionCacheProvider):
"""Tests for fetch coordination between workers."""
@mock.patch("posthog.client.get")
def test_calls_should_fetch_before_each_poll(self, mock_get):
"""should_fetch_flag_definitions is called before each poll cycle."""
self.cache_provider.should_fetch_return_value = True
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
# First poll
client._load_feature_flags()
self.assertEqual(self.cache_provider.should_fetch_call_count, 1)
# Second poll
client._load_feature_flags()
self.assertEqual(self.cache_provider.should_fetch_call_count, 2)
client.join()
@mock.patch("posthog.client.get")
def test_does_not_call_on_received_when_fetch_skipped(self, mock_get):
"""on_flag_definitions_received is NOT called when fetch is skipped."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = self.sample_flags_data
client = self._create_client_with_cache()
client._load_feature_flags()
# Should not call on_received since we didn't fetch
self.assertEqual(self.cache_provider.on_received_call_count, 0)
client.join()
@mock.patch("posthog.client.get")
def test_stores_data_in_cache_after_api_fetch(self, mock_get):
"""on_flag_definitions_received receives the fetched data."""
self.cache_provider.should_fetch_return_value = True
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Should have stored data in cache
self.assertEqual(self.cache_provider.on_received_call_count, 1)
self.assertIsNotNone(self.cache_provider.stored_data)
self.assertEqual(len(self.cache_provider.stored_data["flags"]), 2)
client.join()
@mock.patch("posthog.client.get")
def test_304_not_modified_does_not_update_cache(self, mock_get):
"""When API returns 304 Not Modified, cache should not be updated."""
self.cache_provider.should_fetch_return_value = True
# First fetch to populate flags and ETag
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Verify initial fetch worked
self.assertEqual(self.cache_provider.on_received_call_count, 1)
self.assertEqual(len(client.feature_flags), 2)
# Second fetch returns 304 Not Modified
mock_get.return_value = GetResponse(
data=None, etag="test-etag", not_modified=True
)
client._load_feature_flags()
# API was called twice
self.assertEqual(mock_get.call_count, 2)
# should_fetch was called twice
self.assertEqual(self.cache_provider.should_fetch_call_count, 2)
# on_received should NOT be called again (304 = no new data)
self.assertEqual(self.cache_provider.on_received_call_count, 1)
# Flags should still be present
self.assertEqual(len(client.feature_flags), 2)
client.join()
class TestErrorHandling(TestFlagDefinitionCacheProvider):
"""Tests for error handling in cache provider operations."""
@mock.patch("posthog.client.get")
def test_should_fetch_error_defaults_to_fetching(self, mock_get):
"""When should_fetch throws an error, default to fetching from API."""
self.cache_provider.should_fetch_error = Exception("Lock acquisition failed")
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Should still fetch from API
mock_get.assert_called_once()
# Flags should be loaded
self.assertEqual(len(client.feature_flags), 2)
client.join()
@mock.patch("posthog.client.get")
def test_get_error_falls_back_to_api_fetch(self, mock_get):
"""When get_flag_definitions throws an error, fetch from API."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.get_error = Exception("Cache read failed")
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Should fall back to API
mock_get.assert_called_once()
client.join()
@mock.patch("posthog.client.get")
def test_on_received_error_keeps_flags_in_memory(self, mock_get):
"""When on_flag_definitions_received throws, flags are still in memory."""
self.cache_provider.should_fetch_return_value = True
self.cache_provider.on_received_error = Exception("Cache write failed")
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Flags should still be loaded in memory despite cache error
self.assertEqual(len(client.feature_flags), 2)
self.assertEqual(client.feature_flags[0]["key"], "test-flag")
client.join()
@mock.patch("posthog.client.get")
def test_shutdown_error_is_logged_but_continues(self, mock_get):
"""When shutdown throws an error, it's logged but shutdown continues."""
self.cache_provider.shutdown_error = Exception("Lock release failed")
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Should not raise when joining
client.join()
# Shutdown was called
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
"""Tests for shutdown lifecycle."""
@mock.patch("posthog.client.get")
def test_shutdown_calls_cache_provider_shutdown(self, mock_get):
"""Client shutdown calls cache provider shutdown."""
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Shutdown
client.join()
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
@mock.patch("posthog.client.get")
def test_shutdown_called_even_without_fetching(self, mock_get):
"""Shutdown is called even when cache was used instead of fetching."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = self.sample_flags_data
client = self._create_client_with_cache()
client._load_feature_flags()
client.join()
# Shutdown should still be called
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
@mock.patch("posthog.client.get")
def test_multiple_join_calls_only_shutdown_once(self, mock_get):
"""Calling join() multiple times should only call cache provider shutdown once."""
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
client._load_feature_flags()
# Call join multiple times
client.join()
client.join()
client.join()
# Shutdown should be called each time (current behavior - no guard)
# This test documents the current behavior
self.assertGreaterEqual(self.cache_provider.shutdown_call_count, 1)
class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
"""Tests for backward compatibility without cache provider."""
@mock.patch("posthog.client.get")
def test_works_without_cache_provider(self, mock_get):
"""Client works normally without a cache provider configured."""
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
# Create client without cache provider
client = Client(
FAKE_TEST_API_KEY,
personal_api_key="test-personal-key",
sync_mode=True,
enable_local_evaluation=False,
)
client._load_feature_flags()
# Should fetch from API
mock_get.assert_called_once()
# Flags should be loaded
self.assertEqual(len(client.feature_flags), 2)
client.join()
class TestDataIntegrity(TestFlagDefinitionCacheProvider):
"""Tests for data integrity between cache and client state."""
@mock.patch("posthog.client.get")
def test_cached_flags_available_for_evaluation(self, mock_get):
"""Flags loaded from cache are available for local evaluation."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = {
"flags": [
{
"key": "test-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [],
"rollout_percentage": 100,
}
]
},
}
],
"group_type_mapping": {},
"cohorts": {},
}
client = self._create_client_with_cache()
client._load_feature_flags()
# Flag should be accessible
self.assertEqual(len(client.feature_flags), 1)
self.assertEqual(client.feature_flags_by_key["test-flag"]["key"], "test-flag")
client.join()
@mock.patch("posthog.client.get")
def test_group_type_mapping_loaded_from_cache(self, mock_get):
"""Group type mapping is correctly loaded from cache."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = self.sample_flags_data
client = self._create_client_with_cache()
client._load_feature_flags()
self.assertEqual(client.group_type_mapping["0"], "company")
self.assertEqual(client.group_type_mapping["1"], "project")
client.join()
@mock.patch("posthog.client.get")
def test_cohorts_loaded_from_cache(self, mock_get):
"""Cohorts are correctly loaded from cache."""
self.cache_provider.should_fetch_return_value = False
self.cache_provider.stored_data = self.sample_flags_data
client = self._create_client_with_cache()
client._load_feature_flags()
self.assertIn("1", client.cohorts)
client.join()
@mock.patch("posthog.client.get")
def test_cache_updated_when_api_returns_new_data(self, mock_get):
"""State transition: cache has old data -> API returns new -> cache updated."""
# Start with old cached data
old_flags_data: FlagDefinitionCacheData = {
"flags": [{"key": "old-flag", "active": True, "filters": {}}],
"group_type_mapping": {},
"cohorts": {},
}
self.cache_provider.stored_data = old_flags_data
self.cache_provider.should_fetch_return_value = False
client = self._create_client_with_cache()
# First load from cache
client._load_feature_flags()
self.assertEqual(client.feature_flags[0]["key"], "old-flag")
self.assertEqual(self.cache_provider.on_received_call_count, 0)
# Now trigger API fetch with new data
self.cache_provider.should_fetch_return_value = True
new_flags_data: FlagDefinitionCacheData = {
"flags": [{"key": "new-flag", "active": True, "filters": {}}],
"group_type_mapping": {"0": "company"},
"cohorts": {"1": {"properties": []}},
}
mock_get.return_value = GetResponse(
data=new_flags_data, etag="new-etag", not_modified=False
)
client._load_feature_flags()
# Verify new flags loaded
self.assertEqual(client.feature_flags[0]["key"], "new-flag")
self.assertEqual(client.group_type_mapping["0"], "company")
# Verify cache was updated
self.assertEqual(self.cache_provider.on_received_call_count, 1)
self.assertEqual(self.cache_provider.stored_data["flags"][0]["key"], "new-flag")
client.join()
class TestConcurrency(TestFlagDefinitionCacheProvider):
"""Tests for thread safety and concurrent access."""
@mock.patch("posthog.client.get")
def test_concurrent_load_feature_flags_is_thread_safe(self, mock_get):
"""Multiple threads calling _load_feature_flags should not cause errors."""
mock_get.return_value = GetResponse(
data=self.sample_flags_data, etag="test-etag", not_modified=False
)
client = self._create_client_with_cache()
errors = []
def load_flags():
try:
client._load_feature_flags()
except Exception as e:
errors.append(e)
# Launch 5 threads concurrently
threads = [threading.Thread(target=load_flags) for _ in range(5)]
for t in threads:
t.start()
for t in threads:
t.join()
# Should complete without errors
self.assertEqual(len(errors), 0, f"Unexpected errors: {errors}")
# Flags should be loaded
self.assertIsNotNone(client.feature_flags)
self.assertEqual(len(client.feature_flags), 2)
client.join()
class TestProtocolCompliance(unittest.TestCase):
"""Tests for Protocol compliance."""
def test_mock_provider_is_protocol_instance(self):
"""MockCacheProvider satisfies FlagDefinitionCacheProvider protocol."""
provider = MockCacheProvider()
self.assertIsInstance(provider, FlagDefinitionCacheProvider)
def test_incomplete_provider_is_not_protocol_instance(self):
"""Class missing methods is not a FlagDefinitionCacheProvider."""
class IncompleteProvider:
def get_flag_definitions(self):
return None
provider = IncompleteProvider()
self.assertNotIsInstance(provider, FlagDefinitionCacheProvider)
if __name__ == "__main__":
unittest.main()
-8
View File
@@ -18,14 +18,6 @@ class TestModule(unittest.TestCase):
"testsecret", host="http://localhost:8000", on_error=self.failed
)
def test_no_api_key(self):
self.posthog.api_key = None
self.assertRaises(Exception, self.posthog.capture)
def test_no_host(self):
self.posthog.host = None
self.assertRaises(Exception, self.posthog.capture)
def test_track(self):
res = self.posthog.capture("python module event", distinct_id="distinct_id")
self._assert_enqueue_result(res)
+536
View File
@@ -6,16 +6,60 @@ import mock
import pytest
import requests
import posthog.request as request_module
from posthog.request import (
APIError,
DatetimeSerializer,
GetResponse,
KEEP_ALIVE_SOCKET_OPTIONS,
QuotaLimitError,
_mask_tokens_in_url,
batch_post,
decide,
determine_server_host,
disable_connection_reuse,
enable_keep_alive,
flags,
get,
set_socket_options,
)
from posthog.test.test_utils import TEST_API_KEY
@pytest.mark.parametrize(
"url, expected",
[
# Token with params after - masks keeping first 10 chars
(
"https://example.com/api/flags?token=phc_abc123xyz789&send_cohorts",
"https://example.com/api/flags?token=phc_abc123...&send_cohorts",
),
# Token at end of URL
(
"https://example.com/api/flags?token=phc_abc123xyz789",
"https://example.com/api/flags?token=phc_abc123...",
),
# No token - unchanged
(
"https://example.com/api/flags?other=value",
"https://example.com/api/flags?other=value",
),
# Short token (<10 chars) - unchanged
(
"https://example.com/api/flags?token=short",
"https://example.com/api/flags?token=short",
),
# Exactly 10 char token - gets ellipsis
(
"https://example.com/api/flags?token=1234567890",
"https://example.com/api/flags?token=1234567890...",
),
],
)
def test_mask_tokens_in_url(url, expected):
assert _mask_tokens_in_url(url) == expected
class TestRequests(unittest.TestCase):
def test_valid_request(self):
res = batch_post(
@@ -107,6 +151,184 @@ class TestRequests(unittest.TestCase):
self.assertEqual(response["featureFlags"], {"flag1": True})
class TestGet(unittest.TestCase):
"""Unit tests for the get() function HTTP-level behavior."""
@mock.patch("posthog.request._session.get")
def test_get_returns_data_and_etag(self, mock_get):
"""Test that get() returns GetResponse with data and etag from headers."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response.headers["ETag"] = '"abc123"'
mock_response._content = json.dumps({"flags": [{"key": "test-flag"}]}).encode(
"utf-8"
)
mock_get.return_value = mock_response
response = get("api_key", "/test-url", host="https://example.com")
self.assertIsInstance(response, GetResponse)
self.assertEqual(response.data, {"flags": [{"key": "test-flag"}]})
self.assertEqual(response.etag, '"abc123"')
self.assertFalse(response.not_modified)
@mock.patch("posthog.request._session.get")
def test_get_sends_if_none_match_header_when_etag_provided(self, mock_get):
"""Test that If-None-Match header is sent when etag parameter is provided."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response.headers["ETag"] = '"new-etag"'
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
mock_get.return_value = mock_response
get("api_key", "/test-url", host="https://example.com", etag='"previous-etag"')
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["headers"]["If-None-Match"], '"previous-etag"')
@mock.patch("posthog.request._session.get")
def test_get_does_not_send_if_none_match_when_no_etag(self, mock_get):
"""Test that If-None-Match header is not sent when no etag provided."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
mock_get.return_value = mock_response
get("api_key", "/test-url", host="https://example.com")
call_kwargs = mock_get.call_args[1]
self.assertNotIn("If-None-Match", call_kwargs["headers"])
@mock.patch("posthog.request._session.get")
def test_get_handles_304_not_modified(self, mock_get):
"""Test that 304 Not Modified response returns not_modified=True with no data."""
mock_response = requests.Response()
mock_response.status_code = 304
mock_response.headers["ETag"] = '"unchanged-etag"'
mock_get.return_value = mock_response
response = get(
"api_key", "/test-url", host="https://example.com", etag='"unchanged-etag"'
)
self.assertIsInstance(response, GetResponse)
self.assertIsNone(response.data)
self.assertEqual(response.etag, '"unchanged-etag"')
self.assertTrue(response.not_modified)
@mock.patch("posthog.request._session.get")
def test_get_304_without_etag_header_uses_request_etag(self, mock_get):
"""Test that 304 response without ETag header falls back to request etag."""
mock_response = requests.Response()
mock_response.status_code = 304
# Server doesn't return ETag header on 304
mock_get.return_value = mock_response
response = get(
"api_key", "/test-url", host="https://example.com", etag='"original-etag"'
)
self.assertTrue(response.not_modified)
self.assertEqual(response.etag, '"original-etag"')
@mock.patch("posthog.request._session.get")
def test_get_200_without_etag_header(self, mock_get):
"""Test that 200 response without ETag header returns None for etag."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({"flags": []}).encode("utf-8")
# No ETag header
mock_get.return_value = mock_response
response = get("api_key", "/test-url", host="https://example.com")
self.assertFalse(response.not_modified)
self.assertIsNone(response.etag)
self.assertEqual(response.data, {"flags": []})
@mock.patch("posthog.request._session.get")
def test_get_error_response_raises_api_error(self, mock_get):
"""Test that error responses raise APIError."""
mock_response = requests.Response()
mock_response.status_code = 401
mock_response._content = json.dumps({"detail": "Unauthorized"}).encode("utf-8")
mock_get.return_value = mock_response
with self.assertRaises(APIError) as ctx:
get("bad_key", "/test-url", host="https://example.com")
self.assertEqual(ctx.exception.status, 401)
self.assertEqual(ctx.exception.message, "Unauthorized")
@mock.patch("posthog.request._session.get")
def test_get_sends_authorization_header(self, mock_get):
"""Test that Authorization header is sent with Bearer token."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({}).encode("utf-8")
mock_get.return_value = mock_response
get("my-api-key", "/test-url", host="https://example.com")
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-api-key")
@mock.patch("posthog.request._session.get")
def test_get_sends_user_agent_header(self, mock_get):
"""Test that User-Agent header is sent."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({}).encode("utf-8")
mock_get.return_value = mock_response
get("api_key", "/test-url", host="https://example.com")
call_kwargs = mock_get.call_args[1]
self.assertIn("User-Agent", call_kwargs["headers"])
self.assertTrue(
call_kwargs["headers"]["User-Agent"].startswith("posthog-python/")
)
@mock.patch("posthog.request._session.get")
def test_get_passes_timeout(self, mock_get):
"""Test that timeout parameter is passed to the request."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({}).encode("utf-8")
mock_get.return_value = mock_response
get("api_key", "/test-url", host="https://example.com", timeout=30)
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["timeout"], 30)
@mock.patch("posthog.request._session.get")
def test_get_constructs_full_url(self, mock_get):
"""Test that host and url are combined correctly."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({}).encode("utf-8")
mock_get.return_value = mock_response
get("api_key", "/api/flags", host="https://example.com")
call_args = mock_get.call_args[0]
self.assertEqual(call_args[0], "https://example.com/api/flags")
@mock.patch("posthog.request._session.get")
def test_get_removes_trailing_slash_from_host(self, mock_get):
"""Test that trailing slash is removed from host."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps({}).encode("utf-8")
mock_get.return_value = mock_response
get("api_key", "/api/flags", host="https://example.com/")
call_args = mock_get.call_args[0]
self.assertEqual(call_args[0], "https://example.com/api/flags")
@pytest.mark.parametrize(
"host, expected",
[
@@ -128,3 +350,317 @@ class TestRequests(unittest.TestCase):
)
def test_routing_to_custom_host(host, expected):
assert determine_server_host(host) == expected
def test_enable_keep_alive_sets_socket_options():
try:
enable_keep_alive()
from posthog.request import _session
adapter = _session.get_adapter("https://example.com")
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
finally:
set_socket_options(None)
def test_set_socket_options_clears_with_none():
try:
enable_keep_alive()
set_socket_options(None)
from posthog.request import _session
adapter = _session.get_adapter("https://example.com")
assert adapter.socket_options is None
finally:
set_socket_options(None)
def test_disable_connection_reuse_creates_fresh_sessions():
try:
disable_connection_reuse()
session1 = request_module._get_session()
session2 = request_module._get_session()
assert session1 is not session2
finally:
request_module._pooling_enabled = True
def test_set_socket_options_is_idempotent():
try:
enable_keep_alive()
session1 = request_module._session
enable_keep_alive()
session2 = request_module._session
assert session1 is session2
finally:
set_socket_options(None)
class TestFlagsSession(unittest.TestCase):
"""Tests for flags session configuration."""
def test_retry_status_forcelist_excludes_rate_limits(self):
"""Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
from posthog.request import RETRY_STATUS_FORCELIST
self.assertNotIn(429, RETRY_STATUS_FORCELIST)
def test_retry_status_forcelist_excludes_quota_errors(self):
"""Verify 402 (payment required/quota) is NOT retried - won't resolve."""
from posthog.request import RETRY_STATUS_FORCELIST
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
@mock.patch("posthog.request._get_flags_session")
def test_flags_uses_flags_session(self, mock_get_flags_session):
"""flags() uses the dedicated flags session, not the general session."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{
"featureFlags": {"test-flag": True},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
).encode("utf-8")
mock_session = mock.MagicMock()
mock_session.post.return_value = mock_response
mock_get_flags_session.return_value = mock_session
result = flags("test-key", "https://test.posthog.com", distinct_id="user123")
self.assertEqual(result["featureFlags"]["test-flag"], True)
mock_get_flags_session.assert_called_once()
mock_session.post.assert_called_once()
@mock.patch("posthog.request._get_flags_session")
def test_flags_no_retry_on_quota_limit(self, mock_get_flags_session):
"""flags() raises QuotaLimitError without retrying (at application level)."""
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{
"quotaLimited": ["feature_flags"],
"featureFlags": {},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
).encode("utf-8")
mock_session = mock.MagicMock()
mock_session.post.return_value = mock_response
mock_get_flags_session.return_value = mock_session
with self.assertRaises(QuotaLimitError):
flags("test-key", "https://test.posthog.com", distinct_id="user123")
# QuotaLimitError is raised after response is received, not retried
self.assertEqual(mock_session.post.call_count, 1)
class TestFlagsSessionNetworkRetries(unittest.TestCase):
"""Tests for network failure retries in the flags session."""
def test_flags_session_retry_config_includes_connection_errors(self):
"""
Verify that the flags session is configured to retry on connection errors.
The urllib3 Retry adapter with connect=2 and read=2 automatically
retries on network-level failures (DNS failures, connection refused,
connection reset, etc.) up to 2 times each.
"""
from posthog.request import _build_flags_session
session = _build_flags_session()
# Get the adapter for https://
adapter = session.get_adapter("https://test.posthog.com")
# Verify retry configuration
retry = adapter.max_retries
self.assertEqual(retry.total, 2, "Should have 2 total retries")
self.assertEqual(retry.connect, 2, "Should retry connection errors twice")
self.assertEqual(retry.read, 2, "Should retry read errors twice")
self.assertIn("POST", retry.allowed_methods, "Should allow POST retries")
def test_flags_session_retries_on_server_errors(self):
"""
Verify that transient server errors (5xx) trigger retries.
This tests the status_forcelist configuration which specifies
which HTTP status codes should trigger a retry.
"""
from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST
session = _build_flags_session()
adapter = session.get_adapter("https://test.posthog.com")
retry = adapter.max_retries
# Verify the status codes that trigger retries
self.assertEqual(
set(retry.status_forcelist),
set(RETRY_STATUS_FORCELIST),
"Should retry on transient server errors",
)
# Verify specific codes are included
self.assertIn(500, retry.status_forcelist)
self.assertIn(502, retry.status_forcelist)
self.assertIn(503, retry.status_forcelist)
self.assertIn(504, retry.status_forcelist)
# Verify rate limits and quota errors are NOT retried
self.assertNotIn(429, retry.status_forcelist)
self.assertNotIn(402, retry.status_forcelist)
def test_flags_session_has_backoff(self):
"""
Verify that retries use exponential backoff to avoid thundering herd.
"""
from posthog.request import _build_flags_session
session = _build_flags_session()
adapter = session.get_adapter("https://test.posthog.com")
retry = adapter.max_retries
self.assertEqual(
retry.backoff_factor,
0.5,
"Should use 0.5s backoff factor (0.5s, 1s delays)",
)
class TestFlagsSessionRetryIntegration(unittest.TestCase):
"""Integration tests that verify actual retry behavior with a local server."""
def test_retries_on_503_then_succeeds(self):
"""
Verify that 503 errors trigger retries and eventually succeed.
Uses a local HTTP server that fails twice with 503, then succeeds.
This tests the full retry flow including backoff timing.
"""
import threading
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib3.util.retry import Retry
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
request_count = 0
class RetryTestHandler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def do_POST(self):
nonlocal request_count
request_count += 1
# Read and discard request body to prevent connection issues
content_length = int(self.headers.get("Content-Length", 0))
if content_length > 0:
self.rfile.read(content_length)
if request_count <= 2:
self.send_response(503)
self.send_header("Content-Type", "application/json")
body = b'{"error": "Service unavailable"}'
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
self.send_response(200)
self.send_header("Content-Type", "application/json")
body = (
b'{"featureFlags": {"test": true}, "featureFlagPayloads": {}}'
)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, format, *args):
pass # Suppress logging
# Use ThreadingMixIn for cleaner shutdown
class ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
# Start server on a random available port
server = ThreadedHTTPServer(("127.0.0.1", 0), RetryTestHandler)
port = server.server_address[1]
server_thread = threading.Thread(target=server.serve_forever)
server_thread.daemon = True
server_thread.start()
try:
# Build session with same retry config as _build_flags_session
# but mounted on http:// for local testing
adapter = HTTPAdapterWithSocketOptions(
max_retries=Retry(
total=2,
connect=2,
read=2,
backoff_factor=0.01, # Fast backoff for testing
status_forcelist=RETRY_STATUS_FORCELIST,
allowed_methods=["POST"],
),
)
session = requests.Session()
session.mount("http://", adapter)
response = session.post(
f"http://127.0.0.1:{port}/flags/?v=2",
json={"distinct_id": "user123"},
timeout=5,
)
# Should succeed on 3rd attempt
self.assertEqual(response.status_code, 200)
self.assertEqual(request_count, 3) # 1 initial + 2 retries
finally:
server.shutdown()
server.server_close()
def test_connection_errors_are_retried(self):
"""
Verify that connection errors (no server) trigger retries.
Binds a socket to get a guaranteed available port, then closes it
so connection attempts fail with ConnectionError.
"""
import socket
import time
from urllib3.util.retry import Retry
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
# Get an available port by binding then closing a socket
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.bind(("127.0.0.1", 0))
port = sock.getsockname()[1]
sock.close() # Port is now available but nothing is listening
adapter = HTTPAdapterWithSocketOptions(
max_retries=Retry(
total=2,
connect=2,
read=2,
backoff_factor=0.05, # Very fast for testing
status_forcelist=RETRY_STATUS_FORCELIST,
allowed_methods=["POST"],
),
)
session = requests.Session()
session.mount("http://", adapter)
start = time.time()
with self.assertRaises(requests.exceptions.ConnectionError):
session.post(
f"http://127.0.0.1:{port}/flags/?v=2",
json={"distinct_id": "user123"},
timeout=1,
)
elapsed = time.time() - start
# With 3 attempts and backoff, should take more than instant
# but less than timeout (confirms retries happened)
self.assertGreater(elapsed, 0.05, "Should have some delay from retries")
+4 -1
View File
@@ -1,3 +1,4 @@
import sys
import time
import unittest
from dataclasses import dataclass
@@ -122,7 +123,9 @@ class TestUtils(unittest.TestCase):
"bar": 2,
"baz": None,
}
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
# Pydantic V1 is not compatible with Python 3.14+
if sys.version_info < (3, 14):
assert utils.clean(ModelV1(foo=1, bar="2")) == {"foo": 1, "bar": "2"}
assert utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))) == {
"foo": {"foo": "1", "bar": 2, "baz": "3"}
}
+51 -3
View File
@@ -9,6 +9,7 @@ FlagValue = Union[bool, str]
BeforeSendCallback = Callable[[dict[str, Any]], Optional[dict[str, Any]]]
# Type alias for the send_feature_flags parameter
class SendFeatureFlagsOptions(TypedDict, total=False):
"""Options for sending feature flags with capture events.
@@ -22,9 +23,11 @@ class SendFeatureFlagsOptions(TypedDict, total=False):
Format: { group_type_name: { group_properties } }
"""
should_send: bool
only_evaluate_locally: Optional[bool]
person_properties: Optional[dict[str, Any]]
group_properties: Optional[dict[str, dict[str, Any]]]
flag_keys_filter: Optional[list[str]]
@dataclass(frozen=True)
@@ -110,7 +113,7 @@ class FeatureFlag:
variant=variant,
reason=None,
metadata=LegacyFlagMetadata(
payload=payload if payload else None,
payload=payload,
),
)
@@ -120,6 +123,7 @@ class FlagsResponse(TypedDict, total=False):
errorsWhileComputingFlags: bool
requestId: str
quotaLimit: Optional[List[str]]
evaluatedAt: Optional[int]
class FlagsAndPayloads(TypedDict, total=True):
@@ -178,7 +182,9 @@ class FeatureFlagResult:
key=key,
enabled=enabled,
variant=variant,
payload=json.loads(payload) if isinstance(payload, str) else payload,
payload=json.loads(payload)
if isinstance(payload, str) and payload
else payload,
reason=None,
)
@@ -219,6 +225,7 @@ class FeatureFlagResult:
payload=(
json.loads(details.metadata.payload)
if isinstance(details.metadata.payload, str)
and details.metadata.payload
else details.metadata.payload
),
reason=details.reason.description if details.reason else None,
@@ -296,5 +303,46 @@ def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
return {
key: value.metadata.payload
for key, value in response.get("flags", {}).items()
if isinstance(value, FeatureFlag) and value.enabled and value.metadata.payload
if isinstance(value, FeatureFlag)
and value.enabled
and value.metadata.payload is not None
}
class FeatureFlagError:
"""Error type constants for the $feature_flag_error property.
These values are sent in analytics events to track flag evaluation failures.
They should not be changed without considering impact on existing dashboards
and queries that filter on these values.
Error values:
ERRORS_WHILE_COMPUTING: Server returned errorsWhileComputingFlags=true
FLAG_MISSING: Requested flag not in API response
QUOTA_LIMITED: Rate/quota limit exceeded
TIMEOUT: Request timed out
CONNECTION_ERROR: Network connectivity issue
UNKNOWN_ERROR: Unexpected exceptions
For API errors with status codes, use the api_error() method which returns
a string like "api_error_500".
"""
ERRORS_WHILE_COMPUTING = "errors_while_computing_flags"
FLAG_MISSING = "flag_missing"
QUOTA_LIMITED = "quota_limited"
TIMEOUT = "timeout"
CONNECTION_ERROR = "connection_error"
UNKNOWN_ERROR = "unknown_error"
@staticmethod
def api_error(status: Union[int, str]) -> str:
"""Generate API error string with status code.
Args:
status: HTTP status code from the API error
Returns:
Error string like "api_error_500"
"""
return f"api_error_{status}"
+1 -1
View File
@@ -1,4 +1,4 @@
VERSION = "6.3.2"
VERSION = "7.5.0"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+11 -9
View File
@@ -10,18 +10,18 @@ authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.9"
requires-python = ">=3.10"
classifiers = [
"Development Status :: 5 - Production/Stable",
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"License :: OSI Approved :: MIT License",
"Programming Language :: Python",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
dependencies = [
"requests>=2.7,<3.0",
@@ -66,13 +66,13 @@ test = [
"pytest-timeout",
"pytest-asyncio",
"django",
"openai",
"anthropic",
"langgraph>=0.4.8",
"langchain-core>=0.3.65",
"langchain-community>=0.3.25",
"langchain-openai>=0.3.22",
"langchain-anthropic>=0.3.15",
"openai>=2.0",
"anthropic>=0.72",
"langgraph>=1.0",
"langchain-core>=1.0",
"langchain-community>=0.4",
"langchain-openai>=1.0",
"langchain-anthropic>=1.0",
"google-genai",
"pydantic",
"parameterized>=0.8.1",
@@ -96,3 +96,5 @@ version = { attr = "posthog.version.VERSION" }
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["posthog/test"]
norecursedirs = ["integration_tests"]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -14,7 +14,7 @@ long_description = """
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.9 or higher.
This package requires Python 3.10 or higher.
"""
# Minimal setup.py for backward compatibility
+1 -1
View File
@@ -47,7 +47,7 @@ long_description = """
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.9 or higher.
This package requires Python 3.10 or higher.
"""
# Minimal setup.py for backward compatibility
Generated
+2422 -2145
View File
File diff suppressed because it is too large Load Diff