feat(desktop): track full onboarding chat messages in PostHog#6011
Conversation
Adds onboarding_chat_message_detailed events with full message text, tool calls, model used, and errors for debugging onboarding issues. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Greptile SummaryThis PR adds Key issues found:
Confidence Score: 2/5
Important Files Changed
Sequence DiagramsequenceDiagram
participant User
participant ChatProvider
participant ACPBridge
participant Backend as APIClient
participant PostHog
User->>ChatProvider: sendMessage(text)
ChatProvider->>PostHog: onboarding_chat_message_detailed(role=user, text=trimmedText)
ChatProvider->>ACPBridge: query(prompt, ...) [streaming]
ACPBridge-->>ChatProvider: textDelta callbacks (populate messages[aiMessageId].text)
ACPBridge-->>ChatProvider: queryResult (queryResult.text may be empty for streaming)
ChatProvider->>Backend: saveMessage(aiText)
Backend-->>ChatProvider: response.id
ChatProvider->>ChatProvider: messages[aiMessageId].id = response.id ⚠️ ID replaced
Note over ChatProvider: aiMessageId is now stale
ChatProvider->>PostHog: onboarding_chat_message_detailed(role=assistant, text=??)<br/>lookup by aiMessageId → nil → fallback to queryResult.text (empty)
alt Error path
ACPBridge-->>ChatProvider: throw error
ChatProvider->>PostHog: onboarding_chat_message_detailed(role=error, text=trimmedText, error=rawError)
end
Reviews (1): Last reviewed commit: "feat(desktop): track full onboarding cha..." | Re-trigger Greptile |
|
|
||
| // Track onboarding AI responses with full content and tool calls | ||
| if isOnboarding { | ||
| let aiText = messages.first(where: { $0.id == aiMessageId })?.text ?? queryResult.text |
There was a problem hiding this comment.
Stale
aiMessageId lookup returns nil, falling back to empty text
By the time execution reaches line 2111, the saveMessage call (lines 2082–2094) has already awaited and, on success, replaced the in-memory message's id from aiMessageId (a local UUID) to the server-assigned response.id:
if let syncIndex = messages.firstIndex(where: { $0.id == aiMessageId }) {
messages[syncIndex].id = response.id // ← ID is no longer aiMessageId here
messages[syncIndex].isSynced = true
}So messages.first(where: { $0.id == aiMessageId }) returns nil, and the code falls back to queryResult.text. For streaming responses, queryResult.text is typically empty (the streamed text lives in messages[index].text, not in queryResult.text), meaning the PostHog event is sent with text: "".
messageText (declared earlier at line 2045 and populated before the ID swap) already holds the correct final AI text. Using it directly avoids the stale lookup:
| let aiText = messages.first(where: { $0.id == aiMessageId })?.text ?? queryResult.text | |
| let aiText = messageText |
| // Track onboarding errors with full context | ||
| if isOnboarding { | ||
| AnalyticsManager.shared.onboardingChatMessageDetailed( | ||
| role: "error", text: trimmedText, step: "chat", | ||
| error: rawError | ||
| ) | ||
| } |
There was a problem hiding this comment.
Error event
text field sends user's query, not the error
The PR description states that error events capture "raw error string when chat fails", but here text: trimmedText is the user's query that triggered the error (not the error itself). The actual error string is in the error: parameter. This means the text field in error events has the opposite semantics from what the PR description implies, and PostHog queries filtering/grouping on text will get the user input rather than the error message.
If the intent is to record the error text in the text field for uniform query patterns, the arguments should be swapped:
| // Track onboarding errors with full context | |
| if isOnboarding { | |
| AnalyticsManager.shared.onboardingChatMessageDetailed( | |
| role: "error", text: trimmedText, step: "chat", | |
| error: rawError | |
| ) | |
| } | |
| AnalyticsManager.shared.onboardingChatMessageDetailed( | |
| role: "error", text: rawError, step: "chat", | |
| error: rawError | |
| ) |
Alternatively, if keeping text as the user query is intentional, the PR description and any PostHog dashboards should clarify that text carries the user's input in the role=error case.
| props["tool_calls"] = toolCalls.joined(separator: ", ") | ||
| } | ||
| if let model = model { props["model"] = model } | ||
| if let error = error { props["error"] = error } |
There was a problem hiding this comment.
error field lacks truncation unlike other error properties
The text field is capped at 2000 chars (String(text.prefix(2000))), and the existing chatAgentError method truncates rawError to 500 chars. The new error property has no upper bound. While errors are usually short, a stack trace or a very verbose bridge error could be unbounded. For consistency with the rest of the analytics codebase, consider adding a cap:
| if let error = error { props["error"] = error } | |
| if let error = error { props["error"] = String(error.prefix(500)) } |
…ardware#6011) ## Summary Adds `onboarding_chat_message_detailed` PostHog events that capture: - **User messages**: full text sent during onboarding chat - **AI responses**: full text, tool calls used, model - **Errors**: raw error string when chat fails Query in PostHog: filter by `onboarding_chat_message_detailed` and group by `distinct_id` to reconstruct any user's full onboarding conversation. ## Test plan - [ ] Go through onboarding, check PostHog for `onboarding_chat_message_detailed` events - [ ] Verify user messages have `role=user` and AI responses have `role=assistant` - [ ] Verify tool calls appear in the `tool_calls` property 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Summary
Adds
onboarding_chat_message_detailedPostHog events that capture:Query in PostHog: filter by
onboarding_chat_message_detailedand group bydistinct_idto reconstruct any user's full onboarding conversation.Test plan
onboarding_chat_message_detailedeventsrole=userand AI responses haverole=assistanttool_callsproperty🤖 Generated with Claude Code