Compare commits

..
410 Commits
Author SHA1 Message Date
github-actions[bot] 1e1e566fa5 chore: Release v7.9.5 2026-03-02 20:53:53 +00:00
830244bd40 feat: add semver targeting support to local flag evaluation (#447)
* feat: add semver targeting support to local flag evaluation

Implement 9 semver comparison operators (semver_eq, semver_neq, semver_gt, semver_gte, semver_lt, semver_lte, semver_tilde, semver_caret, semver_wildcard) for feature flag local evaluation. Uses regex-based parsing that matches the server-side sortableSemver behavior to handle v-prefix, whitespace, pre-release suffixes, and non-standard version formats.

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

* fix: guard against ReDoS in semver regex parsing

Add input length limit before regex search to prevent polynomial
backtracking on adversarial input (CodeQL py/polynomial-redos).

* fix: replace regex with string parsing to resolve ReDoS warning

Replace SEMVER_EXTRACT_RE regex with simple string splitting to
eliminate nested quantifiers that CodeQL flagged as polynomial-redos.

* refactor: inline semver operator tuple to match existing patterns

* add changeset

---------

Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-02 20:52:37 +00:00
github-actions[bot] 60001f1829 chore: Release v7.9.4 2026-02-25 15:28:29 +00:00
Carlos MarchalandGitHub a68a6a6d04 fix: revert manual release and add sampo changeset for ai_tokens_source (#445) 2026-02-25 16:25:53 +01:00
Andrew MaguireandGitHub 150e24ba6a feat(llma): add $ai_tokens_source property to detect token value overrides (#444)
* feat: add $ai_tokens_source property to detect token value overrides

When users pass token properties (e.g. $ai_input_tokens) via
posthog_properties, these override the SDK-computed values. This new
$ai_tokens_source property ("sdk" or "passthrough") lets us distinguish
whether token values came from the SDK or were externally injected,
which is critical for diagnosing cost calculation discrepancies.

* chore: bump version to 7.9.4

* chore: add changelog entry for 7.9.4

* chore: fix ruff formatting

* chore: remove unused pytest import
2026-02-25 13:38:53 +00:00
Michael BiancoandGitHub a8b5529baf fix: use $ip not $ip_addess (#356) 2026-02-20 07:46:13 +01:00
github-actions[bot] d45c04646e chore: Release v7.9.3 2026-02-18 22:20:09 +00:00
Rafael AudibertandGitHub 9f9553a420 Small fixes for python publishing (#441)
* fix: Avoid setting dynamic version

Version is now fixed because of sampo, so we can get rid of this

* feat: add changeset

* docs: Add new RELEASING section to README
2026-02-18 22:17:26 +00:00
github-actions[bot] 16bc87b646 chore: Release v7.9.2 2026-02-18 22:05:00 +00:00
Rafael AudibertandGitHub f1dc4d7391 chore: Migrate releases to sampo (#398)
* chore: Migrate releases to `sampo`

This is much closer to what we have in `posthog-js`, let's see if it's a good thing!

There's still a lot to do before deploying this:
- updating CI
- updating README with instructions

* Add sampo changeset

* chore: Update  to relase Python via Slack + sampo

* Update release.yml

* fix: Use pyproject.toml version as source of truth
2026-02-18 19:02:19 -03:00
Radu RaiceaandGitHub 23dae56d68 fix(ai): bind prompt reads to project token (#433)
* fix(ai): bind prompt reads to project token

* chore(release): bump version to 7.8.7
2026-02-17 16:58:59 +00:00
73bec043cf chore: release v7.9.0 (#434)
chore: bump version to 7.9.0

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 10:32:05 +01:00
AndersandGitHub 603ed376dd feat: Support device_id as bucketing identifier for local evaluation (#424)
* feat: Support device_id as bucketing identifier for local evaluation

Add support for `bucketing_identifier` field on feature flags to allow
using `device_id` instead of `distinct_id` for hashing/bucketing in
local evaluation.
2026-02-16 11:05:09 +01:00
AndersandGitHub bb0c7b4fa8 test(flags): make wrong-key load_feature_flags deterministic (#432) 2026-02-13 15:53:22 +01:00
Aleksander BłaszkiewiczandGitHub 499194e0c4 feat: limit max number of items in collection to scan (#430)
* feat: limit max number of items in collection to scan

* feat: changelog

* fix: format

* feat: test

* feat: replace entire collection instead of truncating
2026-02-11 14:59:11 +01:00
Aleksander BłaszkiewiczandGitHub ffb8e9b591 feat: further optimize code variables regex search (#429)
* feat: initial

* fix: ruff
2026-02-09 23:59:03 +01:00
Aleksander BłaszkiewiczandGitHub 7780ca8390 fix: long variables pattern matching (#428) 2026-02-09 17:45:23 +01:00
AndersandGitHub bca175214d fix: Retry on 408 and respect Retry-After header (#426)
* fix: Retry on 408 and respect Retry-After header

408 (Request Timeout) was incorrectly treated as a non-retryable client
error. Retry-After response headers were ignored during backoff. Replace
backoff library usage with a manual retry loop that honours Retry-After
when present and falls back to exponential backoff otherwise.

* fix: Parse HTTP-date Retry-After values

Retry-After can be seconds or an HTTP-date per RFC 7231. Fall back to
email.utils.parsedate_to_datetime when the numeric parse fails.

* fix: Don't retry on unclassifiable APIError status

When APIError.status is "N/A" (no HTTP status), treat it as
non-retryable to avoid unexpected retry loops on errors the SDK
cannot classify.

* test: Add retry delay tests for Retry-After and exponential backoff

Verify time.sleep is called with the Retry-After value when present,
uses exponential backoff (2^attempt) when absent, and that 408 is
retried.
2026-02-09 12:15:49 +00:00
Aleksander BłaszkiewiczandGitHub fe3a9bbf75 fix: openai image sanitization (#425) 2026-02-06 14:15:57 +01:00
b6e66330e5 fix: openAI input image sanitization (#384)
Co-authored-by: Aleksander Błaszkiewicz <kqmdjc8@gmail.com>
2026-02-06 13:53:02 +01:00
Gabriel GrinbergandGitHub 4f32fa4100 Fix feature flag 401 errors causing HTTP request storm (#422)
* Fix feature flag 401 errors causing HTTP request storm

Set feature_flags = [] on 401 error to prevent repeated requests.

* Clear flag_cache, group_type_mapping, cohorts on 401
2026-02-04 10:31:10 -05:00
Radu RaiceaandGitHub f5719f39da fix(llma): default prompts url (#423) 2026-02-04 15:10:00 +00:00
Radu RaiceaandGitHub d4f2d6dfb0 fix(llma): small fixes for prompt management (#420)
* fix(llma): small fixes for prompt management

* fix(llma): tests

* fix(llma): tests
2026-02-04 09:49:19 +02:00
José SequeiraandGitHub 72f448816c feat: SDK Compliance (#397)
* feat: SDK Compliance
2026-01-30 16:12:43 +01:00
Radu RaiceaandGitHub 4350389f93 feat(llma): add prompt management (#417)
* feat(llma): add prompt management

* chore(llma): bump version

* fix(llma): use SDK session with retry logic for prompt fetching

Use _get_session() from posthog/request.py instead of raw requests.get()
to benefit from the SDK's existing retry configuration on transient
network failures.
2026-01-30 08:43:04 -05:00
c32c78312f feat(llma): pass raw provider usage metadata for backend cost calculations (#411)
* feat: pass raw provider usage metadata for backend cost calculations

Add raw_usage field to TokenUsage type to capture raw provider usage metadata (OpenAI, Anthropic, Gemini). This enables the backend to extract modality-specific token counts (text vs image vs audio) for accurate cost calculations.

- Add raw_usage field to TokenUsage TypedDict
- Update all provider converters to capture raw usage:
  - OpenAI: capture response.usage and chunk usage
  - Anthropic: capture usage from message_start and message_delta events
  - Gemini: capture usage_metadata from responses and chunks
- Pass raw usage as $ai_usage property in PostHog events
- Update merge_usage_stats to handle raw_usage in both modes
- Add tests verifying $ai_usage is captured for all providers

Backend will extract provider-specific details and delete $ai_usage after processing to avoid bloating properties.

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

* fix: add serialize_raw_usage helper to ensure JSON serializability

Address PR review feedback from @andrewm4894:

1. **Serialization**: Add serialize_raw_usage() helper with fallback chain:
   - .model_dump() for Pydantic models (OpenAI/Anthropic)
   - .to_dict() for protobuf-like objects
   - vars() for simple objects
   - str() as last resort
   This ensures we never pass unserializable objects to PostHog client.

2. **Data loss prevention**: Change from replacing to merging raw_usage in
   incremental mode. For Anthropic streaming, message_start has input token
   details and message_delta has output token details - merging preserves
   both instead of losing input data.

3. **Test coverage**: Enhanced tests to verify:
   - JSON serializability with json.dumps()
   - Expected structure of raw_usage dicts
   - Coverage for both non-streaming and streaming modes
   - Fixed Gemini test mocks to return proper dicts from model_dump()

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

* refactor: move raw_usage serialization from utils to converters

Address PR feedback from @andrewm4894 - serialize in converters, not utils.

**Problem:**
Utils was receiving raw Pydantic/protobuf objects and serializing them,
which meant provider-specific knowledge leaked into generic code.

**Solution:**
Move serialization into converters where provider context exists:

Converters (NEW):
- OpenAI: serialize_raw_usage(response.usage) → dict
- Anthropic: serialize_raw_usage(event.usage) → dict
- Gemini: serialize_raw_usage(metadata) → dict

Utils (SIMPLIFIED):
- Just passes dicts through, no serialization needed
- Merge operations work with dicts only

**Benefits:**
1. Type correctness: raw_usage is always Dict[str, Any]
2. Separation of concerns: converters handle provider formats
3. Fail fast: serialization errors in converters with context
4. Cleaner abstraction: utils doesn't know about Pydantic/protobuf

**Flow:**
Provider object → Converter serializes → dict → Utils → PostHog

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

* fix: add type annotation for current_raw to satisfy mypy

Fix mypy error: "Need type annotation for 'current_raw'"

Extract value first, then apply explicit type annotation with ternary
conditional to satisfy mypy's type checker.

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

---------

Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-28 11:51:02 +02:00
Andrew MaguireandGitHub 1875b712d2 feat(ai): add OpenAI Agents SDK integration (#408)
* feat(ai): add OpenAI Agents SDK integration

Add PostHogTracingProcessor that implements the OpenAI Agents SDK
TracingProcessor interface to capture agent traces in PostHog.

- Maps GenerationSpanData to $ai_generation events
- Maps FunctionSpanData, AgentSpanData, HandoffSpanData, GuardrailSpanData
  to $ai_span events with appropriate types
- Supports privacy mode, groups, and custom properties
- Includes instrument() helper for one-liner setup
- 22 unit tests covering all span types

* feat(openai-agents): add $ai_group_id support for linking conversation traces

- Capture group_id from trace and include as $ai_group_id on all events
- Add _get_group_id() helper to retrieve group_id from trace metadata
- Pass group_id through all span handlers (generation, function, agent, handoff, guardrail, response, custom, audio, mcp, generic)
- Enables linking multiple traces in the same conversation thread

* feat(openai-agents): add enhanced span properties

- Add $ai_total_tokens to generation and response spans (required by PostHog cost reporting)
- Add $ai_error_type for cross-provider error categorization (model_behavior_error, user_error, input_guardrail_triggered, output_guardrail_triggered, max_turns_exceeded)
- Add $ai_output_choices to response spans for output content capture
- Add audio pass-through properties for voice spans:
  - first_content_at (time to first audio byte)
  - audio_input_format / audio_output_format
  - model_config
  - $ai_input for TTS text input
- Add comprehensive tests for all new properties

* Add $ai_framework property and standardize $ai_provider for OpenAI Agents

- Add $ai_framework="openai-agents" to all events for framework identification
- Standardize $ai_provider="openai" on all events (previously some used "openai_agents")
- Follows pattern from posthog-js where $ai_provider is the underlying LLM provider

* chore: bump version to 7.7.0 for OpenAI Agents SDK integration

* fix: add openai_agents package to setuptools config

Without this, the module is not included in the distribution
and users get an ImportError after pip install.

* fix: correct indentation in on_trace_start properties dict

* fix: prevent unbounded growth of span/trace tracking dicts

Add max entry limit and eviction for _span_start_times and
_trace_metadata dicts. If on_span_end or on_trace_end is never
called (e.g., due to an SDK exception), these dicts could grow
indefinitely in long-running processes.

* fix: resolve distinct_id from trace metadata in on_span_end

Previously on_span_end always called _get_distinct_id(None), which
meant callable distinct_id resolvers never received the trace object
for spans. Now the resolved distinct_id is stored at trace start and
looked up by trace_id during span end.

* refactor: extract _base_properties helper to reduce duplication

All span handlers repeated the same 6 base fields (trace_id, span_id,
parent_id, provider, framework, latency) plus the group_id conditional.
Extract into a shared helper to reduce ~100 lines of boilerplate.

* test: add missing edge case tests for openai agents processor

- test_generation_span_with_no_usage: zero tokens when usage is None
- test_generation_span_with_partial_usage: only input_tokens present
- test_error_type_categorization_by_type_field_only: type field without
  matching message content
- test_distinct_id_resolved_from_trace_for_spans: callable resolver
  uses trace context for span events
- test_eviction_of_stale_entries: memory leak prevention works

* fix: handle non-dict error_info in span error parsing

If span.error is a string instead of a dict, calling .get() would
raise AttributeError. Now falls back to str() for non-dict errors.

* style: apply ruff formatting

* style: replace lambda assignments with def (ruff E731)

* fix: restore full CHANGELOG.md history

The rebase conflict resolution accidentally truncated the changelog
to only the most recent entries. Restored all historical entries.

* fix: preserve personless mode for trace-id fallback distinct IDs

When no distinct_id is provided, _get_distinct_id falls back to
trace_id or "unknown". Since these are non-None strings, the
$process_person_profile=False check in _capture_event never fired,
creating unwanted person profiles keyed by trace IDs.

Track whether the user explicitly provided a distinct_id and use
that flag to control personless mode, matching the pattern used
by the langchain and openai integrations.

* fix: restore changelog history and fix personless mode edge cases

Two fixes from bot review:

1. CHANGELOG.md was accidentally truncated to 38 lines during rebase
   conflict resolution. Restored all 767 lines of history.

2. Personless mode now follows the same pattern as langchain/openai
   integrations: _get_distinct_id returns None when no user-provided
   ID is available, and callers set $process_person_profile=False
   before falling back to trace_id. This covers the edge case where
   a callable distinct_id returns None.

* fix: handle None token counts in generation span

Guard against input_tokens or output_tokens being None when computing
$ai_total_tokens to avoid TypeError.

* fix: check error_type_raw for all error categories

Check both error_type_raw and error_message for guardrail and
max_turns errors, consistent with how ModelBehaviorError and
UserError are already checked.

* fix: add type hints to instrument() function

* refactor: rename _safe_json to _ensure_serializable for clarity

The function validates JSON serializability and falls back to str(),
not serializes. Rename and update docstring to make the contract clear.

* refactor: emit $ai_trace at trace end instead of start

Move the $ai_trace event from on_trace_start to on_trace_end to
capture full metadata including latency, matching the LangChain
integration approach. on_trace_start now only stores metadata for
use by spans.

* style: fix ruff formatting

* fix: add TYPE_CHECKING imports for type hints in instrument()
2026-01-27 21:15:16 +00:00
AndersandGitHub 661a0ec8ba feat: add device_id to flags request payload (#407)
* feat: add device_id to flags request payload

Add device_id parameter to all feature flag methods, similar to how
distinct_id is handled. The device_id is included in the flags request
payload sent to the server.

- Add device_id parameter to Client methods and module-level functions
- Add context support via set_context_device_id() for automatic fallback
- Add tests for explicit device_id and context-based device_id
- Bump version to 7.6.0
2026-01-19 17:22:34 +01:00
Paul D'AmbraandGitHub d3609c2975 Fix link formatting in CHANGELOG.md (#406)
Updated link formatting for clarity in changelog.
2026-01-08 19:59:24 -03:00
Paul D'AmbraandGitHub 92d810e6b6 chore: check for syntax warnings (#404) 2026-01-08 21:26:22 +00:00
f9c2959fd0 fix: avoid return from finally block to fix Python 3.14 SyntaxWarning (#361)
* fix: Avoid return from finally: block

This fixes a SyntaxWarning on Python 3.14.

```
❯ uvx --no-cache --python 3.14.0 --with posthog==6.7.11 python -c "import posthog"
Installed 11 packages in 5ms
.../lib/python3.14/site-packages/posthog/consumer.py:92: SyntaxWarning: 'return' in a 'finally' block
  return success
````

* add versioning info

---------

Co-authored-by: Paul D'Ambra <paul.dambra@gmail.com>
2026-01-08 21:18:11 +00:00
Tom PiccirelloandGitHub 2b3eb6782b feat: add CodeQL Advanced workflow (#405)
This is required to be able to run CodeQL against external contributors' PRs.
2026-01-07 22:17:54 -03:00
Paul D'AmbraGitHubClaudegreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
50f675b849 chore: add SDK version support table to README (#402)
* docs: add Python version support table to README

Add a table documenting which SDK versions introduced or dropped
support for different Python versions, based on CHANGELOG.md entries.

* fix: correct Python 3.14 support version to 7.4.3

* Apply suggestions from code review

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

---------

Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2026-01-07 19:32:43 -03:00
Paul D'AmbraandGitHub 7dd6229530 chore: add a test to describe upload behaviour when there are errors (#403)
* chore: add a test to describe upload behaviour when there are errors

* refactor the test file

* add typehints to the test file
2026-01-07 22:16:15 +00:00
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
Radu RaiceaandGitHub 09b9b5dc88 bug(llmo): fix anthropic tool call response (#297)
* bug(llmo): fix anthropic's tool call response

* bug(llmo): fix tool calls response handling for anthropic

* bug(llmo): run formatter

* bug(llmo): bump version

* bug(llmo): add date to changelog
2025-07-31 17:13:44 -04:00
Georgiy TarasovandGitHub 5a52af66a9 fix(ai): capture tool calls in reasoning models (#292)
* fix: capture tool calls in reasoning models

* fix: check for empty tool calls
2025-07-23 11:51:58 +02:00
Dylan MartinandGitHub 722c88701b feat(flags): make the sendFeatureFlags parameter more declarative and ergonomic (#283) 2025-07-22 15:20:54 -07:00
Radu RaiceaandGitHub 6ab2856f8d feat(llmo): Use default PH client for LangChain (#293)
* feat(llmo): Use default PH client for langchain

* chore: Run formatter

* feat: Test the CallbackHandler without any PH client

* chore: Run formatter
2025-07-22 14:09:47 -07:00
7a8b09123c feat(llmo): Make it optional to pass posthog client (#291)
Co-authored-by: Peter Kirkham <peter@posthog.com>
2025-07-22 06:53:47 +00:00
David NewellandGitHub da09639428 fix: capture django processed exceptions (#287) 2025-07-16 21:54:01 +02:00
Vincent (Wen Yu) GeandGitHub 6a271026d1 Init reference doc generation (#280) 2025-07-15 13:53:13 -04:00
Phil HaackandGitHub 6d9247960f fix: Ignore new flag filter type in local evaluation (#285) 2025-07-11 16:33:23 +00:00
Dylan MartinandGitHub c4e09cdd40 feat(flags): decouple local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller (#282) 2025-07-10 08:03:37 -07:00
Oliver BrowneandGitHub c61236b26a fix: add middleware setting for custom client (#281)
* Add middleware setting for custom client

* mypy

* comment
2025-07-09 17:06:20 +03:00
Dylan MartinandGitHub b965332698 feat(flags): add a flag_fallback_cache that tracks feature flag evaluation results and uses them as fallback values whenever the /flags API isn't available (#275) 2025-07-07 07:13:35 +00:00
Oliver BrowneandGitHub 4739945a82 fix: default send_feature_flags false for capture_exception (#278)
* default send_feature_flags false

* bump version
2025-07-02 22:21:25 +03:00
Oliver BrowneandGitHub 50ab10c858 fix(err): permit disabling person processing (#277)
* whoops

* bump version
2025-07-01 15:26:57 +03:00
Oliver BrowneGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>David Newell
37bd30194e feat: prep for 6.0.0, bunch of breaking changes (#273)
* alright

* fix exports

* Update posthog/test/test_before_send.py

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

* Update posthog/__init__.py

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

* Update posthog/__init__.py

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

* fix tests after rebase

* update examples

* update mypy baseline

* Revert "update mypy baseline"

This reverts commit da395dd7cc075d1f5b1c03d748544e4abcd75bb9.

* try again

* whatever

* request user

* fix middleware

* getattr is_authenticated

* allow using custom client for exception capture

* update comments

* fix circular import

* type arguments, use TypedDict

* fix setup

* mypy

* whoops

* ok

* further mypy

* mypy sync

* docs and fixes

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: David Newell <david@posthog.com>
2025-06-27 14:57:02 +03:00
Lucas RicoyandGitHub b41dc8568e docs: update release details on readme (#272) 2025-06-21 00:38:18 +01:00
Lucas RicoyGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
f0e1cdf870 feat: bump version to 5.4.0 with session_id on page method (#271)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-06-21 00:18:57 +01:00
Lucas RicoyandGitHub e23ca94296 chore: ensure session_id context works with page method (#269) 2025-06-20 23:49:53 +01:00
Oliver BrowneandGitHub 5a7f324a61 fix(err): always safe_str exception values (#267)
* always safe_str

* bump version
2025-06-19 14:38:17 +00:00
Oliver BrowneandGitHub e13c428ff6 feat(err): construct full trace if no traceback available (#266)
* construct full trace if no traceback available

* delete asserts

* bump version
2025-06-19 16:47:42 +03:00
Oliver BrowneandGitHub 77190c23e1 document prep_local (#265) 2025-06-18 19:50:32 +01:00
Oliver BrowneandGitHub b7753392f7 feat: session and identity integrate with context now (#264)
* session and identity in context

* bump version

* make django integration use context distinct id and session functions

* don't use self

* fix comments

* fix middleware tests

* clarify fresh and distinct id's

* Fix exported modules, add makefile command to test changes locally

* tiny fix
2025-06-18 19:27:27 +03:00
250bd424d0 feat(err): add django middleware (#263)
* fix exactly-once capture

* add middleware

* fix typing

* ignore unreacable

* Revert "ignore unreacable"

This reverts commit 0458f0efa6c8e52ecfb1eeb41c57a76d84578164.

* add unreachable ignore

* move unreachable ignore

* switch to use request.headers

* clarify comment

* Update posthog/integrations/django.py

Co-authored-by: David Newell <d.newell1@outlook.com>

* explain typle

* fix comment

* explain that tags become properties

* fix tests

---------

Co-authored-by: David Newell <d.newell1@outlook.com>
2025-06-17 17:54:32 +03:00
Oliver BrowneandGitHub 579cc56787 fix: delete sentry integration (#262)
* delete relevant files

* bump uv lock, bump major version as deprecation

* README.md

* try mypy sync

* Revert "try mypy sync"

This reverts commit e1b98b26e59132e52eff6389afd42cb1b07a6a0b.

* try looking at the github action
2025-06-16 18:38:53 +03:00
Oliver BrowneandGitHub 3778eaef7b fix(err): just check if the passed exceptions is a BaseException (#261)
* just check if it's an exception first

* version bump
2025-06-13 19:36:00 +00:00
Georgiy TarasovandGitHub 52df246a3e feat(ai): langchain cached and reasoning tokens (#258)
* fix: reasoning and cached tokens

* test: new flows

* fix: missing field

* chore: bump

* fix: make sure we send write/read/reasoning tokens
2025-06-13 15:02:06 +02:00
Phil HaackandGitHub f1f9ecf7a4 Add flags project board workflow (#259) 2025-06-12 16:33:42 +00:00
Oliver BrowneandGitHub 9db1b7e9f3 fix: change scoped export, add capturing param (#257)
* change export, add capturing param

* capturing -> capture_exceptions
2025-06-12 10:29:32 +01:00
Peter KirkhamandGitHub 01751d1205 feat: add support for parse via responses (#256) 2025-06-11 05:39:24 +01:00
David NewellandGitHub 4426dd9d27 remove 'import posthog' (#255) 2025-06-10 11:05:22 +01:00
David NewellandGitHub bf0d7efbfe fix: makefile import (#254) 2025-06-09 19:20:31 +01:00
David NewellandGitHub f17ebfa12b feat: more django context (#252) 2025-06-09 14:32:42 +01:00
Paul D'AmbraandGitHub 800527da43 feat: add before_send callback (#249) 2025-06-09 13:56:46 +01:00
Paul D'AmbraandGitHub 0d29fb7be3 fix changelog to match pypi (#253) 2025-06-09 12:09:15 +01:00
Paul D'AmbraandGitHub 24d89806cb chore: more fiddling to get release working (#251)
* chore: more fiddling to get release working

* fix

* fix
2025-06-09 11:50:30 +01:00
Paul D'AmbraandGitHub a2105f6e95 chore: use uv run when releasing (#250) 2025-06-09 10:11:12 +00:00
Paul D'AmbraandGitHub 3171193d75 fix: makefile for posthog_analytics release (#248) 2025-06-09 10:51:04 +01:00
Paul D'AmbraandGitHub 1db6e45258 chore: pyproject and CI update (#247) 2025-06-07 14:08:47 +03:00
Dylan MartinandGitHub 1daa8a8053 chore(flags): roll everyone onto /flags (#246) 2025-06-06 16:36:10 -07:00
Oliver BrowneandGitHub 5d58a53b36 fix: lets try again (#244)
* maybe

* bump version
2025-06-06 16:25:47 +02:00
7af8e886ee fix: python release attempt 3 (#242)
* fix: maybe the classifier is deprecated

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* fix

* ruff

* bump version for release

---------

Co-authored-by: Oliver Browne <oliver@posthog.com>
2025-06-06 15:16:31 +03:00
Oliver BrowneandGitHub 90d3fca27d fix: bump for release (#243)
* bump for release

* changelog

* changelog
2025-06-06 11:59:58 +00:00
Oliver BrowneGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>David Newell
243b98df11 feat(err): add context manager and tag functions (#239)
* add context maanager and tag functions

* Update posthog/scopes.py

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

* ran black

* black locally disagrees with ci. python is awful

* bleh

* isort

* fix mypyp thing

* Revert "fix mypyp thing"

This reverts commit 21ad8733610967cad0bbf8451508ca1189be543c.

* update baseline

* lets try again

* alright lets try again

* revert to baseline

* try ignoring it i guess

* black

* try supporting async too

* black

* mypy

* formatting

* fix changelog

* fix comment

* we only support python 3.9+

* change decorator name

* fix example

* isort

* auto-capture in with blocks

* fix tests

* black

* inherit tags by default

* assert swap

* add tags to all events

* rm comment

* Update example.py

Co-authored-by: David Newell <d.newell1@outlook.com>

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: David Newell <d.newell1@outlook.com>
2025-06-06 14:49:15 +03:00
Paul D'AmbraandGitHub 7ab2080309 fix: release action failed (#241) 2025-06-05 15:50:27 +01:00
Paul D'AmbraandGitHub 23e1d8e2a3 fix: opinionated setup and clean fn fix (#240) 2025-06-05 14:25:56 +01:00
e2d8200cc6 pin actions versions (#210)
* pin actions versions
---------

Co-authored-by: Paweł Szczur <orian@users.noreply.github.com>
2025-05-27 08:25:50 +00:00
Paweł SzczurandGitHub da69b68f7d fix: feature flag request use geoip_disable (#235)
* make feature flag request use geoip_disable
2025-05-27 10:18:55 +02:00
Peter KirkhamandGitHub 57c3cba200 feat: support gemini (#237) 2025-05-24 00:23:34 +01:00
Peter KirkhamandGitHub 9f4ef4f24f feat: composition over inheritance (#236) 2025-05-23 01:34:04 +01:00
Rafael AudibertandGitHub 7aea6b72d3 feat: Remove deprecated monotonic lib (#231) 2025-04-29 11:14:59 -03:00
Rafael AudibertandGitHub 7bb7c90a49 chore: Release automatically when changed version.py (#232) 2025-04-29 11:14:50 -03:00
Phil HaackGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
c1f668e8bb feat: Add new FeatureFlagResult class and tests (#227)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-04-24 10:09:29 -07:00
Phil HaackandGitHub a1b81ee3d9 chore: Add parameters to bin/test (#228) 2025-04-23 13:53:28 -07:00
Phil HaackGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
a6fb39902d chore: Make condition_index optional. Also added some scripts for local dev. (#223)
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-04-18 11:59:49 -07:00
Dylan MartinandGitHub a1583f6627 chore(flags): latest version of posthog-python now uses /flags by default, except for a few exceptions (#222)
* wahahaha

* fix tests

* whoops don't forget to roll it out
2025-04-15 17:13:26 -04:00
Dylan MartinandGitHub dfa7f70a04 fix(flags): pass in the correct hashes. (#221)
* shoot

* cut customer token

* bump version

* dump posthog api from excluded
2025-04-15 16:09:47 -04:00
Dylan MartinandGitHub d00d69e448 ubunut 20-04 is EOL (#220) 2025-04-15 13:44:19 -04:00
Dylan MartinandGitHub a833955ee0 chore(flags): roll 10% of posthog-python /decide traffic (and all of PostHog's personal SDK traffic) to /flags (#218)
* init

* moved the constants

* formatting

* mypy

* fr do some damn formatting

* don't exclude posthog

* make it a set

* differentiate

* fix AI test

* use the same type everywhere

* ready to release
2025-04-15 12:57:49 -04:00
58fbe05cb0 test(llm-observability): Account for LangGraph 0.3.29 changes (#219)
* test(llm-observability): Account for LangGraph 0.3.29 changes

* formatting

---------

Co-authored-by: dylan <dylan@posthog.com>
2025-04-15 12:36:15 -04:00
David NewellandGitHub 7a6e185902 fix: add field to proxy client setup (#217) 2025-04-11 13:57:26 +01:00
David NewellandGitHub e9c72e7f8c chore: update license (#213) 2025-04-10 15:30:54 +01:00
David NewellandGitHub 51380ac207 feat: log captured exceptions (#215) 2025-04-10 12:14:29 +01:00
David NewellandGitHub 53ed80366b fix failing ai test (#216) 2025-04-10 12:01:25 +01:00
Frank HamandandGitHub 18729e33b8 bump version (#209) 2025-03-26 16:10:26 +00:00
Frank HamandandGitHub 334394bed2 update automatic retries to include read errors (#208)
in e.g. lambda environments the connection can time out between invocations,
this comes through to the client as a "RemoteDisconnected" error, which it
turns out urllib classifies as a "read" error not a connection error (as
it's possible to get this error after data has been sent)
2025-03-26 16:02:08 +00:00
Phil HaackandGitHub 14a2f80c6d feat(flags): Add more details such as version, id, and reason to $feature_flag_called events (#207)
* Flesh out Decide response types

* Ensure we normalize get_decide

In a back compat manner.

* Populate feature_flags_by_key when setting feature_flags

Since `self.feature_flags_by_key` is derived from `self.feature_flags`, and we often set the latter in unit tests, but forget to set the former, our tests can be wonky.

This ensures that when we set `self.feature_flags`, we always set `self. feature_flags_by_key`

* Annotate types

* Lookup local flag by key

Fixes #121

* Refactor local flag evaluation into its own method

* Include extra details in `$feature_flag_called` events

* Fix up type annotations, tests, and formatting

* Update lib to decide v4

* Bump version and add changelog
2025-03-25 16:27:24 -07:00
RossandGitHub 2779ad194c feat: Support serializing dataclasses (#206)
* Support serializing dataclasses

* Update version

* Run black

* Fix for Python 3.9
2025-03-17 14:28:33 +00:00
Peter KirkhamandGitHub 5a4167d5ce feat: add support for responses api (#205)
* feat: add suppoort for responses api

* fix: test

* fix: black

* fix: test - hopefully

* fix: test - hopefully #2

* fix: test - hopefully #3

* fix: test - hopefully #4

* fix: greptaile catch

* fix: mypy is not my friend

* fix: isort usort weallsort

* fix: noredef

* fix: mypy baseline

* fix: mypy

* fix: mypy
2025-03-14 05:16:52 +00:00
David NewellandGitHub 332a6fffb6 fix: distro requirement for analytics package (#204) 2025-03-12 14:12:15 +00:00
Peter KirkhamandGitHub 28a7d351ba fix: azure open ai delta check (#203) 2025-03-10 21:35:36 +00:00
Peter KirkhamandGitHub 8331af7a42 feat: cached tokens (#202)
* feat: cached tokens

* feat: add tool support

* chore: local test

* chore: isort black

* chore: bump v

* chore: remove import

* fix: types

* fix: black

* fix: mypy unpacking of None

* chore: mypy baseline

* feat: mypy fix

* fix: did things and stuff

* fix: mypy yourpy whos py?

* fix: things can be None

* fix: move test

* fix remove exampels from package

* fix: losing my py
2025-03-06 22:37:21 +00:00
Dylan MartinandGitHub f4c99714c3 chore(flags): improved some logs for quota limiting (#197)
* haha okay

* tests workin

* format

* use case-sensitive comparisons

* omg LOL

* fix tests

* jeez

* this will probably work

* now do local eval

* okay

* yo

* formatting

* fix import order

* type check

* ai yi yi

* code review

* format

* do it

* merge conflict UGH

* black formatting

* bump version

* correct changelog
2025-03-03 14:00:52 -05:00
Peter KirkhamGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
7dc4cbb16b feat: azure export w/ async (#200)
* feat: azure export w/ async

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
2025-02-28 20:40:09 +00:00
Michael MatlokaandGitHub 4cda646f03 feat(llm-observability): $ai_tools capture in Langchain (#199) 2025-02-27 17:50:20 +00:00
Paul D'AmbraandGitHub ea4e7fa16d feat: add some platform info to events (#198) 2025-02-26 12:26:17 +00:00
Peter KirkhamandGitHub 57a3e7470f fix: async client (#196) 2025-02-23 13:10:43 +00:00
Dylan MartinandGitHub 5e0f9e35c1 feat(feature-flags): support quota limiting for feature flags (#195)
* haha okay

* tests workin

* format

* use case-sensitive comparisons

* omg LOL

* fix tests

* jeez

* this will probably work

* now do local eval

* okay

* yo

* formatting

* fix import order

* type check

* ai yi yi

* code review

* format
2025-02-21 15:45:51 -05:00
Dylan MartinandGitHub 337f7da7c5 fix(flags): remove lower() when evaluating feature flag payloads – these payloads are case-sensitive! (#191)
* haha okay

* tests workin

* format

* use case-sensitive comparisons

* omg LOL

* fix tests

* jeez
2025-02-19 19:51:40 -05:00
Peter KirkhamandGitHub 31652d5ec3 fix: support usage as part of generation (#192) 2025-02-18 00:17:52 +00:00
HavenGitHubgreptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>Manoel Aranda Neto
6764c786a4 feat(flags): Add method for fetching decrypted remote config flag payload (#180)
* feat(flags): Add method for fetching decrypted remote config flag payload

* tweak

* tweak

* tweak

* Update posthog/__init__.py

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

* tweak

* get example script working

* format

* sort import

* tweak

* bump minor version

* Update posthog/version.py

Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com>

* Use flag key instead of id

* tweak

* tweak

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
Co-authored-by: Manoel Aranda Neto <5731772+marandaneto@users.noreply.github.com>
2025-02-13 14:21:05 -08:00
Frank HamandandGitHub 1b57a96509 automatically retry connection errors (#190)
* automatically retry connection errors

from the docs for max_retries: this applies only to failed DNS lookups,
 socket connections and connection timeouts

* run tests on multiple python versions

* update freezegun
2025-02-12 12:29:01 +00:00
Phil HaackandGitHub 38683e8550 Add mypy to CI (#189) 2025-02-11 09:50:45 -08:00
Phil HaackandGitHub a5c8f62a63 Use casefold to compare strings case insensitively (#184) 2025-02-11 08:06:20 -08:00
Rafael AudibertandGitHub e480b88dce fix: Move code under mypy type (#188)
* fix: Move code under mypy type

This is incorrect, we should've added these slightly lower in the method definition to avoid mypy from breaking

* feat: Bump to 3.12.1
2025-02-11 12:01:08 -03:00
Phil HaackandGitHub 3ff2a8599d Remove the usage of is_simple_flag (#186) 2025-02-10 18:52:54 -08:00
Phil HaackandGitHub a3cf4ad5fb Stop capturing all feature flags on $feature_flag_called event. (#181) 2025-02-10 17:39:03 -08:00
Peter KirkhamandGitHub cec532f241 feat: add beta parse method support (#185) 2025-02-11 00:43:36 +00:00
Phil HaackandGitHub 415508087f Deprecate the context argument (#182) 2025-02-10 15:26:29 -08:00
Phil HaackandGitHub 994003fc42 Allow specifying the flag in the example script (#157)
* Allow specifying the flag in the example script

* Reformat

* Run isort
2025-02-07 09:28:00 +09:00
Phil HaackandGitHub 319b3807f3 Move accessing variants outside of loop (#175)
* Move accessing variants outside of loop

`flag_variants` doesn't depend on condition so it doesn't make sense to declare it in the loop.

* Fix assertion

* Remove incorrect comment

Comment seems superfluous anyways.

* Break out of the loop when the key is found

The purpose of the loop is to loop through the flag keys and evaluate the one where `flag["key"] == key`. Once that key is found, there's no need to continue the loop.

* Complete the test

Looks like the test was missing an assert.

* Precompute valid variant keys outside loop
2025-02-07 09:24:17 +09:00
Peter KirkhamandGitHub 5e7314f89d fix: langchain tool parent add (#179) 2025-02-05 19:03:14 +00:00
8f43bbc613 feat(llm-observability): LangChain spans (#176)
* feat: refactor to dataclasses

* feat: spans

* test: fix part 1

* test: fix part 2

* test: fix part 3

* test: fix part n

* test: add langgraph agent test

* chore: bump and linters

* chore: bump

* fix: correctly capture a parent id when a custom trace_id is set

* test: multiple spans parent_ids

* fix: exception serialization

* refactor: ai_trace_name -> ai_span_name and ai_generation_id -> ai_span_id

* fix: logs typos

* Add minor breaking change note to changelog

* fix: naming

---------

Co-authored-by: Michael Matloka <michael@matloka.com>
2025-01-28 14:23:22 +01:00
Georgiy TarasovandGitHub eb07aafaa3 fix: serialize pydantic models (#177) 2025-01-27 17:38:49 +00:00
Peter KirkhamandGitHub 0f8b10bb09 feat(ai): add error handling to python ai sdk (#174) 2025-01-24 21:09:49 +00:00
Georgiy TarasovandGitHub 45dc933b9c fix(llm-observability): parallel traces (#172)
* fix: parallel traces

* fix: linters

* chore: bump

* fix: better naming for clarity
2025-01-23 17:27:46 +01:00
Michael Matloka 2835af49cb fix: Actually fix LangChain callback in posthoganalytics 2025-01-22 16:27:36 +01:00
Michael MatlokaandGitHub 54506e5a7c fix: Account for import posthog in posthoganalytics release (#171) 2025-01-22 13:50:39 +00:00
Peter KirkhamandGitHub bcf5b27083 chore: bump (#170) 2025-01-21 23:33:47 +00:00
Michael MatlokaandGitHub 0b6ff2e8d3 feat(llm-observability): LangChain tracing, with LangGraph tests (#169) 2025-01-21 23:18:55 +00:00
80f0b3e52e fix(llm-observability): capture system prompt for anthropic (#167)
Co-authored-by: Peter Kirkham <peter@posthog.com>
2025-01-17 21:04:37 +00:00
d1e22188ec Feat: Add Anthropic to Python SDK (#165)
Co-authored-by: Georgiy Tarasov <gtarasov.work@gmail.com>
2025-01-17 20:33:48 +00:00
Georgiy TarasovandGitHub 9b423495ed fix(llm-observability): flatten langchain's additional_kwargs (#166)
* fix: flatten additional_kwargs

* fix: remove print
2025-01-17 17:59:38 +01:00
Peter KirkhamandGitHub 7870ccd3d8 feat: privacy_mode (#164) 2025-01-15 01:28:52 +00:00
Georgiy TarasovandGitHub 190c628c7a feat(llm-observability): add new packages for posthoganalytics (#163) 2025-01-14 10:50:46 +01:00
Georgiy TarasovandGitHub 78ab0ca8b5 fix(llm-observability): include the ai packages (#162)
* fix: setuptools

* fix: include packages
2025-01-14 10:27:05 +01:00
Peter KirkhamandGitHub c5bfc1377a fix: update to export module (#161) 2025-01-14 01:25:00 +00:00
Peter KirkhamandGitHub 6b1c0dc313 feat: Embeddings + Personless events + Destructure property JSON (#160) 2025-01-14 00:35:09 +00:00
Georgiy TarasovandGitHub e51b883e7b feat(llm-observability): add langchain integration (#159)
* feat(ai): LangChain integration v0.1

* test: langchain integration tests

* test: langchain-openai for v2 and v3

* chore: reorganize imports

* fix: ci

* fix: set python on ci to 3.9

* fix: upgrade ci for python 3.9

* fix: fallback for distinct_id

* fix: personless events for omitted distinct_ids

* fix: review comments

* feat: base url retrieval
2025-01-13 18:40:02 +01:00
66101c92bf Feat: Add llm observability to python sdk (#158)
Co-authored-by: Michael Matloka <michael@matloka.com>
2025-01-11 01:34:27 +00:00
Sibin M SandGitHub 05932b3f13 [FEATURE]Add distinct_id to group_identify (#155)
* [FEATURE]Add distinct_id to group_identify

* [TESTS]Updated test cases for adding distinct_id to group_identify

* [LINT-FIX]client.py and test_client.py

* [CHORE]Verion bump and changelog update
2025-01-03 16:00:35 -05:00
Dylan MartinandGitHub 50c13563b2 fix: CI (#156)
* test CI

* heck it, upgrade python

* okay don't do anything silly with the cache hits i guess

* more CI upgrades :crossedfingers

* upgrade all CI to latest versions, then

* jk this is how python works

* whackamole

* what even

* yeesh

* this can't be it

* if this breaks ill kms

* dark magic dark MAGIC

* im giving up on my dreams
2025-01-03 15:50:21 -05:00
Dylan MartinandGitHub dca4af66ae Update CODEOWNERS (#154) 2025-01-02 12:52:51 -05:00
Dylan MartinandGitHub 9e1bb8c58a fix(flags): bump the version (#148) 2024-11-27 17:15:38 -05:00
fb57de2e12 fix(flags): correctly emit feature flag events with the FF response on get_feature_flag_payload calls (#143)
* this is the fix, needs tests

* fix test

* tests

* yeah

* please work

* ran the formatter

* code review feedback

* how'd this get here

* bump version add changelog

* Update CHANGELOG.md

Co-authored-by: David Newell <d.newell1@outlook.com>

---------

Co-authored-by: David Newell <d.newell1@outlook.com>
2024-11-25 14:51:06 -05:00
db565bc0fd fix(err): fix distinct_id, set personless and use a uuid (#144)
Co-authored-by: David Newell <david@posthog.com>
2024-11-25 12:09:57 +00:00
David NewellandGitHub 8ae3f2b623 chore: add type to stack (#142) 2024-11-19 12:46:52 +00:00
David NewellandGitHub 39f72a0070 chore: add lang to frames (#139) 2024-10-24 16:18:02 +01:00
David NewellandGitHub ee0305993d feat: add super properties (#138) 2024-10-03 17:07:47 +01:00
28c4802d9b Remove deprecated datetime.utcnow() in favour of datetime.now(tz=tzutc()) (#124)
Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2024-09-24 11:09:15 +01:00
Neil KakkarandGitHub 67a343f242 fix(errors): Make sure project root exists to judge in app frames (#136)
* fix(errors): Make sure project root exists to judge in app frames

* prep release
2024-09-16 11:11:28 +01:00
Neil KakkarandGitHub 1521621d66 fix: Update Django integration for manual capture (#135) 2024-09-10 09:27:47 +01:00
Neil KakkarandGitHub 39070babfb feat(errors): Add manual exception capture (#134)
* feat(errors): Add manual exception capture

* prep release

* use backwards compatible helper

* add tests
2024-09-09 11:40:51 +01:00
Neil KakkarandGitHub 716eab0bc2 fix(setup): Make sure all packages are bundled (#133)
* fix(setup): Make sure all packages are bundled

* prep release

* black
2024-09-03 08:13:10 +01:00
Neil KakkarandGitHub 1c0a61d6b5 fix(setup): Make sure all packages are bundled (#132)
* fix(setup): Make sure all packages are bundled

* prep release
2024-09-03 07:23:54 +01:00
Neil KakkarandGitHub ffa35fa5cd feat(errors): Add django integration and in app frames (#131) 2024-09-03 06:10:36 +01:00
Neil KakkarandGitHub 24b7b918f7 feat(error-capture): Add basic exception autocapture (#128) 2024-08-28 06:59:41 +01:00
Phani RajandGitHub 16cbd10f1b bump version to 3.5.2 (#130) 2024-08-21 11:58:02 -05:00
Phani RajandGitHub b83d544931 fix(feature flags): Guard for None values when comparing person Properties (#129)
* Guard for None values when comparing person Properties
2024-08-21 11:18:20 -05:00
Frank HamandandGitHub 72c0ed1935 Switch us-api.i hosts to just us.i (#119)
We dropped the -api as it's a bit confusing (is capture really api?)
2024-08-14 08:41:10 +01:00
Neil KakkarandGitHub 5fdd6177ee Create CODEOWNERS (#122) 2024-05-02 18:02:13 +01:00
Neil KakkarandGitHub fc1da7d589 fix(flags): Add a shorter configurable timeout for flag requests (#120) 2024-03-04 14:25:11 +00:00
Brett HoernerandGitHub cba6e86537 Bump to 3.4.2 (#118) 2024-02-20 08:43:10 -07:00
Brett HoernerandGitHub 4e45255207 Add historical_migration option to toplevel Client (#117) 2024-02-15 06:42:43 -07:00
Neil KakkarandGitHub bc37351ab4 chore: Use ingestion hosts for event capture (#116) 2024-02-13 11:19:21 +00:00
Neil KakkarandGitHub a5e8b7d7fb fix(routing): Update hosts to point to right ingestion host (#115) 2024-02-05 12:14:14 +00:00
Neil KakkarandGitHub efb0ccf3c7 chore(flags): Update type hints for newer mypy versions (#114)
* chore(flags): Update type hints for newer mypy versions

* black
2024-01-30 16:06:10 +00:00
Neil KakkarandGitHub 8554b51a48 fix(flags): Update relative date op names (#113) 2024-01-26 15:52:46 +00:00
d0d962a8ba Module functions to also return the same as its Client equivalent (#111)
Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2024-01-19 11:13:52 +00:00
Neil KakkarandGitHub e348106094 fix(flags): Don't override existing props when adding flags (#110) 2024-01-10 14:34:23 +00:00
Neil KakkarandGitHub e60d52c199 feat(flags): Add local props and flags to all calls (#106) 2024-01-09 11:30:10 +00:00
Neil KakkarandGitHub a2c73d0536 feat(flags): Add relative date operators, fix numeric ops (#105) 2024-01-09 11:22:18 +00:00
Xavier VelloandGitHub 33ba5d6843 feat: increase message and batch sizes (#108) 2023-12-04 16:27:26 +01:00
Daniil OkhlopkovandGitHub 3515c40483 Update LICENSE (#104) 2023-10-25 16:08:07 +01:00
Neil KakkarandGitHub 139258cacb fix(flags): Ensure feature properties exist on feature flag called ev… (#101)
* fix(flags): Ensure feature properties exist on feature flag called events

* fix

* make flake8 happy, will this bork?
2023-08-17 18:03:28 +01:00
Neil KakkarandGitHub f75d924d4c fix: disable behaviour for feature flags (#99) 2023-04-21 13:10:02 +01:00
617bb53501 Disable geoip for capture and decide calls by default (#98)
Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2023-04-17 12:06:57 +01:00
Eric DuongandGitHub 4aa3499527 version 2.5.0 (#97) 2023-04-10 15:13:25 -04:00
Eric DuongandGitHub de7def97e2 chore: change package to be an instantiable client (#96)
* initial try

* change init file to a class

* remove commas

* format

* sort

* restore original and add renamed client

* format

* move disabled

* format

* handle changing var for global instance

* add test
2023-04-10 14:33:07 -04:00
Neil KakkarandGitHub dfefd0a1b6 Fix analytics package dependencies (#95) 2023-03-30 10:34:59 -04:00
Neil KakkarandGitHub 477a688016 Add CI to ensure no prints (#94) 2023-03-17 15:22:59 +00:00
Neil KakkarandGitHub fa474a0fe6 Release for print fix (#93) 2023-03-17 12:41:16 +00:00
Jann KleenandGitHub f8bc3f17eb Remove print() call (#92) 2023-03-17 12:37:01 +00:00
Neil KakkarandGitHub 07277d35e7 feat(flags): Enable local evaluation for all cohorts (#91) 2023-03-16 10:47:29 +00:00
Eric DuongandGitHub 15d0716744 fix test (#90) 2023-02-14 14:24:10 -05:00
Eric DuongandGitHub b4103b3ae2 fix: add function for active variants (#89)
* add function for active variants

* format
2023-02-14 10:45:14 -05:00
Eric DuongandGitHub 1aeffa990f add changelog entry and update versoin (#87) 2023-02-07 10:47:26 -05:00
Eric DuongandGitHub 5ae7feb4e9 chore: Remove upper bound (#85)
* remove upper bound

* format
2023-02-07 10:36:26 -05:00
6534afd8e3 fix: change api error to log (#83)
* change api error to log

* change implementation

* sort

* format

* raise when debug is true

* Update posthog/test/test_feature_flags.py

* format

---------

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2023-02-07 10:17:56 -05:00
Eric DuongandGitHub a9d7bf3e0b update version and changelog (#84) 2023-01-31 13:05:04 -05:00
Eric DuongandGitHub d7be253ef8 feat(feature-flags): JSON payload function (#81)
* implementation with test

* format

* v=3 and get_all_payloads

* format

* fix bug

* format

* address comments

* format

* add tests

* format

* add resiliency

* fix lookup

* remove check

* address comments

* example.py

* format

* format
2023-01-31 12:47:32 -05:00
Luke Harries 592c0f362e Revert "added regression tests"
This reverts commit c7fc5a83b4.
2023-01-24 17:31:30 +00:00
Luke Harries c7fc5a83b4 added regression tests 2023-01-24 17:11:37 +00:00
Neil KakkarandGitHub ae8817b611 feat(flags): Add support for variant overrides (#77) 2022-11-14 12:15:33 +00:00
Neil KakkarandGitHub acad2b142e fix: datetime comparison issues (#75) 2022-09-15 15:26:38 +01:00
Neil KakkarandGitHub cb62570e69 Update CHANGELOG.md 2022-09-14 14:22:28 +01:00
Eric DuongandGitHub 33645ecd3c feat: add date comparison to local evaluation (#74) 2022-09-14 13:53:44 +01:00
Neil KakkarandGitHub 81debcef27 fix: Remove defaults for feature flag calls (#72) 2022-08-12 12:20:54 +01:00
Neil KakkarandGitHub dac06bab18 fix(feature-flags): Add more options to make using library easier (#70) 2022-08-04 13:53:20 +01:00
Neil KakkarandGitHub 2dc1298620 Bump version to 2.0.0 and add breaking changes changelog (#69) 2022-08-02 12:10:32 +01:00
Neil KakkarandGitHub 2c6b675be7 feat(feature-flags): Enable local evaluation of flags (#68) 2022-07-29 14:10:51 +01:00
3a6fd07951 Add get feature flag method (#67)
* get feature flag method

* remove groups param from method

* use personal api key

* add groups

* add capture

* black reformat

* black?

* Update posthog/client.py

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>

* add to init

* formatting

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2022-06-30 11:48:53 -04:00
Neil KakkarandGitHub de0ccd29d3 bump version to 1.4.9 (#66) 2022-06-13 12:18:50 +01:00
addd2e3340 Have an option to send feature variants with the .capture(...) calls (#65)
Co-authored-by: Utku Zihnioglu <utku@webshare.io>
2022-06-13 12:13:35 +01:00
Tim GlaserandGitHub 9d2fa72753 bump version 1.4.8 (#61)
* bump version 1.4.8

* Update CHANGELOG.md
2022-05-12 08:12:11 +01:00
306fb2a1fa Feature: Enable multi variate feature flags for Python library (#60)
* Capturing $feature_flag_called at the end of client.feature_enabled method

* Enabling multi-variants for feature flags

Co-authored-by: Utku Zihnioglu <utku@webshare.io>
Co-authored-by: Tim Glaser <tim@posthog.com>
2022-05-12 08:10:54 +01:00
faffd1f88a Capturing $feature_flag_called at the end of client.feature_enabled method (#57)
Co-authored-by: Utku Zihnioglu <utku@webshare.io>
2022-05-12 08:05:26 +01:00
Tim Glaser ab1399d88f Bump version 1.4.7 2022-04-25 14:08:08 +01:00
Tim GlaserandGitHub 1777b7062e fix: Personal api key not required (#56)
* fix: Personal api key not required

* formatting

* Fix false
2022-04-25 14:07:08 +01:00
Tim GlaserandGitHub 565bb8a0eb docs:Remove id: from example (#55) 2022-04-11 16:01:27 +02:00
Marius Andra 009cac8634 1.4.6 2022-03-30 08:45:14 +02:00
Marius AndraandGitHub ec2425996c Support custom UUID values (#53)
* feat(uuid): add support for custom uuids

* fix deprecated assert

* do not send "none" uuid

* you can have it in any color you want, as long as it's black
2022-03-30 08:42:43 +02:00
Paolo D'AmicoandGitHub 90fa0a0604 Update version.py (#49) 2022-01-05 06:46:28 -06:00
Paolo D'AmicoandGitHub a97fe0a40a Use Sentry DSN to obtain project ID (#48) 2022-01-05 06:15:51 -06:00
Karl-Aksel PuulmannandGitHub a474fcff93 Update version to 1.4.4 2021-11-25 11:45:18 +02:00
Karl-Aksel PuulmannandGitHub a181ba718f Groups: Feature flags support (#45)
* Fix a documentation typo

* Feature flags & groups support

* Update examples
2021-11-25 09:55:40 +02:00
Karl-Aksel PuulmannandGitHub b996f3a4e9 Update version to 1.4.3 2021-10-28 18:42:58 +03:00
Karl-Aksel PuulmannandGitHub 6c945a0624 Basic group analytics support (#44)
* Make setup instructions work

* Add basic group analytics support to library

* Resolve formatting issues
2021-10-27 10:25:33 +03:00
Michael Matloka ab8ccb4dff Update pip install 2021-06-22 14:41:11 +02:00
Michael MatlokaandGitHub 870f6f8b6b Update README.md 2021-06-22 14:39:02 +02:00
Michael MatlokaandGitHub 9e0aeaefe6 Update README.md 2021-06-22 14:38:44 +02:00
Michael MatlokaandGitHub a8409960b9 Bump version to 1.4.2 2021-06-22 14:38:15 +02:00
Michael Matloka deb078293a Use PyPI API token instead of username and password 2021-06-22 14:30:53 +02:00
Michael Matloka edb8b7891e Ensure wheel 2021-06-22 14:24:27 +02:00
Michael Matloka 727bdb2b1e Fix make target in release workflow 2021-06-22 14:21:01 +02:00
Michael MatlokaandGitHub 0781a1280e Add release workflow (#42)
* Add release workflow

* Update README.md
2021-06-22 14:19:31 +02:00
Yakko MajuriandGitHub 8b2ed8bb12 Fix integrity issues (#41)
* Fix integrity issues

* fix black, tests

* add test for new behavior

* run black properly

* remove accidental commit
2021-06-16 11:06:14 -03:00
Yakko MajuriandGitHub a139795a74 Better handling on feature_enabled (#40) 2021-06-07 11:39:17 +01:00
Mandeep GillandGitHub 11f1d06761 Remove pinning of backoff dependency (#38) 2021-06-04 14:26:36 -03:00
Neil Kakkar fd321566ed Release 1.4.1 2021-05-28 10:19:56 +01:00
Mandeep GillandGitHub 83737f2477 Bump backoff dependency to 1.10.0 (#36) 2021-05-26 19:02:15 +02:00
Michael MatlokaandGitHub cc5649368f Fix 1.4.0 date in CHANGELOG.md 2021-05-20 15:44:53 +02:00
Michael MatlokaandGitHub 7fe5045da1 Update CHANGELOG.md 2021-05-18 17:21:59 +02:00
Michael MatlokaandGitHub 2c9ad238ec Add PyPI badge to README 2021-05-18 17:20:36 +02:00
Michael Matloka de6e60f12f Release 1.4.0 2021-05-18 17:16:37 +02:00
Neil KakkarandGitHub fd92502d99 Add support for project_api_key. (#32)
* add support for project_api_key

* rm keys

* lint
2021-05-18 17:09:07 +02:00
Neil KakkarandGitHub fe6d0dc1ec Resolve polling issues with feature flags (#29)
* resolve polling issues

* lint
2021-05-17 14:09:20 +01:00
fbde5cafc4 Add python sentry (and sentry & django) integrations (#13)
* Add python sentry (and sentry & django) integrations

* example for django-sentry application and PosthogIntegration code

Co-authored-by: Neil Kakkar <neilkakkar@gmail.com>
2021-05-17 14:08:46 +01:00
Neil KakkarandGitHub 2e99081cb1 Fix feature flag issue with no % rollout (#30)
* fix feature flag issue with no % rollout

* black
2021-05-14 11:52:06 -03:00
Neil Kakkar 372fb74637 Release 1.3.1 2021-05-07 12:22:14 +01:00
Neil KakkarandGitHub ba11548089 revert test change (#28) 2021-05-07 12:39:50 +02:00
Neil KakkarandGitHub 49d0821e27 Merge pull request #23 from PostHog/set_once
Add $set and $set_once support
2021-05-07 11:06:04 +01:00
Neil Kakkar b4489f1dca black 2021-05-07 10:57:53 +01:00
Neil Kakkar 8040964761 Add set and set_once to simulator.py 2021-05-07 10:54:10 +01:00
Tim GlaserandGitHub 4f853403b9 Merge pull request #27 from gagantrivedi/fix/alias
fix: Add distinct id to $create_alias event
2021-05-07 11:35:00 +02:00
Gagan ac61fb0e01 chore: Reformat consumer with black 2021-05-07 14:52:25 +05:30
Gagan 7196dc6048 fix: Add distinct id to $create_alias event 2021-05-07 11:22:03 +05:30
Neil Kakkar 45303b899e black 2021-05-06 14:05:57 +01:00
Neil Kakkar 7e463ccad6 resolve conflicts with master 2021-05-06 13:59:46 +01:00
Neil Kakkar 41dec34929 remove API key 2021-05-06 13:53:18 +01:00
Neil Kakkar 0bf9db0108 add functionality+tests for set and set_once 2021-05-06 13:52:23 +01:00
Neil Kakkar e8308360bb cleanup tests, add instructions to readme 2021-05-06 11:43:13 +01:00
Paolo D'AmicoandGitHub 15ebe85a78 Merge pull request #26 from PostHog/distinct-uuid 2021-04-01 17:59:29 -07:00
Michael Matloka dbc22d2f9f Add UUID to ID_TYPES 2021-04-01 17:31:48 +02:00
Paolo D'Amico f3ee238823 black 2021-02-08 11:21:55 +01:00
Paolo D'Amico 71d81b2da9 update tests 2021-02-08 11:18:55 +01:00
Paolo D'Amico 5493029577 add $set_once support 2021-02-08 11:16:27 +01:00
Michael Matloka d66f944571 Bump version to 1.2.1 2021-02-05 16:27:51 +01:00
Michael MatlokaandGitHub 9d620967f8 Merge pull request #22 from PostHog/fix-possibly-null-percentage
Add condition for rollout-percentage
2021-01-28 15:01:30 +01:00
Eric 563404f914 add condition 2021-01-25 19:57:21 -05:00
Yakko MajuriandGitHub d15aac41a9 Update README.md 2021-01-11 17:19:21 -03:00
Michael MatlokaandGitHub 8a3e28b949 Merge pull request #21 from PostHog/code-style
Black and isort all the things
2021-01-05 12:25:57 +01:00
Michael Matloka 1aa0d6335c Add CI job for this 2021-01-05 12:21:39 +01:00
Michael Matloka 3b46c60cf1 Normalize strings to double quotes 2021-01-05 12:09:44 +01:00
Michael Matloka 4ad8cbfa58 Fix weird concat 2021-01-05 12:07:05 +01:00
Michael Matloka 984a679b19 Black and isort all the things! 2021-01-03 05:37:51 +01:00
Michael Matloka dd1bad6175 Setup black, isort and pre-commit 2021-01-03 05:36:26 +01:00
Michael MatlokaandGitHub e6f71e4cc3 Merge pull request #20 from Hungsiro506/patch-1
Fix post() returning None on non-200 response
2021-01-03 05:07:51 +01:00
Michael Matloka 17874cb131 Update .gitignore 2021-01-03 05:00:41 +01:00
Michael Matloka 8366e09df9 Add typing to request.py 2021-01-03 04:58:55 +01:00
Hưng VũandGitHub e28b237ff1 Update request.py
-- Fix post method return None
2020-12-30 17:43:08 +07:00
Michael MatlokaandGitHub 05fde2a51e Merge pull request #18 from PostHog/better-errors
Add project_api_key, improve errors
2020-12-15 12:01:28 +01:00
Michael MatlokaandGitHub 2be04f3b8b Merge pull request #14 from PostHog/uuid-property
Allow passing in UUID as property
2020-12-15 11:21:31 +01:00
Michael Matloka d52b605742 Rerun CI 2020-12-15 11:14:18 +01:00
yakkomajuri 7ee3002c6f update generic error handler 2020-12-04 11:10:22 +00:00
yakkomajuri d1bc9135c7 specify json return 2020-12-04 10:29:07 +00:00
yakkomajuri 111813296c add project_api_key to config 2020-12-04 09:58:43 +00:00
yakkomajuri 31acda73a3 Better API error handling 2020-12-04 09:34:23 +00:00
Paolo D'AmicoandGitHub 888457387b Merge pull request #17 from PostHog/v1.1.3 2020-11-23 09:33:50 -06:00
Paolo D'Amico fe45ff2ab0 version bump 2020-11-23 10:08:04 -05:00
Marius AndraandGitHub 221d7f09f3 Do not start Feature Flag polling if no API Key (#15)
* jetbrains idea .gitignore

* do not start the poller if no personal api key
2020-11-23 15:37:52 +01:00
Michael Matloka a5f2e030b5 Allow passing in UUID as property 2020-11-03 17:14:00 +01:00
Tim GlaserandGitHub 98d2d4cc05 Merge pull request #12 from PostHog/investigate
Send distinctID to the decide endpoint to determin if user should have features enabled
2020-09-30 14:34:43 +01:00
James Greenhill b7c1572c32 bump version to 1.1.2 2020-09-30 14:25:49 +01:00
James Greenhill 16acf2e278 better testing and org 2020-09-30 14:20:50 +01:00
James Greenhill df9ae05202 fix tests 2020-09-30 14:10:34 +01:00
James Greenhill b05ee3884a Send distinctID to the decide endpoint to determin if user should have features enabled 2020-09-30 13:53:47 +01:00
James GreenhillandGitHub 144a7744e4 Merge pull request #11 from PostHog/none
Default to False if the feature flag key does not exist. None is confusing
2020-09-29 17:06:53 +01:00
James Greenhill ca979f06c8 type annotations 2020-09-29 17:02:39 +01:00
James Greenhill caa64fb6fe Default to False if the feature flag key does not exist. None is confusing 2020-09-29 16:07:43 +01:00
Tim GlaserandGitHub e007dee07e Merge pull request #10 from PostHog/warn-instead-of-error
Warn instead of error
2020-09-29 13:16:06 +02:00
Tim Glaser 1de9553d61 Fix tests 2020-09-29 12:13:28 +01:00
Tim Glaser d447d170fa Warn instead of error 2020-09-29 11:57:44 +01:00
Tim GlaserandGitHub d92c398c0f Merge pull request #9 from PostHog/feature-flags
Feature flags
2020-09-17 10:17:42 +02:00
Tim Glaser b331c4aae3 Add error handling and timeouts 2020-09-17 10:13:03 +02:00
Yakko Majuri 5e70ca84bb minor changes 2020-09-14 09:37:14 +00:00
Yakko Majuri adef8d4928 fix comment 2020-09-14 08:26:40 +00:00
Tim GlaserandGitHub 8682091eec Merge pull request #8 from PostHog/add-tests
Fix tests and clean up integration
2020-09-11 15:39:51 +02:00
Tim Glaser 8977c4e3ab Merge branch 'add-tests' of github.com:PostHog/posthog-python into add-tests 2020-09-11 15:37:31 +02:00
Tim Glaser 710ac05862 Cleaned up simulator 2020-09-11 15:37:06 +02:00
Yakko Majuri 721a6aacf7 fix action 2020-09-11 08:58:14 +00:00
Yakko Majuri 678e4ac97b minor changes 2020-09-11 08:50:50 +00:00
Tim Glaser 72e7e4ad72 typo 2020-09-10 16:52:31 +02:00
Tim Glaser 95d4375663 Fix tests 2020-09-10 16:39:27 +02:00
Tim Glaser 6e39aa0ceb Add poller 2020-09-10 16:35:23 +02:00
Tim Glaser c72ab9a3fd Make feature flags work 2020-09-10 16:30:54 +02:00
Yakko Majuri 6508aa6994 further cleanup of action 2020-09-09 16:48:07 +00:00
Yakko Majuri 3b7f37aa39 updated tests action 2020-09-09 16:44:41 +00:00
Tim Glaser ac74ee9a5c fix test 2020-09-09 16:26:59 +02:00
Tim Glaser b9e323bf47 Fix tests 2020-09-09 16:22:40 +02:00
Tim Glaser 8fee12f004 Move tests into correct folder 2020-09-09 16:16:54 +02:00
Tim Glaser 3c23f0159f Fix tests 2020-09-09 16:15:46 +02:00
Marius AndraandGitHub c944d7df2c Update README.md 2020-05-20 16:43:32 +02:00
Tim Glaser 611e01f9eb Release 1.0.11 2020-04-30 10:10:51 +01:00
Tim GlaserandGitHub 1840bb8f57 Merge pull request #6 from PostHog/5-timestamp-correct-type
Closes #5, add correct timestamp types
2020-04-30 10:08:51 +01:00
Tim Glaser cb06d2fd5d Closes #5, add correct timestamp types 2020-04-30 10:08:28 +01:00
Marius AndraandGitHub 610fc816f1 Merge pull request #3 from casio/patch-1
Fix typo in identify() docstring
2020-04-29 22:12:44 +02:00
Marius AndraandGitHub 992ab2b9a7 Merge pull request #4 from PostHog/links-fix
Fixed links to docs
2020-04-29 22:12:05 +02:00
James HawkinsandGitHub fd68a10e63 Fixed links to docs 2020-04-07 16:26:06 +01:00
Tim Glaser 48c1cf3c02 Update instructions 2020-04-01 17:26:10 +01:00
Tim Glaser b923116a9e Release 1.0.10 2020-04-01 17:25:23 +01:00
Carsten KrausandGitHub 0eed53222c Fix typo in docstring
lil typo, fixed : )
2020-03-25 22:42:05 +01:00
Tim Glaser 1670901156 Merge branch 'master' of github.com:PostHog/posthog-python 2020-02-21 17:09:32 -08:00
Tim GlaserandGitHub 133e1a991c Merge pull request #1 from mariusandra/patch-1
Fix typo with "identify" code example
2020-02-18 18:19:25 -08:00
Marius AndraandGitHub 7a93a99541 Fix typo with "identify" code example 2020-02-18 22:10:02 +01:00
99 changed files with 2359 additions and 2758 deletions
+6 -6
View File
@@ -1,11 +1,11 @@
# Hanzo Insights API Configuration
# PostHog API Configuration
# Copy this file to .env and update with your actual values
# Your project API key (found on the setup page in Insights)
INSIGHTS_PROJECT_API_KEY=hi_your_project_api_key_here
# 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)
INSIGHTS_PERSONAL_API_KEY=phx_your_personal_api_key_here
POSTHOG_PERSONAL_API_KEY=phx_your_personal_api_key_here
# Insights host URL (remove this line if using insights.hanzo.ai)
INSIGHTS_HOST=http://localhost:8000
# PostHog host URL (remove this line if using posthog.com)
POSTHOG_HOST=http://localhost:8000
-5
View File
@@ -1,5 +0,0 @@
---
pypi/posthog: patch
---
feat(llma): support fetching versioned prompts from the prompts sdk
-1
View File
@@ -1 +0,0 @@
LLM.md
+7 -7
View File
@@ -1,6 +1,6 @@
# Before Send Hook
The `before_send` parameter allows you to modify or filter events before they are sent to Insights. This is useful for:
The `before_send` parameter allows you to modify or filter events before they are sent to PostHog. This is useful for:
- **Privacy**: Removing or masking sensitive data (PII)
- **Filtering**: Dropping unwanted events (test events, internal users, etc.)
@@ -10,12 +10,12 @@ The `before_send` parameter allows you to modify or filter events before they ar
## Basic Usage
```python
import hanzo_insights
import posthog
from typing import Optional, Dict, Any
def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""
Process event before sending to Insights.
Process event before sending to PostHog.
Args:
event: The event dictionary containing 'event', 'distinct_id', 'properties', etc.
@@ -27,7 +27,7 @@ def my_before_send(event: Dict[str, Any]) -> Optional[Dict[str, Any]]:
return event
# Initialize client with before_send hook
client = hanzo_insights.Client(
client = posthog.Client(
api_key="your-project-api-key",
before_send=my_before_send
)
@@ -166,7 +166,7 @@ def should_drop_event(event: dict[str, Any]) -> bool:
## Error Handling
If your `before_send` function raises an exception, Insights will:
If your `before_send` function raises an exception, PostHog will:
1. Log the error
2. Continue with the original, unmodified event
@@ -184,7 +184,7 @@ def risky_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
## Complete Example
```python
import hanzo_insights
import posthog
from typing import Optional, Any
import re
@@ -227,7 +227,7 @@ def production_before_send(event: dict[str, Any]) -> Optional[dict[str, Any]]:
return event # Return original event on error
# Usage
client = hanzo_insights.Client(
client = posthog.Client(
api_key="your-api-key",
before_send=production_before_send
)
-13
View File
@@ -1,18 +1,5 @@
# posthog
## 7.9.7 — 2026-03-05
### Patch changes
- [b206669](https://github.com/posthog/posthog-python/commit/b206669bf62c923346ad28881dc4694d933ca424) fix(llma): use distinct_id from outer context if not provided, fix $process_person_profile for context-based identity — Thanks @ethanporcaro for your first contribution 🎉!
- [a99c7d7](https://github.com/posthog/posthog-python/commit/a99c7d73b1e0ef1f35d856c82ace21237ee253a3) Add warning log for local flag evaluation cold start — Thanks @dmarticus!
## 7.9.6 — 2026-03-02
### Patch changes
- [8d83315](https://github.com/posthog/posthog-python/commit/8d83315b67c21eb9e7d6c17bae27ada98ca2643d) add PROPERTY_OPERATORS constant for match_property — Thanks @dmarticus!
## 7.9.5 — 2026-03-02
### Patch changes
-1
View File
@@ -1 +0,0 @@
LLM.md
-48
View File
@@ -1,48 +0,0 @@
# LLM.md - Hanzo Insights Python SDK
## Overview
Integrate Hanzo Insights into any Python application. Package name: `hanzo-insights` on PyPI.
## Tech Stack
- **Language**: Python 3.10+
- **Package**: `hanzo_insights` (import name), `hanzo-insights` (pip name)
## Build & Run
```bash
uv sync
uv run pytest
```
## Structure
```
posthog-python/
hanzo_insights/ # Main package
__init__.py # Module-level API, Insights class
client.py # Client class
ai/ # AI provider integrations (OpenAI, Anthropic, Gemini, LangChain)
integrations/ # Framework integrations (Django middleware)
test/ # Tests
examples/
integration_tests/
pyproject.toml # Package config (name: hanzo-insights)
setup.py # Legacy setup
```
## Key Files
- `pyproject.toml` -- Package config, dependencies, test config
- `hanzo_insights/__init__.py` -- Public API surface
- `hanzo_insights/client.py` -- Client implementation
## Rebrand Notes
- Main class: `Insights` (no backward compat aliases)
- Django middleware: `InsightsContextMiddleware` (no backward compat aliases)
- OpenAI Agents: `InsightsTracingProcessor` (no backward compat aliases)
- `$lib` protocol value: `insights-python`
- Ingestion URLs: `us.i.insights.hanzo.ai` / `eu.i.insights.hanzo.ai`
- AI wrapper kwargs: `insights_*` (e.g. `insights_distinct_id`, `insights_trace_id`)
- Exception attrs: `__insights_exception_captured`, `__insights_exception_uuid`
- Context var: `insights_context_stack`
- Redis prefix: `insights:flags:`
- Redaction sentinels: `$$_insights_redacted_*`, `$$_insights_value_too_long_*`
- Django settings: `INSIGHTS_MW_*` only (no `POSTHOG_MW_*` fallback)
- Django headers: `X-INSIGHTS-SESSION-ID`, `X-INSIGHTS-DISTINCT-ID` only
+21 -22
View File
@@ -9,38 +9,37 @@ build_release:
rm -rf dist/*
python setup.py sdist bdist_wheel
# Builds the `posthoganalytics` PyPI package, which is a mirror of `hanzo_insights`
# published under a different name for backward compatibility with the upstream
# posthog/posthog project.
# Builds the `posthoganalytics` PyPI package, which is a mirror of `posthog`
# published under a different name for internal use by posthog/posthog.
#
# The process works in three phases:
# 1. hanzo_insights -> posthoganalytics: Copy the source, rewrite all imports,
# remove the original hanzo_insights/ dir, and build the dist.
# 2. posthoganalytics -> hanzo_insights: Reverse the import rewrites, copy
# everything back into hanzo_insights/, and clean up.
# 1. posthog -> posthoganalytics: Copy the source, rewrite all imports,
# remove the original posthog/ dir, and build the dist.
# 2. posthoganalytics -> posthog: Reverse the import rewrites, copy
# everything back into posthog/, and clean up.
# 3. Restore pyproject.toml from backup (setup_analytics.py modifies it).
#
# This ensures the working tree is left in the same state it started in.
#
# NOTE: This target clears dist/ before building. In the release workflow,
# `build_release` (hanzo_insights) must be published BEFORE running this target,
# otherwise the hanzo_insights dist artifacts will be lost.
# `build_release` (posthog) must be published BEFORE running this target,
# otherwise the posthog dist artifacts will be lost.
build_release_analytics:
rm -rf dist
rm -rf build
rm -rf posthoganalytics
mkdir posthoganalytics
cp -r hanzo_insights/* posthoganalytics/
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights\./from posthoganalytics\./g' {} \;
cp -r posthog/* posthoganalytics/
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
rm -rf hanzo_insights
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
mkdir hanzo_insights
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from hanzo_insights /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from hanzo_insights\./g' {} \;
mkdir posthog
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthoganalytics\./from posthog\./g' {} \;
find ./posthoganalytics -name "*.bak" -delete
cp -r posthoganalytics/* hanzo_insights/
cp -r posthoganalytics/* posthog/
rm -rf posthoganalytics
rm -f pyproject.toml
cp pyproject.toml.backup pyproject.toml
@@ -55,14 +54,14 @@ prep_local:
cp -r . ../posthog-python-local/
cd ../posthog-python-local && rm -rf dist build posthoganalytics .git
cd ../posthog-python-local && mkdir posthoganalytics
cd ../posthog-python-local && cp -r hanzo_insights/* posthoganalytics/
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights /from posthoganalytics /g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from hanzo_insights\./from posthoganalytics\./g' {} \;
cd ../posthog-python-local && cp -r posthog/* posthoganalytics/
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog /from posthoganalytics /g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -type f -name "*.py" -exec sed -i.bak -e 's/from posthog\./from posthoganalytics\./g' {} \;
cd ../posthog-python-local && find ./posthoganalytics -name "*.bak" -delete
cd ../posthog-python-local && rm -rf hanzo_insights
cd ../posthog-python-local && rm -rf posthog
cd ../posthog-python-local && sed -i.bak 's/from version import VERSION/from posthoganalytics.version import VERSION/' setup_analytics.py
cd ../posthog-python-local && rm setup_analytics.py.bak
cd ../posthog-python-local && sed -i.bak 's/"hanzo_insights"/"posthoganalytics"/' setup.py
cd ../posthog-python-local && sed -i.bak 's/"posthog"/"posthoganalytics"/' setup.py
cd ../posthog-python-local && rm setup.py.bak
cd ../posthog-python-local && python -c "import setup_analytics" 2>/dev/null || true
@echo "Local copy created at ../posthog-python-local"
+57 -47
View File
@@ -1,51 +1,41 @@
# Hanzo Insights Python SDK
# PostHog Python
Integrate [Hanzo Insights](https://insights.hanzo.ai) into any Python application.
<p align="center">
<img alt="posthoglogo" src="https://user-images.githubusercontent.com/65415371/205059737-c8a4f836-4889-4654-902e-f302b187b6a0.png">
</p>
<p align="center">
<a href="https://pypi.org/project/posthog/"><img alt="pypi installs" src="https://img.shields.io/pypi/v/posthog"/></a>
<img alt="GitHub contributors" src="https://img.shields.io/github/contributors/posthog/posthog-python">
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/posthog/posthog-python"/>
<img alt="GitHub closed issues" src="https://img.shields.io/github/issues-closed/posthog/posthog-python"/>
</p>
## Installation
```bash
pip install hanzo-insights
```
## Quick Start
```python
from hanzo_insights import Insights
client = Insights('<your_project_api_key>', host='https://insights.hanzo.ai')
# Capture an event
client.capture('user_123', 'purchase', properties={'product': 'widget'})
# Feature flags
if client.feature_enabled('new-checkout', 'user_123'):
show_new_checkout()
```
## Module-level usage
```python
import hanzo_insights
hanzo_insights.api_key = '<your_project_api_key>'
hanzo_insights.host = 'https://insights.hanzo.ai'
hanzo_insights.capture('movie_played', distinct_id='user_123', properties={'movie_id': '42'})
hanzo_insights.shutdown()
```
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
## Python Version Support
| SDK Version | Python Versions Supported |
| -------------- | ----------------------------- |
| 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 |
| 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 |
| 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 |
| SDK Version | Python Versions Supported | Notes |
| ------------- | ---------------------------- | -------------------------- |
| 7.3.1+ | 3.10, 3.11, 3.12, 3.13, 3.14 | Added Python 3.14 support |
| 7.0.0 - 7.0.1 | 3.10, 3.11, 3.12, 3.13 | Dropped Python 3.9 support |
| 4.0.1 - 6.x | 3.9, 3.10, 3.11, 3.12, 3.13 | Python 3.9+ required |
## Development
We use [uv](https://docs.astral.sh/uv/).
### Testing Locally
We recommend using [uv](https://docs.astral.sh/uv/). It's super fast.
1. Run `uv venv env` (creates virtual environment called "env")
- or `python3 -m venv env`
2. Run `source env/bin/activate` (activates the virtual environment)
3. Run `uv sync --extra dev --extra test` (installs the package in develop mode, along with test dependencies)
- or `pip install -e ".[dev,test]"`
4. you have to run `pre-commit install` to have auto linting pre commit
5. Run `make test`
6. To run a specific test do `pytest -k test_no_api_key`
## PostHog recommends `uv` so...
```bash
uv python install 3.12
@@ -57,14 +47,34 @@ pre-commit install
make test
```
### Running Tests
### Running Locally
```bash
make test
# or run a specific test:
pytest -k test_no_api_key
Assuming you have a [local version of PostHog](https://posthog.com/docs/developing-locally) running, you can run `python3 example.py` to see the library in action.
### Testing changes locally with the PostHog app
You can run `make prep_local`, and it'll create a new folder alongside the SDK repo one called `posthog-python-local`, which you can then import into the posthog project by changing pyproject.toml to look like this:
```toml
dependencies = [
...
"posthoganalytics" #NOTE: no version number
...
]
...
[tools.uv.sources]
posthoganalytics = { path = "../posthog-python-local" }
```
## License
This'll let you build and test SDK changes fully locally, incorporating them into your local posthog app stack. It mainly takes care of the `posthog -> posthoganalytics` module renaming. You'll need to re-run `make prep_local` each time you make a change, and re-run `uv sync --active` in the posthog app project.
MIT
## Releasing
This repository uses [Sampo](https://github.com/bruits/sampo) for versioning, changelogs, and publishing to crates.io.
1. When making changes, include a changeset: `sampo add`
2. Create a PR with your changes and the changeset file
3. Add the `release` label and merge to `main`
4. Approve the release in Slack when prompted — this triggers version bump, crates.io publish, git tag, and GitHub Release
You can also trigger a release manually via the workflow's `workflow_dispatch` trigger (still requires pending changesets).
+1 -1
View File
@@ -4,5 +4,5 @@
source bin/helpers/_utils.sh
set_source_and_root_dir
flake8 hanzo_insights --ignore E501,W503
flake8 posthog --ignore E501,W503
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
+1 -1
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env bash
#/ Usage: bin/docs
#/ Description: Generate documentation for the Insights Python SDK
#/ Description: Generate documentation for the PostHog Python SDK
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
+6 -6
View File
@@ -1,15 +1,15 @@
"""
Constants for Insights Python SDK documentation generation.
Constants for PostHog Python SDK documentation generation.
"""
from typing import Dict, Union
from hanzo_insights.version import VERSION
from posthog.version import VERSION
# Documentation generation metadata
DOCUMENTATION_METADATA = {
"hogRef": "0.3",
"slugPrefix": "insights-python",
"specUrl": "https://github.com/Insights/insights-python",
"slugPrefix": "posthog-python",
"specUrl": "https://github.com/PostHog/posthog-python",
}
# Docstring parsing patterns for new format
@@ -29,8 +29,8 @@ DOCSTRING_PATTERNS = {
# Output file configuration
OUTPUT_CONFIG: Dict[str, Union[str, int]] = {
"output_dir": "./references",
"filename": f"insights-python-references-{VERSION}.json",
"filename_latest": "insights-python-references-latest.json",
"filename": f"posthog-python-references-{VERSION}.json",
"filename_latest": "posthog-python-references-latest.json",
"indent": 2,
}
+26 -26
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
"""
Generate comprehensive SDK documentation JSON from Insights Python SDK.
Generate comprehensive SDK documentation JSON from PostHog Python SDK.
This script inspects the code and docstrings to create documentation in the specified format.
"""
@@ -337,19 +337,19 @@ def analyze_type(cls) -> dict:
def generate_sdk_documentation():
"""Generate complete SDK documentation in the requested format."""
# Import Insights components
import hanzo_insights
from hanzo_insights.client import Client
import hanzo_insights.types as types_module
import hanzo_insights.args as args_module
from hanzo_insights.version import VERSION
# Import PostHog components
import posthog
from posthog.client import Client
import posthog.types as types_module
import posthog.args as args_module
from posthog.version import VERSION
# Main SDK info
sdk_info = {
"version": VERSION,
"id": "insights-python",
"title": "Insights Python SDK",
"description": "Integrate Insights into any python application.",
"id": "posthog-python",
"title": "PostHog Python SDK",
"description": "Integrate PostHog into any python application.",
"slugPrefix": DOCUMENTATION_METADATA["slugPrefix"],
"specUrl": DOCUMENTATION_METADATA["specUrl"],
}
@@ -357,7 +357,7 @@ def generate_sdk_documentation():
# Collect types
types_list = []
# Types from hanzo_insights.types
# Types from posthog.types
for name in dir(types_module):
obj = getattr(types_module, name)
if inspect.isclass(obj) and not name.startswith("_"):
@@ -367,7 +367,7 @@ def generate_sdk_documentation():
except Exception as e:
print(f"Error analyzing type {name}: {e}")
# Types from hanzo_insights.args
# Types from posthog.args
for name in dir(args_module):
obj = getattr(args_module, name)
if inspect.isclass(obj) and not name.startswith("_"):
@@ -388,26 +388,26 @@ def generate_sdk_documentation():
# Collect classes
classes_list = []
# Main Insights class (renamed from Client)
# Main PostHog class (renamed from Client)
client_class = analyze_class(Client)
client_class["id"] = "Insights"
client_class["title"] = "Insights"
client_class["id"] = "PostHog"
client_class["title"] = "PostHog"
classes_list.append(client_class)
# Global module functions (functions callable as hanzo_insights.function_name)
# Global module functions (functions callable as posthog.function_name)
global_functions = []
for func_name in dir(hanzo_insights):
for func_name in dir(posthog):
# Skip private functions and non-callables
if func_name.startswith("_") or not callable(getattr(hanzo_insights, func_name)):
if func_name.startswith("_") or not callable(getattr(posthog, func_name)):
continue
func = getattr(hanzo_insights, func_name)
# Only include functions actually defined in the hanzo_insights module (not imported)
func = getattr(posthog, func_name)
# Only include functions actually defined in the posthog module (not imported)
# and exclude class references
if (
func_name not in ["Client", "Insights"]
func_name not in ["Client", "Posthog"]
and hasattr(func, "__module__")
and func.__module__ == "hanzo_insights"
and func.__module__ == "posthog"
):
try:
func_info = analyze_function(func, func_name)
@@ -421,8 +421,8 @@ def generate_sdk_documentation():
classes_list.append(
{
"id": "PostHogModule",
"title": "Insights Module Functions",
"description": "Global functions available in the Insights module",
"title": "PostHog Module Functions",
"description": "Global functions available in the PostHog module",
"functions": global_functions,
}
)
@@ -443,7 +443,7 @@ def generate_sdk_documentation():
# Create the final structure
result = {
"id": "insights-python",
"id": "posthog-python",
"hogRef": DOCUMENTATION_METADATA["hogRef"],
"info": sdk_info,
"types": types_list,
@@ -455,7 +455,7 @@ def generate_sdk_documentation():
if __name__ == "__main__":
print("Generating Insights Python SDK documentation...")
print("Generating PostHog Python SDK documentation...")
try:
documentation = generate_sdk_documentation()
+87 -87
View File
@@ -1,18 +1,18 @@
# Hanzo Insights Python library example
# PostHog Python library example
#
# This script demonstrates various Hanzo Insights Python SDK capabilities including:
# 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 Insights credentials
# 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 hanzo_insights
import posthog
def load_env_file():
@@ -31,30 +31,30 @@ def load_env_file():
load_env_file()
# Get configuration
project_key = os.getenv("INSIGHTS_PROJECT_API_KEY", "")
personal_api_key = os.getenv("INSIGHTS_PERSONAL_API_KEY", "")
host = os.getenv("INSIGHTS_HOST", "http://localhost:8000")
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 Insights project API key!")
print(" Please set INSIGHTS_PROJECT_API_KEY environment variable")
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 Insights with credentials
hanzo_insights.debug = False
hanzo_insights.api_key = project_key
hanzo_insights.project_api_key = project_key
hanzo_insights.host = host
hanzo_insights.poll_interval = 10
# 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
# Check if personal API key is available for local evaluation
local_eval_available = bool(personal_api_key)
if personal_api_key:
hanzo_insights.personal_api_key = personal_api_key
posthog.personal_api_key = personal_api_key
print("🔑 Insights Configuration:")
print("🔑 PostHog Configuration:")
print(f" Project API Key: {project_key[:9]}...")
if local_eval_available:
print(" Personal API Key: [SET]")
@@ -63,7 +63,7 @@ else:
print(f" Host: {host}\n")
# Display menu and get user choice
print("🚀 Hanzo Insights Python SDK Demo - Choose an example to run:\n")
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}")
@@ -79,11 +79,11 @@ if choice == "1":
print("IDENTIFY AND CAPTURE EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
# Capture an event
print("📊 Capturing events...")
hanzo_insights.capture(
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
@@ -92,14 +92,14 @@ if choice == "1":
# Alias a previous distinct id with a new one
print("🔗 Creating alias...")
hanzo_insights.alias("distinct_id", "new_distinct_id")
posthog.alias("distinct_id", "new_distinct_id")
hanzo_insights.capture(
posthog.capture(
"event2",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
)
hanzo_insights.capture(
posthog.capture(
"event-with-groups",
distinct_id="new_distinct_id",
properties={"property1": "value", "property2": "value"},
@@ -108,28 +108,28 @@ if choice == "1":
# Add properties to the person
print("👤 Identifying user...")
hanzo_insights.set(
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
# Add properties to a group
print("🏢 Identifying group...")
hanzo_insights.group_identify("company", "id:5", {"employees": 11})
posthog.group_identify("company", "id:5", {"employees": 11})
# Properties set only once to the person
print("🔒 Setting properties once...")
hanzo_insights.set_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)
hanzo_insights.set_once(
posthog.set_once(
distinct_id="new_distinct_id", properties={"self_serve_signup": False}
)
print("🔄 Updating properties...")
hanzo_insights.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
hanzo_insights.set(
posthog.set(distinct_id="new_distinct_id", properties={"current_browser": "Chrome"})
posthog.set(
distinct_id="new_distinct_id", properties={"current_browser": "Firefox"}
)
@@ -137,45 +137,45 @@ elif choice == "2":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set INSIGHTS_PERSONAL_API_KEY environment variable to run this example."
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
hanzo_insights.shutdown()
posthog.shutdown()
exit(1)
print("\n" + "=" * 60)
print("FEATURE FLAG LOCAL EVALUATION EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
print("🏁 Testing basic feature flags...")
print(
f"beta-feature for 'distinct_id': {hanzo_insights.feature_enabled('beta-feature', 'distinct_id')}"
f"beta-feature for 'distinct_id': {posthog.feature_enabled('beta-feature', 'distinct_id')}"
)
print(
f"beta-feature for 'new_distinct_id': {hanzo_insights.feature_enabled('beta-feature', 'new_distinct_id')}"
f"beta-feature for 'new_distinct_id': {posthog.feature_enabled('beta-feature', 'new_distinct_id')}"
)
print(
f"beta-feature with groups: {hanzo_insights.feature_enabled('beta-feature-groups', 'distinct_id', groups={'company': 'id:5'})}"
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: {hanzo_insights.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
f"Sydney user: {posthog.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
)
print(
f"Sydney user (local only): {hanzo_insights.feature_enabled('test-flag', 'distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
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: {hanzo_insights.get_all_flags('distinct_id_random_22')}")
print(f"All flags: {posthog.get_all_flags('distinct_id_random_22')}")
print(
f"All flags (local): {hanzo_insights.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
f"All flags (local): {posthog.get_all_flags('distinct_id_random_22', only_evaluate_locally=True)}"
)
print(
f"All flags with properties: {hanzo_insights.get_all_flags('distinct_id_random_22', person_properties={'$geoip_city_name': 'Sydney'}, only_evaluate_locally=True)}"
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":
@@ -183,22 +183,22 @@ elif choice == "3":
print("FEATURE FLAG PAYLOAD EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
print("📦 Testing feature flag payloads...")
print(
f"beta-feature payload: {hanzo_insights.get_feature_flag_payload('beta-feature', 'distinct_id')}"
f"beta-feature payload: {posthog.get_feature_flag_payload('beta-feature', 'distinct_id')}"
)
print(
f"All flags and payloads: {hanzo_insights.get_all_flags_and_payloads('distinct_id')}"
f"All flags and payloads: {posthog.get_all_flags_and_payloads('distinct_id')}"
)
print(
f"Remote config payload: {hanzo_insights.get_remote_config_payload('encrypted_payload_flag_key')}"
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 = hanzo_insights.get_feature_flag_result("beta-feature", "distinct_id")
result = posthog.get_feature_flag_result("beta-feature", "distinct_id")
if result:
print(f"Flag key: {result.key}")
print(f"Flag enabled: {result.enabled}")
@@ -212,9 +212,9 @@ elif choice == "4":
if not local_eval_available:
print("\n❌ This example requires a personal API key for local evaluation.")
print(
" Set INSIGHTS_PERSONAL_API_KEY environment variable to run this example."
" Set POSTHOG_PERSONAL_API_KEY environment variable to run this example."
)
hanzo_insights.shutdown()
posthog.shutdown()
exit(1)
print("\n" + "=" * 60)
@@ -234,10 +234,10 @@ elif choice == "4":
print(" - Rollout: 100%")
print("")
hanzo_insights.debug = True
posthog.debug = True
# Test @example.com user (should satisfy dependency if flags exist)
result1 = hanzo_insights.feature_enabled(
result1 = posthog.feature_enabled(
"test-flag-dependency",
"example_user",
person_properties={"email": "user@example.com"},
@@ -246,7 +246,7 @@ elif choice == "4":
print(f"✅ @example.com user (test-flag-dependency): {result1}")
# Test non-example.com user (dependency should not be satisfied)
result2 = hanzo_insights.feature_enabled(
result2 = posthog.feature_enabled(
"test-flag-dependency",
"regular_user",
person_properties={"email": "user@other.com"},
@@ -255,13 +255,13 @@ elif choice == "4":
print(f"❌ Regular user (test-flag-dependency): {result2}")
# Test beta-feature directly for comparison
beta1 = hanzo_insights.feature_enabled(
beta1 = posthog.feature_enabled(
"beta-feature",
"example_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
beta2 = hanzo_insights.feature_enabled(
beta2 = posthog.feature_enabled(
"beta-feature",
"regular_user",
person_properties={"email": "user@other.com"},
@@ -303,7 +303,7 @@ elif choice == "4":
print("")
# Test pineapple -> blue -> breaking-bad chain
dependent_result3 = hanzo_insights.get_feature_flag(
dependent_result3 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "pineapple@example.com"},
@@ -317,7 +317,7 @@ elif choice == "4":
print("'multivariate-root-flag' with email pineapple@example.com succeeded")
# Test mango -> red -> the-wire chain
dependent_result4 = hanzo_insights.get_feature_flag(
dependent_result4 = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": "mango@example.com"},
@@ -336,19 +336,19 @@ elif choice == "4":
("pineapple@example.com", ["pineapple", "blue", "breaking-bad"]),
("mango@example.com", ["mango", "red", "the-wire"]),
]:
leaf = hanzo_insights.get_feature_flag(
leaf = posthog.get_feature_flag(
"multivariate-leaf-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
intermediate = hanzo_insights.get_feature_flag(
intermediate = posthog.get_feature_flag(
"multivariate-intermediate-flag",
"regular_user",
person_properties={"email": email},
only_evaluate_locally=True,
)
root = hanzo_insights.get_feature_flag(
root = posthog.get_feature_flag(
"multivariate-root-flag",
"regular_user",
person_properties={"email": email},
@@ -373,7 +373,7 @@ elif choice == "5":
print("CONTEXT MANAGEMENT AND TAGGING EXAMPLES")
print("=" * 60)
hanzo_insights.debug = True
posthog.debug = True
print("🏷️ Testing context management...")
print(
@@ -384,12 +384,12 @@ elif choice == "5":
# 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 hanzo_insights.new_context():
hanzo_insights.tag("transaction_id", "abc123")
hanzo_insights.tag("some_arbitrary_value", {"tags": "can be dicts"})
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
hanzo_insights.capture("order_processed")
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")
@@ -398,30 +398,30 @@ elif choice == "5":
# Use fresh=True to start with a clean context (no inherited tags)
try:
with hanzo_insights.new_context(fresh=True):
hanzo_insights.tag("session_id", "xyz789")
with posthog.new_context(fresh=True):
posthog.tag("session_id", "xyz789")
# Only session_id tag will be present, no inherited tags
hanzo_insights.capture("session_event")
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 `@hanzo_insights.scoped()` decorator to enter a new context.
# You can also use the `@posthog.scoped()` decorator to enter a new context.
# By default, it inherits tags from the parent context
@hanzo_insights.scoped()
@posthog.scoped()
def process_order(order_id):
hanzo_insights.tag("order_id", order_id)
hanzo_insights.capture("order_step_completed")
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)
@hanzo_insights.scoped(fresh=True)
@posthog.scoped(fresh=True)
def process_payment(payment_id):
hanzo_insights.tag("payment_id", payment_id)
hanzo_insights.capture("payment_processed")
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")
@@ -436,18 +436,18 @@ elif choice == "6":
# Run example 1
print(f"\n{'🔸' * 20} IDENTIFY AND CAPTURE {'🔸' * 20}")
hanzo_insights.debug = True
posthog.debug = True
print("📊 Capturing events...")
hanzo_insights.capture(
posthog.capture(
"event",
distinct_id="distinct_id",
properties={"property1": "value", "property2": "value"},
send_feature_flags=True,
)
print("🔗 Creating alias...")
hanzo_insights.alias("distinct_id", "new_distinct_id")
posthog.alias("distinct_id", "new_distinct_id")
print("👤 Identifying user...")
hanzo_insights.set(
posthog.set(
distinct_id="new_distinct_id", properties={"email": "something@something.com"}
)
@@ -455,27 +455,27 @@ elif choice == "6":
if local_eval_available:
print(f"\n{'🔸' * 20} FEATURE FLAGS {'🔸' * 20}")
print("🏁 Testing basic feature flags...")
print(f"beta-feature: {hanzo_insights.feature_enabled('beta-feature', 'distinct_id')}")
print(f"beta-feature: {posthog.feature_enabled('beta-feature', 'distinct_id')}")
print(
f"Sydney user: {hanzo_insights.feature_enabled('test-flag', 'random_id_12345', person_properties={'$geoip_city_name': 'Sydney'})}"
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: {hanzo_insights.get_feature_flag_payload('beta-feature', 'distinct_id')}")
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 = hanzo_insights.feature_enabled(
result1 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user",
person_properties={"email": "user@example.com"},
only_evaluate_locally=True,
)
result2 = hanzo_insights.feature_enabled(
result2 = posthog.feature_enabled(
"test-flag-dependency",
"demo_user2",
person_properties={"email": "user@other.com"},
@@ -486,23 +486,23 @@ elif choice == "6":
# Run example 5
print(f"\n{'🔸' * 20} CONTEXT MANAGEMENT {'🔸' * 20}")
print("🏷️ Testing context management...")
with hanzo_insights.new_context():
hanzo_insights.tag("demo_run", "all_examples")
hanzo_insights.capture("demo_completed")
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!")
hanzo_insights.shutdown()
posthog.shutdown()
exit()
else:
print("❌ Invalid choice. Please run again and select 1-7.")
hanzo_insights.shutdown()
posthog.shutdown()
exit()
print("\n" + "=" * 60)
print("✅ Example completed!")
print("=" * 60)
hanzo_insights.shutdown()
posthog.shutdown()
+11 -11
View File
@@ -1,17 +1,17 @@
"""
Redis-based distributed cache for Insights feature flag definitions.
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 hanzo_insights import Insights
from posthog import Posthog
redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)
cache = RedisFlagCache(redis_client, service_key="my-service")
client = Insights(
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
flag_definition_cache_provider=cache,
@@ -24,17 +24,17 @@ Requirements:
import json
import uuid
from hanzo_insights import FlagDefinitionCacheData, FlagDefinitionCacheProvider
from posthog import FlagDefinitionCacheData, FlagDefinitionCacheProvider
from redis import Redis
from typing import Optional
class RedisFlagCache(FlagDefinitionCacheProvider):
"""
A distributed cache for Insights feature flag definitions using Redis.
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 Insights for flag updates, while all instances
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:
@@ -83,8 +83,8 @@ class RedisFlagCache(FlagDefinitionCacheProvider):
Examples: "my-api-prod", "checkout-service", "staging".
Redis Keys Created:
- insights:flags:{service_key} - Cached flag definitions (JSON)
- insights:flags:{service_key}:lock - Leader election lock
- posthog:flags:{service_key} - Cached flag definitions (JSON)
- posthog:flags:{service_key}:lock - Leader election lock
Example:
redis_client = redis.Redis(
@@ -95,8 +95,8 @@ class RedisFlagCache(FlagDefinitionCacheProvider):
cache = RedisFlagCache(redis_client, service_key="my-api-prod")
"""
self._redis = redis
self._cache_key = f"insights:flags:{service_key}"
self._lock_key = f"insights:flags:{service_key}:lock"
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)
@@ -113,7 +113,7 @@ class RedisFlagCache(FlagDefinitionCacheProvider):
def should_fetch_flag_definitions(self) -> bool:
"""
Determines if this instance should fetch flag definitions from Insights.
Determines if this instance should fetch flag definitions from PostHog.
Atomically either:
- Acquires the lock if no one holds it, OR
+8 -8
View File
@@ -1,15 +1,15 @@
#!/usr/bin/env python3
"""
Simple test script for Insights remote config endpoint.
Simple test script for PostHog remote config endpoint.
"""
import hanzo_insights
import posthog
# Initialize Insights client
hanzo_insights.api_key = "phc_..."
hanzo_insights.personal_api_key = "phs_..." # or "phx_..."
hanzo_insights.host = "http://localhost:8000" # or "https://us.insights.hanzo.ai"
hanzo_insights.debug = True
# 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():
@@ -21,7 +21,7 @@ def test_remote_config():
try:
# Get remote config payload
payload = hanzo_insights.get_remote_config_payload(flag_key)
payload = posthog.get_remote_config_payload(flag_key)
print(f"✅ Success! Remote config payload for '{flag_key}': {payload}")
except Exception as e:
-3
View File
@@ -1,3 +0,0 @@
from hanzo_insights.ai.prompts import Prompts
__all__ = ["Prompts"]
-1
View File
@@ -1 +0,0 @@
VERSION = "7.9.7"
-3
View File
@@ -1,3 +0,0 @@
# Convenience re-export so `from insights import Insights` works.
from hanzo_insights import * # noqa: F401, F403
from hanzo_insights import Insights, Client # noqa: F401
@@ -1,10 +1,10 @@
"""
Test that verifies exception capture functionality.
These tests verify that exceptions are actually captured to Insights, not just that
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 Insights (v6.7.11 and earlier).
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.
"""
@@ -30,7 +30,7 @@ def asgi_app():
@pytest.mark.asyncio
async def test_async_exception_is_captured(asgi_app):
"""
Test that async view exceptions are captured to Insights.
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.
@@ -50,8 +50,8 @@ async def test_async_exception_is_captured(asgi_app):
}
)
# Patch at the hanzo_insights module level where middleware imports from
with patch("hanzo_insights.capture_exception", side_effect=mock_capture):
# 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:
@@ -60,8 +60,8 @@ async def test_async_exception_is_captured(asgi_app):
# Django returns 500
assert response.status_code == 500
# CRITICAL: Verify Insights captured the exception
assert len(captured) > 0, "Exception was NOT captured to Insights!"
# 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]
@@ -72,7 +72,7 @@ async def test_async_exception_is_captured(asgi_app):
@pytest.mark.asyncio
async def test_sync_exception_is_captured(asgi_app):
"""
Test that sync view exceptions are captured to Insights.
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.
@@ -92,8 +92,8 @@ async def test_sync_exception_is_captured(asgi_app):
}
)
# Patch at the hanzo_insights module level where middleware imports from
with patch("hanzo_insights.capture_exception", side_effect=mock_capture):
# 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:
@@ -102,8 +102,8 @@ async def test_sync_exception_is_captured(asgi_app):
# Django returns 500
assert response.status_code == 500
# CRITICAL: Verify Insights captured the exception
assert len(captured) > 0, "Exception was NOT captured to Insights!"
# 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]
+5 -5
View File
@@ -1,5 +1,5 @@
"""
Tests for Insights Django middleware in async context.
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)
@@ -103,7 +103,7 @@ async def test_async_authenticated_user_access(asgi_app):
# 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(INSIGHTS_MW_CAPTURE_EXCEPTIONS=False):
with override_settings(POSTHOG_MW_CAPTURE_EXCEPTIONS=False):
async with AsyncClient(
transport=ASGITransport(app=asgi_app),
base_url="http://testserver",
@@ -139,10 +139,10 @@ 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 Insights
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 Insights.
actual exception capture to PostHog.
"""
async with AsyncClient(
transport=ASGITransport(app=asgi_app), base_url="http://testserver"
@@ -158,7 +158,7 @@ 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 Insights.
The middleware's process_exception() method captures view exceptions to PostHog.
This test verifies the exception causes a 500 response.
"""
async with AsyncClient(
@@ -47,7 +47,7 @@ MIDDLEWARE = [
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"hanzo_insights.integrations.django.InsightsContextMiddleware", # Test Insights middleware
"posthog.integrations.django.PosthogContextMiddleware", # Test PostHog middleware
]
ROOT_URLCONF = "testdjango.urls"
@@ -123,7 +123,7 @@ STATIC_URL = "static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
# Insights settings for testing
INSIGHTS_API_KEY = "test-key"
# PostHog settings for testing
POSTHOG_API_KEY = "test-key"
POSTHOG_HOST = "https://app.posthog.com"
INSIGHTS_MW_CAPTURE_EXCEPTIONS = True
POSTHOG_MW_CAPTURE_EXCEPTIONS = True
@@ -1,5 +1,5 @@
"""
Test views for validating Insights middleware with Django 5 ASGI.
Test views for validating PostHog middleware with Django 5 ASGI.
"""
from django.http import JsonResponse
+2 -2
View File
@@ -17,10 +17,10 @@ ignore_missing_imports = True
[mypy-sentry_sdk.*]
ignore_missing_imports = True
[mypy-hanzo_insights.test.*]
[mypy-posthog.test.*]
ignore_errors = True
[mypy-hanzo_insights.*.test.*]
[mypy-posthog.*.test.*]
ignore_errors = True
[mypy-openai.*]
@@ -3,66 +3,66 @@ from typing import Any, Callable, Dict, Optional # noqa: F401
from typing_extensions import Unpack
from hanzo_insights.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from hanzo_insights.client import Client
from hanzo_insights.contexts import (
from posthog.args import ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.client import Client
from posthog.contexts import (
identify_context as inner_identify_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
new_context as inner_new_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
scoped as inner_scoped,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_capture_exception_code_variables_context as inner_set_capture_exception_code_variables_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_code_variables_ignore_patterns_context as inner_set_code_variables_ignore_patterns_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_code_variables_mask_patterns_context as inner_set_code_variables_mask_patterns_context,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_context_device_id as inner_set_context_device_id,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
set_context_session as inner_set_context_session,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
tag as inner_tag,
)
from hanzo_insights.contexts import (
from posthog.contexts import (
get_tags as inner_get_tags,
)
from hanzo_insights.exception_utils import (
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
)
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
InconclusiveMatchError as InconclusiveMatchError,
)
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
RequiresServerEvaluation as RequiresServerEvaluation,
)
from hanzo_insights.flag_definition_cache import (
from posthog.flag_definition_cache import (
FlagDefinitionCacheData as FlagDefinitionCacheData,
FlagDefinitionCacheProvider as FlagDefinitionCacheProvider,
)
from hanzo_insights.request import (
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 hanzo_insights.types import (
from posthog.types import (
FeatureFlag,
FlagsAndPayloads,
)
from hanzo_insights.types import (
from posthog.types import (
FeatureFlagResult as FeatureFlagResult,
)
from hanzo_insights.version import VERSION
from posthog.version import VERSION
__version__ = VERSION
@@ -76,11 +76,11 @@ def new_context(fresh=False, capture_exceptions=True, client=None):
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 Insights client instance to use for this context (default: None)
client: Optional Posthog client instance to use for this context (default: None)
Examples:
```python
from hanzo_insights import new_context, tag, capture
from posthog import new_context, tag, capture
with new_context():
tag("request_id", "123")
capture("event_name", properties={"property": "value"})
@@ -100,11 +100,11 @@ def scoped(fresh=False, capture_exceptions=True):
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture and track exceptions with Insights error tracking (default: True)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Examples:
```python
from hanzo_insights import scoped, tag, capture
from posthog import scoped, tag, capture
@scoped()
def process_payment(payment_id):
tag("payment_id", payment_id)
@@ -126,7 +126,7 @@ def set_context_session(session_id: str):
Examples:
```python
from hanzo_insights import set_context_session
from posthog import set_context_session
set_context_session("session_123")
```
@@ -146,7 +146,7 @@ def set_context_device_id(device_id: str):
Examples:
```python
from hanzo_insights import set_context_device_id
from posthog import set_context_device_id
set_context_device_id("device_123")
```
@@ -165,7 +165,7 @@ def identify_context(distinct_id: str):
Examples:
```python
from hanzo_insights import identify_context
from posthog import identify_context
identify_context("user_123")
```
@@ -206,7 +206,7 @@ def tag(name: str, value: Any):
Examples:
```python
from hanzo_insights import tag
from posthog import tag
tag("user_id", "123")
```
@@ -263,7 +263,7 @@ 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 `hanzo_insights.capture(distinct-id, event-name)` - basically, to enforce
# it impossible to write `posthog.capture(distinct-id, event-name)` - basically, to enforce
# the breaking change made between 5.3.0 and 6.0.0. This decision can be unrolled in later
# versions, without a breaking change, to get back the type information in function signatures
def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
@@ -280,12 +280,12 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
disable_geoip: Whether to disable GeoIP lookup
Details:
Capture allows you to capture anything a user does within your system, which you can later use in Insights to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
Capture allows you to capture anything a user does within your system, which you can later use in PostHog to find patterns in usage, work out which features to improve or where people are giving up. A capture call requires an event name to specify the event. We recommend using [verb] [noun], like `movie played` or `movie updated` to easily identify what your events mean later on. Capture takes a number of optional arguments, which are defined by the `OptionalCaptureArgs` type.
Examples:
```python
# Context and capture usage
from hanzo_insights import new_context, identify_context, tag_context, capture
from posthog import new_context, identify_context, tag_context, capture
# Enter a new context (e.g. a request/response cycle, an instance of a background job, etc)
with new_context():
# Associate this context with some user, by distinct_id
@@ -312,7 +312,7 @@ def capture(event: str, **kwargs: Unpack[OptionalCaptureArgs]) -> Optional[str]:
```
```python
# Set event properties
from hanzo_insights import capture
from posthog import capture
capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
@@ -339,7 +339,7 @@ def set(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
Examples:
```python
# Set person properties
from hanzo_insights import capture
from posthog import capture
capture(
'distinct_id',
event='event_name',
@@ -366,7 +366,7 @@ def set_once(**kwargs: Unpack[OptionalSetArgs]) -> Optional[str]:
Examples:
```python
# Set property once
from hanzo_insights import capture
from posthog import capture
capture(
'distinct_id',
event='event_name',
@@ -406,7 +406,7 @@ def group_identify(
Examples:
```python
# Group identify
from hanzo_insights import group_identify
from posthog import group_identify
group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
@@ -451,7 +451,7 @@ def alias(
Examples:
```python
# Alias user
from hanzo_insights import alias
from posthog import alias
alias(previous_id='distinct_id', distinct_id='alias_id')
```
Category:
@@ -479,12 +479,12 @@ def capture_exception(
exception: The exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
Details:
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in hanzo_insights. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with hanzo_insights.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `hanzo_insights.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
Capture exception is idempotent - if it is called twice with the same exception instance, only a occurrence will be tracked in posthog. This is because, generally, contexts will cause exceptions to be captured automatically. However, to ensure you track an exception, if you catch and do not re-raise it, capturing it manually is recommended, unless you are certain it will have crossed a context boundary (e.g. by existing a `with posthog.new_context():` block already). If the passed exception was raised and caught, the captured stack trace will consist of every frame between where the exception was raised and the point at which it is captured (the "traceback"). If the passed exception was never raised, e.g. if you call `posthog.capture_exception(ValueError("Some Error"))`, the stack trace captured will be the full stack trace at the moment the exception was captured. Note that heavy use of contexts will lead to truncated stack traces, as the exception will be captured by the context entered most recently, which may not be the point you catch the exception for the final time in your code. It's recommended to use contexts sparingly, for this reason. `capture_exception` takes the same set of optional arguments as `capture`.
Examples:
```python
# Capture exception
from hanzo_insights import capture_exception
from posthog import capture_exception
try:
risky_operation()
except Exception as e:
@@ -523,12 +523,12 @@ def feature_enabled(
disable_geoip: Whether to disable GeoIP lookup
Details:
You can call `hanzo_insights.load_feature_flags()` before to make sure you're not doing unexpected requests.
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
Examples:
```python
# Boolean feature flag
from hanzo_insights import feature_enabled, get_feature_flag_payload
from posthog import feature_enabled, get_feature_flag_payload
is_my_flag_enabled = feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
@@ -575,12 +575,12 @@ def get_feature_flag(
disable_geoip: Whether to disable GeoIP lookup
Details:
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "Hanzo", "employees": 11}}.
`groups` are a mapping from group type to group key. So, if you have a group type of "organization" and a group key of "5", you would pass groups={"organization": "5"}. `group_properties` take the format: { group_type_name: { group_properties } }. So, for example, if you have the group type "organization" and the group key "5", with the properties name, and employee count, you'll send these as: group_properties={"organization": {"name": "PostHog", "employees": 11}}.
Examples:
```python
# Multivariate feature flag
from hanzo_insights import get_feature_flag, get_feature_flag_payload
from posthog import get_feature_flag, get_feature_flag_payload
enabled_variant = get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key':
matched_flag_payload = get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
@@ -628,7 +628,7 @@ def get_all_flags(
Examples:
```python
# All flags for user
from hanzo_insights import get_all_flags
from posthog import get_all_flags
get_all_flags('distinct_id_of_your_user')
```
Category:
@@ -670,7 +670,7 @@ def get_feature_flag_result(
Example:
```python
result = hanzo_insights.get_feature_flag_result('beta-feature', 'distinct_id')
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}")
@@ -768,7 +768,7 @@ def feature_flag_definitions():
Examples:
```python
from hanzo_insights import feature_flag_definitions
from posthog import feature_flag_definitions
definitions = feature_flag_definitions()
```
@@ -780,11 +780,11 @@ def feature_flag_definitions():
def load_feature_flags():
"""
Load feature flag definitions from the server.
Load feature flag definitions from PostHog.
Examples:
```python
from hanzo_insights import load_feature_flags
from posthog import load_feature_flags
load_feature_flags()
```
@@ -800,7 +800,7 @@ def flush():
Examples:
```python
from hanzo_insights import flush
from posthog import flush
flush()
```
@@ -816,7 +816,7 @@ def join():
Examples:
```python
from hanzo_insights import join
from posthog import join
join()
```
@@ -832,7 +832,7 @@ def shutdown():
Examples:
```python
from hanzo_insights import shutdown
from posthog import shutdown
shutdown()
```
@@ -888,7 +888,5 @@ def _proxy(method, *args, **kwargs):
return fn(*args, **kwargs)
class Insights(Client):
"""Hanzo Insights client for product analytics."""
class Posthog(Client):
pass
+3
View File
@@ -0,0 +1,3 @@
from posthog.ai.prompts import Prompts
__all__ = ["Prompts"]
@@ -10,38 +10,38 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from hanzo_insights.ai.utils import (
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from posthog.ai.utils import (
call_llm_and_track_usage,
merge_usage_stats,
)
from hanzo_insights.ai.anthropic.anthropic_converter import (
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 hanzo_insights.ai.sanitization import sanitize_anthropic
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.ai.sanitization import sanitize_anthropic
from posthog.client import Client as PostHogClient
from posthog import setup
class Anthropic(anthropic.Anthropic):
"""
A wrapper around the Anthropic SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
insights_client: Insights client for tracking usage
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
@@ -50,46 +50,46 @@ class WrappedMessages(Messages):
def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create a message using Anthropic's API while tracking usage in Insights.
Create a message using Anthropic's API while tracking usage in PostHog.
Args:
insights_distinct_id: Optional ID to associate with the usage event
insights_trace_id: Optional trace UUID for linking events
insights_properties: Optional dictionary of extra properties to include in the event
insights_privacy_mode: Whether to redact sensitive information in tracking
insights_groups: Optional group analytics properties
posthog_distinct_id: Optional ID to associate with the usage event
posthog_trace_id: Optional trace UUID for linking events
posthog_properties: Optional dictionary of extra properties to include in the event
posthog_privacy_mode: Whether to redact sensitive information in tracking
posthog_groups: Optional group analytics properties
**kwargs: Arguments passed to Anthropic's messages.create
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"anthropic",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
@@ -97,32 +97,32 @@ class WrappedMessages(Messages):
def stream(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
return self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
def _create_streaming(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -188,11 +188,11 @@ class WrappedMessages(Messages):
latency = end_time - start_time
self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -204,23 +204,23 @@ class WrappedMessages(Messages):
def _capture_streaming_event(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
from hanzo_insights.ai.types import StreamingEventData
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
@@ -237,11 +237,11 @@ class WrappedMessages(Messages):
),
usage_stats=usage_stats,
latency=latency,
distinct_id=insights_distinct_id,
trace_id=insights_trace_id,
properties=insights_properties,
privacy_mode=insights_privacy_mode,
groups=insights_groups,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -10,38 +10,38 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from hanzo_insights import setup
from hanzo_insights.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from hanzo_insights.ai.utils import (
from posthog import setup
from posthog.ai.types import StreamingContentBlock, TokenUsage, ToolInProgress
from posthog.ai.utils import (
call_llm_and_track_usage_async,
merge_usage_stats,
)
from hanzo_insights.ai.anthropic.anthropic_converter import (
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 hanzo_insights.ai.sanitization import sanitize_anthropic
from hanzo_insights.client import Client as InsightsClient
from posthog.ai.sanitization import sanitize_anthropic
from posthog.client import Client as PostHogClient
class AsyncAnthropic(anthropic.AsyncAnthropic):
"""
An async wrapper around the Anthropic SDK that automatically sends LLM usage events to Insights.
An async wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
insights_client: Insights client for tracking usage
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
@@ -50,46 +50,46 @@ class AsyncWrappedMessages(AsyncMessages):
async def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create a message using Anthropic's API while tracking usage in Insights.
Create a message using Anthropic's API while tracking usage in PostHog.
Args:
insights_distinct_id: Optional ID to associate with the usage event
insights_trace_id: Optional trace UUID for linking events
insights_properties: Optional dictionary of extra properties to include in the event
insights_privacy_mode: Whether to redact sensitive information in tracking
insights_groups: Optional group analytics properties
posthog_distinct_id: Optional ID to associate with the usage event
posthog_trace_id: Optional trace UUID for linking events
posthog_properties: Optional dictionary of extra properties to include in the event
posthog_privacy_mode: Whether to redact sensitive information in tracking
posthog_groups: Optional group analytics properties
**kwargs: Arguments passed to Anthropic's messages.create
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return await self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"anthropic",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
@@ -97,32 +97,32 @@ class AsyncWrappedMessages(AsyncMessages):
async def stream(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
return await self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
async def _create_streaming(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -188,11 +188,11 @@ class AsyncWrappedMessages(AsyncMessages):
latency = end_time - start_time
await self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -204,23 +204,23 @@ class AsyncWrappedMessages(AsyncMessages):
async def _capture_streaming_event(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
content_blocks: List[StreamingContentBlock],
accumulated_content: str,
):
from hanzo_insights.ai.types import StreamingEventData
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.types import StreamingEventData
from posthog.ai.anthropic.anthropic_converter import (
format_anthropic_streaming_input,
format_anthropic_streaming_output_complete,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_anthropic_streaming_input(kwargs)
@@ -237,11 +237,11 @@ class AsyncWrappedMessages(AsyncMessages):
),
usage_stats=usage_stats,
latency=latency,
distinct_id=insights_distinct_id,
trace_id=insights_trace_id,
properties=insights_properties,
privacy_mode=insights_privacy_mode,
groups=insights_groups,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -2,13 +2,13 @@
Anthropic-specific conversion utilities.
This module handles the conversion of Anthropic API responses and inputs
into standardized formats for Insights tracking.
into standardized formats for PostHog tracking.
"""
import json
from typing import Any, Dict, List, Optional, Tuple
from hanzo_insights.ai.types import (
from posthog.ai.types import (
FormattedContentItem,
FormattedFunctionCall,
FormattedMessage,
@@ -17,7 +17,7 @@ from hanzo_insights.ai.types import (
TokenUsage,
ToolInProgress,
)
from hanzo_insights.ai.utils import serialize_raw_usage
from posthog.ai.utils import serialize_raw_usage
def format_anthropic_response(response: Any) -> List[FormattedMessage]:
@@ -425,9 +425,9 @@ def format_anthropic_streaming_input(kwargs: Dict[str, Any]) -> Any:
kwargs: Keyword arguments passed to Anthropic API
Returns:
Formatted input ready for Insights tracking
Formatted input ready for PostHog tracking
"""
from hanzo_insights.ai.utils import merge_system_prompt
from posthog.ai.utils import merge_system_prompt
return merge_system_prompt(kwargs, "anthropic")
@@ -445,7 +445,7 @@ def format_anthropic_streaming_output_complete(
accumulated_content: Raw accumulated text content as fallback
Returns:
Formatted messages ready for Insights tracking
Formatted messages ready for PostHog tracking
"""
formatted_content = format_anthropic_streaming_content(content_blocks)
@@ -7,59 +7,59 @@ except ImportError:
from typing import Optional
from hanzo_insights.ai.anthropic.anthropic import WrappedMessages
from hanzo_insights.ai.anthropic.anthropic_async import AsyncWrappedMessages
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.ai.anthropic.anthropic import WrappedMessages
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
from posthog.client import Client as PostHogClient
from posthog import setup
class AnthropicBedrock(anthropic.AnthropicBedrock):
"""
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
"""
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
class AnthropicVertex(anthropic.AnthropicVertex):
"""
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = WrappedMessages(self)
class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
"""
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to Insights.
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
self.messages = AsyncWrappedMessages(self)
@@ -3,8 +3,8 @@ import time
import uuid
from typing import Any, Dict, Optional
from hanzo_insights.ai.types import TokenUsage, StreamingEventData
from hanzo_insights.ai.utils import merge_system_prompt
from posthog.ai.types import TokenUsage, StreamingEventData
from posthog.ai.utils import merge_system_prompt
try:
from google import genai
@@ -13,40 +13,40 @@ except ImportError:
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
)
from hanzo_insights import setup
from hanzo_insights.ai.utils import (
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage,
capture_streaming_event,
merge_usage_stats,
)
from hanzo_insights.ai.gemini.gemini_converter import (
from posthog.ai.gemini.gemini_converter import (
extract_gemini_usage_from_chunk,
extract_gemini_content_from_chunk,
format_gemini_streaming_output,
)
from hanzo_insights.ai.sanitization import sanitize_gemini
from hanzo_insights.client import Client as InsightsClient
from posthog.ai.sanitization import sanitize_gemini
from posthog.client import Client as PostHogClient
class Client:
"""
A drop-in replacement for genai.Client that automatically sends LLM usage events to Insights.
A drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
Usage:
client = Client(
api_key="your_api_key",
insights_client=insights_client,
insights_distinct_id="default_user", # Optional defaults
insights_properties={"team": "ai"} # Optional defaults
posthog_client=posthog_client,
posthog_distinct_id="default_user", # Optional defaults
posthog_properties={"team": "ai"} # Optional defaults
)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello world"],
insights_distinct_id="specific_user" # Override default
posthog_distinct_id="specific_user" # Override default
)
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(
self,
@@ -57,11 +57,11 @@ class Client:
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
insights_client: Optional[InsightsClient] = None,
insights_distinct_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, 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,
):
"""
@@ -73,18 +73,18 @@ class Client:
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
insights_client: Insights client for tracking usage
insights_distinct_id: Default distinct ID for all calls (can be overridden per call)
insights_properties: Default properties for all calls (can be overridden per call)
insights_privacy_mode: Default privacy mode for all calls (can be overridden per call)
insights_groups: Default groups for all calls (can be overridden per call)
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 = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog tracking")
self.models = Models(
api_key=api_key,
@@ -94,21 +94,21 @@ class Client:
location=location,
debug_config=debug_config,
http_options=http_options,
insights_client=self._ph_client,
insights_distinct_id=insights_distinct_id,
insights_properties=insights_properties,
insights_privacy_mode=insights_privacy_mode,
insights_groups=insights_groups,
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 Models:
"""
Models interface that mimics genai.Client().models with Insights tracking.
Models interface that mimics genai.Client().models with PostHog tracking.
"""
_ph_client: InsightsClient # Not None after __init__ validation
_ph_client: PostHogClient # Not None after __init__ validation
def __init__(
self,
@@ -119,11 +119,11 @@ class Models:
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
insights_client: Optional[InsightsClient] = None,
insights_distinct_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, 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,
):
"""
@@ -135,24 +135,24 @@ class Models:
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
insights_client: Insights client for tracking usage
insights_distinct_id: Default distinct ID for all calls
insights_properties: Default properties for all calls
insights_privacy_mode: Default privacy mode for all calls
insights_groups: Default groups for all calls
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 = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog tracking")
# Store default Insights settings
self._default_distinct_id = insights_distinct_id
self._default_properties = insights_properties or {}
self._default_privacy_mode = insights_privacy_mode
self._default_groups = insights_groups
# 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] = {}
@@ -196,7 +196,7 @@ class Models:
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_insights_params(
def _merge_posthog_params(
self,
call_distinct_id: Optional[str],
call_trace_id: Optional[str],
@@ -204,7 +204,7 @@ class Models:
call_privacy_mode: Optional[bool],
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level Insights parameters with client defaults."""
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = (
@@ -234,38 +234,38 @@ class Models:
self,
model: str,
contents,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: Optional[bool] = None,
insights_groups: Optional[Dict[str, Any]] = None,
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 Insights.
Generate content using Gemini's API while tracking usage in PostHog.
This method signature exactly matches genai.Client().models.generate_content()
with additional Insights tracking parameters.
with additional PostHog tracking parameters.
Args:
model: The model to use (e.g., 'gemini-2.0-flash')
contents: The input content for generation
insights_distinct_id: ID to associate with the usage event (overrides client default)
insights_trace_id: Trace UUID for linking events (auto-generated if not provided)
insights_properties: Extra properties to include in the event (merged with client defaults)
insights_privacy_mode: Whether to redact sensitive information (overrides client default)
insights_groups: Group analytics properties (overrides client default)
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 Insights parameters
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_insights_params(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -380,7 +380,7 @@ class Models:
capture_streaming_event(self._ph_client, event_data)
def _format_input(self, contents, **kwargs):
"""Format input contents for Insights tracking"""
"""Format input contents for PostHog tracking"""
# Create kwargs dict with contents for merge_system_prompt
input_kwargs = {"contents": contents, **kwargs}
@@ -390,21 +390,21 @@ class Models:
self,
model: str,
contents,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: Optional[bool] = None,
insights_groups: Optional[Dict[str, Any]] = None,
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 Insights parameters
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_insights_params(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -3,8 +3,8 @@ import time
import uuid
from typing import Any, Dict, Optional
from hanzo_insights.ai.types import TokenUsage, StreamingEventData
from hanzo_insights.ai.utils import merge_system_prompt
from posthog.ai.types import TokenUsage, StreamingEventData
from posthog.ai.utils import merge_system_prompt
try:
from google import genai
@@ -13,40 +13,40 @@ except ImportError:
"Please install the Google Gemini SDK to use this feature: 'pip install google-genai'"
)
from hanzo_insights import setup
from hanzo_insights.ai.utils import (
from posthog import setup
from posthog.ai.utils import (
call_llm_and_track_usage_async,
capture_streaming_event,
merge_usage_stats,
)
from hanzo_insights.ai.gemini.gemini_converter import (
from posthog.ai.gemini.gemini_converter import (
extract_gemini_usage_from_chunk,
extract_gemini_content_from_chunk,
format_gemini_streaming_output,
)
from hanzo_insights.ai.sanitization import sanitize_gemini
from hanzo_insights.client import Client as InsightsClient
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 Insights.
An async drop-in replacement for genai.Client that automatically sends LLM usage events to PostHog.
Usage:
client = AsyncClient(
api_key="your_api_key",
insights_client=insights_client,
insights_distinct_id="default_user", # Optional defaults
insights_properties={"team": "ai"} # Optional defaults
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"],
insights_distinct_id="specific_user" # Override default
posthog_distinct_id="specific_user" # Override default
)
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(
self,
@@ -57,11 +57,11 @@ class AsyncClient:
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
insights_client: Optional[InsightsClient] = None,
insights_distinct_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, 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,
):
"""
@@ -73,18 +73,18 @@ class AsyncClient:
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
insights_client: Insights client for tracking usage
insights_distinct_id: Default distinct ID for all calls (can be overridden per call)
insights_properties: Default properties for all calls (can be overridden per call)
insights_privacy_mode: Default privacy mode for all calls (can be overridden per call)
insights_groups: Default groups for all calls (can be overridden per call)
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 = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog tracking")
self.models = AsyncModels(
api_key=api_key,
@@ -94,21 +94,21 @@ class AsyncClient:
location=location,
debug_config=debug_config,
http_options=http_options,
insights_client=self._ph_client,
insights_distinct_id=insights_distinct_id,
insights_properties=insights_properties,
insights_privacy_mode=insights_privacy_mode,
insights_groups=insights_groups,
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 Insights tracking.
Async Models interface that mimics genai.Client().aio.models with PostHog tracking.
"""
_ph_client: InsightsClient # Not None after __init__ validation
_ph_client: PostHogClient # Not None after __init__ validation
def __init__(
self,
@@ -119,11 +119,11 @@ class AsyncModels:
location: Optional[str] = None,
debug_config: Optional[Any] = None,
http_options: Optional[Any] = None,
insights_client: Optional[InsightsClient] = None,
insights_distinct_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, 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,
):
"""
@@ -135,24 +135,24 @@ class AsyncModels:
location: GCP location for Vertex AI
debug_config: Debug configuration for the client
http_options: HTTP options for the client
insights_client: Insights client for tracking usage
insights_distinct_id: Default distinct ID for all calls
insights_properties: Default properties for all calls
insights_privacy_mode: Default privacy mode for all calls
insights_groups: Default groups for all calls
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 = insights_client or setup()
self._ph_client = posthog_client or setup()
if self._ph_client is None:
raise ValueError("insights_client is required for Insights tracking")
raise ValueError("posthog_client is required for PostHog tracking")
# Store default Insights settings
self._default_distinct_id = insights_distinct_id
self._default_properties = insights_properties or {}
self._default_privacy_mode = insights_privacy_mode
self._default_groups = insights_groups
# 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] = {}
@@ -196,7 +196,7 @@ class AsyncModels:
self._client = genai.Client(**client_args)
self._base_url = "https://generativelanguage.googleapis.com"
def _merge_insights_params(
def _merge_posthog_params(
self,
call_distinct_id: Optional[str],
call_trace_id: Optional[str],
@@ -204,7 +204,7 @@ class AsyncModels:
call_privacy_mode: Optional[bool],
call_groups: Optional[Dict[str, Any]],
):
"""Merge call-level Insights parameters with client defaults."""
"""Merge call-level PostHog parameters with client defaults."""
# Use call-level values if provided, otherwise fall back to defaults
distinct_id = (
@@ -234,38 +234,38 @@ class AsyncModels:
self,
model: str,
contents,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: Optional[bool] = None,
insights_groups: Optional[Dict[str, Any]] = None,
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 Insights.
Generate content using Gemini's API while tracking usage in PostHog.
This method signature exactly matches genai.Client().aio.models.generate_content()
with additional Insights tracking parameters.
with additional PostHog tracking parameters.
Args:
model: The model to use (e.g., 'gemini-2.0-flash')
contents: The input content for generation
insights_distinct_id: ID to associate with the usage event (overrides client default)
insights_trace_id: Trace UUID for linking events (auto-generated if not provided)
insights_properties: Extra properties to include in the event (merged with client defaults)
insights_privacy_mode: Whether to redact sensitive information (overrides client default)
insights_groups: Group analytics properties (overrides client default)
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 Insights parameters
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_insights_params(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -383,7 +383,7 @@ class AsyncModels:
capture_streaming_event(self._ph_client, event_data)
def _format_input(self, contents, **kwargs):
"""Format input contents for Insights tracking"""
"""Format input contents for PostHog tracking"""
# Create kwargs dict with contents for merge_system_prompt
input_kwargs = {"contents": contents, **kwargs}
@@ -393,21 +393,21 @@ class AsyncModels:
self,
model: str,
contents,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: Optional[bool] = None,
insights_groups: Optional[Dict[str, Any]] = None,
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 Insights parameters
# Merge PostHog parameters
distinct_id, trace_id, properties, privacy_mode, groups = (
self._merge_insights_params(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
self._merge_posthog_params(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
)
)
@@ -2,17 +2,17 @@
Gemini-specific conversion utilities.
This module handles the conversion of Gemini API responses and inputs
into standardized formats for Insights tracking.
into standardized formats for PostHog tracking.
"""
from typing import Any, Dict, List, Optional, TypedDict, Union
from hanzo_insights.ai.types import (
from posthog.ai.types import (
FormattedContentItem,
FormattedMessage,
TokenUsage,
)
from hanzo_insights.ai.utils import serialize_raw_usage
from posthog.ai.utils import serialize_raw_usage
class GeminiPart(TypedDict, total=False):
@@ -349,7 +349,7 @@ def format_gemini_input_with_system(
if system_instruction is not None:
has_system = any(msg.get("role") == "system" for msg in formatted_messages)
if not has_system:
from hanzo_insights.ai.types import FormattedMessage
from posthog.ai.types import FormattedMessage
system_message: FormattedMessage = {
"role": "system",
@@ -362,7 +362,7 @@ def format_gemini_input_with_system(
def format_gemini_input(contents: Any) -> List[FormattedMessage]:
"""
Format Gemini input contents into standardized message format for Insights tracking.
Format Gemini input contents into standardized message format for PostHog tracking.
This function handles various input formats:
- String inputs
@@ -41,12 +41,12 @@ from langchain_core.messages import (
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
from hanzo_insights import setup
from hanzo_insights.ai.sanitization import sanitize_langchain
from hanzo_insights.ai.utils import get_model_params, with_privacy_mode
from hanzo_insights.client import Client
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
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
@dataclass
@@ -79,8 +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."""
insights_properties: Optional[Dict[str, Any]] = None
"""Insights properties of the run."""
posthog_properties: Optional[Dict[str, Any]] = None
"""PostHog properties of the run."""
RunMetadata = Union[SpanMetadata, GenerationMetadata]
@@ -89,11 +89,11 @@ RunMetadataStorage = Dict[UUID, RunMetadata]
class CallbackHandler(BaseCallbackHandler):
"""
The Insights LLM observability callback handler for LangChain.
The PostHog LLM observability callback handler for LangChain.
"""
_ph_client: Client
"""Insights client instance."""
"""PostHog client instance."""
_distinct_id: Optional[Union[str, int, UUID]]
"""Distinct ID of the user to associate the trace with."""
@@ -131,12 +131,12 @@ class CallbackHandler(BaseCallbackHandler):
):
"""
Args:
client: Insights client instance.
client: PostHog client instance.
distinct_id: Optional distinct ID of the user to associate the trace with.
trace_id: Optional trace ID to use for the event.
properties: Optional additional metadata to use for the trace.
privacy_mode: Whether to redact the input and output of the trace.
groups: Optional additional Insights groups to use for the trace.
groups: Optional additional PostHog groups to use for the trace.
"""
self._ph_client = client or setup()
self._distinct_id = distinct_id
@@ -423,7 +423,7 @@ class CallbackHandler(BaseCallbackHandler):
if provider := metadata.get("ls_provider"):
generation.provider = provider
generation.insights_properties = metadata.get("insights_properties")
generation.posthog_properties = metadata.get("posthog_properties")
try:
base_url = serialized["kwargs"]["openai_api_base"]
if base_url is not None:
@@ -578,8 +578,8 @@ class CallbackHandler(BaseCallbackHandler):
"$ai_framework": "langchain",
}
if isinstance(run.insights_properties, dict):
event_properties.update(run.insights_properties)
if isinstance(run.posthog_properties, dict):
event_properties.update(run.posthog_properties)
if run.tools:
event_properties["$ai_tools"] = run.tools
@@ -2,7 +2,7 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import TokenUsage
from posthog.ai.types import TokenUsage
try:
import openai
@@ -11,40 +11,40 @@ except ImportError:
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
)
from hanzo_insights.ai.utils import (
from posthog.ai.utils import (
call_llm_and_track_usage,
extract_available_tool_calls,
merge_usage_stats,
with_privacy_mode,
)
from hanzo_insights.ai.openai.openai_converter import (
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 hanzo_insights.ai.sanitization import sanitize_openai, sanitize_openai_response
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
from posthog.client import Client as PostHogClient
from posthog import setup
class OpenAI(openai.OpenAI):
"""
A wrapper around the OpenAI SDK that automatically sends LLM usage events to Insights.
A wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: OpenAI API key.
insights_client: If provided, events will be captured via this client instead of the global client.
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 = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -67,7 +67,7 @@ class OpenAI(openai.OpenAI):
class WrappedResponses:
"""Wrapper for OpenAI responses that tracks usage in Insights."""
"""Wrapper for OpenAI responses that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_responses):
self._client = client
@@ -79,34 +79,34 @@ class WrappedResponses:
def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -114,11 +114,11 @@ class WrappedResponses:
def _create_streaming(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -160,11 +160,11 @@ class WrappedResponses:
latency = end_time - start_time
output = final_content
self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -177,11 +177,11 @@ class WrappedResponses:
def _capture_streaming_event(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -189,12 +189,12 @@ class WrappedResponses:
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
from hanzo_insights.ai.types import StreamingEventData
from hanzo_insights.ai.openai.openai_converter import (
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
format_openai_streaming_input,
format_openai_streaming_output,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_openai_streaming_input(kwargs, "responses")
@@ -212,11 +212,11 @@ class WrappedResponses:
formatted_output=format_openai_streaming_output(output, "responses"),
usage_stats=usage_stats,
latency=latency,
distinct_id=insights_distinct_id,
trace_id=insights_trace_id,
properties=insights_properties,
privacy_mode=insights_privacy_mode,
groups=insights_groups,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -224,35 +224,35 @@ class WrappedResponses:
def parse(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in Insights.
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
Args:
insights_distinct_id: Optional ID to associate with the usage event.
insights_trace_id: Optional trace UUID for linking events.
insights_properties: Optional dictionary of extra properties to include in the event.
insights_privacy_mode: Whether to anonymize the input and output.
insights_groups: Optional dictionary of groups to associate with the event.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -260,7 +260,7 @@ class WrappedResponses:
class WrappedChat:
"""Wrapper for OpenAI chat that tracks usage in Insights."""
"""Wrapper for OpenAI chat that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_chat):
self._client = client
@@ -276,7 +276,7 @@ class WrappedChat:
class WrappedCompletions:
"""Wrapper for OpenAI chat completions that tracks usage in Insights."""
"""Wrapper for OpenAI chat completions that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_completions):
self._client = client
@@ -288,34 +288,34 @@ class WrappedCompletions:
def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -323,11 +323,11 @@ class WrappedCompletions:
def _create_streaming(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -385,11 +385,11 @@ class WrappedCompletions:
)
self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -403,11 +403,11 @@ class WrappedCompletions:
def _capture_streaming_event(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -416,12 +416,12 @@ class WrappedCompletions:
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
from hanzo_insights.ai.types import StreamingEventData
from hanzo_insights.ai.openai.openai_converter import (
from posthog.ai.types import StreamingEventData
from posthog.ai.openai.openai_converter import (
format_openai_streaming_input,
format_openai_streaming_output,
)
from hanzo_insights.ai.utils import capture_streaming_event
from posthog.ai.utils import capture_streaming_event
# Prepare standardized event data
formatted_input = format_openai_streaming_input(kwargs, "chat")
@@ -439,11 +439,11 @@ class WrappedCompletions:
formatted_output=format_openai_streaming_output(output, "chat", tool_calls),
usage_stats=usage_stats,
latency=latency,
distinct_id=insights_distinct_id,
trace_id=insights_trace_id,
properties=insights_properties,
privacy_mode=insights_privacy_mode,
groups=insights_groups,
distinct_id=posthog_distinct_id,
trace_id=posthog_trace_id,
properties=posthog_properties,
privacy_mode=posthog_privacy_mode,
groups=posthog_groups,
)
# Use the common capture function
@@ -451,7 +451,7 @@ class WrappedCompletions:
class WrappedEmbeddings:
"""Wrapper for OpenAI embeddings that tracks usage in Insights."""
"""Wrapper for OpenAI embeddings that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_embeddings):
self._client = client
@@ -463,30 +463,30 @@ class WrappedEmbeddings:
def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in Insights.
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
Args:
insights_distinct_id: Optional ID to associate with the usage event.
insights_trace_id: Optional trace UUID for linking events.
insights_properties: Optional dictionary of extra properties to include in the event.
insights_privacy_mode: Whether to anonymize the input and output.
insights_groups: Optional dictionary of groups to associate with the event.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
The response from OpenAI's embeddings.create call.
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = self._original.create(**kwargs)
@@ -508,34 +508,34 @@ class WrappedEmbeddings:
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
# Send capture event for embeddings
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_embedding",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
return response
class WrappedBeta:
"""Wrapper for OpenAI beta features that tracks usage in Insights."""
"""Wrapper for OpenAI beta features that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta):
self._client = client
@@ -551,7 +551,7 @@ class WrappedBeta:
class WrappedBetaChat:
"""Wrapper for OpenAI beta chat that tracks usage in Insights."""
"""Wrapper for OpenAI beta chat that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta_chat):
self._client = client
@@ -567,7 +567,7 @@ class WrappedBetaChat:
class WrappedBetaCompletions:
"""Wrapper for OpenAI beta chat completions that tracks usage in Insights."""
"""Wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
def __init__(self, client: OpenAI, original_beta_completions):
self._client = client
@@ -579,21 +579,21 @@ class WrappedBetaCompletions:
def parse(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
return call_llm_and_track_usage(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -2,7 +2,7 @@ import time
import uuid
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import TokenUsage
from posthog.ai.types import TokenUsage
try:
import openai
@@ -11,43 +11,43 @@ except ImportError:
"Please install the OpenAI SDK to use this feature: 'pip install openai'"
)
from hanzo_insights import setup
from hanzo_insights.ai.utils import (
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 hanzo_insights.ai.openai.openai_converter import (
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 hanzo_insights.ai.sanitization import sanitize_openai, sanitize_openai_response
from hanzo_insights.client import Client as InsightsClient
from posthog.ai.sanitization import sanitize_openai, sanitize_openai_response
from posthog.client import Client as PostHogClient
class AsyncOpenAI(openai.AsyncOpenAI):
"""
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to Insights.
An async wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: OpenAI API key.
insights_client: If provided, events will be captured via this client instead
of the global hanzo_insights.
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 = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -70,7 +70,7 @@ class AsyncOpenAI(openai.AsyncOpenAI):
class WrappedResponses:
"""Async wrapper for OpenAI responses that tracks usage in Insights."""
"""Async wrapper for OpenAI responses that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_responses):
self._client = client
@@ -83,34 +83,34 @@ class WrappedResponses:
async def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return await self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -118,11 +118,11 @@ class WrappedResponses:
async def _create_streaming(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -165,11 +165,11 @@ class WrappedResponses:
output = final_content
await self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -182,11 +182,11 @@ class WrappedResponses:
async def _capture_streaming_event(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -194,8 +194,8 @@ class WrappedResponses:
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
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"
@@ -206,12 +206,12 @@ class WrappedResponses:
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
format_openai_streaming_output(output, "responses"),
),
"$ai_http_status": 200,
@@ -222,9 +222,9 @@ class WrappedResponses:
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
# Add web search count if present
@@ -239,48 +239,48 @@ class WrappedResponses:
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
async def parse(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in Insights.
Parse structured output using OpenAI's 'responses.parse' method, but also track usage in PostHog.
Args:
insights_distinct_id: Optional ID to associate with the usage event.
insights_trace_id: Optional trace UUID for linking events.
insights_properties: Optional dictionary of extra properties to include in the event.
insights_privacy_mode: Whether to anonymize the input and output.
insights_groups: Optional dictionary of groups to associate with the event.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Responses Parse API.
Returns:
The response from OpenAI's responses.parse call.
"""
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -288,7 +288,7 @@ class WrappedResponses:
class WrappedChat:
"""Async wrapper for OpenAI chat that tracks usage in Insights."""
"""Async wrapper for OpenAI chat that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_chat):
self._client = client
@@ -304,7 +304,7 @@ class WrappedChat:
class WrappedCompletions:
"""Async wrapper for OpenAI chat completions that tracks usage in Insights."""
"""Async wrapper for OpenAI chat completions that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_completions):
self._client = client
@@ -316,35 +316,35 @@ class WrappedCompletions:
async def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
# If streaming, handle streaming specifically
if kwargs.get("stream", False):
return await self._create_streaming(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
response = await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.create,
**kwargs,
@@ -353,11 +353,11 @@ class WrappedCompletions:
async def _create_streaming(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
**kwargs: Any,
):
start_time = time.time()
@@ -414,11 +414,11 @@ class WrappedCompletions:
)
await self._capture_streaming_event(
insights_distinct_id,
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
@@ -432,11 +432,11 @@ class WrappedCompletions:
async def _capture_streaming_event(
self,
insights_distinct_id: Optional[str],
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
kwargs: Dict[str, Any],
usage_stats: TokenUsage,
latency: float,
@@ -445,8 +445,8 @@ class WrappedCompletions:
available_tool_calls: Optional[List[Dict[str, Any]]] = None,
model_from_response: Optional[str] = None,
):
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
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"
@@ -457,12 +457,12 @@ class WrappedCompletions:
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai(kwargs.get("messages")),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
format_openai_streaming_output(output, "chat", tool_calls),
),
"$ai_http_status": 200,
@@ -473,9 +473,9 @@ class WrappedCompletions:
),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
# Add web search count if present
@@ -491,20 +491,20 @@ class WrappedCompletions:
if available_tool_calls:
event_properties["$ai_tools"] = available_tool_calls
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
class WrappedEmbeddings:
"""Async wrapper for OpenAI embeddings that tracks usage in Insights."""
"""Async wrapper for OpenAI embeddings that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_embeddings):
self._client = client
@@ -517,30 +517,30 @@ class WrappedEmbeddings:
async def create(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
"""
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in Insights.
Create an embedding using OpenAI's 'embeddings.create' method, but also track usage in PostHog.
Args:
insights_distinct_id: Optional ID to associate with the usage event.
insights_trace_id: Optional trace UUID for linking events.
insights_properties: Optional dictionary of extra properties to include in the event.
insights_privacy_mode: Whether to anonymize the input and output.
insights_groups: Optional dictionary of groups to associate with the event.
posthog_distinct_id: Optional ID to associate with the usage event.
posthog_trace_id: Optional trace UUID for linking events.
posthog_properties: Optional dictionary of extra properties to include in the event.
posthog_privacy_mode: Whether to anonymize the input and output.
posthog_groups: Optional dictionary of groups to associate with the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
The response from OpenAI's embeddings.create call.
"""
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = await self._original.create(**kwargs)
@@ -563,34 +563,34 @@ class WrappedEmbeddings:
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(
self._client._ph_client,
insights_privacy_mode,
posthog_privacy_mode,
sanitize_openai_response(kwargs.get("input")),
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": insights_trace_id,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(insights_properties or {}),
**(posthog_properties or {}),
}
if insights_distinct_id is None:
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
# Send capture event for embeddings
if hasattr(self._client._ph_client, "capture"):
self._client._ph_client.capture(
distinct_id=insights_distinct_id or insights_trace_id,
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_embedding",
properties=event_properties,
groups=insights_groups,
groups=posthog_groups,
)
return response
class WrappedBeta:
"""Async wrapper for OpenAI beta features that tracks usage in Insights."""
"""Async wrapper for OpenAI beta features that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta):
self._client = client
@@ -607,7 +607,7 @@ class WrappedBeta:
class WrappedBetaChat:
"""Async wrapper for OpenAI beta chat that tracks usage in Insights."""
"""Async wrapper for OpenAI beta chat that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta_chat):
self._client = client
@@ -624,7 +624,7 @@ class WrappedBetaChat:
class WrappedBetaCompletions:
"""Async wrapper for OpenAI beta chat completions that tracks usage in Insights."""
"""Async wrapper for OpenAI beta chat completions that tracks usage in PostHog."""
def __init__(self, client: AsyncOpenAI, original_beta_completions):
self._client = client
@@ -637,21 +637,21 @@ class WrappedBetaCompletions:
async def parse(
self,
insights_distinct_id: Optional[str] = None,
insights_trace_id: Optional[str] = None,
insights_properties: Optional[Dict[str, Any]] = None,
insights_privacy_mode: bool = False,
insights_groups: Optional[Dict[str, Any]] = None,
posthog_distinct_id: Optional[str] = None,
posthog_trace_id: Optional[str] = None,
posthog_properties: Optional[Dict[str, Any]] = None,
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
return await call_llm_and_track_usage_async(
insights_distinct_id,
posthog_distinct_id,
self._client._ph_client,
"openai",
insights_trace_id,
insights_properties,
insights_privacy_mode,
insights_groups,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
self._original.parse,
**kwargs,
@@ -2,13 +2,13 @@
OpenAI-specific conversion utilities.
This module handles the conversion of OpenAI API responses and inputs
into standardized formats for Insights tracking. It supports both
into standardized formats for PostHog tracking. It supports both
Chat Completions API and Responses API formats.
"""
from typing import Any, Dict, List, Optional
from hanzo_insights.ai.types import (
from posthog.ai.types import (
FormattedContentItem,
FormattedFunctionCall,
FormattedImageContent,
@@ -16,7 +16,7 @@ from hanzo_insights.ai.types import (
FormattedTextContent,
TokenUsage,
)
from hanzo_insights.ai.utils import serialize_raw_usage
from posthog.ai.utils import serialize_raw_usage
def format_openai_response(response: Any) -> List[FormattedMessage]:
@@ -753,8 +753,8 @@ def format_openai_streaming_input(
api_type: Either "chat" or "responses"
Returns:
Formatted input ready for Insights tracking
Formatted input ready for PostHog tracking
"""
from hanzo_insights.ai.utils import merge_system_prompt
from posthog.ai.utils import merge_system_prompt
return merge_system_prompt(kwargs, "openai")
@@ -5,39 +5,39 @@ except ImportError:
"Please install the Open AI SDK to use this feature: 'pip install openai'"
)
from hanzo_insights.ai.openai.openai import (
from posthog.ai.openai.openai import (
WrappedBeta,
WrappedChat,
WrappedEmbeddings,
WrappedResponses,
)
from hanzo_insights.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
from hanzo_insights.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
from hanzo_insights.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
from hanzo_insights.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
from posthog.ai.openai.openai_async import WrappedBeta as AsyncWrappedBeta
from posthog.ai.openai.openai_async import WrappedChat as AsyncWrappedChat
from posthog.ai.openai.openai_async import WrappedEmbeddings as AsyncWrappedEmbeddings
from posthog.ai.openai.openai_async import WrappedResponses as AsyncWrappedResponses
from typing import Optional
from hanzo_insights.client import Client as InsightsClient
from hanzo_insights import setup
from posthog.client import Client as PostHogClient
from posthog import setup
class AzureOpenAI(openai.AzureOpenAI):
"""
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to Insights.
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: Azure OpenAI API key.
insights_client: If provided, events will be captured via this client instead
of the global hanzo_insights.
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 Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -61,21 +61,21 @@ class AzureOpenAI(openai.AzureOpenAI):
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
"""
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to Insights.
An async wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: InsightsClient
_ph_client: PostHogClient
def __init__(self, insights_client: Optional[InsightsClient] = None, **kwargs):
def __init__(self, posthog_client: Optional[PostHogClient] = None, **kwargs):
"""
Args:
api_key: Azure OpenAI API key.
insights_client: If provided, events will be captured via this client instead
of the global hanzo_insights.
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 Azure OpenAI (e.g. azure_endpoint="xxx").
"""
super().__init__(**kwargs)
self._ph_client = insights_client or setup()
self._ph_client = posthog_client or setup()
# Store original objects after parent initialization (only if they exist)
self._original_chat = getattr(self, "chat", None)
@@ -5,7 +5,7 @@ from typing import TYPE_CHECKING, Any, Callable, Dict, Optional, Union
if TYPE_CHECKING:
from agents.tracing import Trace
from hanzo_insights.client import Client
from posthog.client import Client
try:
import agents # noqa: F401
@@ -14,9 +14,9 @@ except ImportError:
"Please install the OpenAI Agents SDK to use this feature: 'pip install openai-agents'"
)
from hanzo_insights.ai.openai_agents.processor import InsightsTracingProcessor
from posthog.ai.openai_agents.processor import PostHogTracingProcessor
__all__ = ["InsightsTracingProcessor", "instrument"]
__all__ = ["PostHogTracingProcessor", "instrument"]
def instrument(
@@ -25,27 +25,27 @@ def instrument(
privacy_mode: bool = False,
groups: Optional[Dict[str, Any]] = None,
properties: Optional[Dict[str, Any]] = None,
) -> InsightsTracingProcessor:
) -> PostHogTracingProcessor:
"""
One-liner to instrument OpenAI Agents SDK with Hanzo Insights tracing.
One-liner to instrument OpenAI Agents SDK with PostHog tracing.
This registers an InsightsTracingProcessor with the OpenAI Agents SDK,
This registers a PostHogTracingProcessor with the OpenAI Agents SDK,
automatically capturing traces, spans, and LLM generations.
Args:
client: Optional Insights client instance. If not provided, uses the default client.
client: Optional PostHog client instance. If not provided, uses the default client.
distinct_id: Optional distinct ID to associate with all traces.
Can also be a callable that takes a trace and returns a distinct ID.
privacy_mode: If True, redacts input/output content from events.
groups: Optional Insights groups to associate with events.
groups: Optional PostHog groups to associate with events.
properties: Optional additional properties to include with all events.
Returns:
InsightsTracingProcessor: The registered processor instance.
PostHogTracingProcessor: The registered processor instance.
Example:
```python
from hanzo_insights.ai.openai_agents import instrument
from posthog.ai.openai_agents import instrument
# Simple setup
instrument(distinct_id="user@example.com")
@@ -57,7 +57,7 @@ def instrument(
properties={"environment": "production"}
)
# Now run agents as normal - traces automatically sent to Insights
# Now run agents as normal - traces automatically sent to PostHog
from agents import Agent, Runner
agent = Agent(name="Assistant", instructions="You are helpful.")
result = Runner.run_sync(agent, "Hello!")
@@ -65,7 +65,7 @@ def instrument(
"""
from agents.tracing import add_trace_processor
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=client,
distinct_id=distinct_id,
privacy_mode=privacy_mode,
@@ -20,10 +20,10 @@ from agents.tracing.span_data import (
TranscriptionSpanData,
)
from hanzo_insights import setup
from hanzo_insights.client import Client
from posthog import setup
from posthog.client import Client
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def _ensure_serializable(obj: Any) -> Any:
@@ -53,27 +53,27 @@ def _parse_iso_timestamp(iso_str: Optional[str]) -> Optional[float]:
return None
class InsightsTracingProcessor(TracingProcessor):
class PostHogTracingProcessor(TracingProcessor):
"""
A tracing processor that sends OpenAI Agents SDK traces to Hanzo Insights.
A tracing processor that sends OpenAI Agents SDK traces to PostHog.
This processor implements the TracingProcessor interface from the OpenAI Agents SDK
and maps agent traces, spans, and generations to Insights LLM analytics events.
and maps agent traces, spans, and generations to PostHog's LLM analytics events.
Example:
```python
from agents import Agent, Runner
from agents.tracing import add_trace_processor
from hanzo_insights.ai.openai_agents import InsightsTracingProcessor
from posthog.ai.openai_agents import PostHogTracingProcessor
# Create and register the processor
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
distinct_id="user@example.com",
privacy_mode=False,
)
add_trace_processor(processor)
# Run agents as normal - traces automatically sent to Insights
# Run agents as normal - traces automatically sent to PostHog
agent = Agent(name="Assistant", instructions="You are helpful.")
result = Runner.run_sync(agent, "Hello!")
```
@@ -88,14 +88,14 @@ class InsightsTracingProcessor(TracingProcessor):
properties: Optional[Dict[str, Any]] = None,
):
"""
Initialize the Insights tracing processor.
Initialize the PostHog tracing processor.
Args:
client: Optional Insights client instance. If not provided, uses the default client.
client: Optional PostHog client instance. If not provided, uses the default client.
distinct_id: Either a string distinct ID or a callable that takes a Trace
and returns a distinct ID. If not provided, uses the trace_id.
privacy_mode: If True, redacts input/output content from events.
groups: Optional Insights groups to associate with all events.
groups: Optional PostHog groups to associate with all events.
properties: Optional additional properties to include with all events.
"""
self._client = client or setup()
@@ -173,7 +173,7 @@ class InsightsTracingProcessor(TracingProcessor):
properties: Dict[str, Any],
distinct_id: Optional[str] = None,
) -> None:
"""Capture an event to Insights with error handling.
"""Capture an event to PostHog with error handling.
Args:
distinct_id: The resolved distinct ID. When the user didn't provide
@@ -199,7 +199,7 @@ class InsightsTracingProcessor(TracingProcessor):
groups=self._groups,
)
except Exception as e:
log.debug(f"Failed to capture Insights event: {e}")
log.debug(f"Failed to capture PostHog event: {e}")
def on_trace_start(self, trace: Trace) -> None:
"""Called when a new trace begins. Stores metadata for spans; the $ai_trace event is emitted in on_trace_end."""
@@ -848,7 +848,7 @@ class InsightsTracingProcessor(TracingProcessor):
self._span_start_times.clear()
self._trace_metadata.clear()
# Flush the Insights client if possible
# Flush the PostHog client if possible
if hasattr(self._client, "flush") and callable(self._client.flush):
self._client.flush()
except Exception as e:
@@ -1,7 +1,7 @@
"""
Prompt management for Hanzo Insights AI SDK.
Prompt management for PostHog AI SDK.
Fetch and compile LLM prompts from Insights with caching and fallback support.
Fetch and compile LLM prompts from PostHog with caching and fallback support.
"""
import logging
@@ -10,16 +10,15 @@ import time
import urllib.parse
from typing import Any, Dict, Optional, Union
from hanzo_insights.request import USER_AGENT, _get_session
from hanzo_insights.utils import remove_trailing_slash
from posthog.request import USER_AGENT, _get_session
from posthog.utils import remove_trailing_slash
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
APP_ENDPOINT = "https://us.insights.hanzo.ai"
APP_ENDPOINT = "https://us.posthog.com"
DEFAULT_CACHE_TTL_SECONDS = 300 # 5 minutes
PromptVariables = Dict[str, Union[str, int, float, bool]]
PromptCacheKey = tuple[str, Optional[int]]
class CachedPrompt:
@@ -30,19 +29,6 @@ class CachedPrompt:
self.fetched_at = fetched_at
def _cache_key(name: str, version: Optional[int]) -> PromptCacheKey:
"""Build a cache key for latest or versioned prompt fetches."""
return (name, version)
def _prompt_reference(name: str, version: Optional[int]) -> str:
"""Format a prompt reference for logs and errors."""
label = f'prompt "{name}"'
if version is not None:
return f"{label} version {version}"
return label
def _is_prompt_api_response(data: Any) -> bool:
"""Check if the response is a valid prompt API response."""
return (
@@ -54,32 +40,29 @@ def _is_prompt_api_response(data: Any) -> bool:
class Prompts:
"""
Fetch and compile LLM prompts from Insights.
Fetch and compile LLM prompts from PostHog.
Can be initialized with a Insights client or with direct options.
Can be initialized with a PostHog client or with direct options.
Examples:
```python
from hanzo_insights import Insights
from hanzo_insights.ai.prompts import Prompts
from posthog import Posthog
from posthog.ai.prompts import Prompts
# With Insights client
client = Insights('phc_xxx', host='https://us.insights.hanzo.ai', personal_api_key='phx_xxx')
prompts = Prompts(client)
# With PostHog client
posthog = Posthog('phc_xxx', host='https://us.posthog.com', personal_api_key='phx_xxx')
prompts = Prompts(posthog)
# Or with direct options (no Insights client needed)
# Or with direct options (no PostHog client needed)
prompts = Prompts(
personal_api_key='phx_xxx',
project_api_key='phc_xxx',
host='https://us.insights.hanzo.ai',
host='https://us.posthog.com',
)
# Fetch with caching and fallback
template = prompts.get('support-system-prompt', fallback='You are a helpful assistant.')
# Fetch a specific published version
prompt_v1 = prompts.get('support-system-prompt', version=1)
# Compile with variables
system_prompt = prompts.compile(template, {
'company': 'Acme Corp',
@@ -90,7 +73,7 @@ class Prompts:
def __init__(
self,
client: Optional[Any] = None,
posthog: Optional[Any] = None,
*,
personal_api_key: Optional[str] = None,
project_api_key: Optional[str] = None,
@@ -101,22 +84,22 @@ class Prompts:
Initialize Prompts.
Args:
client: Insights client instance (optional if personal_api_key provided)
personal_api_key: Direct personal API key (optional if client provided)
project_api_key: Direct project API key (optional if client provided)
host: Insights host (defaults to app endpoint)
posthog: PostHog client instance (optional if personal_api_key provided)
personal_api_key: Direct personal API key (optional if posthog provided)
project_api_key: Direct project API key (optional if posthog provided)
host: PostHog host (defaults to app endpoint)
default_cache_ttl_seconds: Default cache TTL (defaults to 300)
"""
self._default_cache_ttl_seconds = (
default_cache_ttl_seconds or DEFAULT_CACHE_TTL_SECONDS
)
self._cache: Dict[PromptCacheKey, CachedPrompt] = {}
self._cache: Dict[str, CachedPrompt] = {}
if client is not None:
self._personal_api_key = getattr(client, "personal_api_key", None) or ""
self._project_api_key = getattr(client, "api_key", None) or ""
if posthog is not None:
self._personal_api_key = getattr(posthog, "personal_api_key", None) or ""
self._project_api_key = getattr(posthog, "api_key", None) or ""
self._host = remove_trailing_slash(
getattr(client, "raw_host", None) or APP_ENDPOINT
getattr(posthog, "raw_host", None) or APP_ENDPOINT
)
else:
self._personal_api_key = personal_api_key or ""
@@ -129,10 +112,9 @@ class Prompts:
*,
cache_ttl_seconds: Optional[int] = None,
fallback: Optional[str] = None,
version: Optional[int] = None,
) -> str:
"""
Fetch a prompt by name from the Insights API.
Fetch a prompt by name from the PostHog API.
Caching behavior:
1. If cache is fresh, return cached value
@@ -144,8 +126,6 @@ class Prompts:
name: The name of the prompt to fetch
cache_ttl_seconds: Cache TTL in seconds (defaults to instance default)
fallback: Fallback prompt to use if fetch fails and no cache available
version: Specific prompt version to fetch. If None, fetches the latest
version
Returns:
The prompt string
@@ -158,10 +138,9 @@ class Prompts:
if cache_ttl_seconds is not None
else self._default_cache_ttl_seconds
)
cache_key = _cache_key(name, version)
# Check cache first
cached = self._cache.get(cache_key)
cached = self._cache.get(name)
now = time.time()
if cached is not None:
@@ -172,22 +151,21 @@ class Prompts:
# Try to fetch from API
try:
prompt = self._fetch_prompt_from_api(name, version)
prompt = self._fetch_prompt_from_api(name)
fetched_at = time.time()
# Update cache
self._cache[cache_key] = CachedPrompt(prompt=prompt, fetched_at=fetched_at)
self._cache[name] = CachedPrompt(prompt=prompt, fetched_at=fetched_at)
return prompt
except Exception as error:
prompt_reference = _prompt_reference(name, version)
# Fallback order:
# 1. Return stale cache (with warning)
if cached is not None:
log.warning(
"[Insights Prompts] Failed to fetch %s, using stale cache: %s",
prompt_reference,
'[PostHog Prompts] Failed to fetch prompt "%s", using stale cache: %s',
name,
error,
)
return cached.prompt
@@ -195,8 +173,8 @@ class Prompts:
# 2. Return fallback (with warning)
if fallback is not None:
log.warning(
"[Insights Prompts] Failed to fetch %s, using fallback: %s",
prompt_reference,
'[PostHog Prompts] Failed to fetch prompt "%s", using fallback: %s',
name,
error,
)
return fallback
@@ -229,43 +207,27 @@ class Prompts:
return re.sub(r"\{\{([\w.-]+)\}\}", replace_variable, prompt)
def clear_cache(
self, name: Optional[str] = None, *, version: Optional[int] = None
) -> None:
def clear_cache(self, name: Optional[str] = None) -> None:
"""
Clear cached prompts.
Args:
name: Specific prompt name to clear. If None, clears all cached prompts.
version: Specific prompt version to clear. Requires name.
name: Specific prompt to clear. If None, clears all cached prompts.
"""
if version is not None and name is None:
raise ValueError("'version' requires 'name' to be provided")
if name is None:
if name is not None:
self._cache.pop(name, None)
else:
self._cache.clear()
return
if version is not None:
self._cache.pop(_cache_key(name, version), None)
return
keys_to_clear = [key for key in self._cache if key[0] == name]
for key in keys_to_clear:
self._cache.pop(key, None)
def _fetch_prompt_from_api(self, name: str, version: Optional[int] = None) -> str:
def _fetch_prompt_from_api(self, name: str) -> str:
"""
Fetch prompt from Insights API.
Fetch prompt from PostHog API.
Endpoint:
{host}/api/environments/@current/llm_prompts/name/{encoded_name}/
?token={encoded_project_api_key}[&version={version}]
Endpoint: {host}/api/environments/@current/llm_prompts/name/{encoded_name}/?token={encoded_project_api_key}
Auth: Bearer {personal_api_key}
Args:
name: The name of the prompt to fetch
version: Specific prompt version to fetch. If None, fetches the latest
Returns:
The prompt string
@@ -275,23 +237,18 @@ class Prompts:
"""
if not self._personal_api_key:
raise Exception(
"[Insights Prompts] personal_api_key is required to fetch prompts. "
"[PostHog Prompts] personal_api_key is required to fetch prompts. "
"Please provide it when initializing the Prompts instance."
)
if not self._project_api_key:
raise Exception(
"[Insights Prompts] project_api_key is required to fetch prompts. "
"[PostHog Prompts] project_api_key is required to fetch prompts. "
"Please provide it when initializing the Prompts instance."
)
encoded_name = urllib.parse.quote(name, safe="")
query_params: Dict[str, Union[str, int]] = {"token": self._project_api_key}
if version is not None:
query_params["version"] = version
encoded_query = urllib.parse.urlencode(query_params)
url = f"{self._host}/api/environments/@current/llm_prompts/name/{encoded_name}/?{encoded_query}"
prompt_reference = _prompt_reference(name, version)
prompt_label = prompt_reference[:1].upper() + prompt_reference[1:]
encoded_project_api_key = urllib.parse.quote(self._project_api_key, safe="")
url = f"{self._host}/api/environments/@current/llm_prompts/name/{encoded_name}/?token={encoded_project_api_key}"
headers = {
"Authorization": f"Bearer {self._personal_api_key}",
@@ -302,28 +259,28 @@ class Prompts:
if not response.ok:
if response.status_code == 404:
raise Exception(f"[Insights Prompts] {prompt_label} not found")
raise Exception(f'[PostHog Prompts] Prompt "{name}" not found')
if response.status_code == 403:
raise Exception(
f"[Insights Prompts] Access denied for {prompt_reference}. "
f'[PostHog Prompts] Access denied for prompt "{name}". '
"Check that your personal_api_key has the correct permissions and the LLM prompts feature is enabled."
)
raise Exception(
f"[Insights Prompts] Failed to fetch {prompt_label}: HTTP {response.status_code}"
f'[PostHog Prompts] Failed to fetch prompt "{name}": HTTP {response.status_code}'
)
try:
data = response.json()
except Exception:
raise Exception(
f"[Insights Prompts] Invalid response format for {prompt_label}"
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
)
if not _is_prompt_api_response(data):
raise Exception(
f"[Insights Prompts] Invalid response format for {prompt_label}"
f'[PostHog Prompts] Invalid response format for prompt "{name}"'
)
return data["prompt"]
@@ -1,5 +1,5 @@
"""
Common type definitions for Insights AI SDK.
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.
@@ -41,10 +41,10 @@ FormattedContentItem = Union[
class FormattedMessage(TypedDict):
"""
Standardized message format for Insights tracking.
Standardized message format for PostHog tracking.
Used across all providers to ensure consistent message structure
when sending events to Insights.
when sending events to PostHog.
"""
role: str
@@ -2,15 +2,15 @@ import time
import uuid
from typing import Any, Callable, Dict, List, Optional, cast
from hanzo_insights import get_tags, identify_context, new_context, tag, contexts
from hanzo_insights.ai.sanitization import (
from posthog import get_tags, identify_context, new_context, tag
from posthog.ai.sanitization import (
sanitize_anthropic,
sanitize_gemini,
sanitize_langchain,
sanitize_openai,
)
from hanzo_insights.ai.types import FormattedMessage, StreamingEventData, TokenUsage
from hanzo_insights.client import Client as InsightsClient
from posthog.ai.types import FormattedMessage, StreamingEventData, TokenUsage
from posthog.client import Client as PostHogClient
_TOKEN_PROPERTY_KEYS = frozenset(
@@ -26,10 +26,10 @@ _TOKEN_PROPERTY_KEYS = frozenset(
def _get_tokens_source(
sdk_tags: Dict[str, Any], insights_properties: Optional[Dict[str, Any]]
sdk_tags: Dict[str, Any], posthog_properties: Optional[Dict[str, Any]]
) -> str:
if insights_properties and any(
key in insights_properties for key in _TOKEN_PROPERTY_KEYS
if posthog_properties and any(
key in posthog_properties for key in _TOKEN_PROPERTY_KEYS
):
return "passthrough"
return "sdk"
@@ -40,7 +40,7 @@ def serialize_raw_usage(raw_usage: Any) -> Optional[Dict[str, Any]]:
Convert raw provider usage objects to JSON-serializable dicts.
Handles Pydantic models (OpenAI/Anthropic) and protobuf-like objects (Gemini)
with a fallback chain to ensure we never pass unserializable objects to Insights.
with a fallback chain to ensure we never pass unserializable objects to PostHog.
Args:
raw_usage: Raw usage object from provider SDK
@@ -193,19 +193,19 @@ def get_usage(response, provider: str) -> TokenUsage:
Delegates to provider-specific converter functions.
"""
if provider == "anthropic":
from hanzo_insights.ai.anthropic.anthropic_converter import (
from posthog.ai.anthropic.anthropic_converter import (
extract_anthropic_usage_from_response,
)
return extract_anthropic_usage_from_response(response)
elif provider == "openai":
from hanzo_insights.ai.openai.openai_converter import (
from posthog.ai.openai.openai_converter import (
extract_openai_usage_from_response,
)
return extract_openai_usage_from_response(response)
elif provider == "gemini":
from hanzo_insights.ai.gemini.gemini_converter import (
from posthog.ai.gemini.gemini_converter import (
extract_gemini_usage_from_response,
)
@@ -219,15 +219,15 @@ def format_response(response, provider: str):
Format a regular (non-streaming) response.
"""
if provider == "anthropic":
from hanzo_insights.ai.anthropic.anthropic_converter import format_anthropic_response
from posthog.ai.anthropic.anthropic_converter import format_anthropic_response
return format_anthropic_response(response)
elif provider == "openai":
from hanzo_insights.ai.openai.openai_converter import format_openai_response
from posthog.ai.openai.openai_converter import format_openai_response
return format_openai_response(response)
elif provider == "gemini":
from hanzo_insights.ai.gemini.gemini_converter import format_gemini_response
from posthog.ai.gemini.gemini_converter import format_gemini_response
return format_gemini_response(response)
return []
@@ -238,15 +238,15 @@ def extract_available_tool_calls(provider: str, kwargs: Dict[str, Any]):
Extract available tool calls for the given provider.
"""
if provider == "anthropic":
from hanzo_insights.ai.anthropic.anthropic_converter import extract_anthropic_tools
from posthog.ai.anthropic.anthropic_converter import extract_anthropic_tools
return extract_anthropic_tools(kwargs)
elif provider == "gemini":
from hanzo_insights.ai.gemini.gemini_converter import extract_gemini_tools
from posthog.ai.gemini.gemini_converter import extract_gemini_tools
return extract_gemini_tools(kwargs)
elif provider == "openai":
from hanzo_insights.ai.openai.openai_converter import extract_openai_tools
from posthog.ai.openai.openai_converter import extract_openai_tools
return extract_openai_tools(kwargs)
return None
@@ -259,19 +259,19 @@ def merge_system_prompt(
Merge system prompts and format messages for the given provider.
"""
if provider == "anthropic":
from hanzo_insights.ai.anthropic.anthropic_converter import format_anthropic_input
from posthog.ai.anthropic.anthropic_converter import format_anthropic_input
messages = kwargs.get("messages") or []
system = kwargs.get("system")
return format_anthropic_input(messages, system)
elif provider == "gemini":
from hanzo_insights.ai.gemini.gemini_converter import format_gemini_input_with_system
from posthog.ai.gemini.gemini_converter import format_gemini_input_with_system
contents = kwargs.get("contents", [])
config = kwargs.get("config")
return format_gemini_input_with_system(contents, config)
elif provider == "openai":
from hanzo_insights.ai.openai.openai_converter import format_openai_input
from posthog.ai.openai.openai_converter import format_openai_input
# For OpenAI, handle both Chat Completions and Responses API
messages_param = kwargs.get("messages")
@@ -319,13 +319,13 @@ def merge_system_prompt(
def call_llm_and_track_usage(
insights_distinct_id: Optional[str],
ph_client: InsightsClient,
posthog_distinct_id: Optional[str],
ph_client: PostHogClient,
provider: str,
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
base_url: str,
call_method: Callable[..., Any],
**kwargs: Any,
@@ -342,8 +342,8 @@ def call_llm_and_track_usage(
error_params: Dict[str, Any] = {}
with new_context(client=ph_client, capture_exceptions=False):
if insights_distinct_id:
identify_context(insights_distinct_id)
if posthog_distinct_id:
identify_context(posthog_distinct_id)
try:
response = call_method(**kwargs)
@@ -363,18 +363,8 @@ def call_llm_and_track_usage(
end_time = time.time()
latency = end_time - start_time
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
# Check if we have a real user distinct_id (from param or outer context)
has_person_distinct_id = (
insights_distinct_id is not None
or contexts.get_context_distinct_id() is not None
)
if not has_person_distinct_id:
# Fall back to trace_id as distinct_id when no real user id is available.
identify_context(insights_trace_id)
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (
hasattr(response, "usage")
@@ -390,19 +380,19 @@ def call_llm_and_track_usage(
tag("$ai_model_parameters", get_model_params(kwargs))
tag(
"$ai_input",
with_privacy_mode(ph_client, insights_privacy_mode, sanitized_messages),
with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
)
tag(
"$ai_output_choices",
with_privacy_mode(
ph_client, insights_privacy_mode, format_response(response, provider)
ph_client, posthog_privacy_mode, format_response(response, provider)
),
)
tag("$ai_http_status", http_status)
tag("$ai_input_tokens", usage.get("input_tokens", 0))
tag("$ai_output_tokens", usage.get("output_tokens", 0))
tag("$ai_latency", latency)
tag("$ai_trace_id", insights_trace_id)
tag("$ai_trace_id", posthog_trace_id)
tag("$ai_base_url", str(base_url))
available_tool_calls = extract_available_tool_calls(provider, kwargs)
@@ -431,7 +421,7 @@ def call_llm_and_track_usage(
# Already serialized by converters
tag("$ai_usage", raw_usage)
if not has_person_distinct_id:
if posthog_distinct_id is None:
tag("$process_person_profile", False)
# Process instructions for Responses API
@@ -439,26 +429,26 @@ def call_llm_and_track_usage(
tag(
"$ai_instructions",
with_privacy_mode(
ph_client, insights_privacy_mode, kwargs.get("instructions")
ph_client, posthog_privacy_mode, kwargs.get("instructions")
),
)
# send the event to Insights
# send the event to posthog
if hasattr(ph_client, "capture") and callable(ph_client.capture):
sdk_tags = get_tags()
merged_properties = {
**sdk_tags,
**(insights_properties or {}),
**(posthog_properties or {}),
**(error_params or {}),
}
merged_properties["$ai_tokens_source"] = _get_tokens_source(
sdk_tags, insights_properties
sdk_tags, posthog_properties
)
ph_client.capture(
distinct_id=contexts.get_context_distinct_id(),
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=merged_properties,
groups=insights_groups,
groups=posthog_groups,
)
if error:
@@ -468,13 +458,13 @@ def call_llm_and_track_usage(
async def call_llm_and_track_usage_async(
insights_distinct_id: Optional[str],
ph_client: InsightsClient,
posthog_distinct_id: Optional[str],
ph_client: PostHogClient,
provider: str,
insights_trace_id: Optional[str],
insights_properties: Optional[Dict[str, Any]],
insights_privacy_mode: bool,
insights_groups: Optional[Dict[str, Any]],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
base_url: str,
call_async_method: Callable[..., Any],
**kwargs: Any,
@@ -487,8 +477,8 @@ async def call_llm_and_track_usage_async(
error_params: Dict[str, Any] = {}
with new_context(client=ph_client, capture_exceptions=False):
if insights_distinct_id:
identify_context(insights_distinct_id)
if posthog_distinct_id:
identify_context(posthog_distinct_id)
try:
response = await call_async_method(**kwargs)
@@ -508,18 +498,8 @@ async def call_llm_and_track_usage_async(
end_time = time.time()
latency = end_time - start_time
if insights_trace_id is None:
insights_trace_id = str(uuid.uuid4())
# Check if we have a real user distinct_id (from param or outer context)
has_person_distinct_id = (
insights_distinct_id is not None
or contexts.get_context_distinct_id() is not None
)
if not has_person_distinct_id:
# Fall back to trace_id as distinct_id when no real user id is available.
identify_context(insights_trace_id)
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and (
hasattr(response, "usage")
@@ -535,19 +515,19 @@ async def call_llm_and_track_usage_async(
tag("$ai_model_parameters", get_model_params(kwargs))
tag(
"$ai_input",
with_privacy_mode(ph_client, insights_privacy_mode, sanitized_messages),
with_privacy_mode(ph_client, posthog_privacy_mode, sanitized_messages),
)
tag(
"$ai_output_choices",
with_privacy_mode(
ph_client, insights_privacy_mode, format_response(response, provider)
ph_client, posthog_privacy_mode, format_response(response, provider)
),
)
tag("$ai_http_status", http_status)
tag("$ai_input_tokens", usage.get("input_tokens", 0))
tag("$ai_output_tokens", usage.get("output_tokens", 0))
tag("$ai_latency", latency)
tag("$ai_trace_id", insights_trace_id)
tag("$ai_trace_id", posthog_trace_id)
tag("$ai_base_url", str(base_url))
available_tool_calls = extract_available_tool_calls(provider, kwargs)
@@ -576,7 +556,7 @@ async def call_llm_and_track_usage_async(
# Already serialized by converters
tag("$ai_usage", raw_usage)
if not has_person_distinct_id:
if posthog_distinct_id is None:
tag("$process_person_profile", False)
# Process instructions for Responses API
@@ -584,26 +564,26 @@ async def call_llm_and_track_usage_async(
tag(
"$ai_instructions",
with_privacy_mode(
ph_client, insights_privacy_mode, kwargs.get("instructions")
ph_client, posthog_privacy_mode, kwargs.get("instructions")
),
)
# send the event to Insights
# send the event to posthog
if hasattr(ph_client, "capture") and callable(ph_client.capture):
sdk_tags = get_tags()
merged_properties = {
**sdk_tags,
**(insights_properties or {}),
**(posthog_properties or {}),
**(error_params or {}),
}
merged_properties["$ai_tokens_source"] = _get_tokens_source(
sdk_tags, insights_properties
sdk_tags, posthog_properties
)
ph_client.capture(
distinct_id=contexts.get_context_distinct_id(),
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=merged_properties,
groups=insights_groups,
groups=posthog_groups,
)
if error:
@@ -625,14 +605,14 @@ def sanitize_messages(data: Any, provider: str) -> Any:
return data
def with_privacy_mode(ph_client: InsightsClient, privacy_mode: bool, value: Any):
def with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any):
if ph_client.privacy_mode or privacy_mode:
return None
return value
def capture_streaming_event(
ph_client: InsightsClient,
ph_client: PostHogClient,
event_data: StreamingEventData,
):
"""
@@ -642,15 +622,15 @@ def capture_streaming_event(
All provider-specific formatting should be done BEFORE calling this function.
The function handles:
- Building Insights event properties
- Building PostHog event properties
- Extracting and adding tools based on provider
- Applying privacy mode
- Adding special token fields (cache, reasoning)
- Provider-specific fields (e.g., OpenAI instructions)
- Sending the event to Insights
- Sending the event to PostHog
Args:
ph_client: Insights client instance
ph_client: PostHog client instance
event_data: Standardized streaming event data containing all necessary information
"""
trace_id = event_data.get("trace_id") or str(uuid.uuid4())
@@ -747,7 +727,7 @@ def capture_streaming_event(
if event_data.get("distinct_id") is None:
event_properties["$process_person_profile"] = False
# Send event to Insights
# Send event to PostHog
if hasattr(ph_client, "capture"):
ph_client.capture(
distinct_id=event_data.get("distinct_id") or trace_id,
+1 -1
View File
@@ -5,7 +5,7 @@ from datetime import datetime
import numbers
from uuid import UUID
from hanzo_insights.types import SendFeatureFlagsOptions
from posthog.types import SendFeatureFlagsOptions
ID_TYPES = Union[numbers.Number, str, UUID, int]
+62 -68
View File
@@ -11,9 +11,9 @@ from dateutil.tz import tzutc
from six import string_types
from typing_extensions import Unpack
from hanzo_insights.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from hanzo_insights.consumer import Consumer
from hanzo_insights.contexts import (
from posthog.args import ID_TYPES, ExceptionArg, OptionalCaptureArgs, OptionalSetArgs
from posthog.consumer import Consumer
from posthog.contexts import (
_get_current_context,
get_capture_exception_code_variables_context,
get_code_variables_ignore_patterns_context,
@@ -23,8 +23,8 @@ from hanzo_insights.contexts import (
get_context_session_id,
new_context,
)
from hanzo_insights.exception_capture import ExceptionCapture
from hanzo_insights.exception_utils import (
from posthog.exception_capture import ExceptionCapture
from posthog.exception_utils import (
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS,
DEFAULT_CODE_VARIABLES_MASK_PATTERNS,
exc_info_from_error,
@@ -34,18 +34,18 @@ from hanzo_insights.exception_utils import (
mark_exception_as_captured,
try_attach_code_variables_to_frames,
)
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
InconclusiveMatchError,
RequiresServerEvaluation,
match_feature_flag_properties,
resolve_bucketing_value,
)
from hanzo_insights.flag_definition_cache import (
from posthog.flag_definition_cache import (
FlagDefinitionCacheData,
FlagDefinitionCacheProvider,
)
from hanzo_insights.poller import Poller
from hanzo_insights.request import (
from posthog.poller import Poller
from posthog.request import (
DEFAULT_HOST,
APIError,
QuotaLimitError,
@@ -57,7 +57,7 @@ from hanzo_insights.request import (
get,
remote_config,
)
from hanzo_insights.types import (
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
@@ -71,7 +71,7 @@ from hanzo_insights.types import (
to_payloads,
to_values,
)
from hanzo_insights.utils import (
from posthog.utils import (
FlagCache,
RedisFlagCache,
SizeLimitedDict,
@@ -79,7 +79,7 @@ from hanzo_insights.utils import (
guess_timezone,
system_context,
)
from hanzo_insights.version import VERSION
from posthog.version import VERSION
try:
import queue
@@ -149,22 +149,22 @@ def no_throw(default_return=None):
class Client(object):
"""
This is the SDK reference for the Hanzo Insights Python SDK.
This is the SDK reference for the PostHog Python SDK.
You can learn more about example usage in the [Python SDK documentation](/docs/libraries/python).
You can also follow [Flask](/docs/libraries/flask) and [Django](/docs/libraries/django)
guides to integrate Insights into your project.
guides to integrate PostHog into your project.
Examples:
```python
from hanzo_insights import Insights
client = Insights('<ph_project_api_key>', host='<ph_client_api_host>')
hanzo_insights.debug = True
from posthog import Posthog
posthog = Posthog('<ph_project_api_key>', host='<ph_client_api_host>')
posthog.debug = True
if settings.TEST:
hanzo_insights.disabled = True
posthog.disabled = True
```
"""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def __init__(
self,
@@ -202,7 +202,7 @@ class Client(object):
in_app_modules: list[str] | None = None,
):
"""
Initialize a new Insights client instance.
Initialize a new PostHog client instance.
Args:
project_api_key: The project API key.
@@ -211,9 +211,9 @@ class Client(object):
Examples:
```python
from hanzo_insights import Insights
from posthog import Posthog
client = Insights('<ph_project_api_key>', host='<ph_app_host>')
posthog = Posthog('<ph_project_api_key>', host='<ph_app_host>')
```
Category:
@@ -342,9 +342,9 @@ class Client(object):
Examples:
```python
with hanzo_insights.new_context():
with posthog.new_context():
identify_context('<distinct_id>')
hanzo_insights.capture('event_name')
posthog.capture('event_name')
```
Category:
@@ -438,7 +438,7 @@ class Client(object):
Examples:
```python
payloads = hanzo_insights.get_feature_payloads('<distinct_id>')
payloads = posthog.get_feature_payloads('<distinct_id>')
```
Category:
@@ -480,7 +480,7 @@ class Client(object):
Examples:
```python
result = hanzo_insights.get_feature_flags_and_payloads('<distinct_id>')
result = posthog.get_feature_flags_and_payloads('<distinct_id>')
```
Category:
@@ -522,7 +522,7 @@ class Client(object):
Examples:
```python
decision = hanzo_insights.get_flags_decision('user123')
decision = posthog.get_flags_decision('user123')
```
Category:
@@ -570,7 +570,7 @@ class Client(object):
self, event: str, **kwargs: Unpack[OptionalCaptureArgs]
) -> Optional[str]:
"""
Captures an event manually. [Learn about capture best practices](https://insights.hanzo.ai/docs/product-analytics/capture-events)
Captures an event manually. [Learn about capture best practices](https://posthog.com/docs/product-analytics/capture-events)
Args:
event: The event name to capture.
@@ -585,20 +585,20 @@ class Client(object):
Examples:
```python
# Anonymous event
hanzo_insights.capture('some-anon-event')
posthog.capture('some-anon-event')
```
```python
# Context usage
from hanzo_insights import identify_context, new_context
from posthog import identify_context, new_context
with new_context():
identify_context('distinct_id_of_the_user')
hanzo_insights.capture('user_signed_up')
hanzo_insights.capture('user_logged_in')
hanzo_insights.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
posthog.capture('user_signed_up')
posthog.capture('user_logged_in')
posthog.capture('some-custom-action', distinct_id='distinct_id_of_the_user')
```
```python
# Set event properties
hanzo_insights.capture(
posthog.capture(
"user_signed_up",
distinct_id="distinct_id_of_the_user",
properties={
@@ -609,7 +609,7 @@ class Client(object):
```
```python
# Page view event
hanzo_insights.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})
posthog.capture('$pageview', distinct_id="distinct_id_of_the_user", properties={'$current_url': 'https://example.com'})
```
Category:
@@ -769,7 +769,7 @@ class Client(object):
Examples:
```python
# Set with distinct id
hanzo_insights.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
posthog.set(distinct_id='user123', properties={'name': 'Max Hedgehog'})
```
Category:
@@ -816,7 +816,7 @@ class Client(object):
Examples:
```python
hanzo_insights.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})
posthog.set_once(distinct_id='user123', properties={'initial_signup_date': '2024-01-01'})
```
Category:
@@ -873,7 +873,7 @@ class Client(object):
Examples:
```python
hanzo_insights.group_identify('company', 'company_id_in_your_db', {
posthog.group_identify('company', 'company_id_in_your_db', {
'name': 'Awesome Inc.',
'employees': 11
})
@@ -928,7 +928,7 @@ class Client(object):
Examples:
```python
hanzo_insights.alias(previous_id='distinct_id', distinct_id='alias_id')
posthog.alias(previous_id='distinct_id', distinct_id='alias_id')
```
Category:
@@ -978,7 +978,7 @@ class Client(object):
# Some code that might fail
pass
except Exception as e:
hanzo_insights.capture_exception(e, 'user_distinct_id', properties=additional_properties)
posthog.capture_exception(e, 'user_distinct_id', properties=additional_properties)
```
Category:
@@ -1108,7 +1108,7 @@ class Client(object):
if not msg.get("properties"):
msg["properties"] = {}
msg["properties"]["$lib"] = "insights-python"
msg["properties"]["$lib"] = "posthog-python"
msg["properties"]["$lib_version"] = VERSION
if disable_geoip is None:
@@ -1168,8 +1168,8 @@ class Client(object):
Examples:
```python
hanzo_insights.capture('event_name')
hanzo_insights.flush() # Ensures the event is sent immediately
posthog.capture('event_name')
posthog.flush() # Ensures the event is sent immediately
```
"""
queue = self.queue
@@ -1184,7 +1184,7 @@ class Client(object):
Examples:
```python
hanzo_insights.join()
posthog.join()
```
"""
if self.consumers:
@@ -1212,7 +1212,7 @@ class Client(object):
Examples:
```python
hanzo_insights.shutdown()
posthog.shutdown()
```
"""
self.flush()
@@ -1285,7 +1285,7 @@ class Client(object):
self._fetch_feature_flags_from_api()
def _fetch_feature_flags_from_api(self):
"""Fetch feature flags from the Insights API."""
"""Fetch feature flags from the PostHog API."""
try:
# Store old flags to detect changes
old_flags_by_key: dict[str, dict] = self.feature_flags_by_key or {}
@@ -1334,7 +1334,7 @@ class Client(object):
except APIError as e:
if e.status == 401:
self.log.error(
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://insights.hanzo.ai/docs/api/overview"
"[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview"
)
self.feature_flags = []
self.group_type_mapping = {}
@@ -1348,11 +1348,11 @@ class Client(object):
status=401,
message="You are using a write-only key with feature flags. "
"To use feature flags, please set a personal_api_key "
"More information: https://insights.hanzo.ai/docs/api/overview",
"More information: https://posthog.com/docs/api/overview",
)
elif e.status == 402:
self.log.warning(
"[FEATURE FLAGS] Insights feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://insights.hanzo.ai/docs/billing/limits-alerts"
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
)
# Reset all feature flag data when quota limited
self.feature_flags = []
@@ -1366,7 +1366,7 @@ class Client(object):
if self.debug:
raise APIError(
status=402,
message="Insights feature flags quota limited",
message="PostHog feature flags quota limited",
)
else:
self.log.error(f"[FEATURE FLAGS] Error loading feature flags: {e}")
@@ -1385,7 +1385,7 @@ class Client(object):
Examples:
```python
hanzo_insights.load_feature_flags()
posthog.load_feature_flags()
```
Category:
@@ -1520,11 +1520,11 @@ class Client(object):
Examples:
```python
is_my_flag_enabled = hanzo_insights.feature_enabled('flag-key', 'distinct_id_of_your_user')
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = hanzo_insights.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
```
Category:
@@ -1620,13 +1620,7 @@ class Client(object):
self.flag_cache.set_cached_flag(
distinct_id, key, flag_result, self.flag_definition_version
)
elif only_evaluate_locally:
if self.feature_flags is None:
self.log.warning(
"[FEATURE FLAGS] Local evaluation called but feature flag definitions are not loaded yet. "
"Returning None. You can call load_feature_flags() to load flags explicitly."
)
else:
elif not only_evaluate_locally:
try:
flag_details, request_id, evaluated_at, errors_while_computing = (
self._get_feature_flag_details_from_server(
@@ -1718,7 +1712,7 @@ class Client(object):
Examples:
```python
flag_result = hanzo_insights.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
flag_result = posthog.get_feature_flag_result('flag-key', 'distinct_id_of_your_user')
if flag_result and flag_result.get_value() == 'variant-key':
# Do something differently for this user
# Optional: fetch the payload
@@ -1780,11 +1774,11 @@ class Client(object):
Examples:
```python
enabled_variant = hanzo_insights.get_feature_flag('flag-key', 'distinct_id_of_your_user')
enabled_variant = posthog.get_feature_flag('flag-key', 'distinct_id_of_your_user')
if enabled_variant == 'variant-key': # replace 'variant-key' with the key of your variant
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = hanzo_insights.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
```
Category:
@@ -1874,12 +1868,12 @@ class Client(object):
Examples:
```python
is_my_flag_enabled = hanzo_insights.feature_enabled('flag-key', 'distinct_id_of_your_user')
is_my_flag_enabled = posthog.feature_enabled('flag-key', 'distinct_id_of_your_user')
if is_my_flag_enabled:
# Do something differently for this user
# Optional: fetch the payload
matched_flag_payload = hanzo_insights.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
matched_flag_payload = posthog.get_feature_flag_payload('flag-key', 'distinct_id_of_your_user')
```
Category:
@@ -2071,7 +2065,7 @@ class Client(object):
Examples:
```python
hanzo_insights.get_all_flags('distinct_id_of_your_user')
posthog.get_all_flags('distinct_id_of_your_user')
```
Category:
@@ -2118,7 +2112,7 @@ class Client(object):
Examples:
```python
hanzo_insights.get_all_flags_and_payloads('distinct_id_of_your_user')
posthog.get_all_flags_and_payloads('distinct_id_of_your_user')
```
Category:
@@ -2231,7 +2225,7 @@ class Client(object):
"""Initialize feature flag cache for graceful degradation during service outages.
When enabled, the cache stores flag evaluation results and serves them as fallback
when the Insights API is unavailable. This ensures your application continues to
when the PostHog API is unavailable. This ensures your application continues to
receive flag values even during outages.
Args:
@@ -3,7 +3,7 @@ import logging
import time
from threading import Thread
from hanzo_insights.request import APIError, DatetimeSerializer, batch_post
from posthog.request import APIError, DatetimeSerializer, batch_post
try:
from queue import Empty
@@ -21,7 +21,7 @@ BATCH_SIZE_LIMIT = 5 * 1024 * 1024
class Consumer(Thread):
"""Consumes the messages from the client's queue."""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def __init__(
self,
@@ -4,7 +4,7 @@ from typing import Optional, Any, Callable, Dict, TypeVar, cast, TYPE_CHECKING
if TYPE_CHECKING:
# To avoid circular imports
from hanzo_insights.client import Client
from posthog.client import Client
class ContextScope:
@@ -104,7 +104,7 @@ class ContextScope:
_context_stack: contextvars.ContextVar[Optional[ContextScope]] = contextvars.ContextVar(
"insights_context_stack", default=None
"posthog_context_stack", default=None
)
@@ -134,32 +134,32 @@ def new_context(
If provided, the client will be used to capture exceptions within the context.
If not provided, the default (global) client will be used. Note that the passed
client is only used to capture exceptions within the context - other events captured
within the context via `Client.capture` or `hanzo_insights.capture` will still carry the context
within the context via `Client.capture` or `posthog.capture` will still carry the context
state (tags, identity, session id), but will be captured by the client directly used (or
the global one, in the case of `hanzo_insights.capture`)
the global one, in the case of `posthog.capture`)
Examples:
```python
# Inherit parent context tags
with hanzo_insights.new_context():
hanzo_insights.tag("request_id", "123")
with posthog.new_context():
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
hanzo_insights.capture("event_name", {"property": "value"})
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
```
```python
# Start with fresh context (no inherited tags)
with hanzo_insights.new_context(fresh=True):
hanzo_insights.tag("request_id", "123")
with posthog.new_context(fresh=True):
posthog.tag("request_id", "123")
# Both this event and the exception will be tagged with the context tags
hanzo_insights.capture("event_name", {"property": "value"})
posthog.capture("event_name", {"property": "value"})
raise ValueError("Something went wrong")
```
Category:
Contexts
"""
from hanzo_insights import capture_exception
from posthog import capture_exception
current_context = _get_current_context()
new_context = ContextScope(current_context, fresh, capture_exceptions, client)
@@ -189,7 +189,7 @@ def tag(key: str, value: Any) -> None:
Example:
```python
hanzo_insights.tag("user_id", "123")
posthog.tag("user_id", "123")
```
Category:
@@ -221,7 +221,7 @@ def identify_context(distinct_id: str) -> None:
"""
Identify the current context with a distinct ID, associating all events captured in this or
child contexts with the given distinct ID (unless identify_context is called again). This is overridden by
distinct id's passed directly to hanzo_insights.capture and related methods (identify, set etc). Entering a
distinct id's passed directly to posthog.capture and related methods (identify, set etc). Entering a
fresh context will clear the context-level distinct ID. The distinct-id passed should be uniquely associated
with one of your users. Events captured outside of a context, or in a context with no associated distinct
ID, will be assigned a random UUID, and captured as "personless".
@@ -244,7 +244,7 @@ def set_context_session(session_id: str) -> None:
Entering a fresh context will clear the context-level session ID.
Args:
session_id: The session ID to associate with the current context and its children. See https://insights.hanzo.ai/docs/data/sessions
session_id: The session ID to associate with the current context and its children. See https://posthog.com/docs/data/sessions
Category:
Contexts
@@ -373,20 +373,20 @@ F = TypeVar("F", bound=Callable[..., Any])
def scoped(fresh: bool = False, capture_exceptions: bool = True):
"""
Decorator that creates a new context for the function. Simply wraps
the function in a with hanzo_insights.new_context(): block.
the function in a with posthog.new_context(): block.
Args:
fresh: Whether to start with a fresh context (default: False)
capture_exceptions: Whether to capture and track exceptions with Insights error tracking (default: True)
capture_exceptions: Whether to capture and track exceptions with posthog error tracking (default: True)
Example:
@hanzo_insights.scoped()
@posthog.scoped()
def process_payment(payment_id):
hanzo_insights.tag("payment_id", payment_id)
hanzo_insights.tag("payment_method", "credit_card")
posthog.tag("payment_id", payment_id)
posthog.tag("payment_method", "credit_card")
# This event will be captured with tags
hanzo_insights.capture("payment_started")
posthog.capture("payment_started")
# If this raises an exception, it will be captured with tags
# and then re-raised
some_risky_function()
@@ -9,13 +9,13 @@ import threading
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from hanzo_insights.client import Client
from posthog.client import Client
class ExceptionCapture:
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def __init__(self, client: "Client"):
self.client = client
@@ -30,7 +30,7 @@ from typing import ( # noqa: F401
cast,
)
from hanzo_insights.args import ExceptionArg, ExcInfo # noqa: F401
from posthog.args import ExceptionArg, ExcInfo # noqa: F401
try:
# Python 3.11
@@ -62,8 +62,8 @@ DEFAULT_CODE_VARIABLES_MASK_PATTERNS = [
DEFAULT_CODE_VARIABLES_IGNORE_PATTERNS = [r"^__.*"]
CODE_VARIABLES_REDACTED_VALUE = "$$_insights_redacted_based_on_masking_rules_$$"
CODE_VARIABLES_TOO_LONG_VALUE = "$$_insights_value_too_long_$$"
CODE_VARIABLES_REDACTED_VALUE = "$$_posthog_redacted_based_on_masking_rules_$$"
CODE_VARIABLES_TOO_LONG_VALUE = "$$_posthog_value_too_long_$$"
_MAX_VALUE_LENGTH_FOR_PATTERN_MATCH = 5_000
_MAX_COLLECTION_ITEMS_TO_SCAN = 100
@@ -768,12 +768,12 @@ def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=No
def exception_is_already_captured(error):
# type: (ExceptionArg) -> bool
if isinstance(error, BaseException):
return hasattr(error, "__insights_exception_captured")
return hasattr(error, "__posthog_exception_captured")
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
return error[1] is not None and hasattr(
error[1], "__insights_exception_captured"
error[1], "__posthog_exception_captured"
)
else:
return False # type: ignore[unreachable]
@@ -782,14 +782,14 @@ def exception_is_already_captured(error):
def mark_exception_as_captured(error, uuid):
# type: (ExceptionArg, str) -> None
if isinstance(error, BaseException):
setattr(error, "__insights_exception_captured", True)
setattr(error, "__insights_exception_uuid", uuid)
setattr(error, "__posthog_exception_captured", True)
setattr(error, "__posthog_exception_uuid", uuid)
# Autocaptured exceptions are passed as a tuple from our system hooks,
# the second item is the exception value (the first is the exception type)
elif isinstance(error, tuple) and len(error) > 1:
if error[1] is not None:
setattr(error[1], "__insights_exception_captured", True)
setattr(error[1], "__insights_exception_uuid", uuid)
setattr(error[1], "__posthog_exception_captured", True)
setattr(error[1], "__posthog_exception_uuid", uuid)
def exc_info_from_error(error):
@@ -8,40 +8,16 @@ from typing import Optional
from dateutil import parser
from dateutil.relativedelta import relativedelta
from hanzo_insights import utils
from hanzo_insights.types import FlagValue
from hanzo_insights.utils import convert_to_datetime_aware, is_valid_regex
from posthog import utils
from posthog.types import FlagValue
from posthog.utils import convert_to_datetime_aware, is_valid_regex
__LONG_SCALE__ = float(0xFFFFFFFFFFFFFFF)
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
# All operators supported by match_property, grouped by category.
EQUALITY_OPERATORS = ("exact", "is_not", "is_set", "is_not_set")
STRING_OPERATORS = ("icontains", "not_icontains", "regex", "not_regex")
NUMERIC_OPERATORS = ("gt", "gte", "lt", "lte")
DATE_OPERATORS = ("is_date_before", "is_date_after")
SEMVER_COMPARISON_OPERATORS = (
"semver_eq",
"semver_neq",
"semver_gt",
"semver_gte",
"semver_lt",
"semver_lte",
)
SEMVER_RANGE_OPERATORS = ("semver_tilde", "semver_caret", "semver_wildcard")
SEMVER_OPERATORS = SEMVER_COMPARISON_OPERATORS + SEMVER_RANGE_OPERATORS
PROPERTY_OPERATORS = (
EQUALITY_OPERATORS
+ STRING_OPERATORS
+ NUMERIC_OPERATORS
+ DATE_OPERATORS
+ SEMVER_OPERATORS
)
class InconclusiveMatchError(Exception):
pass
@@ -409,9 +385,6 @@ def match_property(property, property_values) -> bool:
operator = property.get("operator") or "exact"
value = property.get("value")
if operator not in PROPERTY_OPERATORS:
raise InconclusiveMatchError(f"Unknown operator {operator}")
if key not in property_values:
raise InconclusiveMatchError(
"can't match properties without a given property value"
@@ -532,7 +505,17 @@ def match_property(property, property_values) -> bool:
"The date provided must be a string or date object"
)
if operator in SEMVER_OPERATORS:
if operator in (
"semver_eq",
"semver_neq",
"semver_gt",
"semver_gte",
"semver_lt",
"semver_lte",
"semver_tilde",
"semver_caret",
"semver_wildcard",
):
try:
override_parsed = parse_semver(override_value)
except (ValueError, TypeError):
@@ -540,7 +523,14 @@ def match_property(property, property_values) -> bool:
f"Person property value '{override_value}' is not a valid semver"
)
if operator in SEMVER_COMPARISON_OPERATORS:
if operator in (
"semver_eq",
"semver_neq",
"semver_gt",
"semver_gte",
"semver_lt",
"semver_lte",
):
try:
flag_parsed = parse_semver(value)
except (ValueError, TypeError):
@@ -588,8 +578,7 @@ def match_property(property, property_values) -> bool:
)
return lower <= override_parsed < upper
# Unreachable: all operators in PROPERTY_OPERATORS are handled above,
# and unknown operators are rejected at the top of this function.
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")
@@ -9,11 +9,11 @@ functions) to share flag definitions and reduce API calls.
Usage:
from hanzo_insights import Insights
from hanzo_insights.flag_definition_cache import FlagDefinitionCacheProvider
from posthog import Posthog
from posthog.flag_definition_cache import FlagDefinitionCacheProvider
cache = RedisFlagDefinitionCache(redis_client, "my-team")
client = Insights(
posthog = Posthog(
"<project_api_key>",
personal_api_key="<personal_api_key>",
flag_definition_cache_provider=cache,
@@ -63,7 +63,7 @@ class FlagDefinitionCacheProvider(Protocol):
new definitions from the API. Store the data in your external cache
and release any locks.
4. `shutdown()` - Called when the Insights client shuts down. Release any
4. `shutdown()` - Called when the PostHog client shuts down. Release any
distributed locks and clean up resources.
Error Handling:
@@ -104,7 +104,7 @@ class FlagDefinitionCacheProvider(Protocol):
def on_flag_definitions_received(self, data: FlagDefinitionCacheData) -> None:
"""
Called after successfully receiving new flag definitions from Insights.
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()`.
@@ -117,7 +117,7 @@ class FlagDefinitionCacheProvider(Protocol):
def shutdown(self) -> None:
"""
Called when the Insights client shuts down.
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()`
@@ -1,6 +1,6 @@
from typing import TYPE_CHECKING, cast
from hanzo_insights import contexts
from hanzo_insights.client import Client
from posthog import contexts
from posthog.client import Client
try:
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
@@ -21,26 +21,25 @@ if TYPE_CHECKING:
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
class InsightsContextMiddleware:
class PosthogContextMiddleware:
"""Middleware to automatically track Django requests.
This middleware wraps all calls with an Insights context. It attempts to extract the following from the request headers:
- Session ID, (extracted from `X-INSIGHTS-SESSION-ID`)
- Distinct ID, (extracted from `X-INSIGHTS-DISTINCT-ID`)
This middleware wraps all calls with a posthog context. It attempts to extract the following from the request headers:
- Session ID, (extracted from `X-POSTHOG-SESSION-ID`)
- Distinct ID, (extracted from `X-POSTHOG-DISTINCT-ID`)
- Request URL as $current_url
- Request Method as $request_method
The context will also auto-capture exceptions and send them to Insights, unless you disable it by setting
`INSIGHTS_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings.
The exceptions are captured using the global client, unless the setting `INSIGHTS_MW_CLIENT`
is set to a custom client instance.
The context will also auto-capture exceptions and send them to PostHog, unless you disable it by setting
`POSTHOG_MW_CAPTURE_EXCEPTIONS` to `False` in your Django settings. The exceptions are captured using the
global client, unless the setting `POSTHOG_MW_CLIENT` is set to a custom client instance
The middleware behaviour is customisable through 3 additional functions:
- `INSIGHTS_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
- `INSIGHTS_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
- `INSIGHTS_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
- `POSTHOG_MW_EXTRA_TAGS`, which is a Callable[[HttpRequest], Dict[str, Any]] expected to return a dictionary of additional tags to be added to the context.
- `POSTHOG_MW_REQUEST_FILTER`, which is a Callable[[HttpRequest], bool] expected to return `False` if the request should not be tracked.
- `POSTHOG_MW_TAG_MAP`, which is a Callable[[Dict[str, Any]], Dict[str, Any]], which you can use to modify the tags before they're added to the context.
You can use the `INSIGHTS_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
You can use the `POSTHOG_MW_TAG_MAP` function to remove any default tags you don't want to capture, or override them with your own values.
Context tags are automatically included as properties on all events captured within a context, including exceptions.
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
@@ -67,48 +66,47 @@ class InsightsContextMiddleware:
from django.conf import settings
def _get_setting(name):
insights_name = f"INSIGHTS_MW_{name}"
if hasattr(settings, insights_name):
return getattr(settings, insights_name)
return None
extra_tags = _get_setting("EXTRA_TAGS")
if extra_tags and callable(extra_tags):
if hasattr(settings, "POSTHOG_MW_EXTRA_TAGS") and callable(
settings.POSTHOG_MW_EXTRA_TAGS
):
self.extra_tags = cast(
"Optional[Callable[[HttpRequest], Dict[str, Any]]]",
extra_tags,
settings.POSTHOG_MW_EXTRA_TAGS,
)
else:
self.extra_tags = None
request_filter = _get_setting("REQUEST_FILTER")
if request_filter and callable(request_filter):
if hasattr(settings, "POSTHOG_MW_REQUEST_FILTER") and callable(
settings.POSTHOG_MW_REQUEST_FILTER
):
self.request_filter = cast(
"Optional[Callable[[HttpRequest], bool]]",
request_filter,
settings.POSTHOG_MW_REQUEST_FILTER,
)
else:
self.request_filter = None
tag_map = _get_setting("TAG_MAP")
if tag_map and callable(tag_map):
if hasattr(settings, "POSTHOG_MW_TAG_MAP") and callable(
settings.POSTHOG_MW_TAG_MAP
):
self.tag_map = cast(
"Optional[Callable[[Dict[str, Any]], Dict[str, Any]]]",
tag_map,
settings.POSTHOG_MW_TAG_MAP,
)
else:
self.tag_map = None
capture_exceptions = _get_setting("CAPTURE_EXCEPTIONS")
if isinstance(capture_exceptions, bool):
self.capture_exceptions = capture_exceptions
if hasattr(settings, "POSTHOG_MW_CAPTURE_EXCEPTIONS") and isinstance(
settings.POSTHOG_MW_CAPTURE_EXCEPTIONS, bool
):
self.capture_exceptions = settings.POSTHOG_MW_CAPTURE_EXCEPTIONS
else:
self.capture_exceptions = True
mw_client = _get_setting("CLIENT")
if isinstance(mw_client, Client):
self.client = cast("Optional[Client]", mw_client)
if hasattr(settings, "POSTHOG_MW_CLIENT") and isinstance(
settings.POSTHOG_MW_CLIENT, Client
):
self.client = cast("Optional[Client]", settings.POSTHOG_MW_CLIENT)
else:
self.client = None
@@ -127,13 +125,13 @@ class InsightsContextMiddleware:
"""
tags = {}
# Extract session ID from X-INSIGHTS-SESSION-ID header
session_id = request.headers.get("X-INSIGHTS-SESSION-ID")
# Extract session ID from X-POSTHOG-SESSION-ID header
session_id = request.headers.get("X-POSTHOG-SESSION-ID")
if session_id:
contexts.set_context_session(session_id)
# Extract distinct ID from X-INSIGHTS-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-INSIGHTS-DISTINCT-ID") or user_id
# Extract distinct ID from X-POSTHOG-DISTINCT-ID header or request user id
distinct_id = request.headers.get("X-POSTHOG-DISTINCT-ID") or user_id
if distinct_id:
contexts.identify_context(distinct_id)
@@ -316,6 +314,6 @@ class InsightsContextMiddleware:
if self.client:
self.client.capture_exception(exception)
else:
from hanzo_insights import capture_exception
from posthog import capture_exception
capture_exception(exception)
@@ -14,8 +14,8 @@ from requests.adapters import HTTPAdapter # type: ignore[import-untyped]
from urllib3.connection import HTTPConnection
from urllib3.util.retry import Retry
from hanzo_insights.utils import remove_trailing_slash
from hanzo_insights.version import VERSION
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION
SocketOptions = List[Tuple[int, int, Union[int, bytes]]]
@@ -137,7 +137,7 @@ def set_socket_options(socket_options: Optional[SocketOptions]) -> None:
Configure socket options for all HTTP connections.
Example:
from hanzo_insights import set_socket_options
from posthog import set_socket_options
set_socket_options([(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)])
"""
global _session, _flags_session, _socket_options
@@ -159,19 +159,19 @@ def disable_connection_reuse() -> None:
_pooling_enabled = False
US_INGESTION_ENDPOINT = "https://us.i.insights.hanzo.ai"
EU_INGESTION_ENDPOINT = "https://eu.i.insights.hanzo.ai"
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
DEFAULT_HOST = US_INGESTION_ENDPOINT
USER_AGENT = "hanzo-insights-python/" + VERSION
USER_AGENT = "posthog-python/" + VERSION
def determine_server_host(host: Optional[str]) -> str:
"""Determines the server host to use."""
host_or_default = host or DEFAULT_HOST
trimmed_host = remove_trailing_slash(host_or_default)
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com", "https://insights.hanzo.ai", "https://us.insights.hanzo.ai"):
if trimmed_host in ("https://app.posthog.com", "https://us.posthog.com"):
return US_INGESTION_ENDPOINT
elif trimmed_host in ("https://eu.posthog.com", "https://eu.insights.hanzo.ai"):
elif trimmed_host == "https://eu.posthog.com":
return EU_INGESTION_ENDPOINT
else:
return host_or_default
@@ -187,7 +187,7 @@ def post(
**kwargs,
) -> requests.Response:
"""Post the `kwargs` to the API"""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
body = kwargs
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
url = remove_trailing_slash(host or DEFAULT_HOST) + path
@@ -217,7 +217,7 @@ def post(
def _process_response(
res: requests.Response, success_message: str, *, return_json: bool = True
) -> Union[requests.Response, Any]:
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
if res.status_code == 200:
log.debug(success_message)
response = res.json() if return_json else res
@@ -231,7 +231,7 @@ def _process_response(
and "feature_flags" in response["quotaLimited"]
):
log.warning(
"[FEATURE FLAGS] Feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://insights.hanzo.ai/docs/billing/limits-alerts"
"[FEATURE FLAGS] PostHog feature flags quota limited, resetting feature flag data. Learn more about billing limits at https://posthog.com/docs/billing/limits-alerts"
)
raise QuotaLimitError(res.status_code, "Feature flags quota limited")
return response
@@ -341,7 +341,7 @@ def get(
- not_modified=True and data=None if server returns 304
- not_modified=False and data=response if server returns 200
"""
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
full_url = remove_trailing_slash(host or DEFAULT_HOST) + url
headers = {"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}
@@ -375,7 +375,7 @@ class APIError(Exception):
self.retry_after = retry_after
def __str__(self):
msg = "[Insights] {0} ({1})"
msg = "[PostHog] {0} ({1})"
return msg.format(self.message, self.status)
@@ -6,7 +6,7 @@ import unittest
def all_names():
for _, modname, _ in pkgutil.iter_modules(__path__):
yield "hanzo_insights.test." + modname
yield "posthog.test." + modname
def all():
@@ -3,12 +3,10 @@ from unittest.mock import patch
import pytest
from hanzo_insights import identify_context, new_context
try:
from anthropic.types import Message, Usage
from hanzo_insights.ai.anthropic import Anthropic, AsyncAnthropic
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
@@ -105,7 +103,7 @@ class MockDelta:
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -279,12 +277,12 @@ def test_basic_completion(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
@@ -325,12 +323,12 @@ def test_tokens_source_passthrough(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"$ai_input_tokens": 99999},
posthog_distinct_id="test-id",
posthog_properties={"$ai_input_tokens": 99999},
)
props = mock_client.capture.call_args[1]["properties"]
@@ -342,12 +340,12 @@ def test_groups(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_groups={"company": "test_company"},
posthog_distinct_id="test-id",
posthog_groups={"company": "test_company"},
)
assert response == mock_anthropic_response
@@ -361,12 +359,12 @@ def test_privacy_mode_local(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
assert response == mock_anthropic_response
@@ -383,12 +381,12 @@ def test_privacy_mode_global(mock_client, mock_anthropic_response):
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
mock_client.privacy_mode = True
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=False,
posthog_distinct_id="test-id",
posthog_privacy_mode=False,
)
assert response == mock_anthropic_response
@@ -407,14 +405,14 @@ def test_basic_integration(mock_client):
"anthropic.resources.Messages.create",
return_value=create_mock_response(),
):
client = Anthropic(insights_client=mock_client)
client = Anthropic(posthog_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Foo"}],
max_tokens=1,
temperature=0,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
system="You must always answer with 'Bar'.",
)
@@ -452,7 +450,7 @@ async def test_basic_async_integration(mock_client):
"anthropic.resources.messages.AsyncMessages.create",
side_effect=mock_async_create,
):
client = AsyncAnthropic(insights_client=mock_client)
client = AsyncAnthropic(posthog_client=mock_client)
await client.messages.create(
model="claude-3-opus-20240229",
messages=[
@@ -460,8 +458,8 @@ async def test_basic_async_integration(mock_client):
],
max_tokens=1,
temperature=0,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert mock_client.capture.call_count == 1
@@ -513,7 +511,7 @@ async def test_async_streaming_system_prompt(mock_client):
"anthropic.resources.messages.AsyncMessages.create",
side_effect=async_create_wrapper,
):
client = AsyncAnthropic(insights_client=mock_client)
client = AsyncAnthropic(posthog_client=mock_client)
response = await client.messages.create(
model="claude-3-opus-20240229",
system="You must always answer with 'Bar'.",
@@ -541,7 +539,7 @@ def test_error(mock_client, mock_anthropic_response):
with patch(
"anthropic.resources.Messages.create", side_effect=Exception("Test error")
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
with pytest.raises(Exception):
client.messages.create(
model="claude-3-opus-20240229",
@@ -561,12 +559,12 @@ def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_cached_tokens,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_cached_tokens
@@ -600,7 +598,7 @@ def test_tool_definition(mock_client, mock_anthropic_response):
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
tools = [
{
@@ -625,8 +623,8 @@ def test_tool_definition(mock_client, mock_anthropic_response):
temperature=0.7,
tools=tools,
messages=[{"role": "user", "content": "hey"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
@@ -662,7 +660,7 @@ def test_tool_calls_in_output_choices(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_with_tool_calls,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
@@ -680,7 +678,7 @@ def test_tool_calls_in_output_choices(
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_anthropic_response_with_tool_calls
@@ -724,7 +722,7 @@ def test_tool_calls_only_no_content(
"anthropic.resources.Messages.create",
return_value=mock_anthropic_response_tool_calls_only,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
max_tokens=200,
@@ -743,7 +741,7 @@ def test_tool_calls_only_no_content(
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_anthropic_response_tool_calls_only
@@ -790,7 +788,7 @@ def test_async_tool_calls_in_output_choices(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
return await async_client.messages.create(
@@ -810,7 +808,7 @@ def test_async_tool_calls_in_output_choices(
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
response = asyncio.run(run_test())
@@ -855,7 +853,7 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
"anthropic.resources.Messages.create",
return_value=mock_anthropic_stream_with_tools,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-5-sonnet-20241022",
system="You are a helpful weather assistant.",
@@ -877,7 +875,7 @@ def test_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with_tools
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream - this triggers the finally block synchronously
@@ -977,7 +975,7 @@ def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
response = await async_client.messages.create(
@@ -1001,7 +999,7 @@ def test_async_streaming_with_tool_calls(mock_client, mock_anthropic_stream_with
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the async stream
@@ -1101,11 +1099,11 @@ def test_web_search_count(mock_client):
mock_response = MockResponseWithWebSearch()
with patch("anthropic.resources.Messages.create", return_value=mock_response):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1179,12 +1177,12 @@ def test_streaming_with_web_search(mock_client, mock_anthropic_stream_with_web_s
"anthropic.resources.Messages.create",
return_value=mock_anthropic_stream_with_web_search,
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream - this triggers the finally block synchronously
@@ -1234,13 +1232,13 @@ def test_async_with_web_search(mock_client):
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
return response
@@ -1278,14 +1276,14 @@ def test_async_streaming_with_web_search(
"anthropic.resources.AsyncMessages.create",
side_effect=mock_async_create,
):
async_client = AsyncAnthropic(api_key="test-key", insights_client=mock_client)
async_client = AsyncAnthropic(api_key="test-key", posthog_client=mock_client)
async def run_test():
response = await async_client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the async stream
@@ -1304,99 +1302,3 @@ def test_async_streaming_with_web_search(
assert props["$ai_web_search_count"] == 2
assert props["$ai_input_tokens"] == 50
assert props["$ai_output_tokens"] == 25
# =======================
# Distinct ID Context Tests
# =======================
def test_no_distinct_id_uses_trace_id_and_personless(
mock_client, mock_anthropic_response
):
"""When no distinct_id is provided and no outer context, trace_id is used and event is personless."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "trace-123"
assert props["$process_person_profile"] is False
def test_explicit_distinct_id_creates_person_profile(
mock_client, mock_anthropic_response
):
"""When insights_distinct_id is explicitly passed, it is used and event is not personless."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="user-123",
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "user-123"
assert (
"$process_person_profile" not in props
or props["$process_person_profile"] is not False
)
def test_outer_context_distinct_id_is_used(mock_client, mock_anthropic_response):
"""When an outer context has a distinct_id, it should be used instead of trace_id."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with new_context():
identify_context("outer-user-456")
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "outer-user-456"
assert (
"$process_person_profile" not in props
or props["$process_person_profile"] is not False
)
def test_explicit_distinct_id_overrides_outer_context(
mock_client, mock_anthropic_response
):
"""When both outer context and explicit insights_distinct_id are set, explicit wins."""
with patch(
"anthropic.resources.Messages.create", return_value=mock_anthropic_response
):
client = Anthropic(api_key="test-key", insights_client=mock_client)
with new_context():
identify_context("outer-user-456")
client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="explicit-user-789",
insights_trace_id="trace-123",
)
call_args = mock_client.capture.call_args[1]
assert call_args["distinct_id"] == "explicit-user-789"
@@ -6,7 +6,7 @@ import pytest
try:
from google import genai as google_genai
from hanzo_insights.ai.gemini import Client
from posthog.ai.gemini import Client
GEMINI_AVAILABLE = True
except ImportError:
@@ -19,7 +19,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -172,13 +172,13 @@ def test_new_client_basic_generation(
"""Test the new Client/Models API structure"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.0-flash",
contents=["Tell me a fun fact about hedgehogs"],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
@@ -239,13 +239,13 @@ def test_new_client_streaming_with_generate_content_stream(
mock_streaming_response()
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content_stream(
model="gemini-2.0-flash",
contents=["Write a short story"],
insights_distinct_id="test-id",
insights_properties={"feature": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming"},
)
chunks = list(response)
@@ -298,7 +298,7 @@ def test_new_client_streaming_with_tools(mock_client, mock_google_genai_client):
mock_streaming_response()
)
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
@@ -326,8 +326,8 @@ def test_new_client_streaming_with_tools(mock_client, mock_google_genai_client):
model="gemini-2.0-flash",
contents=["What's the weather in SF?"],
config=mock_config,
insights_distinct_id="test-id",
insights_properties={"feature": "streaming_with_tools"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming_with_tools"},
)
chunks = list(response)
@@ -357,13 +357,13 @@ def test_new_client_groups(mock_client, mock_google_genai_client, mock_gemini_re
"""Test groups functionality with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_groups={"company": "company_123"},
posthog_distinct_id="test-id",
posthog_groups={"company": "company_123"},
)
call_args = mock_client.capture.call_args[1]
@@ -376,13 +376,13 @@ def test_new_client_privacy_mode_local(
"""Test local privacy mode with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
call_args = mock_client.capture.call_args[1]
@@ -399,12 +399,12 @@ def test_new_client_privacy_mode_global(
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
@@ -419,11 +419,11 @@ def test_new_client_different_input_formats(
"""Test different input formats with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
# Test string input
client.models.generate_content(
model="gemini-2.0-flash", contents="Hello", insights_distinct_id="test-id"
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -434,7 +434,7 @@ def test_new_client_different_input_formats(
client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -447,7 +447,7 @@ def test_new_client_different_input_formats(
client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -464,7 +464,7 @@ def test_new_client_different_input_formats(
# Test list input with string
mock_client.capture.reset_mock()
client.models.generate_content(
model="gemini-2.0-flash", contents=["List item"], insights_distinct_id="test-id"
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"]
@@ -477,12 +477,12 @@ def test_new_client_model_parameters(
"""Test model parameters with new Client API"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
temperature=0.7,
max_tokens=100,
)
@@ -496,16 +496,16 @@ def test_new_client_model_parameters(
def test_new_client_default_settings(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test client with default Insights settings"""
"""Test client with default PostHog settings"""
mock_google_genai_client.models.generate_content.return_value = mock_gemini_response
client = Client(
api_key="test-key",
insights_client=mock_client,
insights_distinct_id="default_user",
insights_properties={"team": "ai"},
insights_privacy_mode=False,
insights_groups={"company": "acme_corp"},
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
@@ -527,21 +527,21 @@ def test_new_client_override_defaults(
client = Client(
api_key="test-key",
insights_client=mock_client,
insights_distinct_id="default_user",
insights_properties={"team": "ai"},
insights_privacy_mode=False,
insights_groups={"company": "acme_corp"},
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
client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="specific_user",
insights_properties={"feature": "chat", "urgent": True},
insights_privacy_mode=True,
insights_groups={"organization": "special_org"},
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]
@@ -577,7 +577,7 @@ def test_vertex_ai_parameters_passed_through(
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
@@ -597,7 +597,7 @@ def test_api_key_mode(mock_client, mock_google_genai_client):
# Create client with just API key (traditional mode)
Client(
api_key="test-api-key",
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
@@ -618,7 +618,7 @@ def test_vertex_ai_mode_with_optional_api_key(
api_key="test-api-key",
credentials=mock_credentials,
project="test-project",
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with both Vertex AI params and API key
@@ -634,7 +634,7 @@ def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_re
"""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", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
@@ -664,8 +664,8 @@ def test_tool_use_response(mock_client, mock_google_genai_client, mock_gemini_re
model="gemini-2.5-flash",
contents=["hey"],
config=mock_config,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
@@ -702,12 +702,12 @@ def test_function_calls_in_output_choices(
mock_gemini_response_with_function_calls
)
client = Client(api_key="test-key", insights_client=mock_client)
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?"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_calls
@@ -751,12 +751,12 @@ def test_function_calls_only_no_content(
mock_gemini_response_function_calls_only
)
client = Client(api_key="test-key", insights_client=mock_client)
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"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_function_calls_only
@@ -810,12 +810,12 @@ def test_cache_and_reasoning_tokens(mock_client, mock_google_genai_client):
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-pro",
contents="Test with cache",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -869,19 +869,19 @@ def test_streaming_cache_and_reasoning_tokens(mock_client, mock_google_genai_cli
mock_stream = iter([chunk1, chunk2])
mock_google_genai_client.models.generate_content_stream.return_value = mock_stream
client = Client(api_key="test-key", insights_client=mock_client)
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",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream
result = list(response)
assert len(result) == 2
# Check Insights capture was called
# Check PostHog capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
@@ -946,11 +946,11 @@ def test_web_search_grounding(mock_client, mock_google_genai_client):
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
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?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1015,12 +1015,12 @@ def test_streaming_with_web_search(mock_client, mock_google_genai_client):
mock_streaming_response()
)
client = Client(api_key="test-key", insights_client=mock_client)
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?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = list(response)
@@ -1080,12 +1080,12 @@ def test_empty_grounding_metadata_no_web_search(mock_client, mock_google_genai_c
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
client = Client(api_key="test-key", posthog_client=mock_client)
response = client.models.generate_content(
model="gemini-2.5-flash",
contents="Hello",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1143,12 +1143,12 @@ def test_empty_array_grounding_metadata_no_web_search(
# Mock the generate_content method
mock_google_genai_client.models.generate_content.return_value = mock_response
client = Client(api_key="test-key", insights_client=mock_client)
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?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -5,7 +5,7 @@ import pytest
try:
from google import genai as google_genai
from hanzo_insights.ai.gemini import AsyncClient
from posthog.ai.gemini import AsyncClient
GEMINI_AVAILABLE = True
except ImportError:
@@ -21,7 +21,7 @@ pytestmark = [
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -121,13 +121,13 @@ async def test_async_client_basic_generation(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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"],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_gemini_response
@@ -178,13 +178,13 @@ async def test_async_client_streaming_with_generate_content_stream(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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"],
insights_distinct_id="test-id",
insights_properties={"feature": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming"},
)
chunks = []
@@ -239,7 +239,7 @@ async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
# Create mock tools configuration
mock_tool = MagicMock()
@@ -267,8 +267,8 @@ async def test_async_client_streaming_with_tools(mock_client, mock_google_genai_
model="gemini-2.0-flash",
contents=["What's the weather in SF?"],
config=mock_config,
insights_distinct_id="test-id",
insights_properties={"feature": "streaming_with_tools"},
posthog_distinct_id="test-id",
posthog_properties={"feature": "streaming_with_tools"},
)
chunks = []
@@ -305,13 +305,13 @@ async def test_async_client_groups(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_groups={"company": "company_123"},
posthog_distinct_id="test-id",
posthog_groups={"company": "company_123"},
)
call_args = mock_client.capture.call_args[1]
@@ -326,13 +326,13 @@ async def test_async_client_privacy_mode_local(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
call_args = mock_client.capture.call_args[1]
@@ -351,12 +351,12 @@ async def test_async_client_privacy_mode_global(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
@@ -373,11 +373,11 @@ async def test_async_client_different_input_formats(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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", insights_distinct_id="test-id"
model="gemini-2.0-flash", contents="Hello", posthog_distinct_id="test-id"
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -388,7 +388,7 @@ async def test_async_client_different_input_formats(
await client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "hey"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -401,7 +401,7 @@ async def test_async_client_different_input_formats(
await client.models.generate_content(
model="gemini-2.0-flash",
contents=[{"role": "user", "parts": [{"text": "Hello "}, {"text": "world"}]}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
@@ -418,7 +418,7 @@ async def test_async_client_different_input_formats(
# Test list input with string
mock_client.capture.reset_mock()
await client.models.generate_content(
model="gemini-2.0-flash", contents=["List item"], insights_distinct_id="test-id"
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"]
@@ -433,12 +433,12 @@ async def test_async_client_model_parameters(
return_value=mock_gemini_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
client = AsyncClient(api_key="test-key", posthog_client=mock_client)
await client.models.generate_content(
model="gemini-2.0-flash",
contents=["Hello"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
temperature=0.7,
max_tokens=100,
)
@@ -452,18 +452,18 @@ async def test_async_client_model_parameters(
async def test_async_client_default_settings(
mock_client, mock_google_genai_client, mock_gemini_response
):
"""Test async client with default Insights settings"""
"""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",
insights_client=mock_client,
insights_distinct_id="default_user",
insights_properties={"team": "ai"},
insights_privacy_mode=False,
insights_groups={"company": "acme_corp"},
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
@@ -487,21 +487,21 @@ async def test_async_client_override_defaults(
client = AsyncClient(
api_key="test-key",
insights_client=mock_client,
insights_distinct_id="default_user",
insights_properties={"team": "ai"},
insights_privacy_mode=False,
insights_groups={"company": "acme_corp"},
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"],
insights_distinct_id="specific_user",
insights_properties={"feature": "chat", "urgent": True},
insights_privacy_mode=True,
insights_groups={"organization": "special_org"},
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]
@@ -539,7 +539,7 @@ async def test_async_vertex_ai_parameters_passed_through(
location="us-central1",
debug_config=mock_debug_config,
http_options=mock_http_options,
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with correct parameters
@@ -559,7 +559,7 @@ async def test_async_api_key_mode(mock_client, mock_google_genai_client):
# Create async client with just API key (traditional mode)
AsyncClient(
api_key="test-api-key",
insights_client=mock_client,
posthog_client=mock_client,
)
# Verify genai.Client was called with only api_key
@@ -574,12 +574,12 @@ async def test_async_function_calls_in_output_choices(
return_value=mock_gemini_response_with_function_calls
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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?"],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_gemini_response_with_function_calls
@@ -637,12 +637,12 @@ async def test_async_cache_and_reasoning_tokens(mock_client, mock_google_genai_c
return_value=mock_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -689,12 +689,12 @@ async def test_async_streaming_cache_and_reasoning_tokens(
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the stream
@@ -704,7 +704,7 @@ async def test_async_streaming_cache_and_reasoning_tokens(
assert len(result) == 2
# Check Insights capture was called
# Check PostHog capture was called
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
@@ -761,11 +761,11 @@ async def test_async_web_search_grounding(mock_client, mock_google_genai_client)
return_value=mock_response
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -829,12 +829,12 @@ async def test_async_streaming_with_web_search(mock_client, mock_google_genai_cl
return_value=mock_streaming_response()
)
client = AsyncClient(api_key="test-key", insights_client=mock_client)
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?",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = []
@@ -21,8 +21,8 @@ try:
from langgraph.graph.state import END, START, StateGraph
from langgraph.prebuilt import create_react_agent
from hanzo_insights.ai.langchain import CallbackHandler
from hanzo_insights.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
from posthog.ai.langchain import CallbackHandler
from posthog.ai.langchain.callbacks import GenerationMetadata, SpanMetadata
LANGCHAIN_AVAILABLE = True
except ImportError:
@@ -53,9 +53,9 @@ ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
@pytest.fixture(scope="function")
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
logging.getLogger("hanzo_insights").setLevel(logging.DEBUG)
logging.getLogger("posthog").setLevel(logging.DEBUG)
yield mock_client
@@ -101,7 +101,7 @@ def test_metadata_capture(mock_client):
run_id,
messages=[{"role": "user", "content": "Who won the world series in 2020?"}],
invocation_params={"temperature": 0.5},
metadata={"ls_model_name": "hog-mini", "ls_provider": "hanzo_insights"},
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
name="test",
)
expected = GenerationMetadata(
@@ -109,11 +109,11 @@ def test_metadata_capture(mock_client):
input=[{"role": "user", "content": "Who won the world series in 2020?"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="hanzo_insights",
provider="posthog",
base_url="https://us.posthog.com",
name="test",
end_time=None,
insights_properties=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1049,7 +1049,7 @@ def test_base_url_retrieval(mock_client):
prompt = ChatPromptTemplate.from_messages([("user", "Foo")])
chain = prompt | ChatOpenAI(
api_key="test",
model="insights-mini",
model="posthog-mini",
base_url="https://test.posthog.com",
)
callbacks = CallbackHandler(mock_client)
@@ -1257,7 +1257,7 @@ def test_metadata_tools(mock_client):
run_id,
messages=[{"role": "user", "content": "What's the weather like in SF?"}],
invocation_params={"temperature": 0.5, "tools": tools},
metadata={"ls_model_name": "hog-mini", "ls_provider": "hanzo_insights"},
metadata={"ls_model_name": "hog-mini", "ls_provider": "posthog"},
name="test",
)
expected = GenerationMetadata(
@@ -1265,12 +1265,12 @@ def test_metadata_tools(mock_client):
input=[{"role": "user", "content": "What's the weather like in SF?"}],
start_time=1234567890,
model_params={"temperature": 0.5},
provider="hanzo_insights",
provider="posthog",
base_url="https://us.posthog.com",
name="test",
tools=tools,
end_time=None,
insights_properties=None,
posthog_properties=None,
)
assert callbacks._runs[run_id] == expected
with patch("time.time", return_value=1234567891):
@@ -1867,8 +1867,8 @@ def test_openai_reasoning_tokens_o4_mini(mock_client):
def test_callback_handler_without_client():
"""Test that CallbackHandler works properly when no Insights client is passed."""
with patch("hanzo_insights.ai.langchain.callbacks.setup") as mock_setup:
"""Test that CallbackHandler works properly when no PostHog client is passed."""
with patch("posthog.ai.langchain.callbacks.setup") as mock_setup:
mock_client = mock_setup.return_value
callbacks = CallbackHandler()
@@ -1894,7 +1894,7 @@ def test_callback_handler_without_client():
def test_convert_message_to_dict_tool_calls():
"""Test that _convert_message_to_dict properly converts tool calls in AIMessage."""
from hanzo_insights.ai.langchain.callbacks import _convert_message_to_dict
from posthog.ai.langchain.callbacks import _convert_message_to_dict
from langchain_core.messages import AIMessage
from langchain_core.messages.tool import ToolCall
@@ -1984,7 +1984,7 @@ def test_tool_definition(mock_client):
assert run == expected
assert callbacks._runs == {}
# Now test that the tools are properly captured in the Insights event
# Now test that the tools are properly captured in the PostHog event
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
@@ -2236,7 +2236,7 @@ def test_agent_action_and_finish_imports():
from langchain.schema.agent import AgentAction, AgentFinish # type: ignore
# Verify they're available in the callbacks module
from hanzo_insights.ai.langchain.callbacks import CallbackHandler
from posthog.ai.langchain.callbacks import CallbackHandler
# Test on_agent_action with mock data
mock_client = MagicMock()
@@ -2266,8 +2266,8 @@ def test_agent_action_and_finish_imports():
assert call_args["event"] == "$ai_span"
def test_insights_properties_field_in_generation_metadata(mock_client):
"""Test that insights_properties is properly stored in GenerationMetadata."""
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()
@@ -2281,7 +2281,7 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"insights_properties": {"$ai_billable": True},
"posthog_properties": {"$ai_billable": True},
},
name="test",
)
@@ -2294,11 +2294,11 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
provider="openai",
base_url="https://api.openai.com",
name="test",
insights_properties={"$ai_billable": True},
posthog_properties={"$ai_billable": True},
end_time=None,
)
assert callbacks._runs[run_id] == expected
assert callbacks._runs[run_id].insights_properties == {"$ai_billable": True}
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
callbacks._pop_run_metadata(run_id)
@@ -2313,15 +2313,15 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
metadata={
"ls_model_name": "gpt-4o",
"ls_provider": "openai",
"insights_properties": {"$ai_billable": False},
"posthog_properties": {"$ai_billable": False},
},
name="test",
)
assert callbacks._runs[run_id2].insights_properties == {"$ai_billable": False}
assert callbacks._runs[run_id2].posthog_properties == {"$ai_billable": False}
callbacks._pop_run_metadata(run_id2)
# Test when insights_properties not provided
# Test when posthog_properties not provided
run_id3 = uuid.uuid4()
with patch("time.time", return_value=1234567890):
callbacks._set_llm_metadata(
@@ -2333,7 +2333,7 @@ def test_insights_properties_field_in_generation_metadata(mock_client):
name="test",
)
assert callbacks._runs[run_id3].insights_properties is None
assert callbacks._runs[run_id3].posthog_properties is None
def test_billable_property_in_generation_event(mock_client):
@@ -2349,7 +2349,7 @@ def test_billable_property_in_generation_event(mock_client):
run_id,
messages=[{"role": "user", "content": "Test"}],
metadata={
"insights_properties": {"$ai_billable": True},
"posthog_properties": {"$ai_billable": True},
"ls_model_name": "test-model",
},
invocation_params={},
@@ -2412,12 +2412,12 @@ def test_billable_with_real_chain(mock_client):
metadata={
"ls_model_name": "fake-model",
"ls_provider": "fake",
"insights_properties": {"$ai_billable": True},
"posthog_properties": {"$ai_billable": True},
},
invocation_params={"temperature": 0.7},
)
assert callbacks._runs[run_id].insights_properties == {"$ai_billable": True}
assert callbacks._runs[run_id].posthog_properties == {"$ai_billable": True}
mock_response = MagicMock()
mock_response.generations = [[MagicMock()]]
@@ -34,8 +34,8 @@ try:
ParsedResponseOutputText,
)
from hanzo_insights.ai.openai import OpenAI
from hanzo_insights.ai.openai.openai_async import AsyncOpenAI
from posthog.ai.openai import OpenAI
from posthog.ai.openai.openai_async import AsyncOpenAI
OPENAI_AVAILABLE = True
except ImportError:
@@ -49,7 +49,7 @@ pytestmark = pytest.mark.skipif(
@pytest.fixture
def mock_client():
with patch("hanzo_insights.client.Client") as mock_client:
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@@ -467,12 +467,12 @@ def test_basic_completion(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response
@@ -513,12 +513,12 @@ def test_embeddings(mock_client, mock_embedding_response):
"openai.resources.embeddings.Embeddings.create",
return_value=mock_embedding_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.embeddings.create(
model="text-embedding-3-small",
input="Hello world",
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_embedding_response
@@ -543,12 +543,12 @@ def test_groups(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_groups={"company": "test_company"},
posthog_distinct_id="test-id",
posthog_groups={"company": "test_company"},
)
assert response == mock_openai_response
@@ -564,12 +564,12 @@ def test_privacy_mode_local(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=True,
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
assert response == mock_openai_response
@@ -587,12 +587,12 @@ def test_privacy_mode_global(mock_client, mock_openai_response):
return_value=mock_openai_response,
):
mock_client.privacy_mode = True
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_privacy_mode=False,
posthog_distinct_id="test-id",
posthog_privacy_mode=False,
)
assert response == mock_openai_response
@@ -609,7 +609,7 @@ def test_error(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
side_effect=Exception("Test error"),
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
with pytest.raises(Exception):
client.chat.completions.create(
model="gpt-4", messages=[{"role": "user", "content": "Hello"}]
@@ -628,12 +628,12 @@ def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_with_cached_tokens,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_cached_tokens
@@ -666,7 +666,7 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_with_tool_calls,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[
@@ -682,7 +682,7 @@ def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_with_tool_calls
@@ -739,7 +739,7 @@ def test_tool_calls_only_no_content(mock_client, mock_openai_response_tool_calls
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response_tool_calls_only,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Get weather for New York"}],
@@ -753,7 +753,7 @@ def test_tool_calls_only_no_content(mock_client, mock_openai_response_tool_calls
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_tool_calls_only
@@ -793,7 +793,7 @@ def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_call
"openai.resources.responses.Responses.create",
return_value=mock_responses_api_with_tool_calls,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "What's the weather in Chicago?"}],
@@ -808,7 +808,7 @@ def test_responses_api_tool_calls(mock_client, mock_responses_api_with_tool_call
},
}
],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_responses_api_with_tool_calls
@@ -851,7 +851,7 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
# Set up the mock to return our chunks when iterated
mock_create.return_value = streaming_tool_call_chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Call the streaming method
response_generator = client.chat.completions.create(
@@ -870,7 +870,7 @@ def test_streaming_with_tool_calls(mock_client, streaming_tool_call_chunks):
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
@@ -949,12 +949,12 @@ def test_responses_api(mock_client, mock_openai_response_with_responses_api):
"openai.resources.responses.Responses.create",
return_value=mock_openai_response_with_responses_api,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
input="Hello",
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_responses_api
assert mock_client.capture.call_count == 1
@@ -986,7 +986,7 @@ def test_responses_parse(mock_client, mock_parsed_response):
"openai.resources.responses.Responses.parse",
return_value=mock_parsed_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.parse(
model="gpt-4o-2024-08-06",
input=[
@@ -1016,8 +1016,8 @@ def test_responses_parse(mock_client, mock_parsed_response):
},
}
},
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_parsed_response
@@ -1101,15 +1101,15 @@ def test_responses_api_streaming_with_tokens(mock_client):
"openai.resources.responses.Responses.create",
side_effect=mock_streaming_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Consume the streaming response
response = client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "Test message"}],
stream=True,
insights_distinct_id="test-id",
insights_properties={"test": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"test": "streaming"},
)
# Consume all chunks
@@ -1153,7 +1153,7 @@ async def test_async_chat_streaming_with_tool_calls(
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.chat.completions.create(
model="gpt-4",
@@ -1171,7 +1171,7 @@ async def test_async_chat_streaming_with_tool_calls(
}
],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = []
@@ -1239,14 +1239,14 @@ async def test_async_responses_streaming_with_tokens(mock_client):
return chunk_iterable()
with patch("openai.resources.responses.AsyncResponses.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.responses.create(
model="gpt-4o-mini",
input=[{"role": "user", "content": "Test message"}],
stream=True,
insights_distinct_id="test-id",
insights_properties={"test": "streaming"},
posthog_distinct_id="test-id",
posthog_properties={"test": "streaming"},
)
async for _ in response_stream:
@@ -1274,13 +1274,13 @@ async def test_async_embeddings_create(mock_client, mock_embedding_response):
mock_create = AsyncMock(return_value=mock_embedding_response)
with patch("openai.resources.embeddings.AsyncEmbeddings.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response = await client.embeddings.create(
model="text-embedding-3-small",
input="Hello world",
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_embedding_response
@@ -1304,7 +1304,7 @@ def test_tool_definition(mock_client, mock_openai_response):
"openai.resources.chat.completions.Completions.create",
return_value=mock_openai_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Define tools to be passed to the create function
tools = [
@@ -1331,8 +1331,8 @@ def test_tool_definition(mock_client, mock_openai_response):
model="gpt-4o-mini",
messages=[{"role": "user", "content": "hey"}],
tools=tools,
insights_distinct_id="test-id",
insights_properties={"foo": "bar"},
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response
@@ -1392,11 +1392,11 @@ def test_web_search_perplexity_style(mock_client):
mock_response = MockResponseWithAnnotations()
with patch("openai.resources.chat.Completions.create", return_value=mock_response):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4-turbo",
messages=[{"role": "user", "content": "What's happening in tech?"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1439,19 +1439,19 @@ def test_web_search_responses_api(mock_client):
"openai.resources.responses.Responses.create", return_value=mock_response
):
# Manually call the tracking since we're testing the converter logic
from hanzo_insights.ai.utils import call_llm_and_track_usage
from posthog.ai.utils import call_llm_and_track_usage
def mock_create_call(**kwargs):
return mock_response
result = call_llm_and_track_usage(
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
ph_client=mock_client,
provider="openai",
insights_trace_id=None,
insights_properties=None,
insights_privacy_mode=False,
insights_groups=None,
posthog_trace_id=None,
posthog_properties=None,
posthog_privacy_mode=False,
posthog_groups=None,
base_url="https://api.openai.com/v1",
call_method=mock_create_call,
model="gpt-4o",
@@ -1533,12 +1533,12 @@ def test_streaming_with_web_search(mock_client, streaming_web_search_chunks):
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = streaming_web_search_chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
@@ -1569,12 +1569,12 @@ def test_streaming_with_web_search_on_non_usage_chunk(
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = streaming_web_search_chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
@@ -1629,12 +1629,12 @@ async def test_async_chat_with_web_search(mock_client):
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1672,13 +1672,13 @@ async def test_async_chat_streaming_with_web_search(
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Search for recent news"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
chunks = []
@@ -1742,13 +1742,13 @@ def test_streaming_chat_extracts_model_from_chunk_when_not_in_kwargs(mock_client
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs - simulates stored prompt usage
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
# Consume the generator
@@ -1788,13 +1788,13 @@ def test_streaming_chat_prefers_kwargs_model_over_chunk_model(mock_client):
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = chunks
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
model="gpt-4o-from-kwargs", # Explicitly passed model
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
list(response_generator)
@@ -1839,13 +1839,13 @@ def test_streaming_responses_api_extracts_model_from_response_object(mock_client
with patch("openai.resources.responses.Responses.create") as mock_create:
mock_create.return_value = iter(chunks)
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model - simulates stored prompt
response_generator = client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
list(response_generator)
@@ -1886,12 +1886,12 @@ def test_non_streaming_extracts_model_from_response(mock_client):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1948,12 +1948,12 @@ def test_non_streaming_responses_api_extracts_model_from_response(mock_client):
"openai.resources.responses.Responses.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs
response = client.responses.create(
input="Hello",
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
assert response == mock_response
@@ -1995,12 +1995,12 @@ def test_non_streaming_returns_none_when_no_model(mock_client):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model in kwargs and response has no model
client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
call_args = mock_client.capture.call_args[1]
@@ -2032,12 +2032,12 @@ def test_streaming_falls_back_to_unknown_when_no_model(mock_client):
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
mock_create.return_value = [chunk]
client = OpenAI(api_key="test-key", insights_client=mock_client)
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response_generator = client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
list(response_generator)
@@ -2083,13 +2083,13 @@ async def test_async_streaming_chat_extracts_model_from_chunk(mock_client):
with patch(
"openai.resources.chat.completions.AsyncCompletions.create", new=mock_create
):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
# Note: NOT passing model
response_stream = await client.chat.completions.create(
messages=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
async for _ in response_stream:
@@ -2137,12 +2137,12 @@ async def test_async_streaming_responses_extracts_model_from_response(mock_clien
return chunk_iterable()
with patch("openai.resources.responses.AsyncResponses.create", new=mock_create):
client = AsyncOpenAI(api_key="test-key", insights_client=mock_client)
client = AsyncOpenAI(api_key="test-key", posthog_client=mock_client)
response_stream = await client.responses.create(
input=[{"role": "user", "content": "Hello"}],
stream=True,
insights_distinct_id="test-id",
posthog_distinct_id="test-id",
)
async for _ in response_stream:
@@ -16,7 +16,7 @@ try:
TranscriptionSpanData,
)
from hanzo_insights.ai.openai_agents import InsightsTracingProcessor, instrument
from posthog.ai.openai_agents import PostHogTracingProcessor, instrument
OPENAI_AGENTS_AVAILABLE = True
except ImportError:
@@ -33,13 +33,13 @@ pytestmark = pytest.mark.skipif(
def mock_client():
client = MagicMock()
client.privacy_mode = False
logging.getLogger("hanzo_insights").setLevel(logging.DEBUG)
logging.getLogger("posthog").setLevel(logging.DEBUG)
return client
@pytest.fixture(scope="function")
def processor(mock_client):
return InsightsTracingProcessor(
return PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=False,
@@ -68,12 +68,12 @@ def mock_span():
return span
class TestInsightsTracingProcessor:
"""Tests for the InsightsTracingProcessor class."""
class TestPostHogTracingProcessor:
"""Tests for the PostHogTracingProcessor class."""
def test_initialization(self, mock_client):
"""Test processor initializes correctly."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="user@example.com",
privacy_mode=True,
@@ -93,7 +93,7 @@ class TestInsightsTracingProcessor:
def resolver(trace):
return trace.metadata.get("user_id", "default")
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
@@ -127,7 +127,7 @@ class TestInsightsTracingProcessor:
def test_personless_mode_when_no_distinct_id(self, mock_client, mock_trace):
"""Test that trace events use personless mode when no distinct_id is provided."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
)
@@ -143,7 +143,7 @@ class TestInsightsTracingProcessor:
self, mock_client, mock_trace, mock_span
):
"""Test that span events use personless mode when no distinct_id is provided."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
)
@@ -168,7 +168,7 @@ class TestInsightsTracingProcessor:
def resolver(trace):
return None # Simulate no user ID available
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
@@ -188,7 +188,7 @@ class TestInsightsTracingProcessor:
def test_person_profile_when_distinct_id_provided(self, mock_client, mock_trace):
"""Test that events create person profiles when distinct_id is provided."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="real-user",
)
@@ -375,7 +375,7 @@ class TestInsightsTracingProcessor:
def test_privacy_mode_redacts_content(self, mock_client, mock_span):
"""Test that privacy_mode redacts input/output content."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
privacy_mode=True,
@@ -636,7 +636,7 @@ class TestInsightsTracingProcessor:
def test_groups_included_in_events(self, mock_client, mock_trace, mock_span):
"""Test that groups are included in captured events."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
groups={"company": "acme", "team": "engineering"},
@@ -650,7 +650,7 @@ class TestInsightsTracingProcessor:
def test_additional_properties_included(self, mock_client, mock_trace):
"""Test that additional properties are included in events."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
properties={"environment": "production", "version": "1.0"},
@@ -734,7 +734,7 @@ class TestInsightsTracingProcessor:
def resolver(trace):
return f"user-{trace.name}"
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id=resolver,
)
@@ -755,7 +755,7 @@ class TestInsightsTracingProcessor:
def test_eviction_of_stale_entries(self, mock_client):
"""Test that stale entries are evicted when max is exceeded."""
processor = InsightsTracingProcessor(
processor = PostHogTracingProcessor(
client=mock_client,
distinct_id="test-user",
)
@@ -785,7 +785,7 @@ class TestInstrumentHelper:
)
mock_add.assert_called_once_with(processor)
assert isinstance(processor, InsightsTracingProcessor)
assert isinstance(processor, PostHogTracingProcessor)
def test_instrument_with_privacy_mode(self, mock_client):
"""Test instrument() respects privacy_mode."""
@@ -1,7 +1,7 @@
import unittest
from unittest.mock import MagicMock, patch
from hanzo_insights.ai.prompts import Prompts
from posthog.ai.prompts import Prompts
class MockResponse:
@@ -32,13 +32,13 @@ class TestPrompts(unittest.TestCase):
"deleted": False,
}
def create_mock_client(
def create_mock_posthog(
self,
personal_api_key="phx_test_key",
project_api_key="phc_test_key",
host="https://us.insights.hanzo.ai",
host="https://us.posthog.com",
):
"""Create a mock Insights client."""
"""Create a mock PostHog client."""
mock = MagicMock()
mock.personal_api_key = personal_api_key
mock.api_key = project_api_key
@@ -49,14 +49,14 @@ class TestPrompts(unittest.TestCase):
class TestPromptsGet(TestPrompts):
"""Tests for the Prompts.get() method."""
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_successfully_fetch_a_prompt(self, mock_get_session):
"""Should successfully fetch a prompt."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.get("test-prompt")
@@ -65,47 +65,23 @@ class TestPromptsGet(TestPrompts):
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key",
"https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key",
)
self.assertIn("Authorization", call_args[1]["headers"])
self.assertEqual(
call_args[1]["headers"]["Authorization"], "Bearer phx_test_key"
)
@patch("hanzo_insights.ai.prompts._get_session")
def test_successfully_fetch_a_specific_prompt_version(self, mock_get_session):
"""Should successfully fetch a specific prompt version."""
mock_get = mock_get_session.return_value.get
versioned_prompt_response = {
**self.mock_prompt_response,
"prompt": "Prompt version 1",
"version": 1,
}
mock_get.return_value = MockResponse(json_data=versioned_prompt_response)
client = self.create_mock_client()
prompts = Prompts(client)
result = prompts.get("test-prompt", version=1)
self.assertEqual(result, versioned_prompt_response["prompt"])
mock_get.assert_called_once()
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key&version=1",
)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.time.time")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_return_cached_prompt_when_fresh(self, mock_time, mock_get_session):
"""Should return cached prompt when fresh (no API call)."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - fetches from API
result1 = prompts.get("test-prompt", cache_ttl_seconds=300)
@@ -120,43 +96,8 @@ class TestPromptsGet(TestPrompts):
self.assertEqual(result2, self.mock_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 1) # No additional fetch
@patch("hanzo_insights.ai.prompts._get_session")
def test_cache_latest_and_versioned_prompts_separately(self, mock_get_session):
"""Should cache latest and historical prompt versions separately."""
mock_get = mock_get_session.return_value.get
latest_prompt_response = {
**self.mock_prompt_response,
"prompt": "Latest prompt",
"version": 2,
}
versioned_prompt_response = {
**self.mock_prompt_response,
"prompt": "Prompt version 1",
"version": 1,
}
mock_get.side_effect = [
MockResponse(json_data=latest_prompt_response),
MockResponse(json_data=versioned_prompt_response),
]
client = self.create_mock_client()
prompts = Prompts(client)
self.assertEqual(prompts.get("test-prompt"), latest_prompt_response["prompt"])
self.assertEqual(
prompts.get("test-prompt", version=1),
versioned_prompt_response["prompt"],
)
self.assertEqual(prompts.get("test-prompt"), latest_prompt_response["prompt"])
self.assertEqual(
prompts.get("test-prompt", version=1),
versioned_prompt_response["prompt"],
)
self.assertEqual(mock_get.call_count, 2)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.time.time")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_refetch_when_cache_is_stale(self, mock_time, mock_get_session):
"""Should refetch when cache is stale."""
mock_get = mock_get_session.return_value.get
@@ -171,8 +112,8 @@ class TestPromptsGet(TestPrompts):
]
mock_time.return_value = 1000.0
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - fetches from API
result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
@@ -187,9 +128,9 @@ class TestPromptsGet(TestPrompts):
self.assertEqual(result2, updated_prompt_response["prompt"])
self.assertEqual(mock_get.call_count, 2)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.time.time")
@patch("hanzo_insights.ai.prompts.log")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
@patch("posthog.ai.prompts.log")
def test_use_stale_cache_on_fetch_failure_with_warning(
self, mock_log, mock_time, mock_get_session
):
@@ -201,8 +142,8 @@ class TestPromptsGet(TestPrompts):
]
mock_time.return_value = 1000.0
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call - populates cache
result1 = prompts.get("test-prompt", cache_ttl_seconds=60)
@@ -220,8 +161,8 @@ class TestPromptsGet(TestPrompts):
warning_call = mock_log.warning.call_args
self.assertIn("using stale cache", warning_call[0][0])
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.log")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.log")
def test_use_fallback_when_no_cache_and_fetch_fails_with_warning(
self, mock_log, mock_get_session
):
@@ -229,8 +170,8 @@ class TestPromptsGet(TestPrompts):
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
fallback = "Default system prompt."
result = prompts.get("test-prompt", fallback=fallback)
@@ -242,59 +183,42 @@ class TestPromptsGet(TestPrompts):
warning_call = mock_log.warning.call_args
self.assertIn("using fallback", warning_call[0][0])
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_throw_when_no_cache_no_fallback_and_fetch_fails(self, mock_get_session):
"""Should throw when no cache, no fallback, and fetch fails."""
mock_get = mock_get_session.return_value.get
mock_get.side_effect = Exception("Network error")
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn("Network error", str(context.exception))
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_handle_404_response(self, mock_get_session):
"""Should handle 404 response."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=404, ok=False)
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("nonexistent-prompt")
self.assertIn('Prompt "nonexistent-prompt" not found', str(context.exception))
@patch("hanzo_insights.ai.prompts._get_session")
def test_handle_404_response_for_specific_prompt_version(self, mock_get_session):
"""Should handle 404 response for a specific prompt version."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=404, ok=False)
client = self.create_mock_client()
prompts = Prompts(client)
with self.assertRaises(Exception) as context:
prompts.get("nonexistent-prompt", version=3)
self.assertIn(
'Prompt "nonexistent-prompt" version 3 not found',
str(context.exception),
)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_handle_403_response(self, mock_get_session):
"""Should handle 403 response."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(status_code=403, ok=False)
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("restricted-prompt")
@@ -305,8 +229,8 @@ class TestPromptsGet(TestPrompts):
def test_throw_when_no_personal_api_key_configured(self):
"""Should throw when no personal_api_key is configured."""
client = self.create_mock_client(personal_api_key=None)
prompts = Prompts(client)
posthog = self.create_mock_posthog(personal_api_key=None)
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
@@ -317,8 +241,8 @@ class TestPromptsGet(TestPrompts):
def test_throw_when_no_project_api_key_configured(self):
"""Should throw when no project_api_key is configured."""
client = self.create_mock_client(project_api_key=None)
prompts = Prompts(client)
posthog = self.create_mock_posthog(project_api_key=None)
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
@@ -327,49 +251,49 @@ class TestPromptsGet(TestPrompts):
"project_api_key is required to fetch prompts", str(context.exception)
)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_throw_when_api_returns_invalid_response_format(self, mock_get_session):
"""Should throw when API returns invalid response format."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data={"invalid": "response"})
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
with self.assertRaises(Exception) as context:
prompts.get("test-prompt")
self.assertIn("Invalid response format", str(context.exception))
@patch("hanzo_insights.ai.prompts._get_session")
def test_use_custom_host_from_insights_options(self, mock_get_session):
"""Should use custom host from Insights options."""
@patch("posthog.ai.prompts._get_session")
def test_use_custom_host_from_posthog_options(self, mock_get_session):
"""Should use custom host from PostHog options."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
client = self.create_mock_client(host="https://eu.insights.hanzo.ai")
prompts = Prompts(client)
posthog = self.create_mock_posthog(host="https://eu.posthog.com")
prompts = Prompts(posthog)
prompts.get("test-prompt")
call_args = mock_get.call_args
self.assertTrue(
call_args[0][0].startswith(
"https://eu.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key"
"https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key"
),
f"Expected URL to start with 'https://eu.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key', got {call_args[0][0]}",
f"Expected URL to start with 'https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_test_key', got {call_args[0][0]}",
)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.time.time")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_default_cache_ttl_5_minutes(self, mock_time, mock_get_session):
"""Should use default cache TTL (5 minutes) when not specified."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# First call
prompts.get("test-prompt")
@@ -389,8 +313,8 @@ class TestPromptsGet(TestPrompts):
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.time.time")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_custom_default_cache_ttl_from_constructor(
self, mock_time, mock_get_session
):
@@ -399,8 +323,8 @@ class TestPromptsGet(TestPrompts):
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
mock_time.return_value = 1000.0
client = self.create_mock_client()
prompts = Prompts(client, default_cache_ttl_seconds=60)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog, default_cache_ttl_seconds=60)
# First call
prompts.get("test-prompt")
@@ -413,26 +337,26 @@ class TestPromptsGet(TestPrompts):
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_url_encode_prompt_names_with_special_characters(self, mock_get_session):
"""Should URL-encode prompt names with special characters."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
prompts.get("prompt with spaces/and/slashes")
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.insights.hanzo.ai/api/environments/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/?token=phc_test_key",
"https://us.posthog.com/api/environments/@current/llm_prompts/name/prompt%20with%20spaces%2Fand%2Fslashes/?token=phc_test_key",
)
@patch("hanzo_insights.ai.prompts._get_session")
def test_work_with_direct_options_no_insights_client(self, mock_get_session):
"""Should work with direct options (no Insights client)."""
@patch("posthog.ai.prompts._get_session")
def test_work_with_direct_options_no_posthog_client(self, mock_get_session):
"""Should work with direct options (no PostHog client)."""
mock_get = mock_get_session.return_value.get
mock_get.return_value = MockResponse(json_data=self.mock_prompt_response)
@@ -446,13 +370,13 @@ class TestPromptsGet(TestPrompts):
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://us.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
"https://us.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
)
self.assertEqual(
call_args[1]["headers"]["Authorization"], "Bearer phx_direct_key"
)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_use_custom_host_from_direct_options(self, mock_get_session):
"""Should use custom host from direct options."""
mock_get = mock_get_session.return_value.get
@@ -461,7 +385,7 @@ class TestPromptsGet(TestPrompts):
prompts = Prompts(
personal_api_key="phx_direct_key",
project_api_key="phc_direct_key",
host="https://eu.insights.hanzo.ai",
host="https://eu.posthog.com",
)
prompts.get("test-prompt")
@@ -469,11 +393,11 @@ class TestPromptsGet(TestPrompts):
call_args = mock_get.call_args
self.assertEqual(
call_args[0][0],
"https://eu.insights.hanzo.ai/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
"https://eu.posthog.com/api/environments/@current/llm_prompts/name/test-prompt/?token=phc_direct_key",
)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("hanzo_insights.ai.prompts.time.time")
@patch("posthog.ai.prompts._get_session")
@patch("posthog.ai.prompts.time.time")
def test_use_custom_default_cache_ttl_from_direct_options(
self, mock_time, mock_get_session
):
@@ -505,8 +429,8 @@ class TestPromptsCompile(TestPrompts):
def test_replace_a_single_variable(self):
"""Should replace a single variable."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Hello, {{name}}!", {"name": "World"})
@@ -514,8 +438,8 @@ class TestPromptsCompile(TestPrompts):
def test_replace_multiple_variables(self):
"""Should replace multiple variables."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Welcome to {{company}}. Your tier is {{tier}}.",
@@ -528,8 +452,8 @@ class TestPromptsCompile(TestPrompts):
def test_handle_numbers(self):
"""Should handle numbers."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("You have {{count}} items.", {"count": 42})
@@ -537,8 +461,8 @@ class TestPromptsCompile(TestPrompts):
def test_handle_booleans(self):
"""Should handle booleans."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Feature enabled: {{enabled}}", {"enabled": True})
@@ -546,8 +470,8 @@ class TestPromptsCompile(TestPrompts):
def test_leave_unmatched_variables_unchanged(self):
"""Should leave unmatched variables unchanged."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Your {{unknown}} is ready.", {"name": "World"}
@@ -557,8 +481,8 @@ class TestPromptsCompile(TestPrompts):
def test_handle_prompts_with_no_variables(self):
"""Should handle prompts with no variables."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("You are a helpful assistant.", {})
@@ -566,8 +490,8 @@ class TestPromptsCompile(TestPrompts):
def test_handle_empty_variables_dict(self):
"""Should handle empty variables dict."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile("Hello, {{name}}!", {})
@@ -575,8 +499,8 @@ class TestPromptsCompile(TestPrompts):
def test_handle_multiple_occurrences_of_same_variable(self):
"""Should handle multiple occurrences of the same variable."""
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
result = prompts.compile(
"Hello, {{name}}! Goodbye, {{name}}!", {"name": "World"}
@@ -618,17 +542,7 @@ class TestPromptsCompile(TestPrompts):
class TestPromptsClearCache(TestPrompts):
"""Tests for the Prompts.clear_cache() method."""
def test_clear_cache_with_version_and_no_name_raises_value_error(self):
"""Should enforce that versioned cache clearing requires a prompt name."""
client = self.create_mock_client()
prompts = Prompts(client)
with self.assertRaises(ValueError) as context:
prompts.clear_cache(version=1)
self.assertIn("requires 'name'", str(context.exception))
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_clear_a_specific_prompt_from_cache(self, mock_get_session):
"""Should clear a specific prompt from cache."""
mock_get = mock_get_session.return_value.get
@@ -640,8 +554,8 @@ class TestPromptsClearCache(TestPrompts):
MockResponse(json_data=self.mock_prompt_response),
]
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# Populate cache with two prompts
prompts.get("test-prompt")
@@ -659,78 +573,7 @@ class TestPromptsClearCache(TestPrompts):
prompts.get("other-prompt")
self.assertEqual(mock_get.call_count, 3)
@patch("hanzo_insights.ai.prompts._get_session")
def test_clear_a_specific_prompt_version_from_cache(self, mock_get_session):
"""Should clear only the requested prompt version from cache."""
mock_get = mock_get_session.return_value.get
latest_prompt_response = {
**self.mock_prompt_response,
"prompt": "Latest prompt",
"version": 2,
}
versioned_prompt_response = {
**self.mock_prompt_response,
"prompt": "Prompt version 1",
"version": 1,
}
mock_get.side_effect = [
MockResponse(json_data=latest_prompt_response),
MockResponse(json_data=versioned_prompt_response),
MockResponse(json_data=versioned_prompt_response),
]
client = self.create_mock_client()
prompts = Prompts(client)
prompts.get("test-prompt")
prompts.get("test-prompt", version=1)
self.assertEqual(mock_get.call_count, 2)
prompts.clear_cache("test-prompt", version=1)
prompts.get("test-prompt")
self.assertEqual(mock_get.call_count, 2)
prompts.get("test-prompt", version=1)
self.assertEqual(mock_get.call_count, 3)
@patch("hanzo_insights.ai.prompts._get_session")
def test_clear_a_prompt_name_clears_all_cached_versions(self, mock_get_session):
"""Should clear latest and versioned cache entries for the same prompt name."""
mock_get = mock_get_session.return_value.get
latest_prompt_response = {
**self.mock_prompt_response,
"prompt": "Latest prompt",
"version": 2,
}
versioned_prompt_response = {
**self.mock_prompt_response,
"prompt": "Prompt version 1",
"version": 1,
}
mock_get.side_effect = [
MockResponse(json_data=latest_prompt_response),
MockResponse(json_data=versioned_prompt_response),
MockResponse(json_data=latest_prompt_response),
MockResponse(json_data=versioned_prompt_response),
]
client = self.create_mock_client()
prompts = Prompts(client)
prompts.get("test-prompt")
prompts.get("test-prompt", version=1)
self.assertEqual(mock_get.call_count, 2)
prompts.clear_cache("test-prompt")
prompts.get("test-prompt")
prompts.get("test-prompt", version=1)
self.assertEqual(mock_get.call_count, 4)
@patch("hanzo_insights.ai.prompts._get_session")
@patch("posthog.ai.prompts._get_session")
def test_clear_all_prompts_from_cache(self, mock_get_session):
"""Should clear all prompts from cache when no name is provided."""
mock_get = mock_get_session.return_value.get
@@ -743,8 +586,8 @@ class TestPromptsClearCache(TestPrompts):
MockResponse(json_data=other_prompt_response),
]
client = self.create_mock_client()
prompts = Prompts(client)
posthog = self.create_mock_posthog()
prompts = Prompts(posthog)
# Populate cache with two prompts
prompts.get("test-prompt")
@@ -1,7 +1,7 @@
import os
import unittest
from hanzo_insights.ai.sanitization import (
from posthog.ai.sanitization import (
redact_base64_data_url,
sanitize_openai,
sanitize_openai_response,
@@ -13,8 +13,8 @@ import time
import unittest
from unittest.mock import MagicMock, patch
from hanzo_insights.client import Client
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestSystemPromptCapture(unittest.TestCase):
@@ -26,7 +26,7 @@ class TestSystemPromptCapture(unittest.TestCase):
self.test_user_message = "Hello, how are you?"
self.test_response = "I'm doing well, thank you!"
# Create mock Insights client
# Create mock PostHog client
self.client = Client(FAKE_TEST_API_KEY)
self.client._enqueue = MagicMock()
self.client.privacy_mode = False
@@ -61,7 +61,7 @@ class TestSystemPromptCapture(unittest.TestCase):
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from hanzo_insights.ai.openai import OpenAI
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -88,7 +88,7 @@ class TestSystemPromptCapture(unittest.TestCase):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(insights_client=self.client, api_key="test")
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [
{"role": "system", "content": self.test_system_prompt},
@@ -96,7 +96,7 @@ class TestSystemPromptCapture(unittest.TestCase):
]
client.chat.completions.create(
model="gpt-4", messages=messages, insights_distinct_id="test-user"
model="gpt-4", messages=messages, posthog_distinct_id="test-user"
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -110,7 +110,7 @@ class TestSystemPromptCapture(unittest.TestCase):
from openai.types.chat.chat_completion import Choice
from openai.types.completion_usage import CompletionUsage
from hanzo_insights.ai.openai import OpenAI
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -137,7 +137,7 @@ class TestSystemPromptCapture(unittest.TestCase):
"openai.resources.chat.completions.Completions.create",
return_value=mock_response,
):
client = OpenAI(insights_client=self.client, api_key="test")
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
@@ -145,7 +145,7 @@ class TestSystemPromptCapture(unittest.TestCase):
model="gpt-4",
messages=messages,
system=self.test_system_prompt,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -162,7 +162,7 @@ class TestSystemPromptCapture(unittest.TestCase):
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.completion_usage import CompletionUsage
from hanzo_insights.ai.openai import OpenAI
from posthog.ai.openai import OpenAI
except ImportError:
self.skipTest("OpenAI package not available")
@@ -201,7 +201,7 @@ class TestSystemPromptCapture(unittest.TestCase):
"openai.resources.chat.completions.Completions.create",
return_value=[chunk1, chunk2],
):
client = OpenAI(insights_client=self.client, api_key="test")
client = OpenAI(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
@@ -210,7 +210,7 @@ class TestSystemPromptCapture(unittest.TestCase):
messages=messages,
system=self.test_system_prompt,
stream=True,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
list(response_generator) # Consume generator
@@ -223,7 +223,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_anthropic_messages_array_system_prompt(self):
"""Test Anthropic with system prompt in messages array."""
try:
from hanzo_insights.ai.anthropic import Anthropic
from posthog.ai.anthropic import Anthropic
except ImportError:
self.skipTest("Anthropic package not available")
@@ -235,7 +235,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_response.usage.cache_creation_input_tokens = None
mock_create.return_value = mock_response
client = Anthropic(insights_client=self.client, api_key="test")
client = Anthropic(posthog_client=self.client, api_key="test")
messages = [
{"role": "system", "content": self.test_system_prompt},
@@ -245,7 +245,7 @@ class TestSystemPromptCapture(unittest.TestCase):
client.messages.create(
model="claude-3-5-sonnet-20241022",
messages=messages,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -255,7 +255,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_anthropic_separate_system_parameter(self):
"""Test Anthropic with system prompt as separate parameter."""
try:
from hanzo_insights.ai.anthropic import Anthropic
from posthog.ai.anthropic import Anthropic
except ImportError:
self.skipTest("Anthropic package not available")
@@ -267,7 +267,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_response.usage.cache_creation_input_tokens = None
mock_create.return_value = mock_response
client = Anthropic(insights_client=self.client, api_key="test")
client = Anthropic(posthog_client=self.client, api_key="test")
messages = [{"role": "user", "content": self.test_user_message}]
@@ -275,7 +275,7 @@ class TestSystemPromptCapture(unittest.TestCase):
model="claude-3-5-sonnet-20241022",
messages=messages,
system=self.test_system_prompt,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -286,7 +286,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_gemini_contents_array_system_prompt(self):
"""Test Gemini with system prompt in contents array."""
try:
from hanzo_insights.ai.gemini import Client
from posthog.ai.gemini import Client
except ImportError:
self.skipTest("Gemini package not available")
@@ -306,7 +306,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_client_instance.models = mock_models_instance
mock_genai_class.return_value = mock_client_instance
client = Client(insights_client=self.client, api_key="test")
client = Client(posthog_client=self.client, api_key="test")
contents = [
{"role": "system", "content": self.test_system_prompt},
@@ -316,7 +316,7 @@ class TestSystemPromptCapture(unittest.TestCase):
client.models.generate_content(
model="gemini-2.0-flash",
contents=contents,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -326,7 +326,7 @@ class TestSystemPromptCapture(unittest.TestCase):
def test_gemini_system_instruction_parameter(self):
"""Test Gemini with system_instruction in config parameter."""
try:
from hanzo_insights.ai.gemini import Client
from posthog.ai.gemini import Client
except ImportError:
self.skipTest("Gemini package not available")
@@ -346,7 +346,7 @@ class TestSystemPromptCapture(unittest.TestCase):
mock_client_instance.models = mock_models_instance
mock_genai_class.return_value = mock_client_instance
client = Client(insights_client=self.client, api_key="test")
client = Client(posthog_client=self.client, api_key="test")
contents = [{"role": "user", "content": self.test_user_message}]
config = {"system_instruction": self.test_system_prompt}
@@ -355,7 +355,7 @@ class TestSystemPromptCapture(unittest.TestCase):
model="gemini-2.0-flash",
contents=contents,
config=config,
insights_distinct_id="test-user",
posthog_distinct_id="test-user",
)
self.assertEqual(len(self.client._enqueue.call_args_list), 1)
@@ -1,14 +1,14 @@
from parameterized import parameterized
from hanzo_insights.ai.utils import _get_tokens_source
from posthog.ai.utils import _get_tokens_source
@parameterized.expand(
[
("no_insights_properties", {"$ai_input_tokens": 100}, None, "sdk"),
("empty_insights_properties", {"$ai_input_tokens": 100}, {}, "sdk"),
("no_posthog_properties", {"$ai_input_tokens": 100}, None, "sdk"),
("empty_posthog_properties", {"$ai_input_tokens": 100}, {}, "sdk"),
(
"unrelated_insights_properties",
"unrelated_posthog_properties",
{"$ai_input_tokens": 100},
{"foo": "bar"},
"sdk",
@@ -57,6 +57,6 @@ from hanzo_insights.ai.utils import _get_tokens_source
),
]
)
def test_get_tokens_source(name, sdk_tags, insights_properties, expected):
result = _get_tokens_source(sdk_tags, insights_properties)
def test_get_tokens_source(name, sdk_tags, posthog_properties, expected):
result = _get_tokens_source(sdk_tags, posthog_properties)
assert result == expected
@@ -1,4 +1,4 @@
from hanzo_insights.contexts import (
from posthog.contexts import (
new_context,
get_context_session_id,
get_context_distinct_id,
@@ -20,7 +20,7 @@ if not settings.configured:
)
django.setup()
from hanzo_insights.integrations.django import InsightsContextMiddleware
from posthog.integrations.django import PosthogContextMiddleware
class MockRequest:
@@ -45,7 +45,7 @@ class MockRequest:
return f"{scheme}://{self._host}{self.path}"
class TestInsightsContextMiddleware(unittest.TestCase):
class TestPosthogContextMiddleware(unittest.TestCase):
def create_middleware(
self,
extra_tags=None,
@@ -60,24 +60,24 @@ class TestInsightsContextMiddleware(unittest.TestCase):
with patch("django.conf.settings") as mock_settings:
# Configure mock settings
mock_settings.INSIGHTS_MW_EXTRA_TAGS = extra_tags
mock_settings.INSIGHTS_MW_REQUEST_FILTER = request_filter
mock_settings.INSIGHTS_MW_TAG_MAP = tag_map
mock_settings.INSIGHTS_MW_CAPTURE_EXCEPTIONS = capture_exceptions
mock_settings.INSIGHTS_MW_CLIENT = None
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 [
"INSIGHTS_MW_EXTRA_TAGS",
"INSIGHTS_MW_REQUEST_FILTER",
"INSIGHTS_MW_TAG_MAP",
"INSIGHTS_MW_CAPTURE_EXCEPTIONS",
"INSIGHTS_MW_CLIENT",
"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 = InsightsContextMiddleware(get_response)
middleware = PosthogContextMiddleware(get_response)
return middleware
@@ -87,8 +87,8 @@ class TestInsightsContextMiddleware(unittest.TestCase):
middleware = self.create_middleware()
request = MockRequest(
headers={
"X-INSIGHTS-SESSION-ID": "session-123",
"X-INSIGHTS-DISTINCT-ID": "user-456",
"X-POSTHOG-SESSION-ID": "session-123",
"X-POSTHOG-DISTINCT-ID": "user-456",
},
method="POST",
path="/api/test",
@@ -104,7 +104,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
self.assertEqual(tags["$request_method"], "POST")
def test_extract_tags_missing_headers(self):
"""Test tag extraction when Insights headers are missing"""
"""Test tag extraction when PostHog headers are missing"""
with new_context():
middleware = self.create_middleware()
@@ -118,12 +118,12 @@ class TestInsightsContextMiddleware(unittest.TestCase):
self.assertEqual(tags["$request_method"], "GET")
def test_extract_tags_partial_headers(self):
"""Test tag extraction with only some Insights headers present"""
"""Test tag extraction with only some PostHog headers present"""
with new_context():
middleware = self.create_middleware()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "session-only"}, method="PUT"
headers={"X-POSTHOG-SESSION-ID": "session-only"}, method="PUT"
)
tags = middleware.extract_tags(request)
@@ -141,7 +141,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
with new_context():
middleware = self.create_middleware(extra_tags=extra_tags_func)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "session-123"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
@@ -167,7 +167,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
tag_map=tag_map_func, extra_tags=extra_tags_func
)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "session-123"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "session-123"}, method="GET"
)
tags = middleware.extract_tags(request)
@@ -230,7 +230,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
middleware.client = mock_client
request = MockRequest(
headers={"X-INSIGHTS-DISTINCT-ID": "test-user"},
headers={"X-POSTHOG-DISTINCT-ID": "test-user"},
method="POST",
path="/api/endpoint",
)
@@ -282,7 +282,7 @@ class TestInsightsContextMiddleware(unittest.TestCase):
mock_client.capture_exception.assert_not_called()
class TestInsightsContextMiddlewareSync(unittest.TestCase):
class TestPosthogContextMiddlewareSync(unittest.TestCase):
"""Test synchronous middleware behavior"""
def test_sync_middleware_call(self):
@@ -291,13 +291,13 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
get_response = Mock(return_value=mock_response)
# Create middleware with sync get_response
middleware = InsightsContextMiddleware(get_response)
middleware = PosthogContextMiddleware(get_response)
# Verify sync mode detected
self.assertFalse(middleware._is_coroutine)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"},
headers={"X-POSTHOG-SESSION-ID": "test-session"},
method="GET",
path="/test",
)
@@ -318,7 +318,7 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
def request_filter(req):
return False
middleware = InsightsContextMiddleware.__new__(InsightsContextMiddleware)
middleware = PosthogContextMiddleware.__new__(PosthogContextMiddleware)
middleware.get_response = get_response
middleware._is_coroutine = False
middleware.request_filter = request_filter
@@ -351,7 +351,7 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
mock_client = Mock()
get_response = Mock(return_value=Mock(status_code=500))
middleware = InsightsContextMiddleware(get_response)
middleware = PosthogContextMiddleware(get_response)
middleware.client = mock_client
def get_response_simulating_django(request):
@@ -380,7 +380,7 @@ class TestInsightsContextMiddlewareSync(unittest.TestCase):
)
class TestInsightsContextMiddlewareAsync(unittest.TestCase):
class TestPosthogContextMiddlewareAsync(unittest.TestCase):
"""Test asynchronous middleware behavior"""
def test_async_middleware_detection(self):
@@ -389,7 +389,7 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return Mock()
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
# Verify async mode detected
self.assertTrue(middleware._is_coroutine)
@@ -403,10 +403,10 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "async-session"},
headers={"X-POSTHOG-SESSION-ID": "async-session"},
method="POST",
path="/async-test",
)
@@ -434,7 +434,7 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
return mock_response
# Properly initialize middleware
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
# Override request filter after initialization
middleware.request_filter = lambda req: False
@@ -459,10 +459,10 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertEqual(session_id, "async-session-123")
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "async-session-123"},
headers={"X-POSTHOG-SESSION-ID": "async-session-123"},
method="GET",
)
@@ -483,7 +483,7 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
raise ValueError("Async test exception")
# Properly initialize middleware
middleware = InsightsContextMiddleware(raise_exception)
middleware = PosthogContextMiddleware(raise_exception)
middleware.client = mock_client # Override with mock client
request = MockRequest()
@@ -525,11 +525,11 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertEqual(distinct_id, "123")
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser() to return authenticated user
@@ -561,11 +561,11 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertIsNone(distinct_id)
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
async def mock_auser():
@@ -591,12 +591,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
# Request without auser method (no auth middleware)
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
with new_context():
@@ -621,12 +621,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.extra_tags = extra_tags_callback
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser for no user
@@ -658,12 +658,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
async def async_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.tag_map = tag_map_callback
middleware.client = Mock()
request = MockRequest(
headers={"X-INSIGHTS-SESSION-ID": "test-session"}, method="GET"
headers={"X-POSTHOG-SESSION-ID": "test-session"}, method="GET"
)
# Mock auser for no user
@@ -699,12 +699,12 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
self.assertEqual(session_id, "async-sess-123")
return mock_response
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
middleware.client = Mock()
request = MockRequest(
headers={
"X-INSIGHTS-SESSION-ID": "async-sess-123",
"X-POSTHOG-SESSION-ID": "async-sess-123",
"X-Forwarded-For": "192.168.1.1",
"User-Agent": "TestAgent/1.0",
},
@@ -725,13 +725,13 @@ class TestInsightsContextMiddlewareAsync(unittest.TestCase):
asyncio.run(run_test())
class TestInsightsContextMiddlewareHybrid(unittest.TestCase):
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(InsightsContextMiddleware.sync_capable)
self.assertTrue(InsightsContextMiddleware.async_capable)
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"""
@@ -740,7 +740,7 @@ class TestInsightsContextMiddlewareHybrid(unittest.TestCase):
async def async_get_response(request):
return Mock()
middleware = InsightsContextMiddleware(async_get_response)
middleware = PosthogContextMiddleware(async_get_response)
# Verify routing happens
request = MockRequest()
@@ -759,7 +759,7 @@ class TestInsightsContextMiddlewareHybrid(unittest.TestCase):
def sync_get_response(request):
return mock_response
middleware = InsightsContextMiddleware(sync_get_response)
middleware = PosthogContextMiddleware(sync_get_response)
request = MockRequest()
result = middleware(request)
@@ -2,16 +2,16 @@ import unittest
import mock
from hanzo_insights.client import Client
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestClient(unittest.TestCase):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.client_post_patcher = mock.patch("hanzo_insights.client.batch_post")
cls.consumer_post_patcher = mock.patch("hanzo_insights.consumer.batch_post")
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()
@@ -40,7 +40,7 @@ class TestClient(unittest.TestCase):
event["properties"]["processed_by_before_send"] = True
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -73,7 +73,7 @@ class TestClient(unittest.TestCase):
return None
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -101,7 +101,7 @@ class TestClient(unittest.TestCase):
def buggy_before_send(event):
raise ValueError("Oops!")
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -128,7 +128,7 @@ class TestClient(unittest.TestCase):
event["properties"]["marked"] = True
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -153,7 +153,7 @@ class TestClient(unittest.TestCase):
def test_before_send_callback_disabled_when_none(self):
"""Test that client works normally when before_send is None."""
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -189,7 +189,7 @@ class TestClient(unittest.TestCase):
return event
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -7,21 +7,21 @@ import mock
import six
from parameterized import parameterized
from hanzo_insights.client import Client
from hanzo_insights.contexts import get_context_session_id, new_context, set_context_session
from hanzo_insights.request import APIError, GetResponse
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from hanzo_insights.types import FeatureFlag, LegacyFlagMetadata
from hanzo_insights.version import VERSION
from hanzo_insights.contexts import tag
from posthog.client import Client
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):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.client_post_patcher = mock.patch("hanzo_insights.client.batch_post")
cls.consumer_post_patcher = mock.patch("hanzo_insights.consumer.batch_post")
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()
@@ -46,7 +46,7 @@ class TestClient(unittest.TestCase):
self.client.flush()
def test_basic_capture(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.capture("python test event", distinct_id="distinct_id")
self.assertIsNotNone(msg_uuid)
@@ -61,7 +61,7 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNotNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
# these will change between platforms so just asssert on presence here
assert msg["properties"]["$python_runtime"] == mock.ANY
@@ -70,7 +70,7 @@ class TestClient(unittest.TestCase):
assert msg["properties"]["$os_version"] == mock.ANY
def test_basic_capture_with_uuid(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
uuid = str(uuid4())
msg_uuid = client.capture(
@@ -88,11 +88,11 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertEqual(msg["uuid"], uuid)
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
def test_basic_capture_with_project_api_key(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
project_api_key=FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -111,11 +111,11 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["event"], "python test event")
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
def test_basic_super_properties(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
super_properties={"source": "repo-name"},
@@ -230,7 +230,7 @@ class TestClient(unittest.TestCase):
capture_call[1]["properties"]["$exception_list"][0]["stacktrace"][
"frames"
][0]["filename"],
"hanzo_insights/test/test_client.py",
"posthog/test/test_client.py",
)
self.assertEqual(
capture_call[1]["properties"]["$exception_list"][0]["stacktrace"][
@@ -242,7 +242,7 @@ class TestClient(unittest.TestCase):
capture_call[1]["properties"]["$exception_list"][0]["stacktrace"][
"frames"
][0]["module"],
"hanzo_insights.test.test_client",
"posthog.test.test_client",
)
self.assertEqual(
capture_call[1]["properties"]["$exception_list"][0]["stacktrace"][
@@ -253,31 +253,31 @@ class TestClient(unittest.TestCase):
def test_basic_capture_exception_with_no_exception_happening(self):
with mock.patch.object(Client, "capture", return_value=None) as patch_capture:
with self.assertLogs("hanzo_insights", level="WARNING") as logs:
with self.assertLogs("posthog", level="WARNING") as logs:
client = self.client
client.capture_exception(None)
self.assertFalse(patch_capture.called)
self.assertEqual(
logs.output[0],
"WARNING:hanzo_insights:No exception information available",
"WARNING:posthog:No exception information available",
)
def test_capture_exception_logs_when_enabled(self):
client = Client(FAKE_TEST_API_KEY, log_captured_exceptions=True)
with self.assertLogs("hanzo_insights", level="ERROR") as logs:
with self.assertLogs("posthog", level="ERROR") as logs:
client.capture_exception(
Exception("test exception"), distinct_id="distinct_id"
)
self.assertEqual(
logs.output[0], "ERROR:hanzo_insights:test exception\nNoneType: None"
logs.output[0], "ERROR:posthog:test exception\nNoneType: None"
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -299,7 +299,7 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNotNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(
msg["properties"]["$feature/beta-feature"], "random-variant"
@@ -310,7 +310,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_basic_capture_with_locally_evaluated_feature_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
@@ -400,7 +400,7 @@ class TestClient(unittest.TestCase):
},
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -424,7 +424,7 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNotNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(
msg["properties"]["$feature/beta-feature-local"], "third-variant"
@@ -438,7 +438,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
# test that flags are not evaluated without local evaluation
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -460,7 +460,7 @@ class TestClient(unittest.TestCase):
assert "$feature/false-flag" not in msg["properties"]
assert "$active_feature_flags" not in msg["properties"]
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_load_feature_flags_quota_limited(self, patch_get):
mock_response = {
"type": "quota_limited",
@@ -470,21 +470,21 @@ class TestClient(unittest.TestCase):
patch_get.side_effect = APIError(402, mock_response["detail"])
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
with self.assertLogs("hanzo_insights", level="WARNING") as logs:
with self.assertLogs("posthog", level="WARNING") as logs:
client._load_feature_flags()
self.assertEqual(client.feature_flags, [])
self.assertEqual(client.feature_flags_by_key, {})
self.assertEqual(client.group_type_mapping, {})
self.assertEqual(client.cohorts, {})
self.assertIn("Insights feature flags quota limited", logs.output[0])
self.assertIn("PostHog feature flags quota limited", logs.output[0])
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_load_feature_flags_unauthorized(self, patch_get):
patch_get.side_effect = APIError(401, "Unauthorized")
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
with self.assertLogs("hanzo_insights", level="ERROR") as logs:
with self.assertLogs("posthog", level="ERROR") as logs:
client._load_feature_flags()
self.assertEqual(client.feature_flags, [])
@@ -493,7 +493,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(client.cohorts, {})
self.assertIn("please set a valid personal_api_key", logs.output[0])
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_dont_override_capture_with_local_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
@@ -568,7 +568,7 @@ class TestClient(unittest.TestCase):
},
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -595,7 +595,7 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNotNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(
msg["properties"]["$feature/beta-feature-local"], "my-custom-variant"
@@ -608,7 +608,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_returns_active_only(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {
@@ -618,7 +618,7 @@ class TestClient(unittest.TestCase):
}
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -641,7 +641,7 @@ class TestClient(unittest.TestCase):
self.assertIsNotNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertTrue(msg["properties"]["$geoip_disable"])
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(
msg["properties"]["$feature/beta-feature"], "random-variant"
@@ -655,7 +655,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=3,
distinct_id="distinct_id",
groups={},
@@ -665,7 +665,7 @@ class TestClient(unittest.TestCase):
device_id=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_and_disable_geoip_returns_correctly(
self, patch_flags
):
@@ -677,7 +677,7 @@ class TestClient(unittest.TestCase):
}
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
host="https://app.posthog.com",
@@ -706,7 +706,7 @@ class TestClient(unittest.TestCase):
self.assertIsNotNone(msg.get("uuid"))
self.assertTrue("$geoip_disable" not in msg["properties"])
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(
msg["properties"]["$feature/beta-feature"], "random-variant"
@@ -720,7 +720,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=12,
distinct_id="distinct_id",
groups={},
@@ -730,13 +730,13 @@ class TestClient(unittest.TestCase):
device_id=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_basic_capture_with_feature_flags_switched_off_doesnt_send_them(
self, patch_flags
):
patch_flags.return_value = {"featureFlags": {"beta-feature": "random-variant"}}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -758,14 +758,14 @@ class TestClient(unittest.TestCase):
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertIsNotNone(msg.get("uuid"))
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertTrue("$feature/beta-feature" not in msg["properties"])
self.assertTrue("$active_feature_flags" not in msg["properties"])
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_false_and_local_evaluation_doesnt_send_flags(
self, patch_flags
):
@@ -814,7 +814,7 @@ class TestClient(unittest.TestCase):
},
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -847,7 +847,7 @@ class TestClient(unittest.TestCase):
# CRITICAL: Verify the /flags API was NOT called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_true_and_local_evaluation_uses_local_flags(
self, patch_flags
):
@@ -896,7 +896,7 @@ class TestClient(unittest.TestCase):
},
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -937,12 +937,12 @@ class TestClient(unittest.TestCase):
# CRITICAL: Verify the /flags API was NOT called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_only_evaluate_locally_true(
self, patch_flags
):
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=True uses local evaluation"""
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -990,14 +990,14 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$feature/local-flag"], True)
self.assertEqual(msg["properties"]["$active_feature_flags"], ["local-flag"])
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_only_evaluate_locally_false(
self, patch_flags
):
"""Test that SendFeatureFlagsOptions with only_evaluate_locally=False forces remote evaluation"""
patch_flags.return_value = {"featureFlags": {"remote-flag": "remote-value"}}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1036,14 +1036,14 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$feature/remote-flag"], "remote-value")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_with_send_feature_flags_options_default_behavior(
self, patch_flags
):
"""Test that SendFeatureFlagsOptions without only_evaluate_locally defaults to remote evaluation"""
patch_flags.return_value = {"featureFlags": {"default-flag": "default-value"}}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1076,12 +1076,12 @@ class TestClient(unittest.TestCase):
msg["properties"]["$feature/default-flag"], "default-value"
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_exception_with_send_feature_flags_options(self, patch_flags):
"""Test that capture_exception also supports SendFeatureFlagsOptions"""
patch_flags.return_value = {"featureFlags": {"exception-flag": True}}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1120,7 +1120,7 @@ class TestClient(unittest.TestCase):
def test_stringifies_distinct_id(self):
# A large number that loses precision in node:
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.capture(
"python test event", distinct_id=157963456373623802
@@ -1136,7 +1136,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["distinct_id"], "157963456373623802")
def test_advanced_capture(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.capture(
"python test event",
@@ -1156,14 +1156,14 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["properties"]["property"], "value")
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertEqual(msg["uuid"], "new-uuid")
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertTrue("$groups" not in msg["properties"])
def test_groups_capture(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.capture(
"test_event",
@@ -1184,7 +1184,7 @@ class TestClient(unittest.TestCase):
)
def test_basic_set(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.set(
distinct_id="distinct_id", properties={"trait": "value"}
@@ -1203,7 +1203,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_advanced_set(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.set(
distinct_id="distinct_id",
@@ -1221,14 +1221,14 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["$set"]["trait"], "value")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertEqual(msg["uuid"], "new-uuid")
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_basic_set_once(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.set_once(
distinct_id="distinct_id", properties={"trait": "value"}
@@ -1247,7 +1247,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_advanced_set_once(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.set_once(
distinct_id="distinct_id",
@@ -1265,14 +1265,14 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
self.assertEqual(msg["$set_once"]["trait"], "value")
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
self.assertTrue(isinstance(msg["timestamp"], str))
self.assertEqual(msg["uuid"], "new-uuid")
self.assertEqual(msg["distinct_id"], "distinct_id")
def test_basic_group_identify(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.group_identify("organization", "id:5")
@@ -1290,7 +1290,7 @@ class TestClient(unittest.TestCase):
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {},
"$lib": "insights-python",
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
@@ -1299,7 +1299,7 @@ class TestClient(unittest.TestCase):
self.assertIsNotNone(msg.get("uuid"))
def test_basic_group_identify_with_distinct_id(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.group_identify(
"organization", "id:5", distinct_id="distinct_id"
@@ -1319,7 +1319,7 @@ class TestClient(unittest.TestCase):
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {},
"$lib": "insights-python",
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
@@ -1328,7 +1328,7 @@ class TestClient(unittest.TestCase):
self.assertIsNotNone(msg.get("uuid"))
def test_advanced_group_identify(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.group_identify(
"organization",
@@ -1352,7 +1352,7 @@ class TestClient(unittest.TestCase):
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {"trait": "value"},
"$lib": "insights-python",
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
@@ -1360,7 +1360,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
def test_advanced_group_identify_with_distinct_id(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.group_identify(
"organization",
@@ -1387,7 +1387,7 @@ class TestClient(unittest.TestCase):
"$group_type": "organization",
"$group_key": "id:5",
"$group_set": {"trait": "value"},
"$lib": "insights-python",
"$lib": "posthog-python",
"$lib_version": VERSION,
"$geoip_disable": True,
},
@@ -1395,7 +1395,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["timestamp"], "2014-09-03T00:00:00+00:00")
def test_basic_alias(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
msg_uuid = client.alias("previousId", "distinct_id")
self.assertIsNotNone(msg_uuid)
@@ -1435,7 +1435,7 @@ class TestClient(unittest.TestCase):
def test_capture_with_session_id_variations(
self, test_name, session_id, additional_properties, expected_properties
):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
properties = {"$session_id": session_id, **additional_properties}
@@ -1454,7 +1454,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["event"], "python test event")
self.assertEqual(msg["distinct_id"], "distinct_id")
self.assertEqual(msg["properties"]["$session_id"], session_id)
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
# Check additional expected properties
@@ -1462,7 +1462,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"][key], value)
def test_session_id_preserved_with_groups(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
session_id = "group-session-101"
@@ -1487,7 +1487,7 @@ class TestClient(unittest.TestCase):
)
def test_session_id_with_anonymous_event(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
session_id = "anonymous-session-202"
@@ -1584,7 +1584,7 @@ class TestClient(unittest.TestCase):
additional_properties,
expected_additional_properties,
):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, sync_mode=True)
properties = {"$session_id": session_id, **additional_properties}
@@ -1607,7 +1607,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"][key], value)
# Verify system properties are still added
self.assertEqual(msg["properties"]["$lib"], "insights-python")
self.assertEqual(msg["properties"]["$lib"], "posthog-python")
self.assertEqual(msg["properties"]["$lib_version"], VERSION)
@parameterized.expand(
@@ -1651,7 +1651,7 @@ class TestClient(unittest.TestCase):
expected_session_id,
expected_super_props,
):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY, super_properties=super_properties, sync_mode=True
)
@@ -1704,7 +1704,7 @@ class TestClient(unittest.TestCase):
self.assertFalse(consumer.is_alive())
def test_synchronous(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(FAKE_TEST_API_KEY, sync_mode=True)
msg_uuid = client.capture("test event", distinct_id="distinct_id")
@@ -1758,7 +1758,7 @@ class TestClient(unittest.TestCase):
# the post function should be called 2 times, with a batch size of 10
# each time.
with mock.patch(
"hanzo_insights.consumer.batch_post", side_effect=mock_post_fn
"posthog.consumer.batch_post", side_effect=mock_post_fn
) as mock_post:
for _ in range(20):
client.capture(
@@ -1784,7 +1784,7 @@ class TestClient(unittest.TestCase):
self.assertIsNone(msg_uuid)
self.assertFalse(self.failed)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_disabled_with_feature_flags(self, patch_flags):
client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail, disabled=True)
@@ -1812,7 +1812,7 @@ class TestClient(unittest.TestCase):
self.assertTrue(client.queue.empty())
def test_enabled_to_disabled(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1836,7 +1836,7 @@ class TestClient(unittest.TestCase):
self.assertFalse(self.failed)
def test_disable_geoip_default_on_events(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1853,7 +1853,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(capture_msg["properties"]["$geoip_disable"], True)
def test_disable_geoip_override_on_events(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1889,7 +1889,7 @@ class TestClient(unittest.TestCase):
self.assertEqual("$geoip_disable" not in identify_msg["properties"], True)
def test_disable_geoip_method_overrides_init_on_events(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -1907,7 +1907,7 @@ class TestClient(unittest.TestCase):
msg = batch_data[0]
self.assertTrue("$geoip_disable" not in msg["properties"])
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_disable_geoip_default_on_decide(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {
@@ -1920,7 +1920,7 @@ class TestClient(unittest.TestCase):
client.get_feature_flag("random_key", "some_id", disable_geoip=True)
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
@@ -1936,7 +1936,7 @@ class TestClient(unittest.TestCase):
)
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=3,
distinct_id="feature_enabled_distinct_id",
groups={},
@@ -1950,7 +1950,7 @@ class TestClient(unittest.TestCase):
client.get_all_flags_and_payloads("all_flags_payloads_id")
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=3,
distinct_id="all_flags_payloads_id",
groups={},
@@ -1960,8 +1960,8 @@ class TestClient(unittest.TestCase):
device_id=None,
)
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_call_identify_fails(self, patch_get, patch_poller):
def raise_effect():
raise Exception("http exception")
@@ -1972,7 +1972,7 @@ class TestClient(unittest.TestCase):
self.assertFalse(client.feature_enabled("example", "distinct_id"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_default_properties_get_added_properly(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {
@@ -2080,7 +2080,7 @@ class TestClient(unittest.TestCase):
("get_flags_decision", ["some_id"], {}, None),
]
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_is_passed_to_flags_request(
self,
method,
@@ -2107,13 +2107,13 @@ class TestClient(unittest.TestCase):
expected_call["flag_keys_to_evaluate"] = expected_flag_keys
patch_flags.assert_called_with(
"random_key", "https://us.i.insights.hanzo.ai", timeout=3, **expected_call
"random_key", "https://us.i.posthog.com", timeout=3, **expected_call
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_from_context_is_used_in_flags_request(self, patch_flags):
"""Test that device_id from context is used in flags request when not explicitly provided."""
from hanzo_insights.contexts import new_context, set_context_device_id
from posthog.contexts import new_context, set_context_device_id
patch_flags.return_value = {
"featureFlags": {
@@ -2131,7 +2131,7 @@ class TestClient(unittest.TestCase):
client.get_feature_flag("random_key", "some_id")
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
@@ -2151,7 +2151,7 @@ class TestClient(unittest.TestCase):
)
patch_flags.assert_called_with(
"random_key",
"https://us.i.insights.hanzo.ai",
"https://us.i.posthog.com",
timeout=3,
distinct_id="some_id",
groups={},
@@ -2217,8 +2217,8 @@ class TestClient(unittest.TestCase):
distro_info,
):
"""Test that we can mock platform and sys for testing system_context"""
with mock.patch("hanzo_insights.utils.platform") as mock_platform:
with mock.patch("hanzo_insights.utils.sys") as mock_sys:
with mock.patch("posthog.utils.platform") as mock_platform:
with mock.patch("posthog.utils.sys") as mock_sys:
# Set up common mocks
mock_platform.python_implementation.return_value = expected_runtime
mock_sys.version_info = version_info
@@ -2234,15 +2234,15 @@ class TestClient(unittest.TestCase):
if sys_platform == "linux":
# Directly patch the get_os_info function to return our expected values
with mock.patch(
"hanzo_insights.utils.get_os_info",
"posthog.utils.get_os_info",
return_value=(expected_os, expected_os_version),
):
from hanzo_insights.utils import system_context
from posthog.utils import system_context
context = system_context()
else:
# Get system context for non-Linux platforms
from hanzo_insights.utils import system_context
from posthog.utils import system_context
context = system_context()
@@ -2256,7 +2256,7 @@ class TestClient(unittest.TestCase):
assert context == expected_context
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_decide_returns_normalized_decide_response(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {
@@ -2311,7 +2311,7 @@ class TestClient(unittest.TestCase):
}
def test_set_context_session_with_capture(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
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():
set_context_session("context-session-123")
@@ -2334,7 +2334,7 @@ class TestClient(unittest.TestCase):
)
def test_set_context_session_with_page_explicit_properties(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
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():
set_context_session("page-explicit-session-789")
@@ -2360,9 +2360,9 @@ 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 hanzo_insights.contexts import new_context, set_context_session
from posthog.contexts import new_context, set_context_session
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
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():
set_context_session("context-session-override")
@@ -2387,8 +2387,8 @@ class TestClient(unittest.TestCase):
msg["properties"]["$session_id"], "explicit-session-override"
)
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_enable_local_evaluation_false_disables_poller(
self, patch_get, patch_poller
):
@@ -2425,8 +2425,8 @@ class TestClient(unittest.TestCase):
self.assertEqual(len(client.feature_flags), 1)
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@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 = GetResponse(
@@ -2460,7 +2460,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(len(client.feature_flags), 1)
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
@mock.patch("hanzo_insights.client.remote_config")
@mock.patch("posthog.client.remote_config")
def test_get_remote_config_payload_works_without_poller(self, patch_remote_config):
"""Test that get_remote_config_payload works without local evaluation enabled"""
patch_remote_config.return_value = {"test": "payload"}
@@ -2572,7 +2572,7 @@ class TestClient(unittest.TestCase):
client._parse_send_feature_flags(None)
self.assertIn("Invalid type for send_feature_flags", str(cm.exception))
@mock.patch("hanzo_insights.client.flags")
@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
@@ -2583,7 +2583,7 @@ class TestClient(unittest.TestCase):
}
}
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
with mock.patch("posthog.client.batch_post") as mock_post:
client = Client(
FAKE_TEST_API_KEY,
on_error=self.set_fail,
@@ -2619,7 +2619,7 @@ class TestClient(unittest.TestCase):
# flag2 should not be included since it wasn't requested
self.assertNotIn("$feature/flag2", msg["properties"])
@mock.patch("hanzo_insights.client.batch_post")
@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(
@@ -2670,7 +2670,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(result.get_value(), "empty-variant")
self.assertEqual(result.payload, "") # Should be empty string, not None
@mock.patch("hanzo_insights.client.batch_post")
@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(
@@ -2727,7 +2727,7 @@ class TestClient(unittest.TestCase):
)
def test_context_tags_added(self):
with mock.patch("hanzo_insights.client.batch_post") as mock_post:
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():
@@ -2739,7 +2739,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(msg["properties"]["$context_tags"], ["random_tag"])
@mock.patch(
"hanzo_insights.client.Client._enqueue", side_effect=Exception("Unexpected error")
"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."""
@@ -2760,7 +2760,7 @@ class TestClient(unittest.TestCase):
self.assertEqual(result, None)
@mock.patch(
"hanzo_insights.client.Client._enqueue", side_effect=Exception("Expected error")
"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."""
@@ -11,9 +11,9 @@ try:
except ImportError:
from Queue import Queue
from hanzo_insights.consumer import MAX_MSG_SIZE, Consumer
from hanzo_insights.request import APIError
from hanzo_insights.test.test_utils import TEST_API_KEY
from posthog.consumer import MAX_MSG_SIZE, Consumer
from posthog.request import APIError
from posthog.test.test_utils import TEST_API_KEY
def _track_event(event_name: str = "python event") -> dict[str, str]:
@@ -60,7 +60,7 @@ class TestConsumer(unittest.TestCase):
q = Queue()
flush_interval = 0.3
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
with mock.patch("hanzo_insights.consumer.batch_post") as mock_post:
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(3):
q.put(_track_event("python event %d" % i))
@@ -76,7 +76,7 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(
q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval
)
with mock.patch("hanzo_insights.consumer.batch_post") as mock_post:
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(flush_at * 2):
q.put(_track_event("python event %d" % i))
@@ -99,7 +99,7 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(None, TEST_API_KEY, retries=retries)
with mock.patch(
"hanzo_insights.consumer.batch_post", mock.Mock(side_effect=mock_post)
"posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)
):
if exception_count <= retries:
consumer.request([_track_event()])
@@ -159,7 +159,7 @@ class TestConsumer(unittest.TestCase):
return res
with mock.patch(
"hanzo_insights.request._session.post", side_effect=mock_post_fn
"posthog.request._session.post", side_effect=mock_post_fn
) as mock_post:
consumer.start()
for _ in range(0, n_msgs + 2):
@@ -178,8 +178,8 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("hanzo_insights.consumer.batch_post", side_effect=mock_post),
mock.patch("hanzo_insights.consumer.time.sleep") as mock_sleep,
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep") as mock_sleep,
):
consumer.request([_track_event()])
mock_sleep.assert_called_once_with(5.0)
@@ -195,8 +195,8 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("hanzo_insights.consumer.batch_post", side_effect=mock_post),
mock.patch("hanzo_insights.consumer.time.sleep") as mock_sleep,
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep") as mock_sleep,
):
consumer.request([_track_event()])
self.assertEqual(
@@ -218,8 +218,8 @@ class TestConsumer(unittest.TestCase):
consumer = Consumer(None, TEST_API_KEY, retries=3)
with (
mock.patch("hanzo_insights.consumer.batch_post", side_effect=mock_post),
mock.patch("hanzo_insights.consumer.time.sleep"),
mock.patch("posthog.consumer.batch_post", side_effect=mock_post),
mock.patch("posthog.consumer.time.sleep"),
):
consumer.request([_track_event()])
self.assertEqual(call_count[0], 2)
@@ -1,7 +1,7 @@
import unittest
from unittest.mock import patch
from hanzo_insights.contexts import (
from posthog.contexts import (
get_tags,
new_context,
scoped,
@@ -66,7 +66,7 @@ class TestContexts(unittest.TestCase):
# Back to level 1
assert get_tags() == {"level1": "value1"}
@patch("hanzo_insights.capture_exception")
@patch("posthog.capture_exception")
def test_scoped_decorator_success(self, mock_capture):
@scoped()
def successful_function(x, y):
@@ -85,7 +85,7 @@ class TestContexts(unittest.TestCase):
# Context should be cleared after function execution
assert get_tags() == {}
@patch("hanzo_insights.capture_exception")
@patch("posthog.capture_exception")
def test_scoped_decorator_exception(self, mock_capture):
test_exception = ValueError("Test exception")
@@ -111,7 +111,7 @@ class TestContexts(unittest.TestCase):
# Context should be cleared after function execution
assert get_tags() == {}
@patch("hanzo_insights.capture_exception")
@patch("posthog.capture_exception")
def test_new_context_exception_handling(self, mock_capture):
test_exception = RuntimeError("Context exception")
@@ -10,8 +10,8 @@ def test_excepthook(tmpdir):
app.write(
dedent(
"""
from hanzo_insights import Insights
client = Insights('phc_x', host='https://eu.i.insights.hanzo.ai', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
from posthog import Posthog
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
# frame_value = "LOL"
@@ -27,7 +27,7 @@ def test_excepthook(tmpdir):
assert b"ZeroDivisionError" in output
assert b"LOL" in output
assert b"DEBUG:hanzo_insights:data uploaded successfully" in output
assert b"DEBUG:posthog:data uploaded successfully" in output
assert (
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
@@ -40,14 +40,14 @@ def test_code_variables_capture(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
class UnserializableObject:
pass
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -118,29 +118,29 @@ def test_code_variables_capture(tmpdir):
assert b"'my_bool': 'True'" in output
assert b'"my_dict": "{\\"name\\": \\"test\\", \\"value\\": 123}"' in output
assert (
b'{\\"safe_key\\": \\"safe_value\\", \\"password\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"other_key\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\"}'
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\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"data\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"safe\\": \\"visible\\"}}}'
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\\", \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
b'[\\"safe_item\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"another_safe\\"]'
in output
)
assert (
b'[\\"tuple_safe\\", \\"$$_insights_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
b'[\\"tuple_safe\\", \\"$$_posthog_redacted_based_on_masking_rules_$$\\", \\"tuple_also_safe\\"]'
in output
)
assert (
b'[{\\"id\\": 1, \\"password\\": \\"$$_insights_redacted_based_on_masking_rules_$$\\"}, {\\"id\\": 2, \\"value\\": \\"safe_value\\"}]'
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': '$$_insights_redacted_based_on_masking_rules_$$'" in output
assert b"'my_password': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert (
b"'my_innocent_var': '$$_insights_redacted_based_on_masking_rules_$$'" in output
b"'my_innocent_var': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
)
assert b"'__should_be_ignored':" not in output
@@ -160,12 +160,12 @@ def test_code_variables_context_override(tmpdir):
dedent(
"""
import os
import hanzo_insights
from hanzo_insights import Insights
import posthog
from posthog import Posthog
insights_client = Insights(
posthog_client = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
@@ -178,10 +178,10 @@ def test_code_variables_context_override(tmpdir):
1/0
with hanzo_insights.new_context(client=insights_client):
hanzo_insights.set_capture_exception_code_variables_context(True)
hanzo_insights.set_code_variables_mask_patterns_context([r"(?i).*bank.*"])
hanzo_insights.set_code_variables_ignore_patterns_context([])
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()
"""
@@ -195,7 +195,7 @@ def test_code_variables_context_override(tmpdir):
assert b"ZeroDivisionError" in output
assert b"code_variables" in output
assert b"'bank': '$$_insights_redacted_based_on_masking_rules_$$'" in output
assert b"'bank': '$$_posthog_redacted_based_on_masking_rules_$$'" in output
assert b"'__dunder_var': 'should_be_visible'" in output
@@ -205,11 +205,11 @@ def test_code_variables_size_limiter(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -299,11 +299,11 @@ def test_code_variables_disabled_capture(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=False,
@@ -340,12 +340,12 @@ def test_code_variables_enabled_then_disabled_in_context(tmpdir):
dedent(
"""
import os
import hanzo_insights
from hanzo_insights import Insights
import posthog
from posthog import Posthog
insights_client = Insights(
posthog_client = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -358,8 +358,8 @@ def test_code_variables_enabled_then_disabled_in_context(tmpdir):
1/0
with hanzo_insights.new_context(client=insights_client):
hanzo_insights.set_capture_exception_code_variables_context(False)
with posthog.new_context(client=posthog_client):
posthog.set_capture_exception_code_variables_context(False)
process_data()
"""
@@ -388,15 +388,15 @@ def test_code_variables_repr_fallback(tmpdir):
from datetime import datetime, timedelta
from decimal import Decimal
from fractions import Fraction
from hanzo_insights import Insights
from posthog import Posthog
class CustomReprClass:
def __repr__(self):
return '<CustomReprClass: custom representation>'
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -458,11 +458,11 @@ def test_code_variables_too_long_string_value_replaced(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -491,9 +491,9 @@ def test_code_variables_too_long_string_value_replaced(tmpdir):
assert "'short_value': 'I am short'" in output
assert "$$_insights_value_too_long_$$" in output
assert "$$_posthog_value_too_long_$$" in output
assert "'long_blob': '$$_insights_value_too_long_$$'" in output
assert "'long_blob': '$$_posthog_value_too_long_$$'" in output
def test_code_variables_too_long_string_in_nested_dict(tmpdir):
@@ -502,11 +502,11 @@ def test_code_variables_too_long_string_in_nested_dict(tmpdir):
dedent(
"""
import os
from hanzo_insights import Insights
from posthog import Posthog
client = Insights(
posthog = Posthog(
'phc_x',
host='https://eu.i.insights.hanzo.ai',
host='https://eu.i.posthog.com',
debug=True,
enable_exception_autocapture=True,
capture_exception_code_variables=True,
@@ -541,13 +541,13 @@ def test_code_variables_too_long_string_in_nested_dict(tmpdir):
assert "short_val" in output
assert "ok" in output
assert "$$_insights_value_too_long_$$" in output
assert "$$_posthog_value_too_long_$$" in output
assert "y" * 1000 not in output
assert "z" * 1000 not in output
def test_mask_sensitive_data_too_long_dict_key():
from hanzo_insights.exception_utils import (
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
@@ -567,11 +567,11 @@ def test_mask_sensitive_data_too_long_dict_key():
assert result["short"] == "visible"
# This then gets shortened by the JSON truncation at 1024 chars anyways so no worries
assert result["k" * 20000] == CODE_VARIABLES_TOO_LONG_VALUE
assert result["password"] == "$$_insights_redacted_based_on_masking_rules_$$"
assert result["password"] == "$$_posthog_redacted_based_on_masking_rules_$$"
def test_mask_sensitive_data_circular_ref():
from hanzo_insights.exception_utils import _compile_patterns, _mask_sensitive_data
from posthog.exception_utils import _compile_patterns, _mask_sensitive_data
compiled_mask = _compile_patterns([r"(?i)password"])
@@ -593,7 +593,7 @@ def test_mask_sensitive_data_circular_ref():
def test_compile_patterns_fast_path_and_regex_fallback():
from hanzo_insights.exception_utils import _compile_patterns, _pattern_matches
from posthog.exception_utils import _compile_patterns, _pattern_matches
# Simple case-insensitive patterns should become substrings
simple_only = _compile_patterns([r"(?i)password", r"(?i)token", r"(?i)jwt"])
@@ -642,7 +642,7 @@ def test_compile_patterns_fast_path_and_regex_fallback():
def test_mask_sensitive_data_large_dict_replaced():
from hanzo_insights.exception_utils import (
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
@@ -658,7 +658,7 @@ def test_mask_sensitive_data_large_dict_replaced():
def test_mask_sensitive_data_large_list_replaced():
from hanzo_insights.exception_utils import (
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
@@ -674,7 +674,7 @@ def test_mask_sensitive_data_large_list_replaced():
def test_mask_sensitive_data_large_tuple_replaced():
from hanzo_insights.exception_utils import (
from posthog.exception_utils import (
CODE_VARIABLES_TOO_LONG_VALUE,
_compile_patterns,
_mask_sensitive_data,
@@ -1,6 +1,6 @@
import unittest
from hanzo_insights.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
class TestFeatureFlag(unittest.TestCase):
@@ -2,9 +2,9 @@ import unittest
import mock
from hanzo_insights.client import Client
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from hanzo_insights.types import (
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import (
FeatureFlag,
FeatureFlagError,
FeatureFlagResult,
@@ -328,7 +328,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_boolean_decide(self, patch_capture, patch_flags):
patch_flags.return_value = {
@@ -375,7 +375,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_variant_decide(self, patch_capture, patch_flags):
patch_flags.return_value = {
@@ -421,7 +421,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
captured_properties = patch_capture.call_args[1]["properties"]
self.assertNotIn("$feature_flag_error", captured_properties)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_unknown_flag(self, patch_capture, patch_flags):
patch_flags.return_value = {
@@ -461,7 +461,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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
@@ -507,7 +507,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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
@@ -549,7 +549,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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
@@ -585,7 +585,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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."""
@@ -608,11 +608,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import RequestsTimeout
from posthog.request import RequestsTimeout
patch_flags.side_effect = RequestsTimeout("Request timed out")
@@ -633,11 +633,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import RequestsConnectionError
from posthog.request import RequestsConnectionError
patch_flags.side_effect = RequestsConnectionError("Connection refused")
@@ -658,11 +658,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import APIError
from posthog.request import APIError
patch_flags.side_effect = APIError(500, "Internal server error")
@@ -683,11 +683,11 @@ class TestGetFeatureFlagResult(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import QuotaLimitError
from posthog.request import QuotaLimitError
patch_flags.side_effect = QuotaLimitError(429, "Rate limit exceeded")
@@ -712,7 +712,7 @@ class TestGetFeatureFlagResult(unittest.TestCase):
class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
"""Tests for stale cache fallback behavior when flag evaluation fails.
When the Insights API is unavailable (timeout, connection error, etc.), the SDK
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)
@@ -741,11 +741,11 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
flag_definition_version=self.client.flag_definition_version,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import RequestsTimeout
from posthog.request import RequestsTimeout
# Pre-populate cache with a flag result
cached_result = FeatureFlagResult.from_value_and_payload(
@@ -779,13 +779,13 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import RequestsConnectionError
from posthog.request import RequestsConnectionError
# Pre-populate cache with a boolean flag result
cached_result = FeatureFlagResult.from_value_and_payload("my-flag", True, None)
@@ -816,11 +816,11 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import APIError
from posthog.request import APIError
# Pre-populate cache
cached_result = FeatureFlagResult.from_value_and_payload(
@@ -852,11 +852,11 @@ class TestFeatureFlagErrorWithStaleCacheFallback(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@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 hanzo_insights.request import RequestsTimeout
from posthog.request import RequestsTimeout
# Do NOT populate cache - no fallback available
@@ -5,14 +5,14 @@ import mock
from dateutil import parser, tz
from freezegun import freeze_time
from hanzo_insights.client import Client
from hanzo_insights.feature_flags import (
from posthog.client import Client
from posthog.feature_flags import (
InconclusiveMatchError,
match_property,
relative_date_parse_for_feature_flag_matching,
)
from hanzo_insights.request import APIError, GetResponse
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from posthog.request import APIError, GetResponse
from posthog.test.test_utils import FAKE_TEST_API_KEY
class TestLocalEvaluation(unittest.TestCase):
@@ -35,7 +35,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_flag_person_properties(self, patch_get):
self.client.feature_flags = [
{
@@ -137,8 +137,8 @@ class TestLocalEvaluation(unittest.TestCase):
)
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_group_properties(self, patch_get, patch_flags):
self.client.feature_flags = [
{
@@ -254,8 +254,8 @@ class TestLocalEvaluation(unittest.TestCase):
group_properties={},
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_with_complex_definition(self, patch_get, patch_flags):
patch_flags.return_value = {
"featureFlags": {"complex-flag": "decide-fallback-value"}
@@ -384,8 +384,8 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_fallback_to_flags(self, patch_get, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "alakazam", "beta-feature2": "alakazam2"}
@@ -450,8 +450,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(feature_flag_match, "alakazam2")
self.assertEqual(patch_flags.call_count, 2)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_dont_fallback_to_flags_when_only_local_evaluation_is_true(
self, patch_get, patch_flags
):
@@ -534,8 +534,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flag_never_returns_undefined_during_regular_evaluation(
self, patch_get, patch_flags
):
@@ -569,8 +569,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertFalse(client.feature_enabled("beta-feature2", "some-distinct-id"))
self.assertEqual(patch_flags.call_count, 2)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flag_return_none_when_decide_errors_out(
self, patch_get, patch_flags
):
@@ -585,7 +585,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertIsNone(client.feature_enabled("beta-feature2", "some-distinct-id"))
self.assertEqual(patch_flags.call_count, 2)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_experience_continuity_flag_not_evaluated_locally(self, patch_flags):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "decide-fallback-value"}
@@ -617,7 +617,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_fallback(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {
@@ -685,7 +685,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_fallback(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"},
@@ -758,7 +758,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_fallback_empty_local_flags(
self, patch_flags, patch_capture
):
@@ -776,7 +776,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_fallback_empty_local_flags(
self, patch_flags, patch_capture
):
@@ -795,7 +795,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_no_fallback(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"beta-feature": "variant-1", "beta-feature2": "variant-2"}
@@ -841,7 +841,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_no_fallback(
self, patch_flags, patch_capture
):
@@ -894,7 +894,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_fallback_but_only_local_evaluation_set(
self, patch_flags, patch_capture
):
@@ -956,7 +956,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_fallback_but_only_local_evaluation_set(
self, patch_flags, patch_capture
):
@@ -1033,7 +1033,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_compute_inactive_flags_locally(self, patch_flags, patch_capture):
client = self.client
client.feature_flags = [
@@ -1115,8 +1115,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_None_values(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
@@ -1190,8 +1190,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(feature_flag_match, True)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_for_cohorts(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
client.feature_flags = [
@@ -1276,8 +1276,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_local_evaluation_for_negated_cohorts(
self, patch_get, patch_flags
):
@@ -1376,9 +1376,9 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
self.assertEqual(patch_get.call_count, 0)
@mock.patch("hanzo_insights.feature_flags.log")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.feature_flags.log")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_flags_with_flag_dependencies(
self, patch_get, patch_flags, mock_log
):
@@ -1439,8 +1439,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 2) # Called twice now
self.assertEqual(patch_get.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_dependencies_simple_chain(self, patch_get, patch_flags):
"""Test basic flag dependency: flag-b depends on flag-a"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -1506,8 +1506,8 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(result, False)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_dependencies_circular_dependency(self, patch_get, patch_flags):
"""Test circular dependency handling: flag-a depends on flag-b, flag-b depends on flag-a"""
# Mock remote flags call to return empty for these flags (fallback returns None)
@@ -1568,8 +1568,8 @@ class TestLocalEvaluation(unittest.TestCase):
result_b = client.get_feature_flag("flag-b", "test-user")
self.assertIsNone(result_b)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_dependencies_missing_flag(self, patch_get, patch_flags):
"""Test handling of missing flag dependency"""
# Mock remote flags call to return empty for this flag (fallback returns None)
@@ -1605,8 +1605,8 @@ class TestLocalEvaluation(unittest.TestCase):
result = client.get_feature_flag("flag-a", "test-user")
self.assertIsNone(result)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_dependencies_complex_chain(self, patch_get, patch_flags):
"""Test complex dependency chain: flag-d -> flag-c -> [flag-a, flag-b]"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -1701,8 +1701,8 @@ class TestLocalEvaluation(unittest.TestCase):
result = client.get_feature_flag("flag-d", "test-user")
self.assertEqual(result, False)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_dependencies_mixed_conditions(self, patch_get, patch_flags):
"""Test flag dependency mixed with other property conditions"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -1776,8 +1776,8 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(result, False)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_flag_dependencies_malformed_chain(self, patch_get, patch_flags):
"""Test handling of malformed dependency chains"""
# Mock remote flags call to return empty for this flag (fallback returns None)
@@ -1829,7 +1829,7 @@ class TestLocalEvaluation(unittest.TestCase):
def test_flag_dependencies_without_context_raises_inconclusive(self):
"""Test that missing flags_by_key raises InconclusiveMatchError"""
from hanzo_insights.feature_flags import (
from posthog.feature_flags import (
evaluate_flag_dependency,
InconclusiveMatchError,
)
@@ -1856,8 +1856,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertIn("Cannot evaluate flag dependency", str(cm.exception))
self.assertIn("some-flag", str(cm.exception))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_multi_level_multivariate_dependency_chain(self, patch_get, patch_flags):
"""Test multi-level multivariate dependency chain: dependent-flag -> intermediate-flag -> leaf-flag"""
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -2093,7 +2093,7 @@ class TestLocalEvaluation(unittest.TestCase):
def test_matches_dependency_value(self):
"""Test the matches_dependency_value function logic"""
from hanzo_insights.feature_flags import matches_dependency_value
from posthog.feature_flags import matches_dependency_value
# String variant matches string exactly (case-sensitive)
self.assertTrue(matches_dependency_value("control", "control"))
@@ -2121,8 +2121,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertFalse(matches_dependency_value(123, "control"))
self.assertFalse(matches_dependency_value("control", True))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_production_style_multivariate_dependency_chain(
self, patch_get, patch_flags
):
@@ -2366,8 +2366,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(unknown_intermediate_result, False) # Dependency not satisfied
self.assertEqual(unknown_root_result, False) # Chain broken
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags(self, patch_get, patch_poll):
patch_get.return_value = GetResponse(
data={
@@ -2403,8 +2403,8 @@ class TestLocalEvaluation(unittest.TestCase):
# Verify ETag is stored
self.assertEqual(client._flags_etag, '"abc123"')
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_sends_etag_on_subsequent_requests(
self, patch_get, patch_poll
):
@@ -2431,8 +2431,8 @@ class TestLocalEvaluation(unittest.TestCase):
second_call_kwargs = patch_get.call_args_list[1][1]
self.assertEqual(second_call_kwargs.get("etag"), '"initial-etag"')
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_304_not_modified(self, patch_get, patch_poll):
"""Test that 304 Not Modified responses skip flag processing"""
# First response with flags
@@ -2468,8 +2468,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(client.feature_flags[0]["key"], "beta-feature")
self.assertEqual(client.group_type_mapping, {"0": "company"})
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_etag_updated_on_new_response(
self, patch_get, patch_poll
):
@@ -2501,8 +2501,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(client._flags_etag, '"etag-v2"')
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_clears_etag_when_server_stops_sending(
self, patch_get, patch_poll
):
@@ -2534,23 +2534,23 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertIsNone(client._flags_etag)
self.assertEqual(client.feature_flags[0]["key"], "flag-v2")
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_wrong_key(self, patch_get, _patch_poll):
patch_get.side_effect = APIError(401, "Unauthorized")
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
with self.assertLogs("hanzo_insights", level="ERROR") as logs:
with self.assertLogs("posthog", level="ERROR") as logs:
client.load_feature_flags()
self.assertEqual(
logs.output[0],
"ERROR:hanzo_insights:[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://insights.hanzo.ai/docs/api/overview",
"ERROR:posthog:[FEATURE FLAGS] Error loading feature flags: To use feature flags, please set a valid personal_api_key. More information: https://posthog.com/docs/api/overview",
)
client.debug = True
self.assertRaises(APIError, client.load_feature_flags)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = [
@@ -2573,8 +2573,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_is_false(self, patch_get, patch_flags):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = [
@@ -2597,8 +2597,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertFalse(client.feature_enabled("beta-feature", "distinct_id"))
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_is_true_when_rollout_is_undefined(
self, patch_get, patch_flags
):
@@ -2623,7 +2623,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_with_project_api_key(self, patch_get):
client = Client(project_api_key=FAKE_TEST_API_KEY, on_error=self.set_fail)
client.feature_flags = [
@@ -2645,7 +2645,7 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_feature_enabled_request_multi_variate(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
@@ -2670,7 +2670,7 @@ class TestLocalEvaluation(unittest.TestCase):
# decide not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_feature_enabled_simple_without_rollout_percentage(self, patch_get):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = [
@@ -2690,7 +2690,7 @@ class TestLocalEvaluation(unittest.TestCase):
]
self.assertTrue(client.feature_enabled("beta-feature", "distinct_id"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_feature_flag(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
@@ -2723,8 +2723,8 @@ class TestLocalEvaluation(unittest.TestCase):
# decide not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.flags")
def test_feature_enabled_doesnt_exist(self, patch_flags, patch_poll):
client = Client(FAKE_TEST_API_KEY)
client.feature_flags = []
@@ -2735,8 +2735,8 @@ class TestLocalEvaluation(unittest.TestCase):
patch_flags.side_effect = APIError(401, "decide error")
self.assertIsNone(client.feature_enabled("doesnt-exist", "distinct_id"))
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.flags")
def test_personal_api_key_doesnt_exist(self, patch_flags, patch_poll):
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
client.feature_flags = []
@@ -2745,8 +2745,8 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(client.feature_enabled("feature-flag", "distinct_id"))
@mock.patch("hanzo_insights.client.Poller")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.Poller")
@mock.patch("posthog.client.get")
def test_load_feature_flags_error(self, patch_get, patch_poll):
def raise_effect():
raise Exception("http exception")
@@ -2757,7 +2757,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertFalse(client.feature_enabled("doesnt-exist", "distinct_id"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_feature_flag_with_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
@@ -2820,7 +2820,7 @@ class TestLocalEvaluation(unittest.TestCase):
# decide not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_flag_with_clashing_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
@@ -2901,7 +2901,7 @@ class TestLocalEvaluation(unittest.TestCase):
# decide not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_flag_with_invalid_variant_overrides(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"beta-feature": "variant-1"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
@@ -2964,7 +2964,7 @@ class TestLocalEvaluation(unittest.TestCase):
# decide not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_conditions_evaluated_in_order(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"order-test": "server-variant"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key="test")
@@ -3022,7 +3022,7 @@ class TestLocalEvaluation(unittest.TestCase):
# server not called because this can be evaluated locally
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_boolean_feature_flag_payloads_local(self, patch_flags):
basic_flag = {
"id": 1,
@@ -3067,7 +3067,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_boolean_feature_flag_payload_decide(self, patch_flags, patch_capture):
patch_flags.return_value = {
"featureFlags": {"person-flag": True},
@@ -3094,7 +3094,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_capture.call_count, 1)
patch_capture.reset_mock()
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_multivariate_feature_flag_payloads(self, patch_flags):
multivariate_flag = {
"id": 1,
@@ -3175,8 +3175,8 @@ class TestLocalEvaluation(unittest.TestCase):
)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.flags")
@mock.patch("posthog.client.get")
def test_fallback_to_api_when_flag_has_static_cohort_in_multi_condition(
self, patch_get, patch_flags
):
@@ -3244,7 +3244,7 @@ class TestLocalEvaluation(unittest.TestCase):
# Verify API was called (fallback occurred)
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_uses_device_id_for_hash(self, patch_flags):
"""
When a flag has bucketing_identifier: "device_id", the device_id should be
@@ -3292,7 +3292,7 @@ class TestLocalEvaluation(unittest.TestCase):
match_feature_flag_properties should preserve backward compatibility when
bucketing_value is omitted, while warning about deprecation.
"""
from hanzo_insights.feature_flags import match_feature_flag_properties
from posthog.feature_flags import match_feature_flag_properties
flag = {
"id": 1,
@@ -3321,7 +3321,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(result)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_same_device_different_users_same_result(
self, patch_flags
):
@@ -3365,7 +3365,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_fallback_when_device_id_missing(self, patch_flags):
"""
When a flag requires device_id for bucketing but none is provided,
@@ -3398,7 +3398,7 @@ class TestLocalEvaluation(unittest.TestCase):
# API should have been called
self.assertEqual(patch_flags.call_count, 1)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_returns_none_when_only_evaluate_locally_and_no_device_id(
self, patch_flags
):
@@ -3434,7 +3434,7 @@ class TestLocalEvaluation(unittest.TestCase):
# API should NOT have been called
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_default_bucketing_identifier_uses_distinct_id(self, patch_flags):
"""
When bucketing_identifier is not set or is 'distinct_id', should use
@@ -3467,7 +3467,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(result1, result2)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_with_multivariate_flag(self, patch_flags):
"""
Multivariate flag variant selection should use device_id when
@@ -3512,13 +3512,13 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_device_id_bucketing_from_context(self, patch_flags):
"""
When device_id is not passed as a parameter but is set in the context,
it should be resolved from context.
"""
from hanzo_insights.contexts import new_context, set_context_device_id
from posthog.contexts import new_context, set_context_device_id
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -3548,7 +3548,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_group_flags_ignore_bucketing_identifier(self, patch_flags):
"""
Group flags should continue to use the group identifier for hashing,
@@ -3586,7 +3586,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_group_flag_dependency_receives_device_id(self, patch_flags):
"""
Group flag dependency evaluation should receive device_id so dependent
@@ -3645,7 +3645,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_group_flag_dependency_ignores_device_id_bucketing_identifier(
self, patch_flags
):
@@ -3706,7 +3706,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertTrue(result)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_device_id_bucketing(self, patch_flags):
"""
get_all_flags_and_payloads should properly handle flags with device_id bucketing.
@@ -3740,7 +3740,7 @@ class TestLocalEvaluation(unittest.TestCase):
self.assertEqual(result["device-flag"], True)
self.assertEqual(patch_flags.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_fallback_when_device_id_missing_for_some_flags(
self, patch_flags
):
@@ -4610,7 +4610,7 @@ class TestRelativeDateParsing(unittest.TestCase):
class TestCaptureCalls(unittest.TestCase):
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_is_called(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -4725,7 +4725,7 @@ class TestCaptureCalls(unittest.TestCase):
)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_is_called_with_flag_details(self, patch_flags, patch_capture):
patch_flags.return_value = {
"flags": {
@@ -4784,7 +4784,7 @@ class TestCaptureCalls(unittest.TestCase):
)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_is_called_with_flag_details_and_payload(
self, patch_flags, patch_capture
):
@@ -4837,7 +4837,7 @@ class TestCaptureCalls(unittest.TestCase):
disable_geoip=None,
)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_is_called_but_does_not_add_all_flags(self, patch_flags):
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
client = Client(FAKE_TEST_API_KEY, personal_api_key=FAKE_TEST_API_KEY)
@@ -4889,7 +4889,7 @@ class TestCaptureCalls(unittest.TestCase):
assert "$active_feature_flags" not in msg["properties"]
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_feature_flag_payload_does_not_send_feature_flag_called_events(
self, patch_flags, patch_capture
):
@@ -4928,7 +4928,7 @@ class TestCaptureCalls(unittest.TestCase):
self.assertIsNotNone(payload)
self.assertEqual(patch_capture.call_count, 0)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_fallback_to_api_in_get_feature_flag_payload_when_flag_has_static_cohort(
self, patch_flags
):
@@ -4983,7 +4983,7 @@ class TestCaptureCalls(unittest.TestCase):
self.assertEqual(patch_flags.call_count, 1)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_disable_geoip_get_flag_capture_call(self, patch_flags, patch_capture):
patch_flags.return_value = {"featureFlags": {"decide-flag": "decide-value"}}
client = Client(
@@ -5027,7 +5027,7 @@ class TestCaptureCalls(unittest.TestCase):
)
@mock.patch.object(Client, "capture")
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_capture_multiple_users_doesnt_out_of_memory(
self, patch_flags, patch_capture
):
@@ -5097,7 +5097,7 @@ class TestConsistency(unittest.TestCase):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_simple_flag_consistency(self, patch_get):
self.client.feature_flags = [
{
@@ -6124,7 +6124,7 @@ class TestConsistency(unittest.TestCase):
else:
self.assertFalse(feature_flag_match)
@mock.patch("hanzo_insights.client.get")
@mock.patch("posthog.client.get")
def test_multivariate_flag_consistency(self, patch_get):
self.client.feature_flags = [
{
@@ -7181,7 +7181,7 @@ class TestConsistency(unittest.TestCase):
else:
self.assertFalse(feature_flag_match)
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_feature_flag_case_sensitive(self, mock_decide):
mock_decide.return_value = {
"featureFlags": {}
@@ -7206,7 +7206,7 @@ class TestConsistency(unittest.TestCase):
self.assertFalse(client.feature_enabled("beta-feature", "user1"))
self.assertFalse(client.feature_enabled("BETA-FEATURE", "user1"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_feature_flag_payload_case_sensitive(self, mock_decide):
mock_decide.return_value = {
"featureFlags": {"Beta-Feature": True},
@@ -7237,7 +7237,7 @@ class TestConsistency(unittest.TestCase):
self.assertIsNone(client.get_feature_flag_payload("beta-feature", "user1"))
self.assertIsNone(client.get_feature_flag_payload("BETA-FEATURE", "user1"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_feature_flag_case_sensitive_consistency(self, mock_decide):
mock_decide.return_value = {
"featureFlags": {"Beta-Feature": True},
@@ -7273,7 +7273,7 @@ class TestConsistency(unittest.TestCase):
for case in test_cases:
self.assertFalse(client.feature_enabled(case, "user1"))
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_with_flag_keys_to_evaluate(self, mock_flags):
"""Test that get_all_flags with flag_keys_to_evaluate only evaluates specified flags"""
mock_flags.return_value = {
@@ -7305,7 +7305,7 @@ class TestConsistency(unittest.TestCase):
# Check the result
self.assertEqual(result, {"flag1": "value1", "flag2": True})
@mock.patch("hanzo_insights.client.flags")
@mock.patch("posthog.client.flags")
def test_get_all_flags_and_payloads_with_flag_keys_to_evaluate(self, mock_flags):
"""Test that get_all_flags_and_payloads with flag_keys_to_evaluate only evaluates specified flags"""
mock_flags.return_value = {
@@ -1,7 +1,7 @@
"""
Tests for FlagDefinitionCacheProvider functionality.
These tests follow the patterns from the TypeScript implementation in insights-js/packages/node.
These tests follow the patterns from the TypeScript implementation in posthog-js/packages/node.
"""
import threading
@@ -9,13 +9,13 @@ import unittest
from typing import Optional
from unittest import mock
from hanzo_insights.client import Client
from hanzo_insights.flag_definition_cache import (
from posthog.client import Client
from posthog.flag_definition_cache import (
FlagDefinitionCacheData,
FlagDefinitionCacheProvider,
)
from hanzo_insights.request import GetResponse
from hanzo_insights.test.test_utils import FAKE_TEST_API_KEY
from posthog.request import GetResponse
from posthog.test.test_utils import FAKE_TEST_API_KEY
class MockCacheProvider:
@@ -63,8 +63,8 @@ class TestFlagDefinitionCacheProvider(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Prevent real HTTP requests
cls.client_post_patcher = mock.patch("hanzo_insights.client.batch_post")
cls.consumer_post_patcher = mock.patch("hanzo_insights.consumer.batch_post")
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()
@@ -102,7 +102,7 @@ class TestFlagDefinitionCacheProvider(unittest.TestCase):
class TestCacheInitialization(TestFlagDefinitionCacheProvider):
"""Tests for cache initialization behavior."""
@mock.patch("hanzo_insights.client.get")
@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
@@ -124,7 +124,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -148,7 +148,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -169,7 +169,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -197,7 +197,7 @@ class TestCacheInitialization(TestFlagDefinitionCacheProvider):
class TestFetchCoordination(TestFlagDefinitionCacheProvider):
"""Tests for fetch coordination between workers."""
@mock.patch("hanzo_insights.client.get")
@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
@@ -218,7 +218,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -232,7 +232,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -251,7 +251,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -293,7 +293,7 @@ class TestFetchCoordination(TestFlagDefinitionCacheProvider):
class TestErrorHandling(TestFlagDefinitionCacheProvider):
"""Tests for error handling in cache provider operations."""
@mock.patch("hanzo_insights.client.get")
@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")
@@ -313,7 +313,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -331,7 +331,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -350,7 +350,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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")
@@ -372,7 +372,7 @@ class TestErrorHandling(TestFlagDefinitionCacheProvider):
class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
"""Tests for shutdown lifecycle."""
@mock.patch("hanzo_insights.client.get")
@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(
@@ -387,7 +387,7 @@ class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
@mock.patch("hanzo_insights.client.get")
@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
@@ -400,7 +400,7 @@ class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
# Shutdown should still be called
self.assertEqual(self.cache_provider.shutdown_call_count, 1)
@mock.patch("hanzo_insights.client.get")
@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(
@@ -423,7 +423,7 @@ class TestShutdownLifecycle(TestFlagDefinitionCacheProvider):
class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
"""Tests for backward compatibility without cache provider."""
@mock.patch("hanzo_insights.client.get")
@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(
@@ -451,7 +451,7 @@ class TestBackwardCompatibility(TestFlagDefinitionCacheProvider):
class TestDataIntegrity(TestFlagDefinitionCacheProvider):
"""Tests for data integrity between cache and client state."""
@mock.patch("hanzo_insights.client.get")
@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
@@ -483,7 +483,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -497,7 +497,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -510,7 +510,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
client.join()
@mock.patch("hanzo_insights.client.get")
@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
@@ -556,7 +556,7 @@ class TestDataIntegrity(TestFlagDefinitionCacheProvider):
class TestConcurrency(TestFlagDefinitionCacheProvider):
"""Tests for thread safety and concurrent access."""
@mock.patch("hanzo_insights.client.get")
@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(
@@ -1,10 +1,10 @@
import unittest
from hanzo_insights import Insights
from posthog import Posthog
class TestModule(unittest.TestCase):
client = None
posthog = None
def _assert_enqueue_result(self, result):
self.assertEqual(type(result[0]), str)
@@ -14,19 +14,19 @@ class TestModule(unittest.TestCase):
def setUp(self):
self.failed = False
self.client = Insights(
self.posthog = Posthog(
"testsecret", host="http://localhost:8000", on_error=self.failed
)
def test_track(self):
res = self.client.capture("python module event", distinct_id="distinct_id")
res = self.posthog.capture("python module event", distinct_id="distinct_id")
self._assert_enqueue_result(res)
self.client.flush()
self.posthog.flush()
def test_alias(self):
res = self.client.alias("previousId", "distinct_id")
res = self.posthog.alias("previousId", "distinct_id")
self._assert_enqueue_result(res)
self.client.flush()
self.posthog.flush()
def test_flush(self):
self.client.flush()
self.posthog.flush()
@@ -6,8 +6,8 @@ import mock
import pytest
import requests
import hanzo_insights.request as request_module
from hanzo_insights.request import (
import posthog.request as request_module
from posthog.request import (
APIError,
DatetimeSerializer,
GetResponse,
@@ -23,7 +23,7 @@ from hanzo_insights.request import (
get,
set_socket_options,
)
from hanzo_insights.test.test_utils import TEST_API_KEY
from posthog.test.test_utils import TEST_API_KEY
@pytest.mark.parametrize(
@@ -128,7 +128,7 @@ class TestRequests(unittest.TestCase):
}
).encode("utf-8")
with mock.patch("hanzo_insights.request._session.post", return_value=mock_response):
with mock.patch("posthog.request._session.post", return_value=mock_response):
with self.assertRaises(QuotaLimitError) as cm:
decide("fake_key", "fake_host")
@@ -146,7 +146,7 @@ class TestRequests(unittest.TestCase):
}
).encode("utf-8")
with mock.patch("hanzo_insights.request._session.post", return_value=mock_response):
with mock.patch("posthog.request._session.post", return_value=mock_response):
response = decide("fake_key", "fake_host")
self.assertEqual(response["featureFlags"], {"flag1": True})
@@ -154,7 +154,7 @@ class TestRequests(unittest.TestCase):
class TestGet(unittest.TestCase):
"""Unit tests for the get() function HTTP-level behavior."""
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -172,7 +172,7 @@ class TestGet(unittest.TestCase):
self.assertEqual(response.etag, '"abc123"')
self.assertFalse(response.not_modified)
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -186,7 +186,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["headers"]["If-None-Match"], '"previous-etag"')
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -199,7 +199,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertNotIn("If-None-Match", call_kwargs["headers"])
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -216,7 +216,7 @@ class TestGet(unittest.TestCase):
self.assertEqual(response.etag, '"unchanged-etag"')
self.assertTrue(response.not_modified)
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -231,7 +231,7 @@ class TestGet(unittest.TestCase):
self.assertTrue(response.not_modified)
self.assertEqual(response.etag, '"original-etag"')
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -246,7 +246,7 @@ class TestGet(unittest.TestCase):
self.assertIsNone(response.etag)
self.assertEqual(response.data, {"flags": []})
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -260,7 +260,7 @@ class TestGet(unittest.TestCase):
self.assertEqual(ctx.exception.status, 401)
self.assertEqual(ctx.exception.message, "Unauthorized")
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -273,7 +273,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-api-key")
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -286,10 +286,10 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertIn("User-Agent", call_kwargs["headers"])
self.assertTrue(
call_kwargs["headers"]["User-Agent"].startswith("hanzo-insights-python/")
call_kwargs["headers"]["User-Agent"].startswith("posthog-python/")
)
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -302,7 +302,7 @@ class TestGet(unittest.TestCase):
call_kwargs = mock_get.call_args[1]
self.assertEqual(call_kwargs["timeout"], 30)
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -315,7 +315,7 @@ class TestGet(unittest.TestCase):
call_args = mock_get.call_args[0]
self.assertEqual(call_args[0], "https://example.com/api/flags")
@mock.patch("hanzo_insights.request._session.get")
@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()
@@ -339,13 +339,13 @@ class TestGet(unittest.TestCase):
("https://us.posthog.com.rg.proxy.com", "https://us.posthog.com.rg.proxy.com"),
("app.posthog.com", "app.posthog.com"),
("eu.posthog.com", "eu.posthog.com"),
("https://app.posthog.com", "https://us.i.insights.hanzo.ai"),
("https://eu.posthog.com", "https://eu.i.insights.hanzo.ai"),
("https://us.posthog.com", "https://us.i.insights.hanzo.ai"),
("https://app.posthog.com/", "https://us.i.insights.hanzo.ai"),
("https://eu.posthog.com/", "https://eu.i.insights.hanzo.ai"),
("https://us.posthog.com/", "https://us.i.insights.hanzo.ai"),
(None, "https://us.i.insights.hanzo.ai"),
("https://app.posthog.com", "https://us.i.posthog.com"),
("https://eu.posthog.com", "https://eu.i.posthog.com"),
("https://us.posthog.com", "https://us.i.posthog.com"),
("https://app.posthog.com/", "https://us.i.posthog.com"),
("https://eu.posthog.com/", "https://eu.i.posthog.com"),
("https://us.posthog.com/", "https://us.i.posthog.com"),
(None, "https://us.i.posthog.com"),
],
)
def test_routing_to_custom_host(host, expected):
@@ -355,7 +355,7 @@ def test_routing_to_custom_host(host, expected):
def test_enable_keep_alive_sets_socket_options():
try:
enable_keep_alive()
from hanzo_insights.request import _session
from posthog.request import _session
adapter = _session.get_adapter("https://example.com")
assert adapter.socket_options == KEEP_ALIVE_SOCKET_OPTIONS
@@ -367,7 +367,7 @@ def test_set_socket_options_clears_with_none():
try:
enable_keep_alive()
set_socket_options(None)
from hanzo_insights.request import _session
from posthog.request import _session
adapter = _session.get_adapter("https://example.com")
assert adapter.socket_options is None
@@ -401,17 +401,17 @@ class TestFlagsSession(unittest.TestCase):
def test_retry_status_forcelist_excludes_rate_limits(self):
"""Verify 429 (rate limit) is NOT retried - need to wait, not hammer."""
from hanzo_insights.request import RETRY_STATUS_FORCELIST
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 hanzo_insights.request import RETRY_STATUS_FORCELIST
from posthog.request import RETRY_STATUS_FORCELIST
self.assertNotIn(402, RETRY_STATUS_FORCELIST)
@mock.patch("hanzo_insights.request._get_flags_session")
@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()
@@ -434,7 +434,7 @@ class TestFlagsSession(unittest.TestCase):
mock_get_flags_session.assert_called_once()
mock_session.post.assert_called_once()
@mock.patch("hanzo_insights.request._get_flags_session")
@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()
@@ -470,7 +470,7 @@ class TestFlagsSessionNetworkRetries(unittest.TestCase):
retries on network-level failures (DNS failures, connection refused,
connection reset, etc.) up to 2 times each.
"""
from hanzo_insights.request import _build_flags_session
from posthog.request import _build_flags_session
session = _build_flags_session()
@@ -491,7 +491,7 @@ class TestFlagsSessionNetworkRetries(unittest.TestCase):
This tests the status_forcelist configuration which specifies
which HTTP status codes should trigger a retry.
"""
from hanzo_insights.request import _build_flags_session, RETRY_STATUS_FORCELIST
from posthog.request import _build_flags_session, RETRY_STATUS_FORCELIST
session = _build_flags_session()
adapter = session.get_adapter("https://test.posthog.com")
@@ -518,7 +518,7 @@ class TestFlagsSessionNetworkRetries(unittest.TestCase):
"""
Verify that retries use exponential backoff to avoid thundering herd.
"""
from hanzo_insights.request import _build_flags_session
from posthog.request import _build_flags_session
session = _build_flags_session()
adapter = session.get_adapter("https://test.posthog.com")
@@ -545,7 +545,7 @@ class TestFlagsSessionRetryIntegration(unittest.TestCase):
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib3.util.retry import Retry
from hanzo_insights.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
from posthog.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
request_count = 0
@@ -631,7 +631,7 @@ class TestFlagsSessionRetryIntegration(unittest.TestCase):
import socket
import time
from urllib3.util.retry import Retry
from hanzo_insights.request import HTTPAdapterWithSocketOptions, RETRY_STATUS_FORCELIST
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)
@@ -2,7 +2,7 @@ import unittest
from parameterized import parameterized
from hanzo_insights import utils
from posthog import utils
class TestSizeLimitedDict(unittest.TestCase):
@@ -2,7 +2,7 @@ import unittest
from parameterized import parameterized
from hanzo_insights.types import (
from posthog.types import (
FeatureFlag,
FlagMetadata,
FlagReason,
@@ -13,8 +13,8 @@ from parameterized import parameterized
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from hanzo_insights import utils
from hanzo_insights.types import FeatureFlagResult
from posthog import utils
from posthog.types import FeatureFlagResult
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
FAKE_TEST_API_KEY = "random_key"
@@ -96,8 +96,8 @@ class TestUtils(unittest.TestCase):
@parameterized.expand(
[
("http://hanzo_insights.io/", "http://hanzo_insights.io"),
("http://hanzo_insights.io", "http://hanzo_insights.io"),
("http://posthog.io/", "http://posthog.io"),
("http://posthog.io", "http://posthog.io"),
("https://example.com/path/", "https://example.com/path"),
("https://example.com/path", "https://example.com/path"),
]
+2 -2
View File
@@ -16,7 +16,7 @@ import distro # For Linux OS detection
import six
from dateutil.tz import tzlocal, tzutc
log = logging.getLogger("hanzo_insights")
log = logging.getLogger("posthog")
def is_naive(dt):
@@ -277,7 +277,7 @@ class FlagCache:
class RedisFlagCache:
def __init__(
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="insights:flags:"
self, redis_client, default_ttl=300, stale_ttl=3600, key_prefix="posthog:flags:"
):
self.redis = redis_client
self.default_ttl = default_ttl
+1
View File
@@ -0,0 +1 @@
VERSION = "7.9.5"
+19 -21
View File
@@ -3,11 +3,11 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "hanzo-insights"
version = "7.9.7"
description = "Integrate Hanzo Insights into any python application."
authors = [{ name = "Hanzo AI", email = "hey@hanzo.ai" }]
maintainers = [{ name = "Hanzo AI", email = "hey@hanzo.ai" }]
name = "posthog"
version = "7.9.5"
description = "Integrate PostHog into any python application."
authors = [{ name = "PostHog", email = "hey@posthog.com" }]
maintainers = [{ name = "PostHog", email = "hey@posthog.com" }]
license = { text = "MIT" }
readme = "README.md"
requires-python = ">=3.10"
@@ -24,7 +24,6 @@ classifiers = [
"Programming Language :: Python :: 3.14",
]
dependencies = [
# All runtime deps are pure-Python — fully compatible with free-threaded 3.13t.
"requests>=2.7,<3.0",
"six>=1.5",
"python-dateutil>=2.2",
@@ -34,8 +33,8 @@ dependencies = [
]
[project.urls]
Homepage = "https://github.com/hanzoai/insights"
Repository = "https://github.com/hanzoai/posthog-python"
Homepage = "https://github.com/posthog/posthog-python"
Repository = "https://github.com/posthog/posthog-python"
[project.optional-dependencies]
langchain = ["langchain>=0.2.0"]
@@ -81,22 +80,21 @@ test = [
[tool.setuptools]
packages = [
"hanzo_insights",
"hanzo_insights.ai",
"hanzo_insights.ai.langchain",
"hanzo_insights.ai.openai",
"hanzo_insights.ai.openai_agents",
"hanzo_insights.ai.anthropic",
"hanzo_insights.ai.gemini",
"hanzo_insights.test",
"hanzo_insights.test.ai",
"hanzo_insights.test.ai.openai_agents",
"hanzo_insights.integrations",
"insights",
"posthog",
"posthog.ai",
"posthog.ai.langchain",
"posthog.ai.openai",
"posthog.ai.openai_agents",
"posthog.ai.anthropic",
"posthog.ai.gemini",
"posthog.test",
"posthog.test.ai",
"posthog.test.ai.openai_agents",
"posthog.integrations",
]
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["hanzo_insights/test"]
testpaths = ["posthog/test"]
norecursedirs = ["integration_tests"]
+11 -11
View File
@@ -1,8 +1,8 @@
"""
Insights Python SDK Test Adapter
PostHog Python SDK Test Adapter
This adapter implements the SDK Test Adapter Interface defined in the Capture API Contract.
It wraps the insights-python SDK and exposes a REST API for the test harness to exercise.
This adapter implements the SDK Test Adapter Interface defined in the PostHog Capture API Contract.
It wraps the posthog-python SDK and exposes a REST API for the test harness to exercise.
"""
import logging
@@ -13,9 +13,9 @@ from typing import Any, Dict, List, Optional
from flask import Flask, jsonify, request
from hanzo_insights import Client
from hanzo_insights.request import batch_post as original_batch_post
from hanzo_insights.version import VERSION
from posthog import Client
from posthog.request import batch_post as original_batch_post
from posthog.version import VERSION
# Configure logging
logging.basicConfig(
@@ -178,14 +178,14 @@ def patched_batch_post(
# Monkey-patch the batch_post function
import hanzo_insights.request # noqa: E402
import posthog.request # noqa: E402
hanzo_insights.request.batch_post = patched_batch_post
posthog.request.batch_post = patched_batch_post
# Also patch in consumer module
import hanzo_insights.consumer # noqa: E402
import posthog.consumer # noqa: E402
hanzo_insights.consumer.batch_post = patched_batch_post
posthog.consumer.batch_post = patched_batch_post
@app.route("/health", methods=["GET"])
@@ -193,7 +193,7 @@ def health():
"""Health check endpoint"""
return jsonify(
{
"sdk_name": "insights-python",
"sdk_name": "posthog-python",
"sdk_version": VERSION,
"adapter_version": "1.0.0",
}
+15 -11
View File
@@ -6,26 +6,30 @@ try:
except ImportError:
from distutils.core import setup
# Don't import module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "hanzo_insights"))
# Don't import analytics-python module here, since deps may not be installed
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
from version import VERSION # noqa: E402
long_description = """
Hanzo Insights is developer-friendly, self-hosted product analytics.
hanzo_insights is the python package.
PostHog is developer-friendly, self-hosted product analytics.
posthog-python is the python package.
This package requires Python 3.10 or higher.
"""
# Minimal setup.py for backward compatibility
# Most configuration is now in pyproject.toml
setup(
name="hanzo_insights",
name="posthog",
version=VERSION,
url="https://github.com/hanzoai/insights",
author="Hanzo AI",
author_email="hey@hanzo.ai",
maintainer="Hanzo AI",
maintainer_email="hey@hanzo.ai",
# Basic fields for backward compatibility
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
license="MIT License",
description="Integrate Hanzo Insights into any python application.",
description="Integrate PostHog into any python application.",
long_description=long_description,
# This will fallback to pyproject.toml for detailed configuration
)
+6 -6
View File
@@ -25,14 +25,14 @@ with open("pyproject.toml", "rb") as f:
config["project"]["name"] = "posthoganalytics"
config["project"]["readme"] = "README_ANALYTICS.md"
# Rename packages from hanzo_insights.* to posthoganalytics.*
# Rename packages from posthog.* to posthoganalytics.*
if "packages" in config["tool"]["setuptools"]:
new_packages = []
for package in config["tool"]["setuptools"]["packages"]:
if package == "hanzo_insights":
if package == "posthog":
new_packages.append("posthoganalytics")
elif package.startswith("hanzo_insights."):
new_packages.append(package.replace("hanzo_insights.", "posthoganalytics.", 1))
elif package.startswith("posthog."):
new_packages.append(package.replace("posthog.", "posthoganalytics.", 1))
else:
new_packages.append(package)
config["tool"]["setuptools"]["packages"] = new_packages
@@ -56,9 +56,9 @@ setup(
# Basic fields for backward compatibility
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@hanzo.ai",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@hanzo.ai",
maintainer_email="hey@posthog.com",
license="MIT License",
description="Integrate PostHog into any python application.",
long_description=long_description,
Generated
+104 -104
View File
@@ -901,110 +901,6 @@ 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 = "hanzo-insights"
version = "7.9.7"
source = { editable = "." }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "six" },
{ name = "typing-extensions" },
]
[package.optional-dependencies]
dev = [
{ name = "django-stubs" },
{ name = "lxml" },
{ name = "mypy" },
{ name = "mypy-baseline" },
{ name = "packaging" },
{ name = "pre-commit" },
{ name = "pydantic" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "tomli" },
{ name = "tomli-w" },
{ name = "twine" },
{ name = "types-mock" },
{ name = "types-python-dateutil" },
{ name = "types-requests" },
{ name = "types-setuptools" },
{ name = "types-six" },
{ name = "wheel" },
]
langchain = [
{ name = "langchain" },
]
test = [
{ name = "anthropic" },
{ name = "coverage" },
{ name = "django" },
{ name = "freezegun" },
{ name = "google-genai" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
{ name = "langchain-core" },
{ name = "langchain-openai" },
{ name = "langgraph" },
{ name = "mock" },
{ name = "openai" },
{ name = "parameterized" },
{ name = "pydantic" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-timeout" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" },
{ 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 = ">=1.0" },
{ name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.4" },
{ name = "langchain-core", marker = "extra == 'test'", specifier = ">=1.0" },
{ name = "langchain-openai", marker = "extra == 'test'", specifier = ">=1.0" },
{ name = "langgraph", marker = "extra == 'test'", specifier = ">=1.0" },
{ 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'", specifier = ">=2.0" },
{ 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 = "httpcore"
version = "1.0.9"
@@ -2231,6 +2127,110 @@ 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"
version = "7.9.5"
source = { editable = "." }
dependencies = [
{ name = "backoff" },
{ name = "distro" },
{ name = "python-dateutil" },
{ name = "requests" },
{ name = "six" },
{ name = "typing-extensions" },
]
[package.optional-dependencies]
dev = [
{ name = "django-stubs" },
{ name = "lxml" },
{ name = "mypy" },
{ name = "mypy-baseline" },
{ name = "packaging" },
{ name = "pre-commit" },
{ name = "pydantic" },
{ name = "ruff" },
{ name = "setuptools" },
{ name = "tomli" },
{ name = "tomli-w" },
{ name = "twine" },
{ name = "types-mock" },
{ name = "types-python-dateutil" },
{ name = "types-requests" },
{ name = "types-setuptools" },
{ name = "types-six" },
{ name = "wheel" },
]
langchain = [
{ name = "langchain" },
]
test = [
{ name = "anthropic" },
{ name = "coverage" },
{ name = "django" },
{ name = "freezegun" },
{ name = "google-genai" },
{ name = "langchain-anthropic" },
{ name = "langchain-community" },
{ name = "langchain-core" },
{ name = "langchain-openai" },
{ name = "langgraph" },
{ name = "mock" },
{ name = "openai" },
{ name = "parameterized" },
{ name = "pydantic" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-timeout" },
]
[package.metadata]
requires-dist = [
{ name = "anthropic", marker = "extra == 'test'", specifier = ">=0.72" },
{ 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 = ">=1.0" },
{ name = "langchain-community", marker = "extra == 'test'", specifier = ">=0.4" },
{ name = "langchain-core", marker = "extra == 'test'", specifier = ">=1.0" },
{ name = "langchain-openai", marker = "extra == 'test'", specifier = ">=1.0" },
{ name = "langgraph", marker = "extra == 'test'", specifier = ">=1.0" },
{ 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'", specifier = ">=2.0" },
{ 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 = "pre-commit"
version = "4.4.0"