Skip to content

Commit 0aa9760

Browse files
feat(analytics): attach app version to all custom events (#2372)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent e4e31bc commit 0aa9760

6 files changed

Lines changed: 177 additions & 4 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const mockCapture = vi.hoisted(() => vi.fn());
4+
const mockCaptureException = vi.hoisted(() => vi.fn());
5+
const mockIdentify = vi.hoisted(() => vi.fn());
6+
const mockShutdown = vi.hoisted(() => vi.fn());
7+
const MockPostHog = vi.hoisted(() => vi.fn());
8+
9+
vi.mock("posthog-node", () => ({ PostHog: MockPostHog }));
10+
11+
import {
12+
captureException,
13+
initializePostHog,
14+
resetUser,
15+
shutdownPostHog,
16+
trackAppEvent,
17+
} from "./posthog-analytics";
18+
19+
describe("posthog-analytics", () => {
20+
beforeEach(() => {
21+
vi.clearAllMocks();
22+
MockPostHog.mockImplementation(function (this: Record<string, unknown>) {
23+
this.capture = mockCapture;
24+
this.captureException = mockCaptureException;
25+
this.identify = mockIdentify;
26+
this.shutdown = mockShutdown;
27+
});
28+
process.env.VITE_POSTHOG_API_KEY = "test-key";
29+
resetUser();
30+
initializePostHog();
31+
});
32+
33+
afterEach(async () => {
34+
await shutdownPostHog();
35+
});
36+
37+
it("includes the app version on every tracked event", () => {
38+
trackAppEvent("app_started");
39+
40+
expect(mockCapture).toHaveBeenCalledWith(
41+
expect.objectContaining({
42+
event: "app_started",
43+
properties: expect.objectContaining({
44+
team: "posthog-code",
45+
app_version: "0.0.0-test",
46+
}),
47+
}),
48+
);
49+
});
50+
51+
it("lets caller-supplied properties coexist with the app version", () => {
52+
trackAppEvent("app_quit", { reason: "user-initiated" });
53+
54+
expect(mockCapture).toHaveBeenCalledWith(
55+
expect.objectContaining({
56+
properties: expect.objectContaining({
57+
reason: "user-initiated",
58+
app_version: "0.0.0-test",
59+
}),
60+
}),
61+
);
62+
});
63+
64+
it("does not let caller-supplied app_version override the system value", () => {
65+
trackAppEvent("app_quit", { app_version: "spoofed" });
66+
67+
expect(mockCapture).toHaveBeenCalledWith(
68+
expect.objectContaining({
69+
properties: expect.objectContaining({
70+
app_version: "0.0.0-test",
71+
}),
72+
}),
73+
);
74+
});
75+
76+
it("includes the app version on captured exceptions", () => {
77+
captureException(new Error("boom"));
78+
79+
expect(mockCaptureException).toHaveBeenCalledWith(
80+
expect.any(Error),
81+
expect.any(String),
82+
expect.objectContaining({
83+
team: "posthog-code",
84+
app_version: "0.0.0-test",
85+
}),
86+
);
87+
});
88+
89+
it("does not let additionalProperties override app_version on exceptions", () => {
90+
captureException(new Error("boom"), { app_version: "spoofed" });
91+
92+
expect(mockCaptureException).toHaveBeenCalledWith(
93+
expect.any(Error),
94+
expect.any(String),
95+
expect.objectContaining({
96+
app_version: "0.0.0-test",
97+
}),
98+
);
99+
});
100+
});

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PostHog } from "posthog-node";
2+
import { getAppVersion } from "../utils/env";
23

34
let posthogClient: PostHog | null = null;
45
let currentUserId: string | null = null;
@@ -47,6 +48,7 @@ export function trackAppEvent(
4748
properties: {
4849
team: "posthog-code",
4950
...properties,
51+
app_version: getAppVersion(),
5052
$process_person_profile: !!currentUserId,
5153
},
5254
});
@@ -95,5 +97,6 @@ export function captureException(
9597
posthogClient.captureException(error, distinctId, {
9698
team: "posthog-code",
9799
...additionalProperties,
100+
app_version: getAppVersion(),
98101
});
99102
}

