Skip to content

Commit 374f8d0

Browse files
vcarlclaude
andauthored
Replace Amplitude with PostHog for product metrics (#227) (#237)
Migrates backend analytics from Amplitude HTTP API to PostHog Node SDK. Adds new feature interaction events for tickets, honeypot, reactji, spam detection, and bot install/uninstall tracking. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c6236c3 commit 374f8d0

18 files changed

+327
-123
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,9 @@ DIGITALOCEAN_TOKEN=
2323
SENTRY_INGEST=
2424
SENTRY_RELEASES=
2525

26+
## PostHog analytics (frontend)
2627
VITE_PUBLIC_POSTHOG_KEY=phc_…
2728
VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com
29+
## PostHog analytics (backend - can use same key as frontend)
30+
POSTHOG_KEY=phc_…
31+
POSTHOG_HOST=https://us.i.posthog.com

.github/workflows/cd.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ jobs:
132132
STRIPE_WEBHOOK_SECRET: "${{ secrets.STRIPE_WEBHOOK_SECRET }}"
133133
VITE_PUBLIC_POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}"
134134
VITE_PUBLIC_POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}"
135+
POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}"
136+
POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}"
135137
DATABASE_URL: "${{ secrets.DATABASE_URL }}"
136138
EOF
137139
@@ -179,6 +181,8 @@ jobs:
179181
--from-literal=STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }} \
180182
--from-literal=VITE_PUBLIC_POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \
181183
--from-literal=VITE_PUBLIC_POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \
184+
--from-literal=POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \
185+
--from-literal=POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \
182186
--from-literal=DATABASE_URL=/data/mod-bot.sqlite3 \
183187
--dry-run=client -o yaml | kubectl apply -f -
184188

app/commands/setupHoneypot.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88

99
import db from "#~/db.server.js";
1010
import type { AnyCommand } from "#~/helpers/discord.js";
11+
import { featureStats } from "#~/helpers/metrics";
1112
import { log } from "#~/helpers/observability.js";
1213

