- 131cc1a Match local feature flag string operators using the flags service's boolean coercion, JSON stringification, and casing rules. — Thanks @marandaneto!
- f50f333 Add non-blocking feature flag evaluation and remote config APIs to AsyncPosthog — Thanks @marandaneto!
- f50f333 Add an asyncio-native client for buffered and immediate event capture — Thanks @marandaneto!
- fc7e043 Honor
default_cache_ttl_seconds=0in AI prompts so callers can disable default prompt caching. — Thanks @ckarnell for your first contribution 🎉!
- 0e70f0c Align local
is_setandis_not_setevaluation with partial property context. — Thanks @marandaneto!
- 9a1d137 Add an opt-in
capture_trace_contextclient option. When enabled, and a valid OpenTelemetry span is active at capture time, its trace and span IDs are attached to events captured withcapture()andcapture_ai()as$trace_idand$span_id, so they can be correlated with backend traces. Disabled by default, and explicit$trace_id/$span_idproperties take precedence. — Thanks @DanielVisca!
- 8046114 Return an empty feature flag snapshot without evaluation when feature flag keys are explicitly empty. — Thanks @marandaneto!
- 35220f3 Fall back to remote evaluation when a requested flag is missing from local definitions. This changes the previous behavior where the key was omitted without a request. — Thanks @marandaneto!
- c55c9b2 MCP analytics now surfaces the previously-silent case where the stateless session mint middleware (
PostHogMcpStatelessSessionMiddleware) never attached — the trap where an ASGI app is built or mounted beforeinstrument()runs, so autowiring can't retrofit it and every session falls back to a fragmented per-process id.instrument()warns whenstreamable_http_app()was already called before it ran, and a one-time warning fires the first time a tool call arrives over streamable HTTP and the session still has to come from process memory. Both go to theposthog.mcpstandard-library logger as well as theMCPAnalyticsOptions(logger=...)sink, so they are visible without opting in — silence them withlogging.getLogger("posthog.mcp").setLevel(logging.ERROR). Neither fires for stdio, a correctly-wired server, a conversation-anchored session, or the SSE transport (which the mint cannot fix). Documented in the newposthog/mcp/README.md. — Thanks @posthog[bot]!
- f483bab feat(mcp): capture
$mcp_client_user_agentand$mcp_vendor_clientso MCP usage can be attributed to a product surface.clientInfo.nameonly says which client library is calling — Anthropic reportsclaude-codefrom the CLI, the Agent SDK, the VS Code extension and the desktop app alike — so$mcp_client_namecollapses every surface into one bucket and the harness breakdown reads 100% "Other" for Python-backed servers. The distinguishing detail lives in the User-Agent parenthetical (claude-code/2.1.0 (cli)vs(sdk-ts)) and in vendor headers likex-anthropic-client. Both are captured raw and classified at query time, so labels can improve without an SDK release. HTTP transports only: stdio and in-memory servers carry no headers and their events are unchanged. Custom dispatchers pass their own via newclient_user_agent/vendor_clientarguments on everyPostHogMCP.capture_*method. Parity with@posthog/mcp. — Thanks @gesh!
- 2863909 feat(mcp): emit
$mcp_error_messageand$mcp_error_typeon failed MCP events. The reason a tool call failed previously lived only on the sibling$exceptionevent, so PostHog's failures view — which reads the scalars off the primary event — showed empty error rows for every Python-backed MCP server, and switching offenable_exception_autocaptureremoved the reason entirely. Both values are read from the same$exception_listthe sibling carries, so the two surfaces can never disagree, and the message inherits the existing 2048-character cap.PostHogMCP.capture_tool_call()andcapture_tools_list()take a new optionalerror_typefor custom dispatchers that want a coarse category ("validation","timeout") instead of the thrown class name. Exception messages are also redacted before they leave — previously nothing sanitized the error payload, so the$exceptionsibling had been shipping them raw. Credential-looking words go through the SDK's own detector (entropy, known key formats, PEM markers), per word, so a message likeauth failed for sk-...keeps its diagnostic text and loses only the key. Parity with@posthog/mcp, which sanitizes exception values the same way. — Thanks @gesh!
-
b0ab12c feat(mcp): support MCP Python SDK v2 and bring
posthog.mcpto parity with the TypeScript SDK (@posthog/mcp). Most of this reaches SDK 1.x servers too — the parity work is not v2-only.MCP SDK v2 / spec 2026-07-28.
instrument()now wrapsmcp.server.mcpserver.MCPServer(the renamed FastMCP) and the v2 low-levelServer(constructor-injected handlers, string-keyed registry, lateadd_request_handlerregistrations included), capturing tool calls, tools/list, errors, intent, client identity, and$mcp_protocol_versionon both protocol eras — the legacy handshake and the stateless 2026-07-28 envelope, decided per request. Previouslyinstrument()raisedImportErroronmcp>=2and took the host application down with it; it now degrades to a logged no-op on any unsupported or unrecognized SDK.Cross-SDK parity (SDK 1.x and 2.x alike). Conversation-anchored sessions land as the cross-pod correlation the stateless era needs: with
enable_conversation_id,$session_idderives deterministically from the agent-echoedconversation_id(new exportderive_session_id_from_conversation, byte-compatible with@posthog/mcp). Only a handle the SDK could have minted (a uuidv7) anchors a session, so two callers inventing the same id can no longer be merged. The handle is delivered over both channels a tool result has — acontenttext block carrying it as plain JSON data on the minting response (an imperative server sentence inside a tool result is prompt-injection-shaped, and a client that strips it silently breaks the feature), and an_mcp_instructionskey declared on the tool's output schema and mirrored intostructuredContenton every response. That second channel is what makes the feature work at all for tools with structured output: clients that readstructuredContentnever rendercontent, so the agent had no handle to echo (0% echo rate measured against Claude Code before the mirror). The prompt-back now rides errored results too, so a failure on a conversation's first call doesn't split the retry into a new session. The session is resolved only once the handle's fate is known, so the call that mints a handle joins the same session as the calls that echo it — while a handle that could not be delivered anchors nothing, rather than stranding events in a conversation nobody holds. Host callbacks (identify,intent_fallback,event_properties) receive the SDK's own per-request context asextra["ctx"]identically on both majors, with a new exportedget_request_headers(extra)to read HTTP headers off it — the underlying shape differs per major, and a hand-rolled read that works on one silently returns nothing on the other, sending every event out anonymous.Fixes affecting existing SDK 1.x users. Analytics could break a tool call in three ways, each now fixed and regression-tested: the SDK's tool cache is rebuilt from an internal listing pass we skipped injecting on, so after any call to an unlisted tool name a strict schema rejected either the analytics parameters we advertise (
Input validation error) or the conversation key we write (Output validation error); the conversation handle was written into the caller's result object in place, so a tool returning a shared or cached result served one conversation's handle to every later caller; and on jlowin's FastMCP the advertised schema markedcontextrequired while the adapter strips it before validation, failing every call understrict_input_validation=True. Two behavioural changes come with the parity work: an invented (non-uuidv7)conversation_idecho is replaced with a fresh handle rather than trusted, and minted prompt-backs are now appended to errored results. — Thanks @gesh!
- 1adf542 Drop events when before_send callbacks raise exceptions — Thanks @marandaneto!
- 6fc55b6 Normalize SDK event timestamps to UTC, including datetime values and parseable ISO timestamp strings, and correct UTC serialization for exception frame timestamps — Thanks @marandaneto!
- 178ef43 Public beta
capture_ai: AI events on the dedicated AI endpoint with the event UUID returned; newenable_full_ai_captureflag (old private flags kept as deprecated aliases). — Thanks @carlos-marchal-ph!
- 9beed86 fix: preserve event delivery when gevent monkey-patches
queue.Queue, including in preloaded gunicorn workers — Thanks @marandaneto!
- 9c4fd84 Fix async OpenAI streaming captures to include token usage and other generation properties emitted by synchronous streams. — Thanks @ckarnell for your first contribution 🎉!
- f38790c Fix local evaluation for negated, missing, and malformed cohort definitions — Thanks @marandaneto!
- bd5cff4 fix: declare Gemini's cache accounting model on generations with cache reads, so ingestion prices cached tokens from
$ai_cache_reporting_exclusiveinstead of inferring it from the token counts. — Thanks @fivestarspicy!
- 100f993
group_identify()now validates the group identity before enqueuing. Previouslygroup_identify("company", None)(or an empty-stringgroup_type/group_key) sent a$groupidentifyevent with a null/empty$group_typeor$group_key, which cannot address a group profile and just adds an unusable event to the project. Missing values are now dropped with a warning instead, matching the sdk-specsgroup-identifycontract. Valid values, including non-string group keys, are passed through unchanged. — Thanks @posthog[bot]!
- 55370ee fix: prevent client lifecycle deadlocks when error callbacks, concurrent
join()/shutdown()calls, or forked sync-mode clients interact with queue and worker teardown. — Thanks @marandaneto!
- 77821ce feat:
FeatureFlagEvaluations.is_enabled()accepts adefault_valuereturned when the flag has no value in the evaluation — the key was not part of the evaluated set, or the evaluation came back empty (failed/flagsrequest, quota limit, no resolvabledistinct_id). A flag that has a value still wins, so a disabled flag returnsFalseeven withdefault_value=True. The default isFalse, so existing calls behave exactly as before. — Thanks @posthog[bot]!
- 7b6a8d8
evaluate_flags()now JSON-decodes payloads for locally-evaluated flags, the same way it already did for flags resolved remotely. Previouslyget_flag_payload()returned a parsed value ({"copy": "new"}) when the flag came back from/flagsbut the raw JSON string ('{"copy": "new"}') when the poller evaluated it locally, so the payload's type depended on where the flag happened to resolve. The$feature_flag_payloadproperty on$feature_flag_calledevents is decoded for locally-evaluated flags too. Payload strings that aren't valid JSON are still passed through unchanged. — Thanks @posthog[bot]! - 92625cf The
$feature_flag_calleddedupe tracker now evicts its oldest entry when it reaches capacity instead of clearing every entry. Previously, each time a client accumulated 50,000 distinct IDs the whole tracker was wiped, so the next flag read for every previously seen distinct ID re-emitted a$feature_flag_calledevent it had already deduped. — Thanks @posthog[bot]!
- c5f4e8f Normalize Gemini tool calls and tool responses in captured input so they render in traces and reach evaluations — Thanks @marco-g-pm!
- ae26014 fix:
flush()no longer waits outflush_intervalbefore delivering a partial batch. A consumer holding fewer thanflush_atevents now sends them as soon asflush()(orshutdown()) asks it to, instead of blocking the caller for the rest of the batching window — which previously madeflush()deliver nothing at all whenflush_intervalwas longer than the flush timeout. Timer-based batching without an explicit flush is unchanged. — Thanks @posthog[bot]!
- 6397d78
alias()now validates both identities before enqueuing. Previouslyalias(None, "user-123")(or an empty-stringprevious_id) sent a$create_aliasevent with a null/emptydistinct_id, which cannot link anything and just adds an unusable event to the project. Missing identities are now dropped with a warning instead, matching the sdk-specsaliascontract. The drop that already happened when no alias target could be resolved now logs a warning too, and a non-stringprevious_idsuch as0is stringified consistently in bothdistinct_idandproperties.distinct_id. — Thanks @posthog[bot]!
- 5ed7d0d Prevent stale feature flag definition publication — Thanks @marandaneto!
- b16ec74 Isolate MCP pending capture tasks by owner and loop — Thanks @marandaneto!
- 38a09b8 Preserve typed feature flag results in Redis fallback — Thanks @marandaneto!
- 25b9d28 Preserve Anthropic messages.stream compatibility — Thanks @marandaneto!
- f70602b Honor false feature flag payload overrides — Thanks @marandaneto!
- b094725 Use device IDs during local feature flag evaluation — Thanks @marandaneto!
- c5d01c9 Support the
starts_with,not_starts_with,ends_with, andnot_ends_withproperty filter operators in feature flag local evaluation. Matching is case-insensitive and mirrorsicontains, so flags using these operators no longer fall back to remote evaluation. — Thanks @haacked!
- f805e5b
posthog.ai.openai.OpenAI/AsyncOpenAInow accept a per-callposthog_provider_overrideargument. The wrapper is commonly pointed at OpenAI-compatible endpoints (DeepSeek, Groq, Mistral, Together, Fireworks, xAI, Perplexity, Ollama, Cerebras, and various gateways) via a custombase_url, but always reported$ai_provider: "openai", which breaks PostHog's cost attribution for those calls. Passingposthog_provider_override="deepseek"(for example) sets$ai_provideron the emitted event without changing how the OpenAI-shaped response is parsed. Omitting it leaves$ai_provideras"openai", exactly as before. Covers chat completions, the Responses API,.parse(), and embeddings, across sync, async, and streaming calls. — Thanks @marco-g-pm!
- 340eb2a Reset PostHog context after fork. Forked children no longer retain the parent process's active lexical context; they start without inherited context and can establish a new child-local context. — Thanks @marandaneto!
- c95c9f9 Make client shutdown an atomic terminal boundary — Thanks @marandaneto!
- c2b0972 Respect Celery task filters for exception capture — Thanks @marandaneto!
- 60a3e9c Reset the client registry lock after fork — Thanks @marandaneto!
- 0b353a7 Keep consumers alive after malformed before_send results — Thanks @marandaneto!
- 3658ed1 Cap capture v0 Retry-After delays — Thanks @marandaneto!
- 1b30afa Reject negative capture retry counts — Thanks @marandaneto!
- aa00432 Restore exception hooks safely — Thanks @marandaneto!
- bfec2b1 Reset MCP background capture state after fork — Thanks @marandaneto!
- 4bf123e The OpenAI Agents SDK
group_idnow also maps to$ai_session_idon$ai_traceand span events, so grouped runs show up as sessions in PostHog AI observability.$ai_group_idis still emitted alongside it. — Thanks @marco-g-pm for your first contribution 🎉!
- 3c9aa59 feat(ai):
Prompts.get(..., with_metadata=True)results now includeconfig, the JSON object of model parameters or agent configuration stored with the prompt version in PostHog prompt management (Nonewhen the version has none). Config is carried through the client-side cache and the stale-cache fallback. The hardcodedfallbackstring has no config, so use defensive access like(result.config or {}).get("temperature", 0). — Thanks @jurajmajerik!
- 170f4e2 feat(mcp): emit
$mcp_protocol_versionon MCP analytics events — the MCP spec version, recovered from the session token across stateless pods (parity with the TypeScript SDK).PostHogMCPcapture methods gain aprotocol_versionargument. — Thanks @gesh for your first contribution 🎉!
- cdc0825 Preserve Anthropic cache-write TTL breakdowns across Python SDK AI integrations. — Thanks @gouveags!
- 13de879 Fix module-level settings propagation to the default client — Thanks @marandaneto!
- 5535ecd fix(errors): emit
$exception_listin canonical order — index0is the caught/outermost exception, causes follow in unwrap order, and the root cause is last (previously the list was reversed with the root cause first). This aligns posthog-python with the cross-SDK exception ordering spec. Frame order within each stacktrace is unchanged. — Thanks @cat-ph!
- 4c8a85a AI capture now records multimodal and structured content (thinking blocks, tool calls, media, and Responses API output items) faithfully across all providers and streaming paths, and redacts base64 media structurally without leaking raw bytes or over-redacting legitimate values. — Thanks @carlos-marchal-ph!
- 37aafd3 feat(mcp): stateless and multi-pod server support — carry
$session_idand the client identity (harness) across pods via a self-encodedMcp-Session-Idtoken minted atinitializeand replayed on every request. Auto-wired on theinstrument()FastMCP path (stateless_http=True); customPostHogMCPdispatchers addPostHogMcpStatelessSessionMiddlewareand readget_mcp_session(). — Thanks @gesh!
-
f9a163c Refactored capture internals to support multiple delivery lanes per client. Added an internal test lane for heavy AI events.
Events captured after
shutdown()are now dropped with a warning instead of being silently queued with no consumer to deliver them. — Thanks @carlos-marchal-ph!
- 2d7f8cc The
client.metricsconfig can now be set through module-level settings: assignposthog.metrics = {"service_name": ..., ...}alongsideposthog.api_keyand the dict is applied whensetup()builds the global client. Previously module-configured apps had no way to pass the metrics config, so every series recorded through the global client shippedservice.name='unknown_service'. Late assignment (e.g. a Djangoready()hook running after an earlysetup()) still applies on the nextsetup()call, as long as the metrics API hasn't been used yet. — Thanks @DanielVisca!
-
6766309 Harden the alpha
posthog.metricsclient based on review follow-ups.- Metric attributes are now deep-snapshotted at capture time, so mutating a nested list/dict value after
count()/gauge()/histogram()can no longer rewrite an already-recorded series' attributes on the wire. - Failed metric flushes now retry with exponential backoff (first retry at the base interval, then doubling per consecutive failure, capped at 64x the flush interval — the shared JS logs ramp) instead of the fixed cadence, and the buffered window is dropped loudly after 8 consecutive failed flushes — previously documented as 3 but effectively 4.
- Invalid
metricsclient config (non-dict config orresource_attributes, non-numericflush_interval, non-integermax_series_per_flush, non-callablebefore_send) now degrades to defaults with a warning instead of raising from the firstclient.metrics.count()call, matching the client's no-throw contract. — Thanks @DanielVisca!
- Metric attributes are now deep-snapshotted at capture time, so mutating a nested list/dict value after
- ca5e883 Clarify the queue-full warning to say the event is being dropped, instead of only reporting that the queue is full. — Thanks @emmayusufu for your first contribution 🎉!
-
5ef2c23
$feature_flag_calledevents are now minimized for non-experiment flags when the server enables it. When the/flagsv2 response (minimalFlagCalledEvents) or the local-evaluation payload (minimal_flag_called_events) reports the gate as enabled and the evaluated flag has no linked experiment (has_experimentisfalse), the event's properties are reduced to a strict allowlist ($feature_flag,$feature_flag_response,$feature_flag_has_experiment, the$feature_flag_*debug scalars,locally_evaluated,$groups,$process_person_profile,$session_id,$lib,$lib_version,$is_server,$geoip_disable,$os,$os_version,$os_distro,$python_runtime,$python_version). Everything else — including super properties and custom event properties — is stripped from those events.If the server does not report the gate, if the flag's
has_experimentsignal is missing, or if the flag is linked to an experiment, the full property set is sent unchanged. There is no SDK-side configuration; the gate is controlled per-team by the server. Forevaluate_flags()snapshots, the gate is pinned when the snapshot is created, so deferred flag accesses are shaped by the evaluation that produced them.Custom
flag_definition_cacheproviders now receive an additionalminimal_flag_called_eventskey in the definitions payload, so the gate survives external cache round-trips.When the server reports
has_experimentfor a flag, every$feature_flag_calledevent also carries a$feature_flag_has_experimentboolean property. — Thanks @haacked!
- 1653bcb Add a
labeloption toPrompts.get()to fetch the prompt version a label (e.g.production) currently points to. Labeled fetches are cached separately, andPromptResultcarries the resolvedlabel. Requires a PostHog version with prompt labels; older servers ignore the parameter and return the latest version. — Thanks @jurajmajerik!
- 5ab6318 Add the active OpenTelemetry span's
$trace_idand$span_idto events captured withcapture_exception. — Thanks @hpouillot!
- 556c134
$feature_flag_calledevents now carry a$feature_flag_has_experimentboolean property when the server reports whether the flag is linked to an experiment. When the server does not report the signal (older deployments), the property is omitted. — Thanks @haacked!
-
5e42b1e Add the
posthog.metricsAPI (count,gauge,histogram) — alpha.Backend services can now record metrics through the same statsd-style pre-aggregating client the browser SDK ships, with no OpenTelemetry setup:
client = Posthog("<ph_project_api_key>", metrics={"service_name": "billing-worker"}) client.metrics.count("invoices.processed", 1, attributes={"plan": "pro"}) client.metrics.gauge("queue.depth", 42) client.metrics.histogram("job.duration", 187, unit="ms")
Samples aggregate in memory and flush as OTLP/JSON to
/i/v1/metrics(one data point per series per window, delta temporality). Pending metrics are flushed onshutdown(); buffered windows are retried on transient failures and dropped loudly after 3 consecutive failed flushes. Themetricsclient option acceptsservice_name,service_version,environment,resource_attributes,flush_interval(seconds),max_series_per_flush(cardinality guardrail, default 1000), and abefore_sendhook. — Thanks @DanielVisca!
- eb025c8 Django middleware also sends the request user agent as
$raw_user_agent, the standardized property PostHog's server-side classification (e.g. bot detection) reads — Thanks @lricoy!
- ae3c4e5 Malformed flag-dependency conditions (missing key, null value, or wrong operator) now evaluate locally as no-match (false), matching the server, instead of falling back to the
/flagsendpoint on every evaluation. 7.22.1 made these conditions fall back to the server, which could massively increase billable/flagsrequest volume for flag definitions containing legacy/malformed dependency conditions. — Thanks @patricio-posthog!
- 4d61b18 Capture pre-calculated total cost from OpenAI Agents Responses API usage. — Thanks @fuchengwarrenzhu for your first contribution 🎉!
- 650d107 Fix local evaluation of flag dependencies with a
flag_evaluates_to: falsecondition: such conditions never matched, forcing the dependent flag tofalsefor every locally-evaluated user. — Thanks @matheus-vb!
-
d459b57 Add an opt-in
capture_modefor the Capture V1 ingestion protocol (POST /i/v1/analytics/events). Setcapture_mode="v1"on the client (or thePOSTHOG_CAPTURE_MODE=v1environment variable) to use Bearer auth, per-event results, and partial retry. Defaults to"v0"(the legacy/batch/endpoint), so existing setups are unaffected.When using
capture_mode="v1", request bodies can be compressed viacapture_compression(orPOSTHOG_CAPTURE_COMPRESSION):"gzip","deflate","zstd"(requires the optionalposthog[zstd]extra), or"none"(default). The legacygzip=Trueflag is honored as a fallback.Per-event server verdicts are surfaced through the existing
on_errorhandler: events the backend explicitly drops, or fails to accept after retries, raise aCaptureV1Errorcarrying the affected event UUIDs — so a rejection is never silently lost, even when the HTTP request itself succeeded. — Thanks @eli-r-ph for your first contribution 🎉!
- 30c184f Stop duplicating distinct_id inside /flags person properties — Thanks @marandaneto!
- c6350b6 Testing release workflow. — Thanks @marandaneto!
- cd86110 Fall back to uncompressed uploads when gzip compression fails — Thanks @marandaneto!
- 888a725 Add
posthog.mcp, a Python SDK for PostHog MCP analytics (justpip install posthog; the MCP SDK is a peer dependency ofinstrument(), not bundled).instrument(server, posthog_client)wraps aFastMCPor low-levelmcp.server.Serverso every tool call, agent intent, tools/list, initialize, and failure is captured to PostHog as a$mcp_*event. Also addsPostHogMCP, aClientsubclass for custom dispatchers (needs nothing beyond posthog), plus opt-incontextintent capture,identify,report_missing(get_more_tools), andconversation_id. Beta. — Thanks @lucasheriques for your first contribution 🎉!
- cdd878c Clear feature flag called cache on shutdown — Thanks @marandaneto!
- 89adb2f Fix internal imports for posthoganalytics mirror — Thanks @hpouillot!
- 42ff4ca Detect and redact high-entropy secrets (API keys, tokens, passwords) in exception code variables. Adds the
code_variables_detect_secretsoption (defaultTrue). — Thanks @ablaszkiewicz!
- c359f93 Mask sensitive data held inside objects and in URL/DSN credentials when capturing exception code variables. Custom objects are now traversed so fields like
passwordare redacted by attribute name instead of leaking viarepr(), and credentials embedded in connection strings are scrubbed. Adds thecode_variables_mask_url_credentialsoption (defaultTrue). — Thanks @ablaszkiewicz! - c359f93 Improve strict Pyright coverage for public PostHog APIs. — Thanks @ablaszkiewicz!
- 09c8fba Warn on duplicate async PostHog clients and document client lifecycle guidance — Thanks @marandaneto!
- bc8e531 Add a default timeout for flushing queued events. — Thanks @marandaneto!
- 98a305a Increase the default background flush interval to 5 seconds — Thanks @marandaneto!
- 8d416ae Add missing return type annotations to improve typing coverage without changing runtime behavior. — Thanks @miachillgood for your first contribution 🎉!
- b9f3208 Add opt-in client-side rate limiting for exception autocapture, using the same token bucket algorithm as the posthog-js and posthog-node SDKs: a bucket per exception type allows a burst of captures, then refills over time. Rate-limited exceptions are skipped before they reach the ingestion queue. Disabled by default; enable with the new
enable_exception_autocapture_rate_limitingclient option and tune viaexception_autocapture_bucket_size(default 50),exception_autocapture_refill_rate(default 10), andexception_autocapture_refill_interval_seconds(default 10). — Thanks @hpouillot!
- ee6a3c8 Warn when an AI wrapper's
base_urlpoints at the PostHog AI Gateway. The gateway emits its own$ai_generation, so each call would be captured (and billed) twice. The wrapper only warns and never drops the event. Detection covers the wrapper funnels (OpenAI, Anthropic, LangChain) and the OTel span path. — Thanks @richardsolomou!
- fe76fc9 Improve mypy coverage for core SDK modules without changing runtime behavior. — Thanks @Kshitijmishradev for your first contribution 🎉!
- 00b2091 Add internal-only routing of
$ai_*events to a dedicated capture endpoint in their own batch, gated behind the unstable_dedicated_ai_endpointclient option (off by default, not for general use). — Thanks @carlos-marchal-ph!
- a2ce51e feat(feature-flags): support the
early_exitcondition option in local evaluation. When a flag enables early exit, evaluation now stops and returnsFalseas soon as a condition group's property filters match but the rollout percentage excludes the user, instead of falling through to later groups — matching the server-side evaluation behavior. — Thanks @gustavohstrassburger!
- 3aed638 Add a configurable
$is_serverevent property (defaulttrue) so PostHog can identify server-side events. Setis_server=Falsewhen using posthog-python as a client/CLI so the device OS is attributed normally. — Thanks @turnipdabeets for your first contribution 🎉!
- 44e6b14 Fix async streaming responses from the AI wrappers (OpenAI, Anthropic, Gemini) so they support
async withas well asasync for. Previously, consuming a stream viaasync with(e.g. with pydantic-ai) raisedTypeError: 'async_generator' object does not support the asynchronous context manager protocol. — Thanks @turnipdabeets for your first contribution 🎉!
- 643a810 Return empty flag defaults from Client flag helpers when the flags API fails. — Thanks @marandaneto!
- 034dce2 Make module-level setup no-op when API key is blank — Thanks @marandaneto!
- 8f6d6c8 Include group context in the
$feature_flag_calleddedupe key so group-scoped flags fire a separate event for each group a user is evaluated under, instead of being dedup-ed against the first group context the same(distinct_id, flag, response)was seen under. — Thanks @gustavohstrassburger!
- a44e0be Add async flag definition cache providers — Thanks @dustinbyrne!
- 0207088 Track OpenAI chat completions parse calls — Thanks @marandaneto!
- be9b78b Reject semver values with leading zeros in local flag evaluation. Per semver 2.0.0 §2, numeric identifiers must not include leading zeros — values like
1.07.3are not valid semver and should not match targeting conditions. Both override values and flag values are now validated; invalid inputs raiseInconclusiveMatchErrorso the condition does not match. — Thanks @dmarticus!
- 1574b1b Fix OpenAI usage parsing when token detail fields are null — Thanks @michael-ciridae!
- a098aa7 Fix Gemini web search extraction when response candidates are null. — Thanks @marandaneto!
- 52cd20e feat: add Celery integration and improve PostHog client fork safety — Thanks @parinporecha!
- 44c1261 Fix scoped context support for async functions — Thanks @marandaneto!
- f6c8ede fix: type warning on new_context — Thanks @itsaphel for your first contribution 🎉!
-
69dc2a8 Add
evaluate_flags()and a newflagsoption oncapture()so a single/flagscall can power both flag branching and event enrichment per request:flags = posthog.evaluate_flags(distinct_id, person_properties={"plan": "enterprise"}) if flags.is_enabled("new-dashboard"): render_new_dashboard() posthog.capture("page_viewed", distinct_id=distinct_id, flags=flags)
The returned
FeatureFlagEvaluationssnapshot exposesis_enabled(),get_flag(),get_flag_payload()for branching andonly_accessed()/only([keys])filter helpers. Passflag_keys=[...]toevaluate_flags()to scope the underlying/flagsrequest itself.Deprecates
feature_enabled(),get_feature_flag(),get_feature_flag_payload(), andcapture(send_feature_flags=...). They continue to work but now emit aDeprecationWarningpointing atevaluate_flags(). Removal is planned for the next major version. — Thanks @dmarticus!
- f4af88a Prevent flush from hanging after dropping oversized queued events. — Thanks @marandaneto!
- 6b3d1c7 Sanitize PostHog tracing headers extracted by Django middleware. — Thanks @dustinbyrne!
- dea848f Remove python-dateutil as a runtime dependency — Thanks @marandaneto!
- a1c6640 Improve local feature flag authentication error messages. — Thanks @marandaneto!
- 8bdd3fa Treat clients with an empty project API key as disabled no-ops. — Thanks @marandaneto!
- 0d36184 Support mixed user+group targeting in local flag evaluation. — Thanks @patricio-posthog!
- 12c38e7 Add
capture_errorsoption toPromptsthat reports prompt fetch failures to PostHog error tracking viacapture_exception()when enabled. — Thanks @andrewm4894!
- 1b098e7 Trim surrounding whitespace from API keys and host config before using them. — Thanks @marandaneto!
- 220d9e8
Prompts.get()now acceptswith_metadata=Trueand returns aPromptResultdataclass containingsource(api,cache,stale_cache, orcode_fallback),name, andversionalongside the prompt text. The previous plain-string return is deprecated and will be removed in a future major version. — Thanks @marandaneto!
- f5a95b4 feat(flags): switch local evaluation polling from
/api/feature_flag/local_evaluationto/flags/definitions— Thanks @patricio-posthog!
- c3f097f feat: Add os_distro information to events — Thanks @parinporecha!
- b921fe3 Add Gemini
embed_contenttracking support for both sync and async clients — Thanks @carlos-marchal-ph! - 44b92a8 feat(ai): add $ai_stop_reason extraction for all providers — Thanks @carlos-marchal-ph!
- 7c5cad8 fix: graceful fallback in claude_agent_sdk query wrapper when PostHog is not configured — Thanks @andrewm4894!
- e22e893 fix: pass the module-level
posthog.before_sendcallback into the lazily initialized default client — Thanks @marandaneto!
- bae355c feat(flags): make local evaluation endpoint configurable via
POSTHOG_LOCAL_EVALUATION_ENDPOINTenv var with fallback to default endpoint — Thanks @patricio-posthog for your first contribution 🎉!
- a5052b0 fix: Django middleware accidentally passed capture_exceptions as positional arg, setting fresh=True and resetting context state — Thanks @marandaneto!
- d234b53 feat(ai): add Claude Agent SDK integration for LLM analytics — Thanks @andrewm4894!
- 754c45f fix: propagate missing params in module-level wrapper functions (
distinct_idforgroup_identify,flag_keys_to_evaluateforget_all_flags/get_all_flags_and_payloads) — Thanks @dustinbyrne!
- 1729be4 chore(flags): expose flag_definition_cache_provider — Thanks @matheus-vb for your first contribution 🎉!
- 4547810 chore(ci): fix release attribution — Thanks @Piccirello!
- b48a7ac chore(ci): attribute release tag to GitHub App — Thanks @Piccirello!
- 591d3e0 chore(ci): use signed commits when publishing release — Thanks @Piccirello!
- 11466c6 feat(llma): support fetching versioned prompts from the prompts sdk — Thanks @Radu-Raicea!
- 535e9c5 chore(llma): clean up prompt SDK review follow-ups — Thanks @Radu-Raicea!
- b206669 fix(llma): use distinct_id from outer context if not provided, fix $process_person_profile for context-based identity — Thanks @ethanporcaro for your first contribution 🎉!
- a99c7d7 Add warning log for local flag evaluation cold start — Thanks @dmarticus!
- 8d83315 add PROPERTY_OPERATORS constant for match_property — Thanks @dmarticus!
- 830244b add semver targeting support to local evaluation — Thanks @dmarticus!
- a68a6a6 feat(llma): add
$ai_tokens_sourceproperty ("sdk" or "passthrough") to all$ai_generationevents to detect when token values are externally overridden viaposthog_properties— Thanks @carlos-marchal-ph!
- 9f9553a Fix posthoganalytics release, previously broken — Thanks @rafaeelaudibert!
- f1dc4d7 Add sampo to the project — Thanks @rafaeelaudibert!
fix(llma): make prompt fetches deterministic by requiring project_api_key and sending it as token query param
feat: Support device_id as bucketing identifier for local evaluation
fix: limit collections scanning in code variables
fix: further optimize code variables pattern matching
fix: do not pattern match long values in code variables
fix: openAI input image sanitization
fix(llma): fix prompts default url
fix(llma): small fixes for prompt management
feat(llma): add prompt management
Adds the Prompt Management feature. At the time of release, this feature is in a closed alpha.
feat(ai): Add OpenAI Agents SDK integration
Automatic tracing for agent workflows, handoffs, tool calls, guardrails, and custom spans. Includes $ai_total_tokens, $ai_error_type categorization, and $ai_framework property.
feat: add device_id to flags request payload
Add device_id parameter to all feature flag methods, allowing the server to track device identifiers for flag evaluation. The device_id can be passed explicitly or set via context using set_context_device_id().
fix: avoid return from finally block to fix Python 3.14 SyntaxWarning (#361) - thanks @jodal
feat: Capture Langchain, OpenAI and Anthropic errors as exceptions (if exception autocapture is enabled) feat: Add reference to exception in LLMA trace and span events
Fixes cache creation cost for Langchain with Anthropic
feat: add in_app_modules option to control code variables capturing
fix: extract model from response for OpenAI stored prompts
When using OpenAI stored prompts, the model is defined in the OpenAI dashboard rather than passed in the API request. This fix adds a fallback to extract the model from the response object when not provided in kwargs, ensuring generations show up with the correct model and enabling cost calculations.
feat: Add automatic retries for feature flag requests
Feature flag API requests now automatically retry on transient failures:
- Network errors (connection refused, DNS failures, timeouts)
- Server errors (500, 502, 503, 504)
- Up to 2 retries with exponential backoff (0.5s, 1s delays)
Rate limit (429) and quota (402) errors are not retried.
fix: remove unused $exception_message and $exception_type
feat: improve code variables capture masking
feat: add $feature_flag_evaluated_at properties to $feature_flag_called events
Add support for the async version of Gemini.
Add support for Python 3.14. Projects upgrading to Python 3.14 should ensure any Pydantic models passed into the SDK use Pydantic v2, as Pydantic v1 is not compatible with Python 3.14.
Try to use repr() when formatting code variables
NB Python 3.9 is no longer supported
- chore(llma): update LLM provider SDKs to latest major versions
- openai: 1.102.0 → 2.7.1
- anthropic: 0.64.0 → 0.72.0
- google-genai: 1.32.0 → 1.49.0
- langchain-core: 0.3.75 → 1.0.3
- langchain-openai: 0.3.32 → 1.0.2
- langchain-anthropic: 0.3.19 → 1.0.1
- langchain-community: 0.3.29 → 0.4.1
- langgraph: 0.6.6 → 1.0.2
- feat(ph-ai): PostHog properties dict in GenerationMetadata
- fix(llma): fix cache token double subtraction in Langchain for non-Anthropic providers causing negative costs
- fix(error-tracking): pass code variables config from init to client
- feat(error-tracking): add local variables capture
- feat(llma): send web search calls to be used for LLM cost calculations
- fix(django): Handle request.user access in async middleware context to prevent SynchronousOnlyOperation errors in Django 5+ (fixes #355)
- test(django): Add Django 5 integration test suite with real ASGI application testing async middleware behavior
- fix(llma): cache cost calculation in the LangChain callback
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
- fix(ai/langchain): Add LangChain 1.0+ compatibility for CallbackHandler imports (fixes #362)
- feat(ai): Add
$ai_frameworkproperty for framework integrations (e.g. LangChain)
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
- fix(flags): multi-condition flags with static cohorts returning wrong variants
- fix(llma): missing async for OpenAI's streaming implementation
- fix: remove deprecated attribute $exception_personURL from exception events
- fix: don't sort condition sets with variant overrides to the top
- fix: Prevent core Client methods from raising exceptions
- feat: Django middleware now supports async request handling.
- fix: Missing system prompts for some providers
- fix: missing usage tokens in Gemini
- fix: tool call results in streaming providers
- fix: Add base64 inline image sanitization
- feat: Add support for feature flag dependencies
- fix: Prevent
NoneTypeerror whengroup_propertiesisNone
- feat: Add
flag_keys_to_evaluateparameter to optimize feature flag evaluation performance by only evaluating specified flags - feat: Add
flag_keys_filteroption tosend_feature_flagsfor selective flag evaluation in capture events
- feat: Add
$context_tagsto an event to know which properties were included as tags
- fix: Always pass project API key in
remote_configrequests for deterministic project routing
- feat: support Vertex AI for Gemini
- fix: set
$ai_toolsfor all providers and$ai_output_choicesfor all non-streaming provider flows properly
- fix:
get_feature_flag_resultnow correctly returns FeatureFlagResult when payload is empty string instead of None
- fix: Anthropic's tool calls are now handled properly
- feat: Enhanced
send_feature_flagsparameter to acceptSendFeatureFlagsOptionsobject for declarative control over local/remote evaluation and custom properties
- feat: make
posthog_clientan optional argument in PostHog AI providers wrappers (posthog.ai.*), intuitively using the default client as the default
- fix: correctly capture exceptions processed by Django from views or middleware
- feat: decouple feature flag local evaluation from personal API keys; support decrypting remote config payloads without relying on the feature flags poller
- fix: add POSTHOG_MW_CLIENT setting to django middleware, to support custom clients for exception capture.
- feat: add a feature flag evaluation cache (local storage or redis) to support returning flag evaluations when the service is down
- fix: send_feature_flags changed to default to false in
Client::capture_exception
- fix: response
$process_person_profileproperty when passed to capture
This release contains a number of major breaking changes:
- feat: make distinct_id an optional parameter in posthog.capture and related functions
- feat: make capture and related functions return
Optional[str], which is the UUID of the sent event, if it was sent - fix: remove
identify(preferposthog.set()), andpageandscreen(preferposthog.capture()) - fix: delete exception-capture specific integrations module. Prefer the general-purpose django middleware as a replacement for the django
Integration.
To migrate to this version, you'll mostly just need to switch to using named keyword arguments, rather than positional ones. For example:
# Old calling convention
posthog.capture("user123", "button_clicked", {"button_id": "123"})
# New calling convention
posthog.capture(distinct_id="user123", event="button_clicked", properties={"button_id": "123"})
# Better pattern
with posthog.new_context():
posthog.identify_context("user123")
# The event name is the first argument, and can be passed positionally, or as a keyword argument in a later position
posthog.capture("button_pressed")Generally, arguments are now appropriately typed, and docstrings have been updated. If something is unclear, please open an issue, or submit a PR!
- feat: add support to session_id context on page method
- fix: safely handle exception values
- feat: construct artificial stack traces if no traceback is available on a captured exception
- feat: session and distinct ID's can now be associated with contexts, and are used as such
- feat: django http request middleware
- fix: removed deprecated sentry integration
- fix: no longer fail in autocapture.
- feat(ai): track reasoning and cache tokens in the LangChain callback
- fix: export scoped, rather than tracked, decorator
- feat: allow use of contexts without error tracking
- feat: add support for parse endpoint in responses API (no longer beta)
- fix: replace
import posthogwith direct method imports
- fix: replace
import posthoginposthoganalyticspackage
- feat: add additional user and request context to captured exceptions via the Django integration
- feat: Add
setup()function to initialise default client
- feat: add before_send callback (#249)
- empty point release to fix release automation
- empty point release to fix release automation
- Use the new
/flagsendpoint for all feature flag evaluations (don't fall back to/decideat all)
- Add context management:
- New context manager with
posthog.new_context() - Tag functions:
posthog.tag(),posthog.get_tags(),posthog.clear_tags() - Function decorator:
@posthog.scoped- Creates context and captures exceptions thrown within the function
- Automatic deduplication of exceptions to ensure each exception is only captured once
- fix: feature flag request use geoip_disable (#235)
- chore: pin actions versions (#210)
- fix: opinionated setup and clean fn fix (#240)
- fix: release action failed (#241)
Add support for google gemini
Moved ai openai package to a composition approach over inheritance.
- Remove deprecated
monotoniclibrary. Use Python's coretime.monotonicfunction instead - Clarify Python 3.9+ is required
- Added new method
get_feature_flag_resultwhich returns aFeatureFlagResultobject. 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 callget_valueon the result to get the value of the feature flag, which is the same value returned byget_feature_flag(aka the stringvariantif the flag is a multivariate flag or thebooleanvalue if the flag is a boolean flag).
Example:
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:
get_feature_flag_payloadnow deserializes payloads from JSON strings toAny. Previously, it returned the payload as a JSON encoded string.
Before:
payload = get_feature_flag_payload('key', 'distinct_id') # "{\"some\": \"payload\"}"After:
payload = get_feature_flag_payload('key', 'distinct_id') # {"some": "payload"}- Roll out new
/flagsendpoint to 100% of/decidetraffic, excluding the top 10 customers.
- Fix hash inclusion/exclusion for flag rollout
- Roll out new /flags endpoint to 10% of /decide traffic
- Add
log_captured_exceptionsoption to proxy setup
- Add config option to
log_captured_exceptions
- Expand automatic retries to include read errors (e.g. RemoteDisconnected)
- Add more information to
$feature_flag_calledevents. - Support for the
/decide?v=4endpoint which contains more information about feature flags.
- Support serializing dataclasses.
- Add support for OpenAI Responses API.
- Fix install requirements for analytics package
- Fix bug where None is sent as delta in azure
- Add support for tool calls in OpenAI and Anthropic.
- Add support for cached tokens.
- Improve quota-limited feature flag logs
- Add support for Azure OpenAI.
- The LangChain handler now captures tools in
$ai_generationevents, in property$ai_tools. This allows for displaying tools provided to the LLM call in PostHog UI. Note that support for$ai_toolsin OpenAI and Anthropic SDKs is coming soon.
- feat: add some platform info to events (#198)
- Fix async client support for OpenAI.
- Support quota-limited feature flags
- Evaluate feature flag payloads with case sensitivity correctly. Fixes #178
- Add support for Bedrock Anthropic Usage
- Automatically retry connection errors
- Fix mypy support for 3.12.0
- Deprecate
is_simple_flag
- Add support for OpenAI beta parse API.
- Deprecate
contextparameter
- Fix LangChain callback handler to capture parent run ID.
-
Add the
$ai_spanevent to the LangChain callback handler to capture the input and output of intermediary chains.LLM observability naming change: event property
$ai_trace_nameis now$ai_span_name. -
Fix serialiazation of Pydantic models in methods.
- Add
$ai_errorand$ai_is_errorproperties to LangChain callback handler, OpenAI, and Anthropic.
- Fix capturing of multiple traces in the LangChain callback handler.
- Fix importing of LangChain callback handler under certain circumstances.
- Add
$ai_traceevent emission to LangChain callback handler.
- Add Anthropic support for LLM Observability.
- Update LLM Observability to use output_choices.
- Fix setuptools to include the
posthog.ai.openaiandposthog.ai.langchainpackages for theposthoganalyticspackage.
- Fix setuptools to include the
posthog.ai.openaiandposthog.ai.langchainpackages.
- Add LLM Observability with support for OpenAI and Langchain callbacks.
- Add
distinct_idto group_identify
- Fix bug where this SDK incorrectly sent feature flag events with null values when calling
get_feature_flag_payload.
- Use personless mode when sending an exception without a provided
distinct_id.
- Add
typeproperty to exception stacks.
- Add
platformproperty to each frame of exception stacks.
- Adds a new
super_propertiesparameter on the client that are appended to every /capture call.
- Remove deprecated datetime.utcnow() in favour of datetime.now(tz=tzutc())
- Fix manual capture support for in app frames
- Fix django integration support for manual exception capture.
- Add manual exception capture.
- Make sure setup.py for posthoganalytics package also discovers the new exception integration package.
- Make sure setup.py discovers the new exception integration package.
- Adds django integration to exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
- Adds exception autocapture in alpha state. This feature is not yet stable and may change in future versions.
- Guard for None values in local evaluation
- Remove "-api" suffix from ingestion hostnames
-
- Adds a new
feature_flags_request_timeout_secondstimeout parameter for feature flags which defaults to 3 seconds, updated from the default 10s for all other API calls.
- Adds a new
- Add
historical_migrationoption for bulk migration to PostHog Cloud.
- Use new hosts for event capture as well
- Point given hosts to new ingestion hosts
- Update type hints for module variables to work with newer versions of mypy
- Remove new relative date operators, combine into regular date operators
- Return success/failure with all capture calls from module functions
- Make sure we don't override any existing feature flag properties when adding locally evaluated feature flag properties.
- 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.
- 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.
- Add support for relative date operators for local evaluation.
- Increase maximum event size and batch size
- Returns the current flag property with $feature_flag_called events, to make it easier to use in experiments
- Restore how feature flags work when the client library is disabled: All requests return
Noneand no events are sent when the client is disabled. - 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.
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:
posthog.disable_geoip = False
# // and if using client instantiation:
posthog = Posthog('api_key', disable_geoip=False)- Add option for instantiating separate client object
- Update backoff dependency for posthoganalytics package to be the same as posthog package
- Removes accidental print call left in for decide response
- Support evaluating all cohorts in feature flags for local evaluation
- Log instead of raise error on posthog personal api key errors
- Remove upper bound on backoff dependency
- Add support for returning payloads of matched feature flags
Changes:
- Add support for feature flag variant overrides with local evaluation
Changes:
- Fixes issues with date comparison.
Changes:
- Feature flags local evaluation now supports date property filters as well. Accepts both strings and datetime objects.
Changes:
- Feature flag defaults have been removed
- Setup logging only when debug mode is enabled.
- Make poll_interval configurable
- Add
send_feature_flag_eventsparameter to feature flag calls, which determine whether the$feature_flag_calledevent should be sent or not. - Add
only_evaluate_locallyparameter to feature flag calls, which determines whether the feature flag should only be evaluated locally or not.
Breaking changes:
- The minimum version requirement for PostHog servers is now 1.38. If you're using PostHog Cloud, you satisfy this requirement automatically.
- 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 toFalse, the default would override this. Note: These are removed in 2.0.2 - Feature flag remote evaluation doesn't require a personal API key.
New Changes:
- 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_enabledandget_feature_flagcalls. - Introduces a
get_all_flagsmethod that returns all feature flags. This is useful for when you want to seed your frontend with some initial flags, given a user ID.
- Support for sending feature flags with capture calls
- Support multi variate feature flags
- Allow feature flags usage without project_api_key
- Fix packaging issues with Sentry integrations
- 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)
- Add
$setand$set_oncesupport (#23) - Add distinct ID to
$create_aliasevent (#27) - Add
UUIDtoID_TYPES(#26)
Initial release logged in CHANGELOG.md.