Files
shields/core/server/server.js
Paul Melnikow dfc95ddd1a Add per-badge metrics for BaseService (#3093)
This picks up #2068 by adding per-badge stats as discussed in #966.

It ensures every service has a unique `name` property. By default this comes from the class name, and is overridden in all the various places where the class names are duplicated. (Some of those don't seem that useful, like the various download interval services, though those need to be refactored down into a single service anyway.) Tests enforce the names are unique. These are the names used by the service-test runner, so it's a good idea to make them unique anyway. (It was sort of strange before that you had to specify `nuget` instead of e.g. `resharper`.)

I've added validation to `deprecatedService` and `redirector`, and required that every `route` has a `base`, even if it's an empty string.

The name is used to generate unique metric labels, generating metrics like these:

```
service_requests_total{category="activity",family="eclipse-marketplace",service="eclipse_marketplace_update"} 2
service_requests_total{category="activity",family="npm",service="npm_collaborators"} 3
service_requests_total{category="activity",family="steam",service="steam_file_release_date"} 2
service_requests_total{category="analysis",family="ansible",service="ansible_galaxy_content_quality_score"} 2
service_requests_total{category="analysis",family="cii-best-practices",service="cii_best_practices_service"} 4
service_requests_total{category="analysis",family="cocoapods",service="cocoapods_docs"} 2
service_requests_total{category="analysis",family="codacy",service="codacy_grade"} 3
service_requests_total{category="analysis",family="coverity",service="coverity_scan"} 2
service_requests_total{category="analysis",family="coverity",service="deprecated_coverity_ondemand"} 2
service_requests_total{category="analysis",family="dependabot",service="dependabot_semver_compatibility"} 3
service_requests_total{category="analysis",family="lgtm",service="lgtm_alerts"} 2
service_requests_total{category="analysis",family="lgtm",service="lgtm_grade"} 3
service_requests_total{category="analysis",family="snyk",service="snyk_vulnerability_git_hub"} 4
service_requests_total{category="analysis",family="snyk",service="snyk_vulnerability_npm"} 5
service_requests_total{category="analysis",family="symfony",service="sensiolabs_i_redirector"} 1
service_requests_total{category="analysis",family="symfony",service="symfony_insight_grade"} 1
service_requests_total{category="build",family="appveyor",service="app_veyor_ci"} 3
service_requests_total{category="build",family="appveyor",service="app_veyor_tests"} 6
service_requests_total{category="build",family="azure-devops",service="azure_dev_ops_build"} 6
service_requests_total{category="build",family="azure-devops",service="azure_dev_ops_release"} 5
service_requests_total{category="build",family="azure-devops",service="azure_dev_ops_tests"} 6
service_requests_total{category="build",family="azure-devops",service="vso_build_redirector"} 2
service_requests_total{category="build",family="azure-devops",service="vso_release_redirector"} 1
service_requests_total{category="build",family="bitbucket",service="bitbucket_pipelines"} 5
service_requests_total{category="build",family="circleci",service="circle_ci"} 5
```

This is predicated on being able to use Prometheus's [`rate()`](https://prometheus.io/docs/prometheus/latest/querying/functions/#rate) function to visualize a counter's rate of change, as mentioned at https://github.com/badges/shields/issues/2068#issuecomment-466696561. Otherwise the stats will be disrupted every time a server restarts.

The metrics only appear on new-style services.
2019-02-27 18:58:59 -05:00

311 lines
7.9 KiB
JavaScript

'use strict'
const fs = require('fs')
const path = require('path')
const url = require('url')
const bytes = require('bytes')
const Joi = require('joi')
const Camp = require('camp')
const makeBadge = require('../../gh-badges/lib/make-badge')
const GithubConstellation = require('../../services/github/github-constellation')
const { makeBadgeData } = require('../../lib/badge-data')
const suggest = require('../../services/suggest')
const { loadServiceClasses } = require('../base-service/loader')
const { makeSend } = require('../base-service/legacy-result-sender')
const {
handleRequest,
clearRequestCache,
} = require('../base-service/legacy-request-handler')
const { clearRegularUpdateCache } = require('../../lib/regular-update')
const { staticBadgeUrl } = require('../badge-urls/make-badge-url')
const analytics = require('./analytics')
const log = require('./log')
const sysMonitor = require('./monitor')
const PrometheusMetrics = require('./prometheus-metrics')
const optionalUrl = Joi.string().uri({ scheme: ['http', 'https'] })
const requiredUrl = optionalUrl.required()
const notFound = fs.readFileSync(
path.resolve(__dirname, 'error-pages', '404.html'),
'utf-8'
)
const publicConfigSchema = Joi.object({
bind: {
port: Joi.number().port(),
address: Joi.alternatives().try(
Joi.string()
.ip()
.required(),
Joi.string()
.hostname()
.required()
),
},
metrics: {
prometheus: {
enabled: Joi.boolean().required(),
},
},
ssl: {
isSecure: Joi.boolean().required(),
key: Joi.string(),
cert: Joi.string(),
},
redirectUrl: optionalUrl,
cors: {
allowedOrigin: Joi.array()
.items(optionalUrl)
.required(),
},
persistence: {
dir: Joi.string().required(),
redisUrl: optionalUrl,
},
services: {
github: {
baseUri: requiredUrl,
debug: {
enabled: Joi.boolean().required(),
intervalSeconds: Joi.number()
.integer()
.min(1)
.required(),
},
},
trace: Joi.boolean().required(),
},
profiling: {
makeBadge: Joi.boolean().required(),
},
cacheHeaders: {
defaultCacheLengthSeconds: Joi.number()
.integer()
.required(),
},
rateLimit: Joi.boolean().required(),
handleInternalErrors: Joi.boolean().required(),
fetchLimit: Joi.string().regex(/^[0-9]+(b|kb|mb|gb|tb)$/i),
}).required()
const privateConfigSchema = Joi.object({
azure_devops_token: Joi.string(),
bintray_user: Joi.string(),
bintray_apikey: Joi.string(),
gh_client_id: Joi.string(),
gh_client_secret: Joi.string(),
gh_token: Joi.string(),
jenkins_user: Joi.string(),
jenkins_pass: Joi.string(),
jira_user: Joi.string(),
jira_pass: Joi.string(),
nexus_user: Joi.string(),
nexus_pass: Joi.string(),
npm_token: Joi.string(),
sentry_dsn: Joi.string(),
shields_ips: Joi.array().items(Joi.string().ip()),
shields_secret: Joi.string(),
sl_insight_userUuid: Joi.string(),
sl_insight_apiToken: Joi.string(),
sonarqube_token: Joi.string(),
wheelmap_token: Joi.string(),
}).required()
module.exports = class Server {
constructor(config) {
const publicConfig = Joi.attempt(config.public, publicConfigSchema)
let privateConfig
try {
privateConfig = Joi.attempt(config.private, privateConfigSchema)
} catch (e) {
const badPaths = e.details.map(({ path }) => path)
throw Error(
`Private configuration is invalid. Check these paths: ${badPaths.join(
','
)}`
)
}
this.config = {
public: publicConfig,
private: privateConfig,
}
this.githubConstellation = new GithubConstellation({
persistence: publicConfig.persistence,
service: publicConfig.services.github,
})
if (publicConfig.metrics.prometheus.enabled) {
this.metrics = new PrometheusMetrics()
}
}
get port() {
const {
port,
ssl: { isSecure },
} = this.config.public
return port || (isSecure ? 443 : 80)
}
get baseUrl() {
const {
bind: { address, port },
ssl: { isSecure },
} = this.config.public
return url.format({
protocol: isSecure ? 'https' : 'http',
hostname: address,
port,
pathname: '/',
})
}
registerErrorHandlers() {
const { camp } = this
camp.notfound(/\.(svg|png|gif|jpg|json)/, (query, match, end, request) => {
const format = match[1]
const badgeData = makeBadgeData('404', query)
badgeData.text[1] = 'badge not found'
badgeData.colorB = 'red'
// Add format to badge data.
badgeData.format = format
const svg = makeBadge(badgeData)
makeSend(format, request.res, end)(svg)
})
camp.notfound(/.*/, (query, match, end, request) => {
end(notFound)
})
}
registerServices() {
const { config, camp } = this
const { apiProvider: githubApiProvider } = this.githubConstellation
const { requestCounter } = this.metrics || {}
loadServiceClasses().forEach(serviceClass =>
serviceClass.register(
{ camp, handleRequest, githubApiProvider, requestCounter },
{
handleInternalErrors: config.public.handleInternalErrors,
cacheHeaders: config.public.cacheHeaders,
profiling: config.public.profiling,
fetchLimitBytes: bytes(config.public.fetchLimit),
}
)
)
}
registerRedirects() {
const { config, camp } = this
// Any badge, old version. This route must be registered last.
camp.route(/^\/([^/]+)\/(.+).png$/, (queryParams, match, end, ask) => {
const [, label, message] = match
const { color } = queryParams
const redirectUrl = staticBadgeUrl({
label,
message,
color,
format: 'png',
})
ask.res.statusCode = 301
ask.res.setHeader('Location', redirectUrl)
// The redirect is permanent.
const cacheDuration = (365 * 24 * 3600) | 0 // 1 year
ask.res.setHeader('Cache-Control', `max-age=${cacheDuration}`)
ask.res.end()
})
if (config.public.redirectUrl) {
camp.route(/^\/$/, (data, match, end, ask) => {
ask.res.statusCode = 302
ask.res.setHeader('Location', config.public.redirectUrl)
ask.res.end()
})
}
}
async start() {
const {
bind: { port, address: hostname },
ssl: { isSecure: secure, cert, key },
cors: { allowedOrigin },
rateLimit,
} = this.config.public
log(`Server is starting up: ${this.baseUrl}`)
const camp = (this.camp = Camp.start({
documentRoot: path.resolve(__dirname, '..', '..', 'public'),
port,
hostname,
secure,
cert,
key,
}))
analytics.load()
analytics.scheduleAutosaving()
analytics.setRoutes(camp)
this.cleanupMonitor = sysMonitor.setRoutes({ rateLimit }, camp)
const { githubConstellation, metrics } = this
githubConstellation.initialize(camp)
if (metrics) {
metrics.initialize(camp)
}
const { apiProvider: githubApiProvider } = this.githubConstellation
suggest.setRoutes(allowedOrigin, githubApiProvider, camp)
this.registerErrorHandlers()
this.registerServices()
this.registerRedirects()
await new Promise(resolve => camp.on('listening', () => resolve()))
}
static resetGlobalState() {
// This state should be migrated to instance state. When possible, do not add new
// global state.
clearRequestCache()
clearRegularUpdateCache()
}
reset() {
this.constructor.resetGlobalState()
}
async stop() {
if (this.camp) {
await new Promise(resolve => this.camp.close(resolve))
this.camp = undefined
}
if (this.cleanupMonitor) {
this.cleanupMonitor()
this.cleanupMonitor = undefined
}
if (this.githubConstellation) {
await this.githubConstellation.stop()
this.githubConstellation = undefined
}
if (this.metrics) {
this.metrics.stop()
}
analytics.cancelAutosaving()
}
}