Files
habitica/website/server/libs/analyticsService.js
2025-09-10 12:44:17 +02:00

332 lines
8.9 KiB
JavaScript

/* eslint-disable camelcase */
import nconf from 'nconf';
import Amplitude from 'amplitude';
import useragent from 'useragent';
import validator from 'validator';
import { lookup } from 'ip-location-api';
import { createHash } from 'crypto';
import {
omit,
toArray,
} from 'lodash';
import common from '../../common';
import logger from './logger';
const LOG_AMPLITUDE_EVENTS = nconf.get('LOG_AMPLITUDE_EVENTS') === 'true';
const AMPLITUDE_TOKEN = nconf.get('AMPLITUDE_KEY');
const AMPLITUDE_PROPERTIES_TO_SCRUB = [
'uuid', 'user', 'purchaseValue',
'headers', 'registeredThrough',
];
const PLATFORM_MAP = Object.freeze({
'habitica-web': 'Web',
'habitica-ios': 'iOS',
'habitica-android': 'Android',
});
let amplitude;
if (AMPLITUDE_TOKEN) amplitude = new Amplitude(AMPLITUDE_TOKEN);
const Content = common.content;
function _hashUUID (uuid) {
return createHash('sha256').update(uuid).digest('hex');
}
function _anonymizeProperties (properties) {
return properties.map(userProp => {
if (typeof userProp === 'string' && validator.isEmail(userProp)) {
return _hashUUID(userProp);
}
return userProp;
});
}
function _lookUpItemName (itemKey) {
if (!itemKey) return null;
const gear = Content.gear.flat[itemKey];
const egg = Content.eggs[itemKey];
const food = Content.food[itemKey];
const hatchingPotion = Content.hatchingPotions[itemKey];
const quest = Content.quests[itemKey];
const spell = Content.special[itemKey];
let itemName;
if (gear) {
itemName = gear.text();
} else if (egg) {
itemName = `${egg.text()} Egg`;
} else if (food) {
itemName = food.text();
} else if (hatchingPotion) {
itemName = `${hatchingPotion.text()} Hatching Potion`;
} else if (quest) {
itemName = quest.text();
} else if (spell) {
itemName = spell.text();
}
return itemName;
}
function _formatUserData (user, ipaddress, anonymize = false) {
const properties = {};
if (user.stats) {
properties.Class = user.stats.class;
properties.Experience = Math.floor(user.stats.exp);
properties.Gold = Math.floor(user.stats.gp);
properties.Health = Math.ceil(user.stats.hp);
properties.Level = user.stats.lvl;
properties.Mana = Math.floor(user.stats.mp);
}
properties.balance = user.balance;
properties.balanceGemAmount = properties.balance * 4;
properties.tutorialComplete = user.flags && user.flags.tour && user.flags.tour.intro === -2;
properties.verifiedUsername = user.flags && user.flags.verifiedUsername;
if (properties.verifiedUsername && user.auth && user.auth.local && !anonymize) {
properties.username = user.auth.local.lowerCaseUsername;
}
if (user.habits && user.dailys && user.todos && user.rewards) {
properties['Number Of Tasks'] = {
habits: user.habits.length,
dailys: user.dailys.length,
todos: user.todos.length,
rewards: user.rewards.length,
};
}
if (user.contributor && user.contributor.level) {
properties.contributorLevel = user.contributor.level;
}
if (!anonymize) {
if (user.purchased && user.purchased.plan.planId) {
properties.subscription = user.purchased.plan.planId;
} else {
properties.subscription = null;
}
}
if (user._ABtests) {
properties.ABtests = toArray(user._ABtests);
}
if (user.loginIncentives) {
properties.loginIncentives = user.loginIncentives;
}
if (ipaddress) {
const location = lookup(ipaddress);
properties.country = location.country;
properties.region = location.region1;
}
if (anonymize) {
return _anonymizeProperties(properties);
}
return properties;
}
function _formatPlatformForAmplitude (platform) {
if (!platform) {
return 'Unknown';
}
if (platform in PLATFORM_MAP) {
return PLATFORM_MAP[platform];
}
return '3rd Party';
}
function _formatUserAgentForAmplitude (platform, agentString) {
if (!agentString) {
return 'Unknown';
}
const agent = useragent.lookup(agentString).toJSON();
const formattedAgent = {};
if (platform === 'iOS' || platform === 'Android') {
formattedAgent.name = agent.os.family;
formattedAgent.version = `${agent.os.major}.${agent.os.minor}.${agent.os.patch}`;
if (platform === 'Android' && formattedAgent.name === 'Other') {
formattedAgent.name = 'Android';
}
} else {
formattedAgent.name = agent.family;
formattedAgent.version = agent.major;
}
return formattedAgent;
}
function _formatUUIDForAmplitude (uuid, anonymize = false) {
if (anonymize) {
return _hashUUID(uuid);
}
return uuid || 'no-user-id-was-provided';
}
function _formatDataForAmplitude (data) {
const consented = data.user && data.user.preferences && data.user.preferences.analyticsConsent;
const event_properties = omit(data, AMPLITUDE_PROPERTIES_TO_SCRUB);
const platform = _formatPlatformForAmplitude(data.headers && data.headers['x-client']);
const agent = _formatUserAgentForAmplitude(platform, data.headers && data.headers['user-agent']);
const ampData = {
user_id: _formatUUIDForAmplitude(data.uuid, !consented),
platform,
os_name: agent.name,
os_version: agent.version,
event_properties,
};
if (data.user) {
const ipaddress = data.ipaddress || (data.headers && data.headers['x-forwarded-for']);
ampData.user_properties = _formatUserData(data.user, ipaddress, !consented);
}
if (!consented) {
ampData.event_properties = _anonymizeProperties(ampData.event_properties);
}
const itemName = _lookUpItemName(data.itemKey);
if (itemName) {
ampData.event_properties.itemName = itemName;
}
return ampData;
}
function _sendDataToAmplitude (eventType, data, loggerOnly) {
const amplitudeData = _formatDataForAmplitude(data);
amplitudeData.event_type = eventType;
if (LOG_AMPLITUDE_EVENTS) {
logger.info('Amplitude Event', amplitudeData);
}
if (loggerOnly) return Promise.resolve(null);
return amplitude
.track(amplitudeData)
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _sendPurchaseDataToAmplitude (data) {
const amplitudeData = _formatDataForAmplitude(data);
// Stripe transactions come via webhook. We can log these as Web events
if (data.paymentMethod === 'Stripe' && amplitudeData.platform === 'Unknown') {
amplitudeData.platform = 'Web';
}
amplitudeData.event_type = 'purchase';
amplitudeData.revenue = data.purchaseValue;
amplitudeData.productId = data.itemPurchased;
if (LOG_AMPLITUDE_EVENTS) {
logger.info('Amplitude Purchase Event', amplitudeData);
}
return amplitude
.track(amplitudeData)
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _setOnce (dataToSetOnce, uuid) {
return amplitude
.identify({
user_id: _formatUUIDForAmplitude(uuid),
user_properties: {
$setOnce: dataToSetOnce,
},
})
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
function _updateProperties (properties, uuid) {
return amplitude
.identify({
user_id: _formatUUIDForAmplitude(uuid),
user_properties: properties,
})
.catch(err => logger.error(err, 'Error while sending data to Amplitude.'));
}
// There's no error handling directly here because it's handled inside _sendDataTo{Amplitude|Google}
async function track (eventType, data, loggerOnly = false) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
const promises = [
_sendDataToAmplitude(eventType, data, loggerOnly),
];
if (user.registeredThrough) {
promises.push(_setOnce({
registeredPlatform: user.registeredThrough,
}, data.uuid || user._id));
}
return Promise.all(promises);
}
// There's no error handling directly here because
// it's handled inside _sendPurchaseDataTo{Amplitude|Google}
async function trackPurchase (data) {
const { user } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
return Promise.all([
_sendPurchaseDataToAmplitude(data),
]);
}
async function updateUserData (data) {
const { user, properties } = data;
if (!user || !user.preferences || !user.preferences.analyticsConsent) {
return null;
}
const toUpdate = {
..._formatUserData(user, data.ipaddress),
...properties,
};
return _updateProperties(toUpdate, user._id);
}
// Stub for non-prod environments
const mockAnalyticsService = {
track: () => { },
trackPurchase: () => { },
updateUserData: () => { },
};
// Return the production or mock service based on the current environment
function getServiceByEnvironment () {
if (nconf.get('IS_PROD') || nconf.get('USE_PROD_ANALYTICS') || (nconf.get('DEBUG_ENABLED') && !nconf.get('BASE_URL').includes('localhost'))) {
return {
track,
trackPurchase,
updateUserData,
};
}
return mockAnalyticsService;
}
export {
track,
trackPurchase,
mockAnalyticsService,
getServiceByEnvironment as getAnalyticsServiceByEnvironment,
};