1314
const DEFAULT_MESSAGE_TEXT =
@@ -65,6 +66,11 @@ export const Command = [
6566
.execute();
6667
if (result[0].numInsertedOrUpdatedRows ?? 0 > 0) {
6768
await castedChannel.send(messageText);
69+
featureStats.honeypotSetup(
70+
interaction.guildId,
71+
interaction.user.id,
72+
honeypotChannel.id,
73+
);
6874
}
6975

7076
await interaction.reply({

app/commands/setupReactjiChannel.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77

88
import db from "#~/db.server.js";
99
import { type SlashCommand } from "#~/helpers/discord";
10+
import { featureStats } from "#~/helpers/metrics";
1011

1112
export const Command = {
1213
command: new SlashCommandBuilder()
@@ -86,6 +87,13 @@ export const Command = {
8687
)
8788
.execute();
8889

90+
featureStats.reactjiChannelSetup(
91+
guildId,
92+
configuredById,
93+
emoji,
94+
threshold,
95+
);
96+
8997
const thresholdText =
9098
threshold === 1 ? "" : ` (after ${threshold} reactions)`;
9199
await interaction.reply({

app/commands/setupTickets.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type ModalCommand,
2525
type SlashCommand,
2626
} from "#~/helpers/discord";
27+
import { featureStats } from "#~/helpers/metrics";
2728
import { fetchSettings, SETTINGS } from "#~/models/guilds.server";
2829

2930
const DEFAULT_BUTTON_TEXT = "Open a private ticket with the moderators";
@@ -111,6 +112,12 @@ export const Command = [
111112
role_id: roleId,
112113
})
113114
.execute();
115+
116+
featureStats.ticketChannelSetup(
117+
interaction.guild.id,
118+
interaction.user.id,
119+
ticketChannel?.id ?? interaction.channelId,
120+
);
114121
} catch (e) {
115122
console.error(`error:`, e);
116123
}
@@ -204,7 +211,7 @@ export const Command = [
204211
await thread.send(`${user.displayName} said:
205212
${quoteMessageContent(concern)}`);
206213
await thread.send({
207-
content: "When youve finished, please close the ticket.",
214+
content: "When you've finished, please close the ticket.",
208215
components: [
209216
// @ts-expect-error Types for this are super busted
210217
new ActionRowBuilder().addComponents(
@@ -224,6 +231,8 @@ ${quoteMessageContent(concern)}`);
224231
],
225232
});
226233

234+
featureStats.ticketCreated(interaction.guild.id, user.id, thread.id);
235+
227236
void interaction.reply({
228237
content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`,
229238
ephemeral: true,
@@ -260,7 +269,7 @@ ${quoteMessageContent(concern)}`);
260269
rest.delete(Routes.threadMembers(threadId, ticketOpenerUserId)),
261270
rest.post(Routes.channelMessages(modLog), {
262271
body: {
263-
content: `<@${ticketOpenerUserId}>s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`,
272+
content: `<@${ticketOpenerUserId}>'s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`,
264273
allowedMentions: {},
265274
},
266275
}),
@@ -270,6 +279,13 @@ ${quoteMessageContent(concern)}`);
270279
}),
271280
]);
272281

282+
featureStats.ticketClosed(
283+
interaction.guild.id,
284+
interactionUserId,
285+
ticketOpenerUserId,
286+
!!feedback?.trim(),
287+
);
288+
273289
return;
274290
},
275291
} as MessageComponentCommand,

app/commands/track.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
import { Button } from "reacord";
88

99
import { reacord } from "#~/discord/client.server";
10+
import { featureStats } from "#~/helpers/metrics";
1011
import { reportUser } from "#~/helpers/modLog";
1112
import {
1213
markMessageAsDeleted,
@@ -27,6 +28,10 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => {
2728
staff: user,
2829
});
2930

31+
if (interaction.guildId) {
32+
featureStats.userTracked(interaction.guildId, user.id, message.author.id);
33+
}
34+
3035
const instance = reacord.ephemeralReply(
3136
interaction,
3237
<>

app/discord/automod.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { Events, type Client } from "discord.js";
22

33
import { isStaff } from "#~/helpers/discord";
44
import { isSpam } from "#~/helpers/isSpam";
5+
import { featureStats } from "#~/helpers/metrics";
56
import { reportUser } from "#~/helpers/modLog";
67
import {
78
markMessageAsDeleted,
@@ -34,6 +35,12 @@ export default async (bot: Client) => {
3435
.delete()
3536
.then(() => markMessageAsDeleted(message.id, message.guild!.id));
3637

38+
featureStats.spamDetected(
39+
message.guild.id,
40+
message.author.id,
41+
message.channelId,
42+
);
43+
3744
if (warnings >= AUTO_SPAM_THRESHOLD) {
3845
await Promise.all([
3946
member.kick("Autokicked for spamming"),
@@ -42,6 +49,7 @@ export default async (bot: Client) => {
4249
allowedMentions: {},
4350
}),
4451
]);
52+
featureStats.spamKicked(message.guild.id, message.author.id, warnings);
4553
}
4654
}
4755
});

app/discord/deployCommands.server.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ export const deployCommands = async (client: Client) => {
4242
: deployTestCommands(client, localCommands));
4343

4444
client.on(Events.InteractionCreate, (interaction) => {
45+
log("info", "deployCommands", "Handling interaction", {
46+
type: interaction.type,
47+
id: interaction.id,
48+
});
4549
switch (interaction.type) {
4650
case InteractionType.ApplicationCommand: {
4751
const config = matchCommand(interaction.commandName);

app/discord/gateway.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { deployCommands } from "#~/discord/deployCommands.server";
77
import { startEscalationResolver } from "#~/discord/escalationResolver";
88
import onboardGuild from "#~/discord/onboardGuild";
99
import { startReactjiChanneler } from "#~/discord/reactjiChanneler";
10-
import { botStats } from "#~/helpers/metrics";
10+
import { botStats, shutdownMetrics } from "#~/helpers/metrics";
1111
import { log, trackPerformance } from "#~/helpers/observability";
1212
import Sentry from "#~/helpers/sentry.server";
1313

@@ -134,4 +134,14 @@ export default function init() {
134134
// Track reconnections in business analytics
135135
botStats.reconnection(client.guilds.cache.size, client.users.cache.size);
136136
});
137+
138+
// Graceful shutdown handler to flush metrics
139+
const handleShutdown = async (signal: string) => {
140+
log("info", "Gateway", `Received ${signal}, shutting down gracefully`, {});
141+
await shutdownMetrics();
142+
process.exit(0);
143+
};
144+
145+
process.on("SIGTERM", () => void handleShutdown("SIGTERM"));
146+
process.on("SIGINT", () => void handleShutdown("SIGINT"));
137147
}

app/discord/honeypotTracker.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ChannelType, Events, type Client } from "discord.js";
22

33
import db from "#~/db.server.js";
4+
import { featureStats } from "#~/helpers/metrics";
45
import { reportUser } from "#~/helpers/modLog.js";
56
import { log } from "#~/helpers/observability";
67
import { fetchSettings, SETTINGS } from "#~/models/guilds.server.js";
@@ -103,6 +104,7 @@ export async function startHoneypotTracking(client: Client) {
103104
staff: client.user ?? false,
104105
}),
105106
]);
107+
featureStats.honeypotTriggered(msg.guildId, member.id, msg.channelId);
106108
} catch (e) {
107109
log(
108110
"error",

0 commit comments

Comments
 (0)