Skip to content

Commit ba9a3a7

Browse files
authored
Refactor UsageService to streamline site and event count retrieval (#706)
- Updated the getSiteIdsForOrganization method to get all sites with their organization IDs, filtering out those without an organization. - Introduced a new method, getAllSiteEventCounts, to retrieve monthly event counts for all sites in a single query, improving efficiency. - Adjusted organization processing logic to utilize the new methods, enhancing clarity and performance in event usage checks.
1 parent db3d6ef commit ba9a3a7

File tree

1 file changed

+77
-86
lines changed

1 file changed

+77
-86
lines changed

server/src/services/usageService.ts

Lines changed: 77 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { processResults } from "../api/analytics/utils.js";
55
import { clickhouse } from "../db/clickhouse/clickhouse.js";
66
import { db } from "../db/postgres/postgres.js";
77
import { member, organization, sites, user } from "../db/postgres/schema.js";
8-
import { IS_CLOUD } from "../lib/const.js";
8+
import { DEFAULT_EVENT_LIMIT, IS_CLOUD } from "../lib/const.js";
99
import { sendLimitExceededEmail } from "../lib/email/email.js";
1010
import { createServiceLogger } from "../lib/logger/logger.js";
1111
import { getBestSubscription } from "../lib/subscriptionUtils.js";
@@ -86,18 +86,21 @@ class UsageService {
8686
}
8787

8888
/**
89-
* Gets all site IDs for an organization
89+
* Gets all sites with their organization IDs (excludes sites without an organization)
9090
*/
91-
private async getSiteIdsForOrganization(organizationId: string): Promise<number[]> {
91+
private async getAllSites(): Promise<Array<{ siteId: number; organizationId: string }>> {
9292
try {
93-
const siteRecords = await db
94-
.select({ siteId: sites.siteId })
95-
.from(sites)
96-
.where(eq(sites.organizationId, organizationId));
93+
const allSites = await db
94+
.select({
95+
siteId: sites.siteId,
96+
organizationId: sites.organizationId,
97+
})
98+
.from(sites);
9799

98-
return siteRecords.map(record => record.siteId);
100+
// Filter out sites without an organization ID
101+
return allSites.filter(site => site.organizationId !== null) as Array<{ siteId: number; organizationId: string }>;
99102
} catch (error) {
100-
this.logger.error(error as Error, `Error getting sites for organization ${organizationId}`);
103+
this.logger.error(error as Error, `Error getting all sites`);
101104
return [];
102105
}
103106
}
@@ -114,7 +117,7 @@ class UsageService {
114117
name: string;
115118
}): Promise<[number, string | null]> {
116119
// Special case for specific organizations
117-
if (orgData.name === "tomato 2" || orgData.name === "Zam") {
120+
if (orgData.name === "rybbit" || orgData.name === "Zam") {
118121
return [Infinity, this.getStartOfMonth()];
119122
}
120123

@@ -138,68 +141,40 @@ class UsageService {
138141
}
139142

140143
/**
141-
* Gets monthly event count from ClickHouse for the given site IDs
142-
* Sites with ID < 2000 are grandfathered in and only count pageviews
143-
* Sites with ID >= 2000 count all event types (pageview, custom_event, performance)
144+
* Gets monthly event counts for all sites in a single query (for current month)
145+
* Returns a map of site_id -> event count
144146
*/
145-
private async getMonthlyEventCount(siteIds: number[], startDate: string | null): Promise<number> {
146-
if (!siteIds.length) {
147-
return 0;
148-
}
149-
150-
// If no startDate is provided (e.g., no subscription), default to start of month
151-
const periodStart = startDate || this.getStartOfMonth();
152-
153-
// Split sites into grandfathered (< 2000) and new (>= 2000)
154-
const grandfatheredSites = siteIds.filter(id => id < 2000);
155-
const newSites = siteIds.filter(id => id >= 2000);
156-
147+
private async getAllSiteEventCounts(): Promise<Map<number, number>> {
157148
try {
158-
let totalCount = 0;
159-
160-
// Count pageviews only for grandfathered sites
161-
if (grandfatheredSites.length > 0) {
162-
const grandfatheredResult = await clickhouse.query({
163-
query: `
164-
SELECT COUNT(*) as count
165-
FROM events
166-
WHERE site_id IN {siteIds:Array(Int32)} AND type = 'pageview'
149+
const periodStart = this.getStartOfMonth();
150+
151+
const result = await clickhouse.query({
152+
query: `
153+
SELECT
154+
site_id,
155+
COUNT(*) as count
156+
FROM events
157+
WHERE type IN ('pageview', 'custom_event', 'performance')
167158
AND timestamp >= toDate({periodStart:String})
168-
`,
169-
format: "JSONEachRow",
170-
query_params: {
171-
siteIds: grandfatheredSites,
172-
periodStart: periodStart,
173-
},
174-
});
175-
const grandfatheredRows = await processResults<{ count: string }>(grandfatheredResult);
176-
totalCount += parseInt(grandfatheredRows[0].count, 10);
177-
}
159+
GROUP BY site_id
160+
`,
161+
format: "JSONEachRow",
162+
query_params: {
163+
periodStart: periodStart,
164+
},
165+
});
178166

179-
// Count all events (pageview, custom_event, performance) for new sites
180-
if (newSites.length > 0) {
181-
const newSitesResult = await clickhouse.query({
182-
query: `
183-
SELECT COUNT(*) as count
184-
FROM events
185-
WHERE site_id IN {siteIds:Array(Int32)}
186-
AND type IN ('pageview', 'custom_event', 'performance')
187-
AND timestamp >= toDate({periodStart:String})
188-
`,
189-
format: "JSONEachRow",
190-
query_params: {
191-
siteIds: newSites,
192-
periodStart: periodStart,
193-
},
194-
});
195-
const newSitesRows = await processResults<{ count: string }>(newSitesResult);
196-
totalCount += parseInt(newSitesRows[0].count, 10);
167+
const rows = await processResults<{ site_id: number; count: string }>(result);
168+
169+
const eventCountMap = new Map<number, number>();
170+
for (const row of rows) {
171+
eventCountMap.set(row.site_id, parseInt(row.count, 10));
197172
}
198173

199-
return totalCount;
174+
return eventCountMap;
200175
} catch (error) {
201-
this.logger.error(error as Error, `Error querying ClickHouse for events for sites ${siteIds}`);
202-
return 0;
176+
this.logger.error(error as Error, "Error querying ClickHouse for event counts");
177+
return new Map();
203178
}
204179
}
205180

@@ -210,7 +185,22 @@ class UsageService {
210185
this.logger.info("Starting check of monthly event usage for organizations...");
211186

212187
try {
213-
// Get all organizations (both with and without Stripe customer IDs)
188+
// Step 1: Get all sites with their organization IDs
189+
const allSites = await this.getAllSites();
190+
191+
// Step 2: Get event counts for all sites in a single query (current month)
192+
const eventCountMap = await this.getAllSiteEventCounts();
193+
194+
// Step 3: Build a map of organizationId -> { siteIds, eventCount }
195+
const orgDataMap = new Map<string, { siteIds: number[]; eventCount: number }>();
196+
for (const site of allSites) {
197+
const orgData = orgDataMap.get(site.organizationId) || { siteIds: [], eventCount: 0 };
198+
orgData.siteIds.push(site.siteId);
199+
orgData.eventCount += eventCountMap.get(site.siteId) || 0;
200+
orgDataMap.set(site.organizationId, orgData);
201+
}
202+
203+
// Step 4: Get all organizations
214204
const organizations = await db
215205
.select({
216206
id: organization.id,
@@ -221,24 +211,30 @@ class UsageService {
221211
})
222212
.from(organization);
223213

214+
// Step 5: Process each organization
224215
for (const orgData of organizations) {
225216
try {
226-
// Get site IDs for this organization
227-
const siteIds = await this.getSiteIdsForOrganization(orgData.id);
228-
229-
// If organization has no sites, continue to next organization
230-
if (!siteIds.length) {
231-
continue;
217+
const orgStats = orgDataMap.get(orgData.id);
218+
const eventCount = orgStats?.eventCount || 0;
219+
const siteIds = orgStats?.siteIds || [];
220+
221+
// Only fetch subscription info for organizations with > 3000 events
222+
// This avoids slow Stripe API calls for low-usage orgs
223+
let eventLimit: number;
224+
let isOverLimit: boolean;
225+
226+
if (eventCount <= DEFAULT_EVENT_LIMIT) {
227+
// Free tier limit is 3000, so they're definitely not over limit
228+
eventLimit = DEFAULT_EVENT_LIMIT;
229+
isOverLimit = false;
230+
this.logger.debug(`Organization ${orgData.name} has ${eventCount} events, skipping subscription check`);
231+
} else {
232+
// High usage - need to check their actual subscription
233+
const [fetchedLimit, periodStart] = await this.getOrganizationSubscriptionInfo(orgData);
234+
eventLimit = fetchedLimit;
235+
isOverLimit = eventCount > eventLimit;
232236
}
233237

234-
// Get organization's subscription information (limit and period start)
235-
const [eventLimit, periodStart] = await this.getOrganizationSubscriptionInfo(orgData);
236-
237-
// Get monthly event count from ClickHouse using the billing period start date
238-
const eventCount = await this.getMonthlyEventCount(siteIds, periodStart);
239-
240-
// Check if over limit and update global set
241-
const isOverLimit = eventCount > eventLimit;
242238
const wasOverLimit = orgData.overMonthlyLimit ?? false;
243239

244240
// Update organization's monthlyEventCount and overMonthlyLimit fields
@@ -286,13 +282,8 @@ class UsageService {
286282
}
287283
}
288284

289-
// Format additional date info for logging if available
290-
const periodInfo = periodStart ? `period started ${periodStart}` : "this month";
291-
292285
this.logger.info(
293-
`Updated organization ${
294-
orgData.name
295-
}: ${eventCount.toLocaleString()} events, limit ${eventLimit.toLocaleString()}, ${periodInfo}`
286+
`Updated organization ${orgData.name}: ${eventCount.toLocaleString()} events, limit ${eventLimit.toLocaleString()}`
296287
);
297288
} catch (error) {
298289
this.logger.error(error as Error, `Error processing organization ${orgData.id}`);

0 commit comments

Comments
 (0)