[ENG-227] Desktop app and landing page telemetry using Plausible Analytics (#583)

* add Plausible analytics to landing page

* proxy plausible through vercel

* fix typo & add other options

* add plausible to `sd/client`

* add telemetry sharing option into library config

* add telemetry config option to lib creation dialog

* revert error message change but keep the typo fix

* add telemetry sharing & error handling to client context

* add important note about requiring the tracker component in root/base layouts

* add the `PlausibleTracker`

* grammatical tweaks

* some TS cleanup

* disable analytics in debug mode

* further component improvements and use custom event props

* more cleanup

* remove tracking from onboarding (no telemetry sharing config option)

* update comment

* add fancy new plausible hooks/tracking

* add `pageview` monitoring hook to `$libraryId` layout

* add library creation events to onboarding and creation dialog

* revert `useCurrentLibraryId()` error handling & add important comment

* minor comment tweaks

* replace `usage` with `telemetry`

* add missing newline

* add location create & delete events

* add tag create & delete events

* add/update library create & delete events

* add fn for getting telemetry settings for library by uuid

* add more events + fix a few bugs

* update generics

* add `telemetryState` `valtio` store

* use new telemetry state

* remove old artifacts from `ClientContext`

* Revert "add telemetry sharing option into library config"

This reverts commit afb9f892ab.

* update events, docs & generics

* add `tagAssign` event

* light comment updates

* const names, comments, etc

* add additional info to props and update comment

* add telemetry sharing to debug state (for sharing telemetry in debug mode)

* update `debugState` item name

* change how `Switch` updates the store in privacy settings

* remove `getTelemetryState` from `telemetryState`

* cleanup library creation event handling/telemetry config updating

* add `DebugPopover` to onboarding in debug mode

* improve code quality/comments

* remove useless comment

* rename `ob_store` and `shareTelemetryDataWithDevelopers`

* fix typo

* add `telemetryLogger` and prevent multiple of the same events from firing consecutively

* add more unique path matching and fix an issue with events

* rename `telemetryLogger` -> `telemetryLogging`

---------

Co-authored-by: brxken128 <77554505+brxken128@users.noreply.github.com>
This commit is contained in:
Oscar Beaumont
2023-03-09 16:37:57 +08:00
committed by GitHub
parent 2518e98ad5
commit 1f1a2b019b
26 changed files with 582 additions and 48 deletions

View File

@@ -36,7 +36,7 @@ export function ErrorPage({
<pre className="text-ink m-2">Error: {message}</pre>
{debug.enabled && (
<pre className="text-ink-dull m-2 text-sm">
Check the console (CMD/CRTL + OPTION + i) for stack trace.
Check the console (CMD/CTRL + OPTION + i) for stack trace.
</pre>
)}
<div className="text-ink flex flex-row space-x-2">

View File

@@ -18,7 +18,8 @@ import {
isObject,
useLibraryContext,
useLibraryMutation,
useLibraryQuery
useLibraryQuery,
usePlausibleEvent
} from '@sd/client';
import { ContextMenu, dialogManager } from '@sd/ui';
import { useExplorerParams } from '~/app/$libraryId/location/$id';
@@ -248,9 +249,16 @@ export default ({ data, ...props }: Props) => {
};
const AssignTagMenuItems = (props: { objectId: number }) => {
const platform = usePlatform();
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const tags = useLibraryQuery(['tags.list'], { suspense: true });
const tagsForObject = useLibraryQuery(['tags.getForObject', props.objectId], { suspense: true });
const assignTag = useLibraryMutation('tags.assign');
const assignTag = useLibraryMutation('tags.assign', {
onSuccess: () => {
submitPlausibleEvent({ event: { type: 'tagAssign' } });
}
});
return (
<>

View File

@@ -1,12 +1,20 @@
import { useLibraryMutation } from '@sd/client';
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
import { dialogManager } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import AddLocationDialog from '../../settings/library/locations/AddDialog';
export default () => {
const platform = usePlatform();
const createLocation = useLibraryMutation('locations.create');
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const createLocation = useLibraryMutation('locations.create', {
onSuccess: () => {
submitPlausibleEvent({
event: {
type: 'locationCreate'
}
});
}
});
return (
<button

View File

@@ -24,13 +24,45 @@ export default () => {
<Setting
mini
title="rspc Logger"
description="Enable the logger link so you can see what's going on in the browser logs."
description="Enable the RSPC logger so you can see what's going on in the browser logs."
>
<Switch
checked={debugState.rspcLogger}
onClick={() => (getDebugState().rspcLogger = !debugState.rspcLogger)}
/>
</Setting>
<Setting
mini
title="Share telemetry"
description="Share telemetry, even in debug mode (telemetry sharing must also be enabled in your client settings)"
>
<Switch
checked={debugState.shareTelemetry}
onClick={() => {
// if debug telemetry sharing is about to be disabled, but telemetry logging is enabled
// then disable it
if (!debugState.shareTelemetry === false && debugState.telemetryLogging)
getDebugState().telemetryLogging = false;
getDebugState().shareTelemetry = !debugState.shareTelemetry;
}}
/>
</Setting>
<Setting
mini
title="Telemetry logger"
description="Enable the telemetry logger so you can see what's going on in the browser logs"
>
<Switch
checked={debugState.telemetryLogging}
onClick={() => {
// if telemetry logging is about to be enabled, but debug telemetry sharing is disabled
// then enable it
if (!debugState.telemetryLogging && debugState.shareTelemetry === false)
getDebugState().shareTelemetry = true;
getDebugState().telemetryLogging = !debugState.telemetryLogging;
}}
/>
</Setting>
{platform.openPath && (
<Setting
mini

View File

@@ -1,8 +1,14 @@
import clsx from 'clsx';
import { Suspense } from 'react';
import { Navigate, Outlet, useParams } from 'react-router-dom';
import { ClientContextProvider, LibraryContextProvider, useClientContext } from '@sd/client';
import { Navigate, Outlet, useLocation, useParams } from 'react-router-dom';
import {
ClientContextProvider,
LibraryContextProvider,
useClientContext,
usePlausiblePageViewMonitor
} from '@sd/client';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import { usePlatform } from '~/util/Platform';
import Sidebar from './Sidebar';
import Toasts from './Toasts';
@@ -11,6 +17,11 @@ const Layout = () => {
const os = useOperatingSystem();
usePlausiblePageViewMonitor({
currentPath: useLocation().pathname,
platformType: usePlatform().platform
});
if (library === null && libraries.data) {
const firstLibrary = libraries.data[0];

View File

@@ -1,21 +1,24 @@
import { useState } from 'react';
import { Switch } from '@sd/ui';
import { telemetryStore, useTelemetryState } from '~/../packages/client/src';
import { Heading } from '../Layout';
import Setting from '../Setting';
export default function PrivacySettings() {
const [shareUsageData, setShareUsageData] = useState(true);
const [blurEffects, setBlurEffects] = useState(true);
const shareTelemetry = useTelemetryState().shareTelemetry;
return (
<>
<Heading title="Privacy" description="" />
<Setting
mini
title="Share Usage Data"
title="Share Telemetry and Usage Data"
description="Share anonymous usage data to help us improve the app."
>
<Switch checked={shareUsageData} onCheckedChange={setShareUsageData} className="m-2 ml-4" />
<Switch
checked={shareTelemetry}
onClick={() => (telemetryStore.shareTelemetry = !shareTelemetry)}
className="m-2 ml-4"
/>
</Setting>
</>
);

View File

@@ -1,6 +1,7 @@
import { useLibraryMutation } from '@sd/client';
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm } from '@sd/ui/src/forms';
import { usePlatform } from '~/util/Platform';
interface Props extends UseDialogProps {
onSuccess: () => void;
@@ -9,11 +10,16 @@ interface Props extends UseDialogProps {
export default (props: Props) => {
const dialog = useDialog(props);
const platform = usePlatform();
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const form = useZodForm();
const deleteLocation = useLibraryMutation('locations.delete', {
onSuccess: props.onSuccess
onSuccess: () => {
submitPlausibleEvent({ event: { type: 'locationDelete' } });
props.onSuccess();
}
});
return (

View File

@@ -1,10 +1,13 @@
import { useLibraryMutation } from '@sd/client';
import { useClientContext, useLibraryMutation, usePlausibleEvent } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { Input, useZodForm, z } from '@sd/ui/src/forms';
import ColorPicker from '~/components/ColorPicker';
import { usePlatform } from '~/util/Platform';
export default (props: UseDialogProps) => {
const dialog = useDialog(props);
const platform = usePlatform();
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const form = useZodForm({
schema: z.object({
@@ -17,6 +20,9 @@ export default (props: UseDialogProps) => {
});
const createTag = useLibraryMutation('tags.create', {
onSuccess: () => {
submitPlausibleEvent({ event: { type: 'tagCreate' } });
},
onError: (e) => {
console.error('error', e);
}

View File

@@ -1,6 +1,7 @@
import { useLibraryMutation } from '@sd/client';
import { useLibraryMutation, usePlausibleEvent } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { useZodForm } from '@sd/ui/src/forms';
import { usePlatform } from '~/util/Platform';
interface Props extends UseDialogProps {
tagId: number;
@@ -9,11 +10,16 @@ interface Props extends UseDialogProps {
export default (props: Props) => {
const dialog = useDialog(props);
const platform = usePlatform();
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const form = useZodForm();
const deleteTag = useLibraryMutation('tags.delete', {
onSuccess: props.onSuccess
onSuccess: () => {
submitPlausibleEvent({ event: { type: 'tagDelete' } });
props.onSuccess();
}
});
return (

View File

@@ -1,5 +1,5 @@
import { useQueryClient } from '@tanstack/react-query';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash } from 'phosphor-react';
import { ArrowsClockwise, Clipboard, Eye, EyeSlash, Info } from 'phosphor-react';
import { useState } from 'react';
import {
Algorithm,
@@ -7,18 +7,22 @@ import {
HashingAlgoSlug,
generatePassword,
hashingAlgoSlugSchema,
useBridgeMutation
useBridgeMutation,
usePlausibleEvent
} from '@sd/client';
import {
Button,
CheckBox,
Dialog,
PasswordMeter,
Select,
SelectOption,
Tooltip,
UseDialogProps,
useDialog
} from '@sd/ui';
import { forms } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
const { Input, z, useZodForm } = forms;
@@ -27,11 +31,14 @@ const schema = z.object({
password: z.string(),
password_validate: z.string(),
algorithm: z.string(),
hashing_algorithm: hashingAlgoSlugSchema
hashing_algorithm: hashingAlgoSlugSchema,
share_telemetry: z.boolean()
});
export default (props: UseDialogProps) => {
const dialog = useDialog(props);
const platform = usePlatform();
const createLibraryEvent = usePlausibleEvent({ platformType: platform.platform });
const form = useZodForm({
schema,
@@ -54,6 +61,12 @@ export default (props: UseDialogProps) => {
...(libraries || []),
library
]);
createLibraryEvent({
event: {
type: 'libraryCreate'
}
});
},
onError: (err: any) => {
console.error(err);
@@ -95,6 +108,20 @@ export default (props: UseDialogProps) => {
/>
</div>
<div className="mt-3 mb-1 flex flex-row items-center">
<div className="space-x-2">
<CheckBox
className="bg-app-selected"
defaultChecked={true}
{...form.register('share_telemetry', { required: true })}
/>
</div>
<span className="mt-1 text-xs font-medium">Share anonymous usage</span>
<Tooltip label="Share completely anonymous telemetry data to help the developers improve the app">
<Info className="text-ink-faint ml-1.5 h-4 w-4" />
</Tooltip>
</div>
{/* TODO: Proper UI for this. Maybe checkbox for encrypted or not and then reveal these fields. Select encrypted by default. */}
{/* <span className="text-sm">Make the secret key field empty to skip key setup.</span> */}

View File

@@ -1,7 +1,8 @@
import { useQueryClient } from '@tanstack/react-query';
import { useBridgeMutation } from '@sd/client';
import { useBridgeMutation, usePlausibleEvent, useTelemetryState } from '@sd/client';
import { Dialog, UseDialogProps, useDialog } from '@sd/ui';
import { forms } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
const { useZodForm, z } = forms;
@@ -11,11 +12,20 @@ interface Props extends UseDialogProps {
export default function DeleteLibraryDialog(props: Props) {
const dialog = useDialog(props);
const platform = usePlatform();
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const shareTelemetry = useTelemetryState().shareTelemetry;
const queryClient = useQueryClient();
const deleteLib = useBridgeMutation('library.delete', {
onSuccess: () => {
queryClient.invalidateQueries(['library.list']);
submitPlausibleEvent({
event: {
type: 'libraryDelete'
}
});
}
});

View File

@@ -20,6 +20,11 @@ const Index = () => {
return <Navigate to={`${libraryId}/overview`} />;
};
// NOTE: all route `Layout`s below should contain
// the `usePlausiblePageViewMonitor` hook, as early as possible (ideally within the layout itself).
// the hook should only be included if there's a valid `ClientContext` (so not onboarding)
const routes = [
{
index: true,

View File

@@ -2,10 +2,11 @@ import BloomOne from '@sd/assets/images/bloom-one.png';
import clsx from 'clsx';
import { useEffect } from 'react';
import { Outlet, useNavigate } from 'react-router';
import { getOnboardingStore } from '@sd/client';
import { getOnboardingStore, useDebugState } from '@sd/client';
import { tw } from '@sd/ui';
import DragRegion from '~/components/DragRegion';
import { useOperatingSystem } from '~/hooks/useOperatingSystem';
import DebugPopover from '../$libraryId/Layout/Sidebar/DebugPopover';
import Progress from './Progress';
export const OnboardingContainer = tw.div`flex flex-col items-center`;
@@ -15,6 +16,7 @@ export const OnboardingImg = tw.img`w-20 h-20 mb-2`;
export default () => {
const os = useOperatingSystem();
const debugState = useDebugState();
const navigate = useNavigate();
useEffect(
@@ -39,7 +41,6 @@ export default () => {
)}
>
<DragRegion className="z-50 h-9" />
<div className="-mt-5 flex grow flex-col p-10">
<div className="flex grow flex-col items-center justify-center">
<Outlet />
@@ -55,6 +56,7 @@ export default () => {
{/* <img src={BloomThree} className="absolute w-[2000px] h-[2000px] -right-[200px]" /> */}
</div>
</div>
{debugState.enabled && <DebugPopover />}
</div>
);
};

View File

@@ -24,7 +24,7 @@ export function useUnlockOnboardingScreen() {
}
export default function OnboardingProgress() {
const ob_store = useOnboardingStore();
const obStore = useOnboardingStore();
const navigate = useNavigate();
const currentScreenKey = useCurrentOnboardingScreenKey();
@@ -37,7 +37,7 @@ export default function OnboardingProgress() {
return (
<button
key={path}
disabled={!ob_store.unlockedScreens.includes(path)}
disabled={!obStore.unlockedScreens.includes(path)}
onClick={() => navigate(`/onboarding/${path}`)}
className={clsx(
'hover:bg-ink h-2 w-2 rounded-full transition disabled:opacity-10',

View File

@@ -6,30 +6,37 @@ import {
Algorithm,
HASHING_ALGOS,
resetOnboardingStore,
telemetryStore,
useBridgeMutation,
useDebugState,
useOnboardingStore
useOnboardingStore,
usePlausibleEvent
} from '@sd/client';
import { Loader } from '@sd/ui';
import { usePlatform } from '~/util/Platform';
import { OnboardingContainer, OnboardingDescription, OnboardingTitle } from './Layout';
import { useUnlockOnboardingScreen } from './Progress';
export default function OnboardingCreatingLibrary() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const debugState = useDebugState();
const platform = usePlatform();
const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
const [status, setStatus] = useState('Creating your library...');
useUnlockOnboardingScreen();
const debugState = useDebugState();
const createLibrary = useBridgeMutation('library.create', {
onSuccess: (library) => {
queryClient.setQueryData(['library.list'], (libraries: any) => [
...(libraries || []),
library
]);
submitPlausibleEvent({ event: { type: 'libraryCreate' } });
resetOnboardingStore();
navigate(`/${library.uuid}/overview`);
},
@@ -39,17 +46,21 @@ export default function OnboardingCreatingLibrary() {
}
});
const ob_store = useOnboardingStore();
const obStore = useOnboardingStore();
const create = async () => {
// opted to place this here as users could change their mind before library creation/onboarding finalization
// it feels more fitting to configure it here (once)
telemetryStore.shareTelemetry = obStore.shareTelemetry;
createLibrary.mutate({
name: ob_store.newLibraryName,
name: obStore.newLibraryName,
auth: {
type: 'TokenizedPassword',
value: ob_store.passwordSetToken || ''
value: obStore.passwordSetToken || ''
},
algorithm: ob_store.algorithm as Algorithm,
hashing_algorithm: HASHING_ALGOS[ob_store.hashingAlgorithm]
algorithm: obStore.algorithm as Algorithm,
hashing_algorithm: HASHING_ALGOS[obStore.hashingAlgorithm]
});
return;
@@ -58,7 +69,7 @@ export default function OnboardingCreatingLibrary() {
const created = useRef(false);
useEffect(() => {
if (created.current == true) return;
if (created.current) return;
created.current = true;
create();
const timer = setTimeout(() => {

View File

@@ -41,7 +41,7 @@ export default function OnboardingNewLibrary() {
}
});
const ob_store = useOnboardingStore();
const obStore = useOnboardingStore();
const onSubmit = form.handleSubmit(async (data) => {
if (data.password !== data.password_validate) {
@@ -104,7 +104,7 @@ export default function OnboardingNewLibrary() {
<PasswordMeter password={form.watch('password')} />
</div>
<div className="mt-7 flex w-full justify-between">
{!ob_store.passwordSetToken ? (
{!obStore.passwordSetToken ? (
<Button
disabled={form.formState.isSubmitting}
type="submit"

View File

@@ -20,12 +20,12 @@ export default function OnboardingNewLibrary() {
const navigate = useNavigate();
const [importMode, setImportMode] = useState(false);
const ob_store = useOnboardingStore();
const obStore = useOnboardingStore();
const form = useZodForm({
schema,
defaultValues: {
name: ob_store.newLibraryName
name: obStore.newLibraryName
}
});

View File

@@ -36,8 +36,7 @@ export default function OnboardingPrivacy() {
});
const onSubmit = form.handleSubmit(async (data) => {
getOnboardingStore().shareTelemetryDataWithDevelopers =
data.shareTelemetry === 'share-telemetry';
getOnboardingStore().shareTelemetry = data.shareTelemetry === 'share-telemetry';
navigate('/onboarding/creating-library');
});

View File

@@ -19,6 +19,7 @@
"@sd/config": "workspace:*",
"@tanstack/react-query": "^4.12.0",
"crypto-random-string": "^5.0.0",
"plausible-tracker": "^0.3.8",
"valtio": "^1.7.4"
},
"devDependencies": {

View File

@@ -1,3 +1,4 @@
export * from './useClientContext';
export * from './useLibraryContext';
export * from './useOnlineLocations';
export * from './usePlausible';

View File

@@ -0,0 +1,380 @@
import Plausible from 'plausible-tracker';
import { PlausibleOptions as PlausibleTrackerOptions } from 'plausible-tracker';
import { useCallback, useEffect, useRef } from 'react';
import { useDebugState, useTelemetryState } from '../stores';
/**
* This should be in sync with the Core's version.
*/
const Version = '0.1.0';
/**
* Possible Platform types that can be sourced from `usePlatform().platform` or even hardcoded.
*
* @remarks
* The `tauri` platform is renamed to `desktop` for analytic purposes.
*/
type PlatformType = 'web' | 'mobile' | 'tauri';
const Domain = 'app.spacedrive.com';
const PlausibleProvider = Plausible({
trackLocalhost: true,
domain: Domain
});
/**
* This defines all possible options that may be provided by events upon submission.
*
* This extends the standard options provided by the `plausible-tracker`
* package, but also offers some additiional options for custom functionality.
*/
interface PlausibleOptions extends PlausibleTrackerOptions {
/**
* This should **only** be used in contexts where telemetry sharing
* must be allowed/denied via external means. Currently it is not used by anything,
* but probably will be in the future.
*/
telemetryOverride?: boolean;
}
/**
* The base Plausible event, that all other events must be derived
* from in an effort to keep things type-safe.
*/
type BasePlausibleEventWithOption<T, O extends keyof PlausibleOptions> = {
type: T;
plausibleOptions: Required<{
[K in O]: PlausibleOptions[O];
}>;
};
type BasePlausibleEventWithoutOption<T> = {
type: T;
};
export type BasePlausibleEvent<T, O = void> = O extends keyof PlausibleOptions
? BasePlausibleEventWithOption<T, O>
: BasePlausibleEventWithoutOption<T>;
/**
* The Plausible `pageview` event.
*
* **Do not use this directly. Instead, use the
* {@link usePlausiblePageViewMonitor `usePlausiblePageViewMonitor`} hook**.
*/
type PageViewEvent = BasePlausibleEvent<'pageview', 'url'>;
/**
* The custom Plausible `libraryCreate` event.
*
* @example
* ```ts
* const platform = usePlatform();
* const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
*
* const createLibrary = useBridgeMutation('library.create', {
* onSuccess: (library) => {
* submitPlausibleEvent({
* event: {
* type: 'libraryCreate'
* }
* });
* }
* });
* ```
*/
type LibraryCreateEvent = BasePlausibleEvent<'libraryCreate'>;
type LibraryDeleteEvent = BasePlausibleEvent<'libraryDelete'>;
type LocationCreateEvent = BasePlausibleEvent<'locationCreate'>;
type LocationDeleteEvent = BasePlausibleEvent<'locationDelete'>;
type TagCreateEvent = BasePlausibleEvent<'tagCreate'>;
type TagDeleteEvent = BasePlausibleEvent<'tagDelete'>;
type TagAssignEvent = BasePlausibleEvent<'tagAssign'>;
/**
* All union of available, ready-to-use events.
*
* Every possible event must also be added as a "goal" in Plausible's settings (on their site) for the currently active {@link Domain domain}.
*/
type PlausibleEvent =
| PageViewEvent
| LibraryCreateEvent
| LibraryDeleteEvent
| LocationCreateEvent
| LocationDeleteEvent
| TagCreateEvent
| TagDeleteEvent
| TagAssignEvent;
/**
* An event information wrapper for internal use only.
*
* It means that events can both be logged to the console (if enabled) and submitted to Plausible with ease.
*/
interface PlausibleTrackerEvent {
eventName: string;
props: {
platform: 'web' | 'desktop' | 'mobile';
version: string;
debug: boolean;
};
options: PlausibleTrackerOptions;
callback?: () => void;
}
interface SubmitEventProps {
/**
* The Plausible event to submit.
*
* @see {@link PlausibleEvent}
*/
event: PlausibleEvent;
/**
* The current platform type. This should be the output of `usePlatform().platform`
*
* @see {@link PlatformType}
*/
platformType: PlatformType;
/**
* An optional screen width. Default is `window.screen.width`
*/
screenWidth?: number;
/**
* Whether or not telemetry sharing is enabled for the current client.
*
* It is **crucial** that this is the direct output of `useTelemetryState().shareTelemetry`,
* regardless of other conditions that may affect whether we share it (such as event overrides).
*/
shareTelemetry: boolean;
/**
* It is **crucial** that this is sourced from the output of `useDebugState()`
*/
debugState: {
enabled: boolean;
shareTelemetry: boolean;
telemetryLogging: boolean;
};
}
/**
* This function is for directly submitting events to Plausible.
*
* **Avoid using this directly, but if it's necessary then do not misuse this API and only
* send telemetry when certain that it has been allowed by the user. Always prefer the
* {@link usePlausibleEvent `usePlausibleEvent`} hook.**
*
* @remarks
* If any of the following conditions are met, this will return and no data will be submitted:
*
* * If the app is in debug/development mode
* * If a telemetry override is present, but it is not true
* * If no telemetry override is present, and telemetry sharing is not true
*
* @privateRemarks
* Telemetry sharing settings are never matched to `=== false`, but to `!== true` instead.
* This means we can always guarantee that **nothing** will be sent unless the user
* explicitly allows it.
*
* @see {@link https://plausible.io/docs/custom-event-goals Custom events}
* @see {@link https://plausible-tracker.netlify.app/#tracking-custom-events-and-goals Tracking custom events}
*/
const submitPlausibleEvent = async ({ event, debugState, ...props }: SubmitEventProps) => {
if (debugState.enabled && debugState.shareTelemetry !== true) return;
if (
'plausibleOptions' in event && 'telemetryOverride' in event.plausibleOptions
? event.plausibleOptions.telemetryOverride !== true
: props.shareTelemetry !== true
)
return;
const fullEvent: PlausibleTrackerEvent = {
eventName: event.type,
props: {
platform: props.platformType === 'tauri' ? 'desktop' : props.platformType,
version: Version,
debug: debugState.enabled
},
options: {
deviceWidth: props.screenWidth ?? window.screen.width,
// referrer: '', // TODO(brxken128): see if we could have this blank to prevent accidental IP logging
...('plausibleOptions' in event ? event.plausibleOptions : undefined)
},
callback: debugState.telemetryLogging
? () => {
const { callback: _, ...event } = fullEvent;
console.log(event);
}
: undefined
};
PlausibleProvider.trackEvent(
fullEvent.eventName,
{
props: fullEvent.props,
callback: fullEvent.callback
},
fullEvent.options
);
};
interface UsePlausibleEventProps {
/**
* The current platform type. This should be the output of `usePlatform().platform`
*
* @see {@link PlatformType}
*/
platformType: PlatformType;
}
interface EventSubmissionCallbackProps {
/**
* The plausible event to submit.
*
* @see {@link PlausibleEvent}
*/
event: PlausibleEvent;
}
/**
* A Plausible Analytics event submission hook.
*
* The returned callback should only be fired once,
* in order to prevent our analytics from being flooded.
*
* Certain events provide functionality to override the clients's telemetry sharing configuration.
* This is not to ignore the user's choice, but because it should **only** be used in contexts where
* telemetry sharing must be allowed/denied via external means.
*
* @remarks
* If any of the following conditions are met, this will return and no data will be submitted:
*
* * If the app is in debug/development mode
* * If a telemetry override is present, but it is not true
* * If no telemetry override is present, and telemetry sharing is not true
*
* @returns a callback that, once executed, will submit the desired event
*
* @example
* ```ts
* const platform = usePlatform();
* const submitPlausibleEvent = usePlausibleEvent({ platformType: platform.platform });
*
* const createLibrary = useBridgeMutation('library.create', {
* onSuccess: (library) => {
* submitPlausibleEvent({
* event: {
* type: 'libraryCreate'
* }
* });
* }
* });
* ```
*/
export const usePlausibleEvent = ({ platformType }: UsePlausibleEventProps) => {
const debugState = useDebugState();
const shareTelemetry = useTelemetryState().shareTelemetry;
const previousEvent = useRef({} as BasePlausibleEvent<string>);
return useCallback(
async (props: EventSubmissionCallbackProps) => {
if (previousEvent.current === props.event) return;
else previousEvent.current = props.event;
submitPlausibleEvent({ debugState, shareTelemetry, platformType, ...props });
},
[debugState, platformType, shareTelemetry]
);
};
/**
* These rules will be matched as regular expressions via `.replace()`
* in a `forEach` loop.
*
* If a rule matches, the expression will be replaced with the target value.
*
* @example
* ```ts
* let path = "/ed0c715c-d095-4f6a-b83c-1d0b25cc89e7/location/1";
* PageViewRegexRules.forEach((e) => (path = path.replace(e[0], e[1])));
* assert(path === "/location");
* ```
*/
const PageViewRegexRules: [RegExp, string][] = [
/**
* This is for removing the library UUID from the current path
*/
[RegExp('/[a-f0-9]{8}-?[a-f0-9]{4}-?4[a-f0-9]{3}-?[89ab][a-f0-9]{3}-?[a-f0-9]{12}'), ''],
/**
* This is for removing location IDs from the current path
*/
[RegExp('/location/[0-9]+'), '/location'],
/**
* This is for removing tag IDs from the current path
*/
[RegExp('/tag/[0-9]+'), '/tag'],
/**
* This is for removing location IDs from the current path, when in library settings (e.g. `/settings/library/locations/12`)
*/
[RegExp('/locations/[0-9]+'), '/locations']
];
export interface PageViewMonitorProps {
/**
* This should be unsanitized, and should still contain
* all dynamic parameters (such as the library UUID).
*
* Ideally, this should be the output of `useLocation().pathname`
*
* @see {@link PageViewRegexRules} for sanitization
*/
currentPath: string;
/**
* The current platform type. This should be the output of `usePlatform().platform`
*
* @see {@link PlatformType}
*/
platformType: PlatformType;
}
/**
* A Plausible Analytics `pageview` monitoring hook. It watches the router's current
* path, and sends events if a change in the path is detected.
*
* Ideally this should be added to the app extremely early - the sooner the better.
* This means we don't need as many hooks to cover the same amount of routes.
*
* For desktop/web, we use this hook in the `$libraryId` layout and it covers the
* entire app (excluding onboarding, which should not be monitored).
*
* @remarks
* If any of the following conditions are met, this will return and no data will be submitted:
*
* * If the app is in debug/development mode
* * If telemetry sharing (sourced from the client configuration) is not true
*
* @example
* ```ts
* usePlausiblePageViewMonitor({
* currentPath: useLocation().pathname,
* platformType: usePlatform().platform
* });
* ```
*/
export const usePlausiblePageViewMonitor = (props: PageViewMonitorProps) => {
const plausibleEvent = usePlausibleEvent({ platformType: props.platformType });
let path = props.currentPath;
PageViewRegexRules.forEach((e) => (path = path.replace(e[0], e[1])));
useEffect(() => {
plausibleEvent({
event: {
type: 'pageview',
plausibleOptions: { url: path }
}
});
}, [path, plausibleEvent]);
};

View File

@@ -1,13 +1,20 @@
import { useSnapshot } from 'valtio';
import { valtioPersist } from './util';
export const debugState = valtioPersist('sd-debugState', {
export interface DebugState {
enabled: boolean;
rspcLogger: boolean;
reactQueryDevtools: 'enabled' | 'disabled' | 'invisible';
shareTelemetry: boolean; // used for sending telemetry even if the app is in debug mode, and ONLY if client settings also allow telemetry sharing
telemetryLogging: boolean;
}
export const debugState: DebugState = valtioPersist('sd-debugState', {
enabled: globalThis.isDev,
rspcLogger: false,
reactQueryDevtools: (globalThis.isDev ? 'invisible' : 'enabled') as
| 'enabled'
| 'disabled'
| 'invisible'
reactQueryDevtools: globalThis.isDev ? 'invisible' : 'enabled',
shareTelemetry: false,
telemetryLogging: false
});
export function useDebugState() {

View File

@@ -2,3 +2,4 @@ export * from './debugState';
export * from './themeStore';
export * from './util';
export * from './onboardingStore';
export * from './telemetryState';

View File

@@ -18,7 +18,7 @@ const onboardingStoreDefaults = {
algorithm: 'XChaCha20Poly1305',
hashingAlgorithm: 'Argon2id-s' as HashingAlgoSlug,
passwordSetToken: null as string | null,
shareTelemetryDataWithDevelopers: true,
shareTelemetry: true,
useCases: [] as UseCase[],
grantedFullDiskAccess: false
};

View File

@@ -0,0 +1,10 @@
import { useSnapshot } from 'valtio';
import { valtioPersist } from '.';
export const telemetryStore = valtioPersist('sd-telemetryStore', {
shareTelemetry: false // false by default, so functions cannot accidentally send data if the user has not decided
});
export function useTelemetryState() {
return useSnapshot(telemetryStore);
}

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.