@pbvision/fastify-firestore-service 0.0.47 → 0.0.49
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/api/exception.js +22 -0
- package/src/make-app.js +7 -1
- package/src/plugins/error-handler.js +62 -36
- package/src/plugins/sentry-rate-limit.js +37 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pbvision/fastify-firestore-service",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.49",
|
|
4
4
|
"description": "Web Framework using Fastify and Firestore ORM",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -10,11 +10,11 @@
|
|
|
10
10
|
"exports": "./src/index.js",
|
|
11
11
|
"scripts": {
|
|
12
12
|
"build-doc": "./docs/build.sh",
|
|
13
|
-
"debug": "INDEBUGGER=1 ./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./examples --watch ./test --inspect=9229 --experimental-vm-modules ./node_modules/jest/bin/jest.js --coverage --config=./jest.config.json --runInBand",
|
|
13
|
+
"debug": "GCLOUD_PROJECT=localhost-emulator INDEBUGGER=1 ./node_modules/nodemon/bin/nodemon.js --no-lazy --legacy-watch --watch ./src --watch ./examples --watch ./test --inspect=9229 --experimental-vm-modules ./node_modules/jest/bin/jest.js --coverage --config=./jest.config.json --runInBand",
|
|
14
14
|
"setup": "yarn install --frozen-lockfile && pip install -r requirements.txt",
|
|
15
15
|
"start-local-db": "./node_modules/@pbvision/firestore-orm/scripts/start-local-db.sh",
|
|
16
16
|
"test": "yarn -s start-local-db && yarn -s test-without-starting-db",
|
|
17
|
-
"test-without-starting-db": "node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json --coverage"
|
|
17
|
+
"test-without-starting-db": "GCLOUD_PROJECT=localhost-emulator node --experimental-vm-modules ./node_modules/jest/bin/jest.js --config=./jest.config.json --coverage"
|
|
18
18
|
},
|
|
19
19
|
"contributors": [
|
|
20
20
|
"David Underhill",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"@fastify/static": "^6.12",
|
|
43
43
|
"@fastify/swagger": "^8.1.0",
|
|
44
44
|
"@fastify/swagger-ui": "^2.0.1",
|
|
45
|
-
"@pbvision/firestore-orm": "^0.0.
|
|
45
|
+
"@pbvision/firestore-orm": "^0.0.22",
|
|
46
46
|
"@sentry/node": "^7.91.0",
|
|
47
47
|
"fastify": "^4.10.0",
|
|
48
48
|
"fastify-plugin": "^4.5.1",
|
package/src/api/exception.js
CHANGED
|
@@ -181,6 +181,28 @@ class RequestError extends __RequestDone {
|
|
|
181
181
|
data: this.data
|
|
182
182
|
}
|
|
183
183
|
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Mark this error for rate-limited Sentry reporting. Useful when a known
|
|
187
|
+
* dependency outage causes the same error to fire repeatedly -- Cloud Tasks
|
|
188
|
+
* retries, etc. The HTTP response and logs are unaffected; only the Sentry
|
|
189
|
+
* report is throttled so the error budget doesn't get exhausted during the
|
|
190
|
+
* outage. Returns `this`, so it can be chained at the throw site:
|
|
191
|
+
*
|
|
192
|
+
* throw new RequestError('failed to send email', { err }, 550)
|
|
193
|
+
* .rateLimitSentry()
|
|
194
|
+
*
|
|
195
|
+
* Rate-limit keying follows the Sentry fingerprint we'd have used anyway
|
|
196
|
+
* (so Sentry grouping and our suppression agree on what "the same error" is).
|
|
197
|
+
*
|
|
198
|
+
* @param {Number} [windowMs=300000] rate-limit window in ms; at most one
|
|
199
|
+
* Sentry report is sent per window per key (default 5 minutes).
|
|
200
|
+
* @returns {RequestError} this
|
|
201
|
+
*/
|
|
202
|
+
rateLimitSentry (windowMs = 5 * 60 * 1000) {
|
|
203
|
+
this._sentryRateLimitMs = windowMs
|
|
204
|
+
return this
|
|
205
|
+
}
|
|
184
206
|
}
|
|
185
207
|
|
|
186
208
|
/**
|
package/src/make-app.js
CHANGED
|
@@ -31,6 +31,10 @@ const COOKIE_CONFIG = {
|
|
|
31
31
|
* of an error. Recommend to keep it off for remote testing, on for local
|
|
32
32
|
* testing.
|
|
33
33
|
* @property {String} [sentryDSN] The Sentry DSN to report errors to
|
|
34
|
+
* @property {Object} [sentryRateLimiter] Optional rate limiter instance (from
|
|
35
|
+
* {@link createSentryRateLimiter}) to control Sentry reporting for errors
|
|
36
|
+
* flagged via `RequestError.rateLimitSentry()`. Defaults to a fresh limiter
|
|
37
|
+
* using the real clock; tests inject one with a controllable clock.
|
|
34
38
|
* @property {function} [customizePinoOpts] An optional function customize the
|
|
35
39
|
* arguments to pass to Pino. Takes as input the options that will be sent
|
|
36
40
|
* to be Pino by default (can be modified). Return the new options.
|
|
@@ -39,7 +43,8 @@ const LOGGING_CONFIG = {
|
|
|
39
43
|
customizePinoOpts: null,
|
|
40
44
|
reportAllErrors: false,
|
|
41
45
|
reportErrorDetail: false,
|
|
42
|
-
sentryDSN: null
|
|
46
|
+
sentryDSN: null,
|
|
47
|
+
sentryRateLimiter: null
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
/**
|
|
@@ -205,6 +210,7 @@ export default async function makeService (params = {}) {
|
|
|
205
210
|
errorHandler: {
|
|
206
211
|
returnErrorDetail: logging.reportErrorDetail,
|
|
207
212
|
sentryDSN: logging.sentryDSN,
|
|
213
|
+
sentryRateLimiter: logging.sentryRateLimiter,
|
|
208
214
|
serverName: fastifyServerId,
|
|
209
215
|
service
|
|
210
216
|
}
|
|
@@ -3,6 +3,7 @@ import * as Sentry from '@sentry/node'
|
|
|
3
3
|
import fp from 'fastify-plugin'
|
|
4
4
|
|
|
5
5
|
import { InvalidInputException, __RequestDone } from '../api/exception.js'
|
|
6
|
+
import { createSentryRateLimiter } from './sentry-rate-limit.js'
|
|
6
7
|
|
|
7
8
|
export default fp(function (fastify, options, next) {
|
|
8
9
|
const isLocalhost = process.env.NODE_ENV === 'localhost'
|
|
@@ -17,6 +18,11 @@ export default fp(function (fastify, options, next) {
|
|
|
17
18
|
serverName: options.errorHandler.serverName
|
|
18
19
|
})
|
|
19
20
|
|
|
21
|
+
// Opt-in rate limiter: only errors flagged via RequestError.rateLimitSentry()
|
|
22
|
+
// consult this. See sentry-rate-limit.js for details.
|
|
23
|
+
const sentryRateLimiter = options.errorHandler.sentryRateLimiter ??
|
|
24
|
+
createSentryRateLimiter()
|
|
25
|
+
|
|
20
26
|
const returnErrorDetail = options.errorHandler.returnErrorDetail
|
|
21
27
|
// log any exception which occurs
|
|
22
28
|
fastify.setErrorHandler(async (error, req, reply) => {
|
|
@@ -96,43 +102,63 @@ export default fp(function (fastify, options, next) {
|
|
|
96
102
|
reply.log.info(errInfo)
|
|
97
103
|
}
|
|
98
104
|
|
|
99
|
-
Sentry
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
105
|
+
// Check if this error is opted in to Sentry rate limiting; if so and we're
|
|
106
|
+
// inside the window, skip Sentry.captureException entirely. The HTTP
|
|
107
|
+
// response and logs are unaffected regardless.
|
|
108
|
+
let shouldCaptureToSentry = true
|
|
109
|
+
let suppressedCount = 0
|
|
110
|
+
const rateLimitMs = error._sentryRateLimitMs
|
|
111
|
+
if (rateLimitMs) {
|
|
112
|
+
const rlKey = customFingerprint || message
|
|
113
|
+
const rlResult = sentryRateLimiter.shouldReport(rlKey, rateLimitMs)
|
|
114
|
+
shouldCaptureToSentry = rlResult.report
|
|
115
|
+
suppressedCount = rlResult.suppressedCount
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (shouldCaptureToSentry) {
|
|
119
|
+
Sentry.withScope(function (scope) {
|
|
120
|
+
if (customFingerprint) {
|
|
121
|
+
scope.setFingerprint(customFingerprint)
|
|
122
|
+
}
|
|
123
|
+
const user = {}
|
|
124
|
+
// istanbul ignore if
|
|
125
|
+
if (req.headers['x-uid']) {
|
|
126
|
+
user.id = req.headers['x-uid']
|
|
127
|
+
} else {
|
|
128
|
+
user.ip = req.ip
|
|
129
|
+
}
|
|
130
|
+
scope.setLevel(isCrash ? 'error' : 'warning')
|
|
131
|
+
scope.setUser({
|
|
132
|
+
...user,
|
|
133
|
+
...(req.__sentry?.userInfo ?? {})
|
|
134
|
+
})
|
|
135
|
+
scope.setTags({
|
|
136
|
+
...(req.__sentry?.tags ?? {}),
|
|
137
|
+
method: req.method,
|
|
138
|
+
url: req.url,
|
|
139
|
+
status: errInfo.status,
|
|
140
|
+
// Tag -- not extra -- so operators can filter Sentry for "issues
|
|
141
|
+
// where rate-limiting kicked in" during an outage.
|
|
142
|
+
...(suppressedCount > 0
|
|
143
|
+
? { suppressedSimilarEvents: String(suppressedCount) }
|
|
144
|
+
: {})
|
|
145
|
+
})
|
|
146
|
+
const customContexts = req.__sentry?.context ?? {}
|
|
147
|
+
for (const [k, v] of Object.entries(customContexts)) {
|
|
148
|
+
scope.setContext(k, v)
|
|
149
|
+
}
|
|
150
|
+
const extras = {
|
|
151
|
+
msg: errInfo.message,
|
|
152
|
+
reqId: req.id,
|
|
153
|
+
userAgent: req.headers['user-agent'] ?? 'not set'
|
|
154
|
+
}
|
|
155
|
+
if (error instanceof __RequestDone) {
|
|
156
|
+
extras.data = error.data
|
|
157
|
+
}
|
|
158
|
+
scope.setExtras(extras)
|
|
159
|
+
Sentry.captureException(error)
|
|
120
160
|
})
|
|
121
|
-
|
|
122
|
-
for (const [k, v] of Object.entries(customContexts)) {
|
|
123
|
-
scope.setContext(k, v)
|
|
124
|
-
}
|
|
125
|
-
const extras = {
|
|
126
|
-
msg: errInfo.message,
|
|
127
|
-
reqId: req.id,
|
|
128
|
-
userAgent: req.headers['user-agent'] ?? 'not set'
|
|
129
|
-
}
|
|
130
|
-
if (error instanceof __RequestDone) {
|
|
131
|
-
extras.data = error.data
|
|
132
|
-
}
|
|
133
|
-
scope.setExtras(extras)
|
|
134
|
-
Sentry.captureException(error)
|
|
135
|
-
})
|
|
161
|
+
}
|
|
136
162
|
|
|
137
163
|
const errorData = error.respData ?? {
|
|
138
164
|
code: error.constructor.name,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Creates a rate limiter for Sentry event reporting, keyed by an arbitrary
|
|
3
|
+
* string (typically the Sentry fingerprint or error message). State lives in
|
|
4
|
+
* a Map per rate limiter instance: one instance per process is the expected
|
|
5
|
+
* usage.
|
|
6
|
+
*
|
|
7
|
+
* @param {Function} [nowFn=Date.now] clock function (injectable for tests)
|
|
8
|
+
* @returns {Object} rate limiter with one method, {@link shouldReport}.
|
|
9
|
+
*/
|
|
10
|
+
export function createSentryRateLimiter (nowFn = Date.now) {
|
|
11
|
+
const tracking = new Map()
|
|
12
|
+
return {
|
|
13
|
+
/**
|
|
14
|
+
* Record an event and decide whether it should be reported to Sentry.
|
|
15
|
+
*
|
|
16
|
+
* @param {String} key rate-limit key; typically the error fingerprint or
|
|
17
|
+
* message so that Sentry grouping lines up with our suppression.
|
|
18
|
+
* @param {Number} windowMs rate-limit window in ms; within this window
|
|
19
|
+
* after a report, subsequent events for the same key are suppressed.
|
|
20
|
+
* @returns {{report: Boolean, suppressedCount: Number}} when `report` is
|
|
21
|
+
* true, `suppressedCount` is the number of events suppressed for this
|
|
22
|
+
* key since the last report (0 if none were suppressed). When `report`
|
|
23
|
+
* is false, `suppressedCount` is 0 (caller should not surface it).
|
|
24
|
+
*/
|
|
25
|
+
shouldReport (key, windowMs) {
|
|
26
|
+
const now = nowFn()
|
|
27
|
+
const state = tracking.get(key)
|
|
28
|
+
if (state && now - state.lastReported < windowMs) {
|
|
29
|
+
state.suppressed++
|
|
30
|
+
return { report: false, suppressedCount: 0 }
|
|
31
|
+
}
|
|
32
|
+
const suppressedCount = state?.suppressed ?? 0
|
|
33
|
+
tracking.set(key, { lastReported: now, suppressed: 0 })
|
|
34
|
+
return { report: true, suppressedCount }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|