- Introduction
- Installation
- Filtering
- Tagging
- Authorization
- Available Watchers
- Batch Watcher
- BlazeCast Watcher
- Broadcast Watcher
- Cache Watcher
- Authorization Watcher
- AI Watcher
- Command Watcher
- Event Watcher
- Exception Watcher
- HTTP Client Watcher
- Job Watcher
- Queuesadilla Job Watcher
- Dereuromark Job Watcher
- Log Watcher
- Mail Watcher
- Model Watcher
- Mongo Watcher
- Crustum Mongo Watcher
- Mongo Query Log Watcher
- Notification Watcher
- Query Watcher
- Request Watcher
- Schedule Watcher
- Explorator Watcher
- VarDump Watcher
- View Watcher
- MCP Server
- Custom Plugins and Panels
- Displaying User Avatars
CakePHP Speculum makes a wonderful companion to your local CakePHP development environment. Speculum provides insight into the requests coming into your application, exceptions, log entries, database queries, queued jobs, mail, notifications, cache operations, scheduled tasks, broadcasts, and more.
Speculum provides insight into requests, exceptions, log entries, database queries, queued jobs, mail, notifications, cache operations, scheduled tasks, broadcasts, BlazeCast WebSocket traffic, variable dumps, AI agent activity, and more. It is adapted to CakePHP events, queue, mail, and related Crustum plugins.
Install via Composer (crustum/mcp is a hard dependency):
composer require crustum/speculumLoad both Speculum and Mcp. Speculum does not auto-load Mcp; MCP tools need the Mcp plugin loaded.
bin/cake plugin load Crustum/Mcp
bin/cake plugin load Crustum/SpeculumNote
Register both plugins in config/plugins.php (or Application::bootstrap()). Mcp must be loaded before bin/cake speculum mcp / agent tool calls will work.
Tip
After the plugins are loaded, install assets with PluginManifest. Speculum declares Crustum/Mcp as a required manifest dependency — use --with-dependencies so Mcp config/bootstrap are installed too:
bin/cake manifest install --plugin Crustum/Speculum --with-dependenciesThat publishes Speculum's config/speculum.php, migrations, bootstrap append, and the built SPA under webroot/speculum (from the package webroot/frontend build), and installs Mcp's declared assets (config/mcp.php + bootstrap load). Use --all-deps to skip optional-dependency prompts, or --no-dependencies if Mcp assets are already installed. After upgrading Speculum, re-run the install with --force (or the webroot tag alone) so the copied SPA matches the new package build.
bin/cake manifest install --plugin Crustum/Speculum --tag webroot --forceThe dashboard then loads /speculum/app.js with the default Speculum.assets.path of speculum. That path is the simple install for Speculum without host-composed extension panels.
If you prefer Cake’s plugin asset link instead of copying, symlink the plugin webroot and point assets at the Cake layout:
bin/cake plugin assets symlink Crustum/Speculum'assets' => [
'path' => 'crustum/speculum/frontend',
],That serves /crustum/speculum/frontend/app.js. Symlink does not compose third-party Vue panels into the bundle; for those hosts use the host SPA build (resources/host-spa → {APP}/resources/speculum, npm run build) which also writes webroot/speculum and keeps the default assets path.
Or build Vite output elsewhere and point Speculum at it:
// config/speculum.php
'assets' => [
'path' => 'my-build/speculum', // URL under host webroot → /my-build/speculum/app.js
'dir' => ROOT . DS . 'webroot' . DS . 'my-build' . DS . 'speculum', // optional disk override
],The layout links styles.css, app.css, and app.js from that folder.
Alternatively, you can load the plugins in your Application.php:
// In src/Application.php
public function bootstrap(): void
{
parent::bootstrap();
$this->addPlugin('Crustum/Mcp');
$this->addPlugin('Crustum/Speculum');
}After installing via the manifest (or copying config/migrations manually), run migrations to create the tables needed to store Speculum's data:
bin/cake migrations migrateFinally, you may access the Speculum dashboard via the /speculum route.
If you plan to only use Speculum to assist your local development, install it as a development dependency:
composer require crustum/speculum --dev
bin/cake plugin load Crustum/Mcp
bin/cake plugin load Crustum/Speculum
bin/cake manifest install --plugin Crustum/Speculum --with-dependencies
bin/cake migrations migrateThen register the plugins only in non-production environments, for example in config/plugins.php or Application::bootstrap():
if (Configure::read('debug')) {
$this->addPlugin('Crustum/Mcp');
$this->addPlugin('Crustum/Speculum');
}After installing via the manifest, Speculum's primary configuration file is located at config/speculum.php. This configuration file allows you to configure your watcher options. Each configuration option includes a description of its purpose, so be sure to thoroughly explore this file.
If desired, you may enable Speculum's data collection using the enabled configuration option (defaults to off — set SPECULUM_ENABLED=true for local/dev):
'enabled' => filter_var(env('SPECULUM_ENABLED', false), FILTER_VALIDATE_BOOLEAN),All of your application's Speculum configuration is stored under the Speculum Configure key:
return [
'Speculum' => [
'enabled' => filter_var(env('SPECULUM_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
'path' => env('SPECULUM_PATH', 'speculum'),
'driver' => env('SPECULUM_DRIVER', 'database'),
'storage' => [
'database' => [
'connection' => env('SPECULUM_DB_CONNECTION', 'default'),
'chunk' => (int)env('SPECULUM_CHUNK', 1000),
],
],
'ignore_paths' => [
'.well-known*',
'debug-kit*',
'debug_kit*',
],
'ignore_commands' => [
'migrations',
'queue',
'queue worker',
'queue run',
// ...
],
'watchers' => [
// ...
],
],
];Third-party noise (DebugKit, Rhythm, BlazeCast cache keys, and similar) belongs in these config lists / watcher options — not hardcoded in PHP classes. Speculum’s own UI/API paths, speculum_* tables, and Speculum pause-cache keys stay ignored in code.
Without pruning, the speculum_entries table can accumulate records very quickly. To mitigate this, you should schedule the speculum prune console command to run daily (for example with Crustum Scheduling):
use Crustum\Scheduling\Schedule;
$schedule->command('speculum prune')->daily();By default, all entries older than 24 hours will be pruned. You may use the hours option when calling the command to determine how long to retain Speculum data. For example, the following command will delete all records created over 48 hours ago:
bin/cake speculum prune --hours=48You may keep exception entries while pruning other data:
bin/cake speculum prune --hours=48 --keep-exceptionsSee Authorization for the full middleware setup. Who may open /speculum is decided by the host application.
Warning
Ensure Speculum is not publicly reachable in production. Prefer loading the plugin only in local/debug environments, or protect /speculum (and /speculum/api) with your app's own middleware.
Speculum::auth($user) is unrelated: it attaches the current identity to recorded entries (tags like Auth:{id} and the Authenticated User card), not dashboard login.
You may filter the data that is recorded by Speculum via the filter closure. Register it early in your application bootstrap (for example in Application::bootstrap() after the plugin is loaded). By default, without a filter, Speculum records all data when enabled. A typical production filter keeps exceptions, failed jobs, scheduled tasks, slow queries, slow jobs, and entries with monitored tags:
use Cake\Core\Configure;
use Crustum\Speculum\Entry\IncomingEntry;
use Crustum\Speculum\Speculum;
Speculum::filter(function (IncomingEntry $entry) {
if (Configure::read('debug')) {
return true;
}
return $entry->isException() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->isSlowQuery() ||
$entry->isSlowJob() ||
$entry->isSlowRequest() ||
$entry->isSlowCommand() ||
$entry->hasMonitoredTag(Speculum::getRepository());
});Note
IncomingEntry::hasMonitoredTag() requires the entries repository. Pass Speculum::getRepository(). The Monitoring UI alone does not change recording — monitored tags only matter when a filter uses hasMonitoredTag().
While the filter closure filters data for individual entries, you may use the filterBatch method to register a closure that filters all data for a given request or console command. If the closure returns true, all of the entries are recorded by Speculum. The callback receives the queued entries as a PHP array:
use Cake\Core\Configure;
use Crustum\Speculum\Entry\IncomingEntry;
use Crustum\Speculum\Speculum;
Speculum::filterBatch(function (array $entries) {
if (Configure::read('debug')) {
return true;
}
foreach ($entries as $entry) {
if (
$entry->isException() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->isSlowQuery() ||
$entry->isSlowJob() ||
$entry->isSlowRequest() ||
$entry->isSlowCommand() ||
$entry->hasMonitoredTag(Speculum::getRepository())
) {
return true;
}
}
return false;
});Before a request or console command starts recording, Speculum consults top-level config (not per-watcher options):
| Key | Purpose |
|---|---|
ignore_paths |
fnmatch patterns against the HTTP path (leading / stripped). Defaults include DebugKit, and Rhythm. Speculum’s own path / API routes are always ignored in code. |
only_paths |
When non-empty, only matching paths are recorded (everything else is ignored). |
ignore_commands |
Exact Cake command names or first-token prefixes (queue, rhythm, migrations, …). Also used so long-lived workers do not open a recording window for the worker process itself. |
'ignore_paths' => [
'debug-kit*',
'debug_kit*',
],
'ignore_commands' => [
'queue',
'queue worker',
'queue run',
'rhythm',
'rhythm check',
],Prefer one broad mask (rhythm*) over several overlapping ones (rhythm-*, rhythm/*).
Speculum allows you to search entries by "tag". Often, tags are model class names or authenticated user IDs which Speculum automatically adds to entries. Occasionally, you may want to attach your own custom tags to entries. To accomplish this, you may use the Speculum::tag method. The tag method accepts a closure which should return an array of tags. The tags returned by the closure will be merged with any tags Speculum would automatically attach to the entry:
use Crustum\Speculum\Enum\EntryType;
use Crustum\Speculum\Entry\IncomingEntry;
use Crustum\Speculum\Speculum;
Speculum::tag(function (IncomingEntry $entry) {
return $entry->type === EntryType::Request->value
? ['status:' . ($entry->content['response_status'] ?? '')]
: [];
});Watchers queue entries in memory; Speculum flushes them to storage on Server.terminate,
Command.afterExecute, and at process shutdown. Long-working tools (CLI loops, queue
workers, batch jobs) that want to persist records immediately should dispatch the flush
event instead of calling Speculum::store() directly:
use Cake\Event\Event;
use Cake\Event\EventManager;
use Crustum\Speculum\Event\SpeculumFlushEvent;
// every N items inside the tool loop — store immediately
EventManager::instance()->dispatch(new SpeculumFlushEvent());Cake dispatch resolves listeners by event name, so both styles work; use one per call (dual registration, single match — no double store):
new SpeculumFlushEvent()— typed event, nameSpeculum.flush; supportsisThrottled()accessor.new Event('Speculum.flush', null, $data)ornew Event(SpeculumFlushEvent::class, null, $data)— plain events; no Speculum class import needed beyond the constant.
Pass ['throttled' => true] to defer to the worker flush policy
(Speculum.queue.worker_flush_interval / worker_flush_limit) instead of writing on
every loop iteration:
new SpeculumFlushEvent(['throttled' => true]);Store is a no-op on empty queues, so flushing too often is cheap. Host plugins never
need use Crustum\Speculum\Speculum; — when Speculum is not installed the event simply
has no listeners.
Speculum can record every can() / canResult() authorization check made through the AuthorizationServiceInterface. This requires two optional middleware layers that are inserted automatically when the dependencies are loaded:
When authorization/authorization is installed, Speculum inserts two middleware before RequestAuthorizationMiddleware (or before ErrorHandlerMiddleware as a fallback):
SpeculumAuthorizationMiddleware— decorates the Authorization service on the request attribute so everycan()/canResult()call dispatches aSpeculum.Authorization.checkedevent.SpeculumRecordingMiddleware— records the HTTP request/response entry (moved beforeErrorHandlerMiddlewareso controller exceptions are still captured).
When CakeDC Auth is loaded, the decorator also resolves the policy class via PolicyResolver (reflects into the MapResolver to call getPolicy()) and stores it in the entry.
Not part of this plugin. Who may open /speculum is decided by the host application (Authentication / Authorization middleware, admin roles, IP allowlists, or loading the plugin only when debug is true).
Speculum ships a config/permissions.php fragment for CakeDC Auth that sets bypassAuth => true on all Speculum routes so anonymous API clients can reach /speculum/api/* without requiring a logged-in user. Merge it into your host config/permissions.php:
use Cake\Core\Plugin;
$permissions = array_merge(
$permissions,
require Plugin::path('Crustum/Speculum') . 'config' . DS . 'permissions.php',
);Warning
bypassAuth only skips CakeDC's "logged-in user required" check for the HTTP route. OAuth endpoints still validate credentials; authorization endpoints still require session + auth_token. Ensure Speculum is not publicly reachable in production. Prefer loading the plugin only in local/debug environments, or protect /speculum with your app's own middleware.
Speculum::auth($user) is unrelated: it attaches the current identity to recorded entries (tags like Auth:{id} and the Authenticated User card), not dashboard login.
Speculum "watchers" gather application data when a request or console command is executed. You may customize the list of watchers that you would like to enable within your config/speculum.php configuration file:
'watchers' => [
Crustum\Speculum\Watcher\CacheWatcher::class => true,
Crustum\Speculum\Watcher\CommandWatcher::class => true,
// ...
],Some watchers also allow you to provide additional customization options:
'watchers' => [
Crustum\Speculum\Watcher\QueryWatcher::class => [
'enabled' => filter_var(env('SPECULUM_QUERY_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_QUERY_SLOW', 100), // milliseconds
'ignore_connections' => ['debug_kit'],
],
// ...
],A watcher that is missing from watchers, set to false, or has enabled => false does not register and is omitted from the dashboard sidebar. Soft features (Mongo, BlazeCast, …) also require their host dependency; for nav, Speculum checks WatcherRegistry::isSoftAvailable($feature, WatcherClass::class) so an unset soft watcher stays hidden even when the extension/plugin is loaded.
The batch watcher records information about queued batches when BatchQueue.BatchStarted / BatchQueue.BatchFinished fire. Requires crustum/batch-queue.
The BlazeCast watcher records WebSocket fan-out deliveries (bc_delivery) and inbound client messages (bc_message) when BlazeCast events fire. Requires crustum/blazecast. You may disable either stream independently:
'watchers' => [
Crustum\Speculum\Watcher\BlazeCastWatcher::class => [
'enabled' => filter_var(env('SPECULUM_BLAZECAST_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'deliveries' => filter_var(env('SPECULUM_BLAZECAST_DELIVERIES', true), FILTER_VALIDATE_BOOLEAN),
'messages' => filter_var(env('SPECULUM_BLAZECAST_MESSAGES', true), FILTER_VALIDATE_BOOLEAN),
],
// ...
],Long-running BlazeCast servers should flush Speculum on an interval (see BlazeCast speculum_ingest_interval / BLAZECAST_SPECULUM_INGEST_INTERVAL) so queued entries are stored.
The broadcast watcher records broadcast activity when Broadcasting.sent fires. Requires crustum/broadcasting.
The cache watcher records data when a cache key is hit, missed, updated, or forgotten. With ignore_framework enabled (default), Cake core/model/routes/translations keys and session cache keys (session_*) are skipped so Auth session blobs are not stored. Speculum’s own pause-recording keys are always skipped in code. Third-party keys (Rhythm, BlazeCast, and similar) belong in the watcher’s ignore option (fnmatch); one rhythm* mask covers rhythm-… and rhythm-widget-… keys. Use hidden for exact keys whose values should be stored as (REDACTED) while the hit/miss/set is still recorded.
'watchers' => [
Crustum\Speculum\Watcher\CacheWatcher::class => [
'enabled' => filter_var(env('SPECULUM_CACHE_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'hidden' => [],
'ignore_framework' => true,
'ignore' => [
'cake_blazecast:*',
'rhythm*',
],
],
// ...
],The authorization watcher (AuthorizationWatcher) records authorization checks from two sources:
- CakeDC Auth RBAC (
SoftFeature::CakeDCAuth) — listens toAuth.Rbac.checkedandAuth.Authorization.checkedevents. Requirescakedc/cakephp-authenticationorcakedc/cakephp-authorization. - Generic
can()/canResult()(SoftFeature::Authorization) — listens toSpeculum.Authorization.checkedevents dispatched bySpeculumAuthorizationMiddleware. Requirescakephp/authorization.
Each entry captures the user, action/ability, result (allowed/denied), resolved policy class, and request params.
Link checks ($this->AuthLink->link()) are recorded by default only when denied (set link_checks to all to record allowed links too, or summary to aggregate counts). Decorator-based can() / canResult() calls are deduplicated per ability so repeated checks produce a single entry.
'watchers' => [
Crustum\Speculum\Watcher\AuthorizationWatcher::class => [
'enabled' => filter_var(env('SPECULUM_AUTHORIZATION_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'link_checks' => env('SPECULUM_AUTHORIZATION_LINK_CHECKS', 'off'), // 'off' | 'denies' | 'summary' | 'all'
'ignore' => [
['plugin' => 'DebugKit'],
['plugin' => 'Crustum/Speculum'],
['plugin' => 'Crustum/Ignis'],
],
],
// ...
],The ignore option skips authorization checks that match. Each rule is either:
- a string glob matched against
plugin/controller/action(legacy form), or - an associative array of
plugin/prefix/controller/actionglobs, where every given component must match (AND). A*matches any value, including empty (RBAC-style):
'ignore' => [
['plugin' => 'Crustum/Speculum'],
['plugin' => 'Crustum/Speculum', 'controller' => '*', 'action' => '*'], // same as above
['prefix' => 'admin', 'controller' => 'Users', 'action' => 'login'],
['controller' => '*', 'action' => 'login'],
],By default Speculum ignores its own UI/API traffic and DebugKit so the panel stays focused on your application's checks. Because the watcher merges both the CakeDC Auth (cakedc_auth) and the generic authorization entry types under one resource, the Authorization panel lists historical cakedc_auth records alongside new authorization records.
When neither CakeDC Auth nor cakephp/authorization is installed, the watcher silently does nothing.
The AI watcher (AiWatcher) records activity from the Crustum/Ai plugin (soft-gated by SoftFeature::Ai, requires crustum/ai). It subscribes to the plugin's exact Ai.* events and stores each as a Speculum ai entry. The panel appears in the dashboard sidebar as AI once crustum/ai is loaded.
Each entry captures the event category, full event name, invocation id, provider, model, the involved tool (short class), step number, and an is_final flag, plus a sanitizer-safe payload (prompts, messages, tool calls, steps, store/file metadata). Objects are serialized to { class, properties } and the whole tree is run through SensitiveData redaction.
Token-usage metrics (prompt_tokens, completion_tokens, cache_write_input_tokens, cache_read_input_tokens, reasoning_tokens) and continuation_token are counts / provider handles, not secrets — they are intentionally excluded from redaction and stay visible. Real secrets (api_token, access_token, …) remain redacted.
Entries are tagged Ai:<category> and provider:<provider> (when known); operations slower than slow are tagged slow.
| Category | Meaning | Example Ai.* events |
|---|---|---|
agent |
Agent prompts, steps, streams, and agent-level failures | promptingAgent, agentPrompted, streamingAgent, agentStreamed, startingStep, stepCompleted, stepFailed, agentFailedEvent |
tool |
Tool / function calls made by the agent, including approvals and failures | invokingTool, toolInvoked, toolFailed, toolApprovalRequested, toolApprovalResolved |
generation |
Model generations: image, audio, transcription, embeddings, rerank | generatingImage, imageGenerated, generatingAudio, audioGenerated, generatingTranscription, transcriptionGenerated, generatingEmbeddings, embeddingsGenerated, reranking, reranked |
store |
Vector / AI stores and their file membership | creatingStore, storeCreated, storeDeleted, addingFileToStore, fileAddedToStore, removingFileFromStore, fileRemovedFromStore |
file |
File storage operations | storingFile, fileStored, fileDeleted |
failover |
Provider / agent failover | agentFailedOver, providerFailedOver |
'watchers' => [
Crustum\Speculum\Watcher\AiWatcher::class => [
'enabled' => filter_var(env('SPECULUM_AI_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_AI_SLOW', 1000),
'ignore' => [],
'categories' => ['agent', 'tool', 'generation', 'store', 'file', 'failover'],
],
// ...
],categories— only events whose category is in this list are recorded (defaults to all six). Use a subset (for example['tool', 'generation']) to reduce noise.ignore— exactAi.*event names to skip (for example'Ai.toolApprovalRequested').slow— AI operations whose measured duration exceeds this (milliseconds) are taggedslow.
The command watcher records the arguments, options, exit code, and duration whenever a CakePHP console command is executed. Commands slower than 1000 milliseconds are tagged slow. Customize with the watcher's slow option (milliseconds). Exclude individual commands from this watcher with ignore (fnmatch). To prevent Speculum from opening a recording window for an entire CLI process (for example queue worker), use top-level ignore_commands instead.
'watchers' => [
Crustum\Speculum\Watcher\CommandWatcher::class => [
'enabled' => filter_var(env('SPECULUM_COMMAND_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'ignore' => ['cache clear'],
'slow' => (float)env('SPECULUM_COMMAND_SLOW', 1000),
],
// ...
],The event watcher records the payload and broadcast flag for application events. Configure exact event names and/or fnmatch masks in events (for example App.Order.*, Model.*, or *). Empty events records nothing. Framework noise is filtered by default; use Speculum::recordFrameworkEvents() and/or the ignore option to adjust.
'watchers' => [
Crustum\Speculum\Watcher\EventWatcher::class => [
'enabled' => true,
'events' => [
'App.Order.*',
'*',
],
'ignore' => ['App.Noisy.*'],
],
],Note
Masks such as Model.* or * require Speculum's recording EventManager (installed automatically). With * and default framework filtering, only non-framework application events are kept unless you call Speculum::recordFrameworkEvents().
The exception watcher records the data and stack trace for exceptions that are thrown by your application.
The HTTP client watcher (HttpClientWatcher) records outgoing HTTP client requests made by your application via CakePHP's HttpClient events (HttpClient.beforeSend / HttpClient.afterSend). You may ignore hosts via ignore_hosts (fnmatch). Response body storage is capped with size_limit (kilobytes, default 64):
'watchers' => [
Crustum\Speculum\Watcher\HttpClientWatcher::class => [
'enabled' => filter_var(env('SPECULUM_HTTP_CLIENT_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'ignore_hosts' => ['example.com'],
'size_limit' => 64,
],
// ...
],The job watcher records the data and status of any queued jobs dispatched by your application using the CakePHP Queue plugin, including how long each job took to run. Jobs slower than 1000 milliseconds are tagged slow (and content.slow). Customize with the watcher's slow option (milliseconds):
'watchers' => [
Crustum\Speculum\Watcher\Queue\JobWatcher::class => [
'enabled' => filter_var(env('SPECULUM_JOB_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_JOB_SLOW', 1000),
],
// ...
],While a queue worker is processing jobs, Speculum buffers related entries in memory and persists them when Speculum.queue.worker_flush_interval seconds have elapsed since the flush window started (default 1, or env SPECULUM_WORKER_FLUSH_INTERVAL) or when pending entries plus updates reach Speculum.queue.worker_flush_limit (default 2000, or env SPECULUM_WORKER_FLUSH_LIMIT), whichever comes first. Set the interval to 0 to store after every job. Process exit, Command.afterExecute, and HTTP terminate still force an immediate store.
When crustum/cakephp-queue is installed and loaded (Crustum/Queue), Speculum also records jobs on produce via Crustum/Queue.Job.pushed (same Jobs UI / request-command buffer; no mid-request flush). Consume still uses Processor.message.* and links by _uniqueId when present.
cakephp/queue is a suggested dependency (not required). Install it for JobWatcher consume recording and/or Speculum’s own pending-update transport.
When Speculum::store() cannot apply some entry updates, Speculum queues a retry job (delay default 10 seconds, max 3 attempts). Transport is selected via Speculum.queue.transport / SPECULUM_QUEUE_TRANSPORT:
| Value | Backend |
|---|---|
auto (default) |
cakephp/queue → Dereuromark → Queuesadilla |
cakephp |
Cake\Queue\QueueManager |
dereuromark |
Queue.QueuedJobs::createJob (Crustum/Speculum.ProcessPendingUpdates) |
queuesadilla |
Josegonzalez\CakeQueuesadilla\Queue\Queue::push |
Own jobs live under src/Queue/Job/ (cakephp + Queuesadilla) and src/Queue/Task/ (Dereuromark).
When josegonzalez/cakephp-queuesadilla is installed and loaded, Speculum also records Queuesadilla jobs in the same Jobs UI, including duration when available. Uses the same slow threshold (milliseconds) via SPECULUM_JOB_SLOW (default 1000):
'watchers' => [
Crustum\Speculum\Watcher\Queue\QueuesadillaJobWatcher::class => [
'enabled' => filter_var(env('SPECULUM_QUEUESADILLA_JOB_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_JOB_SLOW', 1000),
],
// ...
],When dereuromark/cakephp-queue is installed, Speculum records its jobs in the same Jobs UI via Queue.Job.created|started|completed|failed. Uses the same slow threshold (milliseconds) via SPECULUM_JOB_SLOW (default 1000):
'watchers' => [
Crustum\Speculum\Watcher\Queue\DereuromarkJobWatcher::class => [
'enabled' => filter_var(env('SPECULUM_DEREUROMARK_JOB_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_JOB_SLOW', 1000),
],
// ...
],Produce (Queue.Job.created) stays in the request/command Speculum buffer until that scope stores. Consume uses WorkerFlushPolicy around started → completed/failed.
The log watcher records log data written by your application (Cake Log::write / PSR-3 context).
By default, Speculum records logs at the error level and above, and includes messages from every CakePHP log scope as well as unscoped messages. You may change the minimum level and optionally limit which scopes are recorded in config/speculum.php:
'watchers' => [
Crustum\Speculum\Watcher\LogWatcher::class => [
'enabled' => filter_var(env('SPECULUM_LOG_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'level' => env('SPECULUM_LOG_LEVEL', 'error'), // minimum: debug|info|notice|warning|error|…
'scopes' => null, // null = all Cake scopes; [] = unscoped only; ['payment', 'socket.server'] = list
'include_unscoped' => true, // when scopes is a list: also keep messages with empty scope
],
// ...
],Cake’s own log engines often use scopes => null (unscoped only). Speculum’s engine always listens with Cake scopes => [] (all), then applies the watcher scopes / include_unscoped filter above so you can keep unscoped and named scopes together.
The mail watcher allows you to view an in-browser preview of emails sent by your application along with their associated data. You may also download the email as an .eml file.
CakePHP Mailer does not dispatch send events by default, so Speculum wraps configured transports with SpeculumTransport when the mail watcher is enabled.
The model watcher records model changes whenever CakePHP model events are dispatched. You may specify which model events should be recorded via the watcher's events option:
'watchers' => [
Crustum\Speculum\Watcher\ModelWatcher::class => [
'enabled' => filter_var(env('SPECULUM_MODEL_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'events' => ['Model.afterSave', 'Model.afterDelete'],
],
// ...
],If you would like to record the number of models hydrated during a given request, enable the hydrations option:
'watchers' => [
Crustum\Speculum\Watcher\ModelWatcher::class => [
'enabled' => filter_var(env('SPECULUM_MODEL_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'events' => ['Model.afterSave', 'Model.afterDelete'],
'hydrations' => true,
],
// ...
],Skip third-party ORM traffic with ignore_connections (Cake connection names) and ignore_namespaces (entity class prefixes). Speculum’s own speculum_* tables are always skipped in code:
'watchers' => [
Crustum\Speculum\Watcher\ModelWatcher::class => [
'enabled' => filter_var(env('SPECULUM_MODEL_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'events' => ['Model.afterSave', 'Model.afterDelete'],
'hydrations' => true,
'ignore_connections' => [
'debug_kit',
],
'ignore_namespaces' => [
'DebugKit\\',
],
],
// ...
],When recording created/updated models, Speculum stores dirty attribute changes. By default, keys matching *password*, *token*, *secret*, *api_key*, *apikey* are replaced with (REDACTED). Entity $_hidden is not used (it controls JSON serialization, not security). Hide additional secrets explicitly:
use Crustum\Speculum\Speculum;
Speculum::hideModelAttributes([
'remember_token',
]);When PHP ext-mongodb is loaded, Speculum records MongoDB commands via the driver's public CommandSubscriber APM (same idea as the SQL query watcher). Soft-enabled with extension_loaded('mongodb').
'watchers' => [
Crustum\Speculum\Watcher\Mongo\MongoWatcher::class => [
'enabled' => filter_var(env('SPECULUM_MONGO_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_MONGO_SLOW', 100), // milliseconds
'ignore_commands' => [
'hello',
'ismaster',
'isMaster',
'ping',
'endSessions',
'buildInfo',
'saslStart',
'saslContinue',
'getMore',
'listCollections',
'listIndexes',
'listDatabases',
'collStats',
'dbStats',
'abortTransaction',
'commitTransaction',
'startTransaction',
],
],
// ...
],The Crustum Mongo watcher (CrustumMongoWatcher) records queries from the crustum/cakephp-mongo-odm ODM driver (Crustum\Mongo\Database\Driver\MongoDriver). It wraps the driver's PSR-3 logger with SpeculumMongoQueryLogger so queries are intercepted before the Mongo logger formats them. Soft-enabled when SoftFeature::CrustumMongo is available (the Crustum/Mongo plugin must be loaded).
'watchers' => [
Crustum\Speculum\Watcher\Mongo\CrustumMongoWatcher::class => [
'enabled' => filter_var(env('SPECULUM_CRUSTUM_MONGO_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'ignore_connections' => [
'debug_kit',
'test_mongo',
'test',
],
'slow' => (float)env('SPECULUM_CRUSTUM_MONGO_SLOW', 100),
],
// ...
],Queries are stored as mongo_query entries. Slow queries (above the slow threshold in milliseconds) are tagged slow.
The Mongo Query Log watcher (MongoQueryLogWatcher) records Mongo queries that flow through Cake's Log engine (scopes mongoQueriesLog / mongo.database.queries). It installs a MongoQueryLogEngine log backend that forwards matching entries to CrustumMongoWatcher. Use this when the Crustum Mongo driver logs via Log::write() instead of the PSR-3 logger path.
'watchers' => [
Crustum\Speculum\Watcher\Mongo\MongoQueryLogWatcher::class => [
'enabled' => filter_var(env('SPECULUM_MONGO_QUERY_LOG_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'ignore_connections' => [
'debug_kit',
'test_mongo',
'test',
],
'scopes' => ['mongoQueriesLog', 'mongo.database.queries'],
'slow' => (float)env('SPECULUM_MONGO_QUERY_LOG_SLOW', 100),
],
// ...
],Queries are stored as mongo_query_log entries (separate from the driver-path mongo_query entries) so you can distinguish the two recording paths in the dashboard.
The notification watcher records notifications sent by your application when Model.Notification.sent fires. Requires crustum/notification. If the notification triggers an email and you have the mail watcher enabled, the email will also be available for preview on the mail watcher screen.
The query watcher records the raw SQL, bindings, and execution time for all queries that are executed by your application. The watcher also tags any queries slower than 100 milliseconds as slow. You may customize the slow query threshold using the watcher's slow option (milliseconds).
SQL that targets Speculum storage tables (speculum_*) is always skipped in code. Skip other connections via ignore_connections (for example DebugKit's debug_kit connection). You may also skip specific content types via ignore_content_types (glob patterns via fnmatch):
'watchers' => [
Crustum\Speculum\Watcher\QueryWatcher::class => [
'enabled' => filter_var(env('SPECULUM_QUERY_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'ignore_connections' => [
'debug_kit',
],
'ignore_content_types' => [
'text/event-stream',
],
'slow' => (float)env('SPECULUM_QUERY_SLOW', 100),
],
// ...
],The request watcher records the request, headers, session, response data, and duration associated with any requests handled by the application. Requests slower than 1000 milliseconds are tagged slow. You may limit recorded response data via size_limit (kilobytes), skip methods with ignore_http_methods, skip status codes with ignore_status_codes, skip specific content types with ignore_content_types (glob patterns via fnmatch), and customize the slow threshold (milliseconds):
'watchers' => [
Crustum\Speculum\Watcher\RequestWatcher::class => [
'enabled' => filter_var(env('SPECULUM_REQUEST_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'size_limit' => (int)env('SPECULUM_RESPONSE_SIZE_LIMIT', 64),
'ignore_http_methods' => [],
'ignore_status_codes' => [],
'ignore_content_types' => [
'text/event-stream',
],
'ignore' => [
['plugin' => 'Crustum/Speculum'],
['plugin' => 'Crustum/Ignis'],
],
'slow' => (float)env('SPECULUM_REQUEST_SLOW', 1000),
],
// ...
],The ignore option uses the same structured/glob format as the Authorization watcher — match on plugin / prefix / controller / action (all given components must match; * matches any), or a legacy plugin/controller/action string glob. It composes with the existing ignore_http_methods / ignore_status_codes / ignore_content_types. It is empty by default; Speculum's own API traffic is usually useful to see when debugging Speculum, but add the rules above to hide it (and Ignis, if used).
Content-type matching strips parameters (e.g. ; charset=UTF-8) before comparing, so text/event-stream matches text/event-stream; charset=UTF-8. This prevents Speculum from consuming SSE / AI streaming response bodies, which would break the client connection.
HTTP paths that should never open a recording window (DebugKit, Rhythm, …) are configured under top-level ignore_paths, not on this watcher.
The schedule watcher records the command and output of scheduled tasks when Scheduling.ScheduledTaskFinished (and related events) fire. Requires crustum/cakephp-scheduling.
When crustum/explorator is loaded, Speculum records Explorator searches (Explorator.SearchPerformed) and index writes (Explorator.IndexWritePerformed) in the Searches panel (entry type explorator). Slow operations are tagged using the watcher's slow threshold (milliseconds).
Request and response payloads are stored by default (SQL-style Data cards). Disable either with watcher options / env. Search vectors in options are truncated to dimensions only; response ids are capped. Full Meilisearch HTTP wire dumps are not stored.
'watchers' => [
Crustum\Speculum\Watcher\SearchesWatcher::class => [
'enabled' => filter_var(env('SPECULUM_SEARCHES_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'slow' => (float)env('SPECULUM_SEARCHES_SLOW', 100), // milliseconds
'request' => filter_var(env('SPECULUM_SEARCHES_REQUEST', true), FILTER_VALIDATE_BOOLEAN),
'response' => filter_var(env('SPECULUM_SEARCHES_RESPONSE', true), FILTER_VALIDATE_BOOLEAN),
],
// ...
],The VarDump watcher records Symfony dump() / Speculum::varDump() values into Speculum without printing them to the HTTP or CLI response. When the watcher is disabled, dump() behaves normally and the Var Dumps panel is hidden from the dashboard.
dump($order);
Speculum::varDump($user, $payload); // one entry for the call (both values) + file:lineEach Speculum::varDump(...$values) call stores one entry with a summary, caller file / line (absolute path, same style as Views), and HTML for each value. Plain dump($x) stores one value per call with the same location metadata.
API responses keep absolute file / path and add editor_url via CakePHP Debugger::editorUrl() (honors Debugger.editor / editorBasePath). The SPA receives window.Speculum.root (Cake ROOT) and window.Speculum.editor, shows relative paths in the UI, and can override the project root via Setup (localStorage) so IDE links open your local checkout when Speculum is served remotely.
Sensitive keys are redacted before HTML is stored (same defaults as model sanitization: *password*, *token*, *secret*, *api_key*, redmine_api_key, …). Add patterns under Speculum.sanitize.vardump in config/speculum.php.
'watchers' => [
Crustum\Speculum\Watcher\VarDumpWatcher::class => [
'enabled' => filter_var(env('SPECULUM_VARDUMP_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'max_items' => 250,
'max_string' => 5000,
'max_bytes' => 65536,
],
// ...
],The view watcher records the view name, path, and data keys used when rendering views. Speculum’s own templates are always skipped in code. Skip third-party view paths with fnmatch patterns in ignore_paths:
'watchers' => [
Crustum\Speculum\Watcher\ViewWatcher::class => [
'enabled' => filter_var(env('SPECULUM_VIEW_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
'ignore_paths' => [
'*/DebugKit/*',
'*/cakephp/debug_kit/*',
],
],
// ...
],Speculum registers a dedicated local MCP server (cake-speculum) for AI agents:
| Tool | Purpose |
|---|---|
speculum_search |
Filter entries; returns summaries plus recording meta. Pass a summary id to speculum_entry. |
speculum_entry |
Fetch one entry by id. Optional expand adds related summaries. |
speculum_control |
Monitoring tags (monitor / unmonitor) and recording pause/resume; status lists both. |
| Action | Behavior |
|---|---|
status |
Return enabled, paused, recording, monitored_tags, available_watchers. Default when action is omitted. |
monitor |
Start monitoring a tag (requires tag). Same as Monitoring UI. |
unmonitor |
Stop monitoring a tag (requires tag). |
pause |
Stop recording new entries (dashboard pause / bin/cake speculum pause). |
resume |
Clear the pause flag and record again. |
Monitored tags only change what is kept when the host app uses Speculum::filter with hasMonitoredTag(). Without that filter, Speculum still records everything while enabled.
| Value | Behavior |
|---|---|
none |
Entry only. Default when omitted. |
related |
Other entries from the same request story (same batch_id). Response key related with per-type counts and summary entries. |
family |
Other occurrences of the same exception throw site (same family_hash). Same shape as related. Empty if the entry has no family_hash. |
Expanded rows are summaries only, not full payloads. Content on the primary entry is truncated.
Start the server (requires Crustum/Mcp loaded — see Installation):
bin/cake speculum mcpThis delegates to bin/cake mcp start cake-speculum. Use Ignis MCP for schema, routes, config, and tinker — not Speculum.
Speculum’s first-party screens and API live in this package. You can add your own entry types from a separate CakePHP plugin (or from the host application) without editing Speculum’s Vue sources for each panel. A complete extension shares one stable key (below we use widgets): a watcher that records entries, registration on Speculum bootstrap so the dashboard knows the panel and Speculum exposes /speculum/api/widgets, and Vue screens under a folder that contains register.js. For list/show only, register a type and Speculum’s shared EntryResourcesController serves the JSON. When you need custom actions (preview, download, resolve — the same class of need as Speculum mail or exceptions), register your own controller instead.
Create a normal CakePHP plugin, for example Acme/Widgets, and load it after Crustum/Speculum. Disable the plugin’s own HTTP routes for Speculum’s API: Speculum owns /speculum/api/* and connects your registration.
namespace Acme\Widgets;
use Cake\Core\BasePlugin;
use Cake\Core\Configure;
use Cake\Core\PluginApplicationInterface;
use Crustum\Speculum\Registry\WatcherRegistry;
use Acme\Widgets\EntryTypes;
use Acme\Widgets\Watcher\WidgetWatcher;
class WidgetsPlugin extends BasePlugin
{
protected bool $routesEnabled = false;
public function bootstrap(PluginApplicationInterface $app): void
{
parent::bootstrap($app);
$watchers = Configure::read('Speculum.watchers', []);
if (!is_array($watchers)) {
$watchers = [];
}
if (!array_key_exists(WidgetWatcher::class, $watchers)) {
$watchers[WidgetWatcher::class] = [
'enabled' => filter_var(env('SPECULUM_WIDGET_WATCHER', true), FILTER_VALIDATE_BOOLEAN),
];
Configure::write('Speculum.watchers', $watchers);
}
if (class_exists(WatcherRegistry::class)) {
WatcherRegistry::registerExtensionPanel('widgets', WidgetWatcher::class, [
'type' => EntryTypes::Widget,
]);
}
}
}WatcherRegistry::registerExtensionPanel() stores the SPA nav key and ensures the watcher is registered when Speculum is enabled. With type, Speculum registers an entry resource so routes add POST /speculum/api/widgets and GET /speculum/api/widgets/{id} on Speculum’s EntryResourcesController. Use a short string entry type owned by your package (do not add cases to Speculum’s EntryType enum):
namespace Acme\Widgets;
final class EntryTypes
{
public const Widget = 'widget';
}Extend Crustum\Speculum\Watcher\Watcher, listen to your domain events in register(), and queue payloads with Speculum::recordEntry():
namespace Acme\Widgets\Watcher;
use Acme\Widgets\EntryTypes;
use Cake\Event\EventManager;
use Crustum\Speculum\Entry\IncomingEntry;
use Crustum\Speculum\Speculum;
use Crustum\Speculum\Watcher\Watcher;
class WidgetWatcher extends Watcher
{
public function register(): void
{
EventManager::instance()->on('Widget.afterRender', function ($event, array $payload): void {
Speculum::recordEntry(EntryTypes::Widget, IncomingEntry::make([
'name' => $payload['name'] ?? 'widget',
'summary' => $payload['name'] ?? 'widget',
'payload' => $payload,
]));
});
}
}List/show alone does not need a controller in your plugin. If the panel needs extra endpoints (HTML preview, file download, mark resolved, multi-type index, and similar), keep an EntryController subclass and register plugin + controller instead of type. Speculum connects index/view on the sibling /speculum/api scope (Cake cannot nest another plugin inside Speculum’s plugin() block). Allow your plugin in host RBAC when the app gates by plugin name.
WatcherRegistry::registerExtensionPanel('widgets', WidgetWatcher::class, [
'plugin' => 'Acme/Widgets',
'controller' => 'Widgets',
]);namespace Acme\Widgets\Controller;
use Acme\Widgets\EntryTypes;
use Acme\Widgets\Watcher\WidgetWatcher;
use Crustum\Speculum\Controller\EntryController;
class WidgetsController extends EntryController
{
protected function entryType(): string
{
return EntryTypes::Widget;
}
protected function watcher(): string
{
return WidgetWatcher::class;
}
}Do not pass both type and plugin/controller: when a controller is registered, Speculum uses that controller for list/show.
Ship screens under something like resources/speculum/ inside your package. The folder must contain register.js that imports Speculum’s registerPanel helper (Vite alias speculum-extensions) and registers routes plus optional related-tab metadata. Use sidebar group 2 so the item sits with Speculum’s second nav group (sorted by label with the other group-2 items).
import { registerPanel } from 'speculum-extensions';
import index from './widgets/index.vue';
import preview from './widgets/preview.vue';
import RelatedTable from './widgets/RelatedTable.vue';
registerPanel({
key: 'widgets',
label: 'Widgets',
watcher: 'widgets',
group: 2,
routes: [
{ path: '/widgets', name: 'widgets', component: index },
{ path: '/widgets/:id', name: 'widgets-preview', component: preview },
],
related: {
type: 'widget',
title: 'Widgets',
component: RelatedTable,
match: (item) => item.type === 'widget',
},
});Index and preview screens follow Speculum’s existing patterns: wrap index-screen / preview-screen with resource="widgets" (the API path segment), and link rows to the named preview route. Related tables receive items from Speculum’s related-entries UI the same way first-party related tables do. Do not patch files under Speculum’s resources/frontend for each new panel.
When panels live in Composer packages, the host application rebuilds the Speculum SPA so Vite can import each package’s register.js. Copy Speculum’s resources/host-spa template to {APP}/resources/speculum, list panel roots in panels.json (paths relative to the app root or absolute), run npm install once inside vendor/crustum/speculum/resources/frontend, then from the host folder run npm run build. The script writes {APP}/webroot/speculum by default; keep Speculum.assets.path as speculum so the dashboard loads /speculum/app.js. Add another panel package by appending its resources/speculum path to panels.json and rebuilding. Env overrides and a short setup guide are in resources/host-spa/README.md.
The Speculum dashboard can display a user avatar for the user associated with a given entry. By default, Speculum will retrieve avatars using the Gravatar web service when an email is present on the entry user payload. However, you may customize the avatar URL by registering a callback. The callback receives the user payload array and should return the avatar image URL:
use Crustum\Speculum\Speculum;
Speculum::avatar(function (array $user) {
return !empty($user['id'])
? '/avatars/' . $user['id'] . '.jpg'
: '/generic-avatar.jpg';
});