Customised Analytics for admins and users (#4687)

Adds granular privacy controls for analytics - splits single
enableAnalytics toggle into separate PostHog and Scarf controls with
improved admin
  UX.

  Backend Changes

  Configuration (ApplicationProperties.java)
  - Added enablePosthog and enableScarf boolean fields
- New methods: isPosthogEnabled(), isScarfEnabled() (null = enabled when
analytics is on)

  Services
- PostHogService: Now checks isPosthogEnabled() instead of
isAnalyticsEnabled()
  - ConfigController: Exposes new flags via API
- SettingsController: Changed endpoint from @RequestBody to
@RequestParam

  Frontend Changes

  Architecture
- Converted useAppConfig hook → AppConfigContext provider for global
access
  - Added refetch() method for config updates without reload

  New Features
1. AdminAnalyticsChoiceModal: First-launch modal when enableAnalytics
=== null
    - Enable/disable without editing YAML
    - Includes documentation link
  2. Scarf Tracking System: Modular utility with React hook wrapper
    - Respects config + per-service cookie consent
    - Works from any code location (React or vanilla JS)
3. Enhanced Cookie Consent: Per-service toggles (PostHog and Scarf
separate)

  Integration
  - App.tsx: Added AppConfigProvider + scarf initializer
  - HomePage.tsx: Shows admin modal when needed
  - index.tsx: PostHog opt-out by default, service-level consent

  Key Benefits

 Backward compatible (null defaults to enabled)
 Granular control per analytics service
 First-launch admin modal (no YAML editing)
 Privacy-focused with opt-out defaults
 API-based config updates

---------

Co-authored-by: Connor Yoh <connor@stirlingpdf.com>
This commit is contained in:
ConnorYoh
2025-10-27 16:54:59 +00:00
committed by GitHub
parent 2e932f30bf
commit 960d48f80c
22 changed files with 418 additions and 109 deletions

View File

@@ -9,10 +9,12 @@ import { ToolWorkflowProvider } from "./contexts/ToolWorkflowContext";
import { HotkeyProvider } from "./contexts/HotkeyContext";
import { SidebarProvider } from "./contexts/SidebarContext";
import { PreferencesProvider } from "./contexts/PreferencesContext";
import { AppConfigProvider } from "./contexts/AppConfigContext";
import { OnboardingProvider } from "./contexts/OnboardingContext";
import { TourOrchestrationProvider } from "./contexts/TourOrchestrationContext";
import ErrorBoundary from "./components/shared/ErrorBoundary";
import OnboardingTour from "./components/onboarding/OnboardingTour";
import { useScarfTracking } from "./hooks/useScarfTracking";
// Import auth components
import { AuthProvider } from "./auth/UseSession";
@@ -48,6 +50,12 @@ const LoadingFallback = () => (
</div>
);
// Component to initialize scarf tracking (must be inside AppConfigProvider)
function ScarfTrackingInitializer() {
useScarfTracking();
return null;
}
export default function App() {
return (
<Suspense fallback={<LoadingFallback />}>
@@ -66,30 +74,33 @@ export default function App() {
path="/*"
element={
<OnboardingProvider>
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<Landing />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
<AppConfigProvider>
<ScarfTrackingInitializer />
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
<HotkeyProvider>
<SidebarProvider>
<ViewerProvider>
<SignatureProvider>
<RightRailProvider>
<TourOrchestrationProvider>
<Landing />
<OnboardingTour />
</TourOrchestrationProvider>
</RightRailProvider>
</SignatureProvider>
</ViewerProvider>
</SidebarProvider>
</HotkeyProvider>
</ToolWorkflowProvider>
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</OnboardingProvider>
}
/>

View File

@@ -6,6 +6,7 @@ import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { isBaseWorkbench } from '../../types/workbench';
import { useViewer } from '../../contexts/ViewerContext';
import { useAppConfig } from '../../contexts/AppConfigContext';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -20,6 +21,7 @@ import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
const { config } = useAppConfig();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
@@ -188,7 +190,7 @@ export default function Workbench() {
{renderMainContent()}
</Box>
<Footer analyticsEnabled />
<Footer analyticsEnabled={config?.enableAnalytics === true} />
</Box>
);
}

View File

@@ -0,0 +1,112 @@
import { Modal, Stack, Button, Text, Title, Anchor } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import { Z_ANALYTICS_MODAL } from '../../styles/zIndex';
import { useAppConfig } from '../../contexts/AppConfigContext';
import apiClient from '../../services/apiClient';
interface AdminAnalyticsChoiceModalProps {
opened: boolean;
onClose: () => void;
}
export default function AdminAnalyticsChoiceModal({ opened, onClose }: AdminAnalyticsChoiceModalProps) {
const { t } = useTranslation();
const { refetch } = useAppConfig();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleChoice = async (enableAnalytics: boolean) => {
setLoading(true);
setError(null);
try {
const formData = new FormData();
formData.append('enabled', enableAnalytics.toString());
await apiClient.post('/api/v1/settings/update-enable-analytics', formData);
// Refetch config to apply new settings without page reload
await refetch();
// Close the modal after successful save
onClose();
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error occurred');
setLoading(false);
}
};
const handleEnable = () => {
handleChoice(true);
};
const handleDisable = () => {
handleChoice(false);
};
return (
<Modal
opened={opened}
onClose={() => {}} // Prevent closing
closeOnClickOutside={false}
closeOnEscape={false}
withCloseButton={false}
size="lg"
centered
zIndex={Z_ANALYTICS_MODAL}
>
<Stack gap="md">
<Title order={2}>{t('analytics.title', 'Do you want make Stirling PDF better?')}</Title>
<Text size="sm" c="dimmed">
{t('analytics.paragraph1', 'Stirling PDF has opt in analytics to help us improve the product. We do not track any personal information or file contents.')}
</Text>
<Text size="sm" c="dimmed">
{t('analytics.paragraph2', 'Please consider enabling analytics to help Stirling-PDF grow and to allow us to understand our users better.')}{' '}
<Anchor
href="https://docs.stirlingpdf.com/analytics-telemetry"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('analytics.learnMore', 'Learn more')}
</Anchor>
</Text>
{error && (
<Text c="red" size="sm">
{error}
</Text>
)}
<Stack gap="sm">
<Button
onClick={handleEnable}
loading={loading}
fullWidth
size="md"
>
{t('analytics.enable', 'Enable analytics')}
</Button>
<Button
onClick={handleDisable}
loading={loading}
fullWidth
size="md"
variant="subtle"
c="gray"
>
{t('analytics.disable', 'Disable analytics')}
</Button>
</Stack>
<Text size="xs" c="dimmed" ta="center">
{t('analytics.settings', 'You can change the settings for analytics in the config/settings.yml file')}
</Text>
</Stack>
</Modal>
);
}

View File

@@ -13,7 +13,7 @@ import './quickAccessBar/QuickAccessBar.css';
import AllToolsNavButton from './AllToolsNavButton';
import ActiveToolButton from "./quickAccessBar/ActiveToolButton";
import AppConfigModal from './AppConfigModal';
import { useAppConfig } from '../../hooks/useAppConfig';
import { useAppConfig } from '../../contexts/AppConfigContext';
import { useOnboarding } from '../../contexts/OnboardingContext';
import {
isNavButtonActive,

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Stack, Text, Code, Group, Badge, Alert, Loader, Button } from '@mantine/core';
import { useAppConfig } from '../../../../hooks/useAppConfig';
import { useAppConfig } from '../../../../contexts/AppConfigContext';
import { useAuth } from '../../../../auth/UseSession';
import { useNavigate } from 'react-router-dom';
@@ -125,4 +125,4 @@ const Overview: React.FC = () => {
);
};
export default Overview;
export default Overview;

View File

@@ -1,6 +1,6 @@
import { Stack, Button } from "@mantine/core";
import { CertSignParameters } from "../../../hooks/tools/certSign/useCertSignParameters";
import { useAppConfig } from "../../../hooks/useAppConfig";
import { useAppConfig } from "../../../contexts/AppConfigContext";
interface CertificateTypeSettingsProps {
parameters: CertSignParameters;

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import React, { createContext, useContext, useState, useEffect } from 'react';
// Helper to get JWT from localStorage for Authorization header
function getAuthHeaders(): HeadersInit {
@@ -16,7 +16,9 @@ export interface AppConfig {
languages?: string[];
enableLogin?: boolean;
enableAlphaFunctionality?: boolean;
enableAnalytics?: boolean;
enableAnalytics?: boolean | null;
enablePosthog?: boolean | null;
enableScarf?: boolean | null;
premiumEnabled?: boolean;
premiumKey?: string;
termsAndConditions?: string;
@@ -32,17 +34,21 @@ export interface AppConfig {
error?: string;
}
interface UseAppConfigReturn {
interface AppConfigContextValue {
config: AppConfig | null;
loading: boolean;
error: string | null;
refetch: () => Promise<void>;
}
// Create context
const AppConfigContext = createContext<AppConfigContextValue | undefined>(undefined);
/**
* Custom hook to fetch and manage application configuration
* Provider component that fetches and provides app configuration
* Should be placed at the top level of the app, before any components that need config
*/
export function useAppConfig(): UseAppConfigReturn {
export const AppConfigProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [config, setConfig] = useState<AppConfig | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -59,13 +65,13 @@ export function useAppConfig(): UseAppConfigReturn {
if (!response.ok) {
throw new Error(`Failed to fetch config: ${response.status} ${response.statusText}`);
}
const data: AppConfig = await response.json();
setConfig(data);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('Failed to fetch app config:', err);
console.error('[AppConfig] Failed to fetch app config:', err);
} finally {
setLoading(false);
}
@@ -75,11 +81,31 @@ export function useAppConfig(): UseAppConfigReturn {
fetchConfig();
}, []);
return {
const value: AppConfigContextValue = {
config,
loading,
error,
refetch: fetchConfig,
};
return (
<AppConfigContext.Provider value={value}>
{children}
</AppConfigContext.Provider>
);
};
/**
* Hook to access application configuration
* Must be used within AppConfigProvider
*/
export function useAppConfig(): AppConfigContextValue {
const context = useContext(AppConfigContext);
if (context === undefined) {
throw new Error('useAppConfig must be used within AppConfigProvider');
}
return context;
}

View File

@@ -1,4 +1,4 @@
import { useAppConfig } from './useAppConfig';
import { useAppConfig } from '../contexts/AppConfigContext'
export const useBaseUrl = (): string => {
const { config } = useAppConfig();

View File

@@ -1,12 +1,15 @@
import { useEffect, useState } from 'react';
import { useEffect, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { BASE_PATH } from '../constants/app';
import { useAppConfig } from '../contexts/AppConfigContext';
declare global {
interface Window {
CookieConsent: {
CookieConsent?: {
run: (config: any) => void;
show: (show?: boolean) => void;
acceptedCategory: (category: string) => boolean;
acceptedService: (serviceName: string, category: string) => boolean;
};
}
}
@@ -15,8 +18,11 @@ interface CookieConsentConfig {
analyticsEnabled?: boolean;
}
export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConfig = {}) => {
export const useCookieConsent = ({
analyticsEnabled = false
}: CookieConsentConfig = {}) => {
const { t } = useTranslation();
const { config } = useAppConfig();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
@@ -30,7 +36,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
setIsInitialized(true);
// Force show the modal if it exists but isn't visible
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 100);
return;
}
@@ -130,7 +136,24 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
necessary: {
readOnly: true
},
analytics: {}
analytics: {
services: {
...(config?.enablePosthog !== false && {
posthog: {
label: t('cookieBanner.services.posthog', 'PostHog Analytics'),
onAccept: () => console.log('PostHog service accepted'),
onReject: () => console.log('PostHog service rejected')
}
}),
...(config?.enableScarf !== false && {
scarf: {
label: t('cookieBanner.services.scarf', 'Scarf Pixel'),
onAccept: () => console.log('Scarf service accepted'),
onReject: () => console.log('Scarf service rejected')
}
})
}
}
},
language: {
default: "en",
@@ -184,7 +207,7 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
// Force show after initialization
setTimeout(() => {
window.CookieConsent.show();
window.CookieConsent?.show();
}, 200);
} catch (error) {
@@ -212,15 +235,23 @@ export const useCookieConsent = ({ analyticsEnabled = false }: CookieConsentConf
document.head.removeChild(customCSS);
}
};
}, [analyticsEnabled, t]);
}, [analyticsEnabled, config?.enablePosthog, config?.enableScarf, t]);
const showCookiePreferences = () => {
if (isInitialized && window.CookieConsent) {
window.CookieConsent.show(true);
window.CookieConsent?.show(true);
}
};
const isServiceAccepted = useCallback((service: string, category: string): boolean => {
if (typeof window === 'undefined' || !window.CookieConsent) {
return false;
}
return window.CookieConsent.acceptedService(service, category);
}, []);
return {
showCookiePreferences
showCookiePreferences,
isServiceAccepted
};
};

View File

@@ -0,0 +1,44 @@
import { useEffect } from 'react';
import { useAppConfig } from '../contexts/AppConfigContext';
import { useCookieConsent } from './useCookieConsent';
import { setScarfConfig, firePixel } from '../utils/scarfTracking';
/**
* Hook for initializing Scarf tracking
*
* This hook should be mounted once during app initialization (e.g., in index.tsx).
* It configures the scarf tracking utility with current config and consent state,
* and sets up event listeners to auto-fire pixels when consent is granted.
*
* After initialization, firePixel() can be called from anywhere in the app,
* including non-React utility functions like urlRouting.ts.
*/
export function useScarfTracking() {
const { config } = useAppConfig();
const { isServiceAccepted } = useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Update scarf config whenever config or consent changes
useEffect(() => {
if (config && config.enableScarf !== undefined) {
setScarfConfig(config.enableScarf, isServiceAccepted);
}
}, [config?.enableScarf, isServiceAccepted]);
// Listen to cookie consent changes and auto-fire pixel when consent is granted
useEffect(() => {
const handleConsentChange = () => {
console.warn('[useScarfTracking] Consent changed, checking scarf service acceptance');
if (isServiceAccepted('scarf', 'analytics')) {
firePixel(window.location.pathname);
}
};
window.addEventListener('cc:onConsent', handleConsentChange);
window.addEventListener('cc:onChange', handleConsentChange);
return () => {
window.removeEventListener('cc:onConsent', handleConsentChange);
window.removeEventListener('cc:onChange', handleConsentChange);
};
}, [isServiceAccepted]);
}

View File

@@ -17,22 +17,21 @@ posthog.init(import.meta.env.VITE_PUBLIC_POSTHOG_KEY, {
defaults: '2025-05-24',
capture_exceptions: true, // This enables capturing exceptions using Error Tracking, set to false if you don't want this
debug: false,
opt_out_capturing_by_default: false, // We handle opt-out via cookie consent
opt_out_capturing_by_default: true, // Opt-out by default, controlled by cookie consent
});
function updatePosthogConsent(){
if(typeof(posthog) == "undefined") {
return;
}
const optIn = (window.CookieConsent as any).acceptedCategory('analytics');
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated analytics consent: ", optIn? "opted in" : "opted out");
if(typeof(posthog) == "undefined" || !posthog.__loaded) {
return;
}
const optIn = (window.CookieConsent as any)?.acceptedService?.('posthog', 'analytics') || false;
if (optIn) {
posthog.opt_in_capturing();
} else {
posthog.opt_out_capturing();
}
console.log("Updated PostHog consent: ", optIn ? "opted in" : "opted out");
}
window.addEventListener("cc:onConsent", updatePosthogConsent);
window.addEventListener("cc:onChange", updatePosthogConsent);

View File

@@ -7,6 +7,7 @@ import { useDocumentMeta } from "../hooks/useDocumentMeta";
import { BASE_PATH } from "../constants/app";
import { useBaseUrl } from "../hooks/useBaseUrl";
import { useMediaQuery } from "@mantine/hooks";
import { useAppConfig } from "../contexts/AppConfigContext";
import AppsIcon from '@mui/icons-material/AppsRounded';
import ToolPanel from "../components/tools/ToolPanel";
@@ -18,6 +19,7 @@ import LocalIcon from "../components/shared/LocalIcon";
import { useFilesModalContext } from "../contexts/FilesModalContext";
import AppConfigModal from "../components/shared/AppConfigModal";
import ToolPanelModePrompt from "../components/tools/ToolPanelModePrompt";
import AdminAnalyticsChoiceModal from "../components/shared/AdminAnalyticsChoiceModal";
import "./HomePage.css";
@@ -43,11 +45,20 @@ export default function HomePage() {
const { openFilesModal } = useFilesModalContext();
const { colorScheme } = useMantineColorScheme();
const { config } = useAppConfig();
const isMobile = useMediaQuery("(max-width: 1024px)");
const sliderRef = useRef<HTMLDivElement | null>(null);
const [activeMobileView, setActiveMobileView] = useState<MobileView>("tools");
const isProgrammaticScroll = useRef(false);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [showAnalyticsModal, setShowAnalyticsModal] = useState(false);
// Show admin analytics choice modal if analytics settings not configured
useEffect(() => {
if (config && config.enableAnalytics === null) {
setShowAnalyticsModal(true);
}
}, [config]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = `${BASE_PATH}/branding/StirlingPDFLogoNoText${
@@ -152,6 +163,10 @@ export default function HomePage() {
return (
<div className="h-screen overflow-hidden">
<AdminAnalyticsChoiceModal
opened={showAnalyticsModal}
onClose={() => setShowAnalyticsModal(false)}
/>
<ToolPanelModePrompt />
{isMobile ? (
<div className="mobile-layout">

View File

@@ -1,8 +1,8 @@
import { Navigate, useLocation } from 'react-router-dom';
import { useAuth } from '../auth/UseSession';
import { useAppConfig } from '../hooks/useAppConfig';
import HomePage from '../pages/HomePage';
import Login from './Login';
import { Navigate, useLocation } from 'react-router-dom'
import { useAuth } from '../auth/UseSession'
import { useAppConfig } from '../contexts/AppConfigContext'
import HomePage from '../pages/HomePage'
import Login from './Login'
/**
* Landing component - Smart router based on authentication status

View File

@@ -3,8 +3,9 @@
export const Z_INDEX_FULLSCREEN_SURFACE = 1000;
export const Z_INDEX_OVER_FULLSCREEN_SURFACE = 1300;
export const Z_ANALYTICS_MODAL = 1301;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_FILE_MANAGER_MODAL = 1200;
export const Z_INDEX_OVER_FILE_MANAGER_MODAL = 1300;
export const Z_INDEX_AUTOMATE_MODAL = 1100;

View File

@@ -1,14 +1,73 @@
/**
* Scarf analytics pixel tracking utility
*
* This module provides a firePixel function that can be called from anywhere,
* including non-React utility functions. Configuration and consent state are
* injected via setScarfConfig() which should be called from a React hook
* during app initialization.
*
* IMPORTANT: setScarfConfig() must be called before firePixel() will work.
* The initialization hook (useScarfTracking) is mounted in App.tsx.
*
* For testing: Use resetScarfConfig() to clear module state between tests.
*/
// Module-level state
let configured: boolean = false;
let enableScarf: boolean | null = null;
let isServiceAccepted: ((service: string, category: string) => boolean) | null = null;
let lastFiredPathname: string | null = null;
let lastFiredTime = 0;
/**
* Configure scarf tracking with app config and consent checker
* Should be called from a React hook during app initialization (see useScarfTracking)
*
* @param scarfEnabled - Whether scarf tracking is enabled globally
* @param consentChecker - Function to check if user has accepted scarf service
*/
export function setScarfConfig(
scarfEnabled: boolean | null,
consentChecker: (service: string, category: string) => boolean
): void {
configured = true;
enableScarf = scarfEnabled;
isServiceAccepted = consentChecker;
}
/**
* Fire scarf pixel for analytics tracking
* Only fires if pathname is different from last call or enough time has passed
* Only fires if:
* - Scarf tracking has been initialized via setScarfConfig()
* - Scarf is globally enabled in config
* - User has accepted scarf service via cookie consent
* - Pathname has changed or enough time has passed since last fire
*
* @param pathname - The pathname to track (usually window.location.pathname)
*/
export function firePixel(pathname: string): void {
// Dev-mode warning if called before initialization
if (!configured) {
console.warn(
'[scarfTracking] firePixel() called before setScarfConfig(). ' +
'Ensure useScarfTracking() hook is mounted in App.tsx.'
);
return;
}
// Check if Scarf is globally disabled
if (enableScarf === false) {
return;
}
// Check if consent checker is available and scarf service is accepted
if (!isServiceAccepted || !isServiceAccepted('scarf', 'analytics')) {
return;
}
const now = Date.now();
// Only fire if pathname changed or it's been at least 1 second since last fire
// Only fire if pathname changed or it's been at least 250ms since last fire
if (pathname === lastFiredPathname && now - lastFiredTime < 250) {
return;
}
@@ -24,3 +83,13 @@ export function firePixel(pathname: string): void {
img.src = url;
}
/**
* Reset scarf tracking configuration and state
* Useful for testing to ensure clean state between test runs
*/
export function resetScarfConfig(): void {
enableScarf = null;
isServiceAccepted = null;
lastFiredPathname = null;
lastFiredTime = 0;
}