Compare commits

...
223 Commits
Author SHA1 Message Date
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
Yakko Majuri 721a6aacf7 fix action 2020-09-11 08:58:14 +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
80 changed files with 17483 additions and 845 deletions
+72
View File
@@ -0,0 +1,72 @@
name: CI
on:
- pull_request
jobs:
code-quality:
name: Code quality checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Set up Python 3.11
uses: actions/setup-python@v2
with:
python-version: 3.11.11
- uses: actions/cache@v3
with:
path: ~/.cache/pip
key: ${{ runner.os }}-pip-${{ hashFiles('setup.py') }}
restore-keys: |
${{ runner.os }}-pip-
- name: Install dev dependencies
run: |
python -m pip install -e .[dev]
if: steps.cache.outputs.cache-hit != 'true'
- name: Check formatting with black
run: |
black --check .
- name: Lint with flake8
run: |
flake8 posthog --ignore E501,W503
- name: Check import order with isort
run: |
isort --check-only .
- name: Check types with mypy
run: |
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
tests:
name: Python ${{ matrix.python-version }} tests
runs-on: ubuntu-latest
strategy:
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v2
with:
fetch-depth: 1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v2
with:
python-version: ${{ matrix.python-version }}
- name: Install requirements.txt dependencies with pip
run: |
python -m pip install -e .[test]
- name: Run posthog tests
run: |
pytest --verbose --timeout=30
+43
View File
@@ -0,0 +1,43 @@
name: "Release"
on:
push:
branches:
- master
paths:
- "posthog/version.py"
workflow_dispatch:
jobs:
release:
name: Publish release
runs-on: ubuntu-latest
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
steps:
- name: Checkout the repository
uses: actions/checkout@v2
with:
fetch-depth: 0
token: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v2
- name: Detect version
run: echo "REPO_VERSION=$(python3 posthog/version.py)" >> $GITHUB_ENV
- name: Prepare for building release
run: pip install -U pip setuptools wheel twine
- name: Push release to PyPI
run: make release && make release_analytics
- name: Create GitHub release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.POSTHOG_BOT_GITHUB_TOKEN }}
with:
tag_name: v${{ env.REPO_VERSION }}
release_name: ${{ env.REPO_VERSION }}
-27
View File
@@ -1,27 +0,0 @@
name: Backend CI
on:
- pull_request
jobs:
tests:
name: Python tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
with:
fetch-depth: 1
- name: Set up Python 3.7
uses: actions/setup-python@v1
with:
python-version: 3.7
- name: Install requirements.txt dependencies with pip
run: |
python -m pip install -e .
- name: Run posthog tests
run: |
python setup.py test
+10 -5
View File
@@ -1,14 +1,19 @@
**sublime**
*.pyc
dist
dist/
*.egg-info
dist
MANIFEST
build
.eggs
build/
.eggs/
.coverage
.vscode/
env
env/
venv/
flake8.out
pylint.out
posthog-analytics
.idea
.python-version
.coverage
pyrightconfig.json
.env
+9
View File
@@ -0,0 +1,9 @@
repos:
- repo: https://github.com/psf/black
rev: stable
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.7.0
hooks:
- id: isort
+416
View File
@@ -0,0 +1,416 @@
## 4.0.1 2025-04-29
1. Remove deprecated `monotonic` library. Use Python's core `time.monotonic` function instead
2. Clarify Python 3.9+ is required
## 4.0.0 - 2025-04-24
1. Added new method `get_feature_flag_result` which returns a `FeatureFlagResult` object. This object breaks down the result of a feature flag into its enabled state, variant, and payload. The benefit of this method is it allows you to retrieve the result of a feature flag and its payload in a single API call. You can call `get_value` on the result to get the value of the feature flag, which is the same value returned by `get_feature_flag` (aka the string `variant` if the flag is a multivariate flag or the `boolean` value if the flag is a boolean flag).
Example:
```python
result = posthog.get_feature_flag_result("my-flag", "distinct_id")
print(result.enabled) # True or False
print(result.variant) # 'the-variant-value' or None
print(result.payload) # {'foo': 'bar'}
print(result.get_value()) # 'the-variant-value' or True or False
print(result.reason) # 'matched condition set 2' (Not available for local evaluation)
```
Breaking change:
1. `get_feature_flag_payload` now deserializes payloads from JSON strings to `Any`. Previously, it returned the payload as a JSON encoded string.
Before:
```python
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"
```
After:
```python
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}
```
## 3.25.0 2025-04-15
1. Roll out new `/flags` endpoint to 100% of `/decide` traffic, excluding the top 10 customers.
## 3.24.3  2025-04-15
1. Fix hash inclusion/exclusion for flag rollout
## 3.24.2  2025-04-15
1. Roll out new /flags endpoint to 10% of /decide traffic
## 3.24.1  2025-04-11
1. Add `log_captured_exceptions` option to proxy setup
## 3.24.0  2025-04-10
1. Add config option to `log_captured_exceptions`
## 3.23.0  2025-03-26
1. Expand automatic retries to include read errors (e.g. RemoteDisconnected)
## 3.22.0  2025-03-26
1. Add more information to `$feature_flag_called` events.
2. Support for the `/decide?v=4` endpoint which contains more information about feature flags.
## 3.21.0  2025-03-17
1. Support serializing dataclasses.
## 3.20.0  2025-03-13
1. Add support for OpenAI Responses API.
## 3.19.2  2025-03-11
1. Fix install requirements for analytics package
## 3.19.1  2025-03-11
1. Fix bug where None is sent as delta in azure
## 3.19.0  2025-03-04
1. Add support for tool calls in OpenAI and Anthropic.
2. Add support for cached tokens.
## 3.18.1  2025-03-03
1. Improve quota-limited feature flag logs
## 3.18.0 - 2025-02-28
1. Add support for Azure OpenAI.
## 3.17.0 - 2025-02-27
1. The LangChain handler now captures tools in `$ai_generation` events, in property `$ai_tools`. This allows for displaying tools provided to the LLM call in PostHog UI. Note that support for `$ai_tools` in OpenAI and Anthropic SDKs is coming soon.
## 3.16.0 - 2025-02-26
1. feat: add some platform info to events (#198)
## 3.15.1 - 2025-02-23
1. Fix async client support for OpenAI.
## 3.15.0 - 2025-02-19
1. Support quota-limited feature flags
## 3.14.2 - 2025-02-19
1. Evaluate feature flag payloads with case sensitivity correctly. Fixes <https://github.com/PostHog/posthog-python/issues/178>
## 3.14.1 - 2025-02-18
1. Add support for Bedrock Anthropic Usage
## 3.13.0 - 2025-02-12
1. Automatically retry connection errors
## 3.12.1 - 2025-02-11
1. Fix mypy support for 3.12.0
2. Deprecate `is_simple_flag`
## 3.12.0 - 2025-02-11
1. Add support for OpenAI beta parse API.
2. Deprecate `context` parameter
## 3.11.1 - 2025-02-06
1. Fix LangChain callback handler to capture parent run ID.
## 3.11.0 - 2025-01-28
1. Add the `$ai_span` event to the LangChain callback handler to capture the input and output of intermediary chains.
> LLM observability naming change: event property `$ai_trace_name` is now `$ai_span_name`.
2. Fix serialiazation of Pydantic models in methods.
## 3.10.0 - 2025-01-24
1. Add `$ai_error` and `$ai_is_error` properties to LangChain callback handler, OpenAI, and Anthropic.
## 3.9.3 - 2025-01-23
1. Fix capturing of multiple traces in the LangChain callback handler.
## 3.9.2 - 2025-01-22
1. Fix importing of LangChain callback handler under certain circumstances.
## 3.9.0 - 2025-01-22
1. Add `$ai_trace` event emission to LangChain callback handler.
## 3.8.4 - 2025-01-17
1. Add Anthropic support for LLM Observability.
2. Update LLM Observability to use output_choices.
## 3.8.3 - 2025-01-14
1. Fix setuptools to include the `posthog.ai.openai` and `posthog.ai.langchain` packages for the `posthoganalytics` package.
## 3.8.2 - 2025-01-14
1. Fix setuptools to include the `posthog.ai.openai` and `posthog.ai.langchain` packages.
## 3.8.1 - 2025-01-14
1. Add LLM Observability with support for OpenAI and Langchain callbacks.
## 3.7.5 - 2025-01-03
1. Add `distinct_id` to group_identify
## 3.7.4 - 2024-11-25
1. Fix bug where this SDK incorrectly sent feature flag events with null values when calling `get_feature_flag_payload`.
## 3.7.3 - 2024-11-25
1. Use personless mode when sending an exception without a provided `distinct_id`.
## 3.7.2 - 2024-11-19
1. Add `type` property to exception stacks.
## 3.7.1 - 2024-10-24
1. Add `platform` property to each frame of exception stacks.
## 3.7.0 - 2024-10-03
1. Adds a new `super_properties` parameter on the client that are appended to every /capture call.
## 3.6.7 - 2024-09-24
1. Remove deprecated datetime.utcnow() in favour of datetime.now(tz=tzutc())
## 3.6.6 - 2024-09-16
1. Fix manual capture support for in app frames
## 3.6.5 - 2024-09-10
1. Fix django integration support for manual exception capture.
## 3.6.4 - 2024-09-05
1. Add manual exception capture.
## 3.6.3 - 2024-09-03
1. Make sure setup.py for posthoganalytics package also discovers the new exception integration package.
## 3.6.2 - 2024-09-03
1. Make sure setup.py discovers the new exception integration package.
## 3.6.1 - 2024-09-03
1. Adds django integration to exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
## 3.6.0 - 2024-08-28
1. Adds exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
## 3.5.2 - 2024-08-21
1. Guard for None values in local evaluation
## 3.5.1 - 2024-08-13
1. Remove "-api" suffix from ingestion hostnames
## 3.5.0 - 2024-02-29
1. - Adds a new `feature_flags_request_timeout_seconds` timeout parameter for feature flags which defaults to 3 seconds, updated from the default 10s for all other API calls.
## 3.4.2 - 2024-02-20
1. Add `historical_migration` option for bulk migration to PostHog Cloud.
## 3.4.1 - 2024-02-09
1. Use new hosts for event capture as well
## 3.4.0 - 2024-02-05
1. Point given hosts to new ingestion hosts
## 3.3.4 - 2024-01-30
1. Update type hints for module variables to work with newer versions of mypy
## 3.3.3 - 2024-01-26
1. Remove new relative date operators, combine into regular date operators
## 3.3.2 - 2024-01-19
1. Return success/failure with all capture calls from module functions
## 3.3.1 - 2024-01-10
1. Make sure we don't override any existing feature flag properties when adding locally evaluated feature flag properties.
## 3.3.0 - 2024-01-09
1. When local evaluation is enabled, we automatically add flag information to all events sent to PostHog, whenever possible. This makes it easier to use these events in experiments.
## 3.2.0 - 2024-01-09
1. Numeric property handling for feature flags now does the expected: When passed in a number, we do a numeric comparison. When passed in a string, we do a string comparison. Previously, we always did a string comparison.
2. Add support for relative date operators for local evaluation.
## 3.1.0 - 2023-12-04
1. Increase maximum event size and batch size
## 3.0.2 - 2023-08-17
1. Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
## 3.0.1 - 2023-04-21
1. Restore how feature flags work when the client library is disabled: All requests return `None` and no events are sent when the client is disabled.
2. Add a `feature_flag_definitions()` debug option, which returns currently loaded feature flag definitions. You can use this to more cleverly decide when to request local evaluation of feature flags.
## 3.0.0 - 2023-04-14
Breaking change:
All events by default now send the `$geoip_disable` property to disable geoip lookup in app. This is because usually we don't
want to update person properties to take the server's location.
The same now happens for feature flag requests, where we discard the IP address of the server for matching on geoip properties like city, country, continent.
To restore previous behaviour, you can set the default to False like so:
```python
posthog.disable_geoip = False
# // and if using client instantiation:
posthog = Posthog('api_key', disable_geoip=False)
```
## 2.5.0 - 2023-04-10
1. Add option for instantiating separate client object
## 2.4.2 - 2023-03-30
1. Update backoff dependency for posthoganalytics package to be the same as posthog package
## 2.4.1 - 2023-03-17
1. Removes accidental print call left in for decide response
## 2.4.0 - 2023-03-14
1. Support evaluating all cohorts in feature flags for local evaluation
## 2.3.1 - 2023-02-07
1. Log instead of raise error on posthog personal api key errors
2. Remove upper bound on backoff dependency
## 2.3.0 - 2023-01-31
1. Add support for returning payloads of matched feature flags
## 2.2.0 - 2022-11-14
Changes:
1. Add support for feature flag variant overrides with local evaluation
## 2.1.2 - 2022-09-15
Changes:
1. Fixes issues with date comparison.
## 2.1.1 - 2022-09-14
Changes:
1. Feature flags local evaluation now supports date property filters as well. Accepts both strings and datetime objects.
## 2.1.0 - 2022-08-11
Changes:
1. Feature flag defaults have been removed
2. Setup logging only when debug mode is enabled.
## 2.0.1 - 2022-08-04
- Make poll_interval configurable
- Add `send_feature_flag_events` parameter to feature flag calls, which determine whether the `$feature_flag_called` event should be sent or not.
- Add `only_evaluate_locally` parameter to feature flag calls, which determines whether the feature flag should only be evaluated locally or not.
## 2.0.0 - 2022-08-02
Breaking changes:
1. The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
2. Feature flag defaults apply only when there's an error fetching feature flag results. Earlier, if the default was set to `True`, even if a flag resolved to `False`, the default would override this.
**Note: These are removed in 2.0.2**
3. Feature flag remote evaluation doesn't require a personal API key.
New Changes:
1. You can now evaluate feature flags locally (i.e. without sending a request to your PostHog servers) by setting a personal API key, and passing in groups and person properties to `is_feature_enabled` and `get_feature_flag` calls.
2. Introduces a `get_all_flags` method that returns all feature flags. This is useful for when you want to seed your frontend with some initial flags, given a user ID.
## 1.4.9 - 2022-06-13
- Support for sending feature flags with capture calls
## 1.4.8 - 2022-05-12
- Support multi variate feature flags
## 1.4.7 - 2022-04-25
- Allow feature flags usage without project_api_key
## 1.4.1 - 2021-05-28
- Fix packaging issues with Sentry integrations
## 1.4.0 - 2021-05-18
- Improve support for `project_api_key` (#32)
- Resolve polling issues with feature flags (#29)
- Add Sentry (and Sentry+Django) integrations (#13)
- Fix feature flag issue with no percentage rollout (#30)
## 1.3.1 - 2021-05-07
- Add `$set` and `$set_once` support (#23)
- Add distinct ID to `$create_alias` event (#27)
- Add `UUID` to `ID_TYPES` (#26)
## 1.2.1 - 2021-02-05
Initial release logged in CHANGELOG.md.
+1
View File
@@ -0,0 +1 @@
@PostHog/team-feature-flags
+27 -1
View File
@@ -1,4 +1,4 @@
Copyright (c) 2020 PostHog (part of Hiberly Inc)
Copyright (c) 2023 PostHog (part of Hiberly Inc)
Copyright (c) 2013 Segment Inc. friends@segment.com
@@ -20,3 +20,29 @@ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
---
Some files in this codebase contain code from getsentry/sentry-javascript by Software, Inc. dba Sentry.
In such cases it is explicitly stated in the file header. This license only applies to the relevant code in such cases.
MIT License
Copyright (c) 2012 Functional Software, Inc. dba Sentry
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+12 -3
View File
@@ -1,7 +1,10 @@
test:
lint:
pylint --rcfile=.pylintrc --reports=y --exit-zero analytics | tee pylint.out
flake8 --max-complexity=10 --statistics analytics > flake8.out || true
coverage run --branch --include=analytics/\* --omit=*/test* setup.py test
test:
coverage run -m pytest
coverage report
release:
rm -rf dist/*
@@ -14,11 +17,13 @@ release_analytics:
rm -rf posthoganalytics
mkdir posthoganalytics
cp -r posthog/* posthoganalytics/
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog /from posthoganalytics /g' {} \;
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthog\./from posthoganalytics\./g' {} \;
rm -rf posthog
python setup_analytics.py sdist bdist_wheel
twine upload dist/*
mkdir posthog
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics /from posthog /g' {} \;
find ./posthoganalytics -type f -exec sed -i '' -e 's/from posthoganalytics\./from posthog\./g' {} \;
cp -r posthoganalytics/* posthog/
rm -rf posthoganalytics
@@ -26,4 +31,8 @@ release_analytics:
e2e_test:
.buildscripts/e2e.sh
.PHONY: test release e2e_test
django_example:
python -m pip install -e ".[sentry]"
cd sentry_django_example && python manage.py runserver 8080
.PHONY: test lint release e2e_test
+45 -2
View File
@@ -1,5 +1,48 @@
# PostHog Python
Please see the main [PostHog docs](https://posthog.com/docs).
[![PyPI](https://img.shields.io/pypi/v/posthog)](https://pypi.org/project/posthog/)
Specifically, the [Python integration](https://posthog.com/docs/integrations/python-integration) details.
Please see the [Python integration docs](https://posthog.com/docs/integrations/python-integration) for details.
## Development
### Testing Locally
1. Run `python3 -m venv env` (creates virtual environment called "env")
* or `uv venv env`
2. Run `source env/bin/activate` (activates the virtual environment)
3. Run `python3 -m pip install -e ".[test]"` (installs the package in develop mode, along with test dependencies)
* or `uv pip install -e ".[test]"`
4. Run `make test`
1. To run a specific test do `pytest -k test_no_api_key`
### Running Locally
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.
### Running the Django Sentry Integration Locally
There's a sample Django project included, called `sentry_django_example`, which explains how to use PostHog with Sentry.
There's 2 places of importance (Changes required are all marked with TODO in the sample project directory)
1. Settings.py
1. Input your Sentry DSN
2. Input your Sentry Org and ProjectID details into `PosthogIntegration()`
3. Add `POSTHOG_DJANGO` to settings.py. This allows the `PosthogDistinctIdMiddleware` to get the distinct_ids
2. urls.py
1. This includes the `sentry-debug/` endpoint, which generates an exception
To run things: `make django_example`. This installs the posthog-python library with the sentry-sdk add-on, and then runs the django app.
Also start the PostHog app locally.
Then navigate to `http://127.0.0.1:8080/sentry-debug/` and you should get an event in both Sentry and PostHog, with links to each other.
### Releasing Versions
Updated are released using GitHub Actions: after bumping `version.py` in `master` and adding to `CHANGELOG.md`, go to [our release workflow's page](https://github.com/PostHog/posthog-python/actions/workflows/release.yaml) and dispatch it manually, using workflow from `master`.
## Questions?
### [Join our Slack community.](https://join.slack.com/t/posthogusers/shared_invite/enQtOTY0MzU5NjAwMDY3LTc2MWQ0OTZlNjhkODk3ZDI3NDVjMDE1YjgxY2I4ZjI4MzJhZmVmNjJkN2NmMGJmMzc2N2U3Yjc3ZjI5NGFlZDQ)
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
#/ Usage: bin/build
#/ Description: Runs linter and mypy
source bin/helpers/_utils.sh
set_source_and_root_dir
flake8 posthog --ignore E501,W503
mypy --no-site-packages --config-file mypy.ini . | mypy-baseline filter
Executable
+14
View File
@@ -0,0 +1,14 @@
#!/usr/bin/env bash
#/ Usage: bin/fmt
#/ Description: Formats and lints the code
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
if [[ "$1" == "--check" ]]; then
black --check .
isort --check-only .
else
black .
isort .
fi
+26
View File
@@ -0,0 +1,26 @@
error() {
echo "$@" >&2
}
fatal() {
error "$@"
exit 1
}
set_source_and_root_dir() {
{ set +x; } 2>/dev/null
source_dir="$( cd -P "$( dirname "$0" )" >/dev/null 2>&1 && pwd )"
root_dir=$(cd "$source_dir" && cd ../ && pwd)
cd "$root_dir"
}
ensure_virtual_env() {
if [ -z "$VIRTUAL_ENV" ]; then
echo "Virtual environment not activated. Activating now..."
if [ ! -f env/bin/activate ]; then
echo "Virtual environment not found. Please run 'python -m venv env' first."
exit 1
fi
source env/bin/activate
fi
}
Executable
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env bash
#/ Usage: bin/setup
#/ Description: Sets up the dependencies needed to develop this project
source bin/helpers/_utils.sh
set_source_and_root_dir
if [ ! -d "env" ]; then
python3 -m venv env
fi
source env/bin/activate
pip install -e ".[dev,test]"
Executable
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
#/ Usage: bin/test
#/ Description: Runs all the unit tests for this project
source bin/helpers/_utils.sh
set_source_and_root_dir
ensure_virtual_env
# Pass through all arguments to pytest
pytest "$@"
+93 -6
View File
@@ -1,20 +1,107 @@
# PostHog Python library example
import argparse
# Import the library
import posthog
# Add argument parsing
parser = argparse.ArgumentParser(description="PostHog Python library example")
parser.add_argument(
"--flag", default="person-on-events-enabled", help="Feature flag key to check (default: person-on-events-enabled)"
)
args = parser.parse_args()
posthog.debug = True
# You can find this key on the /setup page in PostHog
posthog.api_key = '<your key>'
posthog.project_api_key = "phc_gtWmTq3Pgl06u4sZY3TRcoQfp42yfuXHKoe8ZVSR6Kh"
posthog.personal_api_key = "phx_fiRCOQkTA3o2ePSdLrFDAILLHjMu2Mv52vUi8MNruIm"
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
# posthog.host = 'http://127.0.0.1:8000'
posthog.host = "http://localhost:8000"
posthog.poll_interval = 10
print(
posthog.feature_enabled(
args.flag, # Use the flag from command line arguments
"12345",
groups={"organization": str("0182ee91-8ef7-0000-4cb9-fedc5f00926a")},
group_properties={
"organization": {
"id": "0182ee91-8ef7-0000-4cb9-fedc5f00926a",
"created_at": "2022-06-30 11:44:52.984121+00:00",
}
},
only_evaluate_locally=True,
)
)
# Capture an event
posthog.capture('distinct_id', 'event', {'property1': 'value', 'property2': 'value'})
posthog.capture("distinct_id", "event", {"property1": "value", "property2": "value"}, send_feature_flags=True)
print(posthog.feature_enabled("beta-feature", "distinct_id"))
print(posthog.feature_enabled("beta-feature-groups", "distinct_id", groups={"company": "id:5"}))
print(posthog.feature_enabled("beta-feature", "distinct_id"))
# get payload
print(posthog.get_feature_flag_payload("beta-feature", "distinct_id"))
print(posthog.get_all_flags_and_payloads("distinct_id"))
exit()
# # Alias a previous distinct id with a new one
# posthog.alias('distinct_id', 'new_distinct_id')
posthog.alias("distinct_id", "new_distinct_id")
posthog.capture("new_distinct_id", "event2", {"property1": "value", "property2": "value"})
posthog.capture(
"new_distinct_id", "event-with-groups", {"property1": "value", "property2": "value"}, groups={"company": "id:5"}
)
# # Add properties to the person
# posthog.identify('distinct_id', {'email': 'something@something.com'})
posthog.identify("new_distinct_id", {"email": "something@something.com"})
# Add properties to a group
posthog.group_identify("company", "id:5", {"employees": 11})
# properties set only once to the person
posthog.set_once("new_distinct_id", {"self_serve_signup": True})
posthog.set_once(
"new_distinct_id", {"self_serve_signup": False}
) # this will not change the property (because it was already set)
posthog.set("new_distinct_id", {"current_browser": "Chrome"})
posthog.set("new_distinct_id", {"current_browser": "Firefox"})
# #############################################################################
# Make sure you have a personal API key for the examples below
# Local Evaluation
# If flag has City=Sydney, this call doesn't go to `/decide`
print(posthog.feature_enabled("test-flag", "distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}))
print(
posthog.feature_enabled(
"test-flag",
"distinct_id_random_22",
person_properties={"$geoip_city_name": "Sydney"},
only_evaluate_locally=True,
)
)
print(posthog.get_all_flags("distinct_id_random_22"))
print(posthog.get_all_flags("distinct_id_random_22", only_evaluate_locally=True))
print(
posthog.get_all_flags(
"distinct_id_random_22", person_properties={"$geoip_city_name": "Sydney"}, only_evaluate_locally=True
)
)
print(posthog.get_remote_config_payload("encrypted_payload_flag_key"))
posthog.shutdown()
+41
View File
@@ -0,0 +1,41 @@
posthog/utils.py:0: error: Library stubs not installed for "six" [import-untyped]
posthog/utils.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
posthog/utils.py:0: error: Statement is unreachable [unreachable]
posthog/utils.py:0: error: Argument 1 to "join" of "str" has incompatible type "AttributeError"; expected "Iterable[str]" [arg-type]
posthog/request.py:0: error: Library stubs not installed for "requests" [import-untyped]
posthog/request.py:0: note: Hint: "python3 -m pip install types-requests"
posthog/request.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
posthog/request.py:0: error: Incompatible types in assignment (expression has type "bytes", variable has type "str") [assignment]
posthog/consumer.py:0: error: Name "Empty" already defined (possibly by an import) [no-redef]
posthog/consumer.py:0: error: Need type annotation for "items" (hint: "items: list[<type>] = ...") [var-annotated]
posthog/consumer.py:0: error: Unsupported operand types for <= ("int" and "str") [operator]
posthog/consumer.py:0: note: Right operand is of type "int | str"
posthog/consumer.py:0: error: Unsupported operand types for < ("str" and "int") [operator]
posthog/consumer.py:0: note: Left operand is of type "int | str"
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil" [import-untyped]
posthog/feature_flags.py:0: error: Library stubs not installed for "dateutil.relativedelta" [import-untyped]
posthog/feature_flags.py:0: error: Unused "type: ignore" comment [unused-ignore]
posthog/client.py:0: error: Library stubs not installed for "dateutil.tz" [import-untyped]
posthog/client.py:0: note: Hint: "python3 -m pip install types-python-dateutil"
posthog/client.py:0: note: (or run "mypy --install-types" to install all missing stub packages)
posthog/client.py:0: note: See https://mypy.readthedocs.io/en/stable/running_mypy.html#missing-imports
posthog/client.py:0: error: Library stubs not installed for "six" [import-untyped]
posthog/client.py:0: note: Hint: "python3 -m pip install types-six"
posthog/client.py:0: error: Name "queue" already defined (by an import) [no-redef]
posthog/client.py:0: error: Need type annotation for "queue" [var-annotated]
posthog/client.py:0: error: Item "None" of "Any | None" has no attribute "get" [union-attr]
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "capture" [call-arg]
posthog/__init__.py:0: note: "capture" defined here
simulator.py:0: error: Unexpected keyword argument "anonymous_id" for "identify" [call-arg]
posthog/__init__.py:0: note: "identify" defined here
simulator.py:0: error: Unexpected keyword argument "traits" for "identify" [call-arg]
posthog/__init__.py:0: note: "identify" defined here
example.py:0: error: Statement is unreachable [unreachable]
posthog/sentry/posthog_integration.py:0: error: Statement is unreachable [unreachable]
posthog/ai/utils.py:0: error: Need type annotation for "output" (hint: "output: list[<type>] = ...") [var-annotated]
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
posthog/ai/utils.py:0: error: Function "builtins.any" is not valid as a type [valid-type]
posthog/ai/utils.py:0: note: Perhaps you meant "typing.Any" instead of "any"?
sentry_django_example/sentry_django_example/settings.py:0: error: Need type annotation for "ALLOWED_HOSTS" (hint: "ALLOWED_HOSTS: list[<type>] = ...") [var-annotated]
sentry_django_example/sentry_django_example/settings.py:0: error: Incompatible types in assignment (expression has type "str", variable has type "None") [assignment]
+39
View File
@@ -0,0 +1,39 @@
[mypy]
python_version = 3.11
plugins =
pydantic.mypy
strict_optional = True
no_implicit_optional = True
warn_unused_ignores = True
check_untyped_defs = True
warn_unreachable = True
strict_equality = True
ignore_missing_imports = True
exclude = env/.*|venv/.*
[mypy-django.*]
ignore_missing_imports = True
[mypy-sentry_sdk.*]
ignore_missing_imports = True
[mypy-posthog.test.*]
ignore_errors = True
[mypy-posthog.*.test.*]
ignore_errors = True
[mypy-openai.*]
ignore_missing_imports = True
[mypy-langchain.*]
ignore_missing_imports = True
[mypy-langchain_core.*]
ignore_missing_imports = True
[mypy-anthropic.*]
ignore_missing_imports = True
[mypy-httpx.*]
ignore_missing_imports = True
+521 -52
View File
@@ -1,63 +1,109 @@
import datetime # noqa: F401
import warnings
from typing import Callable, Dict, List, Optional, Tuple # noqa: F401
from posthog.version import VERSION
from posthog.client import Client
from typing import Optional, Dict, Callable
from posthog.exception_capture import Integrations # noqa: F401
from posthog.types import FeatureFlag, FlagsAndPayloads
from posthog.version import VERSION
__version__ = VERSION
"""Settings."""
api_key = None # type: str
host = None # type: str
on_error = None # type: Callable
debug = False # type: bool
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
api_key = None # type: Optional[str]
host = None # type: Optional[str]
on_error = None # type: Optional[Callable]
debug = False # type: bool
send = True # type: bool
sync_mode = False # type: bool
disabled = False # type: bool
personal_api_key = None # type: Optional[str]
project_api_key = None # type: Optional[str]
poll_interval = 30 # type: int
disable_geoip = True # type: bool
feature_flags_request_timeout_seconds = 3 # type: int
super_properties = None # type: Optional[Dict]
# Currently alpha, use at your own risk
enable_exception_autocapture = False # type: bool
exception_autocapture_integrations = [] # type: List[Integrations]
log_captured_exceptions = False # type: bool
# Used to determine in app paths for exception autocapture. Defaults to the current working directory
project_root = None # type: Optional[str]
# Used for our AI observability feature to not capture any prompt or output just usage + metadata
privacy_mode = False # type: bool
default_client = None
default_client = None # type: Optional[Client]
def capture(
distinct_id, # type: str,
event, # type: str,
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
# type: (...) -> None
distinct_id, # type: str
event, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
groups=None, # type: Optional[Dict]
send_feature_flags=False,
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
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
- `distinct id` which uniquely identifies your user
- `event name` to make sure
- `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.
Optionally you can submit
- `properties`, which can be a dict with any information you'd like to add
- `groups`, which is a dict of group type -> group key mappings
For example:
```python
posthog.capture('distinct id', 'opened app')
posthog.capture('distinct id', 'movie played', {'movie_id': '123', 'category': 'romcom'})
posthog.capture('distinct id', 'purchase', groups={'company': 'id:5'})
```
"""
_proxy('capture', distinct_id=distinct_id, event=event, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"capture",
distinct_id=distinct_id,
event=event,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
groups=groups,
send_feature_flags=send_feature_flags,
disable_geoip=disable_geoip,
)
def identify(
distinct_id, # type: str,
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
# type: (...) -> None
distinct_id, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
Identify lets you add metadata on your users so you can more easily identify who they are in PostHog, and even do things like segment users by these properties.
An `identify` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
- `properties` with a dict with any key: value pairs
For example:
```python
@@ -67,21 +113,165 @@ def identify(
})
```
"""
_proxy('identify', distinct_id=distinct_id, properties=properties, context=context, timestamp=timestamp, message_id=message_id)
def group(*args, **kwargs):
"""Send a group call."""
_proxy('group', *args, **kwargs)
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"identify",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
def set(
distinct_id, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
Set properties on a user record.
This will overwrite previous people property values, just like `identify`.
A `set` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
For example:
```python
posthog.set('distinct id', {
'current_browser': 'Chrome',
})
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"set",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
def set_once(
distinct_id, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
Set properties on a user record, only if they do not yet exist.
This will not overwrite previous people property values, unlike `identify`.
A `set_once` call requires
- `distinct id` which uniquely identifies your user
- `properties` with a dict with any key: value pairs
For example:
```python
posthog.set_once('distinct id', {
'referred_by': 'friend',
})
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"set_once",
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
def group_identify(
group_type, # type: str
group_key, # type: str
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
Set properties on a group
A `group_identify` call requires
- `group_type` type of your group
- `group_key` unique identifier of the group
- `properties` with a dict with any key: value pairs
For example:
```python
posthog.group_identify('company', 5, {
'employees': 11,
})
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"group_identify",
group_type=group_type,
group_key=group_key,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
def alias(
previous_id, # type: str,
distinct_id, # type: str,
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
message_id=None, # type: Optional[str]
):
# type: (...) -> None
previous_id, # type: str
distinct_id, # type: str
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> Tuple[bool, dict]
"""
To marry up whatever a user does before they sign up or log in with what they do after you need to make an alias call. This will allow you to answer questions like "Which marketing channels leads to users churning after a month?" or "What do users do on our website before signing up?"
@@ -98,44 +288,323 @@ def alias(
posthog.alias('anonymous session id', 'distinct id')
```
"""
_proxy('alias', previous_id=previous_id, distinct_id=distinct_id, context=context, timestamp=timestamp, message_id=message_id)
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"alias",
previous_id=previous_id,
distinct_id=distinct_id,
context=context,
timestamp=timestamp,
uuid=uuid,
disable_geoip=disable_geoip,
)
def capture_exception(
exception=None, # type: Optional[BaseException]
distinct_id=None, # type: Optional[str]
properties=None, # type: Optional[Dict]
context=None, # type: Optional[Dict]
timestamp=None, # type: Optional[datetime.datetime]
uuid=None, # type: Optional[str]
groups=None, # type: Optional[Dict]
**kwargs
):
# type: (...) -> Tuple[bool, dict]
"""
capture_exception allows you to capture exceptions that happen in your code. This is useful for debugging and understanding what errors your users are encountering.
This function never raises an exception, even if it fails to send the event.
A `capture_exception` call does not require any fields, but we recommend sending:
- `distinct id` which uniquely identifies your user for which this exception happens
- `exception` to specify the exception to capture. If not provided, the current exception is captured via `sys.exc_info()`
Optionally you can submit
- `properties`, which can be a dict with any information you'd like to add
- `groups`, which is a dict of group type -> group key mappings
- remaining `kwargs` will be logged if `log_captured_exceptions` is enabled
For example:
```python
try:
1 / 0
except Exception as e:
posthog.capture_exception(e, 'my specific distinct id')
posthog.capture_exception(distinct_id='my specific distinct id')
```
"""
if context is not None:
warnings.warn(
"The 'context' parameter is deprecated and will be removed in a future version.",
DeprecationWarning,
stacklevel=2,
)
return _proxy(
"capture_exception",
exception=exception,
distinct_id=distinct_id,
properties=properties,
context=context,
timestamp=timestamp,
uuid=uuid,
groups=groups,
**kwargs
)
def feature_enabled(
key, # type: str
distinct_id, # type: str
groups={}, # type: dict
person_properties={}, # type: dict
group_properties={}, # type: dict
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
):
# type: (...) -> bool
"""
Use feature flags to enable or disable features for users.
For example:
```python
if posthog.feature_enabled('beta feature', 'distinct id'):
# do something
if posthog.feature_enabled('groups feature', 'distinct id', groups={"organization": "5"}):
# do something
```
You can call `posthog.load_feature_flags()` before to make sure you're not doing unexpected requests.
"""
return _proxy(
"feature_enabled",
key=key,
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
def get_feature_flag(
key, # type: str
distinct_id, # type: str
groups={}, # type: dict
person_properties={}, # type: dict
group_properties={}, # type: dict
only_evaluate_locally=False, # type: bool
send_feature_flag_events=True, # type: bool
disable_geoip=None, # type: Optional[bool]
) -> Optional[FeatureFlag]:
"""
Get feature flag variant for users. Used with experiments.
Example:
```python
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'test-variant':
# do test variant code
if posthog.get_feature_flag('beta-feature', 'distinct_id') == 'control':
# do control code
```
`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:
```python
group_properties={"organization": {"name": "PostHog", "employees": 11}}
```
"""
return _proxy(
"get_feature_flag",
key=key,
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
def get_all_flags(
distinct_id, # type: str
groups={}, # type: dict
person_properties={}, # type: dict
group_properties={}, # type: dict
only_evaluate_locally=False, # type: bool
disable_geoip=None, # type: Optional[bool]
) -> Optional[dict[str, FeatureFlag]]:
"""
Get all flags for a given user.
Example:
```python
flags = posthog.get_all_flags('distinct_id')
```
flags are key-value pairs where the key is the flag key and the value is the flag variant, or True, or False.
"""
return _proxy(
"get_all_flags",
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
def get_feature_flag_payload(
key,
distinct_id,
match_value=None,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
send_feature_flag_events=True,
disable_geoip=None, # type: Optional[bool]
) -> Optional[str]:
return _proxy(
"get_feature_flag_payload",
key=key,
distinct_id=distinct_id,
match_value=match_value,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
send_feature_flag_events=send_feature_flag_events,
disable_geoip=disable_geoip,
)
def get_remote_config_payload(
key, # type: str
):
"""Get the payload for a remote config feature flag.
Args:
key: The key of the feature flag
Returns:
The payload associated with the feature flag. If payload is encrypted, the return value will decrypted
Note:
Requires personal_api_key to be set for authentication
"""
return _proxy(
"get_remote_config_payload",
key=key,
)
def get_all_flags_and_payloads(
distinct_id,
groups={},
person_properties={},
group_properties={},
only_evaluate_locally=False,
disable_geoip=None, # type: Optional[bool]
) -> FlagsAndPayloads:
return _proxy(
"get_all_flags_and_payloads",
distinct_id=distinct_id,
groups=groups,
person_properties=person_properties,
group_properties=group_properties,
only_evaluate_locally=only_evaluate_locally,
disable_geoip=disable_geoip,
)
def feature_flag_definitions():
"""Returns loaded feature flags, if any. Helpful for debugging what flag information you have loaded."""
return _proxy("feature_flag_definitions")
def load_feature_flags():
"""Load feature flag definitions from PostHog."""
return _proxy("load_feature_flags")
def page(*args, **kwargs):
"""Send a page call."""
_proxy('page', *args, **kwargs)
_proxy("page", *args, **kwargs)
def screen(*args, **kwargs):
"""Send a screen call."""
_proxy('screen', *args, **kwargs)
_proxy("screen", *args, **kwargs)
def flush():
"""Tell the client to flush."""
_proxy('flush')
_proxy("flush")
def join():
"""Block program until the client clears the queue"""
_proxy('join')
_proxy("join")
def shutdown():
"""Flush all messages and cleanly shutdown the client"""
_proxy('flush')
_proxy('join')
_proxy("flush")
_proxy("join")
def _proxy(method, *args, **kwargs):
"""Create an analytics client if one doesn't exist and send to it."""
global default_client
if disabled:
return None
if not default_client:
default_client = Client(api_key, host=host, debug=debug,
on_error=on_error, send=send,
sync_mode=sync_mode)
default_client = Client(
api_key,
host=host,
debug=debug,
on_error=on_error,
send=send,
sync_mode=sync_mode,
personal_api_key=personal_api_key,
project_api_key=project_api_key,
poll_interval=poll_interval,
disabled=disabled,
disable_geoip=disable_geoip,
feature_flags_request_timeout_seconds=feature_flags_request_timeout_seconds,
super_properties=super_properties,
# TODO: Currently this monitoring begins only when the Client is initialised (which happens when you do something with the SDK)
# This kind of initialisation is very annoying for exception capture. We need to figure out a way around this,
# or deprecate this proxy option fully (it's already in the process of deprecation, no new clients should be using this method since like 5-6 months)
enable_exception_autocapture=enable_exception_autocapture,
log_captured_exceptions=log_captured_exceptions,
exception_autocapture_integrations=exception_autocapture_integrations,
)
# always set incase user changes it
default_client.disabled = disabled
default_client.debug = debug
fn = getattr(default_client, method)
fn(*args, **kwargs)
return fn(*args, **kwargs)
class Posthog(Client):
pass
View File
+12
View File
@@ -0,0 +1,12 @@
from .anthropic import Anthropic
from .anthropic_async import AsyncAnthropic
from .anthropic_providers import AnthropicBedrock, AnthropicVertex, AsyncAnthropicBedrock, AsyncAnthropicVertex
__all__ = [
"Anthropic",
"AsyncAnthropic",
"AnthropicBedrock",
"AsyncAnthropicBedrock",
"AnthropicVertex",
"AsyncAnthropicVertex",
]
+206
View File
@@ -0,0 +1,206 @@
try:
import anthropic
from anthropic.resources import Messages
except ImportError:
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
import time
import uuid
from typing import Any, Dict, Optional
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, merge_system_prompt, with_privacy_mode
from posthog.client import Client as PostHogClient
class Anthropic(anthropic.Anthropic):
"""
A wrapper around the Anthropic SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
"""
Args:
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.messages = WrappedMessages(self)
class WrappedMessages(Messages):
_client: Anthropic
def create(
self,
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 PostHog.
Args:
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
posthog_distinct_id,
self._client._ph_client,
"anthropic",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
)
def stream(
self,
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
return self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
def _create_streaming(
self,
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()
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
accumulated_content = []
response = super().create(**kwargs)
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
try:
for event in response:
if hasattr(event, "usage") and event.usage:
usage_stats = {
k: getattr(event.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
]
}
if hasattr(event, "content") and event.content:
accumulated_content.append(event.content)
yield event
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
output,
)
return generator()
def _capture_streaming_event(
self,
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: Dict[str, int],
latency: float,
output: str,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "anthropic",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
merge_system_prompt(kwargs, "anthropic"),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
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=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
+206
View File
@@ -0,0 +1,206 @@
try:
import anthropic
from anthropic.resources import AsyncMessages
except ImportError:
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
import time
import uuid
from typing import Any, Dict, Optional
from posthog.ai.utils import call_llm_and_track_usage_async, get_model_params, merge_system_prompt, with_privacy_mode
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 PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
"""
Args:
posthog_client: PostHog client for tracking usage
**kwargs: Additional arguments passed to the Anthropic client
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.messages = AsyncWrappedMessages(self)
class AsyncWrappedMessages(AsyncMessages):
_client: AsyncAnthropic
async def create(
self,
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 PostHog.
Args:
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return await self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return await call_llm_and_track_usage_async(
posthog_distinct_id,
self._client._ph_client,
"anthropic",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
)
async def stream(
self,
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
return await self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
async def _create_streaming(
self,
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()
usage_stats: Dict[str, int] = {"input_tokens": 0, "output_tokens": 0}
accumulated_content = []
response = await super().create(**kwargs)
async def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
try:
async for event in response:
if hasattr(event, "usage") and event.usage:
usage_stats = {
k: getattr(event.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"cache_read_input_tokens",
"cache_creation_input_tokens",
]
}
if hasattr(event, "content") and event.content:
accumulated_content.append(event.content)
yield event
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
await self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
output,
)
return generator()
async def _capture_streaming_event(
self,
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: Dict[str, int],
latency: float,
output: str,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "anthropic",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
merge_system_prompt(kwargs, "anthropic"),
),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_cache_creation_input_tokens": usage_stats.get("cache_creation_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
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=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
@@ -0,0 +1,60 @@
try:
import anthropic
except ImportError:
raise ModuleNotFoundError("Please install the Anthropic SDK to use this feature: 'pip install anthropic'")
from posthog.ai.anthropic.anthropic import WrappedMessages
from posthog.ai.anthropic.anthropic_async import AsyncWrappedMessages
from posthog.client import Client as PostHogClient
class AnthropicBedrock(anthropic.AnthropicBedrock):
"""
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self.messages = WrappedMessages(self)
class AsyncAnthropicBedrock(anthropic.AsyncAnthropicBedrock):
"""
A wrapper around the Anthropic Bedrock SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self.messages = AsyncWrappedMessages(self)
class AnthropicVertex(anthropic.AnthropicVertex):
"""
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self.messages = WrappedMessages(self)
class AsyncAnthropicVertex(anthropic.AsyncAnthropicVertex):
"""
A wrapper around the Anthropic Vertex SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self.messages = AsyncWrappedMessages(self)
+3
View File
@@ -0,0 +1,3 @@
from .callbacks import CallbackHandler
__all__ = ["CallbackHandler"]
+729
View File
@@ -0,0 +1,729 @@
try:
import langchain # noqa: F401
except ImportError:
raise ModuleNotFoundError("Please install LangChain to use this feature: 'pip install langchain'")
import logging
import time
from dataclasses import dataclass
from typing import (
Any,
Dict,
List,
Optional,
Sequence,
Tuple,
Union,
cast,
)
from uuid import UUID
from langchain.callbacks.base import BaseCallbackHandler
from langchain.schema.agent import AgentAction, AgentFinish
from langchain_core.documents import Document
from langchain_core.messages import AIMessage, BaseMessage, FunctionMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.outputs import ChatGeneration, LLMResult
from pydantic import BaseModel
from posthog import default_client
from posthog.ai.utils import get_model_params, with_privacy_mode
from posthog.client import Client
log = logging.getLogger("posthog")
@dataclass
class SpanMetadata:
name: str
"""Name of the run: chain name, model name, etc."""
start_time: float
"""Start time of the run."""
end_time: Optional[float]
"""End time of the run."""
input: Optional[Any]
"""Input of the run: messages, prompt variables, etc."""
@property
def latency(self) -> float:
if not self.end_time:
return 0
return self.end_time - self.start_time
@dataclass
class GenerationMetadata(SpanMetadata):
provider: Optional[str] = None
"""Provider of the run: OpenAI, Anthropic"""
model: Optional[str] = None
"""Model used in the run"""
model_params: Optional[Dict[str, Any]] = None
"""Model parameters of the run: temperature, max_tokens, etc."""
base_url: Optional[str] = None
"""Base URL of the provider's API used in the run."""
tools: Optional[List[Dict[str, Any]]] = None
"""Tools provided to the model."""
RunMetadata = Union[SpanMetadata, GenerationMetadata]
RunMetadataStorage = Dict[UUID, RunMetadata]
class CallbackHandler(BaseCallbackHandler):
"""
The PostHog LLM observability callback handler for LangChain.
"""
_client: Client
"""PostHog client instance."""
_distinct_id: Optional[Union[str, int, float, UUID]]
"""Distinct ID of the user to associate the trace with."""
_trace_id: Optional[Union[str, int, float, UUID]]
"""Global trace ID to be sent with every event. Otherwise, the top-level run ID is used."""
_trace_input: Optional[Any]
"""The input at the start of the trace. Any JSON object."""
_trace_name: Optional[str]
"""Name of the trace, exposed in the UI."""
_properties: Optional[Dict[str, Any]]
"""Global properties to be sent with every event."""
_runs: RunMetadataStorage
"""Mapping of run IDs to run metadata as run metadata is only available on the start of generation."""
_parent_tree: Dict[UUID, UUID]
"""
A dictionary that maps chain run IDs to their parent chain run IDs (parent pointer tree),
so the top level can be found from a bottom-level run ID.
"""
def __init__(
self,
client: Optional[Client] = None,
*,
distinct_id: Optional[Union[str, int, float, UUID]] = None,
trace_id: Optional[Union[str, int, float, UUID]] = None,
properties: Optional[Dict[str, Any]] = None,
privacy_mode: bool = False,
groups: Optional[Dict[str, Any]] = None,
):
"""
Args:
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 PostHog groups to use for the trace.
"""
posthog_client = client or default_client
if posthog_client is None:
raise ValueError("PostHog client is required")
self._client = posthog_client
self._distinct_id = distinct_id
self._trace_id = trace_id
self._properties = properties or {}
self._privacy_mode = privacy_mode
self._groups = groups or {}
self._runs = {}
self._parent_tree = {}
def on_chain_start(
self,
serialized: Dict[str, Any],
inputs: Dict[str, Any],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs,
):
self._log_debug_event("on_chain_start", run_id, parent_run_id, inputs=inputs)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(serialized, inputs, run_id, parent_run_id, **kwargs)
def on_chain_end(
self,
outputs: Dict[str, Any],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
self._log_debug_event("on_chain_end", run_id, parent_run_id, outputs=outputs)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, outputs)
def on_chain_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
self._log_debug_event("on_chain_error", run_id, parent_run_id, error=error)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
def on_chat_model_start(
self,
serialized: Dict[str, Any],
messages: List[List[BaseMessage]],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs,
):
self._log_debug_event("on_chat_model_start", run_id, parent_run_id, messages=messages)
self._set_parent_of_run(run_id, parent_run_id)
input = [_convert_message_to_dict(message) for row in messages for message in row]
self._set_llm_metadata(serialized, run_id, input, **kwargs)
def on_llm_start(
self,
serialized: Dict[str, Any],
prompts: List[str],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
self._log_debug_event("on_llm_start", run_id, parent_run_id, prompts=prompts)
self._set_parent_of_run(run_id, parent_run_id)
self._set_llm_metadata(serialized, run_id, prompts, **kwargs)
def on_llm_new_token(
self,
token: str,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> Any:
"""Run on new LLM token. Only available when streaming is enabled."""
self._log_debug_event("on_llm_new_token", run_id, parent_run_id, token=token)
def on_llm_end(
self,
response: LLMResult,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
"""
The callback works for both streaming and non-streaming runs. For streaming runs, the chain must set `stream_usage=True` in the LLM.
"""
self._log_debug_event("on_llm_end", run_id, parent_run_id, response=response, kwargs=kwargs)
self._pop_run_and_capture_generation(run_id, parent_run_id, response)
def on_llm_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
self._log_debug_event("on_llm_error", run_id, parent_run_id, error=error)
self._pop_run_and_capture_generation(run_id, parent_run_id, error)
def on_tool_start(
self,
serialized: Optional[Dict[str, Any]],
input_str: str,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
self._log_debug_event("on_tool_start", run_id, parent_run_id, input_str=input_str)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(serialized, input_str, run_id, parent_run_id, **kwargs)
def on_tool_end(
self,
output: str,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> Any:
self._log_debug_event("on_tool_end", run_id, parent_run_id, output=output)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, output)
def on_tool_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
tags: Optional[list[str]] = None,
**kwargs: Any,
) -> Any:
self._log_debug_event("on_tool_error", run_id, parent_run_id, error=error)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
def on_retriever_start(
self,
serialized: Optional[Dict[str, Any]],
query: str,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
metadata: Optional[Dict[str, Any]] = None,
**kwargs: Any,
) -> Any:
self._log_debug_event("on_retriever_start", run_id, parent_run_id, query=query)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(serialized, query, run_id, parent_run_id, **kwargs)
def on_retriever_end(
self,
documents: Sequence[Document],
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
):
self._log_debug_event("on_retriever_end", run_id, parent_run_id, documents=documents)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, documents)
def on_retriever_error(
self,
error: BaseException,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
tags: Optional[list[str]] = None,
**kwargs: Any,
) -> Any:
"""Run when Retriever errors."""
self._log_debug_event("on_retriever_error", run_id, parent_run_id, error=error)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, error)
def on_agent_action(
self,
action: AgentAction,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> Any:
"""Run on agent action."""
self._log_debug_event("on_agent_action", run_id, parent_run_id, action=action)
self._set_parent_of_run(run_id, parent_run_id)
self._set_trace_or_span_metadata(None, action, run_id, parent_run_id, **kwargs)
def on_agent_finish(
self,
finish: AgentFinish,
*,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs: Any,
) -> Any:
self._log_debug_event("on_agent_finish", run_id, parent_run_id, finish=finish)
self._pop_run_and_capture_trace_or_span(run_id, parent_run_id, finish)
def _set_parent_of_run(self, run_id: UUID, parent_run_id: Optional[UUID] = None):
"""
Set the parent run ID for a chain run. If there is no parent, the run is the root.
"""
if parent_run_id is not None:
self._parent_tree[run_id] = parent_run_id
def _pop_parent_of_run(self, run_id: UUID):
"""
Remove the parent run ID for a chain run.
"""
try:
self._parent_tree.pop(run_id)
except KeyError:
pass
def _find_root_run(self, run_id: UUID) -> UUID:
"""
Finds the root ID of a chain run.
"""
id: UUID = run_id
while id in self._parent_tree:
id = self._parent_tree[id]
return id
def _set_trace_or_span_metadata(
self,
serialized: Optional[Dict[str, Any]],
input: Any,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs,
):
default_name = "trace" if parent_run_id is None else "span"
run_name = _get_langchain_run_name(serialized, **kwargs) or default_name
self._runs[run_id] = SpanMetadata(name=run_name, input=input, start_time=time.time(), end_time=None)
def _set_llm_metadata(
self,
serialized: Dict[str, Any],
run_id: UUID,
messages: Union[List[Dict[str, Any]], List[str]],
metadata: Optional[Dict[str, Any]] = None,
invocation_params: Optional[Dict[str, Any]] = None,
**kwargs,
):
run_name = _get_langchain_run_name(serialized, **kwargs) or "generation"
generation = GenerationMetadata(name=run_name, input=messages, start_time=time.time(), end_time=None)
if isinstance(invocation_params, dict):
generation.model_params = get_model_params(invocation_params)
if tools := invocation_params.get("tools"):
generation.tools = tools
if isinstance(metadata, dict):
if model := metadata.get("ls_model_name"):
generation.model = model
if provider := metadata.get("ls_provider"):
generation.provider = provider
try:
base_url = serialized["kwargs"]["openai_api_base"]
if base_url is not None:
generation.base_url = base_url
except KeyError:
pass
self._runs[run_id] = generation
def _pop_run_metadata(self, run_id: UUID) -> Optional[RunMetadata]:
end_time = time.time()
try:
run = self._runs.pop(run_id)
except KeyError:
log.warning(f"No run metadata found for run {run_id}")
return None
run.end_time = end_time
return run
def _get_trace_id(self, run_id: UUID):
trace_id = self._trace_id or self._find_root_run(run_id)
if not trace_id:
return run_id
return trace_id
def _get_parent_run_id(self, trace_id: Any, run_id: UUID, parent_run_id: Optional[UUID]):
"""
Replace the parent run ID with the trace ID for second level runs when a custom trace ID is set.
"""
if parent_run_id is not None and parent_run_id not in self._parent_tree:
return trace_id
return parent_run_id
def _pop_run_and_capture_trace_or_span(self, run_id: UUID, parent_run_id: Optional[UUID], outputs: Any):
trace_id = self._get_trace_id(run_id)
self._pop_parent_of_run(run_id)
run = self._pop_run_metadata(run_id)
if not run:
return
if isinstance(run, GenerationMetadata):
log.warning(f"Run {run_id} is a generation, but attempted to be captured as a trace or span.")
return
self._capture_trace_or_span(
trace_id,
run_id,
run,
outputs,
self._get_parent_run_id(trace_id, run_id, parent_run_id),
)
def _capture_trace_or_span(
self,
trace_id: Any,
run_id: UUID,
run: SpanMetadata,
outputs: Any,
parent_run_id: Optional[UUID],
):
event_name = "$ai_trace" if parent_run_id is None else "$ai_span"
event_properties = {
"$ai_trace_id": trace_id,
"$ai_input_state": with_privacy_mode(self._client, self._privacy_mode, run.input),
"$ai_latency": run.latency,
"$ai_span_name": run.name,
"$ai_span_id": run_id,
}
if parent_run_id is not None:
event_properties["$ai_parent_id"] = parent_run_id
if self._properties:
event_properties.update(self._properties)
if isinstance(outputs, BaseException):
event_properties["$ai_error"] = _stringify_exception(outputs)
event_properties["$ai_is_error"] = True
elif outputs is not None:
event_properties["$ai_output_state"] = with_privacy_mode(self._client, self._privacy_mode, outputs)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._client.capture(
distinct_id=self._distinct_id or run_id,
event=event_name,
properties=event_properties,
groups=self._groups,
)
def _pop_run_and_capture_generation(
self,
run_id: UUID,
parent_run_id: Optional[UUID],
response: Union[LLMResult, BaseException],
):
trace_id = self._get_trace_id(run_id)
self._pop_parent_of_run(run_id)
run = self._pop_run_metadata(run_id)
if not run:
return
if not isinstance(run, GenerationMetadata):
log.warning(f"Run {run_id} is not a generation, but attempted to be captured as a generation.")
return
self._capture_generation(
trace_id,
run_id,
run,
response,
self._get_parent_run_id(trace_id, run_id, parent_run_id),
)
def _capture_generation(
self,
trace_id: Any,
run_id: UUID,
run: GenerationMetadata,
output: Union[LLMResult, BaseException],
parent_run_id: Optional[UUID] = None,
):
event_properties = {
"$ai_trace_id": trace_id,
"$ai_span_id": run_id,
"$ai_span_name": run.name,
"$ai_parent_id": parent_run_id,
"$ai_provider": run.provider,
"$ai_model": run.model,
"$ai_model_parameters": run.model_params,
"$ai_input": with_privacy_mode(self._client, self._privacy_mode, run.input),
"$ai_http_status": 200,
"$ai_latency": run.latency,
"$ai_base_url": run.base_url,
}
if run.tools:
event_properties["$ai_tools"] = with_privacy_mode(
self._client,
self._privacy_mode,
run.tools,
)
if isinstance(output, BaseException):
event_properties["$ai_http_status"] = _get_http_status(output)
event_properties["$ai_error"] = _stringify_exception(output)
event_properties["$ai_is_error"] = True
else:
# Add usage
input_tokens, output_tokens = _parse_usage(output)
event_properties["$ai_input_tokens"] = input_tokens
event_properties["$ai_output_tokens"] = output_tokens
# Generation results
generation_result = output.generations[-1]
if isinstance(generation_result[-1], ChatGeneration):
completions = [
_convert_message_to_dict(cast(ChatGeneration, generation).message)
for generation in generation_result
]
else:
completions = [_extract_raw_esponse(generation) for generation in generation_result]
event_properties["$ai_output_choices"] = with_privacy_mode(self._client, self._privacy_mode, completions)
if self._properties:
event_properties.update(self._properties)
if self._distinct_id is None:
event_properties["$process_person_profile"] = False
self._client.capture(
distinct_id=self._distinct_id or trace_id,
event="$ai_generation",
properties=event_properties,
groups=self._groups,
)
def _log_debug_event(
self,
event_name: str,
run_id: UUID,
parent_run_id: Optional[UUID] = None,
**kwargs,
):
log.debug(
f"Event: {event_name}, run_id: {str(run_id)[:5]}, parent_run_id: {str(parent_run_id)[:5]}, kwargs: {kwargs}"
)
def _extract_raw_esponse(last_response):
"""Extract the response from the last response of the LLM call."""
# We return the text of the response if not empty
if last_response.text is not None and last_response.text.strip() != "":
return last_response.text.strip()
elif hasattr(last_response, "message"):
# Additional kwargs contains the response in case of tool usage
return last_response.message.additional_kwargs
else:
# Not tool usage, some LLM responses can be simply empty
return ""
def _convert_message_to_dict(message: BaseMessage) -> Dict[str, Any]:
# assistant message
if isinstance(message, HumanMessage):
message_dict = {"role": "user", "content": message.content}
elif isinstance(message, AIMessage):
message_dict = {"role": "assistant", "content": message.content}
elif isinstance(message, SystemMessage):
message_dict = {"role": "system", "content": message.content}
elif isinstance(message, ToolMessage):
message_dict = {"role": "tool", "content": message.content}
elif isinstance(message, FunctionMessage):
message_dict = {"role": "function", "content": message.content}
else:
message_dict = {"role": message.type, "content": str(message.content)}
if message.additional_kwargs:
message_dict.update(message.additional_kwargs)
return message_dict
def _parse_usage_model(
usage: Union[BaseModel, Dict],
) -> Tuple[Union[int, None], Union[int, None]]:
if isinstance(usage, BaseModel):
usage = usage.__dict__
conversion_list = [
# https://pypi.org/project/langchain-anthropic/ (works also for Bedrock-Anthropic)
("input_tokens", "input"),
("output_tokens", "output"),
# https://cloud.google.com/vertex-ai/generative-ai/docs/multimodal/get-token-count
("prompt_token_count", "input"),
("candidates_token_count", "output"),
# Bedrock: https://docs.aws.amazon.com/bedrock/latest/userguide/monitoring-cw.html#runtime-cloudwatch-metrics
("inputTokenCount", "input"),
("outputTokenCount", "output"),
# Bedrock Anthropic
("prompt_tokens", "input"),
("completion_tokens", "output"),
# langchain-ibm https://pypi.org/project/langchain-ibm/
("input_token_count", "input"),
("generated_token_count", "output"),
]
parsed_usage = {}
for model_key, type_key in conversion_list:
if model_key in usage:
captured_count = usage[model_key]
final_count = (
sum(captured_count) if isinstance(captured_count, list) else captured_count
) # For Bedrock, the token count is a list when streamed
parsed_usage[type_key] = final_count
return parsed_usage.get("input"), parsed_usage.get("output")
def _parse_usage(response: LLMResult):
# langchain-anthropic uses the usage field
llm_usage_keys = ["token_usage", "usage"]
llm_usage: Tuple[Union[int, None], Union[int, None]] = (None, None)
if response.llm_output is not None:
for key in llm_usage_keys:
if response.llm_output.get(key):
llm_usage = _parse_usage_model(response.llm_output[key])
break
if hasattr(response, "generations"):
for generation in response.generations:
if "usage" in generation:
llm_usage = _parse_usage_model(generation["usage"])
break
for generation_chunk in generation:
if generation_chunk.generation_info and ("usage_metadata" in generation_chunk.generation_info):
llm_usage = _parse_usage_model(generation_chunk.generation_info["usage_metadata"])
break
message_chunk = getattr(generation_chunk, "message", {})
response_metadata = getattr(message_chunk, "response_metadata", {})
bedrock_anthropic_usage = (
response_metadata.get("usage", None) # for Bedrock-Anthropic
if isinstance(response_metadata, dict)
else None
)
bedrock_titan_usage = (
response_metadata.get("amazon-bedrock-invocationMetrics", None) # for Bedrock-Titan
if isinstance(response_metadata, dict)
else None
)
ollama_usage = getattr(message_chunk, "usage_metadata", None) # for Ollama
chunk_usage = bedrock_anthropic_usage or bedrock_titan_usage or ollama_usage
if chunk_usage:
llm_usage = _parse_usage_model(chunk_usage)
break
return llm_usage
def _get_http_status(error: BaseException) -> int:
# OpenAI: https://github.com/openai/openai-python/blob/main/src/openai/_exceptions.py
# Anthropic: https://github.com/anthropics/anthropic-sdk-python/blob/main/src/anthropic/_exceptions.py
# Google: https://github.com/googleapis/python-api-core/blob/main/google/api_core/exceptions.py
status_code = getattr(error, "status_code", getattr(error, "code", 0))
return status_code
def _get_langchain_run_name(serialized: Optional[Dict[str, Any]], **kwargs: Any) -> Optional[str]:
"""Retrieve the name of a serialized LangChain runnable.
The prioritization for the determination of the run name is as follows:
- The value assigned to the "name" key in `kwargs`.
- The value assigned to the "name" key in `serialized`.
- The last entry of the value assigned to the "id" key in `serialized`.
- "<unknown>".
Args:
serialized (Optional[Dict[str, Any]]): A dictionary containing the runnable's serialized data.
**kwargs (Any): Additional keyword arguments, potentially including the 'name' override.
Returns:
str: The determined name of the Langchain runnable.
"""
if "name" in kwargs and kwargs["name"] is not None:
return kwargs["name"]
if serialized is None:
return None
try:
return serialized["name"]
except (KeyError, TypeError):
pass
try:
return serialized["id"][-1]
except (KeyError, TypeError):
pass
return None
def _stringify_exception(exception: BaseException) -> str:
description = str(exception)
if description:
return f"{exception.__class__.__name__}: {description}"
return exception.__class__.__name__
+5
View File
@@ -0,0 +1,5 @@
from .openai import OpenAI
from .openai_async import AsyncOpenAI
from .openai_providers import AsyncAzureOpenAI, AzureOpenAI
__all__ = ["OpenAI", "AsyncOpenAI", "AzureOpenAI", "AsyncAzureOpenAI"]
+494
View File
@@ -0,0 +1,494 @@
import time
import uuid
from typing import Any, Dict, List, Optional
try:
import openai
import openai.resources
except ImportError:
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
from posthog.ai.utils import call_llm_and_track_usage, get_model_params, with_privacy_mode
from posthog.client import Client as PostHogClient
class OpenAI(openai.OpenAI):
"""
A wrapper around the OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
"""
Args:
api_key: OpenAI API key.
posthog_client: If provided, events will be captured via this client instead
of the global posthog.
**openai_config: Any additional keyword args to set on openai (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = WrappedChat(self)
self.embeddings = WrappedEmbeddings(self)
self.beta = WrappedBeta(self)
self.responses = WrappedResponses(self)
class WrappedResponses(openai.resources.responses.Responses):
_client: OpenAI
def create(
self,
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
)
def _create_streaming(
self,
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()
usage_stats: Dict[str, int] = {}
final_content = []
response = super().create(**kwargs)
def generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
try:
for chunk in response:
if hasattr(chunk, "type") and chunk.type == "response.completed":
res = chunk.response
if res.output and len(res.output) > 0:
final_content.append(res.output[0])
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
chunk.usage.input_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.input_tokens_details.cached_tokens
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = final_content
self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
output,
)
return generator()
def _capture_streaming_event(
self,
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: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
output,
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
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=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
class WrappedChat(openai.resources.chat.Chat):
_client: OpenAI
@property
def completions(self):
return WrappedCompletions(self._client)
class WrappedCompletions(openai.resources.chat.completions.Completions):
_client: OpenAI
def create(
self,
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return call_llm_and_track_usage(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
)
def _create_streaming(
self,
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()
usage_stats: Dict[str, int] = {}
accumulated_content = []
accumulated_tools = {}
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
response = super().create(**kwargs)
def generator():
nonlocal usage_stats
nonlocal accumulated_content # noqa: F824
nonlocal accumulated_tools # noqa: F824
try:
for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"prompt_tokens",
"completion_tokens",
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
# Process tool calls
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
index = tool_call.index
if index not in accumulated_tools:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
accumulated_tools[index].function.arguments += tool_call.function.arguments
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
tools = list(accumulated_tools.values()) if accumulated_tools else None
self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
output,
tools,
)
return generator()
def _capture_streaming_event(
self,
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: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
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=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
class WrappedEmbeddings(openai.resources.embeddings.Embeddings):
_client: OpenAI
def create(
self,
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 PostHog.
Args:
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.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
The response from OpenAI's embeddings.create call.
"""
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = super().create(**kwargs)
end_time = time.time()
# Extract usage statistics if available
usage_stats = {}
if hasattr(response, "usage") and response.usage:
usage_stats = {
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
"total_tokens": getattr(response.usage, "total_tokens", 0),
}
latency = end_time - start_time
# Build the event properties
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
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=posthog_distinct_id or posthog_trace_id,
event="$ai_embedding",
properties=event_properties,
groups=posthog_groups,
)
return response
class WrappedBeta(openai.resources.beta.Beta):
_client: OpenAI
@property
def chat(self):
return WrappedBetaChat(self._client)
class WrappedBetaChat(openai.resources.beta.chat.Chat):
_client: OpenAI
@property
def completions(self):
return WrappedBetaCompletions(self._client)
class WrappedBetaCompletions(openai.resources.beta.chat.completions.Completions):
_client: OpenAI
def parse(
self,
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(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().parse,
**kwargs,
)
+488
View File
@@ -0,0 +1,488 @@
import time
import uuid
from typing import Any, Dict, List, Optional
try:
import openai
import openai.resources
except ImportError:
raise ModuleNotFoundError("Please install the OpenAI SDK to use this feature: 'pip install openai'")
from posthog.ai.utils import call_llm_and_track_usage_async, get_model_params, with_privacy_mode
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 PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
"""
Args:
api_key: OpenAI API key.
posthog_client: If provided, events will be captured via this client instance.
**openai_config: Additional keyword args (e.g. organization="xxx").
"""
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = WrappedChat(self)
self.embeddings = WrappedEmbeddings(self)
self.beta = WrappedBeta(self)
self.responses = WrappedResponses(self)
class WrappedResponses(openai.resources.responses.Responses):
_client: AsyncOpenAI
async def create(
self,
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 posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if kwargs.get("stream", False):
return await self._create_streaming(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
return await call_llm_and_track_usage_async(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
)
async def _create_streaming(
self,
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()
usage_stats: Dict[str, int] = {}
final_content = []
response = await super().create(**kwargs)
async def async_generator():
nonlocal usage_stats
nonlocal final_content # noqa: F824
try:
async for chunk in response:
if hasattr(chunk, "type") and chunk.type == "response.completed":
res = chunk.response
if res.output and len(res.output) > 0:
final_content.append(res.output[0])
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"input_tokens",
"output_tokens",
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "output_tokens_details") and hasattr(
chunk.usage.output_tokens_details, "reasoning_tokens"
):
usage_stats["reasoning_tokens"] = chunk.usage.output_tokens_details.reasoning_tokens
if hasattr(chunk.usage, "input_tokens_details") and hasattr(
chunk.usage.input_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.input_tokens_details.cached_tokens
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = final_content
await self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
output,
)
return async_generator()
async def _capture_streaming_event(
self,
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: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
output,
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("input_tokens", 0),
"$ai_output_tokens": usage_stats.get("output_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_reasoning_tokens": usage_stats.get("reasoning_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
await self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
class WrappedChat(openai.resources.chat.AsyncChat):
_client: AsyncOpenAI
@property
def completions(self):
return WrappedCompletions(self._client)
class WrappedCompletions(openai.resources.chat.completions.AsyncCompletions):
_client: AsyncOpenAI
async def create(
self,
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 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(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
**kwargs,
)
response = await call_llm_and_track_usage_async(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().create,
**kwargs,
)
return response
async def _create_streaming(
self,
posthog_distinct_id: Optional[str],
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool = False,
posthog_groups: Optional[Dict[str, Any]] = None,
**kwargs: Any,
):
start_time = time.time()
usage_stats: Dict[str, int] = {}
accumulated_content = []
accumulated_tools = {}
if "stream_options" not in kwargs:
kwargs["stream_options"] = {}
kwargs["stream_options"]["include_usage"] = True
response = await super().create(**kwargs)
async def async_generator():
nonlocal usage_stats, accumulated_content, accumulated_tools # noqa: F824
try:
async for chunk in response:
if hasattr(chunk, "usage") and chunk.usage:
usage_stats = {
k: getattr(chunk.usage, k, 0)
for k in [
"prompt_tokens",
"completion_tokens",
"total_tokens",
]
}
# Add support for cached tokens
if hasattr(chunk.usage, "prompt_tokens_details") and hasattr(
chunk.usage.prompt_tokens_details, "cached_tokens"
):
usage_stats["cache_read_input_tokens"] = chunk.usage.prompt_tokens_details.cached_tokens
if hasattr(chunk, "choices") and chunk.choices and len(chunk.choices) > 0:
if chunk.choices[0].delta and chunk.choices[0].delta.content:
content = chunk.choices[0].delta.content
if content:
accumulated_content.append(content)
# Process tool calls
tool_calls = getattr(chunk.choices[0].delta, "tool_calls", None)
if tool_calls:
for tool_call in tool_calls:
index = tool_call.index
if index not in accumulated_tools:
accumulated_tools[index] = tool_call
else:
# Append arguments for existing tool calls
if hasattr(tool_call, "function") and hasattr(tool_call.function, "arguments"):
accumulated_tools[index].function.arguments += tool_call.function.arguments
yield chunk
finally:
end_time = time.time()
latency = end_time - start_time
output = "".join(accumulated_content)
tools = list(accumulated_tools.values()) if accumulated_tools else None
await self._capture_streaming_event(
posthog_distinct_id,
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
kwargs,
usage_stats,
latency,
output,
tools,
)
return async_generator()
async def _capture_streaming_event(
self,
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: Dict[str, int],
latency: float,
output: Any,
tool_calls: Optional[List[Dict[str, Any]]] = None,
):
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("messages")),
"$ai_output_choices": with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
[{"content": output, "role": "assistant"}],
),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_output_tokens": usage_stats.get("completion_tokens", 0),
"$ai_cache_read_input_tokens": usage_stats.get("cache_read_input_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(
self._client._ph_client,
posthog_privacy_mode,
tool_calls,
)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
if hasattr(self._client._ph_client, "capture"):
await self._client._ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
class WrappedEmbeddings(openai.resources.embeddings.AsyncEmbeddings):
_client: AsyncOpenAI
async def create(
self,
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 PostHog.
Args:
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 store input and output in PostHog.
posthog_groups: Optional dictionary of groups to include in the event.
**kwargs: Any additional parameters for the OpenAI Embeddings API.
Returns:
The response from OpenAI's embeddings.create call.
"""
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
start_time = time.time()
response = await super().create(**kwargs)
end_time = time.time()
# Extract usage statistics if available
usage_stats = {}
if hasattr(response, "usage") and response.usage:
usage_stats = {
"prompt_tokens": getattr(response.usage, "prompt_tokens", 0),
"total_tokens": getattr(response.usage, "total_tokens", 0),
}
latency = end_time - start_time
# Build the event properties
event_properties = {
"$ai_provider": "openai",
"$ai_model": kwargs.get("model"),
"$ai_input": with_privacy_mode(self._client._ph_client, posthog_privacy_mode, kwargs.get("input")),
"$ai_http_status": 200,
"$ai_input_tokens": usage_stats.get("prompt_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(self._client.base_url),
**(posthog_properties or {}),
}
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=posthog_distinct_id or posthog_trace_id,
event="$ai_embedding",
properties=event_properties,
groups=posthog_groups,
)
return response
class WrappedBeta(openai.resources.beta.AsyncBeta):
_client: AsyncOpenAI
@property
def chat(self):
return WrappedBetaChat(self._client)
class WrappedBetaChat(openai.resources.beta.chat.AsyncChat):
_client: AsyncOpenAI
@property
def completions(self):
return WrappedBetaCompletions(self._client)
class WrappedBetaCompletions(openai.resources.beta.chat.completions.AsyncCompletions):
_client: AsyncOpenAI
async def parse(
self,
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(
posthog_distinct_id,
self._client._ph_client,
"openai",
posthog_trace_id,
posthog_properties,
posthog_privacy_mode,
posthog_groups,
self._client.base_url,
super().parse,
**kwargs,
)
+41
View File
@@ -0,0 +1,41 @@
try:
import openai
import openai.resources
except ImportError:
raise ModuleNotFoundError("Please install the Open AI SDK to use this feature: 'pip install openai'")
from posthog.ai.openai.openai import WrappedBeta, WrappedChat, WrappedEmbeddings
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.client import Client as PostHogClient
class AzureOpenAI(openai.AzureOpenAI):
"""
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = WrappedChat(self)
self.embeddings = WrappedEmbeddings(self)
self.beta = WrappedBeta(self)
class AsyncAzureOpenAI(openai.AsyncAzureOpenAI):
"""
A wrapper around the Azure OpenAI SDK that automatically sends LLM usage events to PostHog.
"""
_ph_client: PostHogClient
def __init__(self, posthog_client: PostHogClient, **kwargs):
super().__init__(**kwargs)
self._ph_client = posthog_client
self.chat = AsyncWrappedChat(self)
self.embeddings = AsyncWrappedEmbeddings(self)
self.beta = AsyncWrappedBeta(self)
+429
View File
@@ -0,0 +1,429 @@
import time
import uuid
from typing import Any, Callable, Dict, List, Optional
from httpx import URL
from posthog.client import Client as PostHogClient
def get_model_params(kwargs: Dict[str, Any]) -> Dict[str, Any]:
"""
Extracts model parameters from the kwargs dictionary.
"""
model_params = {}
for param in [
"temperature",
"max_tokens", # Deprecated field
"max_completion_tokens",
"top_p",
"frequency_penalty",
"presence_penalty",
"n",
"stop",
"stream", # OpenAI-specific field
"streaming", # Anthropic-specific field
]:
if param in kwargs and kwargs[param] is not None:
model_params[param] = kwargs[param]
return model_params
def get_usage(response, provider: str) -> Dict[str, Any]:
if provider == "anthropic":
return {
"input_tokens": response.usage.input_tokens,
"output_tokens": response.usage.output_tokens,
"cache_read_input_tokens": response.usage.cache_read_input_tokens,
"cache_creation_input_tokens": response.usage.cache_creation_input_tokens,
}
elif provider == "openai":
cached_tokens = 0
input_tokens = 0
output_tokens = 0
reasoning_tokens = 0
# responses api
if hasattr(response.usage, "input_tokens"):
input_tokens = response.usage.input_tokens
if hasattr(response.usage, "output_tokens"):
output_tokens = response.usage.output_tokens
if hasattr(response.usage, "input_tokens_details") and hasattr(
response.usage.input_tokens_details, "cached_tokens"
):
cached_tokens = response.usage.input_tokens_details.cached_tokens
if hasattr(response.usage, "output_tokens_details") and hasattr(
response.usage.output_tokens_details, "reasoning_tokens"
):
reasoning_tokens = response.usage.output_tokens_details.reasoning_tokens
# chat completions
if hasattr(response.usage, "prompt_tokens"):
input_tokens = response.usage.prompt_tokens
if hasattr(response.usage, "completion_tokens"):
output_tokens = response.usage.completion_tokens
if hasattr(response.usage, "prompt_tokens_details") and hasattr(
response.usage.prompt_tokens_details, "cached_tokens"
):
cached_tokens = response.usage.prompt_tokens_details.cached_tokens
return {
"input_tokens": input_tokens,
"output_tokens": output_tokens,
"cache_read_input_tokens": cached_tokens,
"reasoning_tokens": reasoning_tokens,
}
return {
"input_tokens": 0,
"output_tokens": 0,
"cache_read_input_tokens": 0,
"cache_creation_input_tokens": 0,
"reasoning_tokens": 0,
}
def format_response(response, provider: str):
"""
Format a regular (non-streaming) response.
"""
output = []
if response is None:
return output
if provider == "anthropic":
return format_response_anthropic(response)
elif provider == "openai":
return format_response_openai(response)
return output
def format_response_anthropic(response):
output = []
for choice in response.content:
if choice.text:
output.append(
{
"role": "assistant",
"content": choice.text,
}
)
return output
def format_response_openai(response):
output = []
if hasattr(response, "choices"):
for choice in response.choices:
# Handle Chat Completions response format
if hasattr(choice, "message") and choice.message and choice.message.content:
output.append(
{
"content": choice.message.content,
"role": choice.message.role,
}
)
# Handle Responses API format
if hasattr(response, "output"):
for item in response.output:
if item.type == "message":
# Extract text content from the content list
if hasattr(item, "content") and isinstance(item.content, list):
for content_item in item.content:
if (
hasattr(content_item, "type")
and content_item.type == "output_text"
and hasattr(content_item, "text")
):
output.append(
{
"content": content_item.text,
"role": item.role,
}
)
elif hasattr(content_item, "text"):
output.append(
{
"content": content_item.text,
"role": item.role,
}
)
elif (
hasattr(content_item, "type")
and content_item.type == "input_image"
and hasattr(content_item, "image_url")
):
output.append(
{
"content": {
"type": "image",
"image": content_item.image_url,
},
"role": item.role,
}
)
else:
output.append(
{
"content": item.content,
"role": item.role,
}
)
return output
def format_tool_calls(response, provider: str):
if provider == "anthropic":
if hasattr(response, "tools") and response.tools and len(response.tools) > 0:
return response.tools
elif provider == "openai":
# Handle both Chat Completions and Responses API
if hasattr(response, "choices") and response.choices:
# Check for tool_calls in message (Chat Completions format)
if (
hasattr(response.choices[0], "message")
and hasattr(response.choices[0].message, "tool_calls")
and response.choices[0].message.tool_calls
):
return response.choices[0].message.tool_calls
# Check for tool_calls directly in response (Responses API format)
if hasattr(response.choices[0], "tool_calls") and response.choices[0].tool_calls:
return response.choices[0].tool_calls
return None
def merge_system_prompt(kwargs: Dict[str, Any], provider: str):
messages: List[Dict[str, Any]] = []
if provider == "anthropic":
messages = kwargs.get("messages") or []
if kwargs.get("system") is None:
return messages
return [{"role": "system", "content": kwargs.get("system")}] + messages
# For OpenAI, handle both Chat Completions and Responses API
if kwargs.get("messages") is not None:
messages = list(kwargs.get("messages", []))
if kwargs.get("input") is not None:
input_data = kwargs.get("input")
if isinstance(input_data, list):
messages.extend(input_data)
else:
messages.append({"role": "user", "content": input_data})
# Check if system prompt is provided as a separate parameter
if kwargs.get("system") is not None:
has_system = any(msg.get("role") == "system" for msg in messages)
if not has_system:
messages = [{"role": "system", "content": kwargs.get("system")}] + messages
# For Responses API, add instructions to the system prompt if provided
if kwargs.get("instructions") is not None:
# Find the system message if it exists
system_idx = next((i for i, msg in enumerate(messages) if msg.get("role") == "system"), None)
if system_idx is not None:
# Append instructions to existing system message
system_content = messages[system_idx].get("content", "")
messages[system_idx]["content"] = f"{system_content}\n\n{kwargs.get('instructions')}"
else:
# Create a new system message with instructions
messages = [{"role": "system", "content": kwargs.get("instructions")}] + messages
return messages
def call_llm_and_track_usage(
posthog_distinct_id: Optional[str],
ph_client: PostHogClient,
provider: str,
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
base_url: URL,
call_method: Callable[..., Any],
**kwargs: Any,
) -> Any:
"""
Common usage-tracking logic for both sync and async calls.
call_method: the llm call method (e.g. openai.chat.completions.create)
"""
start_time = time.time()
response = None
error = None
http_status = 200
usage: Dict[str, Any] = {}
error_params: Dict[str, any] = {}
try:
response = call_method(**kwargs)
except Exception as exc:
error = exc
http_status = getattr(exc, "status_code", 0) # default to 0 becuase its likely an SDK error
error_params = {
"$ai_is_error": True,
"$ai_error": exc.__str__(),
}
finally:
end_time = time.time()
latency = end_time - start_time
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and hasattr(response, "usage"):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
event_properties = {
"$ai_provider": provider,
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(ph_client, posthog_privacy_mode, messages),
"$ai_output_choices": with_privacy_mode(
ph_client, posthog_privacy_mode, format_response(response, provider)
),
"$ai_http_status": http_status,
"$ai_input_tokens": usage.get("input_tokens", 0),
"$ai_output_tokens": usage.get("output_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(base_url),
**(posthog_properties or {}),
**(error_params or {}),
}
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
if usage.get("reasoning_tokens") is not None and usage.get("reasoning_tokens", 0) > 0:
event_properties["$ai_reasoning_tokens"] = usage.get("reasoning_tokens", 0)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
# Process instructions for Responses API
if provider == "openai" and kwargs.get("instructions") is not None:
event_properties["$ai_instructions"] = with_privacy_mode(
ph_client, posthog_privacy_mode, kwargs.get("instructions")
)
# send the event to posthog
if hasattr(ph_client, "capture") and callable(ph_client.capture):
ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
if error:
raise error
return response
async def call_llm_and_track_usage_async(
posthog_distinct_id: Optional[str],
ph_client: PostHogClient,
provider: str,
posthog_trace_id: Optional[str],
posthog_properties: Optional[Dict[str, Any]],
posthog_privacy_mode: bool,
posthog_groups: Optional[Dict[str, Any]],
base_url: URL,
call_async_method: Callable[..., Any],
**kwargs: Any,
) -> Any:
start_time = time.time()
response = None
error = None
http_status = 200
usage: Dict[str, Any] = {}
error_params: Dict[str, any] = {}
try:
response = await call_async_method(**kwargs)
except Exception as exc:
error = exc
http_status = getattr(exc, "status_code", 0) # default to 0 because its likely an SDK error
error_params = {
"$ai_is_error": True,
"$ai_error": exc.__str__(),
}
finally:
end_time = time.time()
latency = end_time - start_time
if posthog_trace_id is None:
posthog_trace_id = str(uuid.uuid4())
if response and hasattr(response, "usage"):
usage = get_usage(response, provider)
messages = merge_system_prompt(kwargs, provider)
event_properties = {
"$ai_provider": provider,
"$ai_model": kwargs.get("model"),
"$ai_model_parameters": get_model_params(kwargs),
"$ai_input": with_privacy_mode(ph_client, posthog_privacy_mode, messages),
"$ai_output_choices": with_privacy_mode(
ph_client, posthog_privacy_mode, format_response(response, provider)
),
"$ai_http_status": http_status,
"$ai_input_tokens": usage.get("input_tokens", 0),
"$ai_output_tokens": usage.get("output_tokens", 0),
"$ai_latency": latency,
"$ai_trace_id": posthog_trace_id,
"$ai_base_url": str(base_url),
**(posthog_properties or {}),
**(error_params or {}),
}
tool_calls = format_tool_calls(response, provider)
if tool_calls:
event_properties["$ai_tools"] = with_privacy_mode(ph_client, posthog_privacy_mode, tool_calls)
if usage.get("cache_read_input_tokens") is not None and usage.get("cache_read_input_tokens", 0) > 0:
event_properties["$ai_cache_read_input_tokens"] = usage.get("cache_read_input_tokens", 0)
if usage.get("cache_creation_input_tokens") is not None and usage.get("cache_creation_input_tokens", 0) > 0:
event_properties["$ai_cache_creation_input_tokens"] = usage.get("cache_creation_input_tokens", 0)
if posthog_distinct_id is None:
event_properties["$process_person_profile"] = False
# Process instructions for Responses API
if provider == "openai" and kwargs.get("instructions") is not None:
event_properties["$ai_instructions"] = with_privacy_mode(
ph_client, posthog_privacy_mode, kwargs.get("instructions")
)
# send the event to posthog
if hasattr(ph_client, "capture") and callable(ph_client.capture):
ph_client.capture(
distinct_id=posthog_distinct_id or posthog_trace_id,
event="$ai_generation",
properties=event_properties,
groups=posthog_groups,
)
if error:
raise error
return response
def with_privacy_mode(ph_client: PostHogClient, privacy_mode: bool, value: Any):
if ph_client.privacy_mode or privacy_mode:
return None
return value
+1211 -96
View File
File diff suppressed because it is too large Load Diff
+48 -34
View File
@@ -1,30 +1,43 @@
import logging
from threading import Thread
import monotonic
import backoff
import json
import logging
import time
from threading import Thread
from posthog.request import post, APIError, DatetimeSerializer
import backoff
from posthog.request import APIError, DatetimeSerializer, batch_post
try:
from queue import Empty
except ImportError:
from Queue import Empty
MAX_MSG_SIZE = 32 << 10
# Our servers only accept batches less than 500KB. Here limit is set slightly
# lower to leave space for extra data that will be added later, eg. "sentAt".
BATCH_SIZE_LIMIT = 475000
MAX_MSG_SIZE = 900 * 1024 # 900KiB per event
# The maximum request body size is currently 20MiB, let's be conservative
# in case we want to lower it in the future.
BATCH_SIZE_LIMIT = 5 * 1024 * 1024
class Consumer(Thread):
"""Consumes the messages from the client's queue."""
log = logging.getLogger('posthog')
def __init__(self, queue, api_key, flush_at=100, host=None,
on_error=None, flush_interval=0.5, gzip=False, retries=10,
timeout=15):
log = logging.getLogger("posthog")
def __init__(
self,
queue,
api_key,
flush_at=100,
host=None,
on_error=None,
flush_interval=0.5,
gzip=False,
retries=10,
timeout=15,
historical_migration=False,
):
"""Create a consumer thread."""
Thread.__init__(self)
# Make consumer a daemon thread so that it doesn't block program exit
@@ -43,14 +56,15 @@ class Consumer(Thread):
self.running = True
self.retries = retries
self.timeout = timeout
self.historical_migration = historical_migration
def run(self):
"""Runs the consumer."""
self.log.debug('consumer is running...')
self.log.debug("consumer is running...")
while self.running:
self.upload()
self.log.debug('consumer exited.')
self.log.debug("consumer exited.")
def pause(self):
"""Pause the consumer."""
@@ -67,7 +81,7 @@ class Consumer(Thread):
self.request(batch)
success = True
except Exception as e:
self.log.error('error uploading: %s', e)
self.log.error("error uploading: %s", e)
success = False
if self.on_error:
self.on_error(e, batch)
@@ -82,27 +96,23 @@ class Consumer(Thread):
queue = self.queue
items = []
start_time = monotonic.monotonic()
start_time = time.monotonic()
total_size = 0
while len(items) < self.flush_at:
elapsed = monotonic.monotonic() - start_time
elapsed = time.monotonic() - start_time
if elapsed >= self.flush_interval:
break
try:
item = queue.get(
block=True, timeout=self.flush_interval - elapsed)
item_size = len(json.dumps(
item, cls=DatetimeSerializer).encode())
item = queue.get(block=True, timeout=self.flush_interval - elapsed)
item_size = len(json.dumps(item, cls=DatetimeSerializer).encode())
if item_size > MAX_MSG_SIZE:
self.log.error(
'Item exceeds 32kb limit, dropping. (%s)', str(item))
self.log.error("Item exceeds 900kib limit, dropping. (%s)", str(item))
continue
items.append(item)
total_size += item_size
if total_size >= BATCH_SIZE_LIMIT:
self.log.debug(
'hit batch size limit (size: %d)', total_size)
self.log.debug("hit batch size limit (size: %d)", total_size)
break
except Empty:
break
@@ -110,25 +120,29 @@ class Consumer(Thread):
return items
def request(self, batch):
"""Attempt to upload the batch and retry before raising an error """
"""Attempt to upload the batch and retry before raising an error"""
def fatal_exception(exc):
if isinstance(exc, APIError):
# retry on server errors and client errors
# with 429 status code (rate limited),
# don't retry on other client errors
if exc.status == "N/A":
return False
return (400 <= exc.status < 500) and exc.status != 429
else:
# retry on all other errors (eg. network)
return False
@backoff.on_exception(
backoff.expo,
Exception,
max_tries=self.retries + 1,
giveup=fatal_exception)
@backoff.on_exception(backoff.expo, Exception, max_tries=self.retries + 1, giveup=fatal_exception)
def send_request():
post(self.api_key, self.host, gzip=self.gzip,
timeout=self.timeout, batch=batch)
batch_post(
self.api_key,
self.host,
gzip=self.gzip,
timeout=self.timeout,
batch=batch,
historical_migration=self.historical_migration,
)
send_request()
+69
View File
@@ -0,0 +1,69 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# 💖open source (under MIT License)
import logging
import sys
import threading
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
if TYPE_CHECKING:
from posthog.client import Client
class Integrations(str, Enum):
Django = "django"
class ExceptionCapture:
# TODO: Add client side rate limiting to prevent spamming the server with exceptions
log = logging.getLogger("posthog")
def __init__(self, client: "Client", integrations: Optional[List[Integrations]] = None):
self.client = client
self.original_excepthook = sys.excepthook
sys.excepthook = self.exception_handler
threading.excepthook = self.thread_exception_handler
self.enabled_integrations = []
for integration in integrations or []:
# TODO: Maybe find a better way of enabling integrations
# This is very annoying currently if we had to add any configuration per integration
if integration == Integrations.Django:
try:
from posthog.exception_integrations.django import DjangoIntegration
enabled_integration = DjangoIntegration(self.exception_receiver)
self.enabled_integrations.append(enabled_integration)
except Exception as e:
self.log.exception(f"Failed to enable Django integration: {e}")
def close(self):
sys.excepthook = self.original_excepthook
for integration in self.enabled_integrations:
integration.uninstall()
def exception_handler(self, exc_type, exc_value, exc_traceback):
# don't affect default behaviour.
self.capture_exception((exc_type, exc_value, exc_traceback))
self.original_excepthook(exc_type, exc_value, exc_traceback)
def thread_exception_handler(self, args):
self.capture_exception((args.exc_type, args.exc_value, args.exc_traceback))
def exception_receiver(self, exc_info, extra_properties):
if "distinct_id" in extra_properties:
metadata = {"distinct_id": extra_properties["distinct_id"]}
else:
metadata = None
self.capture_exception((exc_info[0], exc_info[1], exc_info[2]), metadata)
def capture_exception(self, exception, metadata=None):
try:
distinct_id = metadata.get("distinct_id") if metadata else None
self.client.capture_exception(exception, distinct_id)
except Exception as e:
self.log.exception(f"Failed to capture exception: {e}")
@@ -0,0 +1,5 @@
class IntegrationEnablingError(Exception):
"""
The integration could not be enabled due to a user error like
`django` not being installed for the `DjangoIntegration`.
"""
+93
View File
@@ -0,0 +1,93 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# 💖open source (under MIT License)
import re
import sys
from typing import TYPE_CHECKING
from posthog.exception_integrations import IntegrationEnablingError
try:
from django import VERSION as DJANGO_VERSION
from django.core import signals
except ImportError:
raise IntegrationEnablingError("Django not installed")
if TYPE_CHECKING:
from typing import Any, Dict # noqa: F401
from django.core.handlers.wsgi import WSGIRequest # noqa: F401
class DjangoIntegration:
# TODO: Abstract integrations one we have more and can see patterns
"""
Autocapture errors from a Django application.
"""
identifier = "django"
def __init__(self, capture_exception_fn=None):
if DJANGO_VERSION < (4, 2):
raise IntegrationEnablingError("Django 4.2 or newer is required.")
# TODO: Right now this seems too complicated / overkill for us, but seems like we can automatically plug in middlewares
# which is great for users (they don't need to do this) and everything should just work.
# We should consider this in the future, but for now we can just use the middleware and signals handlers.
# See: https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/integrations/django/__init__.py
self.capture_exception_fn = capture_exception_fn
def _got_request_exception(request=None, **kwargs):
# type: (WSGIRequest, **Any) -> None
extra_props = {}
if request is not None:
# get headers metadata
extra_props = DjangoRequestExtractor(request).extract_person_data()
self.capture_exception_fn(sys.exc_info(), extra_props)
signals.got_request_exception.connect(_got_request_exception)
def uninstall(self):
pass
class DjangoRequestExtractor:
def __init__(self, request):
# type: (Any) -> None
self.request = request
def extract_person_data(self):
headers = self.headers()
# Extract traceparent and tracestate headers
traceparent = headers.get("traceparent")
tracestate = headers.get("tracestate")
# Extract the distinct_id from tracestate
distinct_id = None
if tracestate:
# TODO: Align on the format of the distinct_id in tracestate
# We can't have comma or equals in header values here, so maybe we should base64 encode it?
match = re.search(r"posthog-distinct-id=([^,]+)", tracestate)
if match:
distinct_id = match.group(1)
return {
"distinct_id": distinct_id,
"ip": headers.get("X-Forwarded-For"),
"user_agent": headers.get("User-Agent"),
"traceparent": traceparent,
}
def headers(self):
# type: () -> Dict[str, str]
return dict(self.request.headers)
+876
View File
@@ -0,0 +1,876 @@
# Portions of this file are derived from getsentry/sentry-javascript by Software, Inc. dba Sentry
# Licensed under the MIT License
# copied and adapted from https://github.com/getsentry/sentry-python/blob/269d96d6e9821122fbff280e6a26956e5ed03c0b/sentry_sdk/utils.py#L689
# 💖open source (under MIT License)
# We want to keep payloads as similar to Sentry as possible for easy interoperability
import linecache
import os
import re
import sys
from datetime import datetime
from typing import TYPE_CHECKING
try:
# Python 3.11
from builtins import BaseExceptionGroup
except ImportError:
# Python 3.10 and below
BaseExceptionGroup = None # type: ignore
DEFAULT_MAX_VALUE_LENGTH = 1024
if TYPE_CHECKING:
from types import FrameType, TracebackType
from typing import ( # noqa: F401
Any,
Callable,
Dict,
Iterator,
List,
Literal,
Optional,
Set,
Tuple,
Type,
TypedDict,
TypeVar,
Union,
cast,
)
ExcInfo = Union[
Tuple[Type[BaseException], BaseException, Optional[TracebackType]],
Tuple[None, None, None],
]
LogLevelStr = Literal["fatal", "critical", "error", "warning", "info", "debug"]
Event = TypedDict(
"Event",
{
"breadcrumbs": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
"check_in_id": str,
"contexts": Dict[str, Dict[str, object]],
"dist": str,
"duration": Optional[float],
"environment": str,
"errors": List[Dict[str, Any]], # TODO: We can expand on this type
"event_id": str,
"exception": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
# "extra": MutableMapping[str, object],
# "fingerprint": List[str],
"level": LogLevelStr,
# "logentry": Mapping[str, object],
"logger": str,
# "measurements": Dict[str, MeasurementValue],
"message": str,
"modules": Dict[str, str],
# "monitor_config": Mapping[str, object],
"monitor_slug": Optional[str],
"platform": Literal["python"],
"profile": object, # Should be sentry_sdk.profiler.Profile, but we can't import that here due to circular imports
"release": str,
"request": Dict[str, object],
# "sdk": Mapping[str, object],
"server_name": str,
"spans": List[Dict[str, object]],
"stacktrace": Dict[str, object], # We access this key in the code, but I am unsure whether we ever set it
"start_timestamp": datetime,
"status": Optional[str],
# "tags": MutableMapping[
# str, str
# ], # Tags must be less than 200 characters each
"threads": Dict[Literal["values"], List[Dict[str, Any]]], # TODO: We can expand on this type
"timestamp": Optional[datetime], # Must be set before sending the event
"transaction": str,
# "transaction_info": Mapping[str, Any], # TODO: We can expand on this type
"type": Literal["check_in", "transaction"],
"user": Dict[str, object],
"_metrics_summary": Dict[str, object],
},
total=False,
)
epoch = datetime(1970, 1, 1)
BASE64_ALPHABET = re.compile(r"^[a-zA-Z0-9/+=]*$")
SENSITIVE_DATA_SUBSTITUTE = "[Filtered]"
def to_timestamp(value):
# type: (datetime) -> float
return (value - epoch).total_seconds()
def format_timestamp(value):
# type: (datetime) -> str
return value.strftime("%Y-%m-%dT%H:%M:%S.%fZ")
def event_hint_with_exc_info(exc_info=None):
# type: (Optional[ExcInfo]) -> Dict[str, Optional[ExcInfo]]
"""Creates a hint with the exc info filled in."""
if exc_info is None:
exc_info = sys.exc_info()
else:
exc_info = exc_info_from_error(exc_info)
if exc_info[0] is None:
exc_info = None
return {"exc_info": exc_info}
class AnnotatedValue:
"""
Meta information for a data field in the event payload.
This is to tell Relay that we have tampered with the fields value.
See:
https://github.com/getsentry/relay/blob/be12cd49a0f06ea932ed9b9f93a655de5d6ad6d1/relay-general/src/types/meta.rs#L407-L423
"""
__slots__ = ("value", "metadata")
def __init__(self, value, metadata):
# type: (Optional[Any], Dict[str, Any]) -> None
self.value = value
self.metadata = metadata
def __eq__(self, other):
# type: (Any) -> bool
if not isinstance(other, AnnotatedValue):
return False
return self.value == other.value and self.metadata == other.metadata
@classmethod
def removed_because_raw_data(cls):
# type: () -> AnnotatedValue
"""The value was removed because it could not be parsed. This is done for request body values that are not json nor a form."""
return AnnotatedValue(
value="",
metadata={
"rem": [ # Remark
[
"!raw", # Unparsable raw data
"x", # The fields original value was removed
]
]
},
)
@classmethod
def removed_because_over_size_limit(cls):
# type: () -> AnnotatedValue
"""The actual value was removed because the size of the field exceeded the configured maximum size (specified with the max_request_body_size sdk option)"""
return AnnotatedValue(
value="",
metadata={
"rem": [ # Remark
[
"!config", # Because of configured maximum size
"x", # The fields original value was removed
]
]
},
)
@classmethod
def substituted_because_contains_sensitive_data(cls):
# type: () -> AnnotatedValue
"""The actual value was removed because it contained sensitive information."""
return AnnotatedValue(
value=SENSITIVE_DATA_SUBSTITUTE,
metadata={
"rem": [ # Remark
[
"!config", # Because of SDK configuration (in this case the config is the hard coded removal of certain django cookies)
"s", # The fields original value was substituted
]
]
},
)
if TYPE_CHECKING:
T = TypeVar("T")
Annotated = Union[AnnotatedValue, T]
def get_type_name(cls):
# type: (Optional[type]) -> Optional[str]
return getattr(cls, "__qualname__", None) or getattr(cls, "__name__", None)
def get_type_module(cls):
# type: (Optional[type]) -> Optional[str]
mod = getattr(cls, "__module__", None)
if mod not in (None, "builtins", "__builtins__"):
return mod
return None
def should_hide_frame(frame: "FrameType") -> bool:
try:
mod = frame.f_globals["__name__"]
if mod.startswith("sentry_sdk."):
return True
except (AttributeError, KeyError):
pass
for flag_name in "__traceback_hide__", "__tracebackhide__":
try:
if frame.f_locals[flag_name]:
return True
except Exception:
pass
return False
def iter_stacks(tb):
# type: (Optional[TracebackType]) -> Iterator[TracebackType]
tb_ = tb # type: Optional[TracebackType]
while tb_ is not None:
if not should_hide_frame(tb_.tb_frame):
yield tb_
tb_ = tb_.tb_next
def get_lines_from_file(
filename, # type: str
lineno, # type: int
max_length=None, # type: Optional[int]
loader=None, # type: Optional[Any]
module=None, # type: Optional[str]
):
# type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]
context_lines = 5
source = None
if loader is not None and hasattr(loader, "get_source"):
try:
source_str = loader.get_source(module) # type: Optional[str]
except (ImportError, IOError):
source_str = None
if source_str is not None:
source = source_str.splitlines()
if source is None:
try:
source = linecache.getlines(filename)
except (OSError, IOError):
return [], None, []
if not source:
return [], None, []
lower_bound = max(0, lineno - context_lines)
upper_bound = min(lineno + 1 + context_lines, len(source))
try:
pre_context = [strip_string(line.strip("\r\n"), max_length=max_length) for line in source[lower_bound:lineno]]
context_line = strip_string(source[lineno].strip("\r\n"), max_length=max_length)
post_context = [
strip_string(line.strip("\r\n"), max_length=max_length)
for line in source[(lineno + 1) : upper_bound] # noqa: E203
]
return pre_context, context_line, post_context
except IndexError:
# the file may have changed since it was loaded into memory
return [], None, []
def get_source_context(
frame, # type: FrameType
tb_lineno, # type: int
max_value_length=None, # type: Optional[int]
):
# type: (...) -> Tuple[List[Annotated[str]], Optional[Annotated[str]], List[Annotated[str]]]
try:
abs_path = frame.f_code.co_filename # type: Optional[str]
except Exception:
abs_path = None
try:
module = frame.f_globals["__name__"]
except Exception:
return [], None, []
try:
loader = frame.f_globals["__loader__"]
except Exception:
loader = None
lineno = tb_lineno - 1
if lineno is not None and abs_path:
return get_lines_from_file(abs_path, lineno, max_value_length, loader=loader, module=module)
return [], None, []
def safe_str(value):
# type: (Any) -> str
try:
return str(value)
except Exception:
return safe_repr(value)
def safe_repr(value):
# type: (Any) -> str
try:
return repr(value)
except Exception:
return "<broken repr>"
def filename_for_module(module, abs_path):
# type: (Optional[str], Optional[str]) -> Optional[str]
if not abs_path or not module:
return abs_path
try:
if abs_path.endswith(".pyc"):
abs_path = abs_path[:-1]
base_module = module.split(".", 1)[0]
if base_module == module:
return os.path.basename(abs_path)
base_module_path = sys.modules[base_module].__file__
if not base_module_path:
return abs_path
return abs_path.split(base_module_path.rsplit(os.sep, 2)[0], 1)[-1].lstrip(os.sep)
except Exception:
return abs_path
def serialize_frame(
frame,
tb_lineno=None,
include_local_variables=True,
include_source_context=True,
max_value_length=None,
custom_repr=None,
):
# type: (FrameType, Optional[int], bool, bool, Optional[int], Optional[Callable[..., Optional[str]]]) -> Dict[str, Any]
f_code = getattr(frame, "f_code", None)
if not f_code:
abs_path = None
function = None
else:
abs_path = frame.f_code.co_filename
function = frame.f_code.co_name
try:
module = frame.f_globals["__name__"]
except Exception:
module = None
if tb_lineno is None:
tb_lineno = frame.f_lineno
rv = {
"platform": "python",
"filename": filename_for_module(module, abs_path) or None,
"abs_path": os.path.abspath(abs_path) if abs_path else None,
"function": function or "<unknown>",
"module": module,
"lineno": tb_lineno,
} # type: Dict[str, Any]
if include_source_context:
rv["pre_context"], rv["context_line"], rv["post_context"] = get_source_context(
frame, tb_lineno, max_value_length
)
if include_local_variables:
# TODO(nk): Sort out this current invalid import
# from sentry_sdk.serializer import serialize
# rv["vars"] = serialize(
# dict(frame.f_locals), is_vars=True, custom_repr=custom_repr
# )
pass
return rv
def current_stacktrace(
include_local_variables=True, # type: bool
include_source_context=True, # type: bool
max_value_length=None, # type: Optional[int]
):
# type: (...) -> Dict[str, Any]
__tracebackhide__ = True
frames = []
f = sys._getframe() # type: Optional[FrameType]
while f is not None:
if not should_hide_frame(f):
frames.append(
serialize_frame(
f,
include_local_variables=include_local_variables,
include_source_context=include_source_context,
max_value_length=max_value_length,
)
)
f = f.f_back
frames.reverse()
return {"frames": frames, "type": "raw"}
def get_errno(exc_value):
# type: (BaseException) -> Optional[Any]
return getattr(exc_value, "errno", None)
def get_error_message(exc_value):
# type: (Optional[BaseException]) -> str
return getattr(exc_value, "message", "") or getattr(exc_value, "detail", "") or safe_str(exc_value)
def single_exception_from_error_tuple(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=None, # type: Optional[int]
parent_id=None, # type: Optional[int]
source=None, # type: Optional[str]
):
# type: (...) -> Dict[str, Any]
"""
Creates a dict that goes into the events `exception.values` list and is ingestible by Sentry.
See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
"""
exception_value = {} # type: Dict[str, Any]
exception_value["mechanism"] = mechanism.copy() if mechanism else {"type": "generic", "handled": True}
if exception_id is not None:
exception_value["mechanism"]["exception_id"] = exception_id
if exc_value is not None:
errno = get_errno(exc_value)
else:
errno = None
if errno is not None:
exception_value["mechanism"].setdefault("meta", {}).setdefault("errno", {}).setdefault("number", errno)
if source is not None:
exception_value["mechanism"]["source"] = source
is_root_exception = exception_id == 0
if not is_root_exception and parent_id is not None:
exception_value["mechanism"]["parent_id"] = parent_id
exception_value["mechanism"]["type"] = "chained"
if is_root_exception and "type" not in exception_value["mechanism"]:
exception_value["mechanism"]["type"] = "generic"
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
if is_exception_group:
exception_value["mechanism"]["is_exception_group"] = True
exception_value["module"] = get_type_module(exc_type)
exception_value["type"] = get_type_name(exc_type)
exception_value["value"] = get_error_message(exc_value)
if client_options is None:
include_local_variables = True
include_source_context = True
max_value_length = DEFAULT_MAX_VALUE_LENGTH # fallback
custom_repr = None
else:
include_local_variables = client_options["include_local_variables"]
include_source_context = client_options["include_source_context"]
max_value_length = client_options["max_value_length"]
custom_repr = client_options.get("custom_repr")
frames = [
serialize_frame(
tb.tb_frame,
tb_lineno=tb.tb_lineno,
include_local_variables=include_local_variables,
include_source_context=include_source_context,
max_value_length=max_value_length,
custom_repr=custom_repr,
)
for tb in iter_stacks(tb)
]
if frames:
exception_value["stacktrace"] = {"frames": frames, "type": "raw"}
return exception_value
HAS_CHAINED_EXCEPTIONS = hasattr(Exception, "__suppress_context__")
if HAS_CHAINED_EXCEPTIONS:
def walk_exception_chain(exc_info):
# type: (ExcInfo) -> Iterator[ExcInfo]
exc_type, exc_value, tb = exc_info
seen_exceptions = []
seen_exception_ids = set() # type: Set[int]
while exc_type is not None and exc_value is not None and id(exc_value) not in seen_exception_ids:
yield exc_type, exc_value, tb
# Avoid hashing random types we don't know anything
# about. Use the list to keep a ref so that the `id` is
# not used for another object.
seen_exceptions.append(exc_value)
seen_exception_ids.add(id(exc_value))
if exc_value.__suppress_context__:
cause = exc_value.__cause__
else:
cause = exc_value.__context__
if cause is None:
break
exc_type = type(cause)
exc_value = cause
tb = getattr(cause, "__traceback__", None)
else:
def walk_exception_chain(exc_info):
# type: (ExcInfo) -> Iterator[ExcInfo]
yield exc_info
def exceptions_from_error(
exc_type, # type: Optional[type]
exc_value, # type: Optional[BaseException]
tb, # type: Optional[TracebackType]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
exception_id=0, # type: int
parent_id=0, # type: int
source=None, # type: Optional[str]
):
# type: (...) -> Tuple[int, List[Dict[str, Any]]]
"""
Creates the list of exceptions.
This can include chained exceptions and exceptions from an ExceptionGroup.
See the Exception Interface documentation for more details:
https://develop.sentry.dev/sdk/event-payloads/exception/
"""
parent = single_exception_from_error_tuple(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
source=source,
)
exceptions = [parent]
parent_id = exception_id
exception_id += 1
should_supress_context = hasattr(exc_value, "__suppress_context__") and exc_value.__suppress_context__ # type: ignore
if should_supress_context:
# Add direct cause.
# The field `__cause__` is set when raised with the exception (using the `from` keyword).
exception_has_cause = exc_value and hasattr(exc_value, "__cause__") and exc_value.__cause__ is not None
if exception_has_cause:
cause = exc_value.__cause__ # type: ignore
(exception_id, child_exceptions) = exceptions_from_error(
exc_type=type(cause),
exc_value=cause,
tb=getattr(cause, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__cause__",
)
exceptions.extend(child_exceptions)
else:
# Add indirect cause.
# The field `__context__` is assigned if another exception occurs while handling the exception.
exception_has_content = exc_value and hasattr(exc_value, "__context__") and exc_value.__context__ is not None
if exception_has_content:
context = exc_value.__context__ # type: ignore
(exception_id, child_exceptions) = exceptions_from_error(
exc_type=type(context),
exc_value=context,
tb=getattr(context, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
source="__context__",
)
exceptions.extend(child_exceptions)
# Add exceptions from an ExceptionGroup.
is_exception_group = exc_value and hasattr(exc_value, "exceptions")
if is_exception_group:
for idx, e in enumerate(exc_value.exceptions): # type: ignore
(exception_id, child_exceptions) = exceptions_from_error(
exc_type=type(e),
exc_value=e,
tb=getattr(e, "__traceback__", None),
client_options=client_options,
mechanism=mechanism,
exception_id=exception_id,
parent_id=parent_id,
source="exceptions[%s]" % idx,
)
exceptions.extend(child_exceptions)
return (exception_id, exceptions)
def exceptions_from_error_tuple(
exc_info, # type: ExcInfo
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> List[Dict[str, Any]]
exc_type, exc_value, tb = exc_info
is_exception_group = BaseExceptionGroup is not None and isinstance(exc_value, BaseExceptionGroup)
if is_exception_group:
(_, exceptions) = exceptions_from_error(
exc_type=exc_type,
exc_value=exc_value,
tb=tb,
client_options=client_options,
mechanism=mechanism,
exception_id=0,
parent_id=0,
)
else:
exceptions = []
for exc_type, exc_value, tb in walk_exception_chain(exc_info):
exceptions.append(single_exception_from_error_tuple(exc_type, exc_value, tb, client_options, mechanism))
exceptions.reverse()
return exceptions
def to_string(value):
# type: (str) -> str
try:
return str(value)
except UnicodeDecodeError:
return repr(value)[1:-1]
def iter_event_stacktraces(event):
# type: (Event) -> Iterator[Dict[str, Any]]
if "stacktrace" in event:
yield event["stacktrace"]
if "threads" in event:
for thread in event["threads"].get("values") or ():
if "stacktrace" in thread:
yield thread["stacktrace"]
if "exception" in event:
for exception in event["exception"].get("values") or ():
if "stacktrace" in exception:
yield exception["stacktrace"]
def iter_event_frames(event):
# type: (Event) -> Iterator[Dict[str, Any]]
for stacktrace in iter_event_stacktraces(event):
for frame in stacktrace.get("frames") or ():
yield frame
def handle_in_app(event, in_app_exclude=None, in_app_include=None, project_root=None):
# type: (Event, Optional[List[str]], Optional[List[str]], Optional[str]) -> Event
for stacktrace in iter_event_stacktraces(event):
set_in_app_in_frames(
stacktrace.get("frames"),
in_app_exclude=in_app_exclude,
in_app_include=in_app_include,
project_root=project_root,
)
return event
def set_in_app_in_frames(frames, in_app_exclude, in_app_include, project_root=None):
# type: (Any, Optional[List[str]], Optional[List[str]], Optional[str]) -> Optional[Any]
if not frames:
return None
for frame in frames:
# if frame has already been marked as in_app, skip it
current_in_app = frame.get("in_app")
if current_in_app is not None:
continue
module = frame.get("module")
# check if module in frame is in the list of modules to include
if _module_in_list(module, in_app_include):
frame["in_app"] = True
continue
# check if module in frame is in the list of modules to exclude
if _module_in_list(module, in_app_exclude):
frame["in_app"] = False
continue
# if frame has no abs_path, skip further checks
abs_path = frame.get("abs_path")
if abs_path is None:
continue
if _is_external_source(abs_path):
frame["in_app"] = False
continue
if _is_in_project_root(abs_path, project_root):
frame["in_app"] = True
continue
return frames
def exc_info_from_error(error):
# type: (Union[BaseException, ExcInfo]) -> ExcInfo
if isinstance(error, tuple) and len(error) == 3:
exc_type, exc_value, tb = error
elif isinstance(error, BaseException):
tb = getattr(error, "__traceback__", None)
if tb is not None:
exc_type = type(error)
exc_value = error
else:
exc_type, exc_value, tb = sys.exc_info()
if exc_value is not error:
tb = None
exc_value = error
exc_type = type(error)
else:
raise ValueError("Expected Exception object to report, got %s!" % type(error))
exc_info = (exc_type, exc_value, tb)
if TYPE_CHECKING:
# This cast is safe because exc_type and exc_value are either both
# None or both not None.
exc_info = cast(ExcInfo, exc_info)
return exc_info
def event_from_exception(
exc_info, # type: Union[BaseException, ExcInfo]
client_options=None, # type: Optional[Dict[str, Any]]
mechanism=None, # type: Optional[Dict[str, Any]]
):
# type: (...) -> Tuple[Event, Dict[str, Any]]
exc_info = exc_info_from_error(exc_info)
hint = event_hint_with_exc_info(exc_info)
return (
{
"level": "error",
"exception": {"values": exceptions_from_error_tuple(exc_info, client_options, mechanism)},
},
hint,
)
def _module_in_list(name, items):
# type: (str | None, Optional[List[str]]) -> bool
if name is None:
return False
if not items:
return False
for item in items:
if item == name or name.startswith(item + "."):
return True
return False
def _is_external_source(abs_path):
# type: (str) -> bool
# check if frame is in 'site-packages' or 'dist-packages'
external_source = re.search(r"[\\/](?:dist|site)-packages[\\/]", abs_path) is not None
return external_source
def _is_in_project_root(abs_path, project_root):
# type: (str, Optional[str]) -> bool
if project_root is None:
return False
# check if path is in the project root
if abs_path.startswith(project_root):
return True
return False
def _truncate_by_bytes(string, max_bytes):
# type: (str, int) -> str
"""
Truncate a UTF-8-encodable string to the last full codepoint so that it fits in max_bytes.
"""
truncated = string.encode("utf-8")[: max_bytes - 3].decode("utf-8", errors="ignore")
return truncated + "..."
def _get_size_in_bytes(value):
# type: (str) -> Optional[int]
try:
return len(value.encode("utf-8"))
except (UnicodeEncodeError, UnicodeDecodeError):
return None
def strip_string(value, max_length=None):
# type: (str, Optional[int]) -> Union[AnnotatedValue, str]
if not value:
return value
if max_length is None:
max_length = DEFAULT_MAX_VALUE_LENGTH
byte_size = _get_size_in_bytes(value)
text_size = len(value)
if byte_size is not None and byte_size > max_length:
# truncate to max_length bytes, preserving code points
truncated_value = _truncate_by_bytes(value, max_length)
elif text_size is not None and text_size > max_length:
# fallback to truncating by string length
truncated_value = value[: max_length - 3] + "..."
else:
return value
return AnnotatedValue(
value=truncated_value,
metadata={
"len": byte_size or text_size,
"rem": [["!limit", "x", max_length - 3, max_length]],
},
)
+339
View File
@@ -0,0 +1,339 @@
import datetime
import hashlib
import logging
import re
from typing import Optional
from dateutil import parser
from dateutil.relativedelta import relativedelta
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("posthog")
NONE_VALUES_ALLOWED_OPERATORS = ["is_not"]
class InconclusiveMatchError(Exception):
pass
# This function takes a distinct_id and a feature flag key and returns a float between 0 and 1.
# Given the same distinct_id and key, it'll always return the same float. These floats are
# uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
# we can do _hash(key, distinct_id) < 0.2
def _hash(key: str, distinct_id: str, salt: str = "") -> float:
hash_key = f"{key}.{distinct_id}{salt}"
hash_val = int(hashlib.sha1(hash_key.encode("utf-8")).hexdigest()[:15], 16)
return hash_val / __LONG_SCALE__
def get_matching_variant(flag, distinct_id):
hash_value = _hash(flag["key"], distinct_id, salt="variant")
for variant in variant_lookup_table(flag):
if hash_value >= variant["value_min"] and hash_value < variant["value_max"]:
return variant["key"]
return None
def variant_lookup_table(feature_flag):
lookup_table = []
value_min = 0
multivariates = ((feature_flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
for variant in multivariates:
value_max = value_min + variant["rollout_percentage"] / 100
lookup_table.append({"value_min": value_min, "value_max": value_max, "key": variant["key"]})
value_min = value_max
return lookup_table
def match_feature_flag_properties(flag, distinct_id, properties, cohort_properties=None) -> FlagValue:
flag_conditions = (flag.get("filters") or {}).get("groups") or []
is_inconclusive = False
cohort_properties = cohort_properties or {}
# Some filters can be explicitly set to null, which require accessing variants like so
flag_variants = ((flag.get("filters") or {}).get("multivariate") or {}).get("variants") or []
valid_variant_keys = [variant["key"] for variant in flag_variants]
# Stable sort conditions with variant overrides to the top. This ensures that if overrides are present, they are
# evaluated first, and the variant override is applied to the first matching condition.
sorted_flag_conditions = sorted(
flag_conditions,
key=lambda condition: 0 if condition.get("variant") else 1,
)
for condition in sorted_flag_conditions:
try:
# if any one condition resolves to True, we can shortcircuit and return
# the matching variant
if is_condition_match(flag, distinct_id, condition, properties, cohort_properties):
variant_override = condition.get("variant")
if variant_override and variant_override in valid_variant_keys:
variant = variant_override
else:
variant = get_matching_variant(flag, distinct_id)
return variant or True
except InconclusiveMatchError:
is_inconclusive = True
if is_inconclusive:
raise InconclusiveMatchError("Can't determine if feature flag is enabled or not with given properties")
# We can only return False when either all conditions are False, or
# no condition was inconclusive.
return False
def is_condition_match(feature_flag, distinct_id, condition, properties, cohort_properties) -> bool:
rollout_percentage = condition.get("rollout_percentage")
if len(condition.get("properties") or []) > 0:
for prop in condition.get("properties"):
property_type = prop.get("type")
if property_type == "cohort":
matches = match_cohort(prop, properties, cohort_properties)
else:
matches = match_property(prop, properties)
if not matches:
return False
if rollout_percentage is None:
return True
if rollout_percentage is not None and _hash(feature_flag["key"], distinct_id) > (rollout_percentage / 100):
return False
return True
def match_property(property, property_values) -> bool:
# only looks for matches where key exists in override_property_values
# doesn't support operator is_not_set
key = property.get("key")
operator = property.get("operator") or "exact"
value = property.get("value")
if key not in property_values:
raise InconclusiveMatchError("can't match properties without a given property value")
if operator == "is_not_set":
raise InconclusiveMatchError("can't match properties with operator is_not_set")
override_value = property_values[key]
if (operator not in NONE_VALUES_ALLOWED_OPERATORS) and override_value is None:
return False
if operator in ("exact", "is_not"):
def compute_exact_match(value, override_value):
if isinstance(value, list):
return str(override_value).casefold() in [str(val).casefold() for val in value]
return utils.str_iequals(value, override_value)
if operator == "exact":
return compute_exact_match(value, override_value)
else:
return not compute_exact_match(value, override_value)
if operator == "is_set":
return key in property_values
if operator == "icontains":
return utils.str_icontains(override_value, value)
if operator == "not_icontains":
return not utils.str_icontains(override_value, value)
if operator == "regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is not None
if operator == "not_regex":
return is_valid_regex(str(value)) and re.compile(str(value)).search(str(override_value)) is None
if operator in ("gt", "gte", "lt", "lte"):
# :TRICKY: We adjust comparison based on the override value passed in,
# to make sure we handle both numeric and string comparisons appropriately.
def compare(lhs, rhs, operator):
if operator == "gt":
return lhs > rhs
elif operator == "gte":
return lhs >= rhs
elif operator == "lt":
return lhs < rhs
elif operator == "lte":
return lhs <= rhs
else:
raise ValueError(f"Invalid operator: {operator}")
parsed_value = None
try:
parsed_value = float(value) # type: ignore
except Exception:
pass
if parsed_value is not None and override_value is not None:
if isinstance(override_value, str):
return compare(override_value, str(value), operator)
else:
return compare(override_value, parsed_value, operator)
else:
return compare(str(override_value), str(value), operator)
if operator in ["is_date_before", "is_date_after"]:
try:
parsed_date = relative_date_parse_for_feature_flag_matching(str(value))
if not parsed_date:
parsed_date = parser.parse(str(value))
parsed_date = convert_to_datetime_aware(parsed_date)
except Exception as e:
raise InconclusiveMatchError("The date set on the flag is not a valid format") from e
if not parsed_date:
raise InconclusiveMatchError("The date set on the flag is not a valid format")
if isinstance(override_value, datetime.datetime):
override_date = convert_to_datetime_aware(override_value)
if operator == "is_date_before":
return override_date < parsed_date
else:
return override_date > parsed_date
elif isinstance(override_value, datetime.date):
if operator == "is_date_before":
return override_value < parsed_date.date()
else:
return override_value > parsed_date.date()
elif isinstance(override_value, str):
try:
override_date = parser.parse(override_value)
override_date = convert_to_datetime_aware(override_date)
if operator == "is_date_before":
return override_date < parsed_date
else:
return override_date > parsed_date
except Exception:
raise InconclusiveMatchError("The date provided is not a valid format")
else:
raise InconclusiveMatchError("The date provided must be a string or date object")
# if we get here, we don't know how to handle the operator
raise InconclusiveMatchError(f"Unknown operator {operator}")
def match_cohort(property, property_values, cohort_properties) -> bool:
# Cohort properties are in the form of property groups like this:
# {
# "cohort_id": {
# "type": "AND|OR",
# "values": [{
# "key": "property_name", "value": "property_value"
# }]
# }
# }
cohort_id = str(property.get("value"))
if cohort_id not in cohort_properties:
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
property_group = cohort_properties[cohort_id]
return match_property_group(property_group, property_values, cohort_properties)
def match_property_group(property_group, property_values, cohort_properties) -> bool:
if not property_group:
return True
property_group_type = property_group.get("type")
properties = property_group.get("values")
if not properties or len(properties) == 0:
# empty groups are no-ops, always match
return True
error_matching_locally = False
if "values" in properties[0]:
# a nested property group
for prop in properties:
try:
matches = match_property_group(prop, property_values, cohort_properties)
if property_group_type == "AND":
if not matches:
return False
else:
# OR group
if matches:
return True
except InconclusiveMatchError as e:
log.debug(f"Failed to compute property {prop} locally: {e}")
error_matching_locally = True
if error_matching_locally:
raise InconclusiveMatchError("Can't match cohort without a given cohort property value")
# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"
else:
for prop in properties:
try:
if prop.get("type") == "cohort":
matches = match_cohort(prop, property_values, cohort_properties)
else:
matches = match_property(prop, property_values)
negation = prop.get("negation", False)
if property_group_type == "AND":
# if negated property, do the inverse
if not matches and not negation:
return False
if matches and negation:
return False
else:
# OR group
if matches and not negation:
return True
if not matches and negation:
return True
except InconclusiveMatchError as e:
log.debug(f"Failed to compute property {prop} locally: {e}")
error_matching_locally = True
if error_matching_locally:
raise InconclusiveMatchError("can't match cohort without a given cohort property value")
# if we get here, all matched in AND case, or none matched in OR case
return property_group_type == "AND"
def relative_date_parse_for_feature_flag_matching(value: str) -> Optional[datetime.datetime]:
regex = r"^-?(?P<number>[0-9]+)(?P<interval>[a-z])$"
match = re.search(regex, value)
parsed_dt = datetime.datetime.now(datetime.timezone.utc)
if match:
number = int(match.group("number"))
if number >= 10_000:
# Guard against overflow, disallow numbers greater than 10_000
return None
interval = match.group("interval")
if interval == "h":
parsed_dt = parsed_dt - relativedelta(hours=number)
elif interval == "d":
parsed_dt = parsed_dt - relativedelta(days=number)
elif interval == "w":
parsed_dt = parsed_dt - relativedelta(weeks=number)
elif interval == "m":
parsed_dt = parsed_dt - relativedelta(months=number)
elif interval == "y":
parsed_dt = parsed_dt - relativedelta(years=number)
else:
return None
return parsed_dt
else:
return None
+20
View File
@@ -0,0 +1,20 @@
import threading
class Poller(threading.Thread):
def __init__(self, interval, execute, *args, **kwargs):
threading.Thread.__init__(self)
self.daemon = True # Make daemon to not interfere with program exit
self.stopped = threading.Event()
self.interval = interval
self.execute = execute
self.args = args
self.kwargs = kwargs
def stop(self):
self.stopped.set()
self.join()
def run(self):
while not self.stopped.wait(self.interval.total_seconds()):
self.execute(*self.args, **self.kwargs)
+117 -34
View File
@@ -1,69 +1,152 @@
from datetime import date, datetime
from dateutil.tz import tzutc
import logging
import json
import logging
from datetime import date, datetime
from gzip import GzipFile
from requests.auth import HTTPBasicAuth
from requests import sessions
from io import BytesIO
from typing import Any, Optional, Union
import requests
from dateutil.tz import tzutc
from urllib3.util.retry import Retry
from posthog.version import VERSION
from posthog.utils import remove_trailing_slash
from posthog.version import VERSION
_session = sessions.Session()
# Retry on both connect and read errors
# by default read errors will only retry idempotent HTTP methods (so not POST)
adapter = requests.adapters.HTTPAdapter(
max_retries=Retry(
total=2,
connect=2,
read=2,
)
)
_session = requests.sessions.Session()
_session.mount("https://", adapter)
US_INGESTION_ENDPOINT = "https://us.i.posthog.com"
EU_INGESTION_ENDPOINT = "https://eu.i.posthog.com"
DEFAULT_HOST = US_INGESTION_ENDPOINT
USER_AGENT = "posthog-python/" + VERSION
def post(api_key, host=None, gzip=False, timeout=15, **kwargs):
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"):
return US_INGESTION_ENDPOINT
elif trimmed_host == "https://eu.posthog.com":
return EU_INGESTION_ENDPOINT
else:
return host_or_default
def post(
api_key: str, host: Optional[str] = None, path=None, gzip: bool = False, timeout: int = 15, **kwargs
) -> requests.Response:
"""Post the `kwargs` to the API"""
log = logging.getLogger('posthog')
log = logging.getLogger("posthog")
body = kwargs
body["sentAt"] = datetime.utcnow().replace(tzinfo=tzutc()).isoformat()
url = remove_trailing_slash(host or 'https://t.posthog.com') + '/batch/'
body['api_key'] = api_key
body["sentAt"] = datetime.now(tz=tzutc()).isoformat()
url = remove_trailing_slash(host or DEFAULT_HOST) + path
body["api_key"] = api_key
data = json.dumps(body, cls=DatetimeSerializer)
log.debug('making request: %s', data)
headers = {
'Content-Type': 'application/json',
'User-Agent': 'analytics-python/' + VERSION
}
log.debug("making request: %s to url: %s", data, url)
headers = {"Content-Type": "application/json", "User-Agent": USER_AGENT}
if gzip:
headers['Content-Encoding'] = 'gzip'
headers["Content-Encoding"] = "gzip"
buf = BytesIO()
with GzipFile(fileobj=buf, mode='w') as gz:
with GzipFile(fileobj=buf, mode="w") as gz:
# 'data' was produced by json.dumps(),
# whose default encoding is utf-8.
gz.write(data.encode('utf-8'))
gz.write(data.encode("utf-8"))
data = buf.getvalue()
res = _session.post(url, data=data,
headers=headers, timeout=timeout)
res = _session.post(url, data=data, headers=headers, timeout=timeout)
if res.status_code == 200:
log.debug('data uploaded successfully')
return res
log.debug("data uploaded successfully")
return res
def _process_response(
res: requests.Response, success_message: str, *, return_json: bool = True
) -> Union[requests.Response, Any]:
log = logging.getLogger("posthog")
if res.status_code == 200:
log.debug(success_message)
response = res.json() if return_json else res
# Handle quota limited decide responses by raising a specific error
# NB: other services also put entries into the quotaLimited key, but right now we only care about feature flags
# since most of the other services handle quota limiting in other places in the application.
if (
isinstance(response, dict)
and "quotaLimited" in response
and isinstance(response["quotaLimited"], list)
and "feature_flags" in response["quotaLimited"]
):
log.warning(
"[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
try:
payload = res.json()
log.debug('received response: %s', payload)
raise APIError(res.status_code, payload['code'], payload['message'])
except ValueError:
raise APIError(res.status_code, 'unknown', res.text)
log.debug("received response: %s", payload)
raise APIError(res.status_code, payload["detail"])
except (KeyError, ValueError):
raise APIError(res.status_code, res.text)
def decide(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
"""Post the `kwargs to the decide API endpoint"""
res = post(api_key, host, "/decide/?v=4", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags decided successfully")
def flags(api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs) -> Any:
"""Post the `kwargs to the flags API endpoint"""
res = post(api_key, host, "/flags/?v=2", gzip, timeout, **kwargs)
return _process_response(res, success_message="Feature flags evaluated successfully")
def remote_config(personal_api_key: str, host: Optional[str] = None, key: str = "", timeout: int = 15) -> Any:
"""Get remote config flag value from remote_config API endpoint"""
return get(personal_api_key, f"/api/projects/@current/feature_flags/{key}/remote_config/", host, timeout)
def batch_post(
api_key: str, host: Optional[str] = None, gzip: bool = False, timeout: int = 15, **kwargs
) -> requests.Response:
"""Post the `kwargs` to the batch API endpoint for events"""
res = post(api_key, host, "/batch/", gzip, timeout, **kwargs)
return _process_response(res, success_message="data uploaded successfully", return_json=False)
def get(api_key: str, url: str, host: Optional[str] = None, timeout: Optional[int] = None) -> requests.Response:
url = remove_trailing_slash(host or DEFAULT_HOST) + url
res = requests.get(url, headers={"Authorization": "Bearer %s" % api_key, "User-Agent": USER_AGENT}, timeout=timeout)
return _process_response(res, success_message=f"GET {url} completed successfully")
class APIError(Exception):
def __init__(self, status, code, message):
def __init__(self, status: Union[int, str], message: str):
self.message = message
self.status = status
self.code = code
def __str__(self):
msg = "[PostHog] {0}: {1} ({2})"
return msg.format(self.code, self.message, self.status)
msg = "[PostHog] {0} ({1})"
return msg.format(self.message, self.status)
class QuotaLimitError(APIError):
pass
class DatetimeSerializer(json.JSONEncoder):
def default(self, obj):
def default(self, obj: Any):
if isinstance(obj, (date, datetime)):
return obj.isoformat()
+1
View File
@@ -0,0 +1 @@
POSTHOG_ID_TAG = "posthog_distinct_id"
+28
View File
@@ -0,0 +1,28 @@
from django.conf import settings
from sentry_sdk import configure_scope
from posthog.sentry import POSTHOG_ID_TAG
GET_DISTINCT_ID = getattr(settings, "POSTHOG_DJANGO", {}).get("distinct_id")
def get_distinct_id(request):
if not GET_DISTINCT_ID:
return None
try:
return GET_DISTINCT_ID(request)
except: # noqa: E722
return None
class PosthogDistinctIdMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
with configure_scope() as scope:
distinct_id = get_distinct_id(request)
if distinct_id:
scope.set_tag(POSTHOG_ID_TAG, distinct_id)
response = self.get_response(request)
return response
+52
View File
@@ -0,0 +1,52 @@
from sentry_sdk._types import MYPY
from sentry_sdk.hub import Hub
from sentry_sdk.integrations import Integration
from sentry_sdk.scope import add_global_event_processor
from sentry_sdk.utils import Dsn
import posthog
from posthog.request import DEFAULT_HOST
from posthog.sentry import POSTHOG_ID_TAG
if MYPY:
from typing import Optional # noqa: F401
from sentry_sdk._types import Event, Hint # noqa: F401
class PostHogIntegration(Integration):
identifier = "posthog-python"
organization = None # The Sentry organization, used to send a direct link from PostHog to Sentry
project_id = None # The Sentry project id, used to send a direct link from PostHog to Sentry
prefix = "https://sentry.io/organizations/" # URL of a hosted sentry instance (default: https://sentry.io/organizations/)
@staticmethod
def setup_once():
@add_global_event_processor
def processor(event, hint):
# type: (Event, Optional[Hint]) -> Optional[Event]
if Hub.current.get_integration(PostHogIntegration) is not None:
if event.get("level") != "error":
return event
if event.get("tags", {}).get(POSTHOG_ID_TAG):
posthog_distinct_id = event["tags"][POSTHOG_ID_TAG]
event["tags"]["PostHog URL"] = f"{posthog.host or DEFAULT_HOST}/person/{posthog_distinct_id}"
properties = {
"$sentry_event_id": event["event_id"],
"$sentry_exception": event["exception"],
}
if PostHogIntegration.organization:
project_id = PostHogIntegration.project_id or (
not not Hub.current.client.dsn and Dsn(Hub.current.client.dsn).project_id
)
if project_id:
properties["$sentry_url"] = (
f"{PostHogIntegration.prefix}{PostHogIntegration.organization}/issues/?project={project_id}&query={event['event_id']}"
)
posthog.capture(posthog_distinct_id, "$exception", properties)
return event
+3 -3
View File
@@ -1,12 +1,12 @@
import unittest
import pkgutil
import logging
import pkgutil
import sys
import unittest
def all_names():
for _, modname, _ in pkgutil.iter_modules(__path__):
yield 'posthog.test.' + modname
yield "posthog.test." + modname
def all():
View File
+403
View File
@@ -0,0 +1,403 @@
import os
import time
from unittest.mock import patch
import pytest
try:
from anthropic.types import Message, Usage
from posthog.ai.anthropic import Anthropic, AsyncAnthropic
ANTHROPIC_AVAILABLE = True
except ImportError:
ANTHROPIC_AVAILABLE = False
ANTHROPIC_API_KEY = os.getenv("ANTHROPIC_API_KEY")
# Skip all tests if Anthropic is not available
pytestmark = pytest.mark.skipif(not ANTHROPIC_AVAILABLE, reason="Anthropic package is not available")
@pytest.fixture
def mock_client():
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@pytest.fixture
def mock_anthropic_response():
return Message(
id="msg_123",
type="message",
role="assistant",
content=[{"type": "text", "text": "Test response"}],
model="claude-3-opus-20240229",
usage=Usage(
input_tokens=20,
output_tokens=10,
),
stop_reason="end_turn",
stop_sequence=None,
)
@pytest.fixture
def mock_anthropic_stream():
class MockStreamEvent:
def __init__(self, content, usage=None):
self.content = content
self.usage = usage
def stream_generator():
yield MockStreamEvent("A")
yield MockStreamEvent("B")
yield MockStreamEvent(
"C",
usage=Usage(
input_tokens=20,
output_tokens=10,
),
)
return stream_generator()
@pytest.fixture
def mock_anthropic_response_with_cached_tokens():
# Create a mock Usage object with cached_tokens in input_tokens_details
usage = Usage(
input_tokens=20,
output_tokens=10,
cache_read_input_tokens=15,
cache_creation_input_tokens=2,
)
return Message(
id="msg_123",
type="message",
role="assistant",
content=[{"type": "text", "text": "Test response"}],
model="claude-3-opus-20240229",
usage=usage,
stop_reason="end_turn",
stop_sequence=None,
)
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", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_streaming(mock_client, mock_anthropic_stream):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_stream):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
stream=True,
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
# Consume the stream
chunks = list(response)
assert len(chunks) == 3
assert chunks[0].content == "A"
assert chunks[1].content == "B"
assert chunks[2].content == "C"
# Wait a bit to ensure the capture is called
time.sleep(0.1)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "ABC"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert isinstance(props["$ai_latency"], float)
assert props["foo"] == "bar"
def test_streaming_with_stream_endpoint(mock_client, mock_anthropic_stream):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_stream):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.stream(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
# Consume the stream
chunks = list(response)
assert len(chunks) == 3
assert chunks[0].content == "A"
assert chunks[1].content == "B"
assert chunks[2].content == "C"
# Wait a bit to ensure the capture is called
time.sleep(0.1)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "ABC"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert isinstance(props["$ai_latency"], float)
assert props["foo"] == "bar"
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", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_groups={"company": "test_company"},
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
assert call_args["groups"] == {"company": "test_company"}
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", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_privacy_mode_global(mock_client, mock_anthropic_response):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response):
mock_client.privacy_mode = True
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_privacy_mode=False,
)
assert response == mock_anthropic_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
@pytest.mark.skipif(not ANTHROPIC_API_KEY, reason="ANTHROPIC_API_KEY is not set")
def test_basic_integration(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,
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
system="You must always answer with 'Bar'.",
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [
{"role": "system", "content": "You must always answer with 'Bar'."},
{"role": "user", "content": "Foo"},
]
assert props["$ai_output_choices"][0]["role"] == "assistant"
assert props["$ai_output_choices"][0]["content"] == "Bar"
assert props["$ai_input_tokens"] == 18
assert props["$ai_output_tokens"] == 1
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
@pytest.mark.skipif(not ANTHROPIC_API_KEY, reason="ANTHROPIC_API_KEY is not set")
async def test_basic_async_integration(mock_client):
client = AsyncAnthropic(posthog_client=mock_client)
await client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "You must always answer with 'Bar'."}],
max_tokens=1,
temperature=0,
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "You must always answer with 'Bar'."}]
assert props["$ai_output_choices"][0]["role"] == "assistant"
assert props["$ai_input_tokens"] == 16
assert props["$ai_output_tokens"] == 1
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_streaming_system_prompt(mock_client, mock_anthropic_stream):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_stream):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
system="Foo",
messages=[{"role": "user", "content": "Bar"}],
stream=True,
)
# Consume the stream
list(response)
# Wait a bit to ensure the capture is called
time.sleep(0.1)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [{"role": "system", "content": "Foo"}, {"role": "user", "content": "Bar"}]
@pytest.mark.skipif(not ANTHROPIC_API_KEY, reason="ANTHROPIC_API_KEY is not set")
async def test_async_streaming_system_prompt(mock_client, mock_anthropic_stream):
client = AsyncAnthropic(posthog_client=mock_client)
response = await client.messages.create(
model="claude-3-opus-20240229",
system="You must always answer with 'Bar'.",
messages=[{"role": "user", "content": "Foo"}],
stream=True,
max_tokens=1,
)
# Consume the stream
[c async for c in response]
# Wait a bit to ensure the capture is called
time.sleep(0.1)
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] == [
{"role": "system", "content": "You must always answer with 'Bar'."},
{"role": "user", "content": "Foo"},
]
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", posthog_client=mock_client)
with pytest.raises(Exception):
client.messages.create(model="claude-3-opus-20240229", messages=[{"role": "user", "content": "Hello"}])
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_is_error"] is True
assert props["$ai_error"] == "Test error"
def test_cached_tokens(mock_client, mock_anthropic_response_with_cached_tokens):
with patch("anthropic.resources.Messages.create", return_value=mock_anthropic_response_with_cached_tokens):
client = Anthropic(api_key="test-key", posthog_client=mock_client)
response = client.messages.create(
model="claude-3-opus-20240229",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_anthropic_response_with_cached_tokens
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "anthropic"
assert props["$ai_model"] == "claude-3-opus-20240229"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 15
assert props["$ai_cache_creation_input_tokens"] == 2
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
+5
View File
@@ -0,0 +1,5 @@
import pytest
pytest.importorskip("langchain")
pytest.importorskip("langchain_community")
pytest.importorskip("langgraph")
File diff suppressed because it is too large Load Diff
+584
View File
@@ -0,0 +1,584 @@
import json
import time
from unittest.mock import patch
import pytest
try:
from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import ChatCompletionChunk
from openai.types.chat.chat_completion_chunk import Choice as ChoiceChunk
from openai.types.chat.chat_completion_chunk import ChoiceDelta, ChoiceDeltaToolCall, ChoiceDeltaToolCallFunction
from openai.types.chat.chat_completion_message_tool_call import ChatCompletionMessageToolCall, Function
from openai.types.completion_usage import CompletionUsage
from openai.types.create_embedding_response import CreateEmbeddingResponse, Usage
from openai.types.embedding import Embedding
from openai.types.responses import Response, ResponseOutputMessage, ResponseOutputText, ResponseUsage
from posthog.ai.openai import OpenAI
OPENAI_AVAILABLE = True
except ImportError:
OPENAI_AVAILABLE = False
# Skip all tests if OpenAI is not available
pytestmark = pytest.mark.skipif(not OPENAI_AVAILABLE, reason="OpenAI package is not available")
@pytest.fixture
def mock_client():
with patch("posthog.client.Client") as mock_client:
mock_client.privacy_mode = False
yield mock_client
@pytest.fixture
def mock_openai_response():
return ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Test response",
role="assistant",
),
)
],
usage=CompletionUsage(
completion_tokens=10,
prompt_tokens=20,
total_tokens=30,
),
)
@pytest.fixture
def mock_openai_response_with_responses_api():
return Response(
id="test",
model="gpt-4o-mini",
object="response",
created_at=1741476542,
status="completed",
error=None,
incomplete_details=None,
instructions=None,
max_output_tokens=None,
tools=[],
tool_choice="auto",
output=[
ResponseOutputMessage(
id="msg_123",
type="message",
role="assistant",
status="completed",
content=[
ResponseOutputText(
type="output_text",
text="Test response",
annotations=[],
)
],
)
],
parallel_tool_calls=True,
previous_response_id=None,
usage=ResponseUsage(
input_tokens=10,
output_tokens=10,
input_tokens_details={"prompt_tokens": 10, "cached_tokens": 0},
output_tokens_details={"reasoning_tokens": 15},
total_tokens=20,
),
user=None,
metadata={},
)
@pytest.fixture
def mock_embedding_response():
return CreateEmbeddingResponse(
data=[
Embedding(
embedding=[0.1, 0.2, 0.3],
index=0,
object="embedding",
)
],
model="text-embedding-3-small",
object="list",
usage=Usage(
prompt_tokens=10,
total_tokens=10,
),
)
@pytest.fixture
def mock_openai_response_with_cached_tokens():
return ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="stop",
index=0,
message=ChatCompletionMessage(
content="Test response",
role="assistant",
),
)
],
usage=CompletionUsage(
completion_tokens=10,
prompt_tokens=20,
total_tokens=30,
prompt_tokens_details={"cached_tokens": 15},
),
)
@pytest.fixture
def mock_openai_response_with_tool_calls():
return ChatCompletion(
id="test",
model="gpt-4",
object="chat.completion",
created=int(time.time()),
choices=[
Choice(
finish_reason="tool_calls",
index=0,
message=ChatCompletionMessage(
content="I'll check the weather for you.",
role="assistant",
tool_calls=[
ChatCompletionMessageToolCall(
id="call_abc123",
type="function",
function=Function(
name="get_weather",
arguments='{"location": "San Francisco", "unit": "celsius"}',
),
)
],
),
)
],
usage=CompletionUsage(
completion_tokens=15,
prompt_tokens=20,
total_tokens=35,
),
)
def test_basic_completion(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_embeddings(mock_client, mock_embedding_response):
with patch("openai.resources.embeddings.Embeddings.create", return_value=mock_embedding_response):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.embeddings.create(
model="text-embedding-3-small",
input="Hello world",
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_embedding_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_embedding"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "text-embedding-3-small"
assert props["$ai_input"] == "Hello world"
assert props["$ai_input_tokens"] == 10
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_groups(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_groups={"company": "test_company"},
)
assert response == mock_openai_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
assert call_args["groups"] == {"company": "test_company"}
def test_privacy_mode_local(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_privacy_mode=True,
)
assert response == mock_openai_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_privacy_mode_global(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", return_value=mock_openai_response):
mock_client.privacy_mode = True
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_privacy_mode=False,
)
assert response == mock_openai_response
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_input"] is None
assert props["$ai_output_choices"] is None
def test_error(mock_client, mock_openai_response):
with patch("openai.resources.chat.completions.Completions.create", side_effect=Exception("Test error")):
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"}])
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert props["$ai_is_error"] is True
assert props["$ai_error"] == "Test error"
def test_cached_tokens(mock_client, mock_openai_response_with_cached_tokens):
with patch(
"openai.resources.chat.completions.Completions.create", return_value=mock_openai_response_with_cached_tokens
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "Hello"}],
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_cached_tokens
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 10
assert props["$ai_cache_read_input_tokens"] == 15
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
def test_tool_calls(mock_client, mock_openai_response_with_tool_calls):
with patch(
"openai.resources.chat.completions.Completions.create", return_value=mock_openai_response_with_tool_calls
):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
tools=[
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": {}},
}
],
posthog_distinct_id="test-id",
)
assert response == mock_openai_response_with_tool_calls
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
assert props["$ai_input"] == [{"role": "user", "content": "What's the weather in San Francisco?"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "I'll check the weather for you."}]
# Check that tool calls are properly captured
assert "$ai_tools" in props
tool_calls = props["$ai_tools"]
assert len(tool_calls) == 1
# Verify the tool call details
tool_call = tool_calls[0]
assert tool_call.id == "call_abc123"
assert tool_call.type == "function"
assert tool_call.function.name == "get_weather"
# Verify the arguments
arguments = tool_call.function.arguments
parsed_args = json.loads(arguments)
assert parsed_args == {"location": "San Francisco", "unit": "celsius"}
# Check token usage
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
assert props["$ai_http_status"] == 200
def test_streaming_with_tool_calls(mock_client):
# Create mock tool call chunks that will be returned in sequence
tool_call_chunks = [
ChatCompletionChunk(
id="chunk1",
model="gpt-4",
object="chat.completion.chunk",
created=1234567890,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
role="assistant",
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_abc123",
type="function",
function=ChoiceDeltaToolCallFunction(
name="get_weather",
arguments='{"location": "',
),
)
],
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk2",
model="gpt-4",
object="chat.completion.chunk",
created=1234567891,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_abc123",
type="function",
function=ChoiceDeltaToolCallFunction(
arguments='San Francisco"',
),
)
],
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk3",
model="gpt-4",
object="chat.completion.chunk",
created=1234567892,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
tool_calls=[
ChoiceDeltaToolCall(
index=0,
id="call_abc123",
type="function",
function=ChoiceDeltaToolCallFunction(
arguments=', "unit": "celsius"}',
),
)
],
),
finish_reason=None,
)
],
),
ChatCompletionChunk(
id="chunk4",
model="gpt-4",
object="chat.completion.chunk",
created=1234567893,
choices=[
ChoiceChunk(
index=0,
delta=ChoiceDelta(
content="The weather in San Francisco is 15°C.",
),
finish_reason=None,
)
],
usage=CompletionUsage(
prompt_tokens=20,
completion_tokens=15,
total_tokens=35,
),
),
]
# Mock the create method to return our chunks
with patch("openai.resources.chat.completions.Completions.create") as mock_create:
# Set up the mock to return our chunks when iterated
mock_create.return_value = tool_call_chunks
client = OpenAI(api_key="test-key", posthog_client=mock_client)
# Call the streaming method
response_generator = client.chat.completions.create(
model="gpt-4",
messages=[{"role": "user", "content": "What's the weather in San Francisco?"}],
tools=[
{
"type": "function",
"function": {"name": "get_weather", "description": "Get weather", "parameters": {}},
}
],
stream=True,
posthog_distinct_id="test-id",
)
# Consume the generator to trigger the event capture
chunks = list(response_generator)
# Verify the chunks were returned correctly
assert len(chunks) == 4
assert chunks == tool_call_chunks
# Verify the capture was called with the right arguments
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4"
# Check that the tool calls were properly accumulated
assert "$ai_tools" in props
tool_calls = props["$ai_tools"]
assert len(tool_calls) == 1
# Verify the complete tool call was properly assembled
tool_call = tool_calls[0]
assert tool_call.id == "call_abc123"
assert tool_call.type == "function"
assert tool_call.function.name == "get_weather"
# Verify the arguments were concatenated correctly
arguments = tool_call.function.arguments
parsed_args = json.loads(arguments)
assert parsed_args == {"location": "San Francisco", "unit": "celsius"}
# Check that the content was also accumulated
assert props["$ai_output_choices"][0]["content"] == "The weather in San Francisco is 15°C."
# Check token usage
assert props["$ai_input_tokens"] == 20
assert props["$ai_output_tokens"] == 15
# test responses api
def test_responses_api(mock_client, mock_openai_response_with_responses_api):
with patch("openai.resources.responses.Responses.create", return_value=mock_openai_response_with_responses_api):
client = OpenAI(api_key="test-key", posthog_client=mock_client)
response = client.responses.create(
model="gpt-4o-mini",
input="Hello",
posthog_distinct_id="test-id",
posthog_properties={"foo": "bar"},
)
assert response == mock_openai_response_with_responses_api
assert mock_client.capture.call_count == 1
call_args = mock_client.capture.call_args[1]
props = call_args["properties"]
assert call_args["distinct_id"] == "test-id"
assert call_args["event"] == "$ai_generation"
assert props["$ai_provider"] == "openai"
assert props["$ai_model"] == "gpt-4o-mini"
assert props["$ai_input"] == [{"role": "user", "content": "Hello"}]
assert props["$ai_output_choices"] == [{"role": "assistant", "content": "Test response"}]
assert props["$ai_input_tokens"] == 10
assert props["$ai_output_tokens"] == 10
assert props["$ai_reasoning_tokens"] == 15
assert props["$ai_http_status"] == 200
assert props["foo"] == "bar"
assert isinstance(props["$ai_latency"], float)
-223
View File
@@ -1,223 +0,0 @@
from datetime import date, datetime
import unittest
import six
import mock
import time
from posthog.version import VERSION
from posthog.client import Client
from posthog.test.utils import TEST_API_KEY
class TestClient(unittest.TestCase):
def fail(self, e, batch):
"""Mark the failure handler"""
print('FAIL', e, batch)
self.failed = True
def setUp(self):
self.failed = False
self.client = Client(TEST_API_KEY, on_error=self.fail)
def test_requires_api_key(self):
self.assertRaises(AssertionError, Client)
def test_empty_flush(self):
self.client.flush()
def test_basic_capture(self):
client = self.client
success, msg = client.capture('distinct_id', 'python test event')
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['event'], 'python test event')
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertTrue(isinstance(msg['messageId'], str))
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
def test_stringifies_distinct_id(self):
# A large number that loses precision in node:
# node -e "console.log(157963456373623802 + 1)" > 157963456373623800
client = self.client
success, msg = client.capture(
distinct_id=157963456373623802, event='python test event')
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['distinct_id'], '157963456373623802')
def test_advanced_capture(self):
client = self.client
success, msg = client.capture(
'distinct_id', 'python test event', {'property': 'value'},
{'ip': '192.168.0.1'}, datetime(2014, 9, 3),
'messageId')
self.assertTrue(success)
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
self.assertEqual(msg['properties']['property'], 'value')
self.assertEqual(msg['context']['ip'], '192.168.0.1')
self.assertEqual(msg['event'], 'python test event')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertEqual(msg['messageId'], 'messageId')
self.assertEqual(msg['distinct_id'], 'distinct_id')
def test_basic_identify(self):
client = self.client
success, msg = client.identify('distinct_id', {'trait': 'value'})
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['$set']['trait'], 'value')
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertTrue(isinstance(msg['messageId'], str))
self.assertEqual(msg['distinct_id'], 'distinct_id')
def test_advanced_identify(self):
client = self.client
success, msg = client.identify(
'distinct_id', {'trait': 'value'}, {'ip': '192.168.0.1'},
datetime(2014, 9, 3), 'messageId')
self.assertTrue(success)
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
self.assertEqual(msg['context']['ip'], '192.168.0.1')
self.assertEqual(msg['$set']['trait'], 'value')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertEqual(msg['messageId'], 'messageId')
self.assertEqual(msg['distinct_id'], 'distinct_id')
def test_basic_alias(self):
client = self.client
success, msg = client.alias('previousId', 'distinct_id')
client.flush()
self.assertTrue(success)
self.assertFalse(self.failed)
self.assertEqual(msg['properties']['distinct_id'], 'previousId')
self.assertEqual(msg['properties']['alias'], 'distinct_id')
def test_basic_page(self):
client = self.client
success, msg = client.page('distinct_id', url='https://posthog.com/contact')
self.assertFalse(self.failed)
client.flush()
self.assertTrue(success)
self.assertEqual(msg['distinct_id'], 'distinct_id')
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
def test_advanced_page(self):
client = self.client
success, msg = client.page(
'distinct_id', 'https://posthog.com/contact', {'property': 'value'},
{'ip': '192.168.0.1'}, datetime(2014, 9, 3), 'messageId')
self.assertTrue(success)
self.assertEqual(msg['timestamp'], '2014-09-03T00:00:00+00:00')
self.assertEqual(msg['context']['ip'], '192.168.0.1')
self.assertEqual(msg['properties']['$current_url'], 'https://posthog.com/contact')
self.assertEqual(msg['properties']['property'], 'value')
self.assertEqual(msg['properties']['$lib'], 'posthog-python')
self.assertEqual(msg['properties']['$lib_version'], VERSION)
self.assertTrue(isinstance(msg['timestamp'], str))
self.assertEqual(msg['messageId'], 'messageId')
self.assertEqual(msg['distinct_id'], 'distinct_id')
def test_flush(self):
client = self.client
# set up the consumer with more requests than a single batch will allow
for i in range(1000):
success, msg = client.identify('distinct_id', {'trait': 'value'})
# We can't reliably assert that the queue is non-empty here; that's
# a race condition. We do our best to load it up though.
client.flush()
# Make sure that the client queue is empty after flushing
self.assertTrue(client.queue.empty())
def test_shutdown(self):
client = self.client
# set up the consumer with more requests than a single batch will allow
for i in range(1000):
success, msg = client.identify('distinct_id', {'trait': 'value'})
client.shutdown()
# we expect two things after shutdown:
# 1. client queue is empty
# 2. consumer thread has stopped
self.assertTrue(client.queue.empty())
for consumer in client.consumers:
self.assertFalse(consumer.is_alive())
def test_synchronous(self):
client = Client(TEST_API_KEY, sync_mode=True)
success, message = client.identify('distinct_id')
self.assertFalse(client.consumers)
self.assertTrue(client.queue.empty())
self.assertTrue(success)
def test_overflow(self):
client = Client(TEST_API_KEY, max_queue_size=1)
# Ensure consumer thread is no longer uploading
client.join()
for i in range(10):
client.identify('distinct_id')
success, msg = client.identify('distinct_id')
# Make sure we are informed that the queue is at capacity
self.assertFalse(success)
def test_unicode(self):
Client(six.u('unicode_key'))
def test_numeric_distinct_id(self):
self.client.capture(1234, 'python event')
self.client.flush()
self.assertFalse(self.failed)
def test_debug(self):
Client('bad_key', debug=True)
def test_gzip(self):
client = Client(TEST_API_KEY, on_error=self.fail, gzip=True)
for _ in range(10):
client.identify('distinct_id', {'trait': 'value'})
client.flush()
self.assertFalse(self.failed)
def test_user_defined_flush_at(self):
client = Client(TEST_API_KEY, on_error=self.fail,
flush_at=10, flush_interval=3)
def mock_post_fn(*args, **kwargs):
self.assertEquals(len(kwargs['batch']), 10)
# the post function should be called 2 times, with a batch size of 10
# each time.
with mock.patch('posthog.consumer.post', side_effect=mock_post_fn) \
as mock_post:
for _ in range(20):
client.identify('distinct_id', {'trait': 'value'})
time.sleep(1)
self.assertEquals(mock_post.call_count, 2)
def test_user_defined_timeout(self):
client = Client(TEST_API_KEY, timeout=10)
for consumer in client.consumers:
self.assertEquals(consumer.timeout, 10)
def test_default_timeout_15(self):
client = Client(TEST_API_KEY)
for consumer in client.consumers:
self.assertEquals(consumer.timeout, 15)
@@ -0,0 +1,3 @@
import pytest
pytest.importorskip("django")
@@ -0,0 +1,68 @@
from posthog.exception_integrations.django import DjangoRequestExtractor
DEFAULT_USER_AGENT = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3"
)
def mock_request_factory(override_headers):
class Request:
META = {}
# TRICKY: Actual django request dict object has case insensitive matching, and strips http from the names
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Referrer": "http://example.com",
"X-Forwarded-For": "193.4.5.12",
**(override_headers or {}),
}
return Request()
def test_request_extractor_with_no_trace():
request = mock_request_factory(None)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": None,
}
def test_request_extractor_with_trace():
request = mock_request_factory({"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"})
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": None,
}
def test_request_extractor_with_tracestate():
request = mock_request_factory(
{
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"tracestate": "posthog-distinct-id=1234",
}
)
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01",
"distinct_id": "1234",
}
def test_request_extractor_with_complicated_tracestate():
request = mock_request_factory({"tracestate": "posthog-distinct-id=alohaMountainsXUYZ,rojo=00f067aa0ba902b7"})
extractor = DjangoRequestExtractor(request)
assert extractor.extract_person_data() == {
"ip": "193.4.5.12",
"user_agent": DEFAULT_USER_AGENT,
"traceparent": None,
"distinct_id": "alohaMountainsXUYZ",
}
-41
View File
@@ -1,41 +0,0 @@
import unittest
import posthog
class TestModule(unittest.TestCase):
def failed(self):
self.failed = True
def setUp(self):
self.failed = False
posthog.api_key = 'testsecret'
posthog.on_error = self.failed
def test_no_api_key(self):
posthog.api_key = None
self.assertRaises(Exception, posthog.capture)
def test_no_host(self):
posthog.host = None
self.assertRaises(Exception, posthog.capture)
def test_track(self):
posthog.capture('distinct_id', 'python module event')
posthog.flush()
def test_identify(self):
posthog.identify('distinct_id', {'email': 'user@email.com'})
posthog.flush()
def test_alias(self):
posthog.alias('previousId', 'distinct_id')
posthog.flush()
def test_page(self):
posthog.page('distinct_id', 'https://posthog.com/contact')
posthog.flush()
def test_flush(self):
posthog.flush()
-54
View File
@@ -1,54 +0,0 @@
from datetime import datetime, date
import unittest
import json
import requests
from posthog.request import post, DatetimeSerializer
from posthog.test.utils import TEST_API_KEY
class TestRequests(unittest.TestCase):
def test_valid_request(self):
res = post(TEST_API_KEY, batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
}])
self.assertEqual(res.status_code, 200)
def test_invalid_request_error(self):
self.assertRaises(Exception, post, 'testsecret',
'https://t.posthog.com', False, '[{]')
def test_invalid_host(self):
self.assertRaises(Exception, post, 'testsecret',
't.posthog.com/', batch=[])
def test_datetime_serialization(self):
data = {'created': datetime(2012, 3, 4, 5, 6, 7, 891011)}
result = json.dumps(data, cls=DatetimeSerializer)
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
def test_date_serialization(self):
today = date.today()
data = {'created': today}
result = json.dumps(data, cls=DatetimeSerializer)
expected = '{"created": "%s"}' % today.isoformat()
self.assertEqual(result, expected)
def test_should_not_timeout(self):
res = post(TEST_API_KEY, batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
}], timeout=15)
self.assertEqual(res.status_code, 200)
def test_should_timeout(self):
with self.assertRaises(requests.ReadTimeout):
post('key', batch=[{
'distinct_id': 'distinct_id',
'event': 'python event',
'type': 'track'
}], timeout=0.0001)
File diff suppressed because it is too large Load Diff
@@ -1,23 +1,23 @@
import unittest
import mock
import time
import json
import time
import unittest
import mock
try:
from queue import Queue
except ImportError:
from Queue import Queue
from posthog.consumer import Consumer, MAX_MSG_SIZE
from posthog.consumer import MAX_MSG_SIZE, Consumer
from posthog.request import APIError
from posthog.test.utils import TEST_API_KEY
from posthog.test.test_utils import TEST_API_KEY
class TestConsumer(unittest.TestCase):
def test_next(self):
q = Queue()
consumer = Consumer(q, '')
consumer = Consumer(q, "")
q.put(1)
next = consumer.next()
self.assertEqual(next, [1])
@@ -25,7 +25,7 @@ class TestConsumer(unittest.TestCase):
def test_next_limit(self):
q = Queue()
flush_at = 50
consumer = Consumer(q, '', flush_at)
consumer = Consumer(q, "", flush_at)
for i in range(10000):
q.put(i)
next = consumer.next()
@@ -33,8 +33,8 @@ class TestConsumer(unittest.TestCase):
def test_dropping_oversize_msg(self):
q = Queue()
consumer = Consumer(q, '')
oversize_msg = {'m': 'x' * MAX_MSG_SIZE}
consumer = Consumer(q, "")
oversize_msg = {"m": "x" * MAX_MSG_SIZE}
q.put(oversize_msg)
next = consumer.next()
self.assertEqual(next, [])
@@ -43,11 +43,7 @@ class TestConsumer(unittest.TestCase):
def test_upload(self):
q = Queue()
consumer = Consumer(q, TEST_API_KEY)
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
q.put(track)
success = consumer.upload()
self.assertTrue(success)
@@ -58,16 +54,11 @@ class TestConsumer(unittest.TestCase):
# The consumer should upload _n_ times.
q = Queue()
flush_interval = 0.3
consumer = Consumer(q, TEST_API_KEY, flush_at=10,
flush_interval=flush_interval)
with mock.patch('posthog.consumer.post') as mock_post:
consumer = Consumer(q, TEST_API_KEY, flush_at=10, flush_interval=flush_interval)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(0, 3):
track = {
'type': 'track',
'event': 'python event %d' % i,
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 3)
@@ -78,45 +69,30 @@ class TestConsumer(unittest.TestCase):
q = Queue()
flush_interval = 0.5
flush_at = 10
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at,
flush_interval=flush_interval)
with mock.patch('posthog.consumer.post') as mock_post:
consumer = Consumer(q, TEST_API_KEY, flush_at=flush_at, flush_interval=flush_interval)
with mock.patch("posthog.consumer.batch_post") as mock_post:
consumer.start()
for i in range(0, flush_at * 2):
track = {
'type': 'track',
'event': 'python event %d' % i,
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event %d" % i, "distinct_id": "distinct_id"}
q.put(track)
time.sleep(flush_interval * 1.1)
self.assertEqual(mock_post.call_count, 2)
def test_request(self):
consumer = Consumer(None, TEST_API_KEY)
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
consumer.request([track])
def _test_request_retry(self, consumer,
expected_exception, exception_count):
def _test_request_retry(self, consumer, expected_exception, exception_count):
def mock_post(*args, **kwargs):
mock_post.call_count += 1
if mock_post.call_count <= exception_count:
raise expected_exception
mock_post.call_count = 0
with mock.patch('posthog.consumer.post',
mock.Mock(side_effect=mock_post)):
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
with mock.patch("posthog.consumer.batch_post", mock.Mock(side_effect=mock_post)):
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id"}
# request() should succeed if the number of exceptions raised is
# less than the retries paramater.
if exception_count <= consumer.retries:
@@ -131,38 +107,35 @@ class TestConsumer(unittest.TestCase):
self.assertEqual(exc, expected_exception)
else:
self.fail(
"request() should raise an exception if still failing "
"after %d retries" % consumer.retries)
"request() should raise an exception if still failing after %d retries" % consumer.retries
)
def test_request_retry(self):
# we should retry on general errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, Exception('generic exception'), 2)
self._test_request_retry(consumer, Exception("generic exception"), 2)
# we should retry on server errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(
500, 'code', 'Internal Server Error'), 2)
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 2)
# we should retry on HTTP 429 errors
consumer = Consumer(None, TEST_API_KEY)
self._test_request_retry(consumer, APIError(
429, 'code', 'Too Many Requests'), 2)
self._test_request_retry(consumer, APIError(429, "Too Many Requests"), 2)
# we should NOT retry on other client errors
consumer = Consumer(None, TEST_API_KEY)
api_error = APIError(400, 'code', 'Client Errors')
api_error = APIError(400, "Client Errors")
try:
self._test_request_retry(consumer, api_error, 1)
except APIError:
pass
else:
self.fail('request() should not retry on client errors')
self.fail("request() should not retry on client errors")
# test for number of exceptions raise > retries value
consumer = Consumer(None, TEST_API_KEY, retries=3)
self._test_request_retry(consumer, APIError(
500, 'code', 'Internal Server Error'), 3)
self._test_request_retry(consumer, APIError(500, "Internal Server Error"), 3)
def test_pause(self):
consumer = Consumer(None, TEST_API_KEY)
@@ -171,29 +144,26 @@ class TestConsumer(unittest.TestCase):
def test_max_batch_size(self):
q = Queue()
consumer = Consumer(
q, TEST_API_KEY, flush_at=100000, flush_interval=3)
track = {
'type': 'track',
'event': 'python event',
'distinct_id': 'distinct_id'
}
consumer = Consumer(q, TEST_API_KEY, flush_at=100000, flush_interval=3)
properties = {}
for n in range(0, 500):
properties[str(n)] = "one_long_property_value_to_build_a_big_event"
track = {"type": "track", "event": "python event", "distinct_id": "distinct_id", "properties": properties}
msg_size = len(json.dumps(track).encode())
# number of messages in a maximum-size batch
n_msgs = int(475000 / msg_size)
# Let's capture 8MB of data to trigger two batches
n_msgs = int(8_000_000 / msg_size)
def mock_post_fn(_, data, **kwargs):
res = mock.Mock()
res.status_code = 200
self.assertTrue(len(data.encode()) < 500000,
'batch size (%d) exceeds 500KB limit'
% len(data.encode()))
request_size = len(data.encode())
# Batches close after the first message bringing it bigger than BATCH_SIZE_LIMIT, let's add 10% of margin
self.assertTrue(request_size < (5 * 1024 * 1024) * 1.1, "batch size (%d) higher than limit" % request_size)
return res
with mock.patch('posthog.request._session.post',
side_effect=mock_post_fn) as mock_post:
with mock.patch("posthog.request._session.post", side_effect=mock_post_fn) as mock_post:
consumer.start()
for _ in range(0, n_msgs + 2):
q.put(track)
q.join()
self.assertEquals(mock_post.call_count, 2)
self.assertEqual(mock_post.call_count, 2)
+63
View File
@@ -0,0 +1,63 @@
import subprocess
import sys
from textwrap import dedent
import pytest
def test_excepthook(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
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"
1/0
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"LOL" 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
)
def test_trying_to_use_django_integration(tmpdir):
app = tmpdir.join("app.py")
app.write(
dedent(
"""
from posthog import Posthog, Integrations
posthog = Posthog('phc_x', host='https://eu.i.posthog.com', enable_exception_autocapture=True, exception_autocapture_integrations=[Integrations.Django], debug=True, on_error=lambda e, batch: print('error handling batch: ', e, batch))
# frame_value = "LOL"
1/0
"""
)
)
with pytest.raises(subprocess.CalledProcessError) as excinfo:
subprocess.check_output([sys.executable, str(app)], stderr=subprocess.STDOUT)
output = excinfo.value.output
assert b"ZeroDivisionError" in output
assert b"LOL" 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
)
+142
View File
@@ -0,0 +1,142 @@
import unittest
from posthog.types import FeatureFlag, FlagMetadata, FlagReason, LegacyFlagMetadata
class TestFeatureFlag(unittest.TestCase):
def test_feature_flag_from_json(self):
# Test with full metadata
resp = {
"key": "test-flag",
"enabled": True,
"variant": "test-variant",
"reason": {"code": "matched_condition", "condition_index": 0, "description": "Matched condition set 1"},
"metadata": {"id": 1, "payload": '{"some": "json"}', "version": 2, "description": "test-description"},
}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertTrue(flag.enabled)
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertEqual(
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
)
self.assertEqual(
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
)
def test_feature_flag_from_json_minimal(self):
# Test with minimal required fields
resp = {"key": "test-flag", "enabled": True}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertTrue(flag.enabled)
self.assertIsNone(flag.variant)
self.assertEqual(flag.get_value(), True)
self.assertIsNone(flag.reason)
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
def test_feature_flag_from_json_without_metadata(self):
# Test with reason but no metadata
resp = {
"key": "test-flag",
"enabled": True,
"variant": "test-variant",
"reason": {"code": "matched_condition", "condition_index": 0, "description": "Matched condition set 1"},
}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertTrue(flag.enabled)
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertEqual(
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
)
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
def test_flag_reason_from_json(self):
# Test with complete data
resp = {"code": "user_in_segment", "condition_index": 1, "description": "User is in segment 'beta_users'"}
reason = FlagReason.from_json(resp)
self.assertEqual(reason.code, "user_in_segment")
self.assertEqual(reason.condition_index, 1)
self.assertEqual(reason.description, "User is in segment 'beta_users'")
# Test with partial data
resp = {"code": "user_in_segment"}
reason = FlagReason.from_json(resp)
self.assertEqual(reason.code, "user_in_segment")
self.assertIsNone(reason.condition_index) # default value
self.assertEqual(reason.description, "")
# Test with None
self.assertIsNone(FlagReason.from_json(None))
def test_flag_metadata_from_json(self):
# Test with complete data
resp = {"id": 123, "payload": {"key": "value"}, "version": 1, "description": "Test flag"}
metadata = FlagMetadata.from_json(resp)
self.assertEqual(metadata.id, 123)
self.assertEqual(metadata.payload, {"key": "value"})
self.assertEqual(metadata.version, 1)
self.assertEqual(metadata.description, "Test flag")
# Test with partial data
resp = {"id": 123}
metadata = FlagMetadata.from_json(resp)
self.assertEqual(metadata.id, 123)
self.assertIsNone(metadata.payload)
self.assertEqual(metadata.version, 0) # default value
self.assertEqual(metadata.description, "") # default value
# Test with None
self.assertIsInstance(FlagMetadata.from_json(None), LegacyFlagMetadata)
def test_feature_flag_from_json_complete(self):
# Test with complete data
resp = {
"key": "test-flag",
"enabled": True,
"variant": "control",
"reason": {
"code": "user_in_segment",
"condition_index": 1,
"description": "User is in segment 'beta_users'",
},
"metadata": {"id": 123, "payload": {"key": "value"}, "version": 1, "description": "Test flag"},
}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertTrue(flag.enabled)
self.assertEqual(flag.variant, "control")
self.assertIsInstance(flag.reason, FlagReason)
self.assertEqual(flag.reason.code, "user_in_segment")
self.assertIsInstance(flag.metadata, FlagMetadata)
self.assertEqual(flag.metadata.id, 123)
self.assertEqual(flag.metadata.payload, {"key": "value"})
def test_feature_flag_from_json_minimal_data(self):
# Test with minimal data
resp = {"key": "test-flag", "enabled": False}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertFalse(flag.enabled)
self.assertIsNone(flag.variant)
self.assertIsNone(flag.reason)
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
self.assertIsNone(flag.metadata.payload)
def test_feature_flag_from_json_with_reason(self):
# Test with reason but no metadata
resp = {"key": "test-flag", "enabled": True, "reason": {"code": "user_in_segment"}}
flag = FeatureFlag.from_json(resp)
self.assertEqual(flag.key, "test-flag")
self.assertTrue(flag.enabled)
self.assertIsNone(flag.variant)
self.assertIsInstance(flag.reason, FlagReason)
self.assertEqual(flag.reason.code, "user_in_segment")
self.assertIsInstance(flag.metadata, LegacyFlagMetadata)
self.assertIsNone(flag.metadata.payload)
+402
View File
@@ -0,0 +1,402 @@
import unittest
import mock
from posthog.client import Client
from posthog.test.test_utils import FAKE_TEST_API_KEY
from posthog.types import FeatureFlag, FeatureFlagResult, FlagMetadata, FlagReason
class TestFeatureFlagResult(unittest.TestCase):
def test_from_bool_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", True, "[1, 2, 3]")
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, [1, 2, 3])
def test_from_false_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", False, '{"some": "value"}')
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, False)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, {"some": "value"})
def test_from_variant_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", "control", "true")
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, "control")
self.assertEqual(result.payload, True)
def test_from_none_value_and_payload(self):
result = FeatureFlagResult.from_value_and_payload("test-flag", None, '{"some": "value"}')
self.assertIsNone(result)
def test_from_boolean_flag_details(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, "Some string")
def test_from_boolean_flag_details_with_override_variant_match_value(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='"Some string"'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value="control")
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, "control")
self.assertEqual(result.payload, "Some string")
def test_from_boolean_flag_details_with_override_boolean_match_value(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=True)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, {"some": "value"})
def test_from_boolean_flag_details_with_override_false_match_value(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details, override_match_value=False)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, False)
self.assertEqual(result.variant, None)
self.assertEqual(result.payload, {"some": "value"})
def test_from_variant_flag_details(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant="control",
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload='{"some": "value"}'),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, "control")
self.assertEqual(result.payload, {"some": "value"})
def test_from_none_flag_details(self):
result = FeatureFlagResult.from_flag_details(None)
self.assertIsNone(result)
def test_from_flag_details_with_none_payload(self):
flag_details = FeatureFlag(
key="test-flag",
enabled=True,
variant=None,
metadata=FlagMetadata(id=1, version=1, description="test-flag", payload=None),
reason=FlagReason(code="test-reason", description="test-reason", condition_index=0),
)
result = FeatureFlagResult.from_flag_details(flag_details)
self.assertEqual(result.key, "test-flag")
self.assertEqual(result.enabled, True)
self.assertEqual(result.variant, None)
self.assertIsNone(result.payload)
class TestGetFeatureFlagResult(unittest.TestCase):
@classmethod
def setUpClass(cls):
# This ensures no real HTTP POST requests are made
cls.capture_patch = mock.patch.object(Client, "capture")
cls.capture_patch.start()
@classmethod
def tearDownClass(cls):
cls.capture_patch.stop()
def set_fail(self, e, batch):
"""Mark the failure handler"""
print("FAIL", e, batch) # noqa: T201
self.failed = True
def setUp(self):
self.failed = False
self.client = Client(FAKE_TEST_API_KEY, on_error=self.set_fail)
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_boolean_local_evaluation(self, patch_capture):
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"payloads": {"true": "300"},
},
}
self.client.feature_flags = [basic_flag]
flag_result = self.client.get_feature_flag_result(
"person-flag", "some-distinct-id", person_properties={"region": "USA"}
)
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": True,
"$feature/person-flag": True,
"$feature_flag_payload": 300,
},
groups={},
disable_geoip=None,
)
@mock.patch.object(Client, "capture")
def test_get_feature_flag_result_variant_local_evaluation(self, patch_capture):
basic_flag = {
"id": 1,
"name": "Beta Feature",
"key": "person-flag",
"active": True,
"filters": {
"groups": [
{
"properties": [
{
"key": "region",
"operator": "exact",
"value": ["USA"],
"type": "person",
}
],
"rollout_percentage": 100,
}
],
"multivariate": {
"variants": [
{"key": "variant-1", "rollout_percentage": 50},
{"key": "variant-2", "rollout_percentage": 50},
]
},
"payloads": {"variant-1": '{"some": "value"}'},
},
}
self.client.feature_flags = [basic_flag]
flag_result = self.client.get_feature_flag_result(
"person-flag", "distinct_id", person_properties={"region": "USA"}
)
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, "variant-1")
self.assertEqual(flag_result.get_value(), "variant-1")
self.assertEqual(flag_result.payload, {"some": "value"})
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": True,
"$feature/person-flag": "variant-1",
"$feature_flag_payload": {"some": "value"},
},
groups={},
disable_geoip=None,
)
another_flag_result = self.client.get_feature_flag_result(
"person-flag", "another-distinct-id", person_properties={"region": "USA"}
)
self.assertEqual(another_flag_result.enabled, True)
self.assertEqual(another_flag_result.variant, "variant-2")
self.assertEqual(another_flag_result.get_value(), "variant-2")
self.assertIsNone(another_flag_result.payload)
patch_capture.assert_called_with(
"another-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-2",
"locally_evaluated": True,
"$feature/person-flag": "variant-2",
},
groups={},
disable_geoip=None,
)
@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 = {
"flags": {
"person-flag": {
"key": "person-flag",
"enabled": True,
"variant": None,
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 23,
"version": 42,
"payload": "300",
},
},
},
}
flag_result = self.client.get_feature_flag_result("person-flag", "some-distinct-id")
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, None)
self.assertEqual(flag_result.payload, 300)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": True,
"locally_evaluated": False,
"$feature/person-flag": True,
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 23,
"$feature_flag_version": 42,
"$feature_flag_payload": 300,
},
groups={},
disable_geoip=None,
)
@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 = {
"flags": {
"person-flag": {
"key": "person-flag",
"enabled": True,
"variant": "variant-1",
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 1,
"version": 2,
"payload": "[1, 2, 3]",
},
},
},
}
flag_result = self.client.get_feature_flag_result("person-flag", "distinct_id")
self.assertEqual(flag_result.enabled, True)
self.assertEqual(flag_result.variant, "variant-1")
self.assertEqual(flag_result.get_value(), "variant-1")
self.assertEqual(flag_result.payload, [1, 2, 3])
patch_capture.assert_called_with(
"distinct_id",
"$feature_flag_called",
{
"$feature_flag": "person-flag",
"$feature_flag_response": "variant-1",
"locally_evaluated": False,
"$feature/person-flag": "variant-1",
"$feature_flag_reason": "Matched condition set 1",
"$feature_flag_id": 1,
"$feature_flag_version": 2,
"$feature_flag_payload": [1, 2, 3],
},
groups={},
disable_geoip=None,
)
@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 = {
"flags": {
"person-flag": {
"key": "person-flag",
"enabled": True,
"variant": None,
"reason": {
"description": "Matched condition set 1",
},
"metadata": {
"id": 23,
"version": 42,
"payload": "300",
},
},
},
}
flag_result = self.client.get_feature_flag_result("no-person-flag", "some-distinct-id")
self.assertIsNone(flag_result)
patch_capture.assert_called_with(
"some-distinct-id",
"$feature_flag_called",
{
"$feature_flag": "no-person-flag",
"$feature_flag_response": None,
"locally_evaluated": False,
"$feature/no-person-flag": None,
},
groups={},
disable_geoip=None,
)
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
import unittest
from posthog import Posthog
class TestModule(unittest.TestCase):
posthog = None
def _assert_enqueue_result(self, result):
self.assertEqual(type(result[0]), bool)
self.assertEqual(type(result[1]), dict)
def failed(self):
self.failed = True
def setUp(self):
self.failed = False
self.posthog = Posthog("testsecret", host="http://localhost:8000", on_error=self.failed)
def test_no_api_key(self):
self.posthog.api_key = None
self.assertRaises(Exception, self.posthog.capture)
def test_no_host(self):
self.posthog.host = None
self.assertRaises(Exception, self.posthog.capture)
def test_track(self):
res = self.posthog.capture("distinct_id", "python module event")
self._assert_enqueue_result(res)
self.posthog.flush()
def test_identify(self):
res = self.posthog.identify("distinct_id", {"email": "user@email.com"})
self._assert_enqueue_result(res)
self.posthog.flush()
def test_alias(self):
res = self.posthog.alias("previousId", "distinct_id")
self._assert_enqueue_result(res)
self.posthog.flush()
def test_page(self):
self.posthog.page("distinct_id", "https://posthog.com/contact")
self.posthog.flush()
def test_flush(self):
self.posthog.flush()
+99
View File
@@ -0,0 +1,99 @@
import json
import unittest
from datetime import date, datetime
import mock
import pytest
import requests
from posthog.request import DatetimeSerializer, QuotaLimitError, batch_post, decide, determine_server_host
from posthog.test.test_utils import TEST_API_KEY
class TestRequests(unittest.TestCase):
def test_valid_request(self):
res = batch_post(TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}])
self.assertEqual(res.status_code, 200)
def test_invalid_request_error(self):
self.assertRaises(Exception, batch_post, "testsecret", "https://t.posthog.com", False, "[{]")
def test_invalid_host(self):
self.assertRaises(Exception, batch_post, "testsecret", "t.posthog.com/", batch=[])
def test_datetime_serialization(self):
data = {"created": datetime(2012, 3, 4, 5, 6, 7, 891011)}
result = json.dumps(data, cls=DatetimeSerializer)
self.assertEqual(result, '{"created": "2012-03-04T05:06:07.891011"}')
def test_date_serialization(self):
today = date.today()
data = {"created": today}
result = json.dumps(data, cls=DatetimeSerializer)
expected = '{"created": "%s"}' % today.isoformat()
self.assertEqual(result, expected)
def test_should_not_timeout(self):
res = batch_post(
TEST_API_KEY, batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=15
)
self.assertEqual(res.status_code, 200)
def test_should_timeout(self):
with self.assertRaises(requests.ReadTimeout):
batch_post(
"key", batch=[{"distinct_id": "distinct_id", "event": "python event", "type": "track"}], timeout=0.0001
)
def test_quota_limited_response(self):
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{
"quotaLimited": ["feature_flags"],
"featureFlags": {},
"featureFlagPayloads": {},
"errorsWhileComputingFlags": False,
}
).encode("utf-8")
with mock.patch("posthog.request._session.post", return_value=mock_response):
with self.assertRaises(QuotaLimitError) as cm:
decide("fake_key", "fake_host")
self.assertEqual(cm.exception.status, 200)
self.assertEqual(cm.exception.message, "Feature flags quota limited")
def test_normal_decide_response(self):
mock_response = requests.Response()
mock_response.status_code = 200
mock_response._content = json.dumps(
{"featureFlags": {"flag1": True}, "featureFlagPayloads": {}, "errorsWhileComputingFlags": False}
).encode("utf-8")
with mock.patch("posthog.request._session.post", return_value=mock_response):
response = decide("fake_key", "fake_host")
self.assertEqual(response["featureFlags"], {"flag1": True})
@pytest.mark.parametrize(
"host, expected",
[
("https://t.posthog.com", "https://t.posthog.com"),
("https://t.posthog.com/", "https://t.posthog.com/"),
("t.posthog.com", "t.posthog.com"),
("t.posthog.com/", "t.posthog.com/"),
("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.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):
assert determine_server_host(host) == expected
+175
View File
@@ -0,0 +1,175 @@
import unittest
from parameterized import parameterized
from posthog.types import (
FeatureFlag,
FlagMetadata,
FlagReason,
LegacyFlagMetadata,
normalize_flags_response,
to_flags_and_payloads,
)
class TestTypes(unittest.TestCase):
@parameterized.expand([(True,), (False,)])
def test_normalize_decide_response_v4(self, has_errors: bool):
resp = {
"flags": {
"my-flag": FeatureFlag(
key="my-flag",
enabled=True,
variant="test-variant",
reason=FlagReason(
code="matched_condition", condition_index=0, description="Matched condition set 1"
),
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
)
},
"errorsWhileComputingFlags": has_errors,
"requestId": "test-id",
}
result = normalize_flags_response(resp)
flag = result["flags"]["my-flag"]
self.assertEqual(flag.key, "my-flag")
self.assertTrue(flag.enabled)
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertEqual(
flag.reason, FlagReason(code="matched_condition", condition_index=0, description="Matched condition set 1")
)
self.assertEqual(
flag.metadata, FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description")
)
self.assertEqual(result["errorsWhileComputingFlags"], has_errors)
self.assertEqual(result["requestId"], "test-id")
def test_normalize_decide_response_legacy(self):
# Test legacy response format with "featureFlags" and "featureFlagPayloads"
resp = {
"featureFlags": {"my-flag": "test-variant"},
"featureFlagPayloads": {"my-flag": '{"some": "json-payload"}'},
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
result = normalize_flags_response(resp)
flag = result["flags"]["my-flag"]
self.assertEqual(flag.key, "my-flag")
self.assertTrue(flag.enabled)
self.assertEqual(flag.variant, "test-variant")
self.assertEqual(flag.get_value(), "test-variant")
self.assertIsNone(flag.reason)
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload='{"some": "json-payload"}'))
self.assertFalse(result["errorsWhileComputingFlags"])
self.assertEqual(result["requestId"], "test-id")
# Verify legacy fields are removed
self.assertNotIn("featureFlags", result)
self.assertNotIn("featureFlagPayloads", result)
def test_normalize_decide_response_boolean_flag(self):
# Test legacy response with boolean flag
resp = {"featureFlags": {"my-flag": True}, "errorsWhileComputingFlags": False}
result = normalize_flags_response(resp)
self.assertIn("requestId", result)
self.assertIsNone(result["requestId"])
flag = result["flags"]["my-flag"]
self.assertEqual(flag.key, "my-flag")
self.assertTrue(flag.enabled)
self.assertIsNone(flag.variant)
self.assertIsNone(flag.reason)
self.assertEqual(flag.metadata, LegacyFlagMetadata(payload=None))
self.assertFalse(result["errorsWhileComputingFlags"])
self.assertNotIn("featureFlags", result)
self.assertNotIn("featureFlagPayloads", result)
def test_to_flags_and_payloads_v4(self):
# Test v4 response format
resp = {
"flags": {
"my-variant-flag": FeatureFlag(
key="my-variant-flag",
enabled=True,
variant="test-variant",
reason=FlagReason(
code="matched_condition", condition_index=0, description="Matched condition set 1"
),
metadata=FlagMetadata(id=1, payload='{"some": "json"}', version=2, description="test-description"),
),
"my-boolean-flag": FeatureFlag(
key="my-boolean-flag",
enabled=True,
variant=None,
reason=FlagReason(
code="matched_condition", condition_index=0, description="Matched condition set 1"
),
metadata=FlagMetadata(id=1, payload=None, version=2, description="test-description"),
),
"disabled-flag": FeatureFlag(
key="disabled-flag",
enabled=False,
variant=None,
reason=None,
metadata=LegacyFlagMetadata(payload=None),
),
},
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
result = to_flags_and_payloads(resp)
self.assertEqual(result["featureFlags"]["my-variant-flag"], "test-variant")
self.assertEqual(result["featureFlags"]["my-boolean-flag"], True)
self.assertEqual(result["featureFlags"]["disabled-flag"], False)
self.assertEqual(result["featureFlagPayloads"]["my-variant-flag"], '{"some": "json"}')
self.assertNotIn("my-boolean-flag", result["featureFlagPayloads"])
self.assertNotIn("disabled-flag", result["featureFlagPayloads"])
def test_to_flags_and_payloads_empty(self):
# Test empty response
resp = {
"flags": {},
"errorsWhileComputingFlags": False,
"requestId": "test-id",
}
result = to_flags_and_payloads(resp)
self.assertEqual(result["featureFlags"], {})
self.assertEqual(result["featureFlagPayloads"], {})
def test_to_flags_and_payloads_with_payload(self):
resp = {
"flags": {
"decide-flag": {
"key": "decide-flag",
"enabled": True,
"variant": "decide-variant",
"reason": {
"code": "matched_condition",
"condition_index": 0,
"description": "Matched condition set 1",
},
"metadata": {
"id": 23,
"version": 42,
"payload": '{"foo": "bar"}',
},
}
},
"requestId": "18043bf7-9cf6-44cd-b959-9662ee20d371",
}
normalized = normalize_flags_response(resp)
result = to_flags_and_payloads(normalized)
self.assertEqual(result["featureFlags"]["decide-flag"], "decide-variant")
self.assertEqual(result["featureFlagPayloads"]["decide-flag"], '{"foo": "bar"}')
+176
View File
@@ -0,0 +1,176 @@
import unittest
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from decimal import Decimal
from typing import Optional
from uuid import UUID
import six
from dateutil.tz import tzutc
from pydantic import BaseModel
from pydantic.v1 import BaseModel as BaseModelV1
from posthog import utils
TEST_API_KEY = "kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4"
FAKE_TEST_API_KEY = "random_key"
class TestUtils(unittest.TestCase):
def test_timezone_utils(self):
now = datetime.now()
utcnow = datetime.now(tz=tzutc())
self.assertTrue(utils.is_naive(now))
self.assertFalse(utils.is_naive(utcnow))
fixed = utils.guess_timezone(now)
self.assertFalse(utils.is_naive(fixed))
shouldnt_be_edited = utils.guess_timezone(utcnow)
self.assertEqual(utcnow, shouldnt_be_edited)
def test_clean(self):
simple = {
"decimal": Decimal("0.142857"),
"unicode": six.u("woo"),
"date": datetime.now(),
"long": 200000000,
"integer": 1,
"float": 2.0,
"bool": True,
"str": "woo",
"none": None,
}
complicated = {
"exception": Exception("This should show up"),
"timedelta": timedelta(microseconds=20),
"list": [1, 2, 3],
}
combined = dict(simple.items())
combined.update(complicated.items())
pre_clean_keys = combined.keys()
utils.clean(combined)
self.assertEqual(combined.keys(), pre_clean_keys)
# test UUID separately, as the UUID object doesn't equal its string representation according to Python
self.assertEqual(
utils.clean(UUID("12345678123456781234567812345678")),
"12345678-1234-5678-1234-567812345678",
)
def test_clean_with_dates(self):
dict_with_dates = {
"birthdate": date(1980, 1, 1),
"registration": datetime.now(tz=tzutc()),
}
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
def test_bytes(self):
if six.PY3:
item = bytes(10)
else:
item = bytearray(10)
utils.clean(item)
def test_clean_fn(self):
cleaned = utils.clean({"fn": lambda x: x, "number": 4})
self.assertEqual(cleaned["number"], 4)
# TODO: fixme, different behavior on python 2 and 3
if "fn" in cleaned:
self.assertEqual(cleaned["fn"], None)
def test_remove_slash(self):
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io/"))
self.assertEqual("http://posthog.io", utils.remove_trailing_slash("http://posthog.io"))
def test_clean_pydantic(self):
class ModelV2(BaseModel):
foo: str
bar: int
baz: Optional[str] = None
class ModelV1(BaseModelV1):
foo: int
bar: str
class NestedModel(BaseModel):
foo: ModelV2
self.assertEqual(utils.clean(ModelV2(foo="1", bar=2)), {"foo": "1", "bar": 2, "baz": None})
self.assertEqual(utils.clean(ModelV1(foo=1, bar="2")), {"foo": 1, "bar": "2"})
self.assertEqual(
utils.clean(NestedModel(foo=ModelV2(foo="1", bar=2, baz="3"))),
{"foo": {"foo": "1", "bar": 2, "baz": "3"}},
)
class Dummy:
def model_dump(self, required_param):
pass
# Skips a class with a defined non-Pydantic `model_dump` method.
self.assertEqual(utils.clean({"test": Dummy()}), {})
def test_clean_dataclass(self):
@dataclass
class InnerDataClass:
inner_foo: str
inner_bar: int
inner_uuid: UUID
inner_date: datetime
inner_optional: Optional[str] = None
@dataclass
class TestDataClass:
foo: str
bar: int
nested: InnerDataClass
self.assertEqual(
utils.clean(
TestDataClass(
foo="1",
bar=2,
nested=InnerDataClass(
inner_foo="3",
inner_bar=4,
inner_uuid=UUID("12345678123456781234567812345678"),
inner_date=datetime(2025, 1, 1),
),
)
),
{
"foo": "1",
"bar": 2,
"nested": {
"inner_foo": "3",
"inner_bar": 4,
"inner_uuid": "12345678-1234-5678-1234-567812345678",
"inner_date": datetime(2025, 1, 1),
"inner_optional": None,
},
},
)
class TestSizeLimitedDict(unittest.TestCase):
def test_size_limited_dict(self):
size = 10
values = utils.SizeLimitedDict(size, lambda _: -1)
for i in range(100):
values[i] = i
self.assertEqual(values[i], i)
self.assertEqual(len(values), i % size + 1)
if i % size == 0:
# old numbers should've been removed
self.assertIsNone(values.get(i - 1))
self.assertIsNone(values.get(i - 3))
self.assertIsNone(values.get(i - 5))
self.assertIsNone(values.get(i - 9))
-79
View File
@@ -1,79 +0,0 @@
from datetime import date, datetime, timedelta
from decimal import Decimal
import unittest
from dateutil.tz import tzutc
import six
from posthog import utils
TEST_API_KEY = 'kOOlRy2QlMY9jHZQv0bKz0FZyazBUoY8Arj0lFVNjs4'
class TestUtils(unittest.TestCase):
def test_timezone_utils(self):
now = datetime.now()
utcnow = datetime.now(tz=tzutc())
self.assertTrue(utils.is_naive(now))
self.assertFalse(utils.is_naive(utcnow))
fixed = utils.guess_timezone(now)
self.assertFalse(utils.is_naive(fixed))
shouldnt_be_edited = utils.guess_timezone(utcnow)
self.assertEqual(utcnow, shouldnt_be_edited)
def test_clean(self):
simple = {
'decimal': Decimal('0.142857'),
'unicode': six.u('woo'),
'date': datetime.now(),
'long': 200000000,
'integer': 1,
'float': 2.0,
'bool': True,
'str': 'woo',
'none': None
}
complicated = {
'exception': Exception('This should show up'),
'timedelta': timedelta(microseconds=20),
'list': [1, 2, 3]
}
combined = dict(simple.items())
combined.update(complicated.items())
pre_clean_keys = combined.keys()
utils.clean(combined)
self.assertEqual(combined.keys(), pre_clean_keys)
def test_clean_with_dates(self):
dict_with_dates = {
'birthdate': date(1980, 1, 1),
'registration': datetime.utcnow(),
}
self.assertEqual(dict_with_dates, utils.clean(dict_with_dates))
def test_bytes(self):
if six.PY3:
item = bytes(10)
else:
item = bytearray(10)
utils.clean(item)
def test_clean_fn(self):
cleaned = utils.clean({'fn': lambda x: x, 'number': 4})
self.assertEqual(cleaned['number'], 4)
# TODO: fixme, different behavior on python 2 and 3
if 'fn' in cleaned:
self.assertEqual(cleaned['fn'], None)
def test_remove_slash(self):
self.assertEqual('http://posthog.io',
utils.remove_trailing_slash('http://posthog.io/'))
self.assertEqual('http://posthog.io',
utils.remove_trailing_slash('http://posthog.io'))
+266
View File
@@ -0,0 +1,266 @@
import json
from dataclasses import dataclass
from typing import Any, List, Optional, TypedDict, Union, cast
FlagValue = Union[bool, str]
@dataclass(frozen=True)
class FlagReason:
code: str
condition_index: Optional[int]
description: str
@classmethod
def from_json(cls, resp: Any) -> Optional["FlagReason"]:
if not resp:
return None
return cls(
code=resp.get("code", ""),
condition_index=resp.get("condition_index"),
description=resp.get("description", ""),
)
@dataclass(frozen=True)
class LegacyFlagMetadata:
payload: Any
@dataclass(frozen=True)
class FlagMetadata:
id: int
payload: Optional[str]
version: int
description: str
@classmethod
def from_json(cls, resp: Any) -> Union["FlagMetadata", LegacyFlagMetadata]:
if not resp:
return LegacyFlagMetadata(payload=None)
return cls(
id=resp.get("id", 0),
payload=resp.get("payload"),
version=resp.get("version", 0),
description=resp.get("description", ""),
)
@dataclass(frozen=True)
class FeatureFlag:
key: str
enabled: bool
variant: Optional[str]
reason: Optional[FlagReason]
metadata: Union[FlagMetadata, LegacyFlagMetadata]
def get_value(self) -> FlagValue:
return self.variant or self.enabled
@classmethod
def from_json(cls, resp: Any) -> "FeatureFlag":
reason = None
if resp.get("reason"):
reason = FlagReason.from_json(resp.get("reason"))
metadata = None
if resp.get("metadata"):
metadata = FlagMetadata.from_json(resp.get("metadata"))
else:
metadata = LegacyFlagMetadata(payload=None)
return cls(
key=resp.get("key"),
enabled=resp.get("enabled"),
variant=resp.get("variant"),
reason=reason,
metadata=metadata,
)
@classmethod
def from_value_and_payload(cls, key: str, value: FlagValue, payload: Any) -> "FeatureFlag":
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
return cls(
key=key,
enabled=enabled,
variant=variant,
reason=None,
metadata=LegacyFlagMetadata(
payload=payload if payload else None,
),
)
class FlagsResponse(TypedDict, total=False):
flags: dict[str, FeatureFlag]
errorsWhileComputingFlags: bool
requestId: str
quotaLimit: Optional[List[str]]
class FlagsAndPayloads(TypedDict, total=True):
featureFlags: Optional[dict[str, FlagValue]]
featureFlagPayloads: Optional[dict[str, Any]]
@dataclass(frozen=True)
class FeatureFlagResult:
"""
The result of calling a feature flag which includes the flag result, variant, and payload.
Attributes:
key (str): The unique identifier of the feature flag.
enabled (bool): Whether the feature flag is enabled for the current context.
variant (Optional[str]): The variant value if the flag is enabled and has variants, None otherwise.
payload (Optional[Any]): Additional data associated with the feature flag, if any.
reason (Optional[str]): A description of why the flag was enabled or disabled, if available.
"""
key: str
enabled: bool
variant: Optional[str]
payload: Optional[Any]
reason: Optional[str]
def get_value(self) -> FlagValue:
"""
Returns the value of the flag. This is the variant if it exists, otherwise the enabled value.
This is the value we report as `$feature_flag_response` in the `$feature_flag_called` event.
Returns:
FlagValue: Either a string variant or boolean value representing the flag's state.
"""
return self.variant or self.enabled
@classmethod
def from_value_and_payload(
cls, key: str, value: Union[FlagValue, None], payload: Any
) -> Union["FeatureFlagResult", None]:
"""
Creates a FeatureFlagResult from a flag value and payload.
Args:
key (str): The unique identifier of the feature flag.
value (Union[FlagValue, None]): The value of the flag (string variant or boolean).
payload (Any): Additional data associated with the feature flag.
Returns:
Union[FeatureFlagResult, None]: A new FeatureFlagResult instance, or None if value is None.
"""
if value is None:
return None
enabled, variant = (True, value) if isinstance(value, str) else (value, None)
return cls(
key=key,
enabled=enabled,
variant=variant,
payload=json.loads(payload) if isinstance(payload, str) else payload,
reason=None,
)
@classmethod
def from_flag_details(
cls, details: Union[FeatureFlag, None], override_match_value: Optional[FlagValue] = None
) -> "FeatureFlagResult | None":
"""
Create a FeatureFlagResult from a FeatureFlag object.
Args:
details (Union[FeatureFlag, None]): The FeatureFlag object to convert.
override_match_value (Optional[FlagValue]): If provided, this value will be used to populate
the enabled and variant fields instead of the values from the FeatureFlag.
Returns:
FeatureFlagResult | None: A new FeatureFlagResult instance, or None if details is None.
"""
if details is None:
return None
if override_match_value is not None:
enabled, variant = (
(True, override_match_value) if isinstance(override_match_value, str) else (override_match_value, None)
)
else:
enabled, variant = (details.enabled, details.variant)
return cls(
key=details.key,
enabled=enabled,
variant=variant,
payload=(
json.loads(details.metadata.payload)
if isinstance(details.metadata.payload, str)
else details.metadata.payload
),
reason=details.reason.description if details.reason else None,
)
def normalize_flags_response(resp: Any) -> FlagsResponse:
"""
Normalize the response from the decide or flags API endpoint into a FlagsResponse.
Args:
resp: A v3 or v4 response from the decide (or a v1 or v2 response from the flags) API endpoint.
Returns:
A FlagsResponse containing feature flags and their details.
"""
if "requestId" not in resp:
resp["requestId"] = None
if "flags" in resp:
flags = resp["flags"]
# For each flag, create a FeatureFlag object
for key, value in flags.items():
if isinstance(value, FeatureFlag):
continue
value["key"] = key
flags[key] = FeatureFlag.from_json(value)
else:
# Handle legacy format
featureFlags = resp.get("featureFlags", {})
featureFlagPayloads = resp.get("featureFlagPayloads", {})
resp.pop("featureFlags", None)
resp.pop("featureFlagPayloads", None)
# look at each key in featureFlags and create a FeatureFlag object
flags = {}
for key, value in featureFlags.items():
flags[key] = FeatureFlag.from_value_and_payload(key, value, featureFlagPayloads.get(key, None))
resp["flags"] = flags
return cast(FlagsResponse, resp)
def to_flags_and_payloads(resp: FlagsResponse) -> FlagsAndPayloads:
"""
Convert a FlagsResponse into a FlagsAndPayloads object which is a
dict of feature flags and their payloads. This is needed by certain
functions in the client.
Args:
resp: A FlagsResponse containing feature flags and their payloads.
Returns:
A tuple containing:
- A dictionary mapping flag keys to their values (bool or str)
- A dictionary mapping flag keys to their payloads
"""
return {"featureFlags": to_values(resp), "featureFlagPayloads": to_payloads(resp)}
def to_values(response: FlagsResponse) -> Optional[dict[str, FlagValue]]:
if "flags" not in response:
return None
flags = response.get("flags", {})
return {key: value.get_value() for key, value in flags.items() if isinstance(value, FeatureFlag)}
def to_payloads(response: FlagsResponse) -> Optional[dict[str, str]]:
if "flags" not in response:
return None
return {
key: value.metadata.payload
for key, value in response.get("flags", {}).items()
if isinstance(value, FeatureFlag) and value.enabled and value.metadata.payload
}
+106 -17
View File
@@ -1,12 +1,16 @@
from dateutil.tz import tzlocal, tzutc
from datetime import date, datetime
from decimal import Decimal
import logging
import numbers
import re
from collections import defaultdict
from dataclasses import asdict, is_dataclass
from datetime import date, datetime, timezone
from decimal import Decimal
from uuid import UUID
import six
from dateutil.tz import tzlocal, tzutc
log = logging.getLogger('posthog')
log = logging.getLogger("posthog")
def is_naive(dt):
@@ -17,8 +21,7 @@ def is_naive(dt):
def total_seconds(delta):
"""Determines total seconds with python < 2.7 compat."""
# http://stackoverflow.com/questions/3694835/python-2-6-5-divide-timedelta-with-timedelta
return (delta.microseconds
+ (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
return (delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 1e6) / 1e6
def guess_timezone(dt):
@@ -39,7 +42,7 @@ def guess_timezone(dt):
def remove_trailing_slash(host):
if host.endswith('/'):
if host.endswith("/"):
return host[:-1]
return host
@@ -47,15 +50,28 @@ def remove_trailing_slash(host):
def clean(item):
if isinstance(item, Decimal):
return float(item)
elif isinstance(item, (six.string_types, bool, numbers.Number, datetime,
date, type(None))):
if isinstance(item, UUID):
return str(item)
if isinstance(item, (six.string_types, bool, numbers.Number, datetime, date, type(None))):
return item
elif isinstance(item, (set, list, tuple)):
if isinstance(item, (set, list, tuple)):
return _clean_list(item)
elif isinstance(item, dict):
# Pydantic model
try:
# v2+
if hasattr(item, "model_dump") and callable(item.model_dump):
item = item.model_dump()
# v1
elif hasattr(item, "dict") and callable(item.dict):
item = item.dict()
except TypeError as e:
log.debug(f"Could not serialize Pydantic-like model: {e}")
pass
if isinstance(item, dict):
return _clean_dict(item)
else:
return _coerce_unicode(item)
if is_dataclass(item) and not isinstance(item, type):
return _clean_dataclass(item)
return _coerce_unicode(item)
def _clean_list(list_):
@@ -69,19 +85,92 @@ def _clean_dict(dict_):
data[k] = clean(v)
except TypeError:
log.warning(
'Dictionary values must be serializeable to '
'JSON "%s" value %s of type %s is unsupported.',
k, v, type(v),
'Dictionary values must be serializeable to JSON "%s" value %s of type %s is unsupported.',
k,
v,
type(v),
)
return data
def _clean_dataclass(dataclass_):
data = asdict(dataclass_)
data = _clean_dict(data)
return data
def _coerce_unicode(cmplx):
try:
item = cmplx.decode("utf-8", "strict")
except AttributeError as exception:
item = ":".join(exception)
item.decode("utf-8", "strict")
log.warning('Error decoding: %s', item)
log.warning("Error decoding: %s", item)
return None
return item
def is_valid_regex(value) -> bool:
try:
re.compile(value)
return True
except re.error:
return False
class SizeLimitedDict(defaultdict):
def __init__(self, max_size, *args, **kwargs):
super().__init__(*args, **kwargs)
self.max_size = max_size
def __setitem__(self, key, value):
if len(self) >= self.max_size:
self.clear()
super().__setitem__(key, value)
def convert_to_datetime_aware(date_obj):
if date_obj.tzinfo is None:
date_obj = date_obj.replace(tzinfo=timezone.utc)
return date_obj
def str_icontains(source, search):
"""
Check if a string contains another string, ignoring case.
Args:
source: The string to search within
search: The substring to search for
Returns:
bool: True if search is a substring of source (case-insensitive), False otherwise
Examples:
>>> str_icontains("Hello World", "WORLD")
True
>>> str_icontains("Hello World", "python")
False
"""
return str(search).casefold() in str(source).casefold()
def str_iequals(value, comparand):
"""
Check if a string equals another string, ignoring case.
Args:
value: The string to compare
comparand: The string to compare with
Returns:
bool: True if value and comparand are equal (case-insensitive), False otherwise
Examples:
>>> str_iequals("Hello World", "hello world")
True
>>> str_iequals("Hello World", "hello")
False
"""
return str(value).casefold() == str(comparand).casefold()
+4 -1
View File
@@ -1 +1,4 @@
VERSION = '1.0.11'
VERSION = "4.0.1"
if __name__ == "__main__":
print(VERSION, end="") # noqa: T201
+10
View File
@@ -0,0 +1,10 @@
[tool.black]
line-length = 120
[tool.isort]
multi_line_output = 3
include_trailing_comma = true
force_grid_wrap = 8
ensure_newline_before_comments = true
line_length = 120
virtual_env = "env"
View File
+22
View File
@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == "__main__":
main()
@@ -0,0 +1,16 @@
"""
ASGI config for sentry_django_example project.
It exposes the ASGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
"""
import os
from django.core.asgi import get_asgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
application = get_asgi_application()
@@ -0,0 +1,167 @@
"""
Django settings for sentry_django_example project.
Generated by 'django-admin startproject' using Django 3.2.2.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
from pathlib import Path
from uuid import uuid4
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "django-insecure-4kzfiq7vb(t0+jbl#vq)u=%06ouf)n*=l%730c8=tk(wkm9i9o"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = []
# PostHog Setup (can be a separate app)
import posthog
# You can find this key on the /setup page in PostHog
posthog.api_key = "LXP6nQXvo-2TCqGVrWvPah8uJIyVykoMmhnEkEBi5PA" # TODO: replace with your api key
posthog.personal_api_key = ""
# Where you host PostHog, with no trailing /.
# You can remove this line if you're using posthog.com
posthog.host = "http://127.0.0.1:8000"
from posthog.sentry.posthog_integration import PostHogIntegration
PostHogIntegration.organization = "posthog" # TODO: your sentry organization
# PostHogIntegration.prefix = # TODO: your self hosted Sentry url. (default: https://sentry.io/organizations/)
# Since Sentry doesn't allow Integrations configuration (see https://github.com/getsentry/sentry-python/blob/master/sentry_sdk/integrations/__init__.py#L171-L183)
# we work around this by setting static class variables beforehand
# Sentry Setup
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn="https://27ac54f7f4cf484abf1335436b0c52e5@o344752.ingest.sentry.io/5624115", # TODO: your Sentry DSN here
integrations=[DjangoIntegration(), PostHogIntegration()],
# Set traces_sample_rate to 1.0 to capture 100%
# of transactions for performance monitoring.
# We recommend adjusting this value in production.
traces_sample_rate=1.0,
# If you wish to associate users to errors (assuming you are using
# django.contrib.auth) you may enable sending PII data.
send_default_pii=True,
)
POSTHOG_DJANGO = {
"distinct_id": lambda request: str(uuid4()) # TODO: your logic for generating unique ID, given the request object
}
# Application definition
INSTALLED_APPS = [
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
"posthog.sentry.django.PosthogDistinctIdMiddleware",
]
ROOT_URLCONF = "sentry_django_example.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "sentry_django_example.wsgi.application"
# Database
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_L10N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"
# Default primary key field type
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -0,0 +1,28 @@
"""sentry_django_example URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/3.2/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path
def trigger_error(request):
division_by_zero = 1 / 0
urlpatterns = [
path("admin/", admin.site.urls),
path("sentry-debug/", trigger_error),
]
@@ -0,0 +1,16 @@
"""
WSGI config for sentry_django_example project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "sentry_django_example.settings")
application = get_wsgi_application()
+3
View File
@@ -1,2 +1,5 @@
[bdist_wheel]
universal = 1
[tool:pytest]
asyncio_mode = auto
+73 -32
View File
@@ -1,4 +1,3 @@
import os
import sys
@@ -8,39 +7,87 @@ except ImportError:
from distutils.core import setup
# 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'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthog"))
from version import VERSION
long_description = '''
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
'''
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"backoff==1.6.0",
"python-dateutil>2.1"
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
tests_require = [
"mock>=2.0.0"
]
extras_require = {
"dev": [
"black",
"django-stubs",
"isort",
"flake8",
"flake8-print",
"lxml",
"mypy",
"mypy-baseline",
"types-mock",
"types-python-dateutil",
"types-requests",
"types-setuptools",
"types-six",
"pre-commit",
"pydantic",
],
"test": [
"mock>=2.0.0",
"freezegun==1.5.1",
"pylint",
"flake8",
"coverage",
"pytest",
"pytest-timeout",
"pytest-asyncio",
"django",
"openai",
"anthropic",
"langgraph",
"langchain-community>=0.2.0",
"langchain-openai>=0.2.0",
"langchain-anthropic>=0.2.0",
"pydantic",
"parameterized>=0.8.1",
],
"sentry": ["sentry-sdk", "django"],
"langchain": ["langchain>=0.2.0"],
}
setup(
name='posthog',
name="posthog",
version=VERSION,
url='https://github.com/posthog/posthog-python',
author='Posthog',
author_email='hey@posthog.com',
maintainer='PostHog',
maintainer_email='hey@posthog.com',
test_suite='posthog.test.all',
packages=['posthog', 'posthog.test'],
license='MIT License',
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthog.test.all",
packages=[
"posthog",
"posthog.ai",
"posthog.ai.langchain",
"posthog.ai.openai",
"posthog.ai.anthropic",
"posthog.test",
"posthog.sentry",
"posthog.exception_integrations",
],
license="MIT License",
install_requires=install_requires,
tests_require=tests_require,
description='Integrate PostHog into any python application.',
extras_require=extras_require,
description="Integrate PostHog into any python application.",
long_description=long_description,
classifiers=[
"Development Status :: 5 - Production/Stable",
@@ -48,16 +95,10 @@ setup(
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.2",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
)
+36 -31
View File
@@ -1,4 +1,3 @@
import os
import sys
@@ -8,39 +7,51 @@ 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__), 'posthoganalytics'))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "posthoganalytics"))
from version import VERSION
long_description = '''
long_description = """
PostHog is developer-friendly, self-hosted product analytics. posthog-python is the python package.
'''
This package requires Python 3.9 or higher.
"""
install_requires = [
"requests>=2.7,<3.0",
"six>=1.5",
"monotonic>=1.5",
"backoff==1.6.0",
"python-dateutil>2.1"
"python-dateutil>=2.2",
"backoff>=1.10.0",
"distro>=1.5.0", # Required for Linux OS detection in Python 3.9+
]
tests_require = [
"mock>=2.0.0"
]
tests_require = ["mock>=2.0.0"]
setup(
name='posthoganalytics',
name="posthoganalytics",
version=VERSION,
url='https://github.com/posthog/posthog-python',
author='Posthog',
author_email='hey@posthog.com',
maintainer='PostHog',
maintainer_email='hey@posthog.com',
test_suite='posthoganalytics.test.all',
packages=['posthoganalytics', 'posthoganalytics.test'],
license='MIT License',
url="https://github.com/posthog/posthog-python",
author="Posthog",
author_email="hey@posthog.com",
maintainer="PostHog",
maintainer_email="hey@posthog.com",
test_suite="posthoganalytics.test.all",
packages=[
"posthoganalytics",
"posthoganalytics.ai",
"posthoganalytics.ai.langchain",
"posthoganalytics.ai.openai",
"posthoganalytics.ai.anthropic",
"posthoganalytics.test",
"posthoganalytics.sentry",
"posthoganalytics.exception_integrations",
],
license="MIT License",
install_requires=install_requires,
tests_require=tests_require,
description='Integrate PostHog into any python application.',
extras_require={
"sentry": ["sentry-sdk", "django"],
},
description="Integrate PostHog into any python application.",
long_description=long_description,
classifiers=[
"Development Status :: 5 - Production/Stable",
@@ -48,16 +59,10 @@ setup(
"License :: OSI Approved :: MIT License",
"Operating System :: OS Independent",
"Programming Language :: Python",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.6",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.2",
"Programming Language :: Python :: 3.3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
],
)
+49 -32
View File
@@ -1,42 +1,38 @@
import posthog
import argparse
import json
import logging
__name__ = 'simulator.py'
__version__ = '0.0.1'
__description__ = 'scripting simulator'
import posthog
__name__ = "simulator.py"
__version__ = "0.0.1"
__description__ = "scripting simulator"
def json_hash(str):
if str:
return json.loads(str)
# posthog -method=<method> -posthog-write-key=<posthogWriteKey> [options]
parser = argparse.ArgumentParser(description='send a posthog message')
parser = argparse.ArgumentParser(description="send a posthog message")
parser.add_argument('--writeKey', help='the posthog writeKey')
parser.add_argument('--type', help='The posthog message type')
parser.add_argument("--writeKey", help="the posthog writeKey")
parser.add_argument("--type", help="The posthog message type")
parser.add_argument('--distinct_id', help='the user id to send the event as')
parser.add_argument(
'--anonymousId', help='the anonymous user id to send the event as')
parser.add_argument(
'--context', help='additional context for the event (JSON-encoded)')
parser.add_argument("--distinct_id", help="the user id to send the event as")
parser.add_argument("--anonymousId", help="the anonymous user id to send the event as")
parser.add_argument('--event', help='the event name to send with the event')
parser.add_argument(
'--properties', help='the event properties to send (JSON-encoded)')
parser.add_argument("--event", help="the event name to send with the event")
parser.add_argument("--properties", help="the event properties to send (JSON-encoded)")
parser.add_argument(
'--name', help='name of the screen or page to send with the message')
parser.add_argument("--name", help="name of the screen or page to send with the message")
parser.add_argument(
'--traits', help='the identify/group traits to send (JSON-encoded)')
parser.add_argument("--traits", help="the identify/group traits to send (JSON-encoded)")
parser.add_argument('--groupId', help='the group id')
parser.add_argument("--groupId", help="the group id")
options = parser.parse_args()
@@ -46,18 +42,43 @@ def failed(status, msg):
def capture():
posthog.capture(options.distinct_id, options.event, anonymous_id=options.anonymousId,
properties=json_hash(options.properties), context=json_hash(options.context))
posthog.capture(
options.distinct_id,
options.event,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
)
def page():
posthog.page(options.distinct_id, name=options.name, anonymous_id=options.anonymousId,
properties=json_hash(options.properties), context=json_hash(options.context))
posthog.page(
options.distinct_id,
name=options.name,
anonymous_id=options.anonymousId,
properties=json_hash(options.properties),
)
def identify():
posthog.identify(options.distinct_id, anonymous_id=options.anonymousId,
traits=json_hash(options.traits), context=json_hash(options.context))
posthog.identify(
options.distinct_id,
anonymous_id=options.anonymousId,
traits=json_hash(options.traits),
)
def set_once():
posthog.set_once(
options.distinct_id,
properties=json_hash(options.traits),
)
def set():
posthog.set(
options.distinct_id,
properties=json_hash(options.traits),
)
def unknown():
@@ -68,16 +89,12 @@ posthog.api_key = options.writeKey
posthog.on_error = failed
posthog.debug = True
log = logging.getLogger('posthog')
log = logging.getLogger("posthog")
ch = logging.StreamHandler()
ch.setLevel(logging.DEBUG)
log.addHandler(ch)
switcher = {
"capture": capture,
"page": page,
"identify": identify
}
switcher = {"capture": capture, "page": page, "identify": identify, "set_once": set_once, "set": set}
func = switcher.get(options.type)
if func: