From 80b1bd719abfead8e37444040ca895f993eb4185 Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Thu, 11 Jun 2026 15:26:22 -0400 Subject: [PATCH 1/2] BDMS-857: Add search recording, copy/paste tracking, and accessibility props to PostHog 1. Search recording: replace data-posthog-unmask-search attribute with ph-no-mask CSS class on the search input in SearchModal. Update maskInputFn in posthog.ts to check for ph-no-mask so the custom masking function respects the standard class. 2. Copy/paste tracking: new useCopyPasteTracking hook attaches document-level listeners for copy, cut, and paste events. Fires text_copied (with path and selection_length) and text_pasted (with path). No actual text is captured. Wired up via a new renderless CopyPasteTracking component mounted in AppProviders. 3. Accessibility preferences: new getAccessibilityProps() in posthog.ts reads browser_font_size_px, font_size_increased, device_pixel_ratio, prefers_reduced_motion, and prefers_high_contrast once per session. PostHogIdentify now calls setPersonProperties(getAccessibilityProps()) after the user is identified so the values are attached to the person record. --- src/AppProviders.tsx | 2 + src/analytics/posthog.ts | 29 ++++++++++++- src/components/SearchModal.tsx | 2 +- .../analytics/CopyPasteTracking.tsx | 10 +++++ src/components/analytics/PostHogIdentify.tsx | 3 +- src/hooks/useCopyPasteTracking.ts | 41 +++++++++++++++++++ 6 files changed, 83 insertions(+), 4 deletions(-) create mode 100644 src/components/analytics/CopyPasteTracking.tsx create mode 100644 src/hooks/useCopyPasteTracking.ts diff --git a/src/AppProviders.tsx b/src/AppProviders.tsx index ea1f5fc9..6b81bd27 100644 --- a/src/AppProviders.tsx +++ b/src/AppProviders.tsx @@ -26,6 +26,7 @@ import { import { resources } from '@/resources' import { PostHogPageview } from '@/components/analytics/PostHogPageview' import { PostHogIdentify } from '@/components/analytics/PostHogIdentify' +import { CopyPasteTracking } from '@/components/analytics/CopyPasteTracking' import { ErrorBoundary } from '@/components/analytics/ErrorBoundary' import { SearchProvider } from '@/providers/search-provider' @@ -99,6 +100,7 @@ export const AppProviders = ({ children }: { children: ReactNode }) => ( + diff --git a/src/analytics/posthog.ts b/src/analytics/posthog.ts index 4dd2c96a..84c67485 100644 --- a/src/analytics/posthog.ts +++ b/src/analytics/posthog.ts @@ -25,9 +25,11 @@ export const initPostHog = () => { if (el?.type === 'password') { return '*'.repeat(text.length) } + // ph-no-mask is PostHog's standard class for opting inputs out of masking. + // Check the element and its ancestors so the class can be set on a wrapper. if ( - el?.hasAttribute?.('data-posthog-unmask-search') || - el?.closest?.('[data-posthog-unmask-search]') + el?.classList?.contains('ph-no-mask') || + el?.closest?.('.ph-no-mask') ) { return text } @@ -109,3 +111,26 @@ export const resetUser = () => { posthog.reset() } +export const setPersonProperties = ( + properties: Record +) => { + if (!isEnabled || !initialized) return + posthog.setPersonProperties(properties) +} + +/** + * Reads browser-level accessibility and display preferences once per + * session and returns them as PostHog person properties. Called after + * the user is identified so the values are attached to the person record. + */ +export const getAccessibilityProps = (): Record => { + const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize) + return { + browser_font_size_px: rootFontSize, + font_size_increased: rootFontSize > 16, + device_pixel_ratio: window.devicePixelRatio ?? 1, + prefers_reduced_motion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + prefers_high_contrast: window.matchMedia('(prefers-contrast: more)').matches, + } +} + diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 52e01b21..32d8bb7c 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -86,7 +86,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => { sx={{ fontSize: 15 }} inputProps={{ 'aria-label': 'Search', - 'data-posthog-unmask-search': true, + className: 'ph-no-mask', }} endAdornment={ state.query ? ( diff --git a/src/components/analytics/CopyPasteTracking.tsx b/src/components/analytics/CopyPasteTracking.tsx new file mode 100644 index 00000000..6399d36a --- /dev/null +++ b/src/components/analytics/CopyPasteTracking.tsx @@ -0,0 +1,10 @@ +import { useCopyPasteTracking } from '@/hooks/useCopyPasteTracking' + +/** + * Renderless component that wires up copy/cut/paste event tracking for the + * entire app. Mount once inside AppProviders. + */ +export const CopyPasteTracking = () => { + useCopyPasteTracking() + return null +} diff --git a/src/components/analytics/PostHogIdentify.tsx b/src/components/analytics/PostHogIdentify.tsx index 0837d4b0..e442d5c0 100644 --- a/src/components/analytics/PostHogIdentify.tsx +++ b/src/components/analytics/PostHogIdentify.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react' import { useGetIdentity } from '@refinedev/core' import { AuthentikIdentity } from '@/providers/authentik-provider' -import { identifyUser, resetUser } from '@/analytics/posthog' +import { identifyUser, resetUser, getAccessibilityProps, setPersonProperties } from '@/analytics/posthog' export const PostHogIdentify = () => { const { data: identity } = useGetIdentity() @@ -9,6 +9,7 @@ export const PostHogIdentify = () => { useEffect(() => { if (identity?.id) { identifyUser(identity.id, { name: identity.name, email: identity.email }) + setPersonProperties(getAccessibilityProps()) } else { resetUser() } diff --git a/src/hooks/useCopyPasteTracking.ts b/src/hooks/useCopyPasteTracking.ts new file mode 100644 index 00000000..7c8aae7b --- /dev/null +++ b/src/hooks/useCopyPasteTracking.ts @@ -0,0 +1,41 @@ +import { useEffect } from 'react' +import { captureEvent } from '@/analytics/posthog' + +/** + * Attaches document-level listeners for copy, cut, and paste events and + * fires PostHog events for each. The actual copied or pasted text is never + * captured — only the page path and, for copy/cut, the selection length. + */ +export const useCopyPasteTracking = () => { + useEffect(() => { + const handleCopy = () => { + captureEvent('text_copied', { + path: window.location.pathname, + selection_length: window.getSelection()?.toString().length ?? 0, + }) + } + + const handleCut = () => { + captureEvent('text_copied', { + path: window.location.pathname, + selection_length: window.getSelection()?.toString().length ?? 0, + }) + } + + const handlePaste = () => { + captureEvent('text_pasted', { + path: window.location.pathname, + }) + } + + document.addEventListener('copy', handleCopy) + document.addEventListener('cut', handleCut) + document.addEventListener('paste', handlePaste) + + return () => { + document.removeEventListener('copy', handleCopy) + document.removeEventListener('cut', handleCut) + document.removeEventListener('paste', handlePaste) + } + }, []) +} From 7f11cfaed88d6244e9b3b2f61efa90fe2bb594cd Mon Sep 17 00:00:00 2001 From: Jeremy Zilar Date: Sat, 13 Jun 2026 11:59:25 -0400 Subject: [PATCH 2/2] Track cut as text_cut instead of folding it into text_copied Chase's review on PR #291: cut should be its own PostHog event so copy and cut can be analyzed separately in trends and session recordings. --- src/hooks/useCopyPasteTracking.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/useCopyPasteTracking.ts b/src/hooks/useCopyPasteTracking.ts index 7c8aae7b..a228d84c 100644 --- a/src/hooks/useCopyPasteTracking.ts +++ b/src/hooks/useCopyPasteTracking.ts @@ -5,6 +5,7 @@ import { captureEvent } from '@/analytics/posthog' * Attaches document-level listeners for copy, cut, and paste events and * fires PostHog events for each. The actual copied or pasted text is never * captured — only the page path and, for copy/cut, the selection length. + * Cut is tracked as text_cut, separate from text_copied. */ export const useCopyPasteTracking = () => { useEffect(() => { @@ -16,7 +17,7 @@ export const useCopyPasteTracking = () => { } const handleCut = () => { - captureEvent('text_copied', { + captureEvent('text_cut', { path: window.location.pathname, selection_length: window.getSelection()?.toString().length ?? 0, })