Skip to content

Commit 956b81d

Browse files
feat(analytics): move slash-command analytics to dedicated table
1 parent 2c3c8b9 commit 956b81d

6 files changed

Lines changed: 164 additions & 40 deletions

File tree

migrations/006_command_usage.cjs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Migration 006: Command Usage Table
3+
*
4+
* Dedicated table for slash-command analytics. Replaces inline aggregation
5+
* from the logs table for faster analytics queries and historical trend analysis.
6+
*
7+
* @see https://github.com/VolvoxLLC/volvox-bot/issues/122
8+
*/
9+
10+
'use strict';
11+
12+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
13+
exports.up = (pgm) => {
14+
pgm.sql(`
15+
CREATE TABLE IF NOT EXISTS command_usage (
16+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
17+
guild_id TEXT NOT NULL,
18+
user_id TEXT NOT NULL,
19+
command_name TEXT NOT NULL,
20+
channel_id TEXT,
21+
used_at TIMESTAMPTZ DEFAULT NOW()
22+
)
23+
`);
24+
25+
pgm.sql(`
26+
CREATE INDEX IF NOT EXISTS idx_command_usage_guild_used_at
27+
ON command_usage(guild_id, used_at)
28+
`);
29+
pgm.sql(`
30+
CREATE INDEX IF NOT EXISTS idx_command_usage_user_id
31+
ON command_usage(user_id)
32+
`);
33+
pgm.sql(`
34+
CREATE INDEX IF NOT EXISTS idx_command_usage_command_name
35+
ON command_usage(command_name)
36+
`);
37+
pgm.sql(`
38+
CREATE INDEX IF NOT EXISTS idx_command_usage_guild_channel_used_at
39+
ON command_usage(guild_id, channel_id, used_at)
40+
`);
41+
};
42+
43+
/** @param {import('node-pg-migrate').MigrationBuilder} pgm */
44+
exports.down = (pgm) => {
45+
pgm.sql('DROP INDEX IF EXISTS idx_command_usage_guild_channel_used_at');
46+
pgm.sql('DROP INDEX IF EXISTS idx_command_usage_command_name');
47+
pgm.sql('DROP INDEX IF EXISTS idx_command_usage_user_id');
48+
pgm.sql('DROP INDEX IF EXISTS idx_command_usage_guild_used_at');
49+
pgm.sql('DROP TABLE IF EXISTS command_usage');
50+
};

src/api/routes/guilds.js

Lines changed: 12 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
import { fetchUserGuilds } from '../utils/discordApi.js';
1717
import { getSessionToken } from '../utils/sessionStore.js';
1818
import { validateConfigPatchBody } from '../utils/validateConfigPatch.js';
19+
import { getAggregatedCommandUsage } from '../../utils/commandUsage.js';
1920
import { fireAndForgetWebhook } from '../utils/webhook.js';
2021

