cyberwoven / telemetry
Exposes a lightweight, API-key protected HTTP endpoint reporting basic information about installed modules.
Package info
bitbucket.org/cyberwoven/telemetry
Type:drupal-module
pkg:composer/cyberwoven/telemetry
Requires
- php: >=8.1
Requires (Dev)
None
Suggests
- drush/drush: For CLI key management via telemetry:set-key and telemetry:key-status
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-08 17:50:20 UTC
README
A standalone module. Exposes a single HTTP endpoint that reports whether a given module is installed and its version — read directly from Drupal's own module system, no SSH required.
Where this goes
Copy this whole telemetry/ directory into your custom modules
directory, e.g.:
modules/custom/telemetry/
Installing
drush en telemetry
Set up the API key (once per site)
You can do this either from the command line or from the UI — both go through the same underlying logic, so it doesn't matter which you use.
Command line:
drush telemetry:set-key
Generates a random key, stores it via Drupal's State API (not config — so it won't end up in an exported config file), and prints it once.
Admin UI:
Visit Configuration → System → Telemetry
(/admin/config/system/telemetry, "API key" tab), gated behind the
administer telemetry api key permission. Click "Generate key" —
the new key is shown once, in the status message after submitting.
If telemetry_hub is also enabled, a second "Hub settings" tab appears
next to it — see the telemetry_hub section below.
Either way: copy the key somewhere safe immediately. There's no way
to read it back out later — if you lose it, generate a new one and
update sites.yml (or wherever you're tracking it) to match. Generating
a new key immediately invalidates the old one.
To check whether a key is set without revealing it:
drush telemetry:key-status
Alternative: fix the key in settings.local.php. The State-based key
above lives in the database, so anything that touches the DB wholesale —
drush sql:sync, a fresh site:install, restoring a backup — can
silently change or wipe it. If you'd rather the key never move on its
own, set it in settings.local.php instead:
$settings['telemetry_api_key'] = 'your-key-here';
This overrides State entirely: telemetry:set-key, telemetry:key-status,
and the admin form all detect it and refuse to generate/rotate a key
while it's set, since editing the file is the only way to change it from
then on.
Usage
Check one module:
GET /telemetry/check?module=webform
X-API-Key: <the key from above>
{
"core": {
"version": "11.4.7",
"update_available": true,
"update_type": "update",
"recommended_version": "11.4.8",
"last_checked": "5 hours 32 minutes ago",
"last_cron_run": "2 hours 10 minutes ago"
},
"modules": [
{
"module": "webform",
"status": "Enabled",
"version": "8.x-6.2",
"origin": "contrib",
"source_host": "drupal.org",
"source_url": "https://www.drupal.org/project/webform",
"name": "Webform",
"description": "Enables the creation of webforms and questionnaires.",
"package": "Webform",
"update_available": false,
"update_type": null,
"recommended_version": null
}
]
}
core is always present alongside modules, both here and in the full
list below — it's not specific to whichever module you queried. Its
update fields and last_checked come from the same core Update Status
data described below; both are null if updates have never been checked.
last_cron_run is separate — when cron itself last ran, null if never.
Force a live check for one module, e.g. right after a security advisory drops, instead of waiting for cron:
GET /telemetry/check?module=webform&refresh=1
This fetches just that module's release data from drupal.org — one
network call, typically well under a second — and returns its
now-current status. Only valid alongside module; it's silently ignored
on the full-list call below (checking every tracked project live would
mean dozens of outbound requests in a single response). No-ops quietly
for modules with no drupal.org project to check (custom/in-house code).
Same shape as the full list below, just with one entry in modules.
status is one of:
"Enabled"— installed and turned on"Disabled"— present in the codebase but not installed"Not found"— no such module in the codebase at all, or it's a core module (core isn't tracked bymodules— see below)
origin is "contrib" or "custom", based on which directory the module
lives in (modules/contrib/ vs modules/custom/, including inside
installation profiles). source_host narrows "contrib" further — the
actual code-hosting domain behind the module's Composer package (e.g.
"drupal.org", "github.com", "bitbucket.org"), or null for a
"custom" module, which by definition has no separate package to trace
(see below). source_url is a real, clickable link to that same
package — its Drupal.org project page for a "drupal.org" host, or its
actual repo otherwise — null under the same conditions as
source_host (see below). name, description, and package come straight
from the module's .info.yml. update_available is true/false for
Drupal.org-tracked modules, or null if that can't be determined (see
below). When true, update_type is one of "update", "security",
"unsupported", or "revoked", and recommended_version names the
version to move to; both are null otherwise. All of these fields are
null whenever status is "Not found".
List every module: omit the module parameter entirely to get the
status of every contrib/custom module in the codebase in one call (same
shape, more entries). Core modules are excluded entirely — this endpoint
only tracks contrib and custom in modules (core gets its own top-level
key, see above):
GET /telemetry/check
X-API-Key: <the key from above>
{
"core": {
"version": "11.4.7",
"update_available": true,
"update_type": "update",
"recommended_version": "11.4.8",
"last_checked": "5 hours 32 minutes ago",
"last_cron_run": "2 hours 10 minutes ago"
},
"modules": [
{
"module": "webform",
"status": "Enabled",
"version": "8.x-6.2",
"origin": "contrib",
"source_host": "drupal.org",
"source_url": "https://www.drupal.org/project/webform",
"name": "Webform",
"description": "Enables the creation of webforms and questionnaires.",
"package": "Webform",
"update_available": false,
"update_type": null,
"recommended_version": null
},
{
"module": "devel",
"status": "Disabled",
"version": "5.3.1",
"origin": "contrib",
"source_host": "drupal.org",
"source_url": "https://www.drupal.org/project/devel",
"name": "Devel",
"description": "Various blocks, pages, and functions for developers.",
"package": "Development",
"update_available": true,
"update_type": "security",
"recommended_version": "5.3.2"
},
{
"module": "telemetry",
"status": "Enabled",
"version": "1.0.0",
"origin": "contrib",
"source_host": "bitbucket.org",
"source_url": "https://bitbucket.org/cyberwoven/telemetry",
"name": "Telemetry",
"description": "Exposes a lightweight, API-key protected HTTP endpoint reporting basic information about installed modules.",
"package": "Cyberwoven",
"update_available": null,
"update_type": null,
"recommended_version": null
}
]
}
Missing/wrong API key → 401:
{ "error": "Unauthorized" }
No key configured on this site yet → 500:
{ "error": "No API key has been configured on this site. Run: drush telemetry:set-key, or visit /admin/config/system/telemetry." }
Invalid module parameter → 400:
{ "error": "Invalid \"module\" query parameter. Use lowercase letters, numbers, and underscores only." }
Reporting to a central hub
This module only exposes /telemetry/check — it doesn't know or care
whether anything's calling it. A central telemetry_hub (see below)
can pull from this site on its own schedule, using the exact same API
key configured above. There's nothing extra to set up here: register
this site on the hub with the URL and API key it already has, and the
hub takes it from there.
How it's put together
ApiKeyManager(src/ApiKeyManager.php) is the one place that generates, stores, and verifies the pull key. Both the drush command and the admin form call it — neither duplicates the logic.StatusCollector(src/StatusCollector.php) gathers the actual core/module status data — everything documented above aboutversion,origin,source_host,update_available, and thecorekey lives here, kept separate fromStatusControllerso the HTTP concerns (auth, query params, response codes) don't tangle with "what does this site actually look like".StatusControllerchecks the pull key viaApiKeyManager::verify()(constant-time comparison) before doing anything else, fails closed (500) if no key has ever been set, then just delegates toStatusCollector.SettingsFormis a normal Drupal form behind a real permission — follows the standard submit → redirect → GET flow, so the generated pull key only ever appears in a transient status message, never on a page that could be reloaded or cached with the key still showing.
Where version comes from
Modules released through Drupal.org get a version: line stamped into
their .info.yml by Drupal.org's packaging script — that's what
getAllAvailableInfo() normally reports. In-house modules installed
straight from a VCS repo (no packaging step) never get that line, so
StatusController::resolveComposerVersion() falls back to whatever
Composer actually resolved: it matches the module's real directory
against each installed package's install path (via
Composer\InstalledVersions) and reports that package's version
instead of guessing a package name from the module's machine name.
Where origin comes from, and why core is excluded
StatusController::classifyModule() looks at the module's
Drupal-root-relative path: anything under core/ is core, anything with
a custom path segment is custom, anything with a contrib path segment
is contrib. Core is filtered out everywhere (both the list and a direct
?module= lookup) rather than just labeled, since this endpoint exists to
track the contrib/custom surface a site owner actually manages — not the
~60 modules that ship with every Drupal install regardless.
Note this classifies by where the code physically lives, not who wrote
it: an in-house module pulled in as its own Composer package (like this
one) lands in modules/contrib/ same as any Drupal.org module, so it's
reported as "contrib". Only code committed directly into the site's own
repo under modules/custom/ is "custom".
Where source_host comes from
origin alone can't tell a Drupal.org release apart from an in-house
module pulled in as its own Composer package — both physically live in
modules/contrib/. source_host fills that gap with the actual
code-hosting domain, read from this project's own composer.lock
(source/dist URLs aren't exposed by Composer's runtime
InstalledVersions API, only the lock file retains them — see
StatusCollector::getComposerLockSources()).
This is also why "installed via Packagist" isn't a value you'll ever see
here: Packagist is an index that points at a package's real VCS host
(commonly GitHub), not a host itself, so a Packagist-distributed module
just shows up as "github.com" (or wherever its repository actually
lives) — the same way drupal/core itself resolves to "github.com"
despite being a core Drupal package, because that's genuinely where its
code is hosted. "drupal.org" only shows up for packages that go through
Drupal.org's own Composer facade (ftp.drupal.org / git.drupalcode.org
under the hood, normalized to one value). source_host is null for a
"custom" module, since by definition it has no separate package to
trace in the first place.
Where source_url comes from
source_host tells you where — source_url is an actual link you
can click, derived from the same composer.lock entry. For anything
not on Drupal.org, the lock file's source URL is already a real,
browsable repo page once the trailing .git is stripped (e.g.
https://bitbucket.org/cyberwoven/telemetry.git →
https://bitbucket.org/cyberwoven/telemetry).
Drupal.org packages need more than that: the URL Composer actually
pulls from (https://git.drupalcode.org/project/webform.git) isn't
one a person would want to land on. Instead,
StatusCollector::drupalOrgProjectShortName() derives the project
shortname — from the Composer package name itself for anything
vendor-prefixed drupal/ (drupal/webform → webform, true for every
real Drupal.org release), falling back to parsing it out of that same
git URL otherwise — and builds https://www.drupal.org/project/webform
from it directly.
source_url is null under the same conditions source_host is: no
resolvable package (custom code), or a Drupal.org-style URL with no
derivable project shortname (in practice, shouldn't happen for a real
Drupal.org package).
Where update_available comes from, and why it's often null
This reads core's own Update Status module (update) — specifically
update_calculate_project_data(), the same function behind
/admin/reports/updates. It's read-only: update_get_available(FALSE)
only queues a fetch task when data is missing rather than blocking on a
live request, and the project-status computation is cached for an hour by
core itself, so this doesn't add any network calls of its own.
That data only covers modules with a project: key in their .info.yml
— the value Drupal.org's packaging script stamps in, which is also what
version and the update fields depend on. In-house modules have no such
project, so update_available/update_type/recommended_version are
all null for them (not "false" — it isn't that they're up to date, it's
that there's no upstream release feed to compare against). They're also
null if the update module isn't enabled on the site at all.
update_type maps directly from core's own status constants for the
project: UpdateManagerInterface::NOT_CURRENT → "update", NOT_SECURE
→ "security", REVOKED → "revoked", NOT_SUPPORTED → "unsupported".
recommended_version is whatever core computed as the release to move
to ($project['recommended']); it's only populated alongside an actual
update_type, never for a module that's already current.
The core key
core.version is \Drupal::VERSION — always available regardless of
whether the update module is enabled. core.update_available,
core.update_type, core.recommended_version, and core.last_checked
come from the same update_calculate_project_data() call described above
(core is itself just another project in that data, named drupal), so
they're null under the same conditions: update not enabled, or (for
last_checked specifically) updates never having been checked at all —
reading update.last_check from State, the same value
/admin/reports/updates shows as "Last checked: ... ago".
core.last_cron_run is independent of all that — it's system.cron_last
from State, the same timestamp /admin/reports/status shows for "Cron
maintenance tasks". null if cron has never run on this site.
telemetry_hub: running the central hub
modules/telemetry_hub/ is a submodule, disabled by default — enable it
only on whichever one site you want acting as the fleet-wide
collector. It depends on telemetry but is otherwise independent: any
Drupal 10/11 site can be the hub, including one that's itself also
registered elsewhere.
drush en telemetry_hub
Register a remote site: visit Configuration → System → Telemetry
hub: registered sites (/admin/config/system/telemetry-hub/sites),
gated behind the administer telemetry hub permission, and click Add
site (top right). Unlike the old push model, the hub doesn't generate
anything here — you provide the site's name, its base URL, and its
existing pull API key (whatever that site's own Configuration →
System → Telemetry page already shows it has — see "Set up the API key"
above). There's no one-time-reveal step; this is just a normal editable
field, since it's not a secret the hub created.
How a pull happens: SitePuller::pull() does exactly what you'd do
by hand — a GET to that site's /telemetry/check with its stored key
as X-API-Key, same request documented under "Usage" above. The
response is stored as a new telemetry_report entity (full payload +
received time), the site's "last seen" is updated, and one site failing
(unreachable, wrong key, bad JSON) is logged and skipped rather than
stopping the rest — see the telemetry_hub logger channel
(/admin/reports/dblog).
It runs automatically on this hub site's own hook_cron() — every
registered site gets pulled on every cron run, no separate throttle.
Unlike the old push model, only this one site's cron reliability
matters now, not every remote site's.
Force an immediate pull, e.g. right after registering a site:
drush telemetry-hub:pull # every registered site
drush telemetry-hub:pull 3 # just site 3
or click "Pull now" in that site's row on the Sites dashboard
(Operations dropdown, alongside History/View) — a plain link, not a
form, same CSRF-protected-GET pattern core's own "Run cron" button
on /admin/reports/status uses.
Report retention: right after each successful pull, that site's
oldest reports beyond a configured count are deleted automatically —
storage per site stays bounded no matter how often it gets pulled.
Configure the limit at Configuration → System → Telemetry, "Hub
settings" tab (/admin/config/system/telemetry/hub — only present when
telemetry_hub is enabled), gated behind administer telemetry hub.
Defaults to 200 reports per site; set to 0 to keep every report
forever.
Sites: Reports → Telemetry fleet dashboard
(/admin/reports/telemetry-hub) lists every registered site with when
it was last pulled, its core version and whether an update's available,
and counts of modules with updates (and specifically security updates)
outstanding — all derived from that site's latest report. Each row's
Operations column is a dropdown with three options:
- History — that one site's past reports. Click "View raw" on any report in that history for its full JSON payload exactly as pulled, in a read-only textarea (same display pattern as core's own single config export page) — useful for checking what a remote site actually returned, without needing drush or direct database access. Above the textarea: which site reported it and when, and a link back to that site's history.
- View — that site's details: the full
coreblock from its latest report (version, update status, recommended version, last checked, last cron run) at the top, and every one of its modules in a sortable table below. Unlike every other list here, this one isn't paginated — it's a single site's data, meant to be scanned or sorted as a whole, not paged through. - Pull now — forces an immediate pull for just that site, same as the drush command above, then redirects back here with a status/ error message.
All Modules: a second tab next to "Dashboard", at
/admin/reports/telemetry-hub/modules. Flat, one row per module per
site (so a site with 150 modules contributes 150 rows), rather than the
per-site summary the dashboard shows — useful for answering
"everything, everywhere" questions like which sites are still running
some specific module, rather than drilling into one site at a time.
Columns: Site (links out to that site's own URL, opens in a new tab,
followed by (view) — only the word "view" is a link — to that
site's View page), Module (machine name, as the visible text — linked to source_url
when known: the module's actual Drupal.org project page, or its real
repo for anything else, e.g. bitbucket.org/cyberwoven/telemetry for
this very module; plain text for custom code with no resolvable
package), Version, Available, Status. "Available" combines
recommended_version and update_type into one cell (e.g. 7.0.17
(Update)) when update_available is true, — otherwise. The whole
row gets a color class when an update is available, reusing core's own
Available Updates report classes exactly as-is (/admin/reports/updates's
update/drupal.update.admin library, no overridden styling):
"security"/"revoked"/"unsupported" get its red (color-error),
"update" gets its yellow (color-warning) — a left border always,
background tint on hover/active/focus, same as that report. A row is
left entirely untouched — no class — whenever update_available isn't
true, whether that's false (confirmed current) or null (no
update-tracking data at all, e.g. custom code). Like the dashboard, this is
built from each site's latest report only — a site with no reports
yet contributes no rows. Four filters above the table, combinable: a
text field for site name (substring match against that site's
registered label, not its URL); a text field for module name
(substring match, e.g. admin matches admin_toolbar); a Status
select (Enabled/Disabled/- Any -, exact match); and an Update type
select (- Any -/All types/Update/Security/Unsupported/Revoked). "All
types" matches update_available being exactly true regardless of
which type; the four specific options match update_type exactly.
Submit as plain ?site_name=/?module=/?status=/?update_type=
query parameters, so a filtered view is just a URL you can share or
bookmark. Styled with the same
views-exposed-form class the Content overview page's own filters
use, so it reads consistently with the rest of the admin UI even
though this isn't a View.
Sorting: every column on the Dashboard, All Modules, and per-site
History tables is clickable to sort — same sortable-header convention
as any core admin listing. Since none of these tables are backed by a
live database query (they're built from already-pulled JSON payloads),
sorting happens in PHP against each table's underlying data rather
than a SQL ORDER BY; functionally it looks and behaves the same to
you, just worth knowing if you're digging into the code
(DashboardController::sortEntries()).
Pagination: 50 rows per page on all three tables, using Drupal's
standard pager (#type => pager — the same Prev/Next/page-number
widget as any core admin list, driven by a ?page= query parameter).
A "Showing x to y of n entries" line sits above each table; n
reflects whatever's left after filtering, not the unfiltered total.
Sort and filter are applied before pagination, so page 2 of a sorted
or filtered view is page 2 of that result, not the unsorted/
unfiltered one.
Security notes
- Always call this over HTTPS in production — the API key travels in a plain header, so an HTTP connection would expose it in transit.
- The key is compared with
hash_equals()(constant-time), so a wrong guess can't be timed to leak information about the correct value. - The check endpoint's access is enforced entirely inside the controller
(
_access: 'TRUE'in routing), by design — it isn't meant to require a logged-in Drupal user, since it's called by an external script. The admin settings page is the opposite: a real permission-gated route, since that one's for you, logged in. - Rotate the key any time (CLI or UI) — no restart or deploy needed, it takes effect on the next request.
- Consider also restricting
/telemetry/checkby IP at the web server level (Apache/Nginx config) as a second layer, if that's easy to do on your hosting — defense in depth, not a replacement for the API key. - The hub stores a working pull key for every registered site, in
plain text, in the
telemetry_siteentity — this is a real tradeoff of the pull model, not an oversight: a hub database compromise now exposes read-credentials into every registered site's module/version data, not just a write-only token like a push model would. Treat the hub's database with at least the same care as any single site holding its own API key, and consider that risk multiplied by your whole fleet.