Skip to content

Commit f792038

Browse files
authored
feat: Implement error tracking and surveys for customer feedback (#634)
1 parent 28d49f9 commit f792038

7 files changed

Lines changed: 133 additions & 4 deletions

File tree

.env.example

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,8 @@ APPLE_CODESIGN_KEYCHAIN_PASSWORD="xxx"
99

1010
VITE_POSTHOG_API_KEY=xxx
1111
VITE_POSTHOG_API_HOST=xxx
12-
VITE_POSTHOG_UI_HOST=xxx
12+
VITE_POSTHOG_UI_HOST=xxx
13+
14+
# PostHog Survey IDs (optional)
15+
VITE_POSTHOG_BUG_SURVEY_ID=
16+
VITE_POSTHOG_FEEDBACK_SURVEY_ID=

apps/twig/src/main/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type { DockBadgeService } from "./services/dock-badge/service.js";
1414
import type { ExternalAppsService } from "./services/external-apps/service.js";
1515
import type { OAuthService } from "./services/oauth/service.js";
1616
import {
17+
captureException,
1718
initializePostHog,
1819
trackAppEvent,
1920
} from "./services/posthog-analytics.js";
@@ -123,8 +124,13 @@ process.on("uncaughtException", (error) => {
123124
return;
124125
}
125126
log.error("Uncaught exception", error);
127+
128+
captureException(error, { source: "main", type: "uncaughtException" });
126129
});
127130

128131
process.on("unhandledRejection", (reason) => {
129132
log.error("Unhandled rejection", reason);
133+
134+
const error = reason instanceof Error ? reason : new Error(String(reason));
135+
captureException(error, { source: "main", type: "unhandledRejection" });
130136
});

apps/twig/src/main/services/posthog-analytics.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,15 @@ export function getPostHogClient() {
8383
export function resetUser() {
8484
currentUserId = null;
8585
}
86+
87+
export function captureException(
88+
error: unknown,
89+
additionalProperties?: Record<string, unknown>,
90+
) {
91+
if (!posthogClient) {
92+
return;
93+
}
94+
95+
const distinctId = currentUserId || "anonymous-app-event";
96+
posthogClient.captureException(error, distinctId, additionalProperties);
97+
}

apps/twig/src/renderer/components/ErrorBoundary.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Warning } from "@phosphor-icons/react";
22
import { Box, Button, Callout, Flex, Text } from "@radix-ui/themes";
3+
import { captureException } from "@renderer/lib/analytics";
34
import { logger } from "@renderer/lib/logger";
45
import { Component, type ErrorInfo, type ReactNode } from "react";
56

@@ -34,6 +35,12 @@ export class ErrorBoundary extends Component<Props, State> {
3435
stack: error.stack,
3536
componentStack: errorInfo.componentStack,
3637
});
38+
39+
captureException(error, {
40+
$exception_component_stack: errorInfo.componentStack,
41+
boundary_name: this.props.name,
42+
source: "error-boundary",
43+
});
3744
}
3845

