@pbvision/fastify-firestore-service 0.0.50
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/LICENSE +204 -0
- package/README.md +187 -0
- package/docs/api.md +706 -0
- package/docs/build.sh +24 -0
- package/docs/jsdoc.config.json +22 -0
- package/package.json +93 -0
- package/src/api/api.js +852 -0
- package/src/api/db-api.js +109 -0
- package/src/api/exception.js +344 -0
- package/src/api/response.js +8 -0
- package/src/component-registrar.js +27 -0
- package/src/fetch-wrapper.js +30 -0
- package/src/index.js +13 -0
- package/src/make-app.js +223 -0
- package/src/make-logger.js +27 -0
- package/src/plugins/compress.js +28 -0
- package/src/plugins/content-parser.js +21 -0
- package/src/plugins/cookie.js +17 -0
- package/src/plugins/error-handler.js +180 -0
- package/src/plugins/health-check.js +19 -0
- package/src/plugins/latency-tracker.js +34 -0
- package/src/plugins/sentry-rate-limit.js +37 -0
- package/src/plugins/swagger.js +50 -0
- package/test/base-test.js +203 -0
- package/test/environment.js +3 -0
package/src/make-app.js
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import Ajv from 'ajv/dist/2020.js'
|
|
2
|
+
import fastify from 'fastify'
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
4
|
+
|
|
5
|
+
import ComponentRegistrar from './component-registrar.js'
|
|
6
|
+
import { makePinoLoggerOptions } from './make-logger.js'
|
|
7
|
+
import compressPlugin from './plugins/compress.js'
|
|
8
|
+
import contentParserPlugin from './plugins/content-parser.js'
|
|
9
|
+
import cookiePlugin from './plugins/cookie.js'
|
|
10
|
+
import errorHandlerPlugin from './plugins/error-handler.js'
|
|
11
|
+
import healthCheckPlugin from './plugins/health-check.js'
|
|
12
|
+
import latencyTrackerPlugin from './plugins/latency-tracker.js'
|
|
13
|
+
import swaggerPlugin from './plugins/swagger.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {object} CookieConfig
|
|
17
|
+
* @property {boolean} [disabled=false] Adds fastify-cookie plugin
|
|
18
|
+
* @property {string} [secret] A secret to use to secure cookie
|
|
19
|
+
*/
|
|
20
|
+
const COOKIE_CONFIG = {
|
|
21
|
+
disabled: false,
|
|
22
|
+
secret: null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {object} LoggingConfig
|
|
27
|
+
* @property {boolean} [reportAllErrors=false] Whether include all API
|
|
28
|
+
* validation errors in error logging. Recommend to keep it off for production,
|
|
29
|
+
* on for testing.
|
|
30
|
+
* @property {boolean} [reportErrorDetail=false] Whether include all details
|
|
31
|
+
* of an error. Recommend to keep it off for remote testing, on for local
|
|
32
|
+
* testing.
|
|
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.
|
|
38
|
+
* @property {function} [customizePinoOpts] An optional function customize the
|
|
39
|
+
* arguments to pass to Pino. Takes as input the options that will be sent
|
|
40
|
+
* to be Pino by default (can be modified). Return the new options.
|
|
41
|
+
*/
|
|
42
|
+
const LOGGING_CONFIG = {
|
|
43
|
+
customizePinoOpts: null,
|
|
44
|
+
reportAllErrors: false,
|
|
45
|
+
reportErrorDetail: false,
|
|
46
|
+
sentryDSN: null,
|
|
47
|
+
sentryRateLimiter: null
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* @typedef {object} HealthCheckConfig
|
|
52
|
+
* @property {boolean} [disabled=false] Whether to add a health check endpoint
|
|
53
|
+
* that simply returns 200.
|
|
54
|
+
* @property {string} [path='/'] The path to the health check endpoint.
|
|
55
|
+
*/
|
|
56
|
+
const HEALTH_CHECK_CONFIG = {
|
|
57
|
+
disabled: false,
|
|
58
|
+
path: '/'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @typedef {object} SwaggerConfig
|
|
63
|
+
* @property {boolean} [disabled=false] Whether to disable
|
|
64
|
+
* @property {Array<string>} [servers=[]] The host endpoint (scheme + domain) to
|
|
65
|
+
* send requests to
|
|
66
|
+
* @property {Array<string>} [authHeaders=[]] Authentication headers
|
|
67
|
+
* @property {string} [routePrefix='/docs'] Authentication headers
|
|
68
|
+
*/
|
|
69
|
+
const SWAGGER_CONFIG = {
|
|
70
|
+
disabled: false,
|
|
71
|
+
servers: [],
|
|
72
|
+
authHeaders: [],
|
|
73
|
+
routePrefix: '/docs'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @typedef {object} LatencyTrackerConfig
|
|
78
|
+
* @property {boolean} [disabled=false] Whether to record how long from when we
|
|
79
|
+
* start to process a request to when we start to send the response
|
|
80
|
+
* @property {string} [header='x-latency-ms'] response header to send the
|
|
81
|
+
* latency info back in
|
|
82
|
+
*/
|
|
83
|
+
const LATENCY_TRACKER_CONFIG = {
|
|
84
|
+
disabled: false,
|
|
85
|
+
header: 'x-latency-ms'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PARAMS_CONFIG = {
|
|
89
|
+
service: undefined,
|
|
90
|
+
components: undefined,
|
|
91
|
+
RegistrarCls: ComponentRegistrar,
|
|
92
|
+
cookie: {},
|
|
93
|
+
healthCheck: {},
|
|
94
|
+
latencyTracker: {},
|
|
95
|
+
logging: {},
|
|
96
|
+
swagger: {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function loadConfigDefault (config, defaultConfig) {
|
|
100
|
+
for (const key of Object.keys(config)) {
|
|
101
|
+
if (!Object.prototype.hasOwnProperty.call(defaultConfig, key)) {
|
|
102
|
+
throw new Error(`Unknown config ${key}`)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (!config.disabled) {
|
|
106
|
+
for (const [key, defaultValue] of Object.entries(defaultConfig)) {
|
|
107
|
+
if (defaultValue === undefined && !config[key]) {
|
|
108
|
+
throw new Error(`Missing required value for ${key}`)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
Object.assign(config, { ...defaultConfig, ...config })
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {Object} params
|
|
117
|
+
* @param {string} params.service Name of the service, for example, iam,
|
|
118
|
+
* user-id, leaderboard. This affects API's prefixes.
|
|
119
|
+
* @param {Array<API|Model|component>} params.components A list of
|
|
120
|
+
* components.
|
|
121
|
+
* @param {object} [params.RegistrarCls=ComponentRegistrar] A subclass of
|
|
122
|
+
* ComponentRegistrar
|
|
123
|
+
* @param {CookieConfig} [params.cookie] Configures fastify-cookie.
|
|
124
|
+
* @param {HealthCheckConfig} [params.healthCheck] Configures health check endpoint.
|
|
125
|
+
* @param {LatencyTrackerConfig} [params.latencyTracker]
|
|
126
|
+
* @param {LoggingConfig} [params.logging] Configures logging.
|
|
127
|
+
* @param {SwaggerConfig} [params.swagger] Configures swagger.
|
|
128
|
+
* @returns {Promise<server>} fastify app with configured plugins
|
|
129
|
+
*/
|
|
130
|
+
export default async function makeService (params = {}) {
|
|
131
|
+
const configs = [
|
|
132
|
+
[() => params, PARAMS_CONFIG],
|
|
133
|
+
[() => params.cookie, COOKIE_CONFIG],
|
|
134
|
+
[() => params.healthCheck, HEALTH_CHECK_CONFIG],
|
|
135
|
+
[() => params.latencyTracker, LATENCY_TRACKER_CONFIG],
|
|
136
|
+
[() => params.logging, LOGGING_CONFIG],
|
|
137
|
+
[() => params.swagger, SWAGGER_CONFIG]
|
|
138
|
+
]
|
|
139
|
+
for (const [getter, defaultConfig] of configs) {
|
|
140
|
+
loadConfigDefault(getter(), defaultConfig)
|
|
141
|
+
}
|
|
142
|
+
const {
|
|
143
|
+
service,
|
|
144
|
+
components,
|
|
145
|
+
RegistrarCls,
|
|
146
|
+
cookie,
|
|
147
|
+
healthCheck,
|
|
148
|
+
latencyTracker,
|
|
149
|
+
logging,
|
|
150
|
+
swagger
|
|
151
|
+
} = params
|
|
152
|
+
const fastifyServerId = uuidv4()
|
|
153
|
+
let requestCount = 0
|
|
154
|
+
const ajvParams = {
|
|
155
|
+
removeAdditional: false,
|
|
156
|
+
allErrors: logging.reportAllErrors,
|
|
157
|
+
useDefaults: true,
|
|
158
|
+
strictSchema: false,
|
|
159
|
+
strictRequired: true
|
|
160
|
+
}
|
|
161
|
+
const ajvObjs = {
|
|
162
|
+
coerceTypes: new Ajv({ ...ajvParams, coerceTypes: true }),
|
|
163
|
+
noCoerceTypes: new Ajv({ ...ajvParams, coerceTypes: false })
|
|
164
|
+
}
|
|
165
|
+
const logger = makePinoLoggerOptions(logging.customizePinoOpts)
|
|
166
|
+
const app = fastify({
|
|
167
|
+
disableRequestLogging: true,
|
|
168
|
+
logger,
|
|
169
|
+
genReqId: () => `${fastifyServerId}-${++requestCount}`
|
|
170
|
+
})
|
|
171
|
+
.setValidatorCompiler(({ httpPart, schema }) => {
|
|
172
|
+
// header, route, and query string always come as strings; coerce them
|
|
173
|
+
// to the correct types if possible; body should come exactly in the
|
|
174
|
+
// right type so don't allow type coercion there
|
|
175
|
+
const useCoerce = httpPart !== 'body'
|
|
176
|
+
const ajv = useCoerce ? ajvObjs.coerceTypes : ajvObjs.noCoerceTypes
|
|
177
|
+
const validate = ajv.compile(schema)
|
|
178
|
+
return (value) => !validate(value)
|
|
179
|
+
? ({ value, error: validate.errors })
|
|
180
|
+
: ({ value })
|
|
181
|
+
})
|
|
182
|
+
.addHook('onResponse', (req, reply, done) => {
|
|
183
|
+
let objToLog = {}
|
|
184
|
+
try {
|
|
185
|
+
objToLog = logger.serializers.res(reply.raw)
|
|
186
|
+
Object.assign(objToLog, logger.serializers.req(req))
|
|
187
|
+
// istanbul ignore else
|
|
188
|
+
if (latencyTracker.header) {
|
|
189
|
+
// latency in milliseconds
|
|
190
|
+
objToLog.latency = reply.getHeader(latencyTracker.header)
|
|
191
|
+
}
|
|
192
|
+
} catch (err) /* istanbul ignore next */ {
|
|
193
|
+
// crashes in onResponse() aren't reported or caught unless we do this
|
|
194
|
+
if (typeof objToLog !== 'object') {
|
|
195
|
+
objToLog = {}
|
|
196
|
+
}
|
|
197
|
+
objToLog.failedToLog = JSON.stringify(err)
|
|
198
|
+
}
|
|
199
|
+
req.log.info(objToLog)
|
|
200
|
+
done()
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
const registrar = new RegistrarCls(app, service)
|
|
204
|
+
|
|
205
|
+
app.register(cookiePlugin, { cookie })
|
|
206
|
+
.register(compressPlugin)
|
|
207
|
+
.register(contentParserPlugin)
|
|
208
|
+
.register(latencyTrackerPlugin, { latencyTracker })
|
|
209
|
+
.register(errorHandlerPlugin, {
|
|
210
|
+
errorHandler: {
|
|
211
|
+
returnErrorDetail: logging.reportErrorDetail,
|
|
212
|
+
sentryDSN: logging.sentryDSN,
|
|
213
|
+
sentryRateLimiter: logging.sentryRateLimiter,
|
|
214
|
+
serverName: fastifyServerId,
|
|
215
|
+
service
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
.register(healthCheckPlugin, { healthCheck })
|
|
219
|
+
.register(swaggerPlugin, { swagger: { service, ...swagger } })
|
|
220
|
+
|
|
221
|
+
await registrar.registerComponents(components)
|
|
222
|
+
return app
|
|
223
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export function makePinoLoggerOptions (customizeOpts) {
|
|
2
|
+
function serializeReq (req) {
|
|
3
|
+
const q = req.query
|
|
4
|
+
const path = req.routeOptions.config.url
|
|
5
|
+
// istanbul ignore else
|
|
6
|
+
if (req.raw) {
|
|
7
|
+
req = req.raw
|
|
8
|
+
}
|
|
9
|
+
return {
|
|
10
|
+
uid: req.headers['x-uid'] || '',
|
|
11
|
+
method: req.method,
|
|
12
|
+
ua: req.headers['user-agent'] || '',
|
|
13
|
+
path,
|
|
14
|
+
q
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
customizeOpts = customizeOpts ?? (x => x)
|
|
18
|
+
const options = customizeOpts({
|
|
19
|
+
base: null, // omit pino default fields like pid and hostname
|
|
20
|
+
level: 'debug',
|
|
21
|
+
serializers: {
|
|
22
|
+
req: serializeReq,
|
|
23
|
+
res: res => ({ status: res.statusCode })
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
return options
|
|
27
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// eslint babel made changes to chained expressions which is breaking ast-types
|
|
2
|
+
// https://github.com/babel/babel/issues/11908
|
|
3
|
+
|
|
4
|
+
// ast-types has a fix, but it is not yet in a versioned release
|
|
5
|
+
// https://github.com/benjamn/ast-types/pull/399
|
|
6
|
+
|
|
7
|
+
// to circumvent problems caused by importing zlib, we do it in a separate file
|
|
8
|
+
// so we can ignore it in linting
|
|
9
|
+
import zlib from 'node:zlib'
|
|
10
|
+
|
|
11
|
+
import { fastifyCompress } from '@fastify/compress'
|
|
12
|
+
import fp from 'fastify-plugin'
|
|
13
|
+
|
|
14
|
+
// TODO: fine tune configurations once we have a reliable usage pattern
|
|
15
|
+
export default fp(function (fastify, options, next) {
|
|
16
|
+
fastify.register(fastifyCompress, {
|
|
17
|
+
threshold: 20,
|
|
18
|
+
brotliOptions: {
|
|
19
|
+
params: {
|
|
20
|
+
[zlib.constants.BROTLI_PARAM_QUALITY]: 4
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
next()
|
|
25
|
+
}, {
|
|
26
|
+
fastify: '>=3.x',
|
|
27
|
+
name: 'compress'
|
|
28
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
|
|
3
|
+
function addContentParser (fastify, options, next) {
|
|
4
|
+
fastify.addContentTypeParser('application/json',
|
|
5
|
+
{ parseAs: 'string' },
|
|
6
|
+
function (req, body, done) {
|
|
7
|
+
try {
|
|
8
|
+
const json = JSON.parse(body || '{}')
|
|
9
|
+
done(null, json)
|
|
10
|
+
} catch (err) {
|
|
11
|
+
err.statusCode = 400
|
|
12
|
+
done(err, undefined)
|
|
13
|
+
}
|
|
14
|
+
})
|
|
15
|
+
next()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export default fp(addContentParser, {
|
|
19
|
+
fastify: '>=3.x',
|
|
20
|
+
name: 'content-parser'
|
|
21
|
+
})
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import cookiePlugin from '@fastify/cookie'
|
|
2
|
+
import fp from 'fastify-plugin'
|
|
3
|
+
|
|
4
|
+
function addCookiePlugin (fastify, options, next) {
|
|
5
|
+
// istanbul ignore if
|
|
6
|
+
if (options.cookie.disabled || !options.cookie.secret) {
|
|
7
|
+
next()
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
fastify.register(cookiePlugin, { secret: options.cookie.secret })
|
|
11
|
+
next()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default fp(addCookiePlugin, {
|
|
15
|
+
fastify: '>=3.x',
|
|
16
|
+
name: 'content-parser'
|
|
17
|
+
})
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import S from '@pbvision/schema'
|
|
2
|
+
import * as Sentry from '@sentry/node'
|
|
3
|
+
import fp from 'fastify-plugin'
|
|
4
|
+
|
|
5
|
+
import { InvalidInputException, __RequestDone } from '../api/exception.js'
|
|
6
|
+
import { createSentryRateLimiter } from './sentry-rate-limit.js'
|
|
7
|
+
|
|
8
|
+
export default fp(function (fastify, options, next) {
|
|
9
|
+
const isLocalhost = process.env.NODE_ENV === 'localhost'
|
|
10
|
+
const sentryDSN = options.errorHandler.sentryDSN
|
|
11
|
+
// istanbul ignore next
|
|
12
|
+
const isSentryEnabled = sentryDSN && !isLocalhost
|
|
13
|
+
Sentry.init({
|
|
14
|
+
dsn: sentryDSN,
|
|
15
|
+
enabled: isSentryEnabled,
|
|
16
|
+
environment: process.env.NODE_ENV,
|
|
17
|
+
release: `${options.errorHandler.service}@${process.env.GIT_HASH ?? 'unknown'}`,
|
|
18
|
+
serverName: options.errorHandler.serverName
|
|
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
|
+
|
|
26
|
+
const returnErrorDetail = options.errorHandler.returnErrorDetail
|
|
27
|
+
// log any exception which occurs
|
|
28
|
+
fastify.setErrorHandler(async (error, req, reply) => {
|
|
29
|
+
// extract the relevant bit of the traceback: remove fastify lines
|
|
30
|
+
// istanbul ignore next
|
|
31
|
+
const traceback = error.stack?.split('\n') ?? []
|
|
32
|
+
// istanbul ignore next
|
|
33
|
+
const errorMessage = error.message?.split('\n') ?? ''
|
|
34
|
+
traceback.splice(0, errorMessage.length)
|
|
35
|
+
let removeFromIdx
|
|
36
|
+
if (error instanceof InvalidInputException) {
|
|
37
|
+
removeFromIdx = 1
|
|
38
|
+
} else {
|
|
39
|
+
for (let i = traceback.length - 1; i > 0; i--) {
|
|
40
|
+
const tbLine = traceback[i]
|
|
41
|
+
if (tbLine.indexOf('/fastify/lib') !== -1) {
|
|
42
|
+
removeFromIdx = i + 1
|
|
43
|
+
break
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
removeFromIdx = removeFromIdx ?? traceback.length
|
|
48
|
+
|
|
49
|
+
traceback.splice(removeFromIdx, traceback.length - removeFromIdx)
|
|
50
|
+
|
|
51
|
+
const response = reply.raw
|
|
52
|
+
/* istanbul ignore next */
|
|
53
|
+
const message = error.message || 'empty error message'
|
|
54
|
+
const statusCode = error.httpCode ?? error.statusCode ?? 500
|
|
55
|
+
reply.code(statusCode)
|
|
56
|
+
const errInfo = {
|
|
57
|
+
msg: message,
|
|
58
|
+
req,
|
|
59
|
+
status: statusCode,
|
|
60
|
+
stack: traceback
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// improve the error emitted from bad requests (invalid input)
|
|
64
|
+
const isCrash = errInfo.status >= 500
|
|
65
|
+
let customFingerprint = false
|
|
66
|
+
if (!isCrash) {
|
|
67
|
+
const firstTB = traceback[0]
|
|
68
|
+
/* istanbul ignore else */
|
|
69
|
+
if (firstTB) {
|
|
70
|
+
/* istanbul ignore else */
|
|
71
|
+
if (firstTB.indexOf('fastify/lib/contentTypeParser.js') !== -1) {
|
|
72
|
+
customFingerprint = 'Content-Type Not Permitted'
|
|
73
|
+
} else if (error instanceof S.ValidationError) {
|
|
74
|
+
customFingerprint = message
|
|
75
|
+
}
|
|
76
|
+
/* istanbul ignore next */
|
|
77
|
+
if (customFingerprint) {
|
|
78
|
+
if (customFingerprint.indexOf(errInfo.msg) === -1) {
|
|
79
|
+
// prefix the error message with the custom fingerprint text if the
|
|
80
|
+
// fingerprint didn't already contain all of the error message text
|
|
81
|
+
errInfo.msg = customFingerprint + ': ' + errInfo.msg
|
|
82
|
+
}
|
|
83
|
+
// changing the error name results in a cleaner description on the
|
|
84
|
+
// Sentry dashboard
|
|
85
|
+
error.name = customFingerprint
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
Object.getOwnPropertyNames(error).forEach(key => {
|
|
91
|
+
if (key !== 'stack' && key !== 'message') {
|
|
92
|
+
if (!errInfo.error) {
|
|
93
|
+
errInfo.error = {}
|
|
94
|
+
}
|
|
95
|
+
errInfo.error[key] = error[key]
|
|
96
|
+
}
|
|
97
|
+
})
|
|
98
|
+
response.logged = true // don't double-log
|
|
99
|
+
if (statusCode >= 500) {
|
|
100
|
+
reply.log.error(errInfo)
|
|
101
|
+
} else {
|
|
102
|
+
reply.log.info(errInfo)
|
|
103
|
+
}
|
|
104
|
+
|
|
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)
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const errorData = error.respData ?? {
|
|
164
|
+
code: error.constructor.name,
|
|
165
|
+
message: customFingerprint || error.message
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// istanbul ignore else
|
|
169
|
+
if (returnErrorDetail && !error.respData) {
|
|
170
|
+
errorData.detail = errInfo.msg
|
|
171
|
+
errorData.stack = errInfo.stack
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
await reply.header('Content-Type', 'application/json; charset=utf-8')
|
|
175
|
+
.serializer(o => JSON.stringify(o, null, 2))
|
|
176
|
+
.send(errorData)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
next()
|
|
180
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
|
|
3
|
+
export default fp(function (fastify, options, next) {
|
|
4
|
+
// istanbul ignore if
|
|
5
|
+
if (options.healthCheck.disabled) {
|
|
6
|
+
next()
|
|
7
|
+
return
|
|
8
|
+
}
|
|
9
|
+
// istanbul ignore next
|
|
10
|
+
const path = options.healthCheck.path ?? '/'
|
|
11
|
+
fastify.get(path, { schema: { hide: true } },
|
|
12
|
+
async (req, reply) => {
|
|
13
|
+
await reply.send()
|
|
14
|
+
})
|
|
15
|
+
next()
|
|
16
|
+
}, {
|
|
17
|
+
fastify: '>=3.x',
|
|
18
|
+
name: 'healthCheck'
|
|
19
|
+
})
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import fp from 'fastify-plugin'
|
|
2
|
+
const symbolRequestTime = Symbol('RequestTimer')
|
|
3
|
+
|
|
4
|
+
export default fp(function (fastify, options, next) {
|
|
5
|
+
// istanbul ignore if
|
|
6
|
+
if (options.latencyTracker.disabled) {
|
|
7
|
+
next()
|
|
8
|
+
return
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const header = options.latencyTracker.header
|
|
12
|
+
fastify.addHook('onRequest', function onRequestHandler (req, reply, done) {
|
|
13
|
+
// Start the recording of process time
|
|
14
|
+
req.raw[symbolRequestTime] = process.hrtime.bigint()
|
|
15
|
+
|
|
16
|
+
done()
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
fastify.addHook('onSend', async (req, reply, payload) => {
|
|
20
|
+
const fixedDigits = 3
|
|
21
|
+
// Calculate the duration, in nanoseconds
|
|
22
|
+
const hrDuration = process.hrtime.bigint() - req.raw[symbolRequestTime]
|
|
23
|
+
// convert it to milliseconds
|
|
24
|
+
const duration = (Number(hrDuration) / 1e6).toFixed(fixedDigits)
|
|
25
|
+
// add the header to the response
|
|
26
|
+
reply.header(header, duration)
|
|
27
|
+
return payload
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
next()
|
|
31
|
+
}, {
|
|
32
|
+
fastify: '>=3.x',
|
|
33
|
+
name: 'latency-tracker'
|
|
34
|
+
})
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// istanbul ignore file
|
|
2
|
+
import swagger from '@fastify/swagger'
|
|
3
|
+
import swaggerUI from '@fastify/swagger-ui'
|
|
4
|
+
import fp from 'fastify-plugin'
|
|
5
|
+
|
|
6
|
+
export default fp(function (fastify, options, next) {
|
|
7
|
+
if (options.swagger.disabled) {
|
|
8
|
+
next()
|
|
9
|
+
return
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const authHeaders = options.swagger.authHeaders
|
|
13
|
+
fastify.register(swagger, {
|
|
14
|
+
openapi: {
|
|
15
|
+
servers: options.swagger.servers.map(x => { return { url: x } }),
|
|
16
|
+
info: {
|
|
17
|
+
title: `${options.swagger.service.toUpperCase()} Service`
|
|
18
|
+
},
|
|
19
|
+
consumes: ['application/json'],
|
|
20
|
+
produces: ['application/json'],
|
|
21
|
+
components: {
|
|
22
|
+
securitySchemes: authHeaders.reduce((all, c) => {
|
|
23
|
+
all[c.replace('x-', '')] = {
|
|
24
|
+
type: 'apiKey',
|
|
25
|
+
name: c,
|
|
26
|
+
in: 'header'
|
|
27
|
+
}
|
|
28
|
+
return all
|
|
29
|
+
}, {})
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})
|
|
33
|
+
fastify.register(swaggerUI, {
|
|
34
|
+
routePrefix: options.swagger.routePrefix,
|
|
35
|
+
exposeRoute: true,
|
|
36
|
+
uiConfig: {
|
|
37
|
+
docExpansion: 'list',
|
|
38
|
+
deepLinking: false,
|
|
39
|
+
filter: true
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
// fastify.addHook('onReady', function (done) {
|
|
43
|
+
// fastify.swagger()
|
|
44
|
+
// done()
|
|
45
|
+
// })
|
|
46
|
+
next()
|
|
47
|
+
}, {
|
|
48
|
+
fastify: '>=3.x',
|
|
49
|
+
name: 'swagger'
|
|
50
|
+
})
|