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 @@ -214,6 +214,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');
});
}
};
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 @@ -13,6 +13,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
20 changes: 13 additions & 7 deletions resources/views/components/metrics.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,26 @@ function getThemeColors() {

let themeColors = getThemeColors()

function init() {
// Parse metric points
const metricPoints = this.metric.metric_points.map((point) => {
function parsePoints(points) {
return (points ?? []).map((point) => {
return {
x: new Date(point.x),
y: point.y,
}
})
}

function init() {
// The hour and day windows are drawn from raw buckets; the week and
// month windows from hourly totals rolled up in the database.
const rawPoints = parsePoints(this.metric.chart_points?.raw)
const hourlyPoints = parsePoints(this.metric.chart_points?.hourly)

// Filter points based on the selected period
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)

// Initialize chart
const chart = new Chart(this.$refs.canvas, {
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,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
27 changes: 14 additions & 13 deletions src/Actions/Metric/CreateMetricPoint.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,30 +2,31 @@

namespace Cachet\Actions\Metric;

use Cachet\Concerns\RecordsMetricObservations;
use Cachet\Data\Metrics\MetricObservation;
use Cachet\Data\Requests\Metric\CreateMetricPointRequestData;
use Cachet\Models\Metric;
use Cachet\Models\MetricPoint;
use Illuminate\Support\Carbon;

class CreateMetricPoint
{
public function __construct(private readonly RecordsMetricObservations $recorder) {}

/**
* Handle the action.
*
* The submitted value is now added to the metric's bucket for that
* moment rather than replacing it, so an observation arriving inside
* the metric's threshold is no longer thrown away.
*/
public function handle(Metric $metric, ?CreateMetricPointRequestData $data = null): MetricPoint
{
$lastPoint = $metric->metricPoints()->latest()->first();

// If the last point was created within the threshold, increment the counter.
if ($lastPoint && $lastPoint->withinThreshold($metric->threshold, $data->timestamp ?? null)) {
$lastPoint->increment('counter');

return $lastPoint;
}
$timestamp = $data->timestamp ?? null;

return $metric->metricPoints()->create([
'value' => $data->value ?? $metric->default_value,
'counter' => 1,
'created_at' => $data->timestamp ?? now(),
]);
return $this->recorder->record($metric, new MetricObservation(
value: (float) ($data->value ?? $metric->default_value ?? 0),
recordedAt: $timestamp === null ? null : Carbon::parse($timestamp),
));
}
}
Loading
Loading