github.com/ethereum-optimism/optimism@v1.7.2/packages/common-ts/src/base-service/base-service-v2.ts (about) 1 import { Server } from 'net' 2 3 import Config from 'bcfg' 4 import * as dotenv from 'dotenv' 5 import { Command, Option } from 'commander' 6 import { cleanEnv } from 'envalid' 7 import snakeCase from 'lodash/snakeCase' 8 import express from 'express' 9 import prometheus, { Registry } from 'prom-client' 10 import promBundle from 'express-prom-bundle' 11 import bodyParser from 'body-parser' 12 import morgan from 'morgan' 13 14 import { ExpressRouter } from './router' 15 import { Logger } from '../common/logger' 16 import { 17 Metrics, 18 MetricsSpec, 19 StandardMetrics, 20 makeStdMetricsSpec, 21 } from './metrics' 22 import { 23 Options, 24 OptionsSpec, 25 StandardOptions, 26 stdOptionsSpec, 27 getPublicOptions, 28 } from './options' 29 30 /** 31 * BaseServiceV2 is an advanced but simple base class for long-running TypeScript services. 32 */ 33 export abstract class BaseServiceV2< 34 TOptions extends Options, 35 TMetrics extends Metrics, 36 TServiceState 37 > { 38 /** 39 * The timeout that controls the polling interval 40 * If clearTimeout(this.pollingTimeout) is called the timeout will stop 41 */ 42 private pollingTimeout: NodeJS.Timeout 43 44 /** 45 * The promise representing this.main 46 */ 47 private mainPromise: ReturnType<typeof this.main> 48 49 /** 50 * Whether or not the service will loop. 51 */ 52 protected loop: boolean 53 54 /** 55 * Waiting period in ms between loops, if the service will loop. 56 */ 57 protected loopIntervalMs: number 58 59 /** 60 * Whether or not the service is currently running. 61 */ 62 protected running: boolean 63 64 /** 65 * Whether or not the service is currently healthy. 66 */ 67 protected healthy: boolean 68 69 /** 70 * Logger class for this service. 71 */ 72 protected logger: Logger 73 74 /** 75 * Service state, persisted between loops. 76 */ 77 protected state: TServiceState 78 79 /** 80 * Service options. 81 */ 82 protected readonly options: TOptions & StandardOptions 83 84 /** 85 * Metrics. 86 */ 87 protected readonly metrics: TMetrics & StandardMetrics 88 89 /** 90 * Registry for prometheus metrics. 91 */ 92 protected readonly metricsRegistry: Registry 93 94 /** 95 * App server. 96 */ 97 protected server: Server 98 99 /** 100 * Port for the app server. 101 */ 102 protected readonly port: number 103 104 /** 105 * Hostname for the app server. 106 */ 107 protected readonly hostname: string 108 109 /** 110 * @param params Options for the construction of the service. 111 * @param params.name Name for the service. 112 * @param params.optionsSpec Settings for input options. 113 * @param params.metricsSpec Settings that define which metrics are collected. 114 * @param params.options Options to pass to the service. 115 * @param params.loops Whether or not the service should loop. Defaults to true. 116 * @param params.useEnv Whether or not to load options from the environment. Defaults to true. 117 * @param params.useArgv Whether or not to load options from the command line. Defaults to true. 118 */ 119 constructor( 120 private readonly params: { 121 name: string 122 version: string 123 optionsSpec: OptionsSpec<TOptions> 124 metricsSpec: MetricsSpec<TMetrics> 125 options?: Partial<TOptions & StandardOptions> 126 loop?: boolean 127 bodyParserParams?: bodyParser.OptionsJson 128 } 129 ) { 130 this.loop = params.loop !== undefined ? params.loop : true 131 this.state = {} as TServiceState 132 133 // Add standard options spec to user options spec. 134 ;(params.optionsSpec as any) = { 135 ...params.optionsSpec, 136 ...stdOptionsSpec, 137 } 138 139 // Add default metrics to metrics spec. 140 ;(params.metricsSpec as any) = { 141 ...params.metricsSpec, 142 ...makeStdMetricsSpec(params.optionsSpec), 143 } 144 145 /** 146 * Special snake_case function which accounts for the common strings "L1" and "L2" which would 147 * normally be split into "L_1" and "L_2" by the snake_case function. 148 * 149 * @param str String to convert to snake_case. 150 * @returns snake_case string. 151 */ 152 const opSnakeCase = (str: string) => { 153 const reg = /l_1|l_2/g 154 const repl = str.includes('l1') ? 'l1' : 'l2' 155 return snakeCase(str).replace(reg, repl) 156 } 157 158 // Use commander as a way to communicate info about the service. We don't actually *use* 159 // commander for anything besides the ability to run `tsx ./service.ts --help`. 160 const program = new Command().allowUnknownOption(true) 161 for (const [optionName, optionSpec] of Object.entries(params.optionsSpec)) { 162 // Skip options that are not meant to be used by the user. 163 if (['useEnv', 'useArgv'].includes(optionName)) { 164 continue 165 } 166 167 program.addOption( 168 new Option(`--${optionName.toLowerCase()}`, `${optionSpec.desc}`).env( 169 `${opSnakeCase( 170 params.name.replace(/-/g, '_') 171 ).toUpperCase()}__${opSnakeCase(optionName).toUpperCase()}` 172 ) 173 ) 174 } 175 176 const longestMetricNameLength = Object.keys(params.metricsSpec).reduce( 177 (acc, key) => { 178 const nameLength = snakeCase(key).length 179 if (nameLength > acc) { 180 return nameLength 181 } else { 182 return acc 183 } 184 }, 185 0 186 ) 187 188 program.addHelpText( 189 'after', 190 `\nMetrics:\n${Object.entries(params.metricsSpec) 191 .map(([metricName, metricSpec]) => { 192 const parsedName = opSnakeCase(metricName) 193 return ` ${parsedName}${' '.repeat( 194 longestMetricNameLength - parsedName.length + 2 195 )}${metricSpec.desc} (type: ${metricSpec.type.name})` 196 }) 197 .join('\n')} 198 ` 199 ) 200 201 // Load all configuration values from the environment and argv. 202 program.parse() 203 dotenv.config() 204 const config = new Config(params.name) 205 config.load({ 206 env: params.options?.useEnv ?? true, 207 argv: params.options?.useEnv ?? true, 208 }) 209 210 // Clean configuration values using the options spec. 211 // Since BCFG turns everything into lower case, we're required to turn all of the input option 212 // names into lower case for the validation step. We'll turn the names back into their original 213 // names when we're done. 214 const lowerCaseOptions = Object.entries(params.options).reduce( 215 (acc, [key, val]) => { 216 acc[key.toLowerCase()] = val 217 return acc 218 }, 219 {} 220 ) 221 const cleaned = cleanEnv<TOptions>( 222 { ...config.env, ...config.args, ...(lowerCaseOptions || {}) }, 223 Object.entries(params.optionsSpec || {}).reduce((acc, [key, val]) => { 224 acc[key.toLowerCase()] = val.validator({ 225 desc: val.desc, 226 default: val.default, 227 }) 228 return acc 229 }, {}) as any 230 ) 231 232 // Turn the lowercased option names back into camelCase. 233 this.options = Object.keys(params.optionsSpec || {}).reduce((acc, key) => { 234 acc[key] = cleaned[key.toLowerCase()] 235 return acc 236 }, {}) as TOptions 237 238 // Make sure all options are defined. 239 for (const [optionName, optionSpec] of Object.entries(params.optionsSpec)) { 240 if ( 241 optionSpec.default === undefined && 242 this.options[optionName] === undefined 243 ) { 244 throw new Error(`missing required option: ${optionName}`) 245 } 246 } 247 248 // Create the metrics objects. 249 this.metrics = Object.keys(params.metricsSpec || {}).reduce((acc, key) => { 250 const spec = params.metricsSpec[key] 251 acc[key] = new spec.type({ 252 name: `${opSnakeCase(params.name)}_${opSnakeCase(key)}`, 253 help: spec.desc, 254 labelNames: spec.labels || [], 255 }) 256 return acc 257 }, {}) as TMetrics & StandardMetrics 258 259 // Create the metrics server. 260 this.metricsRegistry = prometheus.register 261 this.port = this.options.port 262 this.hostname = this.options.hostname 263 264 // Set up everything else. 265 this.healthy = true 266 this.loopIntervalMs = this.options.loopIntervalMs 267 this.logger = new Logger({ 268 name: params.name, 269 level: this.options.logLevel, 270 }) 271 272 // Gracefully handle stop signals. 273 const maxSignalCount = 3 274 let currSignalCount = 0 275 const stop = async (signal: string) => { 276 // Allow exiting fast if more signals are received. 277 currSignalCount++ 278 if (currSignalCount === 1) { 279 this.logger.info(`stopping service with signal`, { signal }) 280 await this.stop() 281 process.exit(0) 282 } else if (currSignalCount >= maxSignalCount) { 283 this.logger.info(`performing hard stop`) 284 process.exit(0) 285 } else { 286 this.logger.info( 287 `send ${maxSignalCount - currSignalCount} more signal(s) to hard stop` 288 ) 289 } 290 } 291 292 // Handle stop signals. 293 process.on('SIGTERM', stop) 294 process.on('SIGINT', stop) 295 296 // Set metadata synthetic metric. 297 this.metrics.metadata.set( 298 { 299 name: params.name, 300 version: params.version, 301 ...getPublicOptions(params.optionsSpec).reduce((acc, key) => { 302 if (key in stdOptionsSpec) { 303 acc[key] = this.options[key].toString() 304 } else { 305 acc[key] = config.str(key) 306 } 307 return acc 308 }, {}), 309 }, 310 1 311 ) 312 313 // Collect default node metrics. 314 prometheus.collectDefaultMetrics({ 315 register: this.metricsRegistry, 316 labels: { name: params.name, version: params.version }, 317 }) 318 } 319 320 /** 321 * Runs the main function. If this service is set up to loop, will repeatedly loop around the 322 * main function. Will also catch unhandled errors. 323 */ 324 public async run(): Promise<void> { 325 // Start the app server if not yet running. 326 if (!this.server) { 327 this.logger.info('starting app server') 328 329 // Start building the app. 330 const app = express() 331 332 // Body parsing. 333 app.use(bodyParser.urlencoded({ extended: true })) 334 335 // Keep the raw body around in case the application needs it. 336 app.use( 337 bodyParser.json({ 338 verify: (req, res, buf, encoding) => { 339 ;(req as any).rawBody = 340 buf?.toString((encoding as BufferEncoding) || 'utf8') || '' 341 }, 342 ...(this.params.bodyParserParams ?? {}), 343 }) 344 ) 345 346 // Logging. 347 app.use( 348 morgan('short', { 349 stream: { 350 write: (str: string) => { 351 this.logger.info(`server log`, { 352 log: str, 353 }) 354 }, 355 }, 356 }) 357 ) 358 359 // Health status. 360 app.get('/healthz', async (req, res) => { 361 return res.json({ 362 ok: this.healthy, 363 version: this.params.version, 364 }) 365 }) 366 367 // Register user routes. 368 const router = express.Router() 369 if (this.routes) { 370 this.routes(router) 371 } 372 373 // Metrics. 374 // Will expose a /metrics endpoint by default. 375 app.use( 376 promBundle({ 377 promRegistry: this.metricsRegistry, 378 includeMethod: true, 379 includePath: true, 380 includeStatusCode: true, 381 normalizePath: (req) => { 382 for (const layer of router.stack) { 383 if (layer.route && req.path.match(layer.regexp)) { 384 return layer.route.path 385 } 386 } 387 388 return '/invalid_path_not_a_real_route' 389 }, 390 }) 391 ) 392 393 app.use('/api', router) 394 395 // Wait for server to come up. 396 await new Promise((resolve) => { 397 this.server = app.listen(this.port, this.hostname, () => { 398 resolve(null) 399 }) 400 }) 401 402 this.logger.info(`app server started`, { 403 port: this.port, 404 hostname: this.hostname, 405 }) 406 } 407 408 if (this.init) { 409 this.logger.info('initializing service') 410 await this.init() 411 this.logger.info('service initialized') 412 } 413 414 if (this.loop) { 415 this.logger.info('starting main loop') 416 this.running = true 417 418 const doLoop = async () => { 419 try { 420 this.mainPromise = this.main() 421 await this.mainPromise 422 } catch (err) { 423 this.metrics.unhandledErrors.inc() 424 this.logger.error('caught an unhandled exception', { 425 message: err.message, 426 stack: err.stack, 427 code: err.code, 428 }) 429 } 430 431 // Sleep between loops if we're still running (service not stopped). 432 if (this.running) { 433 this.pollingTimeout = setTimeout(doLoop, this.loopIntervalMs) 434 } 435 } 436 doLoop() 437 } else { 438 this.logger.info('running main function') 439 await this.main() 440 } 441 } 442 443 /** 444 * Tries to gracefully stop the service. Service will continue running until the current loop 445 * iteration is finished and will then stop looping. 446 */ 447 public async stop(): Promise<void> { 448 this.logger.info('stopping main loop...') 449 this.running = false 450 clearTimeout(this.pollingTimeout) 451 this.logger.info('waiting for main to complete') 452 // if main is in the middle of running wait for it to complete 453 await this.mainPromise 454 this.logger.info('main loop stopped.') 455 456 // Shut down the metrics server if it's running. 457 if (this.server) { 458 this.logger.info('stopping metrics server') 459 await new Promise((resolve) => { 460 this.server.close(() => { 461 resolve(null) 462 }) 463 }) 464 this.logger.info('metrics server stopped') 465 this.server = undefined 466 } 467 } 468 469 /** 470 * Initialization function. Runs once before the main function. 471 */ 472 protected init?(): Promise<void> 473 474 /** 475 * Initialization function for router. 476 * 477 * @param router Express router. 478 */ 479 protected routes?(router: ExpressRouter): Promise<void> 480 481 /** 482 * Main function. Runs repeatedly when run() is called. 483 */ 484 protected abstract main(): Promise<void> 485 }