2122
const router = Router();
@@ -1070,6 +1071,15 @@ router.get('/:id/analytics', requireRole('viewer'), validateGuild, async (req, r
10701071
const bucketExpr =
10711072
interval === 'hour' ? "date_trunc('hour', created_at)" : "date_trunc('day', created_at)";
10721073

1074+
const commandUsageQuery = getAggregatedCommandUsage({
1075+
pool: dbPool,
1076+
guildId: req.params.id,
1077+
from: from.toISOString(),
1078+
to: to.toISOString(),
1079+
channelId: activeChannelFilter ?? undefined,
1080+
limit: 15,
1081+
});
1082+
10731083
const logsWhereParts = [
10741084
"message = 'AI usage'",
10751085
"metadata->>'guildId' = $1",
@@ -1100,21 +1110,6 @@ router.get('/:id/analytics', requireRole('viewer'), validateGuild, async (req, r
11001110

11011111
const comparisonLogsWhere = comparisonLogsWhereParts.join(' AND ');
11021112

1103-
const commandUsageValues = [req.params.id, from.toISOString(), to.toISOString()];
1104-
const commandUsageWhereParts = [
1105-
"message = 'Command executed'",
1106-
"metadata->>'guildId' = $1",
1107-
'timestamp >= $2',
1108-
'timestamp <= $3',
1109-
];
1110-
1111-
if (activeChannelFilter) {
1112-
commandUsageValues.push(activeChannelFilter);
1113-
commandUsageWhereParts.push(`metadata->>'channelId' = $${commandUsageValues.length}`);
1114-
}
1115-
1116-
const commandUsageWhere = commandUsageWhereParts.join(' AND ');
1117-
11181113
try {
11191114
const [
11201115
kpiResult,
@@ -1261,28 +1256,7 @@ router.get('/:id/analytics', requireRole('viewer'), validateGuild, async (req, r
12611256
return { rows: [] };
12621257
})
12631258
: Promise.resolve({ rows: [] }),
1264-
dbPool
1265-
.query(
1266-
`SELECT
1267-
COALESCE(NULLIF(metadata->>'command', ''), 'unknown') AS command_name,
1268-
COUNT(*)::int AS uses
1269-
FROM logs
1270-
WHERE ${commandUsageWhere}
1271-
GROUP BY 1
1272-
ORDER BY uses DESC, command_name ASC
1273-
LIMIT 15`,
1274-
commandUsageValues,
1275-
)
1276-
.then((result) => ({ rows: result.rows, available: true }))
1277-
.catch((err) => {
1278-
// TODO(issue-122): move slash-command analytics to a dedicated usage table
1279-
// so dashboard metrics are not coupled to log transport availability.
1280-
warn('Command usage query failed; returning empty command usage dataset', {
1281-
guild: req.params.id,
1282-
error: err.message,
1283-
});
1284-
return { rows: [], available: false };
1285-
}),
1259+
commandUsageQuery,
12861260
dbPool
12871261
.query(
12881262
`SELECT
@@ -1450,7 +1424,7 @@ router.get('/:id/analytics', requireRole('viewer'), validateGuild, async (req, r
14501424
channelActivity,
14511425
topChannels: channelActivity,
14521426
commandUsage: {
1453-
source: commandUsageResult.available ? 'logs' : 'unavailable',
1427+
source: commandUsageResult.available ? 'command_usage' : 'unavailable',
14541428
items: commandUsage,
14551429
},
14561430
comparison: compareMode

src/index.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ import { getPermissionError, hasPermission } from './utils/permissions.js';
6262
import { registerCommands } from './utils/registerCommands.js';
6363
import { recordRestart, updateUptimeOnShutdown } from './utils/restartTracker.js';
6464

65+
import { trackCommandUsage } from './utils/commandUsage.js';
6566
import { safeFollowUp, safeReply } from './utils/safeSend.js';
6667

6768
// ES module dirname equivalent
@@ -242,6 +243,12 @@ client.on('interactionCreate', async (interaction) => {
242243
guildId: interaction.guildId,
243244
channelId: interaction.channelId,
244245
});
246+
void trackCommandUsage({
247+
guildId: interaction.guildId,
248+
userId: interaction.user.id,
249+
commandName,
250+
channelId: interaction.channelId ?? undefined,
251+
});
245252
} catch (err) {
246253
error('Command error', {
247254
command: commandName,

src/utils/commandUsage.js

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/**
2+
* Command Usage Tracking
3+
*
4+
* Records slash-command invocations in the command_usage table for analytics.
5+
* Queries are indexed for efficient aggregation by guild, user, and command.
6+
*
7+
* @see https://github.com/VolvoxLLC/volvox-bot/issues/122
8+
*/
9+
10+
import { getPool } from '../db.js';
11+
import { warn } from '../logger.js';
12+
13+
/**
14+
* Record a slash-command usage. Call asynchronously (fire-and-forget) so the
15+
* command response is not blocked. Failures are logged and not thrown.
16+
*
17+
* @param {Object} opts
18+
* @param {import('pg').Pool} [opts.pool] - Database pool (defaults to getPool())
19+
* @param {string} opts.guildId - Discord guild ID
20+
* @param {string} opts.userId - Discord user ID
21+
* @param {string} opts.commandName - Slash command name
22+
* @param {string} [opts.channelId] - Discord channel ID (optional)
23+
*/
24+
export async function trackCommandUsage(opts) {
25+
const pool = opts.pool ?? getPool();
26+
if (!pool) return;
27+
28+
const { guildId, userId, commandName, channelId } = opts;
29+
if (!guildId || !userId || !commandName) return;
30+
31+
try {
32+
await pool.query(
33+
`INSERT INTO command_usage (guild_id, user_id, command_name, channel_id)
34+
VALUES ($1, $2, $3, $4)`,
35+
[guildId, userId, commandName, channelId ?? null],
36+
);
37+
} catch (err) {
38+
warn('Failed to record command usage', {
39+
guildId,
40+
commandName,
41+
error: err.message,
42+
});
43+
}
44+
}
45+
46+
/**
47+
* Fetch aggregated command usage for a guild in a date range for analytics.
48+
* Returns rows with command_name and uses, ordered by uses DESC.
49+
*
50+
* @param {Object} opts
51+
* @param {import('pg').Pool} opts.pool - Database pool
52+
* @param {string} opts.guildId - Guild ID
53+
* @param {string} opts.from - ISO date string (inclusive)
54+
* @param {string} opts.to - ISO date string (inclusive)
55+
* @param {string} [opts.channelId] - Optional channel filter
56+
* @param {number} [opts.limit=15] - Max number of commands to return
57+
* @returns {Promise<{ rows: Array<{ command_name: string, uses: number }>, available: boolean }>}
58+
*/
59+
export async function getAggregatedCommandUsage(opts) {
60+
const { pool, guildId, from, to, channelId, limit = 15 } = opts;
61+
const values = [guildId, from, to];
62+
let whereClause = 'guild_id = $1 AND used_at >= $2 AND used_at <= $3';
63+
if (channelId) {
64+
values.push(channelId);
65+
whereClause += ` AND channel_id = $${values.length}`;
66+
}
67+
values.push(limit);
68+
69+
try {
70+
const result = await pool.query(
71+
`SELECT
72+
COALESCE(NULLIF(command_name, ''), 'unknown') AS command_name,
73+
COUNT(*)::int AS uses
74+
FROM command_usage
75+
WHERE ${whereClause}
76+
GROUP BY command_name
77+
ORDER BY uses DESC, command_name ASC
78+
LIMIT $${values.length}`,
79+
values,
80+
);
81+
return { rows: result.rows, available: true };
82+
} catch (err) {
83+
warn('Command usage analytics query failed', {
84+
guildId,
85+
error: err.message,
86+
});
87+
return { rows: [], available: false };
88+
}
89+
}

web/src/types/analytics-validators.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,11 @@ export function isDashboardAnalyticsPayload(value: unknown): value is DashboardA
119119

120120
if (value.commandUsage !== undefined) {
121121
if (!isRecord(value.commandUsage)) return false;
122-
if (value.commandUsage.source !== 'logs' && value.commandUsage.source !== 'unavailable') {
122+
if (
123+
value.commandUsage.source !== 'logs' &&
124+
value.commandUsage.source !== 'command_usage' &&
125+
value.commandUsage.source !== 'unavailable'
126+
) {
123127
return false;
124128
}
125129
if (

web/src/types/analytics.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,7 @@ export interface DashboardAnalytics {
8080
channelActivity: ChannelBreakdownEntry[];
8181
topChannels?: ChannelBreakdownEntry[];
8282
commandUsage?: {
83-
source: 'logs' | 'unavailable';
83+
source: 'logs' | 'command_usage' | 'unavailable';
8484
items: CommandUsageEntry[];
8585
};
8686
comparison?: {

0 commit comments

Comments
 (0)