Search by

cyberwoven / telemetry

cyberwoven

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

Statistics

Installs: 64

Dependents: 0

Suggesters: 0

1.0.0 2026-09-03 16:49 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 by modules — 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.
  • StatusController checks the key via ApiKeyManager::verify() (constant-time comparison) before doing anything else, and fails closed (500) if no key has ever been set.
  • SettingsForm is 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/check by 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.