Skip to content

Commit 3af6650

Browse files
authored
Merge pull request #11 from DirectoryTree/hourly-metrics
Add ability to record metrics hourly
2 parents 1462442 + 5478b4c commit 3af6650

14 files changed

+423
-38
lines changed

README.md

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,10 @@ Track page views, API calls, user signups, or any other countable events.
2727
- [Using the Redis Driver](#using-the-redis-driver)
2828
- [Usage](#usage)
2929
- [Recording Metrics](#recording-metrics)
30+
- [Recording with Values](#recording-with-values)
3031
- [Recording with Categories](#recording-with-categories)
3132
- [Recording with Dates](#recording-with-dates)
33+
- [Recording Hourly Metrics](#recording-hourly-metrics)
3234
- [Recording for Models](#recording-for-models)
3335
- [Recording with Custom Attributes](#recording-with-custom-attributes)
3436
- [Capturing & Committing](#capturing--committing)
@@ -148,7 +150,7 @@ Which ever method you use, metrics are recorded in the same way. Use whichever y
148150

149151
For the rest of the documentation, we will use the `metric` helper for consistency and brevity.
150152

151-
### Metric Values
153+
### Recording with Values
152154

153155
By default, metrics have a value of `1`. You may specify a custom value in the `record` method:
154156

@@ -207,6 +209,61 @@ metric('jobs:completed')
207209
->record(1250);
208210
```
209211

212+
### Recording Hourly Metrics
213+
214+
By default, metrics are recorded at the **daily** level. For metrics that require hour-level granularity, you may use the `hourly()` method:
215+
216+
```php
217+
// Track API requests by hour
218+
metric('api:requests')
219+
->hourly()
220+
->record();
221+
```
222+
223+
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:
224+
225+
```php
226+
use Carbon\Carbon;
227+
228+
// Record API requests for a specific hour
229+
metric('api:requests')
230+
->date(Carbon::parse('2025-10-19 14:30:00'))
231+
->hourly()
232+
->record();
233+
234+
// This will be stored with hour = 14
235+
```
236+
237+
Hourly metrics are stored separately from daily metrics, even for the same metric name:
238+
239+
```php
240+
metric('page:views')->record(); // Daily metric (hour = null)
241+
metric('page:views')->hourly()->record(); // Hourly metric (hour = current hour)
242+
```
243+
244+
You can query hourly metrics using the `thisHour()`, `lastHour()`, and `onDateTime()` methods:
245+
246+
```php
247+
use DirectoryTree\Metrics\Metric;
248+
249+
// Get metrics for this hour
250+
$metrics = Metric::thisHour()->get();
251+
252+
// Get metrics for last hour
253+
$metrics = Metric::lastHour()->get();
254+
255+
// Get metrics for a specific date and hour
256+
$metrics = Metric::onDateTime(Carbon::parse('2025-10-19 14:00:00'))->get();
257+
258+
// Get API requests for the current hour
259+
$requests = Metric::thisHour()
260+
->where('name', 'api:requests')
261+
->sum('value');
262+
```
263+
264+
> [!tip]
265+
> 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.
266+
210267
### Recording for Models
211268

212269
Associate metrics with Eloquent models using the `HasMetrics` trait:
@@ -300,16 +357,16 @@ metric('api:requests')
300357
Custom attributes are included in the metric's uniqueness check, meaning metrics with different attribute values are stored separately:
301358

302359
```php
303-
metric('page_views')->with(['source' => 'google'])->record(); // Creates metric #1
304-
metric('page_views')->with(['source' => 'facebook'])->record(); // Creates metric #2
305-
metric('page_views')->with(['source' => 'google'])->record(); // Increments metric #1
360+
metric('page:views')->with(['source' => 'google'])->record(); // Creates metric #1
361+
metric('page:views')->with(['source' => 'facebook'])->record(); // Creates metric #2
362+
metric('page:views')->with(['source' => 'google'])->record(); // Increments metric #1
306363
```
307364

308365
This allows you to segment and analyze metrics by any dimension:
309366

310367
```php
311368
// Get page views by source
312-
$googleViews = Metric::where('name', 'page_views')
369+
$googleViews = Metric::where('name', 'page:views')
313370
->where('source', 'google')
314371
->sum('value');
315372

@@ -322,7 +379,7 @@ $conversions = Metric::thisMonth()
322379

323380
// Get mobile vs desktop traffic
324381
$mobileViews = Metric::today()
325-
->where('name', 'page_views')
382+
->where('name', 'page:views')
326383
->where('device', 'mobile')
327384
->sum('value');
328385
```
@@ -334,7 +391,7 @@ use DirectoryTree\Metrics\MetricData;
334391
use DirectoryTree\Metrics\Facades\Metrics;
335392

336393
Metrics::record(new MetricData(
337-
name: 'page_views',
394+
name: 'page:views',
338395
additional: [
339396
'source' => 'google',
340397
'country' => 'US',
@@ -347,7 +404,7 @@ Or with the `PendingMetric` class:
347404
```php
348405
use DirectoryTree\Metrics\PendingMetric;
349406

350-
PendingMetric::make('page_views')
407+
PendingMetric::make('page:views')
351408
->with(['source' => 'google', 'country' => 'US'])
352409
->record();
353410
```
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('metrics', function (Blueprint $table) {
15+
$table->unsignedTinyInteger('hour')->nullable()->after('day');
16+
});
17+
}
18+
19+
/**
20+
* Reverse the migrations.
21+
*/
22+
public function down(): void
23+
{
24+
Schema::table('metrics', function (Blueprint $table) {
25+
$table->dropColumn('hour');
26+
});
27+
}
28+
};
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration
8+
{
9+
/**
10+
* Run the migrations.
11+
*/
12+
public function up(): void
13+
{
14+
Schema::table('metrics', function (Blueprint $table) {
15+
$table->dropIndex(['year', 'month', 'day']);
16+
$table->index(['year', 'month', 'day', 'hour']);
17+
});
18+
}
19+
20+
/**
21+
* Reverse the migrations.
22+
*/
23+
public function down(): void
24+
{
25+
Schema::table('metrics', function (Blueprint $table) {
26+
$table->dropIndex(['year', 'month', 'day', 'hour']);
27+
$table->index(['year', 'month', 'day']);
28+
});
29+
}
30+
};

src/ArrayMetricRepository.php

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,15 @@ public function add(Measurable $metric): void
3434
$metric->name(),
3535
$metric->category(),
3636
$existing->value() + $metric->value(),
37-
CarbonImmutable::create($existing->year(), $existing->month(), $existing->day()),
38-
$metric->measurable()
37+
CarbonImmutable::create(
38+
$existing->year(),
39+
$existing->month(),
40+
$existing->day(),
41+
$existing->hour() ?? 0
42+
),
43+
$metric->measurable(),
44+
$metric->additional(),
45+
$existing->hour() !== null
3946
);
4047
} else {
4148
$this->metrics[$key] = $metric;

src/Jobs/RecordMetric.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ public function handle(): void
4949
'year' => $metric->year(),
5050
'month' => $metric->month(),
5151
'day' => $metric->day(),
52+
'hour' => $metric->hour(),
5253
'measurable_type' => $metric->measurable()?->getMorphClass(),
5354
'measurable_id' => $metric->measurable()?->getKey(),
5455
], ['value' => 0]);

src/JsonMeasurableEncoder.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ public function encode(Measurable $metric): string
2020
'year' => $metric->year(),
2121
'month' => $metric->month(),
2222
'day' => $metric->day(),
23+
'hour' => $metric->hour(),
2324
'measurable' => $model ? get_class($model) : null,
2425
'measurable_key' => $model?->getKeyName() ?? null,
2526
'measurable_id' => $model?->getKey() ?? null,
@@ -46,7 +47,8 @@ public function decode(string $key, int $value): Measurable
4647
$date = CarbonImmutable::create(
4748
$attributes['year'],
4849
$attributes['month'],
49-
$attributes['day']
50+
$attributes['day'],
51+
$attributes['hour'] ?? 0
5052
);
5153

5254
return new MetricData(
@@ -56,6 +58,7 @@ public function decode(string $key, int $value): Measurable
5658
date: $date,
5759
measurable: $model,
5860
additional: $attributes['additional'] ?? [],
61+
hourly: $attributes['hour'] ?? false,
5962
);
6063
}
6164
}

src/Measurable.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ public function month(): int;
3737
*/
3838
public function day(): int;
3939

40+
/**
41+
* Get the hour of the metric.
42+
*/
43+
public function hour(): ?int;
44+
4045
/**
4146
* Get the measurable model of the metric.
4247
*/

src/Metric.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ protected function casts(): array
3030
'year' => 'integer',
3131
'month' => 'integer',
3232
'day' => 'integer',
33+
'hour' => 'integer',
3334
'value' => 'integer',
3435
];
3536
}

src/MetricBuilder.php

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ public function yesterday(): self
2525
);
2626
}
2727

28+
/**
29+
* Get metrics for this hour.
30+
*/
31+
public function thisHour(): self
32+
{
33+
return $this->onDateTime(now());
34+
}
35+
36+
/**
37+
* Get metrics for last hour.
38+
*/
39+
public function lastHour(): self
40+
{
41+
return $this->onDateTime(now()->subHour());
42+
}
43+
2844
/**
2945
* Get metrics for this week.
3046
*/
@@ -160,6 +176,20 @@ public function betweenDates(CarbonInterface $start, CarbonInterface $end): self
160176
);
161177
}
162178

179+
/**
180+
* Get metrics between two datetimes (including hours).
181+
*/
182+
public function betweenDateTimes(CarbonInterface $start, CarbonInterface $end): self
183+
{
184+
return $this->whereRaw(
185+
'(year, month, day, hour) >= (?, ?, ?, ?) AND (year, month, day, hour) <= (?, ?, ?, ?)',
186+
[
187+
$start->year, $start->month, $start->day, $start->hour,
188+
$end->year, $end->month, $end->day, $end->hour,
189+
]
190+
);
191+
}
192+
163193
/**
164194
* Get metrics on a specific date.
165195
*/
@@ -172,4 +202,12 @@ public function onDate(CarbonInterface $date): self
172202
->where('day', $date->day);
173203
});
174204
}
205+
206+
/**
207+
* Get metrics on a specific date and hour.
208+
*/
209+
public function onDateTime(CarbonInterface $hour): self
210+
{
211+
return $this->onDate($hour)->where('hour', $hour->hour);
212+
}
175213
}

src/MetricData.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public function __construct(
2222
protected ?CarbonInterface $date = null,
2323
protected ?Model $measurable = null,
2424
protected array $additional = [],
25+
protected bool $hourly = false,
2526
) {
2627
$this->date ??= new CarbonImmutable;
2728
}
@@ -74,6 +75,14 @@ public function day(): int
7475
return $this->date->day;
7576
}
7677

78+
/**
79+
* {@inheritDoc}
80+
*/
81+
public function hour(): ?int
82+
{
83+
return $this->hourly ? $this->date->hour : null;
84+
}
85+
7786
/**
7887
* {@inheritDoc}
7988
*/

0 commit comments

Comments
 (0)