Repository: badges/shields
Pull request #3093 (2019-02-24 01:25:06)

Add per-badge metrics for BaseService
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 a unique metric name, which causes metrics to be generated like these:

```
# HELP service_amo_downloads_requests_total Total requests for AmoDownloads service
# TYPE service_amo_downloads_requests_total counter
service_amo_downloads_requests_total 0

# HELP service_amo_rating_requests_total Total requests for AmoRating service
# TYPE service_amo_rating_requests_total counter
service_amo_rating_requests_total 0

# HELP service_amo_users_requests_total Total requests for AmoUsers service
# TYPE service_amo_users_requests_total counter
service_amo_users_requests_total 0

# HELP service_amo_version_requests_total Total requests for AmoVersion service
# TYPE service_amo_version_requests_total counter
service_amo_version_requests_total 0

# HELP service_ansible_galaxy_content_quality_score_requests_total Total requests for AnsibleGalaxyContentQualityScore service
# TYPE service_ansible_galaxy_content_quality_score_requests_total counter
service_ansible_galaxy_content_quality_score_requests_total 0

# HELP service_ansible_galaxy_role_downloads_requests_total Total requests for AnsibleGalaxyRoleDownloads service
# TYPE service_ansible_galaxy_role_downloads_requests_total counter
service_ansible_galaxy_role_downloads_requests_total 0

# HELP service_ansible_galaxy_role_name_requests_total Total requests for AnsibleGalaxyRoleName service
# TYPE service_ansible_galaxy_role_name_requests_total counter
service_ansible_galaxy_role_name_requests_total 0

# HELP service_apm_downloads_requests_total Total requests for APMDownloads service
# TYPE service_apm_downloads_requests_total counter
service_apm_downloads_requests_total 0

# HELP service_apm_version_requests_total Total requests for APMVersion service
# TYPE service_apm_version_requests_total counter
service_apm_version_requests_total 0
```

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 are only used on new-style services, but I'm okay with that. They'll be ported soon enough.

shields-ci: 
<!--
  0 failure: 
  21 warning:  This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., This PR modified ..., Found 'assert' st..., This PR modified ...
  1 messages
  
  DangerID: danger-id-default;
-->


