Skip to content

Commit 16fb96b

Browse files
authored
Merge pull request #18841 from mozilla/FXA-11611
task(libs/rate-limit): Add metrics
2 parents 3afb477 + dad1a38 commit 16fb96b

3 files changed

Lines changed: 72 additions & 12 deletions

File tree

libs/accounts/rate-limit/src/lib/rate-limit.in.spec.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,26 @@ import { RateLimit } from './rate-limit';
66
import { parseConfigRules } from './config';
77
import Redis from 'ioredis';
88
import { after } from 'node:test';
9+
import { StatsD } from 'hot-shots';
910

1011
describe('rate-limit', () => {
12+
let mockIncrement: jest.Mock;
13+
let statsd: StatsD;
1114
let redis: Redis.Redis;
1215
let rateLimit: RateLimit;
1316

1417
beforeAll(() => {
1518
redis = new Redis('localhost');
19+
mockIncrement = jest.fn();
20+
statsd = {
21+
increment: mockIncrement,
22+
} as unknown as StatsD;
1623
});
1724

1825
beforeEach(async () => {
1926
await redis.flushall();
20-
rateLimit = new RateLimit({}, redis);
27+
rateLimit = new RateLimit({}, redis, statsd);
28+
mockIncrement.mockReset();
2129
});
2230

2331
after(async () => {
@@ -28,7 +36,8 @@ describe('rate-limit', () => {
2836
it('should block on ' + blockOn, async () => {
2937
rateLimit = new RateLimit(
3038
parseConfigRules(['testBlock:ip:1:1s:1s']),
31-
redis
39+
redis,
40+
statsd
3241
);
3342

3443
const check1 = await rateLimit.check('testBlock', { ip: '127.0.0.1' });
@@ -37,13 +46,20 @@ describe('rate-limit', () => {
3746
expect(check1).toBeNull();
3847
expect(check2?.reason).toEqual('too-many-attempts');
3948
expect(check2?.retryAfter).toEqual(1000);
49+
50+
expect(mockIncrement).toBeCalledTimes(1);
51+
expect(mockIncrement).toBeCalledWith('rate_limit.block', [
52+
'on:ip',
53+
'action:testBlock',
54+
]);
4055
});
4156
}
4257

4358
it(`should not block after window clears`, async () => {
4459
rateLimit = new RateLimit(
4560
parseConfigRules(['testWindowCleared:ip:1:1s:1s']),
46-
redis
61+
redis,
62+
statsd
4763
);
4864

4965
const check1 = await rateLimit.check('testWindowCleared', {
@@ -61,7 +77,8 @@ describe('rate-limit', () => {
6177
it('can block multiple rules on a single action', async () => {
6278
rateLimit = new RateLimit(
6379
parseConfigRules(['testMulti:ip:2:10s:2s', 'testMulti:email:2:10s:3s']),
64-
redis
80+
redis,
81+
statsd
6582
);
6683
const check1 = await rateLimit.check('testMulti', {
6784
ip: '127.0.0.1',
@@ -107,7 +124,8 @@ describe('rate-limit', () => {
107124
'testDouble:email:2:10s:2s',
108125
'testDouble:email:3:10s:4s',
109126
]),
110-
redis
127+
redis,
128+
statsd
111129
);
112130
const check1 = await rateLimit.check('testDouble', {
113131
@@ -134,7 +152,11 @@ describe('rate-limit', () => {
134152
/**
135153
* Note that once the ban kicks in the window disappears. So despite
136154
*/
137-
rateLimit = new RateLimit(parseConfigRules(['test:ip:1:20s:1s']), redis);
155+
rateLimit = new RateLimit(
156+
parseConfigRules(['test:ip:1:20s:1s']),
157+
redis,
158+
statsd
159+
);
138160

139161
const check1 = await rateLimit.check('test', { ip: '127.0.0.1' });
140162
const check2 = await rateLimit.check('test', { ip: '127.0.0.1' });
@@ -153,7 +175,8 @@ describe('rate-limit', () => {
153175
it('can unblock', async () => {
154176
rateLimit = new RateLimit(
155177
parseConfigRules(['testBlock:ip:1:1s:1s']),
156-
redis
178+
redis,
179+
statsd
157180
);
158181

159182
const check1 = await rateLimit.check('testBlock', { ip: '127.0.0.1' });
@@ -165,5 +188,15 @@ describe('rate-limit', () => {
165188
expect(check2?.reason).toEqual('too-many-attempts');
166189
expect(check2?.retryAfter).toEqual(1000);
167190
expect(check3).toBeNull();
191+
192+
expect(statsd.increment).toBeCalledTimes(2);
193+
expect(statsd.increment).toBeCalledWith('rate_limit.block', [
194+
'on:ip',
195+
'action:testBlock',
196+
]);
197+
expect(statsd.increment).toBeCalledWith('rate_limit.unblock', [
198+
'on:ip',
199+
'action:testBlock',
200+
]);
168201
});
169202
});

libs/accounts/rate-limit/src/lib/rate-limit.spec.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,31 +7,45 @@ import { parseConfigRules } from './config';
77
import { Redis } from 'ioredis';
88
import { BlockRecord, Rule } from './models';
99
import { calculateRetryAfter, getKey } from './util';
10+
import { StatsD } from 'hot-shots';
1011

1112
describe('rate-limit', () => {
1213
let redis: Redis;
14+
let mockIncrement: jest.Mock;
15+
let statsd: StatsD;
1316
let rateLimit: RateLimit;
1417

1518
beforeAll(() => {
19+
mockIncrement = jest.fn();
20+
1621
redis = {} as unknown as Redis;
22+
statsd = {
23+
increment: mockIncrement,
24+
} as unknown as StatsD;
1725
});
1826

19-
afterEach(async () => {});
27+
afterEach(async () => {
28+
mockIncrement.mockReset();
29+
});
2030

2131
it('creates rate limiter', () => {
22-
rateLimit = new RateLimit({}, redis);
32+
rateLimit = new RateLimit({}, redis, statsd);
2333
expect(rateLimit).toBeDefined();
2434
});
2535

2636
it('throws if no action can be found', async () => {
27-
rateLimit = new RateLimit({}, redis);
37+
rateLimit = new RateLimit({}, redis, statsd);
2838
expect(rateLimit.check('foo', {})).rejects.toThrow(
2939
`Could not locate the 'foo' action.`
3040
);
3141
});
3242

3343
it('throws error if option is missing', async () => {
34-
rateLimit = new RateLimit(parseConfigRules(['test:ip:1:1s:1s']), redis);
44+
rateLimit = new RateLimit(
45+
parseConfigRules(['test:ip:1:1s:1s']),
46+
redis,
47+
statsd
48+
);
3549

3650
expect(
3751
rateLimit.check('test', { email: '[email protected]' })

libs/accounts/rate-limit/src/lib/rate-limit.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { BlockStatus, BlockRecord, Rule, BlockReason } from './models';
77
import { ActionNotFound, MissingOption } from './error';
88
import { calculateRetryAfter, getKey } from './util';
99

10+
import { StatsD } from '@fxa/shared/metrics/statsd';
11+
1012
/**
1113
* Class that controls 'rate-limiting' specific actions.
1214
* This used to be customs-server, but has been simplified quite a bit by
@@ -20,7 +22,8 @@ export class RateLimit {
2022
*/
2123
constructor(
2224
public readonly rules: Record<string, Rule[]>,
23-
private readonly redis: Redis
25+
private readonly redis: Redis,
26+
private readonly statsd?: StatsD
2427
) {}
2528

2629
/**
@@ -39,6 +42,11 @@ export class RateLimit {
3942
this.redis.del(blockKey),
4043
this.redis.del(attemptsKey),
4144
]);
45+
46+
this.statsd?.increment(`rate_limit.unblock`, [
47+
`on:${rule.blockingOn}`,
48+
`action:${rule.action}`,
49+
]);
4250
}
4351
}
4452
}
@@ -114,6 +122,11 @@ export class RateLimit {
114122
this.redis.expire(blockKey, rule.blockDurationInSeconds),
115123
this.redis.expire(attemptsKey, 0),
116124
]);
125+
126+
this.statsd?.increment(`rate_limit.block`, [
127+
`on:${rule.blockingOn}`,
128+
`action:${rule.action}`,
129+
]);
117130
}
118131
}
119132

0 commit comments

Comments
 (0)