Skip to content

Commit e566768

Browse files
committed
Merge master and resolve conflicts
- Updated version to 6.7.12 - Added changelog entry for 6.7.12
2 parents fbd1a62 + 50b0c71 commit e566768

File tree

11 files changed

+5155
-48
lines changed

11 files changed

+5155
-48
lines changed
File renamed without changes.

CHANGELOG.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,19 @@
1-
# 6.7.10 - 2025-10-24
1+
# Unreleased
2+
3+
- fix(django): Restore process_exception method to capture view and downstream middleware exceptions (fixes #329)
4+
5+
# 6.7.12 - 2025-11-02
26

37
- fix(llma): cache cost calculation in the LangChain callback
48

9+
# 6.7.11 - 2025-10-28
10+
11+
- feat(ai): Add `$ai_framework` property for framework integrations (e.g. LangChain)
12+
13+
# 6.7.10 - 2025-10-24
14+
15+
- fix(django): Make middleware truly hybrid - compatible with both sync (WSGI) and async (ASGI) Django stacks without breaking sync-only deployments
16+
517
# 6.7.9 - 2025-10-22
618

719
- fix(flags): multi-condition flags with static cohorts returning wrong variants

posthog/ai/langchain/callbacks.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -486,6 +486,7 @@ def _capture_trace_or_span(
486486
"$ai_latency": run.latency,
487487
"$ai_span_name": run.name,
488488
"$ai_span_id": run_id,
489+
"$ai_framework": "langchain",
489490
}
490491
if parent_run_id is not None:
491492
event_properties["$ai_parent_id"] = parent_run_id
@@ -556,6 +557,7 @@ def _capture_generation(
556557
"$ai_http_status": 200,
557558
"$ai_latency": run.latency,
558559
"$ai_base_url": run.base_url,
560+
"$ai_framework": "langchain",
559561
}
560562

561563
if run.tools:

posthog/integrations/django.py

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,19 @@
33
from posthog.client import Client
44

55
try:
6-
from asgiref.sync import iscoroutinefunction
6+
from asgiref.sync import iscoroutinefunction, markcoroutinefunction
77
except ImportError:
8-
# Fallback for older Django versions
8+
# Fallback for older Django versions without asgiref
99
import asyncio
1010

1111
iscoroutinefunction = asyncio.iscoroutinefunction
1212

13+
# No-op fallback for markcoroutinefunction
14+
# Older Django versions without asgiref typically don't support async middleware anyway
15+
def markcoroutinefunction(func):
16+
return func
17+
18+
1319
if TYPE_CHECKING:
1420
from django.http import HttpRequest, HttpResponse # noqa: F401
1521
from typing import Callable, Dict, Any, Optional, Union, Awaitable # noqa: F401
@@ -39,26 +45,24 @@ class PosthogContextMiddleware:
3945
See the context documentation for more information. The extracted distinct ID and session ID, if found, are used to
4046
associate all events captured in the middleware context with the same distinct ID and session as currently active on the
4147
frontend. See the documentation for `set_context_session` and `identify_context` for more details.
48+
49+
This middleware is hybrid-capable: it supports both WSGI (sync) and ASGI (async) Django applications. The middleware
50+
detects at initialization whether the next middleware in the chain is async or sync, and adapts its behavior accordingly.
51+
This ensures compatibility with both pure sync and pure async middleware chains, as well as mixed chains in ASGI mode.
4252
"""
4353

44-
# Django middleware capability flags
4554
sync_capable = True
4655
async_capable = True
4756

4857
def __init__(self, get_response):
4958
# type: (Union[Callable[[HttpRequest], HttpResponse], Callable[[HttpRequest], Awaitable[HttpResponse]]]) -> None
59+
self.get_response = get_response
5060
self._is_coroutine = iscoroutinefunction(get_response)
51-
self._async_get_response = None # type: Optional[Callable[[HttpRequest], Awaitable[HttpResponse]]]
52-
self._sync_get_response = None # type: Optional[Callable[[HttpRequest], HttpResponse]]
5361

62+
# Mark this instance as a coroutine function if get_response is async
63+
# This is required for Django to correctly detect async middleware
5464
if self._is_coroutine:
55-
self._async_get_response = cast(
56-
"Callable[[HttpRequest], Awaitable[HttpResponse]]", get_response
57-
)
58-
else:
59-
self._sync_get_response = cast(
60-
"Callable[[HttpRequest], HttpResponse]", get_response
61-
)
65+
markcoroutinefunction(self)
6266

6367
from django.conf import settings
6468

@@ -181,40 +185,67 @@ def extract_request_user(self, request):
181185
return user_id, email
182186

183187
def __call__(self, request):
184-
# type: (HttpRequest) -> HttpResponse
185-
# Purely defensive around django's internal sync/async handling - this should be unreachable, but if it's reached, we may
186-
# as well return something semi-meaningful
188+
# type: (HttpRequest) -> Union[HttpResponse, Awaitable[HttpResponse]]
189+
"""
190+
Unified entry point for both sync and async request handling.
191+
192+
When sync_capable and async_capable are both True, Django passes requests
193+
without conversion. This method detects the mode and routes accordingly.
194+
"""
187195
if self._is_coroutine:
188-
raise RuntimeError(
189-
"PosthogContextMiddleware received sync call but get_response is async"
190-
)
196+
return self.__acall__(request)
197+
else:
198+
# Synchronous path
199+
if self.request_filter and not self.request_filter(request):
200+
return self.get_response(request)
201+
202+
with contexts.new_context(self.capture_exceptions, client=self.client):
203+
for k, v in self.extract_tags(request).items():
204+
contexts.tag(k, v)
205+
206+
return self.get_response(request)
191207

208+
async def __acall__(self, request):
209+
# type: (HttpRequest) -> Awaitable[HttpResponse]
210+
"""
211+
Asynchronous entry point for async request handling.
212+
213+
This method is called when the middleware chain is async.
214+
"""
192215
if self.request_filter and not self.request_filter(request):
193-
assert self._sync_get_response is not None
194-
return self._sync_get_response(request)
216+
return await self.get_response(request)
195217

196218
with contexts.new_context(self.capture_exceptions, client=self.client):
197219
for k, v in self.extract_tags(request).items():
198220
contexts.tag(k, v)
199221

200-
assert self._sync_get_response is not None
201-
return self._sync_get_response(request)
222+
return await self.get_response(request)
202223

203-
async def __acall__(self, request):
204-
# type: (HttpRequest) -> HttpResponse
224+
def process_exception(self, request, exception):
225+
# type: (HttpRequest, Exception) -> None
226+
"""
227+
Process exceptions from views and downstream middleware.
228+
229+
Django calls this WHILE still inside the context created by __call__,
230+
so request tags have already been extracted and set. This method just
231+
needs to capture the exception directly.
232+
233+
Django converts view exceptions into responses before they propagate through
234+
the middleware stack, so the context manager in __call__/__acall__ never sees them.
235+
236+
Note: Django's process_exception is always synchronous, even for async views.
237+
"""
205238
if self.request_filter and not self.request_filter(request):
206-
if self._async_get_response is not None:
207-
return await self._async_get_response(request)
208-
else:
209-
assert self._sync_get_response is not None
210-
return self._sync_get_response(request)
239+
return
211240

212-
with contexts.new_context(self.capture_exceptions, client=self.client):
213-
for k, v in self.extract_tags(request).items():
214-
contexts.tag(k, v)
241+
if not self.capture_exceptions:
242+
return
243+
244+
# Context and tags already set by __call__ or __acall__
245+
# Just capture the exception
246+
if self.client:
247+
self.client.capture_exception(exception)
248+
else:
249+
from posthog import capture_exception
215250

216-
if self._async_get_response is not None:
217-
return await self._async_get_response(request)
218-
else:
219-
assert self._sync_get_response is not None
220-
return self._sync_get_response(request)
251+
capture_exception(exception)

posthog/test/ai/langchain/test_callbacks.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,7 @@ def test_basic_chat_chain(mock_client, stream):
204204
# Generation is second
205205
assert generation_args["event"] == "$ai_generation"
206206
assert "distinct_id" in generation_args
207+
assert generation_props["$ai_framework"] == "langchain"
207208
assert "$ai_model" in generation_props
208209
assert "$ai_provider" in generation_props
209210
assert generation_props["$ai_input"] == [

0 commit comments

Comments
 (0)