<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Warnings</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>bitbucket</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>eclipse-marketplace</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>gratipay</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>hexpm</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>dub</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>npm</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>packagecontrol</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>powershellgallery</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>puppetforge</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>resharper</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>discourse</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>snap-ci</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>conda</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>static-badge</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>cocoapods</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>vaadin-directory</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>clojars</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>wordpress</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>chrome-web-store</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>

  Found 'assert' statement added in `core/base-service/route.js`. <br>Please ensure tests are written using Chai [expect syntax](http://chaijs.com/guide/styles/#expect)
  </td>
    </tr>
  
<tr>
      <td>:warning:</td>
      <td>This PR modified service code for <kbd>jetbrains</kbd> but not its test code. <br>That's okay so long as it's refactoring existing code.</td>
    </tr>
  </tbody>
</table>


<table>
  <thead>
    <tr>
      <th width="50"></th>
      <th width="100%" data-danger-table="true">Messages</th>
    </tr>
  </thead>
  <tbody><tr>
      <td>:book:</td>
      <td>:sparkles: Thanks for your contribution to Shields, @paulmelnikow!</td>
    </tr>
  </tbody>
</table>


<p align="right">
  Generated by :no_entry_sign: <a href="https://danger.systems/js">dangerJS</a> against 728ad84e97f2d18bee00fc84f05221b8738bfb83
</p>

paulmelnikow: @platan Would you be able to help me test this with a Prometheus instance?
platan: > @platan Would you be able to help me test this with a Prometheus instance?

Just deploy your change somewhere, give me an address and I will add it to http://metrics.shields.io/ or https://metrics-test.shields.platan.space. Alternatively, you can test it locally using https://github.com/platan/metrics-shields-io-config (I'm still working on this, but it should be useful :-), just change configuration https://github.com/platan/metrics-shields-io-config/blob/feceb7fbeb15ed63d32e88d013ff6690131081df/shields-io-metrics.yml#L29)
platan: [Labes](https://prometheus.io/docs/practices/naming/#labels) is a powerful feature of Prometheus. 
Instead of:
```
service_amo_downloads_requests_total 0
service_amo_rating_requests_total 0
service_amo_users_requests_total 0
service_amo_version_requests_total 0
service_ansible_galaxy_content_quality_score_requests_total 0
service_ansible_galaxy_role_downloads_requests_total 0
service_ansible_galaxy_role_name_requests_total 0
service_apm_downloads_requests_total 0
service_apm_version_requests_total 0
```
we can have one metric with different labels:
```
service_requests_total {service="amo", metric="downloads_week", category="downloads"} 0
service_requests_total {service="amo", metric="rating", category="rating"} 0
service_requests_total {service="amo", metric="users", category="downloads"} 0
service_requests_total {service="amo", metric="version", category="version"} 0
service_requests_total {service="ansible_galaxy", metric="content_quality_score", category="analysis"} 0
service_requests_total {service="ansible_galaxy", metric="role", category="downloads"} 0
service_requests_total {service="ansible_galaxy", metric="role_name", category="other"} 0
service_requests_total {service="apm", metric="downloads", category="downloads"} 0
service_requests_total {service="apm", metric="version", category="version"} 0
```

Every badge has a corresponding metric for total number of requests to this badge with labels:
- name - service name
- category - categories from https://shields.io/
- metric - there are services with several badges in one category: downloads week, downloads month
 
This allows us to aggregate metrics using those labels. 
paulmelnikow: Let me take another pass through and use that!
paulmelnikow: Here's what I've got now:

```
# HELP service_requests_total Total service requests
# TYPE service_requests_total counter
service_requests_total{category="downloads",family="amo",service="amo_downloads"} 1
service_requests_total{category="version",family="npm",service="npm_version"} 4
```
platan: I've added `METRICS_PROMETHEUS_ENABLED=true` to shields-staging-pr-3093. Metrics are available https://shields-staging-pr-3093.herokuapp.com/metrics
platan: I've added https://shields-staging-pr-3093.herokuapp.com/metrics to https://metrics-test.shields.platan.space/prometheus/graph (username:password: prometheus:prometheus). Sample graph: https://metrics-test.shields.platan.space/prometheus/graph?g0.range_input=1h&g0.expr=service_requests_total&g0.tab=0
paulmelnikow: @platan How often does it poll?

It looks like some metrics are getting dropped when the dyno gets re-deployed.
paulmelnikow: Here's a graph that uses `rate()`, showing requests per minute:

https://metrics-test.shields.platan.space/prometheus/graph?g0.range_input=1h&g0.expr=rate(service_requests_total%5B1m%5D)&g0.tab=0
platan: Metrics are pooled (scraped) every 15 seconds: https://metrics-test.shields.platan.space/prometheus/config, https://metrics-test.shields.platan.space/prometheus/targets. 
I my opinion everything is OK with metrics and redeploys. 
I've made a load with `while true; do curl https://shields-staging-pr-3093.herokuapp.com/badge/test-me-red.svg; sleep 1; done` and restarted this instance several times: 
![screenshot_2019-02-25 prometheus time series collection and processing server](https://user-images.githubusercontent.com/1526000/53364093-56e34000-393e-11e9-803a-df08893cf286.png)

platan: I can review this PR tomorrow. 
calebcartwright: I left a few minor questions/feedback items as well 👍 
platan: Using:
```bash
METRICS_PROMETHEUS_ENABLED=true npm start
SKIP_INTERCEPTED=TRUE npm run test:services --
curl http://localhost:8080/metrics -s | grep "service_requests_total{" | sort
```
I've gathered metrics for all(?) services:

<details>
<summary>metrics</summary>

```
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
service_requests_total{category="build",family="dockbit",service="deprecated_dockbit"} 2
service_requests_total{category="build",family="docker",service="docker_automated_build"} 3
service_requests_total{category="build",family="docker",service="docker_build"} 2
service_requests_total{category="build",family="gitlab",service="gitlab_pipeline_status"} 5
service_requests_total{category="build",family="magnumci",service="deprecated_magnumci_ci"} 1
service_requests_total{category="build",family="readthedocs",service="read_the_docs"} 5
service_requests_total{category="build",family="shippable",service="shippable"} 4
service_requests_total{category="build",family="snap-ci",service="deprecated_snap_ci"} 1
service_requests_total{category="build",family="teamcity",service="team_city_build"} 4
service_requests_total{category="build",family="travis",service="travis_build"} 5
service_requests_total{category="build",family="wercker",service="wercker"} 6
service_requests_total{category="chat",family="discord",service="discord"} 2
service_requests_total{category="chat",family="discourse",service="discourse_likes"} 1
service_requests_total{category="chat",family="discourse",service="discourse_posts"} 1
service_requests_total{category="chat",family="discourse",service="discourse_status"} 1
service_requests_total{category="chat",family="discourse",service="discourse_topics"} 1
service_requests_total{category="chat",family="discourse",service="discourse_users"} 1
service_requests_total{category="chat",family="gitter",service="gitter"} 1
service_requests_total{category="chat",family="matrix",service="matrix"} 2
service_requests_total{category="chat",family="stackexchange",service="stack_exchange_monthly_questions"} 2
service_requests_total{category="chat",family="stackexchange",service="stack_exchange_questions"} 2
service_requests_total{category="chat",family="stackexchange",service="stack_exchange_reputation"} 3
service_requests_total{category="coverage",family="azure-devops",service="azure_dev_ops_coverage"} 3
service_requests_total{category="coverage",family="codacy",service="codacy_coverage"} 4
service_requests_total{category="coverage",family="jenkins",service="cobertura_jenkins_coverage"} 2
service_requests_total{category="coverage",family="jenkins",service="jacoco_jenkins_coverage"} 1
service_requests_total{category="coverage",family="teamcity",service="team_city_coverage"} 4
service_requests_total{category="debug",family="debug",service="debug"} 3
service_requests_total{category="dependencies",family="bithound",service="deprecated_bithound"} 3
service_requests_total{category="dependencies",family="depfu",service="depfu"} 2
service_requests_total{category="dependencies",family="dotnetstatus",service="deprecated_dotnetstatus"} 1
service_requests_total{category="dependencies",family="gemnasium",service="deprecated_gemnasium"} 1
service_requests_total{category="dependencies",family="hackage",service="hackage_deps"} 2
service_requests_total{category="dependencies",family="requires",service="requires_io"} 3
service_requests_total{category="downloads",family="amo",service="amo/d_redirector"} 1
service_requests_total{category="downloads",family="amo",service="amo_users"} 2
service_requests_total{category="downloads",family="amo",service="amo_weekly_downloads"} 2
service_requests_total{category="downloads",family="ansible",service="ansible_galaxy_role_downloads"} 2
service_requests_total{category="downloads",family="apm",service="apm_downloads"} 2
service_requests_total{category="downloads",family="chocolatey",service="chocolatey_downloads"} 2
service_requests_total{category="downloads",family="clojars",service="clojars_downloads"} 2
service_requests_total{category="downloads",family="cocoapods",service="cocoapods_downloads"} 4
service_requests_total{category="downloads",family="conda",service="conda_downloads"} 4
service_requests_total{category="downloads",family="crates",service="crates_downloads"} 6
service_requests_total{category="downloads",family="docker",service="docker_pulls"} 3
service_requests_total{category="downloads",family="dub",service="dub_downloads_day"} 1
service_requests_total{category="downloads",family="dub",service="dub_downloads_month"} 1
service_requests_total{category="downloads",family="dub",service="dub_downloads_total"} 4
service_requests_total{category="downloads",family="dub",service="dub_downloads_week"} 1
service_requests_total{category="downloads",family="eclipse-marketplace",service="eclipse_marketplace_downloads_month"} 1
service_requests_total{category="downloads",family="eclipse-marketplace",service="eclipse_marketplace_downloads_total"} 2
service_requests_total{category="downloads",family="gem",service="gem_downloads"} 10
service_requests_total{category="downloads",family="gem",service="gem_rank"} 3
service_requests_total{category="downloads",family="hexpm",service="hex_pm_downloads_day"} 1
service_requests_total{category="downloads",family="hexpm",service="hex_pm_downloads_total"} 2
service_requests_total{category="downloads",family="hexpm",service="hex_pm_downloads_week"} 1
service_requests_total{category="downloads",family="jenkins",service="jenkins_plugin_installs"} 6
service_requests_total{category="downloads",family="jetbrains",service="jetbrains_downloads"} 4
service_requests_total{category="downloads",family="jsdelivr",service="js_delivr_hits_git_hub"} 5
service_requests_total{category="downloads",family="jsdelivr",service="js_delivr_hits_npm"} 5
service_requests_total{category="downloads",family="myget",service="my_get_download_service"} 3
service_requests_total{category="downloads",family="npm",service="npm_downloads_total"} 3
service_requests_total{category="downloads",family="nuget",service="nuget_download_service"} 2
service_requests_total{category="downloads",family="packagecontrol",service="package_control_downloads_day"} 1
service_requests_total{category="downloads",family="packagecontrol",service="package_control_downloads_month"} 1
service_requests_total{category="downloads",family="packagecontrol",service="package_control_downloads_total"} 2
service_requests_total{category="downloads",family="packagecontrol",service="package_control_downloads_week"} 1
service_requests_total{category="downloads",family="powershellgallery",service="powershell_gallery_downloads"} 2
service_requests_total{category="downloads",family="pypi",service="pypi_downloads"} 7
service_requests_total{category="downloads",family="resharper",service="resharper_plugin_downloads"} 2
service_requests_total{category="downloads",family="spiget",service="spiget_downloads"} 2
service_requests_total{category="downloads",family="steam",service="steam_file_downloads"} 2
service_requests_total{category="downloads",family="versioneye",service="deprecated_versioneye"} 1
service_requests_total{category="downloads",family="visual-studio-marketplace",service="visual_studio_marketplace_azure_dev_ops_installs"} 4
service_requests_total{category="downloads",family="visual-studio-marketplace",service="visual_studio_marketplace_downloads"} 6
service_requests_total{category="downloads",family="wordpress",service="wordpress_downloads_day"} 2
service_requests_total{category="downloads",family="wordpress",service="wordpress_downloads_month"} 2
service_requests_total{category="downloads",family="wordpress",service="wordpress_downloads_week"} 2
service_requests_total{category="downloads",family="wordpress",service="wordpress_plugin_downloads"} 2
service_requests_total{category="downloads",family="wordpress",service="wordpress_plugin_installs"} 2
service_requests_total{category="downloads",family="wordpress",service="wordpress_theme_downloads"} 2
service_requests_total{category="downloads",family="wordpress",service="wordpress_theme_installs"} 2
service_requests_total{category="dynamic",family="dynamic",service="dynamic_json"} 12
service_requests_total{category="dynamic",family="dynamic",service="dynamic_xml"} 13
service_requests_total{category="dynamic",family="dynamic",service="dynamic_yaml"} 11
service_requests_total{category="dynamic",family="endpoint",service="endpoint"} 2
service_requests_total{category="funding",family="beerpay",service="beerpay"} 2
service_requests_total{category="funding",family="bountysource",service="bountysource"} 2
service_requests_total{category="funding",family="codetally",service="codetally"} 2
service_requests_total{category="funding",family="leanpub",service="leanpub_book_summary_service"} 3
service_requests_total{category="funding",family="liberapay",service="liberapay_gives"} 2
service_requests_total{category="funding",family="liberapay",service="liberapay_goal"} 2
service_requests_total{category="funding",family="liberapay",service="liberapay_patrons"} 2
service_requests_total{category="funding",family="liberapay",service="liberapay_receives"} 2
service_requests_total{category="funding",family="opencollective",service="opencollective_all"} 2
service_requests_total{category="funding",family="opencollective",service="opencollective_backers"} 2
service_requests_total{category="funding",family="opencollective",service="opencollective_by_tier"} 2
service_requests_total{category="funding",family="opencollective",service="opencollective_sponsors"} 2
service_requests_total{category="issue-tracking",family="bitbucket",service="bitbucket_issues"} 3
service_requests_total{category="issue-tracking",family="bitbucket",service="bitbucket_issues_raw"} 3
service_requests_total{category="issue-tracking",family="bitbucket",service="bitbucket_pull_request"} 3
service_requests_total{category="issue-tracking",family="bitbucket",service="bitbucket_pull_request_raw"} 3
service_requests_total{category="issue-tracking",family="bugzilla",service="bugzilla"} 2
service_requests_total{category="issue-tracking",family="issuestats",service="deprecated_issuestats"} 2
service_requests_total{category="issue-tracking",family="jira",service="jira_issue"} 2
service_requests_total{category="issue-tracking",family="jira",service="jira_sprint"} 2
service_requests_total{category="license",family="apm",service="apm_license"} 2
service_requests_total{category="license",family="aur",service="aur_license"} 2
service_requests_total{category="license",family="bower",service="bower_license"} 2
service_requests_total{category="license",family="cocoapods",service="cocoapods_license"} 2
service_requests_total{category="license",family="cpan",service="cpan_license"} 2
service_requests_total{category="license",family="cran",service="cran_license"} 2
service_requests_total{category="license",family="crates",service="crates_license"} 3
service_requests_total{category="license",family="ctan",service="ctan_license"} 1
service_requests_total{category="license",family="dub",service="dub_license"} 2
service_requests_total{category="license",family="eclipse-marketplace",service="eclipse_marketplace_license"} 2
service_requests_total{category="license",family="hexpm",service="hex_pm_license"} 2
service_requests_total{category="license",family="npm",service="npm_license"} 8
service_requests_total{category="license",family="pypi",service="pypi_license"} 3
service_requests_total{category="monitoring",family="hsts",service="hsts"} 3
service_requests_total{category="monitoring",family="mozilla-observatory",service="mozilla_observatory"} 2
service_requests_total{category="monitoring",family="nodeping",service="node_ping_status"} 1
service_requests_total{category="monitoring",family="nodeping",service="node_ping_uptime"} 1
service_requests_total{category="monitoring",family="uptimerobot",service="uptime_robot_ratio"} 4
service_requests_total{category="monitoring",family="uptimerobot",service="uptime_robot_status"} 3
service_requests_total{category="other",family="ansible",service="ansible_galaxy_role_name"} 2
service_requests_total{category="other",family="bstats",service="b_stats_players"} 1
service_requests_total{category="other",family="bstats",service="b_stats_servers"} 1
service_requests_total{category="other",family="cauditor",service="deprecated_cauditor"} 1
service_requests_total{category="other",family="cocoapods",service="cocoapods_apps"} 3
service_requests_total{category="other",family="date",service="date"} 2
service_requests_total{category="other",family="eclipse-marketplace",service="eclipse_marketplace_favorites"} 2
service_requests_total{category="other",family="endpoint",service="badge/endpoint_redirector"} 1
service_requests_total{category="other",family="gem",service="gem_owner"} 2
service_requests_total{category="other",family="github",service="dynamic_github_manifest"} 3
service_requests_total{category="other",family="github",service="dynamic_github_package_json"} 4
service_requests_total{category="other",family="librariesio",service="libraries_io_dependent_repos"} 2
service_requests_total{category="other",family="librariesio",service="libraries_io_dependents"} 3
service_requests_total{category="other",family="maintenance",service="maintenance"} 5
service_requests_total{category="other",family="nsp",service="deprecated_nsp_npm"} 4
service_requests_total{category="other",family="osslifecycle",service="oss_tracker"} 3
service_requests_total{category="other",family="pypi",service="pypi_format"} 5
service_requests_total{category="other",family="pypi",service="pypi_status"} 4
service_requests_total{category="other",family="sourcegraph",service="sourcegraph"} 2
service_requests_total{category="other",family="static-badge",service="query_string_static_badge"} 6
service_requests_total{category="other",family="static-badge",service="static_badge"} 14
service_requests_total{category="other",family="steam",service="steam_collection_size"} 2
service_requests_total{category="other",family="steam",service="steam_file_views"} 2
service_requests_total{category="platform-support",family="cocoapods",service="cocoapods_platform"} 2
service_requests_total{category="platform-support",family="conda",service="conda_platform"} 2
service_requests_total{category="platform-support",family="github",service="github_package_json_dependency_version"} 6
service_requests_total{category="platform-support",family="node",service="node_version"} 6
service_requests_total{category="platform-support",family="npm",service="npm_dependency_version"} 5
service_requests_total{category="platform-support",family="npm",service="npm_type_definitions"} 4
service_requests_total{category="platform-support",family="powershellgallery",service="powershell_gallery_platform_support"} 3
service_requests_total{category="platform-support",family="pypi",service="pypi_django_versions"} 4
service_requests_total{category="platform-support",family="pypi",service="pypi_implementation"} 4
service_requests_total{category="platform-support",family="pypi",service="pypi_python_versions"} 6
service_requests_total{category="platform-support",family="pypi",service="pypi_wheel"} 4
service_requests_total{category="platform-support",family="spiget",service="spiget_tested_versions"} 2
service_requests_total{category="platform-support",family="wordpress",service="wordpress_plugin_requires_version"} 2
service_requests_total{category="platform-support",family="wordpress",service="wordpress_plugin_tested_version"} 2
service_requests_total{category="platform-support",family="wordpress",service="wordpress_plugin_tested_version_alias"} 2
service_requests_total{category="rating",family="amo",service="amo_rating"} 3
service_requests_total{category="rating",family="aur",service="aur_votes"} 2
service_requests_total{category="rating",family="docker",service="docker_stars"} 3
service_requests_total{category="rating",family="librariesio",service="libraries_io_sourcerank"} 2
service_requests_total{category="rating",family="libscore",service="deprecated_libscore"} 1
service_requests_total{category="rating",family="redmine",service="redmine_plugin_rating"} 2
service_requests_total{category="rating",family="redmine",service="redmine_plugin_stars"} 1
service_requests_total{category="rating",family="spiget",service="spiget_ratings"} 4
service_requests_total{category="rating",family="steam",service="steam_file_favorites"} 2
service_requests_total{category="rating",family="steam",service="steam_file_subscriptions"} 2
service_requests_total{category="rating",family="visual-studio-marketplace",service="visual_studio_marketplace_rating"} 4
service_requests_total{category="rating",family="wordpress",service="wordpress_plugin_stars"} 4
service_requests_total{category="rating",family="wordpress",service="wordpress_theme_stars"} 4
service_requests_total{category="size",family="imagelayers",service="deprecated_imagelayers"} 2
service_requests_total{category="size",family="spiget",service="spiget_download_size"} 2
service_requests_total{category="size",family="steam",service="steam_file_size"} 2
service_requests_total{category="social",family="keybase",service="keybase_btc"} 4
service_requests_total{category="social",family="keybase",service="keybase_pgp"} 4
service_requests_total{category="social",family="keybase",service="keybase_xlm"} 4
service_requests_total{category="social",family="keybase",service="keybase_zec"} 4
service_requests_total{category="version",family="amo",service="amo_version"} 2
service_requests_total{category="version",family="apm",service="apm_version"} 2
service_requests_total{category="version",family="aur",service="aur_version"} 3
service_requests_total{category="version",family="bintray",service="bintray"} 2
service_requests_total{category="version",family="bower",service="bower_version"} 4
service_requests_total{category="version",family="cdnjs",service="cdnjs"} 2
service_requests_total{category="version",family="chocolatey",service="chocolatey_version"} 4
service_requests_total{category="version",family="clojars",service="clojars_version"} 2
service_requests_total{category="version",family="cocoapods",service="cocoapods_version"} 2
service_requests_total{category="version",family="conda",service="conda_version"} 2
service_requests_total{category="version",family="cookbook",service="cookbook"} 2
service_requests_total{category="version",family="cpan",service="cpan_version"} 2
service_requests_total{category="version",family="cran",service="cran_version"} 2
service_requests_total{category="version",family="crates",service="crates_version"} 2
service_requests_total{category="version",family="ctan",service="ctan_version"} 1
service_requests_total{category="version",family="dub",service="dub_version"} 2
service_requests_total{category="version",family="eclipse-marketplace",service="eclipse_marketplace_version"} 2
service_requests_total{category="version",family="elm-package",service="elm_package"} 2
service_requests_total{category="version",family="f-droid",service="f_droid"} 2
service_requests_total{category="version",family="gem",service="gem_version"} 2
service_requests_total{category="version",family="github",service="github_manifest_version"} 2
service_requests_total{category="version",family="github",service="github_package_json_version"} 2
service_requests_total{category="version",family="github",service="github/release_redirector"} 1
service_requests_total{category="version",family="hackage",service="hackage_version"} 2
service_requests_total{category="version",family="hexpm",service="hex_pm_version"} 2
service_requests_total{category="version",family="homebrew",service="homebrew"} 2
service_requests_total{category="version",family="itunes",service="itunes"} 3
service_requests_total{category="version",family="jetbrains",service="jetbrains_version"} 4
service_requests_total{category="version",family="jitpack",service="jit_pack_version"} 2
service_requests_total{category="version",family="luarocks",service="luarocks"} 5
service_requests_total{category="version",family="maven-metadata",service="maven_metadata"} 2
service_requests_total{category="version",family="myget",service="my_get_version_service"} 5
service_requests_total{category="version",family="nexus",service="nexus"} 8
service_requests_total{category="version",family="npm",service="npm_version"} 8
service_requests_total{category="version",family="nuget",service="nuget_version_service"} 4
service_requests_total{category="version",family="powershellgallery",service="powershell_gallery_version"} 4
service_requests_total{category="version",family="pub",service="pub_version"} 3
service_requests_total{category="version",family="pypi",service="pypi_version"} 3
service_requests_total{category="version",family="resharper",service="resharper_plugin_version"} 4
service_requests_total{category="version",family="spiget",service="spiget_latest_version"} 2
service_requests_total{category="version",family="visual-studio-marketplace",service="visual_studio_marketplace_version"} 2
service_requests_total{category="version",family="wordpress",service="wordpress_plugin_version"} 2
service_requests_total{category="version",family="wordpress",service="wordpress_theme_version"} 2
```

</details>
paulmelnikow: Thanks for the reviews! I've updated this.

Patch:
diff --git a/core/base-service/base-non-memory-caching.js b/core/base-service/base-non-memory-caching.js
index db457ae58a..0b442e5aa5 100644
--- a/core/base-service/base-non-memory-caching.js
+++ b/core/base-service/base-non-memory-caching.js
@@ -24,11 +24,15 @@ const { prepareRoute, namedParamsForMatch } = require('./route')
 // configured by the service, the user's request, and the server's default
 // cache length.
 module.exports = class NonMemoryCachingBaseService extends BaseService {
-  static register({ camp }, serviceConfig) {
+  static register({ camp, requestCounter }, serviceConfig) {
     const { cacheHeaders: cacheHeaderConfig } = serviceConfig
     const { _cacheLength: serviceDefaultCacheLengthSeconds } = this
     const { regex, captureNames } = prepareRoute(this.route)
 
+    const serviceRequestCounter = this._createServiceRequestCounter({
+      requestCounter,
+    })
+
     camp.route(regex, async (queryParams, match, end, ask) => {
       const namedParams = namedParamsForMatch(captureNames, match, this)
       const serviceData = await this.invoke(
@@ -59,6 +63,8 @@ module.exports = class NonMemoryCachingBaseService extends BaseService {
       })
 
       makeSend(format, ask.res, end)(svg)
+
+      serviceRequestCounter.inc()
     })
   }
 }
diff --git a/core/base-service/base-static.js b/core/base-service/base-static.js
index 094040a935..d784054765 100644
--- a/core/base-service/base-static.js
+++ b/core/base-service/base-static.js
@@ -12,12 +12,16 @@ const coalesceBadge = require('./coalesce-badge')
 const { prepareRoute, namedParamsForMatch } = require('./route')
 
 module.exports = class BaseStaticService extends BaseService {
-  static register({ camp }, serviceConfig) {
+  static register({ camp, requestCounter }, serviceConfig) {
     const {
       profiling: { makeBadge: shouldProfileMakeBadge },
     } = serviceConfig
     const { regex, captureNames } = prepareRoute(this.route)
 
+    const serviceRequestCounter = this._createServiceRequestCounter({
+      requestCounter,
+    })
+
     camp.route(regex, async (queryParams, match, end, ask) => {
       analytics.noteRequest(queryParams, match)
 
@@ -58,6 +62,8 @@ module.exports = class BaseStaticService extends BaseService {
       setCacheHeadersForStaticResource(ask.res)
 
       makeSend(format, ask.res, end)(svg)
+
+      serviceRequestCounter.inc()
     })
   }
 }
diff --git a/core/base-service/base.js b/core/base-service/base.js
index 9da4d61685..f8460958d1 100644
--- a/core/base-service/base.js
+++ b/core/base-service/base.js
@@ -1,5 +1,6 @@
 'use strict'
 
+const decamelize = require('decamelize')
 // See available emoji at http://emoji.muan.co/
 const emojic = require('emojic')
 const Joi = require('joi')
@@ -317,11 +318,29 @@ module.exports = class BaseService {
     return serviceData
   }
 
-  static register({ camp, handleRequest, githubApiProvider }, serviceConfig) {
+  static _createServiceRequestCounter({ requestCounter }) {
+    if (requestCounter) {
+      const { category, serviceFamily, name } = this
+      const service = decamelize(name)
+      return requestCounter.labels(category, serviceFamily, service)
+    } else {
+      // When metrics are disabled, return a mock counter.
+      return { inc: () => {} }
+    }
+  }
+
+  static register(
+    { camp, handleRequest, githubApiProvider, requestCounter },
+    serviceConfig
+  ) {
     const { cacheHeaders: cacheHeaderConfig, fetchLimitBytes } = serviceConfig
     const { regex, captureNames } = prepareRoute(this.route)
     const queryParams = getQueryParamNames(this.route)
 
+    const serviceRequestCounter = this._createServiceRequestCounter({
+      requestCounter,
+    })
+
     camp.route(
       regex,
       handleRequest(cacheHeaderConfig, {
@@ -348,6 +367,8 @@ module.exports = class BaseService {
           // The final capture group is the extension.
           const format = match.slice(-1)[0]
           sendBadge(format, badgeData)
+
+          serviceRequestCounter.inc()
         },
         cacheLength: this._cacheLength,
         fetchLimitBytes,
diff --git a/core/base-service/categories.js b/core/base-service/categories.js
index 1068ab3d95..36caaa6c91 100644
--- a/core/base-service/categories.js
+++ b/core/base-service/categories.js
@@ -14,5 +14,6 @@ function assertValidCategory(category, message = undefined) {
 }
 
 module.exports = {
+  isValidCategory,
   assertValidCategory,
 }
diff --git a/core/base-service/deprecated-service.js b/core/base-service/deprecated-service.js
index b774f6dc51..f81c2014a5 100644
--- a/core/base-service/deprecated-service.js
+++ b/core/base-service/deprecated-service.js
@@ -1,19 +1,40 @@
 'use strict'
 
 const Joi = require('joi')
+const camelcase = require('camelcase')
 const BaseService = require('./base')
+const { isValidCategory } = require('./categories')
 const { Deprecated } = require('./errors')
+const { isValidRoute } = require('./route')
+
+const attrSchema = Joi.object({
+  route: isValidRoute,
+  name: Joi.string(),
+  label: Joi.string(),
+  category: isValidCategory,
+  // The content of examples is validated later, via `transformExamples()`.
+  examples: Joi.array().default([]),
+  message: Joi.string(),
+  dateAdded: Joi.date().required(),
+}).required()
+
+function deprecatedService(attrs) {
+  const { route, name, label, category, examples, message } = Joi.attempt(
+    attrs,
+    attrSchema,
+    `Deprecated service for ${attrs.route.base}`
+  )
 
-// Only `url` is required.
-function deprecatedService({
-  route,
-  label,
-  category,
-  examples = [],
-  message,
-  dateAdded,
-}) {
   return class DeprecatedService extends BaseService {
+    static get name() {
+      return (
+        name ||
+        `Deprecated${camelcase(route.base.replace(/\//g, '_'), {
+          pascalCase: true,
+        })}`
+      )
+    }
+
     static get category() {
       return category
     }
@@ -26,17 +47,6 @@ function deprecatedService({
       return true
     }
 
-    static validateDefinition() {
-      super.validateDefinition()
-      Joi.assert(
-        { dateAdded },
-        Joi.object({
-          dateAdded: Joi.date().required(),
-        }),
-        `Deprecated service for ${route.base}`
-      )
-    }
-
     static get defaultBadgeData() {
       return { label }
     }
diff --git a/core/base-service/deprecated-service.spec.js b/core/base-service/deprecated-service.spec.js
index 03c2682132..d448bfde45 100644
--- a/core/base-service/deprecated-service.spec.js
+++ b/core/base-service/deprecated-service.spec.js
@@ -5,29 +5,36 @@ const deprecatedService = require('./deprecated-service')
 
 describe('DeprecatedService', function() {
   const route = {
-    base: 'coverity/ondemand',
+    base: 'service/that/no/longer/exists',
     format: '(?:.+)',
   }
+  const category = 'analysis'
+  const dateAdded = new Date()
+  const commonAttrs = { route, category, dateAdded }
 
   it('returns true on isDeprecated', function() {
-    const service = deprecatedService({ route })
+    const service = deprecatedService({ ...commonAttrs })
     expect(service.isDeprecated).to.be.true
   })
 
+  it('has the expected name', function() {
+    const service = deprecatedService({ ...commonAttrs })
+    expect(service.name).to.equal('DeprecatedServiceThatNoLongerExists')
+  })
+
   it('sets specified route', function() {
-    const service = deprecatedService({ route })
+    const service = deprecatedService({ ...commonAttrs })
     expect(service.route).to.deep.equal(route)
   })
 
   it('sets specified label', function() {
     const label = 'coverity'
-    const service = deprecatedService({ route, label })
+    const service = deprecatedService({ ...commonAttrs, label })
     expect(service.defaultBadgeData.label).to.equal(label)
   })
 
   it('sets specified category', function() {
-    const category = 'analysis'
-    const service = deprecatedService({ route, category })
+    const service = deprecatedService({ ...commonAttrs })
     expect(service.category).to.equal(category)
   })
 
@@ -37,12 +44,12 @@ describe('DeprecatedService', function() {
         title: 'Not sure we would have examples',
       },
     ]
-    const service = deprecatedService({ route, examples })
+    const service = deprecatedService({ ...commonAttrs, examples })
     expect(service.examples).to.deep.equal(examples)
   })
 
   it('uses default deprecation message when no message specified', async function() {
-    const service = deprecatedService({ route })
+    const service = deprecatedService({ ...commonAttrs })
     expect(await service.invoke()).to.deep.equal({
       isError: true,
       color: 'lightgray',
@@ -52,7 +59,7 @@ describe('DeprecatedService', function() {
 
   it('uses custom deprecation message when specified', async function() {
     const message = 'extended outage'
-    const service = deprecatedService({ route, message })
+    const service = deprecatedService({ ...commonAttrs, message })
     expect(await service.invoke()).to.deep.equal({
       isError: true,
       color: 'lightgray',
diff --git a/core/base-service/loader.js b/core/base-service/loader.js
index 6b31bb1eb5..de442ff12e 100644
--- a/core/base-service/loader.js
+++ b/core/base-service/loader.js
@@ -2,6 +2,7 @@
 
 const path = require('path')
 const glob = require('glob')
+const countBy = require('lodash.countby')
 const categories = require('../../services/categories')
 const BaseService = require('./base')
 const { assertValidServiceDefinitionExport } = require('./service-definitions')
@@ -20,35 +21,46 @@ function loadServiceClasses(servicePaths) {
     servicePaths = glob.sync(path.join(serviceDir, '**', '*.service.js'))
   }
 
-  const serviceClasses = []
-  servicePaths.forEach(path => {
-    const module = require(path)
+  let serviceClasses = []
+  servicePaths.forEach(servicePath => {
+    const module = require(servicePath)
+
+    const theseServiceClasses = []
     if (
       !module ||
       (module.constructor === Array && module.length === 0) ||
       (module.constructor === Object && Object.keys(module).length === 0)
     ) {
       throw new InvalidService(
-        `Expected ${path} to export a service or a collection of services`
+        `Expected ${servicePath} to export a service or a collection of services`
       )
     } else if (module.prototype instanceof BaseService) {
-      serviceClasses.push(module)
+      theseServiceClasses.push(module)
     } else if (module.constructor === Array || module.constructor === Object) {
       for (const key in module) {
         const serviceClass = module[key]
         if (serviceClass.prototype instanceof BaseService) {
-          serviceClasses.push(serviceClass)
+          theseServiceClasses.push(serviceClass)
         } else {
           throw new InvalidService(
-            `Expected ${path} to export a service or a collection of services; one of them was ${serviceClass}`
+            `Expected ${servicePath} to export a service or a collection of services; one of them was ${serviceClass}`
           )
         }
       }
     } else {
       throw new InvalidService(
-        `Expected ${path} to export a service or a collection of services; got ${module}`
+        `Expected ${servicePath} to export a service or a collection of services; got ${module}`
       )
     }
+
+    // Decorate each service class with the directory that contains it.
+    theseServiceClasses.forEach(serviceClass => {
+      serviceClass.serviceFamily = servicePath
+        .replace(serviceDir, '')
+        .split(path.sep)[1]
+    })
+
+    serviceClasses = serviceClasses.concat(theseServiceClasses)
   })
 
   serviceClasses.forEach(ServiceClass => ServiceClass.validateDefinition())
@@ -56,6 +68,25 @@ function loadServiceClasses(servicePaths) {
   return serviceClasses
 }
 
+function assertNamesUnique(names, { message }) {
+  const duplicates = {}
+  Object.entries(countBy(names))
+    .filter(([name, count]) => count > 1)
+    .forEach(([name, count]) => {
+      duplicates[name] = count
+    })
+  if (Object.keys(duplicates).length) {
+    throw new Error(`${message}: ${JSON.stringify(duplicates, undefined, 2)}`)
+  }
+}
+
+function checkNames() {
+  const services = loadServiceClasses()
+  assertNamesUnique(services.map(({ name }) => name), {
+    message: 'Duplicate service names found',
+  })
+}
+
 function collectDefinitions() {
   const services = loadServiceClasses()
     // flatMap.
@@ -78,6 +109,7 @@ function loadTesters() {
 module.exports = {
   InvalidService,
   loadServiceClasses,
+  checkNames,
   collectDefinitions,
   loadTesters,
 }
diff --git a/core/base-service/redirector.js b/core/base-service/redirector.js
index 07798d67e4..ff7843f103 100644
--- a/core/base-service/redirector.js
+++ b/core/base-service/redirector.js
@@ -1,5 +1,6 @@
 'use strict'
 
+const camelcase = require('camelcase')
 const emojic = require('emojic')
 const Joi = require('joi')
 const BaseService = require('./base')
@@ -7,10 +8,30 @@ const {
   serverHasBeenUpSinceResourceCached,
   setCacheHeadersForStaticResource,
 } = require('./cache-headers')
-const { prepareRoute, namedParamsForMatch } = require('./route')
+const { isValidCategory } = require('./categories')
+const { isValidRoute, prepareRoute, namedParamsForMatch } = require('./route')
 const trace = require('./trace')
 
-module.exports = function redirector({ category, route, target, dateAdded }) {
+const attrSchema = Joi.object({
+  category: isValidCategory,
+  route: isValidRoute,
+  target: Joi.func()
+    .maxArity(1)
+    .required()
+    .error(
+      () =>
+        '"target" must be a function that transforms named params to a new path'
+    ),
+  dateAdded: Joi.date().required(),
+}).required()
+
+module.exports = function redirector(attrs) {
+  const { category, route, target } = Joi.attempt(
+    attrs,
+    attrSchema,
+    `Redirector for ${attrs.route.base}`
+  )
+
   return class Redirector extends BaseService {
     static get category() {
       return category
@@ -24,20 +45,19 @@ module.exports = function redirector({ category, route, target, dateAdded }) {
       return true
     }
 
-    static validateDefinition() {
-      super.validateDefinition()
-      Joi.assert(
-        { dateAdded },
-        Joi.object({
-          dateAdded: Joi.date().required(),
-        }),
-        `Redirector for ${route.base}`
-      )
+    static get name() {
+      return `${camelcase(route.base.replace(/\//g, '_'), {
+        pascalCase: true,
+      })}Redirect`
     }
 
-    static register({ camp }) {
+    static register({ camp, requestCounter }) {
       const { regex, captureNames } = prepareRoute(this.route)
 
+      const serviceRequestCounter = this._createServiceRequestCounter({
+        requestCounter,
+      })
+
       camp.route(regex, async (queryParams, match, end, ask) => {
         if (serverHasBeenUpSinceResourceCached(ask.req)) {
           // Send Not Modified.
@@ -72,6 +92,8 @@ module.exports = function redirector({ category, route, target, dateAdded }) {
         setCacheHeadersForStaticResource(ask.res)
 
         ask.res.end()
+
+        serviceRequestCounter.inc()
       })
     }
   }
diff --git a/core/base-service/redirector.spec.js b/core/base-service/redirector.spec.js
index 6fb81c5f54..163604c628 100644
--- a/core/base-service/redirector.spec.js
+++ b/core/base-service/redirector.spec.js
@@ -13,17 +13,17 @@ describe('Redirector', function() {
   }
   const category = 'analysis'
   const target = () => {}
-  const attrs = {
-    category,
-    route,
-    target,
-    dateAdded: new Date(),
-  }
+  const dateAdded = new Date()
+  const attrs = { category, route, target, dateAdded }
 
   it('returns true on isDeprecated', function() {
     expect(redirector(attrs).isDeprecated).to.be.true
   })
 
+  it('has the expected name', function() {
+    expect(redirector(attrs).name).to.equal('VeryOldServiceRedirect')
+  })
+
   it('sets specified route', function() {
     expect(redirector(attrs).route).to.deep.equal(route)
   })
@@ -60,8 +60,8 @@ describe('Redirector', function() {
     const target = ({ namedParamA }) => `/new/service/${namedParamA}`
 
     beforeEach(function() {
-      const ServiceClass = redirector({ route, target })
-      ServiceClass.register({ camp })
+      const ServiceClass = redirector({ category, route, target, dateAdded })
+      ServiceClass.register({ camp }, {})
     })
 
     it('should redirect as configured', async function() {
diff --git a/core/base-service/route.js b/core/base-service/route.js
index 711d7d5ca4..688b7ef430 100644
--- a/core/base-service/route.js
+++ b/core/base-service/route.js
@@ -7,8 +7,10 @@ function makeFullUrl(base, partialUrl) {
   return `/${[base, partialUrl].filter(Boolean).join('/')}`
 }
 
-const routeSchema = Joi.object({
-  base: Joi.string().allow(''),
+const isValidRoute = Joi.object({
+  base: Joi.string()
+    .allow('')
+    .required(),
   pattern: Joi.string().allow(''),
   format: Joi.string(),
   capture: Joi.alternatives().when('format', {
@@ -23,7 +25,7 @@ const routeSchema = Joi.object({
   .required()
 
 function assertValidRoute(route, message = undefined) {
-  Joi.assert(route, routeSchema, message)
+  Joi.assert(route, isValidRoute, message)
 }
 
 function prepareRoute({ base, pattern, format, capture }) {
@@ -80,6 +82,7 @@ function getQueryParamNames({ queryParams = [], queryParamSchema }) {
 
 module.exports = {
   makeFullUrl,
+  isValidRoute,
   assertValidRoute,
   prepareRoute,
   namedParamsForMatch,
diff --git a/core/server/prometheus-metrics.js b/core/server/prometheus-metrics.js
index 939dd31944..9acd4ccd47 100644
--- a/core/server/prometheus-metrics.js
+++ b/core/server/prometheus-metrics.js
@@ -3,25 +3,31 @@
 const prometheus = require('prom-client')
 
 module.exports = class PrometheusMetrics {
-  constructor(config = {}) {
-    this.enabled = config.enabled || false
-    if (this.enabled) {
-      console.log('Metrics are enabled.')
-    }
+  constructor() {
+    this.register = new prometheus.Registry()
+    this.requestCounter = new prometheus.Counter({
+      name: 'service_requests_total',
+      help: 'Total service requests',
+      labelNames: ['category', 'family', 'service'],
+      registers: [this.register],
+    })
   }
 
   async initialize(server) {
-    if (this.enabled) {
-      const register = prometheus.register
-      prometheus.collectDefaultMetrics()
-      this.setRoutes(server, register)
-    }
-  }
+    const { register } = this
+    this.interval = prometheus.collectDefaultMetrics({ register })
 
-  setRoutes(server, register) {
     server.route(/^\/metrics$/, (data, match, end, ask) => {
       ask.res.setHeader('Content-Type', register.contentType)
       ask.res.end(register.metrics())
     })
   }
+
+  stop() {
+    this.register.clear()
+    if (this.interval) {
+      clearInterval(this.interval)
+      this.interval = undefined
+    }
+  }
 }
diff --git a/core/server/prometheus-metrics.spec.js b/core/server/prometheus-metrics.spec.js
index e69f2eb0d8..32cdf00622 100644
--- a/core/server/prometheus-metrics.spec.js
+++ b/core/server/prometheus-metrics.spec.js
@@ -25,25 +25,7 @@ describe('Prometheus metrics route', function() {
     }
   })
 
-  it('returns 404 when metrics are disabled', async function() {
-    new Metrics({ enabled: false }).initialize(camp)
-
-    const res = await fetch(`${baseUrl}/metrics`)
-
-    expect(res.status).to.be.equal(404)
-    expect(await res.text()).to.not.contains('nodejs_version_info')
-  })
-
-  it('returns 404 when there is no configuration', async function() {
-    new Metrics().initialize(camp)
-
-    const res = await fetch(`${baseUrl}/metrics`)
-
-    expect(res.status).to.be.equal(404)
-    expect(await res.text()).to.not.contains('nodejs_version_info')
-  })
-
-  it('returns metrics when enabled', async function() {
+  it('returns metrics', async function() {
     new Metrics({ enabled: true }).initialize(camp)
 
     const res = await fetch(`${baseUrl}/metrics`)
diff --git a/core/server/server.js b/core/server/server.js
index ea381f583e..4186a761d4 100644
--- a/core/server/server.js
+++ b/core/server/server.js
@@ -135,7 +135,9 @@ module.exports = class Server {
       persistence: publicConfig.persistence,
       service: publicConfig.services.github,
     })
-    this.metrics = new PrometheusMetrics(publicConfig.metrics.prometheus)
+    if (publicConfig.metrics.prometheus.enabled) {
+      this.metrics = new PrometheusMetrics()
+    }
   }
 
   get port() {
@@ -182,10 +184,11 @@ module.exports = class Server {
   registerServices() {
     const { config, camp } = this
     const { apiProvider: githubApiProvider } = this.githubConstellation
+    const { requestCounter } = this.metrics || {}
 
     loadServiceClasses().forEach(serviceClass =>
       serviceClass.register(
-        { camp, handleRequest, githubApiProvider },
+        { camp, handleRequest, githubApiProvider, requestCounter },
         {
           handleInternalErrors: config.public.handleInternalErrors,
           cacheHeaders: config.public.cacheHeaders,
@@ -257,7 +260,9 @@ module.exports = class Server {
 
     const { githubConstellation, metrics } = this
     githubConstellation.initialize(camp)
-    metrics.initialize(camp)
+    if (metrics) {
+      metrics.initialize(camp)
+    }
 
     const { apiProvider: githubApiProvider } = this.githubConstellation
     suggest.setRoutes(allowedOrigin, githubApiProvider, camp)
@@ -296,6 +301,10 @@ module.exports = class Server {
       this.githubConstellation = undefined
     }
 
+    if (this.metrics) {
+      this.metrics.stop()
+    }
+
     analytics.cancelAutosaving()
   }
 }
diff --git a/package-lock.json b/package-lock.json
index 06623469ba..6ee60725fe 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1472,6 +1472,20 @@
             "which-module": "^2.0.0",
             "y18n": "^3.2.1",
             "yargs-parser": "^7.0.0"
+          },
+          "dependencies": {
+            "camelcase": {
+              "version": "4.1.0",
+              "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+              "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+              "dev": true
+            },
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         },
         "yargs-parser": {
@@ -1481,6 +1495,14 @@
           "dev": true,
           "requires": {
             "camelcase": "^4.1.0"
+          },
+          "dependencies": {
+            "camelcase": {
+              "version": "4.1.0",
+              "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+              "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+              "dev": true
+            }
           }
         }
       }
@@ -3216,6 +3238,14 @@
         "string-width": "^2.0.0",
         "term-size": "^1.2.0",
         "widest-line": "^2.0.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+          "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+          "dev": true
+        }
       }
     },
     "brace-expansion": {
@@ -3647,10 +3677,9 @@
       "dev": true
     },
     "camelcase": {
-      "version": "4.1.0",
-      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
-      "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
-      "dev": true
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.0.0.tgz",
+      "integrity": "sha512-faqwZqnWxbxn+F1d399ygeamQNy3lPp/H9H6rNrqYh4FSVCtcY+3cub1MxA8o9mDd55mM8Aghuu/kuyYA6VTsA=="
     },
     "camelcase-keys": {
       "version": "4.2.0",
@@ -5209,6 +5238,14 @@
             "which-module": "^2.0.0",
             "y18n": "^3.2.1 || ^4.0.0",
             "yargs-parser": "^11.1.0"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         },
         "yargs-parser": {
@@ -5219,6 +5256,14 @@
           "requires": {
             "camelcase": "^5.0.0",
             "decamelize": "^1.2.0"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         }
       }
@@ -6016,10 +6061,12 @@
       }
     },
     "decamelize": {
-      "version": "1.2.0",
-      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
-      "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
-      "dev": true
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-2.0.0.tgz",
+      "integrity": "sha512-Ikpp5scV3MSYxY39ymh45ZLEecsTdv/Xj2CaQfI8RLMuwi7XvjX9H/fhraiSuU+C5w5NTDu4ZU72xNiZnurBPg==",
+      "requires": {
+        "xregexp": "4.0.0"
+      }
     },
     "decamelize-keys": {
       "version": "1.1.0",
@@ -6031,6 +6078,12 @@
         "map-obj": "^1.0.0"
       },
       "dependencies": {
+        "decamelize": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+          "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+          "dev": true
+        },
         "map-obj": {
           "version": "1.0.1",
           "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz",
@@ -9816,6 +9869,14 @@
       "dev": true,
       "requires": {
         "decamelize": "^1.0.0"
+      },
+      "dependencies": {
+        "decamelize": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+          "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+          "dev": true
+        }
       }
     },
     "husky": {
@@ -12737,6 +12798,14 @@
             "which-module": "^2.0.0",
             "y18n": "^3.2.1 || ^4.0.0",
             "yargs-parser": "^11.1.1"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         },
         "yargs-parser": {
@@ -12747,6 +12816,14 @@
           "requires": {
             "camelcase": "^5.0.0",
             "decamelize": "^1.2.0"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         }
       }
@@ -17417,6 +17494,14 @@
             "which-module": "^2.0.0",
             "y18n": "^3.2.1",
             "yargs-parser": "^8.1.0"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         },
         "yargs-parser": {
@@ -17426,6 +17511,14 @@
           "dev": true,
           "requires": {
             "camelcase": "^4.1.0"
+          },
+          "dependencies": {
+            "camelcase": {
+              "version": "4.1.0",
+              "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+              "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+              "dev": true
+            }
           }
         }
       }
@@ -20922,6 +21015,14 @@
           "dev": true,
           "requires": {
             "camelcase": "^4.1.0"
+          },
+          "dependencies": {
+            "camelcase": {
+              "version": "4.1.0",
+              "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+              "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+              "dev": true
+            }
           }
         }
       }
@@ -21196,8 +21297,7 @@
     "xregexp": {
       "version": "4.0.0",
       "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz",
-      "integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg==",
-      "dev": true
+      "integrity": "sha512-PHyM+sQouu7xspQQwELlGwwd05mXUFqwFYfqPO0cC7x4fxyHnnuetmQr6CjJiafIDoH4MogHb9dOoJzR/Y4rFg=="
     },
     "xtend": {
       "version": "4.0.1",
@@ -21242,6 +21342,14 @@
         "which-module": "^2.0.0",
         "y18n": "^3.2.1",
         "yargs-parser": "^9.0.2"
+      },
+      "dependencies": {
+        "decamelize": {
+          "version": "1.2.0",
+          "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+          "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+          "dev": true
+        }
       }
     },
     "yargs-parser": {
@@ -21251,6 +21359,14 @@
       "dev": true,
       "requires": {
         "camelcase": "^4.1.0"
+      },
+      "dependencies": {
+        "camelcase": {
+          "version": "4.1.0",
+          "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz",
+          "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=",
+          "dev": true
+        }
       }
     },
     "yargs-unparser": {
@@ -21405,6 +21521,14 @@
             "which-module": "^2.0.0",
             "y18n": "^3.2.1 || ^4.0.0",
             "yargs-parser": "^11.1.1"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         },
         "yargs-parser": {
@@ -21415,6 +21539,14 @@
           "requires": {
             "camelcase": "^5.0.0",
             "decamelize": "^1.2.0"
+          },
+          "dependencies": {
+            "decamelize": {
+              "version": "1.2.0",
+              "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
+              "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=",
+              "dev": true
+            }
           }
         }
       }
diff --git a/package.json b/package.json
index 897a098e6b..ed5efbafc2 100644
--- a/package.json
+++ b/package.json
@@ -23,12 +23,14 @@
   },
   "dependencies": {
     "bytes": "^3.1.0",
+    "camelcase": "^5.0.0",
     "camp": "~17.2.2",
     "chalk": "^2.4.2",
     "check-node-version": "^3.1.0",
     "chrome-web-store-item-property": "~1.1.2",
     "config": "^3.0.1",
     "cross-env": "^5.2.0",
+    "decamelize": "^2.0.0",
     "dotenv": "^6.2.0",
     "emojic": "^1.1.15",
     "escape-string-regexp": "^1.0.5",
@@ -37,8 +39,8 @@
     "gh-badges": "file:gh-badges",
     "glob": "^7.1.1",
     "joi": "14.3.1",
-    "js-yaml": "^3.12.1",
     "joi-extension-semver": "2.0.0",
+    "js-yaml": "^3.12.1",
     "jsonpath": "~1.0.0",
     "lodash.countby": "^4.6.0",
     "lodash.throttle": "^4.1.1",
diff --git a/services/bitbucket/bitbucket-issues.service.js b/services/bitbucket/bitbucket-issues.service.js
index c0ed5e6a9b..81f0f334aa 100644
--- a/services/bitbucket/bitbucket-issues.service.js
+++ b/services/bitbucket/bitbucket-issues.service.js
@@ -14,6 +14,10 @@ function issueClassGenerator(raw) {
   const badgeSuffix = raw ? '' : ' open'
 
   return class BitbucketIssues extends BaseJsonService {
+    static get name() {
+      return `BitbucketIssues${raw ? 'Raw' : ''}`
+    }
+
     async fetch({ user, repo }) {
       const url = `https://bitbucket.org/api/1.0/repositories/${user}/${repo}/issues/`
       return this._requestJson({
diff --git a/services/bitbucket/bitbucket-pull-request.service.js b/services/bitbucket/bitbucket-pull-request.service.js
index f7561ca85a..7e8803cb98 100644
--- a/services/bitbucket/bitbucket-pull-request.service.js
+++ b/services/bitbucket/bitbucket-pull-request.service.js
@@ -19,6 +19,10 @@ function pullRequestClassGenerator(raw) {
   const badgeSuffix = raw ? '' : ' open'
 
   return class BitbucketPullRequest extends BaseJsonService {
+    static get name() {
+      return `BitbucketPullRequest${raw ? 'Raw' : ''}`
+    }
+
     async fetchCloud({ args, user, repo }) {
       args.url = `https://bitbucket.org/api/2.0/repositories/${user}/${repo}/pullrequests/`
       args.options = { qs: { state: 'OPEN', limit: 0 } }
diff --git a/services/check-services.spec.js b/services/check-services.spec.js
index 082f8a6cd7..605ad0539f 100644
--- a/services/check-services.spec.js
+++ b/services/check-services.spec.js
@@ -1,9 +1,17 @@
 'use strict'
 
-const { collectDefinitions } = require('../core/base-service/loader')
+const {
+  checkNames,
+  collectDefinitions,
+} = require('../core/base-service/loader')
+
+// When these tests fail, they will throw AssertionErrors. Wrapping them in an
+// `expect().not.to.throw()` makes the error output unreadable.
+
+it('Services have unique names', function() {
+  checkNames()
+})
 
 it('Can collect the service definitions', function() {
-  // When this fails, it will throw AssertionErrors. Wrapping this in an
-  // `expect().not.to.throw()` makes the error output unreadable.
   collectDefinitions()
 })
diff --git a/services/chrome-web-store/chrome-web-store.service.js b/services/chrome-web-store/chrome-web-store.service.js
index f7ec547c98..5b8c0edb30 100644
--- a/services/chrome-web-store/chrome-web-store.service.js
+++ b/services/chrome-web-store/chrome-web-store.service.js
@@ -151,6 +151,13 @@ class ChromeWebStore extends LegacyService {
     return 'other'
   }
 
+  static get route() {
+    return {
+      base: 'chrome-web-store',
+      pattern: ':which(v|d|users|price|rating|stars|rating-count)/:storeId',
+    }
+  }
+
   static registerLegacyRouteHandler({ camp, cache }) {
     camp.route(
       /^\/chrome-web-store\/(v|d|users|price|rating|stars|rating-count)\/(.*)\.(svg|png|gif|jpg|json)$/,
diff --git a/services/clojars/clojars-downloads.service.js b/services/clojars/clojars-downloads.service.js
index f1282773f2..7cea2f15ae 100644
--- a/services/clojars/clojars-downloads.service.js
+++ b/services/clojars/clojars-downloads.service.js
@@ -10,7 +10,7 @@ const clojarsSchema = Joi.object({
   downloads: nonNegativeInteger,
 }).required()
 
-module.exports = class Clojars extends BaseJsonService {
+module.exports = class ClojarsDownloads extends BaseJsonService {
   async fetch({ clojar }) {
     const url = `https://clojars.org/api/artifacts/${clojar}`
     return this._requestJson({
diff --git a/services/clojars/clojars-version.service.js b/services/clojars/clojars-version.service.js
index deae4dbd0c..0b2d99367f 100644
--- a/services/clojars/clojars-version.service.js
+++ b/services/clojars/clojars-version.service.js
@@ -9,7 +9,7 @@ const clojarsSchema = Joi.object({
   version: Joi.string(),
 }).required()
 
-module.exports = class Clojars extends BaseJsonService {
+module.exports = class ClojarsVersion extends BaseJsonService {
   async fetch({ clojar }) {
     const url = `https://clojars.org/${clojar}/latest-version.json`
     return this._requestJson({
diff --git a/services/cocoapods/cocoapods-apps.service.js b/services/cocoapods/cocoapods-apps.service.js
index 9734e375f4..277cc6a43c 100644
--- a/services/cocoapods/cocoapods-apps.service.js
+++ b/services/cocoapods/cocoapods-apps.service.js
@@ -3,6 +3,7 @@
 const { deprecatedService } = require('..')
 
 module.exports = deprecatedService({
+  name: 'CocoapodsApps',
   category: 'other',
   route: {
     base: 'cocoapods',
diff --git a/services/cocoapods/cocoapods-downloads.service.js b/services/cocoapods/cocoapods-downloads.service.js
index 777cdd4047..c0caf8da6b 100644
--- a/services/cocoapods/cocoapods-downloads.service.js
+++ b/services/cocoapods/cocoapods-downloads.service.js
@@ -3,6 +3,7 @@
 const { deprecatedService } = require('..')
 
 module.exports = deprecatedService({
+  name: 'CocoapodsDownloads',
   category: 'downloads',
   route: {
     base: 'cocoapods',
diff --git a/services/conda/conda-version.service.js b/services/conda/conda-version.service.js
index 69e4ae1edd..c88d5a896f 100644
--- a/services/conda/conda-version.service.js
+++ b/services/conda/conda-version.service.js
@@ -4,7 +4,7 @@ const { addv: versionText } = require('../../lib/text-formatters')
 const { version: versionColor } = require('../../lib/color-formatters')
 const BaseCondaService = require('./conda-base')
 
-module.exports = class CondaDownloads extends BaseCondaService {
+module.exports = class CondaVersion extends BaseCondaService {
   static get category() {
     return 'version'
   }
diff --git a/services/discourse/discourse.service.js b/services/discourse/discourse.service.js
index 623e64faff..96a8fa710e 100644
--- a/services/discourse/discourse.service.js
+++ b/services/discourse/discourse.service.js
@@ -1,5 +1,6 @@
 'use strict'
 
+const camelcase = require('camelcase')
 const Joi = require('joi')
 const { BaseJsonService } = require('..')
 const { metric } = require('../../lib/text-formatters')
@@ -38,6 +39,12 @@ class DiscourseBase extends BaseJsonService {
 
 function DiscourseMetricIntegrationFactory({ metricName, property }) {
   return class DiscourseMetric extends DiscourseBase {
+    static get name() {
+      // The space is needed so we get 'DiscourseTopics' rather than
+      // 'Discoursetopics'. `camelcase()` removes it.
+      return camelcase(`Discourse ${metricName}`, { pascalCase: true })
+    }
+
     static get route() {
       return this.buildRoute(metricName)
     }
diff --git a/services/dub/dub-download.service.js b/services/dub/dub-download.service.js
index 70a6eec3a0..0cfa3ddb19 100644
--- a/services/dub/dub-download.service.js
+++ b/services/dub/dub-download.service.js
@@ -16,26 +16,34 @@ const schema = Joi.object({
 })
 
 function DownloadsForInterval(interval) {
-  const { base, messageSuffix } = {
+  const { base, messageSuffix, name } = {
     daily: {
       base: 'dub/dd',
       messageSuffix: '/day',
+      name: 'DubDownloadsDay',
     },
     weekly: {
       base: 'dub/dw',
       messageSuffix: '/week',
+      name: 'DubDownloadsWeek',
     },
     monthly: {
       base: 'dub/dm',
       messageSuffix: '/month',
+      name: 'DubDownloadsMonth',
     },
     total: {
       base: 'dub/dt',
       messageSuffix: '',
+      name: 'DubDownloadsTotal',
     },
   }[interval]
 
   return class DubDownloads extends BaseJsonService {
+    static get name() {
+      return name
+    }
+
     static render({ downloads, version }) {
       const label = version ? `downloads@${version}` : 'downloads'
       return {
diff --git a/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js b/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js
index 3c123c1844..991441d4f8 100644
--- a/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js
+++ b/services/eclipse-marketplace/eclipse-marketplace-downloads.service.js
@@ -25,19 +25,25 @@ const totalResponseSchema = Joi.object({
 }).required()
 
 function DownloadsForInterval(interval) {
-  const { base, schema, messageSuffix = '' } = {
+  const { base, schema, messageSuffix = '', name } = {
     month: {
       base: 'eclipse-marketplace/dm',
       messageSuffix: '/month',
       schema: monthlyResponseSchema,
+      name: 'EclipseMarketplaceDownloadsMonth',
     },
     total: {
       base: 'eclipse-marketplace/dt',
       schema: totalResponseSchema,
+      name: 'EclipseMarketplaceDownloadsTotal',
     },
   }[interval]
 
   return class EclipseMarketplaceDownloads extends EclipseMarketplaceBase {
+    static get name() {
+      return name
+    }
+
     static get category() {
       return 'downloads'
     }
diff --git a/services/gratipay/gratipay.service.js b/services/gratipay/gratipay.service.js
index 3551dd039a..eca49ae023 100644
--- a/services/gratipay/gratipay.service.js
+++ b/services/gratipay/gratipay.service.js
@@ -2,11 +2,25 @@
 
 const { deprecatedService } = require('..')
 
-module.exports = deprecatedService({
+const commonAttrs = {
   category: 'funding',
-  route: {
-    format: '(?:gittip|gratipay(?:/user|/team|/project)?)/(?:.*)',
-  },
   label: 'gratipay',
   dateAdded: new Date('2017-12-29'),
-})
+}
+
+module.exports = [
+  deprecatedService({
+    route: {
+      base: 'gittip',
+      format: '(?:/user|/team|/project)?/(?:.*)',
+    },
+    ...commonAttrs,
+  }),
+  deprecatedService({
+    route: {
+      base: 'gratipay',
+      format: '(?:/user|/team|/project)?/(?:.*)',
+    },
+    ...commonAttrs,
+  }),
+]
diff --git a/services/hexpm/hexpm.service.js b/services/hexpm/hexpm.service.js
index 80c94618aa..418d6c31d9 100644
--- a/services/hexpm/hexpm.service.js
+++ b/services/hexpm/hexpm.service.js
@@ -122,22 +122,29 @@ class HexPmVersion extends BaseHexPmService {
 }
 
 function DownloadsForInterval(interval) {
-  const { base, messageSuffix } = {
+  const { base, messageSuffix, name } = {
     day: {
       base: 'hexpm/dd',
       messageSuffix: '/day',
+      name: 'HexPmDownloadsDay',
     },
     week: {
       base: 'hexpm/dw',
       messageSuffix: '/week',
+      name: 'HexPmDownloadsWeek',
     },
     all: {
       base: 'hexpm/dt',
       messageSuffix: '',
+      name: 'HexPmDownloadsTotal',
     },
   }[interval]
 
   return class HexPmDownloads extends BaseHexPmService {
+    static get name() {
+      return name
+    }
+
     static render({ downloads }) {
       return {
         message: `${metric(downloads)}${messageSuffix}`,
diff --git a/services/jetbrains/jetbrains-version.service.js b/services/jetbrains/jetbrains-version.service.js
index 15c859b746..ce3643024e 100644
--- a/services/jetbrains/jetbrains-version.service.js
+++ b/services/jetbrains/jetbrains-version.service.js
@@ -20,7 +20,7 @@ const schema = Joi.object({
   }).required(),
 }).required()
 
-module.exports = class JetbrainsDownloads extends JetbrainsBase {
+module.exports = class JetbrainsVersion extends JetbrainsBase {
   static get category() {
     return 'version'
   }
diff --git a/services/npm/npm-downloads.service.js b/services/npm/npm-downloads.service.js
index ef9b525b2f..a4283fc803 100644
--- a/services/npm/npm-downloads.service.js
+++ b/services/npm/npm-downloads.service.js
@@ -18,26 +18,30 @@ const rangeResponseSchema = Joi.object({
 }).required()
 
 function DownloadsForInterval(interval) {
-  const { base, messageSuffix = '', query, isRange = false } = {
+  const { base, messageSuffix = '', query, isRange = false, name } = {
     week: {
       base: 'npm/dw',
       messageSuffix: '/w',
       query: 'point/last-week',
+      name: 'NpmDownloadsWeek',
     },
     month: {
       base: 'npm/dm',
       messageSuffix: '/m',
       query: 'point/last-month',
+      name: 'NpmDownloadsMonth',
     },
     year: {
       base: 'npm/dy',
       messageSuffix: '/y',
       query: 'point/last-year',
+      name: 'NpmDownloadsYear',
     },
     total: {
       base: 'npm/dt',
       query: 'range/1000-01-01:3000-01-01',
       isRange: true,
+      name: 'NpmDownloadsTotal',
     },
   }[interval]
 
@@ -46,6 +50,10 @@ function DownloadsForInterval(interval) {
   // This hits an entirely different API from the rest of the NPM services, so
   // it does not use NpmBase.
   return class NpmDownloads extends BaseJsonService {
+    static get name() {
+      return name
+    }
+
     static get category() {
       return 'downloads'
     }
diff --git a/services/nuget/nuget-v2-service-family.js b/services/nuget/nuget-v2-service-family.js
index 1d7704c193..102272f562 100644
--- a/services/nuget/nuget-v2-service-family.js
+++ b/services/nuget/nuget-v2-service-family.js
@@ -96,11 +96,12 @@ async function fetch(
  * apiBaseUrl: The complete base URL of the API, e.g. https://api.example.com/api/v2
  */
 function createServiceFamily({
+  title,
+  name = title,
   defaultLabel,
   serviceBaseUrl,
   apiBaseUrl,
   odataFormat,
-  title,
   examplePackageName,
   exampleVersion,
   examplePrereleaseVersion,
@@ -116,6 +117,10 @@ function createServiceFamily({
   }
 
   class NugetVersionService extends Base {
+    static get name() {
+      return `${name}Version`
+    }
+
     static get category() {
       return 'version'
     }
@@ -169,6 +174,10 @@ function createServiceFamily({
   }
 
   class NugetDownloadService extends Base {
+    static get name() {
+      return `${name}Downloads`
+    }
+
     static get category() {
       return 'downloads'
     }
diff --git a/services/packagecontrol/packagecontrol.service.js b/services/packagecontrol/packagecontrol.service.js
index 335c7e5b86..304f0b0544 100644
--- a/services/packagecontrol/packagecontrol.service.js
+++ b/services/packagecontrol/packagecontrol.service.js
@@ -26,7 +26,7 @@ const schema = Joi.object({
 })
 
 function DownloadsForInterval(interval) {
-  const { base, messageSuffix, transform } = {
+  const { base, messageSuffix, transform, name } = {
     day: {
       base: 'packagecontrol/dd',
       messageSuffix: '/day',
@@ -39,6 +39,7 @@ function DownloadsForInterval(interval) {
         })
         return downloads
       },
+      name: 'PackageControlDownloadsDay',
     },
     week: {
       base: 'packagecontrol/dw',
@@ -54,6 +55,7 @@ function DownloadsForInterval(interval) {
         })
         return downloads
       },
+      name: 'PackageControlDownloadsWeek',
     },
     month: {
       base: 'packagecontrol/dm',
@@ -69,15 +71,21 @@ function DownloadsForInterval(interval) {
         })
         return downloads
       },
+      name: 'PackageControlDownloadsMonth',
     },
     total: {
       base: 'packagecontrol/dt',
       messageSuffix: '',
       transform: resp => resp.installs.total,
+      name: 'PackageControlDownloadsTotal',
     },
   }[interval]
 
   return class PackageControlDownloads extends BaseJsonService {
+    static get name() {
+      return name
+    }
+
     static render({ downloads }) {
       return {
         message: `${metric(downloads)}${messageSuffix}`,
diff --git a/services/powershellgallery/powershellgallery.service.js b/services/powershellgallery/powershellgallery.service.js
index d053bd6bf7..e92df73f7d 100644
--- a/services/powershellgallery/powershellgallery.service.js
+++ b/services/powershellgallery/powershellgallery.service.js
@@ -16,6 +16,7 @@ const {
   NugetVersionService: PowershellGalleryVersion,
   NugetDownloadService: PowershellGalleryDownloads,
 } = createServiceFamily({
+  name: 'PowershellGallery',
   defaultLabel: 'powershell gallery',
   serviceBaseUrl: 'powershellgallery',
   apiBaseUrl,
diff --git a/services/puppetforge/puppetforge-modules.service.js b/services/puppetforge/puppetforge-modules.service.js
index a159d97f36..f49c7ce93e 100644
--- a/services/puppetforge/puppetforge-modules.service.js
+++ b/services/puppetforge/puppetforge-modules.service.js
@@ -183,6 +183,13 @@ class PuppetforgeModules extends LegacyService {
     return 'other'
   }
 
+  static get route() {
+    return {
+      base: 'puppetforge',
+      pattern: '([^/]+)/([^/]+)/([^/]+)',
+    }
+  }
+
   static registerLegacyRouteHandler({ camp, cache }) {
     camp.route(
       /^\/puppetforge\/([^/]+)\/([^/]+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
diff --git a/services/puppetforge/puppetforge-users.service.js b/services/puppetforge/puppetforge-users.service.js
index 9cd6314d31..0b2ecb346f 100644
--- a/services/puppetforge/puppetforge-users.service.js
+++ b/services/puppetforge/puppetforge-users.service.js
@@ -81,6 +81,13 @@ class PuppetforgeUsers extends LegacyService {
     return 'other'
   }
 
+  static get route() {
+    return {
+      base: 'puppetforge',
+      pattern: '([^/]+)/([^/]+)/([^/]+)',
+    }
+  }
+
   static registerLegacyRouteHandler({ camp, cache }) {
     camp.route(
       /^\/puppetforge\/([^/]+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
diff --git a/services/resharper/resharper.service.js b/services/resharper/resharper.service.js
index 86fb7a96f0..60585f7464 100644
--- a/services/resharper/resharper.service.js
+++ b/services/resharper/resharper.service.js
@@ -3,6 +3,7 @@
 const { createServiceFamily } = require('../nuget/nuget-v2-service-family')
 
 module.exports = createServiceFamily({
+  name: 'ResharperPlugin',
   defaultLabel: 'resharper',
   serviceBaseUrl: 'resharper',
   apiBaseUrl: 'https://resharper-plugins.jetbrains.com/api/v2',
diff --git a/services/snap-ci/snap-ci.service.js b/services/snap-ci/snap-ci.service.js
index 6d6fdf1a8c..73d03fe920 100644
--- a/services/snap-ci/snap-ci.service.js
+++ b/services/snap-ci/snap-ci.service.js
@@ -2,11 +2,25 @@
 
 const { deprecatedService } = require('..')
 
-module.exports = deprecatedService({
+const commonAttrs = {
   category: 'build',
-  route: {
-    format: 'snap(?:-ci?)/(?:[^/]+/[^/]+)(?:/(?:.+))',
-  },
   label: 'snap ci',
   dateAdded: new Date('2018-01-23'),
-})
+}
+
+module.exports = [
+  deprecatedService({
+    route: {
+      base: 'snap',
+      format: '(?:[^/]+/[^/]+)(?:/(?:.+))',
+    },
+    ...commonAttrs,
+  }),
+  deprecatedService({
+    route: {
+      base: 'snap-ci',
+      format: '(?:[^/]+/[^/]+)(?:/(?:.+))',
+    },
+    ...commonAttrs,
+  }),
+]
diff --git a/services/static-badge/query-string-static.service.js b/services/static-badge/query-string-static.service.js
index ac084da3fb..635fb59f3f 100644
--- a/services/static-badge/query-string-static.service.js
+++ b/services/static-badge/query-string-static.service.js
@@ -14,6 +14,7 @@ module.exports = class QueryStringStaticBadge extends BaseStaticService {
 
   static get route() {
     return {
+      base: '',
       pattern: 'static/:schemaVersion(v1)',
       // All but one of the parameters are parsed via coalesceBadge. This
       // reuses what is the override behaviour for other badges.
diff --git a/services/static-badge/static-badge.service.js b/services/static-badge/static-badge.service.js
index 7d4070b393..1a86808d38 100644
--- a/services/static-badge/static-badge.service.js
+++ b/services/static-badge/static-badge.service.js
@@ -10,6 +10,7 @@ module.exports = class StaticBadge extends BaseStaticService {
 
   static get route() {
     return {
+      base: '',
       format: '(?::|badge/)((?:[^-]|--)*?)-?((?:[^-]|--)*)-((?:[^-]|--)+)',
       capture: ['label', 'message', 'color'],
     }
diff --git a/services/vaadin-directory/vaadin-directory.service.js b/services/vaadin-directory/vaadin-directory.service.js
index b63878f0f0..94855a7129 100644
--- a/services/vaadin-directory/vaadin-directory.service.js
+++ b/services/vaadin-directory/vaadin-directory.service.js
@@ -166,6 +166,14 @@ class VaadinDirectory extends LegacyService {
     return 'other'
   }
 
+  static get route() {
+    return {
+      base: 'vaadin-directory',
+      pattern:
+        ':which(star|stars|status|rating|rc|rating-count|v|version|rd|release-date)/:urlIdentifier',
+    }
+  }
+
   static registerLegacyRouteHandler({ camp, cache }) {
     camp.route(
       /^\/vaadin-directory\/(star|stars|status|rating|rc|rating-count|v|version|rd|release-date)\/(.*).(svg|png|gif|jpg|json)$/,
diff --git a/services/wordpress/wordpress-downloads.service.js b/services/wordpress/wordpress-downloads.service.js
index 20063f356f..459b77a1d6 100644
--- a/services/wordpress/wordpress-downloads.service.js
+++ b/services/wordpress/wordpress-downloads.service.js
@@ -25,6 +25,10 @@ function DownloadsForExtensionType(extensionType) {
   const { capt, exampleSlug } = extensionData[extensionType]
 
   return class WordpressDownloads extends BaseWordpress {
+    static get name() {
+      return `Wordpress${capt}Downloads`
+    }
+
     static render({ response }) {
       return {
         message: metric(response.downloaded),
@@ -66,6 +70,10 @@ function InstallsForExtensionType(extensionType) {
   const { capt, exampleSlug } = extensionData[extensionType]
 
   return class WordpressInstalls extends BaseWordpress {
+    static get name() {
+      return `Wordpress${capt}Installs`
+    }
+
     static get extensionType() {
       return extensionType
     }
@@ -105,30 +113,38 @@ function InstallsForExtensionType(extensionType) {
 }
 
 function DownloadsForInterval(interval) {
-  const { base, messageSuffix = '', query } = {
+  const { base, messageSuffix = '', query, name } = {
     day: {
       base: 'wordpress/plugin/dd',
       messageSuffix: '/day',
       query: 1,
+      name: 'WordpressDownloadsDay',
     },
     week: {
       base: 'wordpress/plugin/dw',
       messageSuffix: '/week',
       query: 7,
+      name: 'WordpressDownloadsWeek',
     },
     month: {
       base: 'wordpress/plugin/dm',
       messageSuffix: '/month',
       query: 30,
+      name: 'WordpressDownloadsMonth',
     },
     year: {
       base: 'wordpress/plugin/dy',
       messageSuffix: '/year',
       query: 365,
+      name: 'WordpressDownloadsYear',
     },
   }[interval]
 
   return class WordpressDownloads extends BaseJsonService {
+    static get name() {
+      return name
+    }
+
     static get category() {
       return 'downloads'
     }
diff --git a/services/wordpress/wordpress-rating.service.js b/services/wordpress/wordpress-rating.service.js
index d160ab95ff..dcabd1d914 100644
--- a/services/wordpress/wordpress-rating.service.js
+++ b/services/wordpress/wordpress-rating.service.js
@@ -38,6 +38,10 @@ function RatingForExtensionType(extensionType) {
   const { capt, exampleSlug } = extensionData[extensionType]
 
   return class WordpressRating extends WordpressRatingBase {
+    static get name() {
+      return `Wordpress${capt}Rating`
+    }
+
     static get route() {
       return {
         base: `wordpress/${extensionType}/rating`,
@@ -70,6 +74,10 @@ function StarsForExtensionType(extensionType) {
   const { capt, exampleSlug } = extensionData[extensionType]
 
   return class WordpressStars extends WordpressRatingBase {
+    static get name() {
+      return `Wordpress${capt}Stars`
+    }
+
     static render({ response }) {
       const rating = (response.rating / 100) * 5
       return { message: starRating(rating), color: floorCount(rating, 2, 3, 4) }
diff --git a/services/wordpress/wordpress-version.service.js b/services/wordpress/wordpress-version.service.js
index 541871e973..259cad3f13 100644
--- a/services/wordpress/wordpress-version.service.js
+++ b/services/wordpress/wordpress-version.service.js
@@ -16,7 +16,11 @@ function VersionForExtensionType(extensionType) {
     },
   }[extensionType]
 
-  return class WordpressPluginVersion extends BaseWordpress {
+  return class WordpressVersion extends BaseWordpress {
+    static get name() {
+      return `Wordpress${capt}Version`
+    }
+
     static get extensionType() {
       return extensionType
     }


Test patch:
diff --git a/test-fixtures/valid-array.fixture.js b/test-fixtures/valid-array.fixture.js
index 0d96853969..4c4cec06a4 100644
--- a/test-fixtures/valid-array.fixture.js
+++ b/test-fixtures/valid-array.fixture.js
@@ -9,7 +9,10 @@ class GoodServiceOne extends BaseJsonService {
   }
 
   static get route() {
-    return { pattern: 'good/one' }
+    return {
+      base: 'good',
+      pattern: 'one',
+    }
   }
 }
 class GoodServiceTwo extends LegacyService {
@@ -18,7 +21,10 @@ class GoodServiceTwo extends LegacyService {
   }
 
   static get route() {
-    return { pattern: 'good/two' }
+    return {
+      base: 'good',
+      pattern: 'two',
+    }
   }
 }
 
diff --git a/test-fixtures/valid-class.fixture.js b/test-fixtures/valid-class.fixture.js
index 248234ee85..f192c49816 100644
--- a/test-fixtures/valid-class.fixture.js
+++ b/test-fixtures/valid-class.fixture.js
@@ -8,7 +8,10 @@ class GoodService extends BaseJsonService {
   }
 
   static get route() {
-    return { pattern: 'good' }
+    return {
+      base: 'it/is',
+      pattern: 'good',
+    }
   }
 }
 
diff --git a/test-fixtures/valid-object.fixture.js b/test-fixtures/valid-object.fixture.js
index b022fa1d22..6a0faeaadb 100644
--- a/test-fixtures/valid-object.fixture.js
+++ b/test-fixtures/valid-object.fixture.js
@@ -9,7 +9,10 @@ class GoodServiceOne extends BaseJsonService {
   }
 
   static get route() {
-    return { pattern: 'good/one' }
+    return {
+      base: 'good',
+      pattern: 'one',
+    }
   }
 }
 class GoodServiceTwo extends LegacyService {
@@ -18,7 +21,10 @@ class GoodServiceTwo extends LegacyService {
   }
 
   static get route() {
-    return { pattern: 'good/two' }
+    return {
+      base: 'good',
+      pattern: 'two',
+    }
   }
 }