feat(analytics): initial Habitica-owned solution

This commit is contained in:
Phillip Thelen
2026-04-07 15:33:57 -05:00
committed by Kalista Payne
parent 746fcfff49
commit e6ffd69148
90 changed files with 762 additions and 2479 deletions
@@ -1,6 +1,5 @@
import validator from 'validator';
import moment from 'moment';
import pick from 'lodash/pick';
import sortBy from 'lodash/sortBy';
import nconf from 'nconf';
import {
@@ -127,14 +126,6 @@ api.loginLocal = {
user.auth.timestamps.updated = new Date();
await user.save();
res.analytics.track('login', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
type: 'local',
uuid: user._id,
headers: req.headers,
});
return loginRes(user, req, res);
},
};
@@ -1,7 +1,6 @@
import cloneDeep from 'lodash/cloneDeep';
import escapeRegExp from 'lodash/escapeRegExp';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import reduce from 'lodash/reduce';
import times from 'lodash/times';
import { authWithHeaders, authWithSession } from '../../middlewares/auth';
@@ -290,19 +289,6 @@ api.createChallenge = {
};
response.group = getChallengeGroupResponse(group);
res.analytics.track('challenge create', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: response._id,
groupID: group._id,
groupName: group.privacy === 'private' ? null : group.name,
groupType: group._id === TAVERN_ID ? 'tavern' : group.type,
prize: response.prize,
headers: req.headers,
});
res.respond(201, response);
},
};
@@ -359,18 +345,6 @@ api.joinChallenge = {
const chalLeader = await User.findById(response.leader).select(nameFields).exec();
response.leader = chalLeader ? chalLeader.toJSON({ minimize: true }) : null;
res.analytics.track('challenge join', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
groupID: group._id,
groupName: group.privacy === 'private' ? null : group.name,
groupType: group._id === TAVERN_ID ? 'tavern' : group.type,
headers: req.headers,
});
res.respond(200, response);
},
};
@@ -410,18 +384,6 @@ api.leaveChallenge = {
// Unlink challenge's tasks from user's tasks and save the challenge
await challenge.unlinkTasks(user, keep);
res.analytics.track('challenge leave', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
groupID: challenge.group._id,
groupName: challenge.group.privacy === 'private' ? null : challenge.group.name,
groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type,
headers: req.headers,
});
res.respond(200, {});
},
};
@@ -895,19 +857,6 @@ api.deleteChallenge = {
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({ broken: 'CHALLENGE_DELETED' });
res.analytics.track('challenge delete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
groupID: challenge.group._id,
groupName: challenge.group.privacy === 'private' ? null : challenge.group.name,
groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type,
prize: challenge.prize,
headers: req.headers,
});
res.respond(200, {});
},
};
@@ -956,20 +905,6 @@ api.selectChallengeWinner = {
// Close channel in background, some ops are run in the background without `await`ing
await challenge.closeChal({ broken: 'CHALLENGE_CLOSED', winner });
res.analytics.track('challenge close', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
challengeID: challenge._id,
challengeWinnerID: winner._id,
groupID: challenge.group._id,
groupName: challenge.group.privacy === 'private' ? null : challenge.group.name,
groupType: challenge.group._id === TAVERN_ID ? 'tavern' : challenge.group.type,
prize: challenge.prize,
headers: req.headers,
});
res.respond(200, {});
},
};
-32
View File
@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import moment from 'moment';
import nconf from 'nconf';
import { authWithHeaders, chatPrivilegesRequired } from '../../middlewares/auth';
@@ -23,9 +22,6 @@ import { getMatchesByWordArray } from '../../libs/stringUtils';
import bannedSlurs from '../../libs/bannedSlurs';
import { apiError } from '../../libs/apiError';
import highlightMentions from '../../libs/highlightMentions';
import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService';
const analytics = getAnalyticsServiceByEnvironment();
const ACCOUNT_MIN_CHAT_AGE = Number(nconf.get('ACCOUNT_MIN_CHAT_AGE'));
@@ -187,13 +183,6 @@ api.postChat = {
// Check if account is newer than the minimum age for chat participation
if (moment().diff(user.auth.timestamps.created, 'minutes') < ACCOUNT_MIN_CHAT_AGE) {
analytics.track('chat age error', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
});
throw new BadRequest(res.t('chatTemporarilyUnavailable'));
}
@@ -239,27 +228,6 @@ api.postChat = {
await Promise.all(toSave);
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
groupType: group.type,
privacy: group.privacy,
headers: req.headers,
};
if (mentions) {
analyticsObject.mentionsCount = mentions.length;
} else {
analyticsObject.mentionsCount = 0;
}
if (group.privacy === 'public') {
analyticsObject.groupName = group.name;
}
res.analytics.track('group chat', analyticsObject);
if (chatUpdated) {
res.respond(200, { chat: chatRes.chat });
} else {
@@ -5,7 +5,6 @@ import findIndex from 'lodash/findIndex';
import includes from 'lodash/includes';
import isArray from 'lodash/isArray';
import mergeWith from 'lodash/mergeWith';
import pick from 'lodash/pick';
import uniqBy from 'lodash/uniqBy';
import nconf from 'nconf';
import moment from 'moment';
@@ -166,25 +165,6 @@ api.createGroup = {
profile: { name: user.profile.name },
};
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: true,
groupId: savedGroup._id,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
invited: false,
};
if (savedGroup.privacy === 'public') {
analyticsObject.groupName = savedGroup.name;
}
res.analytics.track('join group', analyticsObject);
res.respond(201, response); // do not remove chat flags data as we've just created the group
},
};
@@ -217,19 +197,6 @@ api.createGroupPlan = {
const results = await Promise.all([user.save(), group.save()]);
const savedGroup = results[1];
res.analytics.track('join group', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: true,
groupId: savedGroup._id,
groupType: savedGroup.type,
privacy: savedGroup.privacy,
headers: req.headers,
invited: false,
});
// do not remove chat flags data as we've just created the group
const groupResponse = savedGroup.toJSON();
// the leader is the authenticated user
@@ -585,7 +552,6 @@ api.joinGroup = {
if (!group) throw new NotFound(res.t('groupNotFound'));
let isUserInvited = false;
const seekingParty = Boolean(user.party.seeking);
if (group.type === 'party') {
// Check if was invited to party
@@ -710,20 +676,6 @@ api.joinGroup = {
promises.push(group.save());
const analyticsObject = {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
owner: false,
groupId: group._id,
groupType: group.type,
privacy: group.privacy,
headers: req.headers,
invited: isUserInvited,
seekingParty: group.type === 'party' ? seekingParty : null,
};
promises = await Promise.all(promises);
if (group.hasNotCancelled()) {
@@ -737,8 +689,6 @@ api.joinGroup = {
response.leader = leader.toJSON({ minimize: true });
}
res.analytics.track('join group', analyticsObject);
res.respond(200, response);
},
};
@@ -1,5 +1,4 @@
import escapeRegExp from 'lodash/escapeRegExp';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import {
model as User,
@@ -734,17 +733,6 @@ api.transferGems = {
}
res.respond(200, {});
if (res.analytics) {
res.analytics.track('transfer gems', {
user: pick(sender, ['preferences', 'registeredThrough']),
uuid: sender._id,
hitType: 'event',
category: 'behavior',
headers: req.headers,
quantity: gemAmount,
});
}
},
};
@@ -1,9 +1,7 @@
import each from 'lodash/each';
import every from 'lodash/every';
import isBoolean from 'lodash/isBoolean';
import pick from 'lodash/pick';
import { authWithHeaders } from '../../middlewares/auth';
import { getAnalyticsServiceByEnvironment } from '../../libs/analyticsService';
import {
model as Group,
basicFields as basicGroupFields,
@@ -24,8 +22,6 @@ import { apiError } from '../../libs/apiError';
import { questActivityWebhook } from '../../libs/webhook';
import { model as UserHistory } from '../../models/userHistory';
const analytics = getAnalyticsServiceByEnvironment();
const questScrolls = common.content.quests;
function canStartQuestAutomatically (group) {
@@ -166,17 +162,6 @@ api.inviteToQuest = {
quest,
});
// track that the inviting user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
category: 'behavior',
headers: req.headers,
owner: true,
questName: questKey,
response: 'accept',
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withQuestInviteResponse(group.quest.key, 'invite')
.commit();
@@ -231,17 +216,6 @@ api.acceptQuest = {
res.respond(200, savedGroup.quest);
// track that a user has accepted the quest
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: false,
response: 'accept',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withQuestInviteResponse(group.quest.key, 'accept')
.commit();
@@ -297,16 +271,6 @@ api.rejectQuest = {
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: false,
response: 'reject',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
});
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withQuestInviteResponse(group.quest.key, 'reject')
.commit();
@@ -360,16 +324,6 @@ api.forceStart = {
]);
res.respond(200, savedGroup.quest);
analytics.track('quest', {
user: pick(user, ['preferences', 'registeredThrough']),
category: 'behavior',
owner: user._id === group.quest.leader,
response: 'force-start',
questName: group.quest.key,
uuid: user._id,
headers: req.headers,
});
},
};
@@ -1,7 +1,6 @@
import assign from 'lodash/assign';
import find from 'lodash/find';
import merge from 'lodash/merge';
import pick from 'lodash/pick';
import moment from 'moment';
import { authWithHeaders } from '../../middlewares/auth';
import {
@@ -330,17 +329,6 @@ api.createChallengeTasks = {
// If adding tasks to a challenge -> sync users
if (challenge) challenge.addTasks(tasks);
tasks.forEach(task => {
res.analytics.track('challenge task created', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
challengeID: challenge._id,
});
});
},
};
@@ -700,17 +688,6 @@ api.updateTask = {
task: savedTask,
});
}
if (group) {
res.analytics.track('task edit', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
groupID: group._id,
});
}
},
};
@@ -1,4 +1,3 @@
import pick from 'lodash/pick';
import isUUID from 'validator/lib/isUUID';
import { authWithHeaders } from '../../../middlewares/auth';
import * as Tasks from '../../../models/task';
@@ -61,18 +60,6 @@ api.createGroupTasks = {
const tasks = await createTasks(req, res, { user, group });
res.respond(201, tasks.length === 1 ? tasks[0] : tasks);
tasks.forEach(task => {
res.analytics.track('team task created', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
groupID: group._id,
headers: req.headers,
});
});
},
};
@@ -251,16 +238,6 @@ api.assignTask = {
await Promise.all(promises);
res.respond(200, task);
res.analytics.track('task assign', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
taskType: task.type,
groupID: group._id,
headers: req.headers,
});
},
};
+17 -33
View File
@@ -1,7 +1,6 @@
import cloneDeep from 'lodash/cloneDeep';
import forEach from 'lodash/forEach';
import isFunction from 'lodash/isFunction';
import pick from 'lodash/pick';
import nconf from 'nconf';
import get from 'lodash/get';
import { authWithHeaders } from '../../middlewares/auth';
@@ -27,7 +26,6 @@ import * as inboxLib from '../../libs/inbox';
import * as userLib from '../../libs/user';
import { model as UserHistory } from '../../models/userHistory';
const OFFICIAL_PLATFORMS = ['habitica-web', 'habitica-ios', 'habitica-android'];
const TECH_ASSISTANCE_EMAIL = nconf.get('EMAILS_TECH_ASSISTANCE_EMAIL');
const DELETE_CONFIRMATION = 'DELETE';
@@ -325,13 +323,6 @@ api.deleteUser = {
]);
}
res.analytics.track('account delete', {
user: pick(user, ['preferences', 'registeredThrough']),
uuid: user._id,
hitType: 'event',
category: 'behavior',
});
res.respond(200, {});
},
};
@@ -441,7 +432,7 @@ api.sleep = {
url: '/user/sleep',
async handler (req, res) {
const { user } = res.locals;
const sleepRes = common.ops.sleep(user, req, res.analytics);
const sleepRes = common.ops.sleep(user, req);
await user.save();
res.respond(200, ...sleepRes);
},
@@ -500,10 +491,7 @@ api.buy = {
let quantity = 1;
if (req.body.quantity) quantity = req.body.quantity;
req.quantity = quantity;
if (OFFICIAL_PLATFORMS.indexOf(req.headers['x-client']) === -1) {
res.analytics = undefined;
}
const buyRes = await common.ops.buy(user, req, res.analytics);
const buyRes = await common.ops.buy(user, req);
await user.save();
@@ -558,7 +546,7 @@ api.buyGear = {
url: '/user/buy-gear/:key',
async handler (req, res) {
const { user } = res.locals;
const buyGearRes = await common.ops.buy(user, req, res.analytics);
const buyGearRes = await common.ops.buy(user, req);
await user.save();
res.respond(200, ...buyGearRes);
},
@@ -600,10 +588,7 @@ api.buyArmoire = {
const { user } = res.locals;
req.type = 'armoire';
req.params.key = 'armoire';
if (OFFICIAL_PLATFORMS.indexOf(req.headers['x-client']) === -1) {
res.analytics = undefined;
}
const buyArmoireResponse = await common.ops.buy(user, req, res.analytics);
const buyArmoireResponse = await common.ops.buy(user, req);
await user.save();
await UserHistory.beginUserHistoryUpdate(user._id, req.headers)
.withArmoire(buyArmoireResponse[0].armoire.dropKey || 'experience')
@@ -646,7 +631,7 @@ api.buyHealthPotion = {
const { user } = res.locals;
req.type = 'potion';
req.params.key = 'potion';
const buyHealthPotionResponse = await common.ops.buy(user, req, res.analytics);
const buyHealthPotionResponse = await common.ops.buy(user, req);
await user.save();
res.respond(200, ...buyHealthPotionResponse);
},
@@ -688,7 +673,7 @@ api.buyMysterySet = {
async handler (req, res) {
const { user } = res.locals;
req.type = 'mystery';
const buyMysterySetRes = await common.ops.buy(user, req, res.analytics);
const buyMysterySetRes = await common.ops.buy(user, req);
await user.save();
res.respond(200, ...buyMysterySetRes);
},
@@ -731,7 +716,7 @@ api.buyQuest = {
async handler (req, res) {
const { user } = res.locals;
req.type = 'quest';
const buyQuestRes = await common.ops.buy(user, req, res.analytics);
const buyQuestRes = await common.ops.buy(user, req);
await user.save();
res.respond(200, ...buyQuestRes);
},
@@ -818,7 +803,7 @@ api.hatch = {
url: '/user/hatch/:egg/:hatchingPotion',
async handler (req, res) {
const { user } = res.locals;
const hatchRes = common.ops.hatch(user, req, res.analytics);
const hatchRes = common.ops.hatch(user, req);
await user.save();
@@ -916,7 +901,7 @@ api.feed = {
url: '/user/feed/:pet/:food',
async handler (req, res) {
const { user } = res.locals;
const feedRes = common.ops.feed(user, req, res.analytics);
const feedRes = common.ops.feed(user, req);
await user.save();
@@ -964,7 +949,7 @@ api.changeClass = {
url: '/user/change-class',
async handler (req, res) {
const { user } = res.locals;
const changeClassRes = await common.ops.changeClass(user, req, res.analytics);
const changeClassRes = await common.ops.changeClass(user, req);
await user.save();
res.respond(200, ...changeClassRes);
},
@@ -1040,7 +1025,7 @@ api.purchase = {
if (req.body.quantity) quantity = req.body.quantity;
req.quantity = quantity;
const purchaseRes = await common.ops.buy(user, req, res.analytics);
const purchaseRes = await common.ops.buy(user, req);
await user.save();
res.respond(200, ...purchaseRes);
},
@@ -1083,7 +1068,6 @@ api.userPurchaseHourglass = {
const purchaseHourglassRes = await common.ops.buy(
user,
req,
res.analytics,
{ quantity, hourglass: true },
);
await user.save();
@@ -1180,7 +1164,7 @@ api.userOpenMysteryItem = {
url: '/user/open-mystery-item',
async handler (req, res) {
const { user } = res.locals;
const openMysteryItemRes = common.ops.openMysteryItem(user, req, res.analytics);
const openMysteryItemRes = common.ops.openMysteryItem(user, req);
await user.save();
res.respond(200, ...openMysteryItemRes);
},
@@ -1212,7 +1196,7 @@ api.userReleasePets = {
url: '/user/release-pets',
async handler (req, res) {
const { user } = res.locals;
const releasePetsRes = await common.ops.releasePets(user, req, res.analytics);
const releasePetsRes = await common.ops.releasePets(user, req);
await user.save();
res.respond(200, ...releasePetsRes);
},
@@ -1261,7 +1245,7 @@ api.userReleaseBoth = {
url: '/user/release-both',
async handler (req, res) {
const { user } = res.locals;
const releaseBothRes = common.ops.releaseBoth(user, req, res.analytics);
const releaseBothRes = common.ops.releaseBoth(user, req);
await user.save();
res.respond(200, ...releaseBothRes);
},
@@ -1297,7 +1281,7 @@ api.userReleaseMounts = {
url: '/user/release-mounts',
async handler (req, res) {
const { user } = res.locals;
const releaseMountsRes = await common.ops.releaseMounts(user, req, res.analytics);
const releaseMountsRes = await common.ops.releaseMounts(user, req);
await user.save();
res.respond(200, ...releaseMountsRes);
},
@@ -1373,7 +1357,7 @@ api.userUnlock = {
url: '/user/unlock',
async handler (req, res) {
const { user } = res.locals;
const unlockRes = await common.ops.unlock(user, req, res.analytics);
const unlockRes = await common.ops.unlock(user, req);
await user.save();
res.respond(200, ...unlockRes);
},
@@ -1399,7 +1383,7 @@ api.userRevive = {
url: '/user/revive',
async handler (req, res) {
const { user } = res.locals;
const reviveRes = common.ops.revive(user, req, res.analytics);
const reviveRes = common.ops.revive(user, req);
await user.save();
res.respond(200, ...reviveRes);
},