diff --git a/app/layout.tsx b/app/layout.tsx
index b80bb12c9f03d..5bbdd7d12227b 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -4,6 +4,7 @@ import {Theme} from '@radix-ui/themes';
import type {Metadata} from 'next';
import {Rubik} from 'next/font/google';
import Script from 'next/script';
+import PlausibleProvider from 'next-plausible';
import {ThemeProvider} from 'sentry-docs/components/theme-provider';
@@ -31,6 +32,9 @@ export const metadata: Metadata = {
export default function RootLayout({children}: {children: React.ReactNode}) {
return (
+
+
+
+
-
-
);
}
diff --git a/package.json b/package.json
index 7f9048d710f4b..9bde659dd7aa0 100644
--- a/package.json
+++ b/package.json
@@ -71,6 +71,7 @@
"micromark": "^4.0.0",
"next": "15.0.3",
"next-mdx-remote": "^4.4.1",
+ "next-plausible": "^3.12.4",
"next-themes": "^0.3.0",
"nextjs-toploader": "^1.6.6",
"parse-numeric-range": "^1.3.0",
diff --git a/src/components/docPage/index.tsx b/src/components/docPage/index.tsx
index aa7522d1b8539..10da08e5c9ec1 100644
--- a/src/components/docPage/index.tsx
+++ b/src/components/docPage/index.tsx
@@ -19,6 +19,7 @@ import {PaginationNav} from '../paginationNav';
import {PlatformSdkDetail} from '../platformSdkDetail';
import {Sidebar} from '../sidebar';
import {TableOfContents} from '../tableOfContents';
+import {ReaderDepthTracker} from '../track-reader-depth';
type Props = {
children: ReactNode;
@@ -114,6 +115,7 @@ export function DocPage({
+
);
}
diff --git a/src/components/track-reader-depth.tsx b/src/components/track-reader-depth.tsx
new file mode 100644
index 0000000000000..8717c71c3bb69
--- /dev/null
+++ b/src/components/track-reader-depth.tsx
@@ -0,0 +1,59 @@
+'use client';
+import {useEffect} from 'react';
+import {usePlausible} from 'next-plausible';
+
+import {debounce} from 'sentry-docs/utils';
+
+const EVENT = 'Read Progress';
+const milestones = [25, 50, 75, 100] as const;
+type Milestone = (typeof milestones)[number];
+type EVENT_PROPS = {page: string; readProgress: Milestone};
+
+export function ReaderDepthTracker() {
+ const plausible = usePlausible<{[EVENT]: EVENT_PROPS}>();
+
+ const sendProgressToPlausible = (progress: Milestone) => {
+ plausible(EVENT, {props: {readProgress: progress, page: document.title}});
+ };
+
+ useEffect(() => {
+ const reachedMilestones = new Set();
+
+ const trackProgress = () => {
+ // calculate the progress based on the scroll position
+ const scrollPosition = window.scrollY;
+ const totalHeight = document.documentElement.scrollHeight - window.innerHeight;
+ let progress = Math.floor((scrollPosition / totalHeight) * 100);
+ // it's hard to trigger the 100% milestone, so we'll just assume beyond 95%
+ if (progress > 95) {
+ progress = 100;
+ }
+
+ // find the biggest milestone that has not been reached yet
+ const milestone = milestones.findLast(
+ m =>
+ progress >= m &&
+ !reachedMilestones.has(m) &&
+ // we shouldn't report smaller milestones once a bigger one has been reached
+ Array.from(reachedMilestones).every(r => m > r)
+ );
+ if (milestone) {
+ reachedMilestones.add(milestone);
+ sendProgressToPlausible(milestone);
+ }
+ };
+
+ // if the page is not scrollable, we don't need to track anything
+ if (document.documentElement.scrollHeight - window.innerHeight === 0) {
+ return () => {};
+ }
+ const debouncedTrackProgress = debounce(trackProgress, 50);
+
+ window.addEventListener('scroll', debouncedTrackProgress);
+ return () => {
+ window.removeEventListener('scroll', debouncedTrackProgress);
+ };
+ });
+ // do not render anything
+ return null;
+}
diff --git a/src/utils.ts b/src/utils.ts
index 679f28e21ef59..58c60b55ced47 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -106,3 +106,17 @@ export const isLocalStorageAvailable = () => typeof localStorage !== 'undefined'
export const stripTrailingSlash = (url: string) => {
return url.replace(/\/$/, '');
};
+
+/**
+ * Debounce function to limit the number of times a function is called.
+ * @param func The function to be debounced.
+ * @param wait The time to wait before calling the function.
+ * @returns A debounced function that only calls the original function after the wait time has passed.
+ */
+export function debounce(func: (...args: T) => void, delay: number) {
+ let timer: ReturnType;
+ return function (...args: T) {
+ clearTimeout(timer);
+ timer = setTimeout(() => func.apply(this, args), delay);
+ };
+}
diff --git a/yarn.lock b/yarn.lock
index b77612eb6cded..16e9c98956a03 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -9885,6 +9885,11 @@ next-mdx-remote@^4.4.1:
vfile "^5.3.0"
vfile-matter "^3.0.1"
+next-plausible@^3.12.4:
+ version "3.12.4"
+ resolved "https://registry.yarnpkg.com/next-plausible/-/next-plausible-3.12.4.tgz#d0ac1d7dcbe9836b6c93e37d42b80e3661fdaa34"
+ integrity sha512-cD3+ixJxf8yBYvsideTxqli3fvrB7R4BXcvsNJz8Sm2X1QN039WfiXjCyNWkub4h5++rRs6fHhchUMnOuJokcg==
+
next-themes@^0.3.0:
version "0.3.0"
resolved "https://registry.yarnpkg.com/next-themes/-/next-themes-0.3.0.tgz#b4d2a866137a67d42564b07f3a3e720e2ff3871a"