From ca719ad48260e79f43bdf1ef1fe397863a320655 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Fri, 2 Jan 2026 17:19:46 -0500 Subject: [PATCH 1/5] Replace Amplitude with PostHog for product metrics (#227) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .env.example | 4 + .github/workflows/cd.yml | 4 + app/commands/setupHoneypot.ts | 6 + app/commands/setupReactjiChannel.ts | 8 ++ app/commands/setupTickets.ts | 20 ++- app/commands/track.tsx | 5 + app/discord/automod.ts | 8 ++ app/discord/gateway.ts | 12 +- app/discord/honeypotTracker.ts | 2 + app/discord/onboardGuild.ts | 9 ++ app/discord/reactjiChanneler.ts | 3 + app/helpers/env.server.ts | 3 +- app/helpers/metrics.ts | 207 +++++++++++++++++++++++----- package-lock.json | 74 +++------- package.json | 2 +- 15 files changed, 276 insertions(+), 91 deletions(-) diff --git a/.env.example b/.env.example index f9407083..361bc9a7 100644 --- a/.env.example +++ b/.env.example @@ -23,5 +23,9 @@ DIGITALOCEAN_TOKEN= SENTRY_INGEST= SENTRY_RELEASES= +## PostHog analytics (frontend) VITE_PUBLIC_POSTHOG_KEY=phc_… VITE_PUBLIC_POSTHOG_HOST=https://us.i.posthog.com +## PostHog analytics (backend - can use same key as frontend) +POSTHOG_KEY=phc_… +POSTHOG_HOST=https://us.i.posthog.com diff --git a/.github/workflows/cd.yml b/.github/workflows/cd.yml index 83f6f9cf..5e069e9c 100644 --- a/.github/workflows/cd.yml +++ b/.github/workflows/cd.yml @@ -132,6 +132,8 @@ jobs: STRIPE_WEBHOOK_SECRET: "${{ secrets.STRIPE_WEBHOOK_SECRET }}" VITE_PUBLIC_POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}" VITE_PUBLIC_POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}" + POSTHOG_KEY: "${{ secrets.VITE_PUBLIC_POSTHOG_KEY }}" + POSTHOG_HOST: "${{ secrets.VITE_PUBLIC_POSTHOG_HOST }}" DATABASE_URL: "${{ secrets.DATABASE_URL }}" EOF @@ -179,6 +181,8 @@ jobs: --from-literal=STRIPE_WEBHOOK_SECRET=${{ secrets.STRIPE_WEBHOOK_SECRET }} \ --from-literal=VITE_PUBLIC_POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \ --from-literal=VITE_PUBLIC_POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \ + --from-literal=POSTHOG_KEY=${{ secrets.VITE_PUBLIC_POSTHOG_KEY }} \ + --from-literal=POSTHOG_HOST=${{ secrets.VITE_PUBLIC_POSTHOG_HOST }} \ --from-literal=DATABASE_URL=/data/mod-bot.sqlite3 \ --dry-run=client -o yaml | kubectl apply -f - diff --git a/app/commands/setupHoneypot.ts b/app/commands/setupHoneypot.ts index e9d4c10c..c04734b9 100644 --- a/app/commands/setupHoneypot.ts +++ b/app/commands/setupHoneypot.ts @@ -8,6 +8,7 @@ import { import db from "#~/db.server.js"; import type { AnyCommand } from "#~/helpers/discord.js"; +import { featureStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability.js"; const DEFAULT_MESSAGE_TEXT = @@ -65,6 +66,11 @@ export const Command = [ .execute(); if (result[0].numInsertedOrUpdatedRows ?? 0 > 0) { await castedChannel.send(messageText); + featureStats.honeypotSetup( + interaction.guildId, + interaction.user.id, + honeypotChannel.id, + ); } await interaction.reply({ diff --git a/app/commands/setupReactjiChannel.ts b/app/commands/setupReactjiChannel.ts index 0fd69cd6..abcee233 100644 --- a/app/commands/setupReactjiChannel.ts +++ b/app/commands/setupReactjiChannel.ts @@ -7,6 +7,7 @@ import { import db from "#~/db.server.js"; import { type SlashCommand } from "#~/helpers/discord"; +import { featureStats } from "#~/helpers/metrics"; export const Command = { command: new SlashCommandBuilder() @@ -86,6 +87,13 @@ export const Command = { ) .execute(); + featureStats.reactjiChannelSetup( + guildId, + configuredById, + emoji, + threshold, + ); + const thresholdText = threshold === 1 ? "" : ` (after ${threshold} reactions)`; await interaction.reply({ diff --git a/app/commands/setupTickets.ts b/app/commands/setupTickets.ts index 5e1e99cc..cb7fc508 100644 --- a/app/commands/setupTickets.ts +++ b/app/commands/setupTickets.ts @@ -24,6 +24,7 @@ import { type ModalCommand, type SlashCommand, } from "#~/helpers/discord"; +import { featureStats } from "#~/helpers/metrics"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server"; const DEFAULT_BUTTON_TEXT = "Open a private ticket with the moderators"; @@ -111,6 +112,12 @@ export const Command = [ role_id: roleId, }) .execute(); + + featureStats.ticketChannelSetup( + interaction.guild.id, + interaction.user.id, + ticketChannel?.id ?? interaction.channelId, + ); } catch (e) { console.error(`error:`, e); } @@ -204,7 +211,7 @@ export const Command = [ await thread.send(`${user.displayName} said: ${quoteMessageContent(concern)}`); await thread.send({ - content: "When you’ve finished, please close the ticket.", + content: "When you've finished, please close the ticket.", components: [ // @ts-expect-error Types for this are super busted new ActionRowBuilder().addComponents( @@ -224,6 +231,8 @@ ${quoteMessageContent(concern)}`); ], }); + featureStats.ticketCreated(interaction.guild.id, user.id, thread.id); + void interaction.reply({ content: `A private thread with the moderation team has been opened for you: <#${thread.id}>`, ephemeral: true, @@ -260,7 +269,7 @@ ${quoteMessageContent(concern)}`); rest.delete(Routes.threadMembers(threadId, ticketOpenerUserId)), rest.post(Routes.channelMessages(modLog), { body: { - content: `<@${ticketOpenerUserId}>’s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`, + content: `<@${ticketOpenerUserId}>'s ticket <#${threadId}> closed by <@${interactionUserId}>${feedback ? `. feedback: ${feedback}` : ""}`, allowedMentions: {}, }, }), @@ -270,6 +279,13 @@ ${quoteMessageContent(concern)}`); }), ]); + featureStats.ticketClosed( + interaction.guild.id, + interactionUserId, + ticketOpenerUserId, + !!feedback?.trim(), + ); + return; }, } as MessageComponentCommand, diff --git a/app/commands/track.tsx b/app/commands/track.tsx index 09875a98..cbea85f4 100644 --- a/app/commands/track.tsx +++ b/app/commands/track.tsx @@ -7,6 +7,7 @@ import { import { Button } from "reacord"; import { reacord } from "#~/discord/client.server"; +import { featureStats } from "#~/helpers/metrics"; import { reportUser } from "#~/helpers/modLog"; import { markMessageAsDeleted, @@ -27,6 +28,10 @@ const handler = async (interaction: MessageContextMenuCommandInteraction) => { staff: user, }); + if (interaction.guildId) { + featureStats.userTracked(interaction.guildId, user.id, message.author.id); + } + const instance = reacord.ephemeralReply( interaction, <> diff --git a/app/discord/automod.ts b/app/discord/automod.ts index 0595d20f..211b854e 100644 --- a/app/discord/automod.ts +++ b/app/discord/automod.ts @@ -2,6 +2,7 @@ import { Events, type Client } from "discord.js"; import { isStaff } from "#~/helpers/discord"; import { isSpam } from "#~/helpers/isSpam"; +import { featureStats } from "#~/helpers/metrics"; import { reportUser } from "#~/helpers/modLog"; import { markMessageAsDeleted, @@ -34,6 +35,12 @@ export default async (bot: Client) => { .delete() .then(() => markMessageAsDeleted(message.id, message.guild!.id)); + featureStats.spamDetected( + message.guild.id, + message.author.id, + message.channelId, + ); + if (warnings >= AUTO_SPAM_THRESHOLD) { await Promise.all([ member.kick("Autokicked for spamming"), @@ -42,6 +49,7 @@ export default async (bot: Client) => { allowedMentions: {}, }), ]); + featureStats.spamKicked(message.guild.id, message.author.id, warnings); } } }); diff --git a/app/discord/gateway.ts b/app/discord/gateway.ts index 1cb1eb82..1e9aff98 100644 --- a/app/discord/gateway.ts +++ b/app/discord/gateway.ts @@ -7,7 +7,7 @@ import { deployCommands } from "#~/discord/deployCommands.server"; import { startEscalationResolver } from "#~/discord/escalationResolver"; import onboardGuild from "#~/discord/onboardGuild"; import { startReactjiChanneler } from "#~/discord/reactjiChanneler"; -import { botStats } from "#~/helpers/metrics"; +import { botStats, shutdownMetrics } from "#~/helpers/metrics"; import { log, trackPerformance } from "#~/helpers/observability"; import Sentry from "#~/helpers/sentry.server"; @@ -134,4 +134,14 @@ export default function init() { // Track reconnections in business analytics botStats.reconnection(client.guilds.cache.size, client.users.cache.size); }); + + // Graceful shutdown handler to flush metrics + const handleShutdown = async (signal: string) => { + log("info", "Gateway", `Received ${signal}, shutting down gracefully`, {}); + await shutdownMetrics(); + process.exit(0); + }; + + process.on("SIGTERM", () => void handleShutdown("SIGTERM")); + process.on("SIGINT", () => void handleShutdown("SIGINT")); } diff --git a/app/discord/honeypotTracker.ts b/app/discord/honeypotTracker.ts index 518f3ea3..fdc4765d 100644 --- a/app/discord/honeypotTracker.ts +++ b/app/discord/honeypotTracker.ts @@ -1,6 +1,7 @@ import { ChannelType, Events, type Client } from "discord.js"; import db from "#~/db.server.js"; +import { featureStats } from "#~/helpers/metrics"; import { reportUser } from "#~/helpers/modLog.js"; import { log } from "#~/helpers/observability"; import { fetchSettings, SETTINGS } from "#~/models/guilds.server.js"; @@ -103,6 +104,7 @@ export async function startHoneypotTracking(client: Client) { staff: client.user ?? false, }), ]); + featureStats.honeypotTriggered(msg.guildId, member.id, msg.channelId); } catch (e) { log( "error", diff --git a/app/discord/onboardGuild.ts b/app/discord/onboardGuild.ts index a30df4c8..52dbce28 100644 --- a/app/discord/onboardGuild.ts +++ b/app/discord/onboardGuild.ts @@ -1,5 +1,6 @@ import { ChannelType, Events, type Client, type TextChannel } from "discord.js"; +import { botStats } from "#~/helpers/metrics"; import { retry } from "#~/helpers/misc"; import { fetchGuild } from "#~/models/guilds.server"; @@ -12,6 +13,9 @@ export default async (bot: Client) => { bot.on(Events.GuildCreate, async (guild) => { const appGuild = await fetchGuild(guild.id); if (!appGuild) { + // This is a new installation, not a reconnection + botStats.guildJoined(guild); + await deployCommands(client); const welcomeMessage = `Euno is here! Set it up with \`/setup\``; @@ -42,4 +46,9 @@ export default async (bot: Client) => { }); } }); + + // Track when the bot is removed from a guild + bot.on(Events.GuildDelete, (guild) => { + botStats.guildRemoved(guild); + }); }; diff --git a/app/discord/reactjiChanneler.ts b/app/discord/reactjiChanneler.ts index 08b5af1e..83acdd1c 100644 --- a/app/discord/reactjiChanneler.ts +++ b/app/discord/reactjiChanneler.ts @@ -1,6 +1,7 @@ import { Events, type Client } from "discord.js"; import db from "#~/db.server"; +import { featureStats } from "#~/helpers/metrics"; import { log } from "#~/helpers/observability"; export async function startReactjiChanneler(client: Client) { @@ -100,6 +101,8 @@ export async function startReactjiChanneler(client: Client) { allowedMentions: { users: [] }, }); + featureStats.reactjiTriggered(guildId, user.id, emoji, message.id); + log("info", "ReactjiChanneler", "Message forwarded successfully", { messageId: message.id, targetChannelId: config.channel_id, diff --git a/app/helpers/env.server.ts b/app/helpers/env.server.ts index 4aa3f4c1..5752c91d 100644 --- a/app/helpers/env.server.ts +++ b/app/helpers/env.server.ts @@ -39,7 +39,8 @@ export const sentryReleases = getEnv("SENTRY_RELEASES", true); export const stripeSecretKey = getEnv("STRIPE_SECRET_KEY"); export const stripeWebhookSecret = getEnv("STRIPE_WEBHOOK_SECRET"); -export const amplitudeKey = getEnv("AMPLITUDE_API_KEY", true); +export const posthogApiKey = getEnv("POSTHOG_KEY", true); +export const posthogHost = getEnv("POSTHOG_HOST", true); if (!ok) throw new Error("Environment misconfigured"); console.log(""); diff --git a/app/helpers/metrics.ts b/app/helpers/metrics.ts index 33090fc2..9638dd2a 100644 --- a/app/helpers/metrics.ts +++ b/app/helpers/metrics.ts @@ -6,19 +6,21 @@ import type { ThreadChannel, UserContextMenuCommandInteraction, } from "discord.js"; -import fetch from "node-fetch"; -import queryString from "query-string"; +import { PostHog } from "posthog-node"; -import { amplitudeKey } from "#~/helpers/env.server"; +import { posthogApiKey, posthogHost } from "#~/helpers/env.server"; import { log } from "#~/helpers/observability"; -type AmplitudeValue = string | number | boolean; -type EmitEventData = Record; +type EventValue = string | number | boolean; +type EmitEventData = Record; const events = { + // Existing events messageTracked: "message sent", botStarted: "bot started", - guildJoined: "guild joined", + guildJoined: "bot installed", + guildRemoved: "bot uninstalled", + guildResurrected: "bot reinstalled", threadCreated: "thread created", gatewayError: "gateway error", reconnection: "bot reconnected", @@ -26,12 +28,41 @@ const events = { commandFailed: "command failed", setupCompleted: "setup completed", reportSubmitted: "report submitted", + // New events from Issue #227 + userTracked: "user tracked", + ticketChannelSetup: "ticket channel setup", + ticketCreated: "ticket created", + ticketClosed: "ticket closed", + honeypotSetup: "honeypot setup", + honeypotTriggered: "honeypot triggered", + reactjiChannelSetup: "reactji channel setup", + reactjiTriggered: "reactji triggered", + spamDetected: "spam detected", + spamKicked: "spam kicked", }; +// PostHog client singleton +let posthogClient: PostHog | null = null; + +function getPostHog(): PostHog | null { + if (!posthogApiKey) return null; + posthogClient ??= new PostHog(posthogApiKey, { + host: posthogHost ?? "https://us.i.posthog.com", + flushAt: 20, + flushInterval: 10000, + }); + return posthogClient; +} + +export async function shutdownMetrics() { + await posthogClient?.shutdown(); +} + export const threadStats = { messageTracked: (message: Message) => emitEvent(events.messageTracked, { data: { guildId: message.guild?.id ?? "none" }, + guildId: message.guild?.id, }), }; @@ -48,6 +79,27 @@ export const botStats = { guildName: guild.name, memberCount: guild.memberCount, }, + guildId: guild.id, + }), + + guildRemoved: (guild: Guild) => + emitEvent(events.guildRemoved, { + data: { + guildId: guild.id, + guildName: guild.name, + memberCount: guild.memberCount, + }, + guildId: guild.id, + }), + + guildResurrected: (guild: Guild) => + emitEvent(events.guildResurrected, { + data: { + guildId: guild.id, + guildName: guild.name, + memberCount: guild.memberCount, + }, + guildId: guild.id, }), threadCreated: (thread: ThreadChannel) => @@ -58,6 +110,7 @@ export const botStats = { channelId: thread.parentId ?? "none", threadName: thread.name, }, + guildId: thread.guild.id, }), gatewayError: (error: string, guildCount: number) => @@ -91,6 +144,7 @@ export const commandStats = { duration: duration ?? 0, }, userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, }), commandFailed: ( @@ -112,6 +166,7 @@ export const commandStats = { duration: duration ?? 0, }, userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, }), setupCompleted: ( @@ -128,6 +183,7 @@ export const commandStats = { hasRestrictedRole: !!settings.restricted, }, userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, }), reportSubmitted: ( @@ -142,38 +198,121 @@ export const commandStats = { channelId: interaction.channelId, }, userId: interaction.user.id, + guildId: interaction.guildId ?? undefined, + }), +}; + +export const featureStats = { + userTracked: (guildId: string, userId: string, targetUserId: string) => + emitEvent(events.userTracked, { + data: { guildId, targetUserId }, + userId, + guildId, + }), + + ticketChannelSetup: (guildId: string, userId: string, channelId: string) => + emitEvent(events.ticketChannelSetup, { + data: { guildId, channelId }, + userId, + guildId, + }), + + ticketCreated: (guildId: string, userId: string, threadId: string) => + emitEvent(events.ticketCreated, { + data: { guildId, threadId }, + userId, + guildId, + }), + + ticketClosed: ( + guildId: string, + closedByUserId: string, + ticketOpenerId: string, + hasFeedback: boolean, + ) => + emitEvent(events.ticketClosed, { + data: { guildId, ticketOpenerId, hasFeedback }, + userId: closedByUserId, + guildId, + }), + + honeypotSetup: (guildId: string, userId: string, channelId: string) => + emitEvent(events.honeypotSetup, { + data: { guildId, channelId }, + userId, + guildId, + }), + + honeypotTriggered: ( + guildId: string, + spammerUserId: string, + channelId: string, + ) => + emitEvent(events.honeypotTriggered, { + data: { guildId, channelId, spammerUserId }, + guildId, + }), + + reactjiChannelSetup: ( + guildId: string, + userId: string, + emoji: string, + threshold: number, + ) => + emitEvent(events.reactjiChannelSetup, { + data: { guildId, emoji, threshold }, + userId, + guildId, + }), + + reactjiTriggered: ( + guildId: string, + triggeredByUserId: string, + emoji: string, + messageId: string, + ) => + emitEvent(events.reactjiTriggered, { + data: { guildId, emoji, messageId }, + userId: triggeredByUserId, + guildId, + }), + + spamDetected: (guildId: string, spammerUserId: string, channelId: string) => + emitEvent(events.spamDetected, { + data: { guildId, channelId, spammerUserId }, + guildId, + }), + + spamKicked: (guildId: string, kickedUserId: string, warningCount: number) => + emitEvent(events.spamKicked, { + data: { guildId, kickedUserId, warningCount }, + guildId, }), }; const emitEvent = ( eventName: string, - { data, userId }: { data?: EmitEventData; userId?: string } = {}, + { + data, + userId, + guildId, + }: { data?: EmitEventData; userId?: string; guildId?: string } = {}, ) => { - if (!amplitudeKey) { - log("info", "Metrics", "event emitted", { - user_id: userId, - event_type: eventName, - event_properties: data, - }); - return; - } - - const fields = { - api_key: amplitudeKey, - event: JSON.stringify({ - user_id: userId ?? "0", - event_type: eventName, - event_properties: data, - }), - }; - - void (async () => { - try { - await fetch( - `https://api.amplitude.com/httpapi?${queryString.stringify(fields)}`, - ); - } catch (error) { - log("error", "Metrics", "Failed to emit event", { error }); - } - })(); + const client = getPostHog(); + + log("info", "Metrics", "event emitted", { + user_id: userId, + event_type: eventName, + event_properties: data, + client: Boolean(client), + }); + + client?.capture({ + distinctId: userId ?? "system", + event: eventName, + properties: { + ...data, + $groups: guildId ? { guild: guildId } : undefined, + }, + }); }; diff --git a/package-lock.json b/package-lock.json index 36118719..41528f89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,8 +32,8 @@ "node-fetch": "^3.3.2", "pino-http": "^10.4.0", "posthog-js": "^1.280.1", + "posthog-node": "^5.18.1", "pretty-bytes": "^5.6.0", - "query-string": "^9.2.2", "reacord": "^0.6.0", "react": "^18.2.0", "react-dom": "^18.0.0", @@ -72,7 +72,6 @@ "prettier-plugin-tailwindcss": "^0.6.9", "tailwindcss": "^3.0.23", "tsconfig-paths": "^3.14.1", - "tsx": "^4.19.2", "typescript": "5.6.3", "typescript-eslint": "^8.18.2", "vite": "^5.4.11", @@ -4290,15 +4289,6 @@ "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", "license": "MIT" }, - "node_modules/decode-uri-component": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.4.1.tgz", - "integrity": "sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==", - "license": "MIT", - "engines": { - "node": ">=14.16" - } - }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5372,18 +5362,6 @@ "node": ">=8" } }, - "node_modules/filter-obj": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-5.1.0.tgz", - "integrity": "sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==", - "license": "MIT", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/finalhandler": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", @@ -8856,6 +8834,27 @@ "web-vitals": "^4.2.4" } }, + "node_modules/posthog-node": { + "version": "5.18.1", + "resolved": "https://registry.npmjs.org/posthog-node/-/posthog-node-5.18.1.tgz", + "integrity": "sha512-Hi7cRqAlvuEitdiurXJFdMip+BxcwYoX66at5RErMVP91V+Ph9BspGiawC3mJx/4znjwUjF29kAhf8oZQ2uJ5Q==", + "license": "MIT", + "dependencies": { + "@posthog/core": "1.9.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/posthog-node/node_modules/@posthog/core": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.9.0.tgz", + "integrity": "sha512-j7KSWxJTUtNyKynLt/p0hfip/3I46dWU2dk+pt7dKRoz2l5CYueHuHK4EO7Wlgno5yo1HO4sc4s30MXMTICHJw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6" + } + }, "node_modules/preact": { "version": "10.27.2", "resolved": "https://registry.npmjs.org/preact/-/preact-10.27.2.tgz", @@ -9129,23 +9128,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/query-string": { - "version": "9.3.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-9.3.1.tgz", - "integrity": "sha512-5fBfMOcDi5SA9qj5jZhWAcTtDfKF5WFdd2uD9nVNlbxVv1baq65aALy6qofpNEGELHvisjjasxQp7BlM9gvMzw==", - "license": "MIT", - "dependencies": { - "decode-uri-component": "^0.4.1", - "filter-obj": "^5.1.0", - "split-on-first": "^3.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -10363,18 +10345,6 @@ "dev": true, "license": "CC0-1.0" }, - "node_modules/split-on-first": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-3.0.0.tgz", - "integrity": "sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/split2": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", diff --git a/package.json b/package.json index f4adefee..a76178de 100644 --- a/package.json +++ b/package.json @@ -65,8 +65,8 @@ "node-fetch": "^3.3.2", "pino-http": "^10.4.0", "posthog-js": "^1.280.1", + "posthog-node": "^5.18.1", "pretty-bytes": "^5.6.0", - "query-string": "^9.2.2", "reacord": "^0.6.0", "react": "^18.2.0", "react-dom": "^18.0.0", From 43f9d072967ab2f3c7bacdf62b3023e50b9ae580 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Sun, 4 Jan 2026 00:07:33 -0500 Subject: [PATCH 2/5] Address PR review feedback for PostHog metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix GuildDelete to skip unavailable guilds and verify DB record - Add userId to honeypotTriggered and spamDetected for proper attribution - Remove unused guildResurrected event 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- app/discord/onboardGuild.ts | 9 ++++++++- app/helpers/metrics.ts | 13 ++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/app/discord/onboardGuild.ts b/app/discord/onboardGuild.ts index 52dbce28..18b2c928 100644 --- a/app/discord/onboardGuild.ts +++ b/app/discord/onboardGuild.ts @@ -48,7 +48,14 @@ export default async (bot: Client) => { }); // Track when the bot is removed from a guild - bot.on(Events.GuildDelete, (guild) => { + // GuildDelete also fires when a guild becomes temporarily unavailable, + // so we check the unavailable flag and verify the guild exists in our DB + bot.on(Events.GuildDelete, async (guild) => { + if (guild.available === false) return; + + const appGuild = await fetchGuild(guild.id); + if (!appGuild) return; + botStats.guildRemoved(guild); }); }; diff --git a/app/helpers/metrics.ts b/app/helpers/metrics.ts index 9638dd2a..c26a30d6 100644 --- a/app/helpers/metrics.ts +++ b/app/helpers/metrics.ts @@ -20,7 +20,6 @@ const events = { botStarted: "bot started", guildJoined: "bot installed", guildRemoved: "bot uninstalled", - guildResurrected: "bot reinstalled", threadCreated: "thread created", gatewayError: "gateway error", reconnection: "bot reconnected", @@ -92,16 +91,6 @@ export const botStats = { guildId: guild.id, }), - guildResurrected: (guild: Guild) => - emitEvent(events.guildResurrected, { - data: { - guildId: guild.id, - guildName: guild.name, - memberCount: guild.memberCount, - }, - guildId: guild.id, - }), - threadCreated: (thread: ThreadChannel) => emitEvent(events.threadCreated, { data: { @@ -250,6 +239,7 @@ export const featureStats = { ) => emitEvent(events.honeypotTriggered, { data: { guildId, channelId, spammerUserId }, + userId: spammerUserId, guildId, }), @@ -280,6 +270,7 @@ export const featureStats = { spamDetected: (guildId: string, spammerUserId: string, channelId: string) => emitEvent(events.spamDetected, { data: { guildId, channelId, spammerUserId }, + userId: spammerUserId, guildId, }), From f71fc3ca4fc31a1b850e063ef3a1ef26d7ede611 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Mon, 5 Jan 2026 17:53:57 -0500 Subject: [PATCH 3/5] Fix sourcemaps --- index.dev.js | 19 ++++++++++++++++++- package.json | 4 ++-- vite.config.ts | 1 + 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/index.dev.js b/index.dev.js index d1db686c..c89e8a2f 100644 --- a/index.dev.js +++ b/index.dev.js @@ -24,7 +24,7 @@ async function loadServerModule() { currentServerApp = source.app; console.log("Server module (re)loaded"); } catch (error) { - if (typeof error === "object" && error instanceof Error) { + if (error instanceof Error) { viteDevServer.ssrFixStacktrace(error); } console.error("Error loading server module:", error); @@ -70,6 +70,23 @@ viteDevServer.watcher.on("change", async (file) => { } }); +// Fix stack traces for unhandled errors using Vite's source map support +process.on("uncaughtException", (error) => { + if (error instanceof Error) { + viteDevServer.ssrFixStacktrace(error); + } + console.error("Uncaught Exception:", error); + process.exit(1); +}); + +process.on("unhandledRejection", (reason) => { + if (reason instanceof Error) { + viteDevServer.ssrFixStacktrace(reason); + } + console.error("Unhandled Rejection:", reason); + process.exit(1); +}); + const PORT = process.env.PORT ?? "3000"; app.listen(PORT, async () => { console.log("INI", "Now listening on port", PORT); diff --git a/package.json b/package.json index a76178de..b7c75c65 100644 --- a/package.json +++ b/package.json @@ -32,8 +32,8 @@ "build:app": "react-router build", "dev:init": "run-s start:migrate kysely:seed generate:db-types", "dev:css": "npm run generate:css -- --watch", - "dev:bot": "node --trace-warnings index.dev.js", - "dev:web": "node --trace-warnings index.dev.js", + "dev:bot": "node --enable-source-maps --trace-warnings index.dev.js", + "dev:web": "node --enable-source-maps --trace-warnings index.dev.js", "kysely:seed": "kysely --no-outdated-check seed:run", "generate:css": "tailwindcss -o ./app/styles/tailwind.css", "generate:db-types": "kysely-codegen --log-level debug --dialect sqlite --out-file ./app/db.d.ts; prettier --write ./app/db.d.ts" diff --git a/vite.config.ts b/vite.config.ts index d5dc5a6d..9db41c52 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -4,6 +4,7 @@ import { reactRouter } from "@react-router/dev/vite"; export default defineConfig(({ isSsrBuild }) => ({ build: { + sourcemap: true, rollupOptions: isSsrBuild ? { input: "./app/server.ts" } : undefined, }, server: { port: 3000, origin: "localhost:3000" }, From 163cc8b1b2b4da7295094b12703a7c83b97e8aa9 Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 6 Jan 2026 15:16:30 -0500 Subject: [PATCH 4/5] Add better logging when handling interactions --- app/discord/deployCommands.server.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/discord/deployCommands.server.ts b/app/discord/deployCommands.server.ts index 16b95700..4f32c364 100644 --- a/app/discord/deployCommands.server.ts +++ b/app/discord/deployCommands.server.ts @@ -42,6 +42,10 @@ export const deployCommands = async (client: Client) => { : deployTestCommands(client, localCommands)); client.on(Events.InteractionCreate, (interaction) => { + log("info", "deployCommands", "Handling interaction", { + type: interaction.type, + id: interaction.id, + }); switch (interaction.type) { case InteractionType.ApplicationCommand: { const config = matchCommand(interaction.commandName); From 4377144665e121e0ff544ec1416dad90e23770fb Mon Sep 17 00:00:00 2001 From: Carl Vitullo Date: Tue, 6 Jan 2026 15:27:11 -0500 Subject: [PATCH 5/5] Track guildJoined events regardless of if they've been set up before --- app/discord/onboardGuild.ts | 63 ++++++++++++++++++------------------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/app/discord/onboardGuild.ts b/app/discord/onboardGuild.ts index 18b2c928..adf57058 100644 --- a/app/discord/onboardGuild.ts +++ b/app/discord/onboardGuild.ts @@ -12,39 +12,38 @@ export default async (bot: Client) => { // available after downtime, or when actually added to a new guild bot.on(Events.GuildCreate, async (guild) => { const appGuild = await fetchGuild(guild.id); - if (!appGuild) { - // This is a new installation, not a reconnection - botStats.guildJoined(guild); - - await deployCommands(client); - - const welcomeMessage = `Euno is here! Set it up with \`/setup\``; - - const channels = await guild.channels.fetch(); - const likelyChannels = channels.filter((c): c is TextChannel => - Boolean( - c && - c.type === ChannelType.GuildText && - (c.name.includes("mod") || c.name.includes("intro")), - ), - ); - - await retry(5, async (n) => { - switch (n) { - case 0: - void guild.systemChannel!.send(welcomeMessage); - return; - case 1: - void guild.publicUpdatesChannel!.send(welcomeMessage); - return; - default: { - if (likelyChannels.size < n - 2) return; - void likelyChannels.at(n - 2)!.send(welcomeMessage); - return; - } + botStats.guildJoined(guild); + if (appGuild) return; + + // New installation - deploy commands and send welcome message + await deployCommands(client); + + const welcomeMessage = `Euno is here! Set it up with \`/setup\``; + + const channels = await guild.channels.fetch(); + const likelyChannels = channels.filter((c): c is TextChannel => + Boolean( + c && + c.type === ChannelType.GuildText && + (c.name.includes("mod") || c.name.includes("intro")), + ), + ); + + await retry(5, async (n) => { + switch (n) { + case 0: + void guild.systemChannel!.send(welcomeMessage); + return; + case 1: + void guild.publicUpdatesChannel!.send(welcomeMessage); + return; + default: { + if (likelyChannels.size < n - 2) return; + void likelyChannels.at(n - 2)!.send(welcomeMessage); + return; } - }); - } + } + }); }); // Track when the bot is removed from a guild