3946
handleRetry = () => {
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { ChatCircle } from "@phosphor-icons/react";
2+
import { DropdownMenu, IconButton, Text, Tooltip } from "@radix-ui/themes";
3+
import { displaySurvey, getPostHog } from "@renderer/lib/analytics";
4+
import { useCallback } from "react";
5+
6+
type FeedbackType = "bug" | "feedback";
7+
8+
const hasBugSurvey = !!import.meta.env.VITE_POSTHOG_BUG_SURVEY_ID;
9+
const hasFeedbackSurvey = !!import.meta.env.VITE_POSTHOG_FEEDBACK_SURVEY_ID;
10+
11+
export function FeedbackToggle() {
12+
const handleFeedback = useCallback((type: FeedbackType) => {
13+
const surveyId =
14+
type === "bug"
15+
? import.meta.env.VITE_POSTHOG_BUG_SURVEY_ID
16+
: import.meta.env.VITE_POSTHOG_FEEDBACK_SURVEY_ID;
17+
18+
if (surveyId) {
19+
displaySurvey(surveyId);
20+
}
21+
22+
getPostHog()?.capture("Feedback button clicked", {
23+
feedback_type: type,
24+
});
25+
}, []);
26+
27+
if (!hasBugSurvey && !hasFeedbackSurvey) return null;
28+
29+
return (
30+
<DropdownMenu.Root>
31+
<Tooltip content="Send Feedback">
32+
<DropdownMenu.Trigger>
33+
<IconButton
34+
size="1"
35+
variant="ghost"
36+
style={{ color: "var(--gray-9)" }}
37+
>
38+
<ChatCircle size={16} />
39+
</IconButton>
40+
</DropdownMenu.Trigger>
41+
</Tooltip>
42+
<DropdownMenu.Content size="1" align="end">
43+
<DropdownMenu.Item onClick={() => handleFeedback("bug")}>
44+
<Text size="1">Report a Bug</Text>
45+
</DropdownMenu.Item>
46+
<DropdownMenu.Item onClick={() => handleFeedback("feedback")}>
47+
<Text size="1">Share Feedback</Text>
48+
</DropdownMenu.Item>
49+
</DropdownMenu.Content>
50+
</DropdownMenu.Root>
51+
);
52+
}

apps/twig/src/renderer/components/StatusBar.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { CampfireToggle } from "@components/CampfireToggle";
2+
import { FeedbackToggle } from "@components/FeedbackToggle";
23
import { SettingsToggle } from "@components/SettingsToggle";
34
import { StatusBarMenu } from "@components/StatusBarMenu";
45
import { Badge, Box, Code, Flex, Kbd } from "@radix-ui/themes";
@@ -49,6 +50,7 @@ export function StatusBar({ showKeyHints = true }: StatusBarProps) {
4950

5051
<Flex align="center" gap="2">
5152
<CampfireToggle />
53+
<FeedbackToggle />
5254
<SettingsToggle />
5355
{IS_DEV && (
5456
<Badge size="1">

apps/twig/src/renderer/lib/analytics.ts

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ export function initializePostHog() {
2828
api_host: apiHost,
2929
ui_host: uiHost,
3030
disable_session_recording: false,
31+
capture_exceptions: true,
3132
loaded: () => {
3233
log.info("PostHog loaded");
3334
// Start session recording immediately after load
@@ -88,13 +89,19 @@ export function identifyUser(
8889
userId: string,
8990
properties?: UserIdentifyProperties,
9091
) {
91-
if (!isInitialized) return;
92+
if (!isInitialized) {
93+
log.warn("PostHog not initialized, cannot identify user");
94+
return;
95+
}
9296

9397
posthog.identify(userId, properties);
9498
}
9599

96100
export function resetUser() {
97-
if (!isInitialized) return;
101+
if (!isInitialized) {
102+
log.warn("PostHog not initialized, cannot reset user");
103+
return;
104+
}
98105

99106
posthog.reset();
100107
}
@@ -107,6 +114,45 @@ export function track<K extends keyof EventPropertyMap>(
107114
? [properties?: EventPropertyMap[K]]
108115
: [properties: EventPropertyMap[K]]
109116
) {
110-
if (!isInitialized) return;
117+
if (!isInitialized) {
118+
log.warn("PostHog not initialized, cannot track event");
119+
return;
120+
}
121+
111122
posthog.capture(eventName, args[0]);
112123
}
124+
125+
/**
126+
* Capture an exception for error tracking using PostHog's built-in exception tracking.
127+
*/
128+
export function captureException(
129+
error: Error,
130+
additionalProperties?: Record<string, unknown>,
131+
) {
132+
if (!isInitialized) {
133+
log.warn("PostHog not initialized, cannot capture exception");
134+
return;
135+
}
136+
137+
posthog.captureException(error, additionalProperties);
138+
}
139+
140+
/**
141+
* Get the PostHog instance for direct access
142+
*/
143+
export function getPostHog() {
144+
return isInitialized ? posthog : null;
145+
}
146+
147+
// ============================================================================
148+
// Surveys
149+
// ============================================================================
150+
151+
export function displaySurvey(surveyId: string) {
152+
if (!isInitialized) {
153+
log.warn("PostHog not initialized, cannot display survey");
154+
return;
155+
}
156+
157+
posthog.displaySurvey(surveyId);
158+
}

0 commit comments

Comments
 (0)