Skip to content

Commit 9021a7f

Browse files
authored
Merge pull request #7 from DirectoryTree/custom-attributes
Add support for extending/adding additional columns/attributes
2 parents 33969b4 + 882ff15 commit 9021a7f

File tree

9 files changed

+214
-17
lines changed

9 files changed

+214
-17
lines changed

README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Track page views, API calls, user signups, or any other countable events.
3030
- [Recording with Categories](#recording-with-categories)
3131
- [Recording with Dates](#recording-with-dates)
3232
- [Recording for Models](#recording-for-models)
33+
- [Recording with Custom Attributes](#recording-with-custom-attributes)
3334
- [Capturing & Committing](#capturing--committing)
3435
- [Querying Metrics](#querying-metrics)
3536
- [Testing](#testing)
@@ -254,6 +255,105 @@ $ordersThisMonth = $customer->metrics()
254255
->sum('value');
255256
```
256257

258+
### Recording with Custom Attributes
259+
260+
Store additional context with your metrics by adding custom attributes. This is useful for segmenting metrics by various dimensions like source, country, device type, or any other custom data.
261+
262+
First, create a migration to add custom columns to the `metrics` table:
263+
264+
```php
265+
use Illuminate\Database\Schema\Blueprint;
266+
use Illuminate\Support\Facades\Schema;
267+
268+
Schema::table('metrics', function (Blueprint $table) {
269+
$table->string('source')->nullable()->index();
270+
$table->string('country')->nullable()->index();
271+
$table->string('device')->nullable();
272+
});
273+
```
274+
275+
Then, use the `with()` method to record metrics with custom attributes:
276+
277+
```php
278+
// Track page views with traffic source
279+
metric('page_views')
280+
->with(['source' => 'google'])
281+
->record();
282+
283+
// Track conversions with multiple attributes
284+
metric('conversions')
285+
->with([
286+
'source' => 'facebook',
287+
'country' => 'US',
288+
'device' => 'mobile',
289+
])
290+
->record();
291+
292+
// Combine with other methods
293+
metric('api:requests')
294+
->category('users')
295+
->with(['client_id' => 'abc123'])
296+
->record();
297+
```
298+
299+
Custom attributes are included in the metric's uniqueness check, meaning metrics with different attribute values are stored separately:
300+
301+
```php
302+
metric('page_views')->with(['source' => 'google'])->record(); // Creates metric #1
303+
metric('page_views')->with(['source' => 'facebook'])->record(); // Creates metric #2
304+
metric('page_views')->with(['source' => 'google'])->record(); // Increments metric #1
305+
```
306+
307+
This allows you to segment and analyze metrics by any dimension:
308+
309+
```php
310+
// Get page views by source
311+
$googleViews = Metric::where('name', 'page_views')
312+
->where('source', 'google')
313+
->sum('value');
314+
315+
// Get conversions by country this month
316+
$conversions = Metric::thisMonth()
317+
->where('name', 'conversions')
318+
->get()
319+
->groupBy('country')
320+
->map->sum('value');
321+
322+
// Get mobile vs desktop traffic
323+
$mobileViews = Metric::today()
324+
->where('name', 'page_views')
325+
->where('device', 'mobile')
326+
->sum('value');
327+
```
328+
329+
You can also use custom attributes with the `MetricData` class:
330+
331+
```php
332+
use DirectoryTree\Metrics\MetricData;
333+
use DirectoryTree\Metrics\Facades\Metrics;
334+
335+
Metrics::record(new MetricData(
336+
name: 'page_views',
337+
additional: [
338+
'source' => 'google',
339+
'country' => 'US',
340+
]
341+
));
342+
```
343+
344+
Or with the `PendingMetric` class:
345+
346+
```php
347+
use DirectoryTree\Metrics\PendingMetric;
348+
349+
PendingMetric::make('page_views')
350+
->with(['source' => 'google', 'country' => 'US'])
351+
->record();
352+
```
353+
354+
> [!important]
355+
> Core metric attributes (`name`, `category`, `year`, `month`, `day`, `measurable_type`, `measurable_id`, `value`) cannot be overridden via custom attributes. They are protected and will always use the values set through their respective methods.
356+
257357
### Capturing & Committing
258358

259359
For high-performance scenarios, you may capture metrics in memory and commit them in batches:

database/migrations/2025_09_28_131354_create_metrics_table.php

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,16 +23,6 @@ public function up(): void
2323
$table->timestamps();
2424

2525
$table->index(['year', 'month', 'day']);
26-
27-
$table->unique([
28-
'name',
29-
'category',
30-
'year',
31-
'month',
32-
'day',
33-
'measurable_type',
34-
'measurable_id',
35-
], 'metrics_unique');
3626
});
3727
}
3828

src/DatabaseMetricManager.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,30 @@ class DatabaseMetricManager implements MetricManager
1212
*/
1313
protected bool $capturing = false;
1414

15+
/**
16+
* The metric model to use.
17+
*
18+
* @var class-string<\Illuminate\Database\Eloquent\Model>
19+
*/
20+
public static string $model = Metric::class;
21+
1522
/**
1623
* Constructor.
1724
*/
1825
public function __construct(
1926
protected MetricRepository $repository
2027
) {}
2128

29+
/**
30+
* Set the metric model to use.
31+
*
32+
* @param class-string<\Illuminate\Database\Eloquent\Model> $model
33+
*/
34+
public static function useModel(string $model): void
35+
{
36+
static::$model = $model;
37+
}
38+
2239
/**
2340
* {@inheritDoc}
2441
*/

src/Jobs/RecordMetric.php

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace DirectoryTree\Metrics\Jobs;
44

5+
use DirectoryTree\Metrics\DatabaseMetricManager;
56
use DirectoryTree\Metrics\Measurable;
67
use DirectoryTree\Metrics\Metric;
78
use Illuminate\Bus\Queueable;
@@ -19,7 +20,7 @@ class RecordMetric implements ShouldQueue
1920
*/
2021
public function __construct(
2122
/** @var Collection<Measurable>|Measurable */
22-
public Collection|Measurable $metrics,
23+
public Collection|Measurable $metrics
2324
) {}
2425

2526
/**
@@ -38,9 +39,13 @@ public function handle(): void
3839
fn (Measurable $metric) => $metric->value()
3940
);
4041

41-
Metric::query()->getConnection()->transaction(
42-
function (ConnectionInterface $connection) use ($metric, $value) {
43-
$model = Metric::query()->firstOrCreate([
42+
/** @var \Illuminate\Database\Eloquent\Model $model */
43+
$model = new DatabaseMetricManager::$model;
44+
45+
$model->getConnection()->transaction(
46+
function (ConnectionInterface $connection) use ($metric, $value, $model) {
47+
$instance = $model->newQuery()->firstOrCreate([
48+
...$metric->additional(),
4449
'name' => $metric->name(),
4550
'category' => $metric->category(),
4651
'year' => $metric->year(),
@@ -50,7 +55,7 @@ function (ConnectionInterface $connection) use ($metric, $value) {
5055
'measurable_id' => $metric->measurable()?->getKey(),
5156
], ['value' => 0]);
5257

53-
Metric::query()->whereKey($model->getKey())->update([
58+
$model->newQuery()->whereKey($instance->getKey())->update([
5459
'value' => $connection->raw('value + '.$value),
5560
]);
5661
}

src/JsonMeasurableEncoder.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ public function encode(Measurable $metric): string
2323
'measurable' => $model ? get_class($model) : null,
2424
'measurable_key' => $model?->getKeyName() ?? null,
2525
'measurable_id' => $model?->getKey() ?? null,
26+
'additional' => $metric->additional(),
2627
]);
2728
}
2829

@@ -53,7 +54,8 @@ public function decode(string $key, int $value): Measurable
5354
category: $attributes['category'],
5455
value: $value,
5556
date: $date,
56-
measurable: $model
57+
measurable: $model,
58+
additional: $attributes['additional'] ?? [],
5759
);
5860
}
5961
}

src/Measurable.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,9 @@ public function day(): int;
4141
* Get the measurable model of the metric.
4242
*/
4343
public function measurable(): ?Model;
44+
45+
/**
46+
* Get the additional attributes of the metric.
47+
*/
48+
public function additional(): array;
4449
}

src/MetricData.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function __construct(
2121
protected int $value = 1,
2222
protected ?CarbonInterface $date = null,
2323
protected ?Model $measurable = null,
24+
protected array $additional = [],
2425
) {
2526
$this->date ??= new CarbonImmutable;
2627
}
@@ -80,4 +81,12 @@ public function measurable(): ?Model
8081
{
8182
return $this->measurable;
8283
}
84+
85+
/**
86+
* {@inheritDoc}
87+
*/
88+
public function additional(): array
89+
{
90+
return $this->additional;
91+
}
8392
}

src/PendingMetric.php

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ class PendingMetric
2828
*/
2929
protected ?Model $measurable = null;
3030

31+
/**
32+
* Additional attributes to store with the metric.
33+
*
34+
* @var array<string, mixed>
35+
*/
36+
protected array $additional = [];
37+
3138
/**
3239
* Constructor.
3340
*/
@@ -74,6 +81,18 @@ public function measurable(Model $measurable): self
7481
return $this;
7582
}
7683

84+
/**
85+
* Set additional attributes to store with the metric.
86+
*
87+
* @param array<string, mixed> $attributes
88+
*/
89+
public function with(array $attributes): self
90+
{
91+
$this->additional = $attributes;
92+
93+
return $this;
94+
}
95+
7796
/**
7897
* Record the metric.
7998
*/
@@ -94,7 +113,8 @@ public function toMetricData(int $value): Measurable
94113
$this->category,
95114
$value,
96115
$this->date,
97-
$this->measurable
116+
$this->measurable,
117+
$this->additional
98118
);
99119
}
100120
}

tests/Jobs/RecordMetricTest.php

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use DirectoryTree\Metrics\Metric;
66
use DirectoryTree\Metrics\MetricData;
77
use DirectoryTree\Metrics\Tests\User;
8+
use Illuminate\Database\Schema\Blueprint;
9+
use Illuminate\Support\Facades\Schema;
810

911
it('can record a single metric', function () {
1012
$metric = new MetricData('page_views');
@@ -224,3 +226,50 @@
224226

225227
expect($updated->updated_at->isAfter($originalUpdatedAt))->toBeTrue();
226228
});
229+
230+
it('creates metrics with additional attributes', function () {
231+
Schema::table('metrics', function (Blueprint $table) {
232+
$table->string('source')->nullable();
233+
$table->string('country')->nullable();
234+
});
235+
236+
$data = new MetricData('page_views', additional: [
237+
'source' => 'google',
238+
'country' => 'US',
239+
]);
240+
241+
(new RecordMetric($data))->handle();
242+
(new RecordMetric($data))->handle();
243+
244+
$metric = Metric::first();
245+
246+
expect($metric->source)->toBe('google');
247+
expect($metric->country)->toBe('US');
248+
expect($metric->value)->toBe(2);
249+
});
250+
251+
it('cannot override core attributes with additional attributes', function () {
252+
$data = new MetricData('page_views', additional: [
253+
'name' => 'api_calls',
254+
'category' => 'marketing',
255+
'year' => 2025,
256+
'month' => 1,
257+
'day' => 1,
258+
'measurable_type' => 'App\Models\User',
259+
'measurable_id' => 1,
260+
'value' => 100,
261+
]);
262+
263+
(new RecordMetric($data))->handle();
264+
265+
$recorded = Metric::first();
266+
267+
expect($recorded->name)->toBe('page_views')
268+
->and($recorded->category)->toBeNull()
269+
->and($recorded->year)->toBe(today()->year)
270+
->and($recorded->month)->toBe(today()->month)
271+
->and($recorded->day)->toBe(today()->day)
272+
->and($recorded->measurable_type)->toBeNull()
273+
->and($recorded->measurable_id)->toBeNull()
274+
->and($recorded->value)->toBe(1);
275+
});

0 commit comments

Comments
 (0)