Skip to content

Commit 088ea18

Browse files
authored
refactor: update token query and record (#8031)
1 parent 0a82e4d commit 088ea18

File tree

5 files changed

+124
-48
lines changed

5 files changed

+124
-48
lines changed

packages/core/src/event-listeners/index.ts

Lines changed: 26 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { ProductEvent } from '@logto/schemas';
22
import { type Provider } from 'oidc-provider';
33

4+
import { TokenUsageType } from '#src/queries/daily-token-usage.js';
45
import type Queries from '#src/tenants/Queries.js';
56
import { getConsoleLogFromContext } from '#src/utils/console.js';
67
import { captureEvent } from '#src/utils/posthog.js';
@@ -17,7 +18,22 @@ import { getAccessTokenEventPayload } from './utils.js';
1718
*/
1819
export const addOidcEventListeners = (tenantId: string, provider: Provider, queries: Queries) => {
1920
const { recordTokenUsage } = queries.dailyTokenUsage;
20-
const tokenUsageListener = async (payload: unknown) => {
21+
22+
// Listener for user access tokens (increment user_token_usage)
23+
const userTokenUsageListener = async (payload: unknown) => {
24+
if (payload instanceof provider.BaseToken) {
25+
captureEvent(
26+
{ tenantId, request: undefined },
27+
ProductEvent.AccessTokenIssued,
28+
getAccessTokenEventPayload(payload, provider)
29+
);
30+
}
31+
32+
await recordTokenUsage(new Date(), { type: TokenUsageType.User });
33+
};
34+
35+
// Listener for client credentials/M2M tokens (increment m2m_token_usage)
36+
const m2mTokenUsageListener = async (payload: unknown) => {
2137
if (payload instanceof provider.BaseToken) {
2238
captureEvent(
2339
{ tenantId, request: undefined },
@@ -26,7 +42,7 @@ export const addOidcEventListeners = (tenantId: string, provider: Provider, quer
2642
);
2743
}
2844

29-
await recordTokenUsage(new Date());
45+
await recordTokenUsage(new Date(), { type: TokenUsageType.M2m });
3046
};
3147

3248
provider.addListener('grant.success', grantListener);
@@ -58,17 +74,15 @@ export const addOidcEventListeners = (tenantId: string, provider: Provider, quer
5874
return deleteSessionExtensions(queries, session);
5975
});
6076

61-
// Record token usage on token issue and save events. Note that some events are omitted:
77+
// Record token usage on token issue and save events, with proper type distinction
6278
// - `initial_access_token.saved`: client registration related, DCR not enabled in our setup
6379
// - `registration_access_token.saved`: client registration related, DCR not enabled in our setup
64-
const events = Object.freeze([
65-
'access_token.saved',
66-
'access_token.issued',
67-
'client_credentials.saved',
68-
'client_credentials.issued',
69-
] as const);
7080

71-
for (const event of events) {
72-
provider.addListener(event, tokenUsageListener);
73-
}
81+
// User access tokens - increment user_token_usage
82+
provider.addListener('access_token.saved', userTokenUsageListener);
83+
provider.addListener('access_token.issued', userTokenUsageListener);
84+
85+
// Client credentials/M2M tokens - increment m2m_token_usage
86+
provider.addListener('client_credentials.saved', m2mTokenUsageListener);
87+
provider.addListener('client_credentials.issued', m2mTokenUsageListener);
7488
};

packages/core/src/libraries/subscription.test.ts

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,12 +173,16 @@ describe('get tenant token usage', () => {
173173
const to = new Date(from.valueOf() + 1000 * 60 * 60 * 24);
174174

175175
it('should get tenant token usage without cache', async () => {
176-
mockCountTokenUsage.mockResolvedValueOnce({ tokenUsage: 100 });
176+
mockCountTokenUsage.mockResolvedValueOnce({
177+
totalUsage: 100,
178+
userTokenUsage: 60,
179+
m2mTokenUsage: 40,
180+
});
177181
const tokenUsage = await subscription.getTenantTokenUsage({
178182
from,
179183
to,
180184
});
181-
expect(tokenUsage).toBe(100);
185+
expect(tokenUsage.totalUsage).toBe(100);
182186
});
183187

184188
it('should get tenant token usage from cache', async () => {
@@ -187,18 +191,22 @@ describe('get tenant token usage', () => {
187191
from,
188192
to,
189193
});
190-
expect(tokenUsageFromCache).toBe(100);
194+
expect(tokenUsageFromCache.totalUsage).toBe(100);
191195
expect(mockCountTokenUsage).not.toHaveBeenCalled();
192196
});
193197

194198
it('should get new tenant token usage if the period is different', async () => {
195-
mockCountTokenUsage.mockResolvedValueOnce({ tokenUsage: 200 });
199+
mockCountTokenUsage.mockResolvedValueOnce({
200+
totalUsage: 200,
201+
userTokenUsage: 120,
202+
m2mTokenUsage: 80,
203+
});
196204
const tokenUsage = await subscription.getTenantTokenUsage({
197205
from,
198206
to: new Date(to.valueOf() + 1000 * 60 * 60 * 24),
199207
});
200208

201-
expect(tokenUsage).toBe(200);
209+
expect(tokenUsage.totalUsage).toBe(200);
202210
expect(mockCountTokenUsage).toHaveBeenCalled();
203211
});
204212
});
@@ -222,12 +230,16 @@ describe('get tenant token usage with cache expiration', () => {
222230
},
223231
});
224232

225-
mockCountTokenUsage.mockResolvedValueOnce({ tokenUsage: 100 });
233+
mockCountTokenUsage.mockResolvedValueOnce({
234+
totalUsage: 100,
235+
userTokenUsage: 60,
236+
m2mTokenUsage: 40,
237+
});
226238
const tokenUsage = await subscription.getTenantTokenUsage({
227239
from,
228240
to,
229241
});
230-
expect(tokenUsage).toBe(100);
242+
expect(tokenUsage.totalUsage).toBe(100);
231243

232244
// Move the time to 30 minutes later
233245
mockCountTokenUsage.mockClear();
@@ -236,17 +248,21 @@ describe('get tenant token usage with cache expiration', () => {
236248
from,
237249
to,
238250
});
239-
expect(tokenUsageFromCache).toBe(100);
251+
expect(tokenUsageFromCache.totalUsage).toBe(100);
240252
expect(mockCountTokenUsage).not.toHaveBeenCalled();
241253

242254
// Move the time to 1 hour later
243-
mockCountTokenUsage.mockResolvedValueOnce({ tokenUsage: 200 });
255+
mockCountTokenUsage.mockResolvedValueOnce({
256+
totalUsage: 200,
257+
userTokenUsage: 120,
258+
m2mTokenUsage: 80,
259+
});
244260
jest.advanceTimersByTime(tokenUsageCacheTtl / 2 + 1);
245261
const refreshedTokenUsage = await subscription.getTenantTokenUsage({
246262
from,
247263
to,
248264
});
249-
expect(refreshedTokenUsage).toBe(200);
265+
expect(refreshedTokenUsage.totalUsage).toBe(200);
250266
expect(mockCountTokenUsage).toHaveBeenCalled();
251267
});
252268
});

