Skip to content

Commit c58bcd4

Browse files
author
monster
committed
feat(frontend): track authenticated console users
1 parent 5982ecd commit c58bcd4

7 files changed

Lines changed: 231 additions & 31 deletions

File tree

frontend/index.html

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@
4646
<body>
4747
<div id="root"></div>
4848
<script type="module" src="/src/main.tsx"></script>
49-
49+
5050
<!-- Google tag (gtag.js) -->
5151
<script async src="https://www.googletagmanager.com/gtag/js?id=G-RQQN0BK11M"></script>
5252
<script>
@@ -56,32 +56,31 @@
5656

5757
gtag('config', 'G-RQQN0BK11M');
5858
</script>
59-
<script>
60-
var _paq = window._paq = window._paq || [];
61-
_paq.push(["setDocumentTitle", document.domain + "/" + document.title]);
62-
_paq.push(["setRequestMethod", "POST"]);
63-
_paq.push(["setDomains", [
64-
"showcase.monkeycode-ai.online",
65-
"*.showcase.monkeycode-ai.online",
66-
"monkeycode-ai.com",
67-
"monkeycode-ai.net",
68-
]]);
69-
_paq.push(["enableCrossDomainLinking"]);
70-
_paq.push(["trackPageView"]);
71-
_paq.push(["enableLinkTracking"]);
72-
(function() {
73-
var u = "//stats.monkeycode-ai.com/";
74-
_paq.push(["setTrackerUrl", u + "statcall.php"]);
75-
_paq.push(["setSiteId", "2"]);
76-
77-
var d = document;
78-
var g = d.createElement("script");
79-
var s = d.getElementsByTagName("script")[0];
80-
g.async = true;
81-
g.src = u + "statloader.js";
82-
s.parentNode.insertBefore(g, s);
83-
})();
59+
<script>
60+
var _paq = window._paq = window._paq || [];
61+
_paq.push(["setDocumentTitle", location.hostname + "/" + document.title]);
62+
_paq.push(["setRequestMethod", "POST"]);
63+
_paq.push(["setDomains", [
64+
"showcase.monkeycode-ai.online",
65+
"*.showcase.monkeycode-ai.online",
66+
"monkeycode-ai.com",
67+
"monkeycode-ai.net",
68+
]]);
69+
_paq.push(["enableCrossDomainLinking"]);
70+
_paq.push(["enableLinkTracking"]);
71+
(function() {
72+
var u = "https://stats.monkeycode-ai.com/";
73+
_paq.push(["setTrackerUrl", u + "statcall.php"]);
74+
_paq.push(["setSiteId", "2"]);
75+
_paq.push(["trackPageView"]);
8476

85-
</script>
77+
var d = document;
78+
var g = d.createElement("script");
79+
var s = d.getElementsByTagName("script")[0];
80+
g.async = true;
81+
g.src = u + "statloader.js";
82+
s.parentNode.insertBefore(g, s);
83+
})();
84+
</script>
8685
</body>
8786
</html>

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import SelfHostingPage from "./pages/self-hosting"
3535
import { TooltipProvider } from "@/components/ui/tooltip"
3636
import { IS_OFFLINE_EDITION } from "@/utils/edition"
3737
import { SiteRegionPrompt } from "@/components/site-region-prompt"
38+
import { MatomoConsoleTracker } from "@/components/matomo-console-tracker"
3839

