drupal / pulsedeck_ai_usage
Pulsedeck service monitor plugin for AI provider subscription quota usage.
Package info
git.drupalcode.org/project/pulsedeck_ai_usage.git
Type:pulsedeck-plugin
pkg:composer/drupal/pulsedeck_ai_usage
Requires
- php: >=8.3
- drupal/pulsedeck: ^1.0
Requires (Dev)
- phpunit/phpunit: ^11
This package is auto-updated.
Last update: 2026-08-03 11:26:59 UTC
README
A Pulsedeck service monitor plugin package that surfaces AI subscription quota consumption as a dashboard widget with meaningful OK / WARNING / CRITICAL semantics. Anthropic is the first provider; the parsing and evaluation core is provider-agnostic so a second provider needs no change to it.
Layout
The parsing and evaluation core is plain PHP with zero framework dependencies, so all of it is unit testable without a Drupal bootstrap. The plugin and its shared base class sit on top and are the only Drupal-aware classes.
| Class | Responsibility |
|---|---|
QuotaWindow | One quota bucket: key, label, percent, reset time, binding flag, provider severity, scope, provenance. |
Money | A minor-units amount with its own currency and exponent. Never assumes USD, never divides by a hardcoded 100. |
UsageSnapshot | The parsed result of one poll: windows, spend, raw payload, scale decision. |
ScaleInference | How the utilization scale was decided, and by which evidence. |
AnthropicUsageParser | Anthropic payload to UsageSnapshot. Never throws. |
EvaluatorConfig | Operator-tunable thresholds. |
Evaluation | Status, summary, driving window, reason tag, notices. |
UsageStatusEvaluator | UsageSnapshot to Evaluation. |
UsageStatus | Status vocabulary mirroring ServiceMonitorData::STATUS_*. |
CredentialState | Whether the held token permits a request, and whether a lapse means hold-previous-data. |
SampleBuffer | Bounded per-window consumption history for the burn-rate rule. |
AiUsageMonitorBase | Provider-agnostic Drupal glue: thresholds form, metrics and details assembly, widget, frozen mode. |
Plugin/ServiceMonitor/AnthropicUsage | The Anthropic plugin: request, credential gate, config form, remote-credential allow-list. |
Running the tests
Unit tests need no database, no Drupal and no network:
# Standalone, once the package has its own vendor directory.
composer install
vendor/bin/phpunit
# Or with any PHPUnit 11 binary, for example the surrounding project's.
/path/to/project/vendor/bin/phpunit -c phpunit.xml.dist
tests/bootstrap.php uses the local Composer autoloader when one exists and
falls back to a minimal PSR-4 autoloader otherwise, which is what makes the
second form work.
Kernel tests need the host project's Drupal bootstrap. A pulsedeck-plugin
package is not a Drupal extension, so Drupal's test discovery does not find
them; pass the path explicitly:
vendor/bin/phpunit -c tests/phpunit.xml.dist --no-coverage \
vendor/drupal/pulsedeck_ai_usage/tests/src/Kernel/AnthropicUsageTest.php
No kernel test touches the network; the HTTP client is replaced with a Guzzle
MockHandler, and the frozen-mode tests install a handler that fails the test if
it is called at all.
Coding standards:
vendor/bin/phpcs --standard=Drupal,DrupalPractice --extensions=php src/ tests/
Notes on the Anthropic payload
The endpoint is GET https://api.anthropic.com/api/oauth/usage. It is
undocumented, unversioned, and has already changed shape once. Four properties of
it drove most of the design and are each worth knowing before changing this code.
Window keys are open-ended
The live payload captured on 2026-07-30 contained six previously undocumented
codename windows (tangelo, iguana_necktie, omelette_promotional,
nimbus_quill, cinder_cove, amber_ladder), all NULL. Discovery is therefore
structural, not nominal: any top-level value that is an array carrying a
utilization key is a window. There is a short exclusion list for non-windows
that happen to have that shape (extra_usage), but no list of window keys.
is_active does not mean "enforced"
In the live payload the 5-hour session window at 11 % has is_active: false
while the weekly window at 62 % has true. It marks the currently binding
window. Prior art reads it as "this limit applies" and skips the rest, which
silently discards the session limit entirely. Here it maps to
QuotaWindow::$isBinding and is used for labelling only; it never excludes a
window from evaluation.
utilization is inconsistently scaled
Some accounts report 0-1, others 0-100. The obvious guard,
u <= 1 ? u * 100 : u, misreads a genuine 1 % as 100 %. The scale is inferred
from the payload as a whole instead, preferring limits[].percent as an oracle
because that field is reliably 0-100. See AnthropicUsageParser::inferScale()
and ScaleInferenceTest::testOracleBeatsAnyPerValueGuard(), which pins two
fixtures reporting the same raw 1.0 with two different meanings.
Because merged windows take limits[].percent verbatim, the inferred scale only
ever affects windows that have no limits[] counterpart.
Provider severity is a floor, not a hint
limits[].severity and spend.severity carry Anthropic's own verdict. Local
thresholds may escalate past it; nothing may de-escalate it, including the
imminent-reset relief rule. An unrecognized severity value yields OK plus a
recorded notice rather than an exception or a guessed escalation.
Frozen mode
When the access token lapses the collector must not change the reported status. That is not the framework default, and getting there depends on three properties of Pulsedeck that were verified by reading it:
DataStorage::saveSnapshot()inserts a row only when the checksum changes, and the checksum coversstatusandmetricsonly. Re-emitting both verbatim writes no row at all.saveSnapshot()advanceslast_collectedon every cycle regardless, so the instance never tripsServiceStatusResolver's staleness rule.- Returning normally rather than throwing leaves
consecutive_failuresat zero, so it never trips the failure rule either.
The freeze notice therefore rides in summary, which the checksum excludes:
visible to a human, invisible to change detection. The hold is bounded by
max_freeze_seconds (default 24h, 0 for unbounded); past the cap the status
degrades to UNKNOWN so a broken refresh cannot present plausible data forever.
An absent credential is deliberately not held over: freezing is only correct
for one that worked and lapsed. A token that was never supplied, or was cleared,
is a configuration problem and reports UNKNOWN.
Where the sample buffer lives, and why not in the snapshot
The burn-rate buffer is stored in its own key-value collection
(pulsedeck_ai_usage.samples), not carried in the snapshot's details.
The obvious design does not work, and fails silently. saveSnapshot() only
inserts a row when the checksum changes, and details are excluded from the
checksum precisely so volatile data cannot churn it. A buffer carried in details
is therefore written once and then frozen at whatever the last status change left
behind: every later cycle reads the same stale series, appends one sample, and
projects a rate anchored to an arbitrarily old point. The rule would look like it
worked while being permanently blind. A kernel test pins the buffer growing to
its twelve-sample cap across repeated collections, which is what caught this.
Snapshots still carry a copy of the buffer in details because it is useful when
inspecting a stored row, but the key-value entry is authoritative.
The stored form is compact — one "<ts>:<percent>,…" string per window — because
the nested-array form measured 1917 bytes for the live account's three windows at
twelve samples each, which alone overran the whole details budget. The compact
form measures 582 bytes.
Credential supply
The token is short lived, roughly ten hours, and is refreshed outside Drupal by
whatever tool owns it. The plugin implements
Drupal\pulsedeck\Plugin\RemoteCredentialInterface and returns the narrow
allow-list ['oauth_token', 'oauth_expires'], so a cron can POST a fresh value
to Pulsedeck's credential ingest endpoint:
*/15 * * * * jq -c '{oauth_token: .anthropic.access, oauth_expires: .anthropic.expires}' \
~/.local/share/opencode/auth.json \
| curl -sS -X POST --fail-with-body \
-H "X-Pulsedeck-Key: <KEY>" -H "Content-Type: application/json" \
--data-binary @- \
https://<HOST>/pulsedeck/ingest/credential/anthropic_usage/default
Two rules that are not negotiable:
- The plugin never refreshes the token. The refresh token is single use, so rotating a pair shared with an editor would break that editor's authentication. Rotation stays with whoever already owns it.
- The allow-list never contains a connection target. A remotely writable
base_urlwould let a caller redirect the collector, and the credential with it, to a host of their choosing.
extractConfigValues() carries oauth_expires over from the stored
configuration on every save. There is no form field for it, and without that the
act of saving the form would erase what the ingest endpoint had written.
Which clock, and why it matters
Every clock reading uses TimeInterface::getCurrentTime(). Never
getRequestTime().
This is not stylistic. getRequestTime() is captured once per process and never
advances, and a collector runs from cron, a queue worker, a one-shot Drush
command and a long-running pulsedeck:daemon loop. In the daemon it is
frozen at the daemon's start time, which caused a live fault:
- Every snapshot the daemon wrote was stamped with the daemon's start time, so
pulsedeck_snapshot.collectedwas identical across rows written hours apart. getLatestSnapshot()isORDER BY collected DESC LIMIT 1, so with those ties it returned an arbitrary row — and frozen mode, which re-emits the previous snapshot, therefore held arbitrary data.- Every reset countdown was computed from the frozen value, so widgets showed an identical "resets in 3h 50m" on rows hours apart.
- Every burn-rate sample landed on the same timestamp, so the elapsed delta was zero and the projection was discarded. The rule looked alive and computed nothing.
- The freeze cap could never trip, because the elapsed hold never grew.
ClockTest is the regression suite. It substitutes a TimeInterface double
whose request clock is pinned an hour behind a current clock that advances on
demand, because a kernel test serves one request and the real service returns the
same value from both — which is precisely why the original bug survived a green
suite. Reverting the fix turns nine of its ten tests red.
Pulsedeck's own DataStorage and ServiceStatusResolver use getCurrentTime()
for the same reason. last_collected is written by core with that clock, which
is why framework staleness never fired and the fault stayed invisible to the
dashboard's own health signal.
Rate limiting
The endpoint is aggressively rate limited; a credential-free probe returned 429. A 429 is deliberately not treated as a credential event — the credential is fine, the account is over its request budget — so it fails the cycle and the framework's failure tracking applies, rather than holding stale data.
Before failing, a backoff window is recorded in its own key-value collection
(pulsedeck_ai_usage.backoff). While that window is open the collector does not
send a request at all: it cannot succeed, and it would spend another request
against the very budget that is exhausted. A provider-supplied Retry-After
wins, in either the seconds or the HTTP-date form, clamped to an hour so an
absurd value cannot park the collector. Otherwise the delay doubles per
consecutive rejection from 60s to a 1800s ceiling, with equal jitter so several
instances rejected in the same second do not all return in the same second. A
successful cycle clears the state.
The credential gate runs before the backoff gate, so a lapsed token still yields held data rather than a failure while the account is rate limited.
Notes for later
- All injected plugin properties are non-readonly
protected.PluginBasebrings inDependencySerializationTrait, which rebuilds services by writing to properties after unserialization, and a readonly property cannot be written twice. Note thatServiceMonitorBaseitself uses non-readonlyprotectedproperties, so the earlier concern about readonly properties in the base class does not apply to it as shipped on1.0.x. - PHPStan cannot currently run inside the surrounding Drupal project: the
auto-registered
mglaman/phpstan-drupalbootstrap crashes on an unrelatedcanvas_headless/custom_elementsdependency.phpstan.neon.disthere is set to level 9 and works in the package's own context, wherephpstan-drupalis not installed. - Still unverified against a real account: a capped overage spend. No account
with a spend limit has been observed, so the
spend.limit/spend.caphandling and the spend threshold are reasoned rather than measured.