packages/core/src/libraries/subscription.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { TtlCache } from '@logto/shared';
44
import { TenantSubscriptionCache } from '#src/caches/tenant-subscription.js';
55
import { type CacheStore } from '#src/caches/types.js';
66
import { cacheConsole } from '#src/caches/utils.js';
7+
import { type TokenUsageCounts, tokenUsageCountsGuard } from '#src/queries/daily-token-usage.js';
78
import type Queries from '#src/tenants/Queries.js';
89
import { getTenantSubscription } from '#src/utils/subscription/index.js';
910
import { type Subscription } from '#src/utils/subscription/types.js';
@@ -66,7 +67,7 @@ export class SubscriptionLibrary {
6667
* We don't want to calculate the latest token usage for each request.
6768
* Using this cache, we can reduce the number of queries to the database.
6869
*/
69-
private readonly tokenUsageCache = new TtlCache<string, number>(tokenUsageCacheTtl);
70+
private readonly tokenUsageCache = new TtlCache<string, TokenUsageCounts>(tokenUsageCacheTtl);
7071

7172
constructor(
7273
public readonly tenantId: string,
@@ -97,13 +98,19 @@ export class SubscriptionLibrary {
9798
return cachedValue;
9899
}
99100

100-
const { tokenUsage } = await this.queries.dailyTokenUsage.countTokenUsage({
101+
const tokenUsageCounts = await this.queries.dailyTokenUsage.countTokenUsage({
101102
from,
102103
to,
103104
});
105+
const result = tokenUsageCountsGuard.safeParse(tokenUsageCounts);
106+
if (!result.success) {
107+
throw new Error(
108+
`Invalid token usage counts data retrieved for tenant ${this.tenantId}: ${result.error.message}`
109+
);
110+
}
104111

105-
this.tokenUsageCache.set(cacheKey, tokenUsage, getTokenUsageCacheTtl(to));
106-
return tokenUsage;
112+
this.tokenUsageCache.set(cacheKey, result.data, getTokenUsageCacheTtl(to));
113+
return result.data;
107114
}
108115

109116
private buildTokenUsageKey({ tenantId, from, to }: { tenantId: string; from: Date; to: Date }) {

packages/core/src/middleware/koa-token-usage-guard.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export default function koaTokenUsageGuard<StateT, ContextT, ResponseBodyT>(
5757
});
5858

5959
assertThat(
60-
tokenLimit === null || tokenUsage < tokenLimit,
60+
tokenLimit === null || tokenUsage.totalUsage < tokenLimit,
6161
new RequestError({
6262
code: 'auth.exceed_token_limit',
6363
status: 429,

packages/core/src/queries/daily-token-usage.ts

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,33 @@ import { DailyTokenUsage } from '@logto/schemas';
22
import { generateStandardId } from '@logto/shared';
33
import type { CommonQueryMethods } from '@silverhand/slonik';
44
import { sql } from '@silverhand/slonik';
5+
import { z } from 'zod';
56

67
import { getUtcStartOfTheDay } from '#src/oidc/utils.js';
78
import { convertToIdentifiers } from '#src/utils/sql.js';
89

910
const { table, fields } = convertToIdentifiers(DailyTokenUsage);
1011
const { fields: fieldsWithPrefix } = convertToIdentifiers(DailyTokenUsage, true);
1112

13+
export enum TokenUsageType {
14+
User = 'User',
15+
M2m = 'M2m',
16+
}
17+
18+
export const tokenUsageCountsGuard = z.object({
19+
totalUsage: z.number().nonnegative(),
20+
userTokenUsage: z.number().nonnegative(),
21+
m2mTokenUsage: z.number().nonnegative(),
22+
});
23+
24+
export type TokenUsageCounts = z.infer<typeof tokenUsageCountsGuard>;
25+
1226
export const createDailyTokenUsageQueries = (pool: CommonQueryMethods) => {
1327
/**
1428
* Record the token usage of the current date.
1529
*
1630
* @param date The current date.
31+
* @param options Options for recording token usage, including the type of token.
1732
* @returns The updated token usage of the current date.
1833
*/
1934
/**
@@ -23,35 +38,59 @@ export const createDailyTokenUsageQueries = (pool: CommonQueryMethods) => {
2338
* If we were to use the pre-built query methods, completing this operation would
2439
* require two database requests:
2540
* 1. to request the record
26-
* 2. to update it if the record exists, or insert a new one if it doesnt
41+
* 2. to update it if the record exists, or insert a new one if it doesn't
2742
*
2843
* The approach we used allows us to accomplish the task within a single database query.
2944
*/
30-
const recordTokenUsage = async (date: Date) =>
31-
// Insert a new record if not exists (with usage to be 1, since this
32-
// should be the first token use of the day), otherwise increment the usage by 1.
33-
pool.one<DailyTokenUsage>(sql`
34-
insert into ${table} (${fields.id}, ${fields.date}, ${fields.usage})
35-
values (${generateStandardId()}, to_timestamp(${getUtcStartOfTheDay(
36-
date
37-
).getTime()}::double precision / 1000), 1)
38-
on conflict (${fields.date}, ${fields.tenantId}) do update set ${fields.usage} = ${
39-
fieldsWithPrefix.usage
40-
} + 1
45+
const recordTokenUsage = async (date: Date, { type }: { type: TokenUsageType }) => {
46+
// For user tokens: increment both usage and user_token_usage
47+
// For M2M tokens: increment both usage and m2m_token_usage
48+
const userTokenIncrement =
49+
type === TokenUsageType.User
50+
? sql`${fieldsWithPrefix.userTokenUsage} + 1`
51+
: sql`${fieldsWithPrefix.userTokenUsage}`;
52+
const m2mTokenIncrement =
53+
type === TokenUsageType.M2m
54+
? sql`${fieldsWithPrefix.m2mTokenUsage} + 1`
55+
: sql`${fieldsWithPrefix.m2mTokenUsage}`;
56+
57+
return pool.one<DailyTokenUsage>(sql`
58+
insert into ${table} (
59+
${fields.id},
60+
${fields.date},
61+
${fields.usage},
62+
${fields.userTokenUsage},
63+
${fields.m2mTokenUsage}
64+
)
65+
values (
66+
${generateStandardId()},
67+
to_timestamp(${getUtcStartOfTheDay(date).getTime()}::double precision / 1000),
68+
1,
69+
${type === TokenUsageType.User ? 1 : 0},
70+
${type === TokenUsageType.M2m ? 1 : 0}
71+
)
72+
on conflict (${fields.date}, ${fields.tenantId}) do update set
73+
${fields.usage} = ${fieldsWithPrefix.usage} + 1,
74+
${fields.userTokenUsage} = ${userTokenIncrement},
75+
${fields.m2mTokenUsage} = ${m2mTokenIncrement}
4176
returning ${sql.join(Object.values(fields), sql`, `)}
4277
`);
78+
};
4379

4480
const countTokenUsage = async ({ from, to }: { from: Date; to: Date }) => {
45-
return pool.one<{ tokenUsage: number }>(sql`
46-
select sum(${fields.usage}) as token_usage
47-
from ${table}
48-
where ${fields.date} >= to_timestamp(${getUtcStartOfTheDay(
49-
from
50-
).getTime()}::double precision / 1000)
51-
and ${fields.date} < to_timestamp(${getUtcStartOfTheDay(
52-
to
81+
return pool.one<TokenUsageCounts>(sql`
82+
select
83+
coalesce(sum(${fields.usage}), 0) as total_usage,
84+
coalesce(sum(${fields.userTokenUsage}), 0) as user_token_usage,
85+
coalesce(sum(${fields.m2mTokenUsage}), 0) as m2m_token_usage
86+
from ${table}
87+
where ${fields.date} >= to_timestamp(${getUtcStartOfTheDay(
88+
from
5389
).getTime()}::double precision / 1000)
54-
`);
90+
and ${fields.date} < to_timestamp(${getUtcStartOfTheDay(
91+
to
92+
).getTime()}::double precision / 1000)
93+
`);
5594
};
5695

5796
return {

0 commit comments

Comments
 (0)