diff --git a/README.md b/README.md index a90c3df..9b12c02 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,10 @@ Track page views, API calls, user signups, or any other countable events. - [Using the Redis Driver](#using-the-redis-driver) - [Usage](#usage) - [Recording Metrics](#recording-metrics) + - [Recording with Values](#recording-with-values) - [Recording with Categories](#recording-with-categories) - [Recording with Dates](#recording-with-dates) + - [Recording Hourly Metrics](#recording-hourly-metrics) - [Recording for Models](#recording-for-models) - [Recording with Custom Attributes](#recording-with-custom-attributes) - [Capturing & Committing](#capturing--committing) @@ -148,7 +150,7 @@ Which ever method you use, metrics are recorded in the same way. Use whichever y For the rest of the documentation, we will use the `metric` helper for consistency and brevity. -### Metric Values +### Recording with Values By default, metrics have a value of `1`. You may specify a custom value in the `record` method: @@ -207,6 +209,61 @@ metric('jobs:completed') ->record(1250); ``` +### Recording Hourly Metrics + +By default, metrics are recorded at the **daily** level. For metrics that require hour-level granularity, you may use the `hourly()` method: + +```php +// Track API requests by hour +metric('api:requests') + ->hourly() + ->record(); +``` + +Hourly metrics include the hour (0-23) in addition to the year, month, and day, allowing you to track metrics at a more granular level: + +```php +use Carbon\Carbon; + +// Record API requests for a specific hour +metric('api:requests') + ->date(Carbon::parse('2025-10-19 14:30:00')) + ->hourly() + ->record(); + +// This will be stored with hour = 14 +``` + +Hourly metrics are stored separately from daily metrics, even for the same metric name: + +```php +metric('page:views')->record(); // Daily metric (hour = null) +metric('page:views')->hourly()->record(); // Hourly metric (hour = current hour) +``` + +You can query hourly metrics using the `thisHour()`, `lastHour()`, and `onDateTime()` methods: + +```php +use DirectoryTree\Metrics\Metric; + +// Get metrics for this hour +$metrics = Metric::thisHour()->get(); + +// Get metrics for last hour +$metrics = Metric::lastHour()->get(); + +// Get metrics for a specific date and hour +$metrics = Metric::onDateTime(Carbon::parse('2025-10-19 14:00:00'))->get(); + +// Get API requests for the current hour +$requests = Metric::thisHour() + ->where('name', 'api:requests') + ->sum('value'); +``` + +> [!tip] +> Use hourly metrics sparingly, as they create 24x more database rows than daily metrics. Reserve hourly tracking for metrics that genuinely benefit from hour-level granularity. + ### Recording for Models Associate metrics with Eloquent models using the `HasMetrics` trait: @@ -300,16 +357,16 @@ metric('api:requests') Custom attributes are included in the metric's uniqueness check, meaning metrics with different attribute values are stored separately: ```php -metric('page_views')->with(['source' => 'google'])->record(); // Creates metric #1 -metric('page_views')->with(['source' => 'facebook'])->record(); // Creates metric #2 -metric('page_views')->with(['source' => 'google'])->record(); // Increments metric #1 +metric('page:views')->with(['source' => 'google'])->record(); // Creates metric #1 +metric('page:views')->with(['source' => 'facebook'])->record(); // Creates metric #2 +metric('page:views')->with(['source' => 'google'])->record(); // Increments metric #1 ``` This allows you to segment and analyze metrics by any dimension: ```php // Get page views by source -$googleViews = Metric::where('name', 'page_views') +$googleViews = Metric::where('name', 'page:views') ->where('source', 'google') ->sum('value'); @@ -322,7 +379,7 @@ $conversions = Metric::thisMonth() // Get mobile vs desktop traffic $mobileViews = Metric::today() - ->where('name', 'page_views') + ->where('name', 'page:views') ->where('device', 'mobile') ->sum('value'); ``` @@ -334,7 +391,7 @@ use DirectoryTree\Metrics\MetricData; use DirectoryTree\Metrics\Facades\Metrics; Metrics::record(new MetricData( - name: 'page_views', + name: 'page:views', additional: [ 'source' => 'google', 'country' => 'US', @@ -347,7 +404,7 @@ Or with the `PendingMetric` class: ```php use DirectoryTree\Metrics\PendingMetric; -PendingMetric::make('page_views') +PendingMetric::make('page:views') ->with(['source' => 'google', 'country' => 'US']) ->record(); ``` diff --git a/database/migrations/2025_10_19_184728_add_hour_to_metrics_table.php b/database/migrations/2025_10_19_184728_add_hour_to_metrics_table.php new file mode 100644 index 0000000..55ae365 --- /dev/null +++ b/database/migrations/2025_10_19_184728_add_hour_to_metrics_table.php @@ -0,0 +1,28 @@ +unsignedTinyInteger('hour')->nullable()->after('day'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('metrics', function (Blueprint $table) { + $table->dropColumn('hour'); + }); + } +}; diff --git a/database/migrations/2025_10_19_192524_add_hour_to_date_index_on_metrics_table.php b/database/migrations/2025_10_19_192524_add_hour_to_date_index_on_metrics_table.php new file mode 100644 index 0000000..b4d62ad --- /dev/null +++ b/database/migrations/2025_10_19_192524_add_hour_to_date_index_on_metrics_table.php @@ -0,0 +1,30 @@ +dropIndex(['year', 'month', 'day']); + $table->index(['year', 'month', 'day', 'hour']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('metrics', function (Blueprint $table) { + $table->dropIndex(['year', 'month', 'day', 'hour']); + $table->index(['year', 'month', 'day']); + }); + } +}; diff --git a/src/ArrayMetricRepository.php b/src/ArrayMetricRepository.php index 549391b..4ea918d 100644 --- a/src/ArrayMetricRepository.php +++ b/src/ArrayMetricRepository.php @@ -34,8 +34,15 @@ public function add(Measurable $metric): void $metric->name(), $metric->category(), $existing->value() + $metric->value(), - CarbonImmutable::create($existing->year(), $existing->month(), $existing->day()), - $metric->measurable() + CarbonImmutable::create( + $existing->year(), + $existing->month(), + $existing->day(), + $existing->hour() ?? 0 + ), + $metric->measurable(), + $metric->additional(), + $existing->hour() !== null ); } else { $this->metrics[$key] = $metric; diff --git a/src/Jobs/RecordMetric.php b/src/Jobs/RecordMetric.php index 7c8580f..6a4aeba 100644 --- a/src/Jobs/RecordMetric.php +++ b/src/Jobs/RecordMetric.php @@ -49,6 +49,7 @@ public function handle(): void 'year' => $metric->year(), 'month' => $metric->month(), 'day' => $metric->day(), + 'hour' => $metric->hour(), 'measurable_type' => $metric->measurable()?->getMorphClass(), 'measurable_id' => $metric->measurable()?->getKey(), ], ['value' => 0]); diff --git a/src/JsonMeasurableEncoder.php b/src/JsonMeasurableEncoder.php index bd1fd45..22f6f5a 100644 --- a/src/JsonMeasurableEncoder.php +++ b/src/JsonMeasurableEncoder.php @@ -20,6 +20,7 @@ public function encode(Measurable $metric): string 'year' => $metric->year(), 'month' => $metric->month(), 'day' => $metric->day(), + 'hour' => $metric->hour(), 'measurable' => $model ? get_class($model) : null, 'measurable_key' => $model?->getKeyName() ?? null, 'measurable_id' => $model?->getKey() ?? null, @@ -46,7 +47,8 @@ public function decode(string $key, int $value): Measurable $date = CarbonImmutable::create( $attributes['year'], $attributes['month'], - $attributes['day'] + $attributes['day'], + $attributes['hour'] ?? 0 ); return new MetricData( @@ -56,6 +58,7 @@ public function decode(string $key, int $value): Measurable date: $date, measurable: $model, additional: $attributes['additional'] ?? [], + hourly: $attributes['hour'] ?? false, ); } } diff --git a/src/Measurable.php b/src/Measurable.php index bca6d74..ab8cbad 100644 --- a/src/Measurable.php +++ b/src/Measurable.php @@ -37,6 +37,11 @@ public function month(): int; */ public function day(): int; + /** + * Get the hour of the metric. + */ + public function hour(): ?int; + /** * Get the measurable model of the metric. */ diff --git a/src/Metric.php b/src/Metric.php index 853c00a..4d6ee1f 100644 --- a/src/Metric.php +++ b/src/Metric.php @@ -30,6 +30,7 @@ protected function casts(): array 'year' => 'integer', 'month' => 'integer', 'day' => 'integer', + 'hour' => 'integer', 'value' => 'integer', ]; } diff --git a/src/MetricBuilder.php b/src/MetricBuilder.php index da635fd..2ba0956 100644 --- a/src/MetricBuilder.php +++ b/src/MetricBuilder.php @@ -25,6 +25,22 @@ public function yesterday(): self ); } + /** + * Get metrics for this hour. + */ + public function thisHour(): self + { + return $this->onDateTime(now()); + } + + /** + * Get metrics for last hour. + */ + public function lastHour(): self + { + return $this->onDateTime(now()->subHour()); + } + /** * Get metrics for this week. */ @@ -160,6 +176,20 @@ public function betweenDates(CarbonInterface $start, CarbonInterface $end): self ); } + /** + * Get metrics between two datetimes (including hours). + */ + public function betweenDateTimes(CarbonInterface $start, CarbonInterface $end): self + { + return $this->whereRaw( + '(year, month, day, hour) >= (?, ?, ?, ?) AND (year, month, day, hour) <= (?, ?, ?, ?)', + [ + $start->year, $start->month, $start->day, $start->hour, + $end->year, $end->month, $end->day, $end->hour, + ] + ); + } + /** * Get metrics on a specific date. */ @@ -172,4 +202,12 @@ public function onDate(CarbonInterface $date): self ->where('day', $date->day); }); } + + /** + * Get metrics on a specific date and hour. + */ + public function onDateTime(CarbonInterface $hour): self + { + return $this->onDate($hour)->where('hour', $hour->hour); + } } diff --git a/src/MetricData.php b/src/MetricData.php index cffcbe4..f8885a2 100644 --- a/src/MetricData.php +++ b/src/MetricData.php @@ -22,6 +22,7 @@ public function __construct( protected ?CarbonInterface $date = null, protected ?Model $measurable = null, protected array $additional = [], + protected bool $hourly = false, ) { $this->date ??= new CarbonImmutable; } @@ -74,6 +75,14 @@ public function day(): int return $this->date->day; } + /** + * {@inheritDoc} + */ + public function hour(): ?int + { + return $this->hourly ? $this->date->hour : null; + } + /** * {@inheritDoc} */ diff --git a/src/MetricFactory.php b/src/MetricFactory.php index a8a911f..48613d2 100644 --- a/src/MetricFactory.php +++ b/src/MetricFactory.php @@ -24,6 +24,7 @@ public function definition(): array 'year' => $datetime->year, 'month' => $datetime->month, 'day' => $datetime->day, + 'hour' => null, 'measurable_type' => null, 'measurable_id' => null, 'value' => 1, diff --git a/src/PendingMetric.php b/src/PendingMetric.php index 4dfe75f..0b28ea0 100644 --- a/src/PendingMetric.php +++ b/src/PendingMetric.php @@ -28,6 +28,11 @@ class PendingMetric */ protected ?Model $measurable = null; + /** + * Whether to track hourly metrics. + */ + protected bool $trackHourly = false; + /** * Additional attributes to store with the metric. * @@ -81,6 +86,16 @@ public function measurable(Model $measurable): self return $this; } + /** + * Enable hourly tracking for the metric. + */ + public function hourly(): self + { + $this->trackHourly = true; + + return $this; + } + /** * Set additional attributes to store with the metric. * @@ -114,7 +129,8 @@ public function toMetricData(int $value): Measurable $value, $this->date, $this->measurable, - $this->additional + $this->additional, + $this->trackHourly ); } } diff --git a/tests/Commands/CommitMetricsTest.php b/tests/Commands/CommitMetricsTest.php index 05db4da..443b2ba 100644 --- a/tests/Commands/CommitMetricsTest.php +++ b/tests/Commands/CommitMetricsTest.php @@ -14,6 +14,8 @@ beforeEach(function () { Queue::fake(); Redis::flushdb(); + + config(['metrics.queue' => false]); }); it('displays message when no metrics to commit', function () { @@ -25,8 +27,6 @@ }); it('commits captured metrics without queueing', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('page_views')); Metrics::record(new MetricData('page_views')); @@ -62,8 +62,6 @@ }); it('displays singular message for one metric', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('page_views')); @@ -75,8 +73,6 @@ }); it('displays plural message for multiple metrics', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('page_views')); Metrics::record(new MetricData('api_calls')); @@ -90,8 +86,6 @@ }); it('flushes repository after committing', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('page_views')); @@ -109,8 +103,6 @@ }); it('commits metrics with categories', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('page_views', 'marketing')); Metrics::record(new MetricData('page_views', 'analytics')); @@ -127,8 +119,6 @@ }); it('commits metrics with measurable models', function () { - config(['metrics.queue' => false]); - $user1 = createUser(['name' => 'John', 'email' => 'john@example.com']); $user2 = createUser(['name' => 'Jane', 'email' => 'jane@example.com']); @@ -148,8 +138,6 @@ }); it('commits large number of metrics', function () { - config(['metrics.queue' => false]); - Metrics::capture(); for ($i = 0; $i < 100; $i++) { @@ -165,8 +153,6 @@ }); it('can be run multiple times', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('page_views')); @@ -184,8 +170,6 @@ }); it('works when capturing is not enabled', function () { - config(['metrics.queue' => false]); - // Record without capturing Metrics::record(new MetricData('page_views')); @@ -198,8 +182,6 @@ }); it('commits metrics with custom values', function () { - config(['metrics.queue' => false]); - Metrics::capture(); Metrics::record(new MetricData('revenue', value: 100)); Metrics::record(new MetricData('revenue', value: 250)); @@ -214,8 +196,6 @@ }); it('commits metrics with different dates separately', function () { - config(['metrics.queue' => false]); - $today = now(); $yesterday = now()->subDay(); @@ -231,8 +211,6 @@ }); it('handles metrics with all properties', function () { - config(['metrics.queue' => false]); - $user = createUser(); $date = now(); @@ -253,8 +231,6 @@ }); it('works with redis repository', function () { - config(['metrics.queue' => false]); - // Bind Redis repository $this->app->singleton( MetricRepository::class, diff --git a/tests/HourlyMetricsTest.php b/tests/HourlyMetricsTest.php new file mode 100644 index 0000000..be691d8 --- /dev/null +++ b/tests/HourlyMetricsTest.php @@ -0,0 +1,213 @@ + false]); +}); + +it('can record hourly metrics using pending metric', function () { + PendingMetric::make('api:requests') + ->hourly() + ->record(); + + $metric = Metric::first(); + + expect($metric->name)->toEqual('api:requests') + ->and($metric->hour)->toEqual(now()->hour) + ->and($metric->value)->toEqual(1); +}); + +it('can record hourly metrics with custom date', function () { + $datetime = Carbon::parse('2025-10-19 14:30:00'); + + PendingMetric::make('api:requests') + ->date($datetime) + ->hourly() + ->record(); + + $metric = Metric::first(); + + expect($metric->name)->toEqual('api:requests') + ->and($metric->year)->toEqual(2025) + ->and($metric->month)->toEqual(10) + ->and($metric->day)->toEqual(19) + ->and($metric->hour)->toEqual(14) + ->and($metric->value)->toEqual(1); +}); + +it('records daily metrics when hourly is not enabled', function () { + PendingMetric::make('page_views')->record(); + + $metric = Metric::first(); + + expect($metric->name)->toEqual('page_views') + ->and($metric->hour)->toBeNull() + ->and($metric->value)->toEqual(1); +}); + +it('can record hourly metrics using MetricData', function () { + $datetime = Carbon::parse('2025-10-19 15:45:00'); + + Metrics::record(new MetricData( + name: 'api:requests', + date: $datetime, + hourly: true + )); + + $metric = Metric::first(); + + expect($metric->name)->toEqual('api:requests') + ->and($metric->hour)->toEqual(15) + ->and($metric->value)->toEqual(1); +}); + +it('increments hourly metrics for the same hour', function () { + $datetime = Carbon::parse('2025-10-19 14:30:00'); + + PendingMetric::make('api:requests') + ->date($datetime) + ->hourly() + ->record(); + + PendingMetric::make('api:requests') + ->date($datetime) + ->hourly() + ->record(); + + expect(Metric::count())->toEqual(1); + + $metric = Metric::first(); + + expect($metric->hour)->toEqual(14) + ->and($metric->value)->toEqual(2); +}); + +it('creates separate metrics for different hours', function () { + $hour1 = Carbon::parse('2025-10-19 14:00:00'); + $hour2 = Carbon::parse('2025-10-19 15:00:00'); + + PendingMetric::make('api:requests') + ->date($hour1) + ->hourly() + ->record(); + + PendingMetric::make('api:requests') + ->date($hour2) + ->hourly() + ->record(); + + expect(Metric::count())->toEqual(2); + + $metrics = Metric::orderBy('hour')->get(); + + expect($metrics[0]->hour)->toEqual(14) + ->and($metrics[0]->value)->toEqual(1) + ->and($metrics[1]->hour)->toEqual(15) + ->and($metrics[1]->value)->toEqual(1); +}); + +it('can query metrics for this hour', function () { + $now = now(); + $lastHour = now()->subHour(); + + PendingMetric::make('api:requests') + ->date($now) + ->hourly() + ->record(5); + + PendingMetric::make('api:requests') + ->date($lastHour) + ->hourly() + ->record(3); + + $thisHourMetrics = Metric::thisHour()->sum('value'); + + expect($thisHourMetrics)->toEqual(5); +}); + +it('can query metrics for last hour', function () { + $now = now(); + $lastHour = now()->subHour(); + + PendingMetric::make('api:requests') + ->date($now) + ->hourly() + ->record(5); + + PendingMetric::make('api:requests') + ->date($lastHour) + ->hourly() + ->record(3); + + $lastHourMetrics = Metric::lastHour()->sum('value'); + + expect($lastHourMetrics)->toEqual(3); +}); + +it('treats hourly and daily metrics as separate', function () { + $datetime = Carbon::parse('2025-10-19 14:30:00'); + + PendingMetric::make('page_views') + ->date($datetime) + ->record(5); + + PendingMetric::make('page_views') + ->date($datetime) + ->hourly() + ->record(3); + + expect(Metric::count())->toEqual(2); + + $dailyMetric = Metric::whereNull('hour')->first(); + $hourlyMetric = Metric::whereNotNull('hour')->first(); + + expect($dailyMetric->value)->toEqual(5) + ->and($hourlyMetric->value)->toEqual(3) + ->and($hourlyMetric->hour)->toEqual(14); +}); + +it('can chain hourly with other methods', function () { + $user = createUser(); + $datetime = Carbon::parse('2025-10-19 14:30:00'); + + PendingMetric::make('api:requests') + ->category('external') + ->date($datetime) + ->measurable($user) + ->hourly() + ->record(10); + + $metric = Metric::first(); + + expect($metric->name)->toEqual('api:requests') + ->and($metric->category)->toEqual('external') + ->and($metric->hour)->toEqual(14) + ->and($metric->measurable_type)->toEqual(get_class($user)) + ->and($metric->measurable_id)->toEqual($user->id) + ->and($metric->value)->toEqual(10); +}); + +it('works with capturing mode for hourly metrics', function () { + $datetime = Carbon::parse('2025-10-19 14:30:00'); + + Metrics::capture(); + + PendingMetric::make('api:requests')->date($datetime)->hourly()->record(5); + PendingMetric::make('api:requests')->date($datetime)->hourly()->record(3); + + expect(Metric::count())->toEqual(0); + + Metrics::commit(); + + expect(Metric::count())->toEqual(1); + + $metric = Metric::first(); + + expect($metric->value)->toEqual(8) + ->and($metric->hour)->toEqual(14); +});