Skip to content

Commit cf44d1b

Browse files
authored
chore: add PostHog telemetry tracing for LLM assistant operations (baserow#4299)
* Add PostHog telemetry tracing for LLM assistant operations This commit adds comprehensive PostHog telemetry integration to track and analyze LLM assistant operations including: - uDSPy module execution (ChainOfThought, ReAct, Predict) - LLM API calls with automatic generation tracking - Tool invocations - Performance metrics (latency, token usage) - Error tracking with exception messages The telemetry system: - Captures hierarchical span relationships for nested operations - Patches OpenAI client for automatic generation event tracking - Handles errors gracefully without breaking assistant execution - Respects POSTHOG_ENABLED setting - Adds workspace and user context to all events Run the telemetry test suite: ```bash just pytest -k test_telemetry ``` All 8 tests should pass, covering: - Trace context manager (success and exception paths) - Module execution tracing (ChainOfThought, ReAct, Predict) - LM callback enrichment - Tool execution tracing - Exception handling (module and tool failures) - Telemetry disabled state To test manually: 1. Ensure POSTHOG_ENABLED=True in settings 2. Set up PostHog project credentials 3. Use the assistant and verify events appear in PostHog 4. Check for $ai_trace, $ai_span, and $ai_generation events 5. Verify span hierarchy and context propagation * Fix issue when POSTHOG_PROJECT_API_KEY is not set * Fix tests; capture all spans; add trace input and output * Bump udspy version
1 parent 39e7722 commit cf44d1b

File tree

9 files changed

+635
-58
lines changed

9 files changed

+635
-58
lines changed

backend/requirements/base.in

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ Brotli==1.1.0
6464
loguru==0.7.2
6565
django-cachalot==2.6.2
6666
celery-singleton==0.3.1
67-
posthog==3.5.0
67+
posthog==7.0.1
6868
https://github.com/fellowapp/prosemirror-py/archive/refs/tags/v0.3.5.zip
6969
rich==13.7.1
7070
tzdata==2025.2
@@ -88,4 +88,4 @@ httpcore==1.0.9 # Pinned to address vulnerability.
8888
genson==1.3.0
8989
pyotp==2.9.0
9090
qrcode==8.2
91-
udspy==0.1.7
91+
udspy==0.1.8

backend/requirements/base.txt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ distro==1.9.0
125125
# via
126126
# anthropic
127127
# openai
128+
# posthog
128129
dj-database-url==2.1.0
129130
# via -r base.in
130131
django==5.0.14
@@ -311,8 +312,6 @@ mdurl==0.1.2
311312
# via markdown-it-py
312313
mistralai==1.1.0
313314
# via -r base.in
314-
monotonic==1.6
315-
# via posthog
316315
msgpack==1.1.0
317316
# via channels-redis
318317
mypy-extensions==1.0.0
@@ -454,7 +453,7 @@ pgvector==0.4.1
454453
# via -r base.in
455454
pillow==10.3.0
456455
# via -r base.in
457-
posthog==3.5.0
456+
posthog==7.0.1
458457
# via -r base.in
459458
prometheus-client==0.21.1
460459
# via flower
@@ -665,6 +664,7 @@ typing-extensions==4.11.0
665664
# opentelemetry-exporter-otlp-proto-http
666665
# opentelemetry-sdk
667666
# opentelemetry-semantic-conventions
667+
# posthog
668668
# prosemirror
669669
# pydantic
670670
# pydantic-core
@@ -679,7 +679,7 @@ tzdata==2025.2
679679
# -r base.in
680680
# django-celery-beat
681681
# kombu
682-
udspy==0.1.7
682+
udspy==0.1.8
683683
# via -r base.in
684684
unicodecsv==0.14.1
685685
# via -r base.in

backend/src/baserow/config/settings/base.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
from django.core.exceptions import ImproperlyConfigured
1212

1313
import dj_database_url
14-
import posthog
1514
import sentry_sdk
1615
from corsheaders.defaults import default_headers
1716
from sentry_sdk.integrations.django import DjangoIntegration
@@ -1243,13 +1242,8 @@ def __setitem__(self, key, value):
12431242
)
12441243

