Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions config/cachet.php
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,29 @@
'prune_checks_after_days' => env('CACHET_PRUNE_CHECKS_AFTER_DAYS', 30),
],

/*
|--------------------------------------------------------------------------
| Cachet Metrics
|--------------------------------------------------------------------------
|
| Metrics are a curated, human-readable display series, not a time series
| database. "retention_days" is how long metric points are kept before
| the model:prune scheduled task removes them; set it to null to keep
| every point forever, and expect the table to grow without bound.
|
| "max_included_points" caps how many points the API will attach to a
| metric through "?include=points". Use the metric points endpoint,
| which is paginated, to walk the full history of a metric.
|
*/
'metrics' => [
'retention_days' => env('CACHET_METRICS_RETENTION_DAYS', 90),

'max_included_points' => env('CACHET_METRICS_MAX_INCLUDED_POINTS', 100),

'max_batch_points' => env('CACHET_METRICS_MAX_BATCH_POINTS', 1000),
],

/*
|--------------------------------------------------------------------------
| Cachet Webhooks
Expand Down
10 changes: 10 additions & 0 deletions database/factories/MetricPointFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use Cachet\Models\Metric;
use Cachet\Models\MetricPoint;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Carbon;

/**
* @extends Factory<MetricPoint>
Expand All @@ -13,6 +14,14 @@ class MetricPointFactory extends Factory
{
protected $model = MetricPoint::class;

/**
* How many points this factory has made.
*
* A metric holds at most one point per timestamp, so generated points
* are spread a minute apart rather than all landing on now().
*/
private static int $made = 0;

/**
* Define the model's default state.
*
Expand All @@ -24,6 +33,7 @@ public function definition(): array
'metric_id' => Metric::factory(),
'value' => 1,
'counter' => 1,
'created_at' => fn (): Carbon => Carbon::now()->startOfMinute()->subMinutes(self::$made++ % 1440),
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('metric_points', function (Blueprint $table) {
$table->decimal('sum_value', 20, 3)->default(0)->after('value');
});

$connection = Schema::getConnection();
$grammar = $connection->getQueryGrammar();

$connection->statement(sprintf(
'update %s set %s = %s * %s',
$grammar->wrapTable('metric_points'),
$grammar->wrap('sum_value'),
$grammar->wrap('value'),
$grammar->wrap('counter'),
));
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('metric_points', function (Blueprint $table) {
$table->dropColumn('sum_value');
});
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* How many duplicate buckets are collapsed per pass.
*/
private const CHUNK = 500;

/**
* Run the migrations.
*
* A point is now the bucket its observations accumulate into, so a metric
* may only hold one row per timestamp. The read-then-insert ingest path
* this replaces had no lock, so concurrent writes could leave duplicate
* rows behind; they are collapsed before the constraint is added.
*/
public function up(): void
{
$this->collapseDuplicateBuckets();

if (Schema::hasIndex('metric_points', ['metric_id'])) {
Schema::table('metric_points', function (Blueprint $table) {
$table->dropIndex(['metric_id']);
});
}

Schema::table('metric_points', function (Blueprint $table) {
$table->unique(['metric_id', 'created_at']);
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('metric_points', function (Blueprint $table) {
$table->dropUnique(['metric_id', 'created_at']);
$table->index('metric_id');
});
}

/**
* Merge rows sharing a metric and timestamp into the oldest of them.
*
* Each pass removes the buckets it collapses, so re-querying the first
* chunk of duplicates always makes progress.
*/
private function collapseDuplicateBuckets(): void
{
while (true) {
$duplicates = DB::table('metric_points')
->select('metric_id', 'created_at')
->whereNotNull('created_at')
->groupBy('metric_id', 'created_at')
->havingRaw('count(*) > 1')
->limit(self::CHUNK)
->get();

if ($duplicates->isEmpty()) {
return;
}

foreach ($duplicates as $duplicate) {
$this->collapseBucket($duplicate->metric_id, $duplicate->created_at);
}
}
}

/**
* Collapse a single duplicated bucket.
*/
private function collapseBucket(int|string $metricId, string $createdAt): void
{
/** @var Collection<int, object> $points */
$points = DB::table('metric_points')
->where('metric_id', $metricId)
->where('created_at', $createdAt)
->orderBy('id')
->get();

if ($points->count() < 2) {
return;
}

DB::table('metric_points')->where('id', $points->first()->id)->update([
'value' => $points->last()->value,
'sum_value' => $points->sum(fn (object $point): float => (float) $point->sum_value),
'counter' => $points->sum(fn (object $point): int => (int) $point->counter),
]);

DB::table('metric_points')
->whereIn('id', $points->skip(1)->pluck('id')->all())
->delete();
}
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
/**
* Run the migrations.
*
* The link is for display only: it groups a metric's chart under the
* component it describes. Component status is driven by component
* checks and incidents, never by metric points.
*/
public function up(): void
{
Schema::table('metrics', function (Blueprint $table) {
$table->unsignedInteger('component_id')->nullable()->after('order');

$table->index('component_id');
});
}

/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('metrics', function (Blueprint $table) {
$table->dropIndex(['component_id']);
$table->dropColumn('component_id');
});
}
};
14 changes: 10 additions & 4 deletions database/seeders/DemoMetricSeeder.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

