Skip to content

Commit a8bfcaf

Browse files
carderneTrigger.dev RepoOps
authored andcommitted
feat(webapp): track logs search usage and outcomes
Mono-RevId: e0bebe4452f6a4dd76f167774a03d9b32cddaf48
1 parent 13d02be commit a8bfcaf

8 files changed

Lines changed: 712 additions & 13 deletions

File tree

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
// @vitest-environment jsdom
2+
import { createElement } from "react";
3+
import { createRoot } from "react-dom/client";
4+
import { act } from "react-dom/test-utils";
5+
import { createMemoryRouter, RouterProvider, useLoaderData } from "react-router-dom";
6+
import { expect, it } from "vitest";
7+
import { LogsSearchAnalytics, logsSearchKey } from "../utils/logsSearchAnalytics";
8+
import { useLogsSearchLifecycle } from "./useLogsSearchAnalytics";
9+
10+
const logsPath = "/orgs/example/projects/project/env/prod/logs";
11+
const queryResult = {
12+
logs: [{ message: "never sent" }],
13+
filters: { from: new Date(0), to: new Date(3600_000) },
14+
searchProgress: {
15+
complete: true,
16+
timedOut: false,
17+
stopped: false,
18+
expired: false,
19+
queryElapsedMs: 10,
20+
},
21+
};
22+
23+
async function mount(data: Promise<typeof queryResult>) {
24+
const events: { event: string; properties: Record<string, unknown> }[] = [];
25+
const analytics = new LogsSearchAnalytics((event, properties) =>
26+
events.push({ event, properties })
27+
);
28+
let releaseOtherPage!: () => void;
29+
const otherPage = new Promise<void>((resolve) => {
30+
releaseOtherPage = resolve;
31+
});
32+
33+
function Page() {
34+
const { requestFilterState } = useLoaderData() as { requestFilterState: string };
35+
useLogsSearchLifecycle(analytics, data, requestFilterState);
36+
return null;
37+
}
38+
39+
const router = createMemoryRouter(
40+
[
41+
{
42+
path: logsPath,
43+
loader: ({ request }) => {
44+
const url = new URL(request.url);
45+
return { requestFilterState: logsSearchKey(url.pathname, url.search) };
46+
},
47+
element: createElement(Page),
48+
},
49+
{ path: "/other", loader: () => otherPage, element: null },
50+
],
51+
{ initialEntries: [logsPath] }
52+
);
53+
const root = createRoot(document.createElement("div"));
54+
await act(async () => {
55+
root.render(createElement(RouterProvider, { router }));
56+
});
57+
return {
58+
analytics,
59+
events,
60+
router,
61+
cleanup: async () => {
62+
await act(async () => {
63+
root.unmount();
64+
});
65+
releaseOtherPage();
66+
router.dispose();
67+
},
68+
};
69+
}
70+
71+
it("keeps the search active across a BFCache restore without a React remount", async () => {
72+
const page = await mount(Promise.resolve(queryResult));
73+
try {
74+
expect(page.events.map(({ event }) => event)).toEqual([
75+
"logs_search_started",
76+
"logs_search_result",
77+
]);
78+
await act(async () => {
79+
window.dispatchEvent(new PageTransitionEvent("pagehide", { persisted: true }));
80+
window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
81+
});
82+
page.analytics.action("open_result");
83+
expect(page.events.at(-1)?.properties).toMatchObject({
84+
action: "open_result",
85+
search_id: page.events[0].properties.search_id,
86+
});
87+
expect(page.events[1].properties).toMatchObject({
88+
timing_origin: "route_mount",
89+
time_range_bucket: "up_to_1h",
90+
});
91+
expect(JSON.stringify(page.events)).not.toContain("never sent");
92+
} finally {
93+
await page.cleanup();
94+
}
95+
});
96+
97+
it("does not cancel or restart a search for an interrupted navigation", async () => {
98+
let resolveQuery!: (result: typeof queryResult) => void;
99+
const data = new Promise<typeof queryResult>((resolve) => {
100+
resolveQuery = resolve;
101+
});
102+
const page = await mount(data);
103+
try {
104+
await act(async () => {
105+
void page.router.navigate("/other");
106+
});
107+
expect(page.events.map(({ event }) => event)).toEqual(["logs_search_started"]);
108+
await act(async () => {
109+
await page.router.navigate(logsPath);
110+
});
111+
expect(page.events.map(({ event }) => event)).toEqual(["logs_search_started"]);
112+
await act(async () => {
113+
resolveQuery(queryResult);
114+
});
115+
expect(page.events.map(({ event }) => event)).toEqual([
116+
"logs_search_started",
117+
"logs_search_result",
118+
]);
119+
expect(page.events[1].properties.search_id).toBe(page.events[0].properties.search_id);
120+
} finally {
121+
await page.cleanup();
122+
}
123+
});
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { useLocation, useNavigation } from "@remix-run/react";
2+
import { useEffect, useRef, useState } from "react";
3+
import { isLogsPage, LogsSearchAnalytics, logsSearchKey } from "~/utils/logsSearchAnalytics";
4+
import { usePostHogTracking } from "./usePostHog";
5+
6+
type SearchData = {
7+
logs: unknown[];
8+
filters?: { from: Date; to: Date };
9+
searchProgress: {
10+
complete: boolean;
11+
timedOut: boolean;
12+
stopped: boolean;
13+
expired: boolean;
14+
queryElapsedMs: number;
15+
};
16+
};
17+
18+
export function reportLogsSearchResponse(
19+
analytics: LogsSearchAnalytics,
20+
key: string,
21+
data: SearchData
22+
) {
23+
analytics.response(key, {
24+
resultCount: data.logs.length,
25+
complete: data.searchProgress.complete,
26+
timedOut: data.searchProgress.timedOut,
27+
stopped: data.searchProgress.stopped,
28+
expired: data.searchProgress.expired,
29+
queryElapsedMs: data.searchProgress.queryElapsedMs,
30+
rangeDurationMs: data.filters
31+
? data.filters.to.getTime() - data.filters.from.getTime()
32+
: undefined,
33+
});
34+
}
35+
36+
export function useLogsSearchAnalytics(
37+
data: Promise<SearchData | { error: string }>,
38+
requestFilterState: string
39+
) {
40+
const { capture } = usePostHogTracking();
41+
const [analytics] = useState(() => new LogsSearchAnalytics(capture));
42+
useLogsSearchLifecycle(analytics, data, requestFilterState);
43+
return analytics;
44+
}
45+
46+
export function useLogsSearchLifecycle(
47+
analytics: LogsSearchAnalytics,
48+
data: Promise<SearchData | { error: string }>,
49+
requestFilterState: string
50+
) {
51+
const location = useLocation();
52+
const navigation = useNavigation();
53+
const committedKey = logsSearchKey(location.pathname, location.search);
54+
const pendingNavigation = useRef<{ key: string; startedAt: number }>();
55+
56+
useEffect(() => {
57+
if (navigation.location && isLogsPage(navigation.location.pathname)) {
58+
const key = logsSearchKey(navigation.location.pathname, navigation.location.search);
59+
if (key !== committedKey && pendingNavigation.current?.key !== key) {
60+
pendingNavigation.current = { key, startedAt: performance.now() };
61+
}
62+
} else if (navigation.state === "idle" && pendingNavigation.current?.key !== committedKey) {
63+
pendingNavigation.current = undefined;
64+
}
65+
}, [committedKey, navigation.location, navigation.state]);
66+
67+
useEffect(() => {
68+
if (!isLogsPage(location.pathname)) return;
69+
const startedAt =
70+
pendingNavigation.current?.key === committedKey
71+
? pendingNavigation.current.startedAt
72+
: undefined;
73+
analytics.start(committedKey, location.search, startedAt);
74+
pendingNavigation.current = undefined;
75+
}, [analytics, committedKey, location.pathname, location.search]);
76+
77+
useEffect(() => {
78+
if (committedKey !== requestFilterState) return;
79+
let cancelled = false;
80+
void Promise.resolve(data).then(
81+
(result) => {
82+
if (cancelled) return;
83+
if ("error" in result) analytics.result(requestFilterState, "error");
84+
else reportLogsSearchResponse(analytics, requestFilterState, result);
85+
},
86+
() => {
87+
if (!cancelled) analytics.result(requestFilterState, "error");
88+
}
89+
);
90+
return () => {
91+
cancelled = true;
92+
};
93+
}, [analytics, data, requestFilterState, committedKey]);
94+
95+
useEffect(() => {
96+
const leave = (event: PageTransitionEvent) => {
97+
if (!event.persisted) analytics.leave();
98+
};
99+
window.addEventListener("pagehide", leave);
100+
return () => {
101+
window.removeEventListener("pagehide", leave);
102+
analytics.leave();
103+
};
104+
}, [analytics]);
105+
}

