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  }