Skip to content

Commit

Permalink
feat(cmn): have BaseService expose a full server (#2725)
Browse files Browse the repository at this point in the history
Updates BaseServiceV2 to expose a full server instead of simply exposing
the server for metrics. Services can now add new custom routes to this
server. Mainly useful because we're already running the metrics server,
so we might as well allow people to add more things to it.
  • Loading branch information
smartcontracts committed Jun 9, 2022
1 parent 6648fc9 commit d9e3993
Show file tree
Hide file tree
Showing 4 changed files with 233 additions and 46 deletions.
5 changes: 5 additions & 0 deletions .changeset/five-fireants-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@eth-optimism/common-ts': minor
---

Minor upgrade to BaseServiceV2 to expose a full customizable server, instead of just metrics.
9 changes: 7 additions & 2 deletions packages/common-ts/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,21 +34,26 @@
"@eth-optimism/core-utils": "0.8.6",
"@sentry/node": "^6.3.1",
"bcfg": "^0.1.7",
"body-parser": "^1.20.0",
"commander": "^9.0.0",
"dotenv": "^16.0.0",
"envalid": "^7.2.2",
"ethers": "^5.6.8",
"express": "^4.17.1",
"express-prom-bundle": "^6.4.1",
"lodash": "^4.17.21",
"morgan": "^1.10.0",
"pino": "^6.11.3",
"pino-multi-stream": "^5.3.0",
"pino-sentry": "^0.7.0",
"prom-client": "^13.1.0"
"prom-client": "^13.1.0",
"qs": "^6.10.5"
},
"devDependencies": {
"@ethersproject/abstract-provider": "^5.6.1",
"@ethersproject/abstract-signer": "^5.6.2",
"@types/express": "^4.17.12",
"@types/express": "^4.17.13",
"@types/morgan": "^1.9.3",
"@types/pino": "^6.3.6",
"@types/pino-multi-stream": "^5.1.1",
"chai": "^4.3.4",
Expand Down
141 changes: 99 additions & 42 deletions packages/common-ts/src/base-service/base-service-v2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,11 @@ import { Command, Option } from 'commander'
import { ValidatorSpec, Spec, cleanEnv } from 'envalid'
import { sleep } from '@eth-optimism/core-utils'
import snakeCase from 'lodash/snakeCase'
import express from 'express'
import express, { Router } from 'express'
import prometheus, { Registry } from 'prom-client'
import promBundle from 'express-prom-bundle'
import bodyParser from 'body-parser'
import morgan from 'morgan'

import { Logger } from '../common/logger'
import { Metric } from './metrics'
Expand All @@ -19,8 +22,8 @@ export type Options = {

export type StandardOptions = {
loopIntervalMs?: number
metricsServerPort?: number
metricsServerHostname?: string
port?: number
hostname?: string
}

export type OptionsSpec<TOptions extends Options> = {
Expand All @@ -43,6 +46,8 @@ export type MetricsSpec<TMetrics extends MetricsV2> = {
}
}

export type ExpressRouter = Router

/**
* BaseServiceV2 is an advanced but simple base class for long-running TypeScript services.
*/
Expand Down Expand Up @@ -71,6 +76,11 @@ export abstract class BaseServiceV2<
*/
protected done: boolean

/**
* Whether or not the service is currently healthy.
*/
protected healthy: boolean

/**
* Logger class for this service.
*/
Expand All @@ -97,19 +107,19 @@ export abstract class BaseServiceV2<
protected readonly metricsRegistry: Registry

/**
* Metrics server.
* App server.
*/
protected metricsServer: Server
protected server: Server

/**
* Port for the metrics server.
* Port for the app server.
*/
protected readonly metricsServerPort: number
protected readonly port: number

/**
* Hostname for the metrics server.
* Hostname for the app server.
*/
protected readonly metricsServerHostname: string
protected readonly hostname: string

/**
* @param params Options for the construction of the service.
Expand All @@ -122,8 +132,8 @@ export abstract class BaseServiceV2<
* @param params.options Options to pass to the service.
* @param params.loops Whether or not the service should loop. Defaults to true.
* @param params.loopIntervalMs Loop interval in milliseconds. Defaults to zero.
* @param params.metricsServerPort Port for the metrics server. Defaults to 7300.
* @param params.metricsServerHostname Hostname for the metrics server. Defaults to 0.0.0.0.
* @param params.port Port for the app server. Defaults to 7300.
* @param params.hostname Hostname for the app server. Defaults to 0.0.0.0.
*/
constructor(params: {
name: string
Expand All @@ -132,8 +142,8 @@ export abstract class BaseServiceV2<
options?: Partial<TOptions>
loop?: boolean
loopIntervalMs?: number
metricsServerPort?: number
metricsServerHostname?: string
port?: number
hostname?: string
}) {
this.loop = params.loop !== undefined ? params.loop : true
this.state = {} as TServiceState
Expand All @@ -148,15 +158,15 @@ export abstract class BaseServiceV2<
desc: 'Loop interval in milliseconds',
default: params.loopIntervalMs || 0,
},
metricsServerPort: {
port: {
validator: validators.num,
desc: 'Port for the metrics server',
default: params.metricsServerPort || 7300,
desc: 'Port for the app server',
default: params.port || 7300,
},
metricsServerHostname: {
hostname: {
validator: validators.str,
desc: 'Hostname for the metrics server',
default: params.metricsServerHostname || '0.0.0.0',
desc: 'Hostname for the app server',
default: params.hostname || '0.0.0.0',
},
}

Expand Down Expand Up @@ -268,12 +278,13 @@ export abstract class BaseServiceV2<

// Create the metrics server.
this.metricsRegistry = prometheus.register
this.metricsServerPort = this.options.metricsServerPort
this.metricsServerHostname = this.options.metricsServerHostname
this.port = this.options.port
this.hostname = this.options.hostname

// Set up everything else.
this.loopIntervalMs = this.options.loopIntervalMs
this.logger = new Logger({ name: params.name })
this.healthy = true

// Gracefully handle stop signals.
const maxSignalCount = 3
Expand Down Expand Up @@ -307,30 +318,69 @@ export abstract class BaseServiceV2<
public async run(): Promise<void> {
this.done = false

// Start the metrics server if not yet running.
if (!this.metricsServer) {
this.logger.info('starting metrics server')
// Start the app server if not yet running.
if (!this.server) {
this.logger.info('starting app server')

// Start building the app.
const app = express()

// Body parsing.
app.use(bodyParser.json())
app.use(bodyParser.urlencoded({ extended: true }))

// Logging.
app.use(
morgan((tokens, req, res) => {
return [
tokens.method(req, res),
tokens.url(req, res),
tokens.status(req, res),
JSON.stringify(req.body),
'\n',
tokens.res(req, res, 'content-length'),
'-',
tokens['response-time'](req, res),
'ms',
].join(' ')
})
)

await new Promise((resolve) => {
const app = express()
// Metrics.
// Will expose a /metrics endpoint by default.
app.use(
promBundle({
promRegistry: this.metricsRegistry,
includeMethod: true,
includePath: true,
includeStatusCode: true,
})
)

app.get('/metrics', async (_, res) => {
res.status(200).send(await this.metricsRegistry.metrics())
// Health status.
app.get('/healthz', async (req, res) => {
return res.json({
ok: this.healthy,
})
})

this.metricsServer = app.listen(
this.metricsServerPort,
this.metricsServerHostname,
() => {
resolve(null)
}
)
// Registery user routes.
if (this.routes) {
const router = express.Router()
this.routes(router)
app.use('/api', router)
}

// Wait for server to come up.
await new Promise((resolve) => {
this.server = app.listen(this.port, this.hostname, () => {
resolve(null)
})
})

this.logger.info(`metrics started`, {
port: this.metricsServerPort,
hostname: this.metricsServerHostname,
route: '/metrics',
this.logger.info(`app server started`, {
port: this.port,
hostname: this.hostname,
})
}

Expand Down Expand Up @@ -381,15 +431,15 @@ export abstract class BaseServiceV2<
}

// Shut down the metrics server if it's running.
if (this.metricsServer) {
if (this.server) {
this.logger.info('stopping metrics server')
await new Promise((resolve) => {
this.metricsServer.close(() => {
this.server.close(() => {
resolve(null)
})
})
this.logger.info('metrics server stopped')
this.metricsServer = undefined
this.server = undefined
}
}

Expand All @@ -398,6 +448,13 @@ export abstract class BaseServiceV2<
*/
protected init?(): Promise<void>

/**
* Initialization function for router.
*
* @param router Express router.
*/
protected routes?(router: ExpressRouter): Promise<void>

/**
* Main function. Runs repeatedly when run() is called.
*/
Expand Down

0 comments on commit d9e3993

Please sign in to comment.