‎apps/webapp/app/hooks/usePostHog.ts‎

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import { useLocation } from "@remix-run/react";
22
import posthog from "posthog-js";
33
import { useCallback, useEffect, useRef } from "react";
4-
import { useOrganizationChanged } from "./useOrganizations";
4+
import { useOptionalOrganization, useOrganizationChanged } from "./useOrganizations";
55
import { useOptionalUser, useUserChanged } from "./useUser";
6-
import { useProjectChanged } from "./useProject";
6+
import { useOptionalProject, useProjectChanged } from "./useProject";
7+
import { PostHogCaptureQueue } from "~/utils/postHogCaptureQueue";
8+
9+
const captureQueue = new PostHogCaptureQueue((event, properties) =>
10+
posthog.capture(event, properties)
11+
);
712

813
export const usePostHog = (
914
apiKey?: string,
@@ -14,10 +19,15 @@ export const usePostHog = (
1419
const postHogInitialized = useRef(false);
1520
const location = useLocation();
1621
const user = useOptionalUser();
22+
const organization = useOptionalOrganization();
23+
const project = useOptionalProject();
1724

1825
//start PostHog once
1926
useEffect(() => {
20-
if (apiKey === undefined || apiKey === "") return;
27+
if (apiKey === undefined || apiKey === "") {
28+
captureQueue.disable();
29+
return;
30+
}
2131
if (postHogInitialized.current === true) return;
2232
if (logging) console.log("Initializing PostHog");
2333
posthog.init(apiKey, {
@@ -42,10 +52,13 @@ export const usePostHog = (
4252
if (logging) console.log("Loaded: Identifying user", user);
4353
posthog.identify(user.id, { email: user.email });
4454
}
55+
if (organization) posthog.group("organization", organization.id);
56+
if (project) posthog.group("project", project.id);
57+
captureQueue.ready();
4558
},
4659
});
4760
postHogInitialized.current = true;
48-
}, [apiKey, uiHost, logging, debug, user]);
61+
}, [apiKey, uiHost, logging, debug, user, organization, project]);
4962

5063
useUserChanged((user) => {
5164
if (postHogInitialized.current === false) return;
@@ -88,7 +101,7 @@ export const usePostHog = (
88101

89102
export function usePostHogTracking() {
90103
const capture = useCallback((eventName: string, properties?: Record<string, unknown>) => {
91-
posthog.capture(eventName, properties);
104+
captureQueue.capture(eventName, properties);
92105
}, []);
93106

94107
const startSessionRecording = useCallback(() => {

0 commit comments

Comments
 (0)