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: 65

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 (/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 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). 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 about version, origin, source_host, update_available, and the core key lives here, kept separate from StatusController so the HTTP concerns (auth, query params, response codes) don't tangle with "what does this site actually look like".
  • StatusController checks the pull key via ApiKeyManager::verify() (constant-time comparison) before doing anything else, fails closed (500) if no key has ever been set, then just delegates to StatusCollector.
  • SettingsForm is 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 core block 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/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.
  • The hub stores a working pull key for every registered site, in plain text, in the telemetry_site entity — 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.