3940
function TaskDetailRoute() {
4041
const { taskId } = useParams()
@@ -47,6 +48,7 @@ function App() {
4748
<TooltipProvider>
4849
<BrowserRouter>
4950
<ThemePathListener />
51+
<MatomoConsoleTracker />
5052
<Routes>
5153
<Route path="/" element={IS_OFFLINE_EDITION ? <Navigate to="/login" replace /> : <WelcomePage />} />
5254
<Route path="/playground" element={<PlaygroundPage />} />

frontend/src/components/console/nav/nav-balance.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ import { useNavigate } from "react-router-dom";
3131
import SubscriptionPlanDialog from "./subscription-plan-dialog";
3232
import { IS_OFFLINE_EDITION } from "@/utils/edition";
3333
import { useTranslation } from "react-i18next";
34+
import { useAppRuntime } from "@/components/app-runtime-provider";
35+
import { resetMatomoUser } from "@/lib/matomo";
3436

3537
interface NavBalanceProps {
3638
variant?: "sidebar" | "header";
@@ -83,6 +85,7 @@ export default function NavBalance({
8385
const avatarInputRef = useRef<HTMLInputElement>(null);
8486
const previousDialogOpenRef = useRef(false);
8587
const navigate = useNavigate()
88+
const { reloadAuth } = useAppRuntime()
8689
const {
8790
balance,
8891
reloadSubscription,
@@ -115,6 +118,8 @@ export default function NavBalance({
115118
const handleLogout = () => {
116119
apiRequest("v1UsersLogoutCreate", {}, [], (resp) => {
117120
if (resp.code === 0) {
121+
resetMatomoUser()
122+
void reloadAuth()
118123
navigate("/")
119124
} else {
120125
toast.error(t("navBalance.toast.logoutFailed", { message: resp.message || t("navBalance.common.unknownError") }))
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useEffect } from "react";
2+
import { useLocation } from "react-router-dom";
3+
import { useAppRuntime } from "@/components/app-runtime-provider";
4+
import {
5+
identifyMatomoUser,
6+
observeMatomoRoute,
7+
trackMatomoAuthenticated,
8+
} from "@/lib/matomo";
9+
10+
function isConsolePath(pathname: string) {
11+
return pathname === "/console" || pathname.startsWith("/console/");
12+
}
13+
14+
export function MatomoConsoleTracker() {
15+
const { auth } = useAppRuntime();
16+
const location = useLocation();
17+
18+
useEffect(() => {
19+
const normalizedPath = location.pathname.replace(/\/+$/, "") || "/";
20+
const consolePath = isConsolePath(normalizedPath);
21+
22+
if (!consolePath) {
23+
observeMatomoRoute({ trackPageView: false });
24+
return;
25+
}
26+
27+
// /console immediately redirects to its index route. Waiting avoids counting both URLs.
28+
if (normalizedPath === "/console") {
29+
return;
30+
}
31+
32+
const userId = auth.user?.id;
33+
if (auth.status !== "authenticated" || !userId) {
34+
return;
35+
}
36+
37+
const identified = identifyMatomoUser(userId);
38+
const pageViewTracked = observeMatomoRoute({ trackPageView: true });
39+
40+
// A direct page load already produced the anonymous page view in index.html.
41+
// Send an event after identification instead of duplicating that page view.
42+
if (identified && !pageViewTracked) {
43+
trackMatomoAuthenticated();
44+
}
45+
}, [
46+
auth.status,
47+
auth.user?.id,
48+
location.hash,
49+
location.pathname,
50+
location.search,
51+
]);
52+
53+
return null;
54+
}

frontend/src/lib/matomo.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
type MatomoCommand = [name: string, ...args: unknown[]];
2+
3+
declare global {
4+
interface Window {
5+
_paq?: MatomoCommand[];
6+
}
7+
}
8+
9+
let identifiedUserId: string | null = null;
10+
let lastObservedUrl = getCurrentUrl();
11+
12+
function getCurrentUrl() {
13+
return typeof window === "undefined" ? "" : window.location.href;
14+
}
15+
16+
function getMatomoQueue() {
17+
if (typeof window === "undefined") {
18+
return null;
19+
}
20+
21+
window._paq = window._paq || [];
22+
return window._paq;
23+
}
24+
25+
export function identifyMatomoUser(userId: string) {
26+
const normalizedUserId = String(userId).trim();
27+
const queue = getMatomoQueue();
28+
29+
if (!queue || !normalizedUserId || identifiedUserId === normalizedUserId) {
30+
return false;
31+
}
32+
33+
queue.push(["setUserId", normalizedUserId]);
34+
identifiedUserId = normalizedUserId;
35+
return true;
36+
}
37+
38+
export function trackMatomoAuthenticated() {
39+
getMatomoQueue()?.push(["trackEvent", "user", "authenticated"]);
40+
}
41+
42+
export function resetMatomoUser() {
43+
const queue = getMatomoQueue();
44+
45+
if (!queue) {
46+
identifiedUserId = null;
47+
return;
48+
}
49+
50+
if (identifiedUserId) {
51+
queue.push(["trackEvent", "user", "logout_success"]);
52+
}
53+
54+
queue.push(["resetUserId"]);
55+
identifiedUserId = null;
56+
}
57+
58+
type ObserveMatomoRouteOptions = {
59+
trackPageView: boolean;
60+
url?: string;
61+
title?: string;
62+
};
63+
64+
export function observeMatomoRoute({
65+
trackPageView,
66+
url = getCurrentUrl(),
67+
title = typeof document === "undefined" ? "" : document.title,
68+
}: ObserveMatomoRouteOptions) {
69+
if (!url || url === lastObservedUrl) {
70+
return false;
71+
}
72+
73+
lastObservedUrl = url;
74+
75+
if (!trackPageView) {
76+
return false;
77+
}
78+
79+
const queue = getMatomoQueue();
80+
if (!queue) {
81+
return false;
82+
}
83+
84+
const hostname =
85+
typeof window === "undefined" ? "" : window.location.hostname;
86+
queue.push(["setCustomUrl", url]);
87+
queue.push(["setDocumentTitle", `${hostname}/${title}`]);
88+
queue.push(["trackPageView"]);
89+
return true;
90+
}

frontend/src/pages/login.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export default function LoginPage({
5252
const [defaultOIDCConfig, setDefaultOIDCConfig] = React.useState<DomainTeamOIDCPublicConfigResp | null>(null)
5353
const navigate = useNavigate()
5454
const { t } = useTranslation()
55-
const { serverConfig } = useAppRuntime()
55+
const { reloadAuth, serverConfig } = useAppRuntime()
5656
const serverRegion = serverConfig?.region as string | undefined
5757
const isCnRegion = serverRegion === "cn"
5858
const isGlobalRegion = serverRegion === "global"
@@ -122,10 +122,11 @@ export default function LoginPage({
122122
email: userEmail.trim(),
123123
password: userPassword.trim(),
124124
captcha_token: token,
125-
}, [], (resp) => {
125+
}, [], async (resp) => {
126126
if (resp.code === 0) {
127127
localStorage.setItem(USER_STORAGE_KEY, JSON.stringify({ email: userEmail.trim(), password: userPassword.trim() }))
128-
navigate('/console/')
128+
await reloadAuth()
129+
navigate('/console/tasks')
129130
} else {
130131
toast.error(t("login.toast.loginFailed"))
131132
}
@@ -144,7 +145,7 @@ export default function LoginPage({
144145

145146
try {
146147
const response = await new Api().api.v1UsersOauthLoginDetail(provider, {
147-
redirect_url: "/console/",
148+
redirect_url: "/console/tasks",
148149
})
149150
const authUrl = response.data?.data?.auth_url
150151

frontend/test/matomo.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
test("Matomo 在识别用户后记录 Console 页面且避免重复 PV", async () => {
5+
const queue: unknown[][] = [];
6+
const location = new URL("https://monkeycode-ai.com/login");
7+
8+
Object.defineProperty(globalThis, "window", {
9+
configurable: true,
10+
value: {
11+
_paq: queue,
12+
location,
13+
},
14+
});
15+
Object.defineProperty(globalThis, "document", {
16+
configurable: true,
17+
value: { title: "MonkeyCode" },
18+
});
19+
20+
const {
21+
identifyMatomoUser,
22+
observeMatomoRoute,
23+
resetMatomoUser,
24+
trackMatomoAuthenticated,
25+
} = await import("../src/lib/matomo.ts");
26+
27+
assert.equal(identifyMatomoUser("user-1"), true);
28+
assert.deepEqual(queue.at(-1), ["setUserId", "user-1"]);
29+
30+
location.href = "https://monkeycode-ai.com/console/tasks";
31+
assert.equal(observeMatomoRoute({ trackPageView: true }), true);
32+
assert.deepEqual(queue.slice(-3), [
33+
["setCustomUrl", "https://monkeycode-ai.com/console/tasks"],
34+
["setDocumentTitle", "monkeycode-ai.com/MonkeyCode"],
35+
["trackPageView"],
36+
]);
37+
38+
assert.equal(identifyMatomoUser("user-1"), false);
39+
assert.equal(observeMatomoRoute({ trackPageView: true }), false);
40+
41+
trackMatomoAuthenticated();
42+
assert.deepEqual(queue.at(-1), ["trackEvent", "user", "authenticated"]);
43+
44+
resetMatomoUser();
45+
assert.deepEqual(queue.slice(-2), [
46+
["trackEvent", "user", "logout_success"],
47+
["resetUserId"],
48+
]);
49+
});

0 commit comments

Comments
 (0)