Architecture Overview
Architecture Overview
Understanding how Queue Metrics for Laravel works under the hood.
High-Level Architecture
┌─────────────────────────────────────────────────────────┐
│ Laravel Application │
├─────────────────────────────────────────────────────────┤
│ │
│ ┌──────────────┐ ┌──────────────────────────┐ │
│ │ Job Queue │────────▶│ Event Listeners │ │
│ │ │ │ - JobProcessing │ │
│ │ ProcessOrder │ │ - JobProcessed │ │
│ │ SendEmail │ │ - JobFailed │ │
│ └──────────────┘ └──────────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Actions Layer │ │
│ │ - RecordJobStart │ │
│ │ - RecordCompletion │ │
│ │ - RecordFailure │ │
│ └───────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Hooks Pipeline │ │
│ │ before_record hooks │ │
│ └───────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Repository Layer │ │
│ │ - JobMetricsRepo │ │
│ │ - QueueMetricsRepo │ │
│ │ - WorkerRepo │ │
│ └───────┬───────────────┘ │
│ │ │
│ ▼ │
│ ┌───────────────────────┐ │
│ │ Storage Driver │ │
│ │ - Redis / Database │ │
│ └───────┬───────────────┘ │
│ │ │
└───────────────────────────────────┼─────────────────────┘
│
┌───────────────┴───────────────┐
│ │
▼ ▼
┌──────────────┐ ┌──────────────┐
│ Redis │ │ Database │
│ │ │ │
│ queue_metrics│ │ queue_job_ │
│ :jobs:* │ │ metrics │
│ :queues:* │ └──────────────┘
│ :workers:* │
└──────────────┘
Core Components
1. Event Listeners
Purpose: Capture Laravel queue events automatically
Location: src/Listeners/
Key Classes:
JobProcessingListener- Captures job startJobProcessedListener- Captures job completionJobFailedListener- Captures job failures
Flow:
// Laravel dispatches events
event(new JobProcessing($connection, $job));
// Listener captures event
JobProcessingListener::handle($event) {
// Extract job data
$jobId = $job->getJobId();
$jobClass = $job->resolveName();
// Delegate to Action
RecordJobStartAction::execute($jobId, $jobClass, ...);
}
Auto-Registration: Listeners are automatically registered via the service provider.
2. Actions Layer
Purpose: Business logic for recording metrics
Location: src/Actions/
Pattern: Command pattern - each action does one thing
Key Classes:
RecordJobStartAction- Record job start timeRecordJobCompletionAction- Record successful completionRecordJobFailureAction- Record job failureCalculateBaselineAction- Calculate performance baselineTransitionWorkerStateAction- Update worker state
Characteristics:
- Readonly classes: Immutable, dependency injection only
- Single responsibility: Each action has one clear purpose
- Hookable: Use
Hookabletrait for pipeline hooks - Type-safe: Full type declarations
Example:
final readonly class RecordJobCompletionAction
{
use Hookable;
public function __construct(
private JobMetricsRepository $repository
) {}
public function execute(
string $jobId,
string $jobClass,
string $connection,
string $queue,
float $durationMs,
float $memoryMb,
float $cpuTimeMs = 0.0,
?string $hostname = null,
): void {
// Check if enabled
if (!config('queue-metrics.enabled')) {
return;
}
// Prepare data
$data = [
'job_id' => $jobId,
'job_class' => $jobClass,
// ...
];
// Run before_record hooks (can transform data)
$data = $this->runHooks('before_record', $data);
// Type assertions for PHPStan
assert(is_array($data));
assert(is_string($data['job_id']));
// ...
// Record to storage
$this->repository->recordCompletion(...);
// Run after_record hooks (cannot transform)
$this->runHooks('after_record', $data);
}
}
3. Hooks System
Purpose: Allow data transformation before storage
Location: src/Support/Concerns/Hookable.php, src/Support/HookManager.php
Pattern: Pipeline pattern via Laravel Pipeline
Flow:
// 1. Action calls runHooks()
$data = $this->runHooks('before_record', $data);
// 2. Hookable trait delegates to HookManager
protected function runHooks(string $context, mixed $payload): mixed {
return app(HookManager::class)->execute($context, $payload);
}
// 3. HookManager executes via Pipeline
HookManager::execute($context, $payload) {
$hooks = $this->getHooksForContext($context);
return app(HookPipeline::class)
->send($payload)
->through($hooks)
->thenReturn();
}
// 4. Each hook transforms payload
Hook::handle($payload, $next) {
$payload['tenant_id'] = tenant('id');
return $next($payload);
}
Key Features:
- Priority-based execution (lower = earlier)
- Chainable transformations
- Can short-circuit pipeline
- Statamic-inspired trait pattern
4. Repository Layer
Purpose: Data access abstraction
Location: src/Repositories/
Pattern: Repository pattern with contracts
Key Interfaces:
JobMetricsRepository- Job execution dataQueueMetricsRepository- Queue depth and healthWorkerRepository- Worker status trackingWorkerHeartbeatRepository- Worker heartbeatsBaselineRepository- Performance baselines
Implementations:
RedisJobMetricsRepositoryRedisQueueMetricsRepositoryRedisWorkerRepository- etc.
Why Repositories?:
- Decouples business logic from storage
- Enables storage driver swapping
- Testable with mocks
- Consistent API across drivers
Example:
interface JobMetricsRepository
{
public function recordStart(
string $jobId,
string $jobClass,
string $connection,
string $queue,
Carbon $startedAt
): void;
public function recordCompletion(
string $jobId,
string $jobClass,
string $connection,
string $queue,
float $durationMs,
float $memoryMb,
float $cpuTimeMs,
Carbon $completedAt,
?string $hostname
): void;
// ...
}
5. Storage Drivers
Purpose: The key-value primitives the repositories are written against
Location: src/Support/
Available Drivers:
RedisMetricsStore- Redis, with TTLs enforced by RedisDatabaseMetricsStore- Eloquent tables (queue_metrics_keys,_hashes,_sets,_sorted_sets) with anexpires_atcolumn per row
Both stores expose the same Redis-shaped operations: strings, hashes, sets,
sorted sets, expire(), scanKeys(), transaction() and pipeline(). Each
storage driver has its own repository implementations (RedisJobMetricsRepository,
DatabaseJobMetricsRepository, and so on) that use its store directly.
Driver Selection:
// config/queue-metrics.php
'storage' => [
'driver' => 'redis', // 'redis' or 'database'
],
The stores are not an extension point. To store metrics somewhere else, bind
your own implementations of the repository contracts through
queue-metrics.repositories; see Extension Points.
6. Service Layer
Purpose: High-level business operations
Location: src/Services/
Key Services:
JobMetricsQueryService- Job metrics queriesQueueMetricsQueryService- Queue health queriesWorkerMetricsQueryService- Worker queriesOverviewQueryService- System-wide overviewTrendAnalysisService- Trend calculationsPrometheusService- Prometheus exportServerMetricsService- Server resource metrics
Pattern: Service layer on top of repositories
Example:
class JobMetricsQueryService
{
public function __construct(
private JobMetricsRepository $repository,
private BaselineRepository $baselineRepository,
private TrendAnalysisService $trendService
) {}
public function getJobMetrics(
string $jobClass,
string $connection = 'default',
string $queue = 'default'
): JobMetricsData {
// Get raw metrics from repository
$metrics = $this->repository->getMetrics($jobClass, $connection, $queue);
// Get baseline for comparison
$baseline = $this->baselineRepository->getBaseline($connection, $queue, $jobClass);
// Get trend analysis
$trend = $this->trendService->analyzeJobTrend($jobClass, $connection, $queue);
// Combine into DTO
return new JobMetricsData(
jobClass: $jobClass,
connection: $connection,
queue: $queue,
metrics: $metrics,
baseline: $baseline,
trend: $trend,
);
}
}
7. Data Transfer Objects (DTOs)
Purpose: Type-safe, immutable data structures
Location: src/DataTransferObjects/
Pattern: Readonly DTOs
Key DTOs:
JobMetricsData- Complete job metricsQueueMetricsData- Queue health dataDurationStatsDTO- Duration statisticsMemoryStatsDTO- Memory statisticsCpuStatsDTO- CPU time statisticsThroughputStatsDTO- Throughput metricsHealthDataDTO- Health assessmentTrendDataDTO- Trend analysisBaselineDataDTO- Baseline comparison
Characteristics:
- Readonly: Immutable after creation
- Type-safe: Full type declarations
- Self-documenting: Clear property names
- Serializable: Can convert to/from arrays
Example:
final readonly class JobMetricsData
{
public function __construct(
public string $jobClass,
public string $connection,
public string $queue,
public int $totalProcessed,
public int $totalFailed,
public float $failureRate,
public DurationStatsDTO $duration,
public MemoryStatsDTO $memory,
public CpuStatsDTO $cpu,
public ThroughputStatsDTO $throughput,
public ?FailureInfoDTO $lastFailure,
public ?TrendDataDTO $trend,
public ?BaselineDataDTO $baseline,
) {}
public function toArray(): array {
return [
'job_class' => $this->jobClass,
'connection' => $this->connection,
// ...
];
}
}
8. Events System
Purpose: Notify about significant occurrences
Location: src/Events/
Pattern: Observer pattern via Laravel events
Key Events:
MetricsRecorded- After metrics recordedWorkerEfficiencyChanged- Worker efficiency changeHealthScoreChanged- Queue health changeBaselineRecalculated- Baseline updatedQueueDepthThresholdExceeded- Queue depth high
Flow:
// After recording metrics
event(new MetricsRecorded($metricsData));
// Listeners react
class SendMetricsToDatadog {
public function handle(MetricsRecorded $event) {
Datadog::gauge('queue.duration', $event->metrics->duration->average);
}
}
See Events for detailed usage.
Data Flow
Job Execution Flow
1. Job Dispatched
└─▶ Laravel adds to queue
2. Worker Picks Up Job
└─▶ JobProcessing event
└─▶ JobProcessingListener
└─▶ RecordJobStartAction
└─▶ before_record hooks
└─▶ JobMetricsRepository::recordStart()
└─▶ Redis/Database storage
3. Job Executes
└─▶ Your job code runs
4a. Job Succeeds
└─▶ JobProcessed event
└─▶ JobProcessedListener
└─▶ RecordJobCompletionAction
└─▶ before_record hooks
└─▶ JobMetricsRepository::recordCompletion()
└─▶ Storage
└─▶ after_record hooks
└─▶ MetricsRecorded event
4b. Job Fails
└─▶ JobFailed event
└─▶ JobFailedListener
└─▶ RecordJobFailureAction
└─▶ before_record hooks
└─▶ JobMetricsRepository::recordFailure()
└─▶ Storage
└─▶ after_record hooks
Query Flow
1. Facade Call
QueueMetrics::getJobMetrics($jobClass)
└─▶ QueueMetrics facade
└─▶ Routes to JobMetricsQueryService
2. Service Layer
JobMetricsQueryService::getJobMetrics()
├─▶ JobMetricsRepository::getMetrics()
│ └─▶ Storage driver fetch
├─▶ BaselineRepository::getBaseline()
│ └─▶ Storage driver fetch
└─▶ TrendAnalysisService::analyzeJobTrend()
└─▶ Calculate trends
3. Data Assembly
├─▶ Create DTOs
├─▶ Calculate derived metrics
└─▶ Return JobMetricsData
4. Response
└─▶ Return to caller
Queue Detection Strategy
The package uses a 3-layer fallback strategy for maximum compatibility:
Layer 1: Laravel 12.19+ Native API (Best)
if (method_exists($queue, 'pendingSize')) {
$pending = $queue->pendingSize();
$delayed = $queue->delayedSize();
$reserved = $queue->reservedSize();
}
Layer 2: Driver-Specific (Good)
// Redis
if ($driver === 'redis') {
$pending = $redis->llen("queues:$queue");
$delayed = $redis->zcard("queues:$queue:delayed");
$reserved = $redis->zcard("queues:$queue:reserved");
}
// Database
if ($driver === 'database') {
$pending = DB::table('jobs')
->where('queue', $queue)
->whereNull('reserved_at')
->count();
}
Layer 3: Generic Fallback (Basic)
// Use size() method (only pending count)
$size = $queue->size();
This ensures the package works across all Laravel versions and queue drivers.
Storage Schema
Redis Keys Structure
queue_metrics:{type}:{connection}:{queue}:{identifier}
Examples:
queue_metrics:jobs:redis:default:App\Jobs\ProcessOrder
queue_metrics:durations:redis:default:App\Jobs\ProcessOrder
queue_metrics:queue_snapshot:redis:default
queue_metrics:baseline:redis:default:App\Jobs\ProcessOrder
queue_metrics:worker:worker-12345
The prefix comes from queue-metrics.storage.prefix. The database driver uses
the same keys without the prefix, because its tables already namespace them.
Escaped Segments
Segments are joined with :, so a segment that itself contains : would be
read back with the wrong boundaries. Since v3.4.1 every segment is escaped
before it is joined: ~ becomes ~~, then : becomes ~c. A queue named
reports:nightly is stored as:
queue_metrics:jobs:redis:reports~cnightly:App\Jobs\ProcessOrder
Names with neither character, which is nearly all of them, are stored
unchanged. Unescape a segment by reversing the two replacements. Worker IDs
on Horizon contain the supervisor name (master:supervisor-1), so their
worker keys always carry the escape. When you read keys directly, look them
up with the escaped name; Cbox\LaravelQueueMetrics\Support\MetricsKeyBuilder
builds keys with the same escaping.
Discovery Keys
The queues and job classes the package has seen are kept in two sorted sets, scored by the Unix time each entry was last seen:
queue_metrics:discovery:queues:seen # members: redis:default
queue_metrics:discovery:jobs:seen # members: redis:default:App\Jobs\ProcessOrder
An entry not seen for the aggregated TTL is dropped on the next read. Before
v3.5.0 these were plain sets at queue_metrics:discovery:queues and
queue_metrics:discovery:jobs; v3.5.0 still reads those during an upgrade but
no longer writes them. See Queue and Job Discovery for the
details and the upgrade path.
Database Tables
The database driver stores the same keys, without the prefix, in four tables
that mirror the Redis data types (named here with the default queue_metrics
prefix). Every row carries its own expires_at, which
reads respect and queue-metrics:cleanup-database purges.
-- Strings
queue_metrics_keys: key (primary), value, expires_at, updated_at
-- Hashes, stored as JSON
queue_metrics_hashes: key (primary), data, expires_at, updated_at
-- Sets, one row per member; expires_at is per member
queue_metrics_sets: key, member, created_at, expires_at
-- Sorted sets, one row per member
queue_metrics_sorted_sets: key, member, score, expires_at, updated_at
Extension Points
The architecture provides several extension points:
- Custom Repositories: Bind your own implementation of a repository
contract (
JobMetricsRepository,QueueMetricsRepository, ...) inqueue-metrics.repositories - Custom Hooks: Register via
QueueMetrics::hook() - Event Listeners: React to domain events
- Custom Services: Build on repository layer
- Custom DTOs: Extend data structures
Performance Characteristics
Per-Job Overhead:
- Event listener: ~0.1ms
- Action execution: ~0.5ms
- Hook pipeline: ~0.1-0.5ms per hook
- Repository storage: ~0.5-1ms (Redis), ~5-10ms (Database)
- Total: ~1-2ms per job (Redis), ~5-15ms (Database)
Memory Overhead:
- Package classes: ~5-10MB (loaded once)
- Per-job data: ~1-2KB in storage
Query Performance:
- Facade calls: ~1-5ms (Redis), ~10-50ms (Database)
- Overview generation: ~10-50ms depending on queue count
- Trend analysis: ~50-200ms depending on data volume
Next Steps
- Performance Tuning - Optimize for scale