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-01 23:22:30 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 API key
(/admin/config/system/telemetry), gated behind the
administer telemetry api key permission. Click "Generate key" —
the new key is shown once, in the status message after submitting.
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",
"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). 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",
"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",
"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",
"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." }
How it's put together
ApiKeyManager(src/ApiKeyManager.php) is the one place that generates, stores, and verifies the key. Both the drush command and the admin form call it — neither duplicates the logic.StatusControllerchecks the key viaApiKeyManager::verify()(constant-time comparison) before doing anything else, and fails closed (500) if no key has ever been set.SettingsFormis a normal Drupal form behind a real permission — follows the standard submit → redirect → GET flow, so the generated 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
StatusController::getComposerLockHosts()).
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 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.
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.