Your SDK ships in TypeScript and Python, and four more languages are on the request list. The loudest request comes from one agency with a shared Slack channel, the registry dashboards count only the two SDKs you have, and the budget covers one more target this year. You need a ranking you can defend.
What you get
You will end up with one ranking of the candidate languages, built from tickets, signup answers, registry snapshots, and the User-Agents in your access log, and a test that pins it. This is for you if you ship an SDK and must pick the next language.
Short answer
Build one ranking from evidence you already hold. Registry downloads measure the SDKs you ship, so they rank nothing you do not ship. Tickets and the signup form count people who asked. The User-Agent in your access log counts people who never asked and call the API with a bare HTTP library, which is the demand a missing SDK hides. Average each candidate’s share of each source, then weigh the winner against the runtime matrix it brings.
You will need
Node 22 or later, and an SDK shipping in at least one language. Also a quarter of support tickets
tagged by language, the language answer from your signup form, and an access log that kept the
User-Agent header. Verified 2026-10-21 against Node 22.22.0. The sample runs on committed
fixtures for a fictional metering API and makes no network call. Its registry readers are tested
against responses captured from the five live registries.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| A demand ranking from registry downloads, tickets, and access logs | More integrators call the API than you could ask one by one, and your access log keeps the User-Agent | A User-Agent classifier you keep current, because every HTTP library sends its own default, and three sources that count different things, so the weights are a judgment you have to state | You have a handful of customers you can ask, or a gateway rewrites the User-Agent before it reaches your log |
| An HTTP-only stance with a stronger OpenAPI description | Few customers, many languages, and a description good enough for generators and agents to work from | Every integrator writes the client, the retries, and the auth, and some of them stop before the first call | Customers measure you against vendors who ship a client in their language |
| Ecosystem parity across every supported target | A generator that already emits the language, and a team that can test and support each output | A release, a CI leg, a runtime matrix, and an issue queue per language even when nobody asked for it | Demand sits in two or three languages and the rest would ship untested |
Parity ships every target the generator has and pays per language in CI legs, runtime matrices, and triage, even when nobody asked. The HTTP-only stance pays nothing per language and hands the client work to each integrator, some of whom never reach a first call. A demand ranking sits between them, and its cost is the evidence: downloads cover only what you ship, so the ranking needs a source that sees the rest.
Snapshot the registries for the languages you ship
Each registry publishes a count, and each defines it differently. npm’s
download counts API returns
tarball downloads for a period, computed once a day.1
PyPI Stats returns last_month with known mirrors removed, and
Packagist returns monthly, daily, and an all-time total
together.2 The RubyGems API returns one
all-time total per gem. Go has no download count at all: the
Imported By tab on
pkg.go.dev counts packages known to its index that import the module.3 registry.mjs holds
a request builder and a reader for each.
export const registries = {
npm: {
docs: 'https://github.com/npm/registry/blob/main/docs/download-counts.md',
url: (pkg, { start, end } = {}) => `https://api.npmjs.org/downloads/point/${start && end ? `${start}:${end}` : 'last-month'}/${pkg}`,
downloads: (body) => body.downloads,
counts: 'downloads of the tarball in the period asked for',
},
pypistats: {
docs: 'https://pypistats.org/api/',
url: (pkg) => `https://pypistats.org/api/packages/${pkg}/recent`,
downloads: (body) => body.data.last_month,
counts: 'downloads in the last month, known mirrors removed',
},
snapshot.mjs fetches your own packages through those builders and writes the responses to
evidence/registry.json. The page runs on a committed snapshot for the two shipped SDKs.
node registry.mjs
shipped SDKs, 2026-09-01 to 2026-09-30, from evidence/registry.json
typescript npm @meterco/sdk 41,230 https://api.npmjs.org/downloads/point/2026-09-01:2026-09-30/@meterco/sdk
python pypistats meterco 18,904 https://pypistats.org/api/packages/meterco/recent
candidates with no package to count: go, java, php, ruby
what each registry counts
npm downloads of the tarball in the period asked for
pypistats downloads in the last month, known mirrors removed
packagist downloads in the last month, beside a daily and an all-time figure
rubygems downloads since the gem was first pushed, with no period at all
pkg.go.dev packages known to the index that import the module, not downloads
Two numbers come back, for the two packages that exist. The four candidates have no package, so this source has nothing to say about them. It answers a neighboring question instead: how much each shipped SDK is used, which decides whether the SDK channel matters enough to extend.
Rank the candidates from the people who asked
Two sources count people who asked. Support tickets carry a language tag and the account that
filed them. The signup form asks which language the caller will use, and the answers for a quarter
sit in evidence/signups.json. Each source becomes a share: a candidate’s count divided by the
total across candidates. The ranking averages the shares with equal weights, and the weights live
in one function rather than in the arithmetic.
/** Each language's share of one source; every share is zero when the source is empty. */
export function shares(counts) {
const total = Object.values(counts).reduce((a, b) => a + b, 0)
return Object.fromEntries(Object.entries(counts).map(([l, n]) => [l, total ? n / total : 0]))
}
/**
* Equal-weight average of the candidates' shares of each source, ranked. A share
* is of the candidates alone, and a candidate a source leaves out counts zero
* there. Ties fall back to the name.
*/
export function rank(sources, languages) {
const names = Object.keys(sources)
const shared = names.map((n) => shares(Object.fromEntries(languages.map((l) => [l, sources[n][l] ?? 0]))))
return languages
.map((language) => ({ language, score: shared.reduce((sum, s) => sum + s[language], 0) / names.length }))
.sort((a, b) => b.score - a.score || a.language.localeCompare(b.language))
.map((r, i) => ({ ...r, rank: i + 1 }))
}
node rank.mjs
shipped SDKs, downloads 2026-09-01 to 2026-09-30
typescript npm @meterco/sdk 41,230
python pypistats meterco 18,904
candidates with no package to count: go, java, php, ruby
candidates ranked from tickets, signups, equal weights
rank language tickets (accounts) signups score
1 php 14 (6) 39 0.36
2 go 9 (8) 44 0.29
3 java 5 (5) 48 0.25
4 ruby 4 (3) 12 0.10
PHP leads on tickets and Java leads on signups, and the average puts PHP first. The accounts column is the first warning: fourteen PHP tickets came from six accounts, so one of them has been busy. Both sources count voices, and a voice is a person who found one of the forms.
Add the source that sees people who never asked
Someone calling your API from Java without an SDK files no ticket and may have signed up before
the form had a language field. Their requests are in your access log, and the User-Agent header
names the library that sent them. An HTTP library sends a default agent unless the caller sets
one, and the default names the library. Go’s net/http sends Go-http-client/1.1, a constant in
request.go,4 or
Go-http-client/2.0 over HTTP/2, and requests sends python-requests/<version> from
utils.py. Node’s fetch sends a
bare node, with no version, and the aiohttp default puts the Python version first. Your own SDKs
send a name of their own, which is what makes the remainder countable as calls with no SDK.
export const RULES = [
[/^meterco-sdk-ts\//, 'sdk:typescript'],
[/^meterco-sdk-py\//, 'sdk:python'],
[/^Go-http-client\//, 'go'],
[/^(okhttp|Java-http-client|Java|Apache-HttpClient)\//, 'java'],
[/^(GuzzleHttp\/|Symfony HttpClient )/, 'php'],
[/^(Faraday |rest-client\/|Ruby$)/, 'ruby'],
[/^(axios\/|node-fetch\b|undici\b|node$)/, 'typescript'],
[/^(python-requests|python-httpx|python-urllib3|Python-urllib)\/|^Python\/[\d.]+ aiohttp\//, 'python'],
]
/** The language a User-Agent names, 'sdk:<language>' for your own SDKs, or 'unknown'. */
export function classify(agent) {
for (const [re, language] of RULES) if (re.test(agent)) return language
return 'unknown'
}
node rank.mjs --logs
shipped SDKs, downloads 2026-09-01 to 2026-09-30
typescript npm @meterco/sdk 41,230
python pypistats meterco 18,904
candidates with no package to count: go, java, php, ruby
access log, 815 requests, by User-Agent
your SDKs 440 54%
no SDK, a language you ship 65 8%
no SDK, a candidate language 280 34%
not placed 30 4% PostmanRuntime/7.42.0, Dart/3.5 (dart:io), curl/8.9.1
candidates ranked from tickets, signups, logs, equal weights
rank language tickets (accounts) signups raw calls score runtimes in support on 2026-10-18
1 java 5 (5) 48 140 0.33 6 (27, 25, 21, 17, 11, 8)
2 go 9 (8) 44 90 0.30 2 (1.27, 1.26)
3 php 14 (6) 39 35 0.28 4 (8.5, 8.4, 8.3, 8.2)
4 ruby 4 (3) 12 15 0.09 3 (4.0, 3.4, 3.3)
moved: java 3 -> 1, php 1 -> 3
A third of the requests came from candidate languages with no SDK, and half of those from Java, which had filed five tickets. Java moves from third to first and PHP from first to third. That is the demand a missing SDK hides: integrators who wrote the client themselves and had no reason to write to you.
The last column is the other side of the scale. It counts the runtime versions each language
keeps in support on the snapshot date, read from endoflife.date,
because every supported version is a CI leg and a bug report you will receive. Java brings six
lines of its eclipse-temurin product and Go brings two. The ranking says what people want; the
column says what each answer costs to keep, and the decision weighs one against the other.
Check it worked
Eleven tests cover the registry readers, the classifier, the shares, and the ranking.
node --test rank.test.mjs
1..11
# tests 11
# suites 0
# pass 11
# fail 0
# cancelled 0
# skipped 0
# todo 0
The first test runs each reader over a response captured from the live registry, so the five
readers are known to fit the five real shapes. Another asserts that the ranking is php, go, java, ruby from the two sources and java, go, php, ruby once the log joins.
When it goes wrong
The ranking is built from downloads and comes back empty for every candidate. The cause is the source: a registry counts a package, and the candidates have none. Use downloads to size the SDK channel, never to rank what you do not ship.
One account dominates a language. Nine of PHP’s fourteen tickets came from one agency. Counting accounts instead of tickets puts Go ahead of PHP, so print both and decide which one you mean.
The classifier meets an agent it does not know. curl, Postman, and Dart sit in the log, and a
guess for any of them would move the ranking. Leave them as not placed and read the list.
node pitfall.mjs
a ranking from downloads alone
typescript 41,230
python 18,904
go no package to count
java no package to count
php no package to count
ruby no package to count
downloads rank the two languages you ship and cannot rank the four you do not
tickets by account
go 9 tickets from 8 accounts, 2 of them from arcus-metering
java 5 tickets from 5 accounts, 1 of them from fathom-water
php 14 tickets from 6 accounts, 9 of them from agency-blue
ruby 4 tickets from 3 accounts, 2 of them from gale-metering
ranked by tickets: php, go, java, ruby
ranked by accounts: go, php, java, ruby
agents the classifier does not place
curl/8.9.1 15
PostmanRuntime/7.42.0 10
Dart/3.5 (dart:io) 5
30 requests counted as not placed; a guess here would move the ranking without evidence
When not to do this
Do not rank from downloads alone. The registries count the packages you publish, and a language you have never shipped has no number to compare, so a downloads-only ranking is a ranking of the status quo.
Do not read the log as a vote. A single integrator with a busy batch job can send more requests than fifty people with a dashboard. Count distinct API keys beside requests before you trust the raw-call column, as the tickets step counts accounts beside tickets.
Do not add a language because the generator lists it. Parity hands you a runtime matrix per language, and the matrix column in the ranking shows how unequal those are. A Java target carries three times the supported versions of a Go target before the first user arrives.
Do not run this at all with a handful of customers. Ask them. The ranking earns its place when the people you would ask are too many to ask, and the log is the only room they are all in.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0. Every output block is what the command preceding it
printed, over committed fixtures for a fictional API. The registry readers were tested against
responses captured from the five live registries for Stripe’s packages on 2026-10-18, saved under
evidence/live/. The runtime support dates come from the endoflife.date API, saved the same day.
No registry was called while the page’s commands ran.
Footnotes
-
npm’s documentation says the counts come from a map-reduce cluster spun up once a day, after UTC midnight, over the previous day’s logs. So
last-dayis usually yesterday in GMT, and in California it reaches the current day by late afternoon. A bulk query takes 128 packages at a time, and scoped packages are not supported in bulk, which leaves@meterco/sdkto be asked for on its own. ↩︎ Back to text -
PyPI Stats states that all download stats exclude known mirrors and that time series data is retained for 180 days. It adds that nobody should need to fetch the same endpoint more than once a day. Packagist asks every client to send a
User-Agentwith amailto=address, so it can contact you when something goes wrong. It also asks for at most ten concurrent requests and no jobs at midnight or on the hour. ↩︎ Back to text -
The Imported By tab for one major version of Stripe’s Go module reads “Known importers: 50 (displaying 152 packages, including internal and invalid packages)” in its header. Two numbers share one line, with no definition of either. The site’s about page says its data arrives from the module index every few minutes and its source from the module mirror, and mentions no download count anywhere. ↩︎ Back to text
-
The constant in
request.gocarries a comment saying it is not intended to reflect the actual Go version being used. The comment adds that the value was changed at the Go 1.1 release because the former one had been blocked by some intrusion detection systems. The calling card a Go program hands your server over HTTP/1.1 was chosen to get past a doorman. ↩︎ Back to text