Add posthog Telemetry (Anonymity Logic + call duration telemetry) (#658)

Co-authored-by: Timo K <timok@element.io>
Co-authored-by: Šimon Brandner <simon.bra.ag@gmail.com>
This commit is contained in:
Timo 2022-11-04 13:07:14 +01:00 committed by GitHub
parent d5326ed9ee
commit 72503d0335
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 574 additions and 7 deletions

View file

@ -51,6 +51,7 @@
"normalize.css": "^8.0.1",
"pako": "^2.0.4",
"postcss-preset-env": "^7",
"posthog-js": "^1.29.0",
"re-resizable": "^6.9.0",
"react": "18",
"react-dom": "18",

View file

@ -38,6 +38,7 @@ import {
fallbackICEServerAllowed,
} from "./matrix-utils";
import { widget } from "./widget";
import { PosthogAnalytics, RegistrationType } from "./PosthogAnalytics";
import { translatedError } from "./TranslatedError";
declare global {
@ -114,7 +115,9 @@ export const ClientProvider: FC<Props> = ({ children }) => {
if (widget) {
// We're inside a widget, so let's engage *matryoshka mode*
logger.log("Using a matryoshka client");
PosthogAnalytics.instance.setRegistrationType(
RegistrationType.Registered
);
return {
client: await widget.client,
isPasswordlessUser: false,
@ -132,6 +135,11 @@ export const ClientProvider: FC<Props> = ({ children }) => {
session;
try {
PosthogAnalytics.instance.setRegistrationType(
passwordlessUser
? RegistrationType.Guest
: RegistrationType.Registered
);
return {
client: await initClient(
{
@ -279,6 +287,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
error: undefined,
});
history.push("/");
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
}, [history, client]);
const { t } = useTranslation();

336
src/PosthogAnalytics.ts Normal file
View file

@ -0,0 +1,336 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import posthog, { CaptureOptions, PostHog, Properties } from "posthog-js";
import { logger } from "matrix-js-sdk/src/logger";
import { widget } from "./widget";
import { settingsBus } from "./settings/useSetting";
import {
CallEndedTracker,
CallStartedTracker,
LoginTracker,
SignupTracker,
MuteCameraTracker,
MuteMicrophoneTracker,
} from "./PosthogEvents";
import { Config } from "./config/Config";
/* Posthog analytics tracking.
*
* Anonymity behaviour is as follows:
*
* - If Posthog isn't configured in `config.json`, events are not sent.
* - If [Do Not Track](https://developer.mozilla.org/en-US/docs/Web/API/Navigator/doNotTrack) is
* enabled, events are not sent (this detection is built into posthog and turned on via the
* `respect_dnt` flag being passed to `posthog.init`).
* - If the posthog analytics are explicitly activated by the user in the element call settings,
* a randomised analytics ID is created and stored in account_data for that user (shared between devices)
* so that the user can be identified in posthog.
*/
export interface IPosthogEvent {
// The event name that will be used by PostHog. Event names should use camelCase.
eventName: string;
// do not allow these to be sent manually, we enqueue them all for caching purposes
$set?: void;
$set_once?: void;
}
export interface IPostHogEventOptions {
timestamp?: Date;
}
export enum Anonymity {
Disabled,
Anonymous,
Pseudonymous,
}
export enum RegistrationType {
Guest,
Registered,
}
interface PlatformProperties {
appVersion: string;
matrixBackend: "embedded" | "jssdk";
}
interface PosthogSettings {
project_api_key?: string;
api_host?: string;
}
export class PosthogAnalytics {
/* Wrapper for Posthog analytics.
* 3 modes of anonymity are supported, governed by this.anonymity
* - Anonymity.Disabled means *no data* is passed to posthog
* - Anonymity.Anonymous means no identifier is passed to posthog
* - Anonymity.Pseudonymous means an analytics ID stored in account_data and shared between devices
* is passed to posthog.
*
* To update anonymity, call updateAnonymityFromSettings() or you can set it directly via setAnonymity().
*
* To pass an event to Posthog:
*
* 1. Declare a type for the event, extending IPosthogEvent.
*/
private static ANALYTICS_EVENT_TYPE = "im.vector.analytics";
// set true during the constructor if posthog config is present, otherwise false
private static internalInstance = null;
private readonly enabled: boolean = false;
private anonymity = Anonymity.Pseudonymous;
private platformSuperProperties = {};
private registrationType: RegistrationType = RegistrationType.Guest;
public static get instance(): PosthogAnalytics {
if (!this.internalInstance) {
this.internalInstance = new PosthogAnalytics(posthog);
}
return this.internalInstance;
}
constructor(private readonly posthog: PostHog) {
const posthogConfig: PosthogSettings = {
project_api_key: Config.instance.config.posthog?.api_key,
api_host: Config.instance.config.posthog?.api_host,
};
if (posthogConfig.project_api_key && posthogConfig.api_host) {
this.posthog.init(posthogConfig.project_api_key, {
api_host: posthogConfig.api_host,
autocapture: false,
mask_all_text: true,
mask_all_element_attributes: true,
capture_pageview: false,
sanitize_properties: this.sanitizeProperties,
respect_dnt: true,
advanced_disable_decide: true,
});
this.enabled = true;
} else {
this.enabled = false;
}
this.startListeningToSettingsChanges();
}
private sanitizeProperties = (
properties: Properties,
_eventName: string
): Properties => {
// Callback from posthog to sanitize properties before sending them to the server.
// Here we sanitize posthog's built in properties which leak PII e.g. url reporting.
// See utils.js _.info.properties in posthog-js.
if (this.anonymity == Anonymity.Anonymous) {
// drop referrer information for anonymous users
properties["$referrer"] = null;
properties["$referring_domain"] = null;
properties["$initial_referrer"] = null;
properties["$initial_referring_domain"] = null;
// drop device ID, which is a UUID persisted in local storage
properties["$device_id"] = null;
}
return properties;
};
private registerSuperProperties(properties: Properties) {
if (this.enabled) {
this.posthog.register(properties);
}
}
private static getPlatformProperties(): PlatformProperties {
const appVersion = import.meta.env.VITE_APP_VERSION || "unknown";
return {
appVersion,
matrixBackend: widget ? "embedded" : "jssdk",
};
}
private capture(
eventName: string,
properties: Properties,
options?: CaptureOptions
) {
if (!this.enabled) {
return;
}
this.posthog.capture(eventName, { ...properties }, options);
}
public isEnabled(): boolean {
return this.enabled;
}
setAnonymity(anonymity: Anonymity): void {
// Update this.anonymity.
// To update the anonymity typically you want to call updateAnonymityFromSettings
// to ensure this value is in step with the user's settings.
if (
this.enabled &&
(anonymity == Anonymity.Disabled || anonymity == Anonymity.Anonymous)
) {
// when transitioning to Disabled or Anonymous ensure we clear out any prior state
// set in posthog e.g. distinct ID
this.posthog.reset();
// Restore any previously set platform super properties
this.updateSuperProperties();
}
this.anonymity = anonymity;
}
private static getRandomAnalyticsId(): string {
return [...crypto.getRandomValues(new Uint8Array(16))]
.map((c) => c.toString(16))
.join("");
}
public async identifyUser(analyticsIdGenerator: () => string): Promise<void> {
// There might be a better way to get the client here.
const client = window.matrixclient;
if (this.anonymity == Anonymity.Pseudonymous) {
// Check the user's account_data for an analytics ID to use. Storing the ID in account_data allows
// different devices to send the same ID.
try {
const accountData = await client.getAccountDataFromServer(
PosthogAnalytics.ANALYTICS_EVENT_TYPE
);
let analyticsID = accountData?.id;
if (!analyticsID) {
// Couldn't retrieve an analytics ID from user settings, so create one and set it on the server.
// Note there's a race condition here - if two devices do these steps at the same time, last write
// wins, and the first writer will send tracking with an ID that doesn't match the one on the server
// until the next time account data is refreshed and this function is called (most likely on next
// page load). This will happen pretty infrequently, so we can tolerate the possibility.
analyticsID = analyticsIdGenerator();
await client.setAccountData(
PosthogAnalytics.ANALYTICS_EVENT_TYPE,
Object.assign({ id: analyticsID }, accountData)
);
}
this.posthog.identify(analyticsID);
} catch (e) {
// The above could fail due to network requests, but not essential to starting the application,
// so swallow it.
logger.log("Unable to identify user for tracking" + e.toString());
}
}
}
public getAnonymity(): Anonymity {
return this.anonymity;
}
public logout(): void {
if (this.enabled) {
this.posthog.reset();
}
this.setAnonymity(Anonymity.Disabled);
}
public async updateSuperProperties(): Promise<void> {
// Update super properties in posthog with our platform (app version, platform).
// These properties will be subsequently passed in every event.
//
// This only needs to be done once per page lifetime. Note that getPlatformProperties
// is async and can involve a network request if we are running in a browser.
this.platformSuperProperties = PosthogAnalytics.getPlatformProperties();
this.registerSuperProperties({
...this.platformSuperProperties,
registrationType:
this.registrationType == RegistrationType.Guest
? "Guest"
: "Registered",
});
}
private userRegisteredInThisSession(): boolean {
return this.eventSignup.getSignupEndTime() > new Date(0);
}
public async updateAnonymityFromSettings(
pseudonymousOptIn: boolean
): Promise<void> {
// Update this.anonymity based on the user's analytics opt-in settings
const anonymity = pseudonymousOptIn
? Anonymity.Pseudonymous
: Anonymity.Disabled;
this.setAnonymity(anonymity);
if (anonymity === Anonymity.Pseudonymous) {
await this.identifyUser(PosthogAnalytics.getRandomAnalyticsId);
if (this.userRegisteredInThisSession()) {
this.eventSignup.track();
}
}
if (anonymity !== Anonymity.Disabled) {
await this.updateSuperProperties();
}
}
public trackEvent<E extends IPosthogEvent>(
{ eventName, ...properties }: E,
options?: IPostHogEventOptions
): void {
if (
this.anonymity == Anonymity.Disabled ||
this.anonymity == Anonymity.Anonymous
)
return;
this.capture(eventName, properties, options);
}
public startListeningToSettingsChanges(): void {
// Listen to account data changes from sync so we can observe changes to relevant flags and update.
// This is called -
// * On page load, when the account data is first received by sync
// * On login
// * When another device changes account data
// * When the user changes their preferences on this device
// Note that for new accounts, pseudonymousAnalyticsOptIn won't be set, so updateAnonymityFromSettings
// won't be called (i.e. this.anonymity will be left as the default, until the setting changes)
settingsBus.on("opt-in-analytics", (optInAnalytics) => {
this.updateAnonymityFromSettings(optInAnalytics);
});
}
public setRegistrationType(registrationType: RegistrationType): void {
this.registrationType = registrationType;
if (
this.anonymity == Anonymity.Disabled ||
this.anonymity == Anonymity.Anonymous
)
return;
this.updateSuperProperties();
}
// ----- Events
public eventCallEnded = new CallEndedTracker();
public eventSignup = new SignupTracker();
public eventCallStarted = new CallStartedTracker();
public eventLogin = new LoginTracker();
public eventMuteMicrophone = new MuteMicrophoneTracker();
public eventMuteCamera = new MuteCameraTracker();
}

145
src/PosthogEvents.ts Normal file
View file

@ -0,0 +1,145 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import {
IPosthogEvent,
PosthogAnalytics,
RegistrationType,
} from "./PosthogAnalytics";
interface CallEnded extends IPosthogEvent {
eventName: "CallEnded";
callName: string;
callParticipantsOnLeave: number;
callParticipantsMax: number;
callDuration: number;
}
export class CallEndedTracker {
private cache: { startTime: Date; maxParticipantsCount: number } = {
startTime: new Date(0),
maxParticipantsCount: 0,
};
cacheStartCall(time: Date) {
this.cache.startTime = time;
}
cacheParticipantCountChanged(count: number) {
this.cache.maxParticipantsCount = Math.max(
count,
this.cache.maxParticipantsCount
);
}
track(callName: string, callParticipantsNow: number) {
PosthogAnalytics.instance.trackEvent<CallEnded>({
eventName: "CallEnded",
callName,
callParticipantsMax: this.cache.maxParticipantsCount,
callParticipantsOnLeave: callParticipantsNow,
callDuration: (Date.now() - this.cache.startTime.getTime()) / 1000,
});
}
}
interface CallStarted extends IPosthogEvent {
eventName: "CallStarted";
callName: string;
}
export class CallStartedTracker {
track(callName: string) {
PosthogAnalytics.instance.trackEvent<CallStarted>({
eventName: "CallStarted",
callName,
});
}
}
interface Signup extends IPosthogEvent {
eventName: "Signup";
signupDuration: number;
}
export class SignupTracker {
private cache: { signupStart: Date; signupEnd: Date } = {
signupStart: new Date(0),
signupEnd: new Date(0),
};
cacheSignupStart(time: Date) {
this.cache.signupStart = time;
}
getSignupEndTime() {
return this.cache.signupEnd;
}
cacheSignupEnd(time: Date) {
this.cache.signupEnd = time;
}
track() {
PosthogAnalytics.instance.trackEvent<Signup>({
eventName: "Signup",
signupDuration:
new Date().getSeconds() - this.cache.signupStart.getSeconds(),
});
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Registered);
}
}
interface Login extends IPosthogEvent {
eventName: "Login";
}
export class LoginTracker {
track() {
PosthogAnalytics.instance.trackEvent<Login>({
eventName: "Login",
});
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Registered);
}
}
interface MuteMicrophone {
eventName: "MuteMicrophone";
targetMuteState: "mute" | "unmute";
}
export class MuteMicrophoneTracker {
track(targetIsMute: boolean) {
PosthogAnalytics.instance.trackEvent<MuteMicrophone>({
eventName: "MuteMicrophone",
targetMuteState: targetIsMute ? "mute" : "unmute",
});
}
}
interface MuteCamera {
eventName: "MuteCamera";
targetMuteState: "mute" | "unmute";
}
export class MuteCameraTracker {
track(targetIsMute: boolean) {
PosthogAnalytics.instance.trackEvent<MuteCamera>({
eventName: "MuteCamera",
targetMuteState: targetIsMute ? "mute" : "unmute",
});
}
}

View file

@ -33,6 +33,7 @@ import { defaultHomeserver, defaultHomeserverHost } from "../matrix-utils";
import styles from "./LoginPage.module.css";
import { useInteractiveLogin } from "./useInteractiveLogin";
import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../PosthogAnalytics";
export const LoginPage: FC = () => {
const { t } = useTranslation();
@ -64,6 +65,7 @@ export const LoginPage: FC = () => {
} else {
history.push("/");
}
PosthogAnalytics.instance.eventLogin.track();
})
.catch((error) => {
setError(error);

View file

@ -39,6 +39,7 @@ import { LoadingView } from "../FullScreenView";
import { useRecaptcha } from "./useRecaptcha";
import { Caption, Link } from "../typography/Typography";
import { usePageTitle } from "../usePageTitle";
import { PosthogAnalytics } from "../PosthogAnalytics";
export const RegisterPage: FC = () => {
const { t } = useTranslation();
@ -98,6 +99,7 @@ export const RegisterPage: FC = () => {
}
setClient(newClient, session);
PosthogAnalytics.instance.eventSignup.cacheSignupEnd(new Date());
};
submit()
@ -142,6 +144,8 @@ export const RegisterPage: FC = () => {
if (loading) {
return <LoadingView />;
} else {
PosthogAnalytics.instance.eventSignup.cacheSignupStart(new Date());
}
return (

View file

@ -1,6 +1,7 @@
export interface IConfigOptions {
posthog?: {
api_key: string;
api_host: string;
};
sentry?: {
DSN: string;

View file

@ -353,7 +353,7 @@ function useGroupCallState(
client: MatrixClient,
groupCall: GroupCall,
showPollCallStats: boolean
) {
): InspectorContextState {
const [state, dispatch] = useReducer(reducer, {
localUserId: client.getUserId(),
localSessionId: client.getSessionId(),
@ -410,11 +410,13 @@ function useGroupCallState(
return state;
}
interface GroupCallInspectorProps {
client: MatrixClient;
groupCall: GroupCall;
show: boolean;
}
export function GroupCallInspector({
client,
groupCall,

View file

@ -32,6 +32,7 @@ import { CallEndedView } from "./CallEndedView";
import { useRoomAvatar } from "./useRoomAvatar";
import { useSentryGroupCallHandler } from "./useSentryGroupCallHandler";
import { useLocationNavigation } from "../useLocationNavigation";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { useMediaHandler } from "../settings/useMediaHandler";
import { findDeviceByName, getDevices } from "../media-utils";
@ -170,6 +171,12 @@ export function GroupCallView({
const onLeave = useCallback(() => {
setLeft(true);
PosthogAnalytics.instance.eventCallEnded.track(
groupCall.room.name,
groupCall.participants.length
);
leave();
if (widget) {
widget.api.transport.send(ElementWidgetActions.HangupCall, {});
@ -179,7 +186,14 @@ export function GroupCallView({
if (!isPasswordlessUser && !isEmbedded) {
history.push("/");
}
}, [leave, isPasswordlessUser, isEmbedded, history]);
}, [
groupCall.room.name,
groupCall.participants.length,
leave,
isPasswordlessUser,
isEmbedded,
history,
]);
useEffect(() => {
if (widget && state === GroupCallState.Entered) {

View file

@ -57,6 +57,7 @@ import { useAudioContext } from "../video-grid/useMediaStream";
import { useFullscreen } from "../video-grid/useFullscreen";
import { AudioContainer } from "../video-grid/AudioContainer";
import { useAudioOutputDevice } from "../video-grid/useAudioOutputDevice";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { widget, ElementWidgetActions } from "../widget";
import { useJoinRule } from "./useJoinRule";
import { useUrlParams } from "../UrlParams";
@ -210,6 +211,9 @@ export function InCallView({
});
}
PosthogAnalytics.instance.eventCallEnded.cacheParticipantCountChanged(
participants.length
);
// add the screenshares too
for (const screenshareFeed of screenshareFeeds) {
const userMediaItem = tileDescriptors.find(

View file

@ -30,6 +30,7 @@ import { useTranslation } from "react-i18next";
import { IWidgetApiRequest } from "matrix-widget-api";
import { usePageUnload } from "./usePageUnload";
import { PosthogAnalytics } from "../PosthogAnalytics";
import { TranslatedError, translatedError } from "../TranslatedError";
import { ElementWidgetActions, ScreenshareStartData, widget } from "../widget";
@ -280,6 +281,9 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
return;
}
PosthogAnalytics.instance.eventCallEnded.cacheStartCall(new Date());
PosthogAnalytics.instance.eventCallStarted.track(groupCall.room.name);
groupCall.enter().catch((error) => {
console.error(error);
updateState({ error });
@ -289,11 +293,15 @@ export function useGroupCall(groupCall: GroupCall): UseGroupCallReturnType {
const leave = useCallback(() => groupCall.leave(), [groupCall]);
const toggleLocalVideoMuted = useCallback(() => {
groupCall.setLocalVideoMuted(!groupCall.isLocalVideoMuted());
const toggleToMute = !groupCall.isLocalVideoMuted();
groupCall.setLocalVideoMuted(toggleToMute);
PosthogAnalytics.instance.eventMuteCamera.track(toggleToMute);
}, [groupCall]);
const toggleMicrophoneMuted = useCallback(() => {
groupCall.setMicrophoneMuted(!groupCall.isMicrophoneMuted());
const toggleToMute = !groupCall.isMicrophoneMuted();
groupCall.setMicrophoneMuted(toggleToMute);
PosthogAnalytics.instance.eventMuteMicrophone.track(toggleToMute);
}, [groupCall]);
const toggleScreensharing = useCallback(async () => {

View file

@ -26,7 +26,11 @@ import { ReactComponent as VideoIcon } from "../icons/Video.svg";
import { ReactComponent as DeveloperIcon } from "../icons/Developer.svg";
import { SelectInput } from "../input/SelectInput";
import { useMediaHandler } from "./useMediaHandler";
import { useSpatialAudio, useShowInspector } from "./useSetting";
import {
useSpatialAudio,
useShowInspector,
useOptInAnalytics,
} from "./useSetting";
import { FieldRow, InputField } from "../input/Input";
import { Button } from "../button";
import { useDownloadDebugLog } from "./submit-rageshake";
@ -53,6 +57,7 @@ export const SettingsModal = (props: Props) => {
const [spatialAudio, setSpatialAudio] = useSpatialAudio();
const [showInspector, setShowInspector] = useShowInspector();
const [optInAnalytics, setOptInAnalytics] = useOptInAnalytics();
const downloadDebugLog = useDownloadDebugLog();
@ -115,6 +120,18 @@ export const SettingsModal = (props: Props) => {
}
/>
</FieldRow>
<FieldRow>
<InputField
id="optInAnalytics"
label="Allow analytics"
type="checkbox"
checked={optInAnalytics}
description="This will send anonymized data such as the duration of a call the and number of participants to the element call team to help us optimizing the application based on how it is used."
onChange={(event: React.ChangeEvent<HTMLInputElement>) =>
setOptInAnalytics(event.target.checked)
}
/>
</FieldRow>
</TabItem>
<TabItem
title={

View file

@ -18,7 +18,7 @@ import { EventEmitter } from "events";
import { useMemo, useState, useEffect, useCallback } from "react";
// Bus to notify other useSetting consumers when a setting is changed
const settingsBus = new EventEmitter();
export const settingsBus = new EventEmitter();
// Like useState, but reads from and persists the value to localStorage
const useSetting = <T>(
@ -54,3 +54,4 @@ const useSetting = <T>(
export const useSpatialAudio = () => useSetting("spatial-audio", false);
export const useShowInspector = () => useSetting("show-inspector", false);
export const useOptInAnalytics = () => useSetting("opt-in-analytics", false);

View file

@ -2403,6 +2403,11 @@
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-6.19.7.tgz#c6b337912e588083fc2896eb012526cf7cfec7c7"
integrity sha512-jH84pDYE+hHIbVnab3Hr+ZXr1v8QABfhx39KknxqKWr2l0oEItzepV0URvbEhB446lk/S/59230dlUUIBGsXbg==
"@sentry/types@^7.2.0":
version "7.13.0"
resolved "https://registry.yarnpkg.com/@sentry/types/-/types-7.13.0.tgz#398e33e5c92ea0ce91e2c86e3ab003fe00c471a2"
integrity sha512-ttckM1XaeyHRLMdr79wmGA5PFbTGx2jio9DCD/mkEpSfk6OGfqfC7gpwy7BNstDH/VKyQj/lDCJPnwvWqARMoQ==
"@sentry/utils@6.19.7":
version "6.19.7"
resolved "https://registry.yarnpkg.com/@sentry/utils/-/utils-6.19.7.tgz#6edd739f8185fd71afe49cbe351c1bbf5e7b7c79"
@ -7638,6 +7643,11 @@ fetch-retry@^5.0.2:
resolved "https://registry.yarnpkg.com/fetch-retry/-/fetch-retry-5.0.3.tgz#edfa3641892995f9afee94f25b168827aa97fe3d"
integrity sha512-uJQyMrX5IJZkhoEUBQ3EjxkeiZkppBd5jS/fMTJmfZxLSiaQjv2zD0kTvuvkSH89uFvgSlB6ueGpjD3HWN7Bxw==
fflate@^0.4.1:
version "0.4.8"
resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.4.8.tgz#f90b82aefbd8ac174213abb338bd7ef848f0f5ae"
integrity sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==
figgy-pudding@^3.5.1:
version "3.5.2"
resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
@ -11680,6 +11690,15 @@ postcss@^8.4.13:
picocolors "^1.0.0"
source-map-js "^1.0.2"
posthog-js@^1.29.0:
version "1.31.0"
resolved "https://registry.yarnpkg.com/posthog-js/-/posthog-js-1.31.0.tgz#43ffb5a11948a5b10af75e749108936a07519963"
integrity sha512-d6vBb/ChS+t33voi37HA76etwWIukEcvJLZLZvkhJZcIrR29shwkAFUzd8lL7VdAelLlaAtmoPMwr820Yq5GUg==
dependencies:
"@sentry/types" "^7.2.0"
fflate "^0.4.1"
rrweb-snapshot "^1.1.14"
prelude-ls@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
@ -12585,6 +12604,10 @@ rollup@^2.59.0:
optionalDependencies:
fsevents "~2.3.2"
rrweb-snapshot@^1.1.14:
version "1.1.14"
resolved "https://registry.yarnpkg.com/rrweb-snapshot/-/rrweb-snapshot-1.1.14.tgz#9d4d9be54a28a893373428ee4393ec7e5bd83fcc"
integrity sha512-eP5pirNjP5+GewQfcOQY4uBiDnpqxNRc65yKPW0eSoU1XamDfc4M8oqpXGMyUyvLyxFDB0q0+DChuxxiU2FXBQ==
rsvp@^4.8.2:
version "4.8.5"
resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"