12451244
POSTHOG_PROJECT_API_KEY = os.getenv("POSTHOG_PROJECT_API_KEY", "")
1246-
POSTHOG_HOST = os.getenv("POSTHOG_HOST", "")
1247-
POSTHOG_ENABLED = POSTHOG_PROJECT_API_KEY and POSTHOG_HOST
1248-
if POSTHOG_ENABLED:
1249-
posthog.project_api_key = POSTHOG_PROJECT_API_KEY
1250-
posthog.host = POSTHOG_HOST
1251-
else:
1252-
posthog.disabled = True
1245+
POSTHOG_HOST = os.getenv("POSTHOG_HOST") or None
1246+
POSTHOG_ENABLED = bool(POSTHOG_PROJECT_API_KEY)
12531247

12541248
BASEROW_BUILDER_DOMAINS = os.getenv("BASEROW_BUILDER_DOMAINS", None)
12551249
BASEROW_BUILDER_DOMAINS = (

backend/src/baserow/core/posthog.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,20 @@
66
from django.contrib.auth.models import AbstractUser
77
from django.dispatch import receiver
88

9-
import posthog
109
from loguru import logger
10+
from posthog import Posthog
1111

1212
from baserow.core.action.signals import ActionCommandType, action_done
1313
from baserow.core.models import Workspace
1414
from baserow.core.utils import exception_capturer
1515

16+
posthog_client = Posthog(
17+
settings.POSTHOG_PROJECT_API_KEY,
18+
settings.POSTHOG_HOST,
19+
# disabled=True will automatically avoid sending any data, even if capture is called
20+
disabled=not settings.POSTHOG_ENABLED,
21+
)
22+
1623

1724
def capture_event(distinct_id: str, event: str, properties: dict):
1825
"""
@@ -28,7 +35,7 @@ def capture_event(distinct_id: str, event: str, properties: dict):
2835
return
2936

3037
try:
31-
posthog.capture(
38+
posthog_client.capture(
3239
distinct_id=distinct_id,
3340
event=event,
3441
properties=properties,

backend/tests/baserow/core/test_posthog.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ def scope(cls, *args, **kwargs):
2626

2727
@pytest.mark.django_db
2828
@override_settings(POSTHOG_ENABLED=False)
29-
@patch("baserow.core.posthog.posthog")
29+
@patch("baserow.core.posthog.posthog_client")
3030
def test_not_capture_event_if_not_enabled(mock_posthog, data_fixture):
3131
user = data_fixture.create_user()
3232
capture_user_event(user, "test", {})
@@ -35,7 +35,7 @@ def test_not_capture_event_if_not_enabled(mock_posthog, data_fixture):
3535

3636
@pytest.mark.django_db
3737
@override_settings(POSTHOG_ENABLED=True)
38-
@patch("baserow.core.posthog.posthog")
38+
@patch("baserow.core.posthog.posthog_client")
3939
def test_capture_event_if_enabled(mock_posthog, data_fixture):
4040
user = data_fixture.create_user()
4141
workspace = data_fixture.create_workspace()

enterprise/backend/src/baserow_enterprise/assistant/assistant.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
AssistantMessageCancelled,
1616
AssistantModelNotSupportedError,
1717
)
18+
from baserow_enterprise.assistant.telemetry import PosthogTracingCallback
1819
from baserow_enterprise.assistant.tools.navigation.types import AnyNavigationRequestType
1920
from baserow_enterprise.assistant.tools.navigation.utils import unsafe_navigate_to
2021
from baserow_enterprise.assistant.tools.registries import assistant_tool_registry
@@ -190,22 +191,42 @@ def _init_assistant(self):
190191
self._user, self._workspace, self.tool_helpers
191192
)
192193
]
193-
self.callbacks = AssistantCallbacks(self.tool_helpers)
194+
195+
self._assistant_callbacks = AssistantCallbacks(self.tool_helpers)
196+
self._telemetry_callbacks = PosthogTracingCallback()
197+
self._callbacks = [self._assistant_callbacks, self._telemetry_callbacks]
194198

195199
module_kwargs = {
196200
"temperature": settings.BASEROW_ENTERPRISE_ASSISTANT_LLM_TEMPERATURE,
197201
"response_format": {"type": "json_object"},
198202
}
199-
200-
self.search_user_docs_tool = next(
201-
(tool for tool in tools if tool.name == "search_user_docs"), None
202-
)
203+
self.search_user_docs_tool = self._get_search_user_docs_tool(tools)
203204
self.agent_tools = tools
204205
self._request_router = udspy.ChainOfThought(RequestRouter, **module_kwargs)
205206
self._assistant = udspy.ReAct(
206207
ChatSignature, tools=self.agent_tools, max_iters=20, **module_kwargs
207208
)
208209

210+
def _get_search_user_docs_tool(
211+
self, tools: list[udspy.Tool | Callable]
212+
) -> udspy.Tool | None:
213+
"""
214+
Retrieves the search_user_docs tool from the list of tools if available.
215+
216+
:param tools: The list of tools to search through.
217+
:return: The search_user_docs as udspy.Tool or None if not found.
218+
"""
219+
220+
search_user_docs_tool = next(
221+
(tool for tool in tools if tool.name == "search_user_docs"), None
222+
)
223+
if search_user_docs_tool is None or isinstance(
224+
search_user_docs_tool, udspy.Tool
225+
):
226+
return search_user_docs_tool
227+
228+
return udspy.Tool(search_user_docs_tool)
229+
209230
async def acreate_chat_message(
210231
self,
211232
role: AssistantChatMessage.Role,
@@ -360,7 +381,7 @@ async def _acreate_ai_message_response(
360381
:return: The created AiMessage instance to return to the user.
361382
"""
362383

363-
sources = self.callbacks.sources
384+
sources = self._assistant_callbacks.sources
364385
ai_msg = await self.acreate_chat_message(
365386
AssistantChatMessage.Role.AI,
366387
prediction.answer,
@@ -449,7 +470,7 @@ async def _process_router_stream(
449470
messages.append(
450471
AiMessageChunk(
451472
content=event.content,
452-
sources=self.callbacks.sources,
473+
sources=self._assistant_callbacks.sources,
453474
)
454475
)
455476

@@ -472,7 +493,6 @@ async def _process_router_stream(
472493
"the local knowledge base. \n\n"
473494
"You can find setup instructions at: https://baserow.io/user-docs"
474495
),
475-
sources=[],
476496
)
477497
)
478498
elif getattr(event, "answer", None):
@@ -510,7 +530,7 @@ async def _process_agent_stream(
510530
messages.append(
511531
AiMessageChunk(
512532
content=event.content,
513-
sources=self.callbacks.sources,
533+
sources=self._assistant_callbacks.sources,
514534
)
515535
)
516536

@@ -586,11 +606,12 @@ async def astream_messages(
586606
AssistantChatMessage.Role.HUMAN,
587607
message.content,
588608
)
609+
default_callbacks = udspy.settings.callbacks
589610

590611
with udspy.settings.context(
591612
lm=self._lm_client,
592-
callbacks=[*udspy.settings.callbacks, self.callbacks],
593-
):
613+
callbacks=[*default_callbacks, *self._callbacks],
614+
), self._telemetry_callbacks.trace(self._chat, human_msg.content):
594615
message_id = str(human_msg.id)
595616
yield AiStartedMessage(message_id=message_id)
596617

0 commit comments

Comments
 (0)