drupal/pulsedeck_ai_usage

Pulsedeck service monitor plugin for AI provider subscription quota usage.

Maintainers

Package info

git.drupalcode.org/project/pulsedeck_ai_usage.git

Type:pulsedeck-plugin

pkg:composer/drupal/pulsedeck_ai_usage

Transparency log

Statistics

Installs: 27

Dependents: 1

Suggesters: 0

1.0.x-dev 2026-08-03 13:01 UTC

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.

ClassResponsibility
QuotaWindowOne quota bucket: key, label, percent, reset time, binding flag, provider severity, scope, provenance.
MoneyA minor-units amount with its own currency and exponent. Never assumes USD, never divides by a hardcoded 100.
UsageSnapshotThe parsed result of one poll: windows, spend, raw payload, scale decision.
ScaleInferenceHow the utilization scale was decided, and by which evidence.
AnthropicUsageParserAnthropic payload to UsageSnapshot. Never throws.
EvaluatorConfigOperator-tunable thresholds.
EvaluationStatus, summary, driving window, reason tag, notices.
UsageStatusEvaluatorUsageSnapshot to Evaluation.
UsageStatusStatus vocabulary mirroring ServiceMonitorData::STATUS_*.
CredentialStateWhether the held token permits a request, and whether a lapse means hold-previous-data.
SampleBufferBounded per-window consumption history for the burn-rate rule.
AiUsageMonitorBaseProvider-agnostic Drupal glue: thresholds form, metrics and details assembly, widget, frozen mode.
Plugin/ServiceMonitor/AnthropicUsageThe 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 covers status and metrics only. Re-emitting both verbatim writes no row at all.
  • saveSnapshot() advances last_collected on every cycle regardless, so the instance never trips ServiceStatusResolver's staleness rule.
  • Returning normally rather than throwing leaves consecutive_failures at 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_url would 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.collected was identical across rows written hours apart.
  • getLatestSnapshot() is ORDER 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. PluginBase brings in DependencySerializationTrait, which rebuilds services by writing to properties after unserialization, and a readonly property cannot be written twice. Note that ServiceMonitorBase itself uses non-readonly protected properties, so the earlier concern about readonly properties in the base class does not apply to it as shipped on 1.0.x.
  • PHPStan cannot currently run inside the surrounding Drupal project: the auto-registered mglaman/phpstan-drupal bootstrap crashes on an unrelated canvas_headless / custom_elements dependency. phpstan.neon.dist here is set to level 9 and works in the package's own context, where phpstan-drupal is 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.cap handling and the spend threshold are reasoned rather than measured.