apps/code/src/renderer/App.tsx

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ import { isNotAuthenticatedError } from "@shared/errors";
2727
import { ANALYTICS_EVENTS } from "@shared/types/analytics";
2828
import { useQueryClient } from "@tanstack/react-query";
2929
import { useSubscription } from "@trpc/tanstack-react-query";
30-
import { initializePostHog, track } from "@utils/analytics";
30+
import { initializePostHog, registerAppVersion, track } from "@utils/analytics";
3131
import { logger } from "@utils/logger";
3232
import { toast } from "@utils/toast";
3333
import { AnimatePresence, motion } from "framer-motion";
@@ -49,9 +49,15 @@ function App() {
4949
const [showTransition, setShowTransition] = useState(false);
5050
const wasInMainApp = useRef(isAuthenticated && hasCompletedOnboarding);
5151

52-
// Initialize PostHog analytics
52+
// Initialize PostHog analytics and register the app version super property.
5353
useEffect(() => {
5454
initializePostHog();
55+
trpcClient.os.getAppVersion
56+
.query()
57+
.then(registerAppVersion)
58+
.catch((error) => {
59+
log.warn("Failed to register app version super property", { error });
60+
});
5561
}, []);
5662

5763
// Initialize connectivity monitoring

apps/code/src/renderer/utils/analytics.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,41 @@ describe("onFeatureFlagsLoaded", () => {
9191
});
9292
});
9393

94+
describe("registerAppVersion", () => {
95+
it("registers app_version as a super property after init", async () => {
96+
const { initializePostHog, registerAppVersion } = await loadAnalytics();
97+
98+
initializePostHog();
99+
registerAppVersion("1.2.3");
100+
101+
expect(mockPosthog.register).toHaveBeenCalledWith({ app_version: "1.2.3" });
102+
});
103+
104+
it("does nothing before init", async () => {
105+
const { registerAppVersion } = await loadAnalytics();
106+
107+
registerAppVersion("1.2.3");
108+
109+
expect(mockPosthog.register).not.toHaveBeenCalled();
110+
});
111+
112+
it("re-registers app_version after resetUser clears super properties", async () => {
113+
const { initializePostHog, registerAppVersion, resetUser } =
114+
await loadAnalytics();
115+
116+
initializePostHog();
117+
registerAppVersion("1.2.3");
118+
119+
resetUser();
120+
121+
expect(mockPosthog.reset).toHaveBeenCalledTimes(1);
122+
expect(mockPosthog.register).toHaveBeenLastCalledWith({
123+
team: "posthog-code",
124+
app_version: "1.2.3",
125+
});
126+
});
127+
});
128+
94129
describe("initializePostHog", () => {
95130
it("is idempotent across repeat calls", async () => {
96131
const { initializePostHog } = await loadAnalytics();

apps/code/src/renderer/utils/analytics.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,19 @@ const log = logger.scope("analytics");
1414

1515
let isInitialized = false;
1616

17+
// Cached so it can be re-applied after posthog.reset() clears super properties.
18+
let registeredAppVersion: string | null = null;
19+
20+
// posthog.reset() wipes super properties, so these are re-registered after each reset.
21+
function registerPersistentSuperProperties() {
22+
posthog.register({
23+
team: "posthog-code",
24+
...(registeredAppVersion !== null
25+
? { app_version: registeredAppVersion }
26+
: {}),
27+
});
28+
}
29+
1730
type PendingFlagListener = {
1831
callback: () => void;
1932
unsubscribe: (() => void) | null;
@@ -49,10 +62,10 @@ export function initializePostHog() {
4962
},
5063
});
5164

52-
posthog.register({ team: "posthog-code" });
53-
5465
isInitialized = true;
5566

67+
registerPersistentSuperProperties();
68+
5669
for (const listener of pendingFlagListeners) {
5770
listener.unsubscribe = posthog.onFeatureFlags(listener.callback);
5871
}
@@ -102,6 +115,17 @@ export function startSessionRecording() {
102115
}, 1000);
103116
}
104117

118+
// Register the app version as a super property so it rides along on every event.
119+
export function registerAppVersion(appVersion: string) {
120+
registeredAppVersion = appVersion;
121+
122+
if (!isInitialized) {
123+
return;
124+
}
125+
126+
posthog.register({ app_version: appVersion });
127+
}
128+
105129
export function identifyUser(
106130
userId: string,
107131
properties?: UserIdentifyProperties,
@@ -146,6 +170,9 @@ export function resetUser() {
146170
}
147171

148172
posthog.reset();
173+
174+
// reset() clears super properties; re-apply the persistent ones.
175+
registerPersistentSuperProperties();
149176
}
150177

151178
export function track<K extends keyof EventPropertyMap>(

apps/code/vite-plugin-auto-services.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ export function autoServicesPlugin(servicesDir: string): Plugin {
1414
(f) =>
1515
f.endsWith(".ts") &&
1616
!f.endsWith(".types.ts") &&
17+
!f.endsWith(".test.ts") &&
18+
!f.endsWith(".spec.ts") &&
1719
f !== "index.ts" &&
1820
f !== "types.ts",
1921
);

0 commit comments

Comments
 (0)