@pbvision/fastify-firestore-service 0.0.48 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pbvision/fastify-firestore-service",
3
- "version": "0.0.48",
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.21",
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",
@@ -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.withScope(function (scope) {
100
- if (customFingerprint) {
101
- scope.setFingerprint(customFingerprint)
102
- }
103
- const user = {}
104
- // istanbul ignore if
105
- if (req.headers['x-uid']) {
106
- user.id = req.headers['x-uid']
107
- } else {
108
- user.ip = req.ip
109
- }
110
- scope.setLevel(isCrash ? 'error' : 'warning')
111
- scope.setUser({
112
- ...user,
113
- ...(req.__sentry?.userInfo ?? {})
114
- })
115
- scope.setTags({
116
- ...(req.__sentry?.tags ?? {}),
117
- method: req.method,
118
- url: req.url,
119
- status: errInfo.status
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
- const customContexts = req.__sentry?.context ?? {}
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
+ }