Skip to content

Commit 6dbf7c6

Browse files
committedFeb 19, 2024
Update office plugin for mixpanel support
1 parent 528055b commit 6dbf7c6

14 files changed

+448
-2
lines changed
 

‎cSpell.json

+3
Original file line numberDiff line numberDiff line change
@@ -4,17 +4,20 @@
44
"words": [
55
"Cataa",
66
"colour",
7+
"Cookiebot",
78
"jsnext",
89
"lintstagedrc",
910
"mermaidchart",
1011
"mermaidjs",
1112
"openapi",
1213
"outfile",
14+
"pino",
1315
"pkce",
1416
"pnpm",
1517
"regexes",
1618
"sidharth",
1719
"sidharthv",
20+
"skeletonlabs",
1821
"treeshake",
1922
"treeshaking",
2023
"ts-nocheck",

‎packages/office/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@
3535
"lodash-es": "^4.17.21",
3636
"luxon": "^3.1.1",
3737
"mermaid": "10.2.3",
38+
"mixpanel": "^0.18.0",
39+
"mixpanel-browser": "^2.49.0",
3840
"office-addin-dev-certs": "^1.11.4",
3941
"openapi-types": "^12.1.3",
4042
"pino": "^8.11.0",
@@ -50,6 +52,7 @@
5052
"@tailwindcss/forms": "^0.5.3",
5153
"@types/lodash-es": "^4.17.6",
5254
"@types/luxon": "^3.1.0",
55+
"@types/mixpanel-browser": "^2.38.1",
5356
"@types/office-js": "^1.0.350",
5457
"@types/office-runtime": "^1.0.33",
5558
"@typescript-eslint/eslint-plugin": "^5.48.2",

‎packages/office/src/.env.dev

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,5 @@ PUBLIC_ENVIRONMENT="dev"
22
BASIC_AUTH_LOGIN=""
33
E2E_ENV="dev"
44
PUBLIC_LEVEL_EXCEPTIONS="[]"
5-
PUBLIC_LOG_LEVEL="debug"
5+
PUBLIC_LOG_LEVEL="debug"
6+
PUBLIC_MIXPANEL_TOKEN="invalid"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { RuntimeEnvironment } from '$lib/enums';
2+
import { currentEnv } from '$lib/env';
3+
4+
enum FeatureState {
5+
Ready = 'ready',
6+
UnderDevelopment = 'under development',
7+
OnlyInProd = 'only in prod',
8+
StageAndProd = 'only in stage and prod',
9+
NotInProd = 'everywhere but in prod'
10+
}
11+
12+
export enum FeatureName {
13+
Editor = 'editor',
14+
Dashboard = 'dashboard',
15+
UserBehavior = 'userBehavior',
16+
CookieBot = 'cookieBot'
17+
}
18+
19+
/**
20+
* Define the Features interface to describe the shape of the features object.
21+
* Create a features object with a set of feature keys and their corresponding statuses.
22+
*/
23+
const features: Record<FeatureName, FeatureState> = {
24+
editor: FeatureState.UnderDevelopment,
25+
dashboard: FeatureState.UnderDevelopment,
26+
userBehavior: FeatureState.StageAndProd,
27+
cookieBot: FeatureState.OnlyInProd
28+
};
29+
30+
/**
31+
* Check if a feature should be used based on its status in the features object and the current environment.
32+
* @param featureId - The string identifier of the feature to check.
33+
* @returns true if the feature is ready or not found in the features object, or if the environment is 'dev' or 'test', false otherwise.
34+
*/
35+
export const shouldUseFeature = (featureId: FeatureName): boolean => {
36+
if (features[featureId] === FeatureState.Ready) {
37+
return true;
38+
}
39+
if (features[featureId] === FeatureState.OnlyInProd) {
40+
return currentEnv === RuntimeEnvironment.Prod;
41+
}
42+
43+
if (features[featureId] === FeatureState.StageAndProd) {
44+
return currentEnv === RuntimeEnvironment.Prod || currentEnv === RuntimeEnvironment.Stage;
45+
}
46+
47+
if (currentEnv === RuntimeEnvironment.Dev || currentEnv === RuntimeEnvironment.Test) {
48+
return true;
49+
}
50+
51+
if (features[featureId] === FeatureState.NotInProd) {
52+
return currentEnv !== RuntimeEnvironment.Prod;
53+
}
54+
55+
return false;
56+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { RuntimeEnvironment } from '$lib/enums';
2+
import { currentEnv } from '$lib/env';
3+
import { writable } from 'svelte/store';
4+
import { FeatureName, shouldUseFeature } from '../featureSet';
5+
6+
interface CookieBotConsent {
7+
necessary: boolean;
8+
preferences: boolean;
9+
marketing: boolean;
10+
statistics: boolean;
11+
}
12+
13+
declare global {
14+
interface Window {
15+
Cookiebot?: {
16+
consent: CookieBotConsent;
17+
};
18+
}
19+
}
20+
21+
export const analyticsEnabled = writable(
22+
shouldUseFeature(FeatureName.UserBehavior)
23+
);
24+
25+
export const updateConsent = (consent?: CookieBotConsent) => {
26+
if (!consent) {
27+
return;
28+
}
29+
analyticsEnabled.set(consent.statistics && shouldUseFeature(FeatureName.UserBehavior));
30+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Tier } from '$lib/enums';
2+
import type { MCUser } from '$lib/mermaidChartApi';
3+
import { derived, writable } from 'svelte/store';
4+
5+
const defaultSession: MCUser = {
6+
emailAddress: '',
7+
id: '',
8+
authID: '',
9+
analyticsID: '',
10+
subscriptionTier: Tier.Free,
11+
fullName: '',
12+
allowMarketingEmail: false,
13+
allowProductEmail: true
14+
};
15+
16+
function createSession() {
17+
const { subscribe, set, update } = writable(defaultSession);
18+
19+
return {
20+
subscribe,
21+
update: (user: MCUser) => {
22+
const res = update((session) => {
23+
return { ...session, ...user };
24+
});
25+
return res;
26+
},
27+
set,
28+
reset: () => set(defaultSession)
29+
};
30+
}
31+
32+
export const sessionStore = createSession();
33+
34+
export const isFreeUser = derived(sessionStore, ({ subscriptionTier }) => {
35+
return subscriptionTier === Tier.Free;
36+
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { browser } from '$app/environment';
2+
import type { BehaviorEvent } from '$lib/types';
3+
import mixpanel from 'mixpanel-browser';
4+
import { analyticsEnabled } from '../stores/analytics';
5+
import { minutesToMilliSeconds } from '$lib/utils';
6+
import type { MCUser } from '$lib/mermaidChartApi';
7+
import log from '$lib/log';
8+
9+
let initialized = false;
10+
11+
interface QueueItem {
12+
description: string;
13+
event: BehaviorEvent;
14+
}
15+
16+
const MAX_QUEUE_SIZE = 100;
17+
const queue: QueueItem[] = [];
18+
19+
function sendQueue() {
20+
if (queue.length === 0 || !initialized) {
21+
return;
22+
}
23+
log.info('Sending mixpanel events');
24+
for (const { description, event } of queue) {
25+
try {
26+
mixpanel.track(description, event);
27+
} catch (error) {
28+
log.error(error);
29+
}
30+
}
31+
queue.length = 0;
32+
}
33+
34+
function init(mixPanelProject: string, sessionID: string, session: MCUser) {
35+
if (initialized) {
36+
return;
37+
}
38+
// Execute code that sets marketing cookies
39+
mixpanel.init(mixPanelProject, { debug: true, ignore_dnt: true });
40+
41+
mixpanel.identify(session?.analyticsID || sessionID);
42+
mixpanel.people.set({
43+
$email: session.emailAddress,
44+
$name: session.fullName,
45+
tier: session.subscriptionTier,
46+
AllowProductEmail: session.allowProductEmail
47+
});
48+
initialized = true;
49+
sendQueue();
50+
}
51+
52+
export function initializeMixPanel(mixPanelProject: string, sessionID: string, session: MCUser) {
53+
if (!browser || !mixPanelProject) {
54+
return;
55+
}
56+
analyticsEnabled.subscribe((enabled) => {
57+
// This will initialize MixPanel only after the user has accepted analytics cookies
58+
if (enabled) {
59+
init(mixPanelProject, sessionID, session);
60+
}
61+
});
62+
}
63+
64+
function sendEvent(description: string, event: BehaviorEvent) {
65+
// Avoid queueing too many events
66+
if (queue.length < MAX_QUEUE_SIZE) {
67+
queue.push({ description, event });
68+
}
69+
sendQueue();
70+
}
71+
72+
const delaysPerEvent: Record<string, number> = {
73+
DIAGRAM_TYPE: minutesToMilliSeconds(1)
74+
};
75+
const timeouts: Record<string, number> = {};
76+
77+
export function sendBehaviorEvent(description: string, event: BehaviorEvent) {
78+
if (timeouts[event.eventID]) {
79+
clearTimeout(timeouts[event.eventID]);
80+
}
81+
if (delaysPerEvent[event.eventID] === undefined) {
82+
sendEvent(description, event);
83+
} else {
84+
timeouts[event.eventID] = window.setTimeout(() => {
85+
sendEvent(description, event);
86+
delete timeouts[event.eventID];
87+
}, delaysPerEvent[event.eventID]);
88+
}
89+
}
90+
91+
export function clear() {
92+
if (browser && initialized) {
93+
mixpanel.reset();
94+
}
95+
}

‎packages/office/src/lib/enums.ts

+7
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ export enum MessageType {
1919
Success = 'success'
2020
}
2121

22+
export enum Tier {
23+
Free = 'free',
24+
Individual = 'individual',
25+
Pro = 'pro',
26+
Team = 'team',
27+
Enterprise = 'enterprise'
28+
}
2229

2330
/**
2431
* Type guard for enum values

‎packages/office/src/lib/log.ts

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
/* eslint-disable no-console */
2+
import { browser } from '$app/environment';
3+
import { env as publicEnv } from '$env/dynamic/public';
4+
import pino, { type LoggerOptions } from 'pino';
5+
6+
type LogLevel = 'debug' | 'info' | 'warn' | 'error' | 'trace';
7+
type LogLevelException = { name: string; level: LogLevel };
8+
type LogLevelExceptionArray = LogLevelException[];
9+
10+
const logLevel = publicEnv.PUBLIC_LOG_LEVEL as LogLevel;
11+
const exceptionsStr = publicEnv.PUBLIC_LEVEL_EXCEPTIONS;
12+
13+
let urlOverride;
14+
if (browser) {
15+
urlOverride = new URLSearchParams(window.location.search).get('log-level') as LogLevel;
16+
}
17+
const logLevelKey = urlOverride || logLevel || 'info';
18+
19+
function logMethod(args: string[], method: unknown) {
20+
let newArgs;
21+
if (args.length > 1) {
22+
newArgs = ['%s', args.join(' ')];
23+
}
24+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
25+
// @ts-ignore
26+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
27+
method.apply(this, newArgs ?? args);
28+
}
29+
30+
// eslint-disable-next-line @typescript-eslint/no-empty-function
31+
const noop = () => {};
32+
const getLoggerForLevel = (level: LogLevel) => {
33+
let logger;
34+
if (import.meta.env?.DEV) {
35+
logger = {
36+
trace: console.trace,
37+
debug: console.debug,
38+
info: console.info,
39+
warn: console.warn,
40+
error: console.error
41+
};
42+
43+
switch (level) {
44+
case 'error': {
45+
{
46+
logger.warn = noop;
47+
logger.info = noop;
48+
logger.debug = noop;
49+
logger.trace = noop;
50+
}
51+
break;
52+
}
53+
case 'warn': {
54+
{
55+
logger.info = noop;
56+
logger.debug = noop;
57+
logger.trace = noop;
58+
}
59+
break;
60+
}
61+
case 'info': {
62+
{
63+
logger.debug = noop;
64+
logger.trace = noop;
65+
}
66+
break;
67+
}
68+
case 'debug': {
69+
logger.trace = noop;
70+
}
71+
}
72+
} else {
73+
const pinoOptions: LoggerOptions = {
74+
hooks: { logMethod },
75+
level
76+
};
77+
logger = pino(pinoOptions);
78+
}
79+
return logger;
80+
};
81+
const log = getLoggerForLevel(logLevelKey);
82+
83+
export const getLogger = (name: string) => {
84+
try {
85+
const exceptions = JSON.parse(exceptionsStr || '[]') as LogLevelExceptionArray;
86+
let logLevelForName = logLevelKey;
87+
88+
if (exceptions) {
89+
for (const e of exceptions) {
90+
if (e.name === name) {
91+
logLevelForName = e.level;
92+
}
93+
}
94+
}
95+
96+
const namedLogger = getLoggerForLevel(logLevelForName || logLevelKey);
97+
return namedLogger;
98+
} catch (error) {
99+
console.error('error parsing log level exceptions', error);
100+
}
101+
102+
return log;
103+
};
104+
105+
export const getModuleLogger = (name: string) => {
106+
return pino({ name, timestamp: true });
107+
};
108+
109+
export default log;

0 commit comments

Comments
 (0)
Failed to load comments.