namespace Cachet\Database\Seeders;

use Cachet\Concerns\RecordsMetricObservations;
use Cachet\Data\Metrics\MetricObservation;
use Cachet\Models\Metric;
use DateTimeInterface;
use Illuminate\Database\Seeder;
Expand All @@ -16,17 +18,21 @@ class DemoMetricSeeder extends Seeder
/**
* Push a fresh metric point onto the demo metric, if it still exists.
*/
public function run(): void
public function run(RecordsMetricObservations $recorder): void
{
$metric = Metric::query()->where('name', self::METRIC_NAME)->first();

if ($metric === null) {
return;
}

$metric->metricPoints()->create([
'value' => self::valueAt(now()),
]);
$recordedAt = now();

$recorder->record($metric, new MetricObservation(
value: self::valueAt($recordedAt),
recordedAt: $recordedAt,
source: 'cachet-demo',
));
}

/**
Expand Down

Large diffs are not rendered by default.

2 changes: 0 additions & 2 deletions public/build/assets/cachet-Ck_Tb6p8.css

This file was deleted.

2 changes: 2 additions & 0 deletions public/build/assets/cachet-D5GIyK51.css

Large diffs are not rendered by default.

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions public/build/manifest.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"resources/css/cachet.css": {
"file": "assets/cachet-Ck_Tb6p8.css",
"file": "assets/cachet-D5GIyK51.css",
"name": "cachet",
"names": [
"cachet.css"
Expand All @@ -18,7 +18,7 @@
"isEntry": true
},
"resources/js/cachet.js": {
"file": "assets/cachet-CCLiwWgX.js",
"file": "assets/cachet-2UijlQXg.js",
"name": "cachet",
"src": "resources/js/cachet.js",
"isEntry": true,
Expand All @@ -27,7 +27,7 @@
]
},
"resources/js/metrics.js": {
"file": "assets/metrics-DzddGFQc.js",
"file": "assets/metrics-DIhk1J3k.js",
"name": "metrics",
"src": "resources/js/metrics.js",
"isDynamicEntry": true
Expand Down
14 changes: 9 additions & 5 deletions resources/js/metrics.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,19 @@ function getTimeUnit(period, metricView) {
}

window.cachetMetricChart = function () {
const metricPoints = this.metric.metric_points.map((point) => ({
const rawPoints = (this.metric.chart_points?.raw ?? []).map((point) => ({
x: new Date(point.x),
y: point.y,
}))
const hourlyPoints = (this.metric.chart_points?.hourly ?? []).map((point) => ({
x: new Date(point.x),
y: point.y,
}))

this.points[0] = metricPoints.filter((point) => point.x >= previousHour)
this.points[1] = metricPoints.filter((point) => point.x >= previous24Hours)
this.points[2] = metricPoints.filter((point) => point.x >= previous7Days)
this.points[3] = metricPoints.filter((point) => point.x >= previous30Days)
this.points[0] = rawPoints.filter((point) => point.x >= previousHour)
this.points[1] = rawPoints.filter((point) => point.x >= previous24Hours)
this.points[2] = hourlyPoints.filter((point) => point.x >= previous7Days)
this.points[3] = hourlyPoints.filter((point) => point.x >= previous30Days)

let themeColors = getThemeColors()
const chart = new Chart(this.$refs.canvas, {
Expand Down
3 changes: 3 additions & 0 deletions resources/lang/en/metric.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
'default_view' => 'Default view',
'threshold' => 'Threshold',
'order' => 'Order',
'component' => 'Component',
'visible' => 'Visible',
'points_count' => 'Points count',
'created_at' => 'Created at',
Expand All @@ -32,6 +33,8 @@
'calc_type_label' => 'Metric type',
'places_label' => 'Places',
'threshold_label' => 'Threshold',
'component_label' => 'Component',
'component_helper_text' => 'Display this metric alongside a component. Metrics never affect a component\'s status.',

'visible_label' => 'Visible',
'display_chart_label' => 'Display chart',
Expand Down
6 changes: 6 additions & 0 deletions resources/views/components/metric.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ class="group relative overflow-hidden rounded-lg bg-white shadow-sm ring-1 ring-
<div class="flex flex-col gap-4 p-4 sm:gap-5 sm:p-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="flex min-w-0 flex-col gap-1">
@if($metric->component)
<div class="truncate text-xs font-medium uppercase tracking-wide text-zinc-500 dark:text-zinc-400">
{{ $metric->component->name }}
</div>
@endif

<div class="flex items-center gap-1.5">
<h3 class="truncate text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
{{ $metric->name }}
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@
])
->scoped(['updateable_id']);

Route::post('metrics/{metric}/points/batch', [MetricPointController::class, 'storeBatch'])
->name('metrics.points.batch');

Route::apiResource('metrics.points', MetricPointController::class, [
'except' => ['index', 'show', 'update'],
])
Expand Down
Loading
Loading