diff --git a/config/cachet.php b/config/cachet.php index bcacd278..6d98e65f 100644 --- a/config/cachet.php +++ b/config/cachet.php @@ -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 diff --git a/database/factories/MetricPointFactory.php b/database/factories/MetricPointFactory.php index 8cec8dfc..d3ce2f29 100644 --- a/database/factories/MetricPointFactory.php +++ b/database/factories/MetricPointFactory.php @@ -5,6 +5,7 @@ use Cachet\Models\Metric; use Cachet\Models\MetricPoint; use Illuminate\Database\Eloquent\Factories\Factory; +use Illuminate\Support\Carbon; /** * @extends Factory @@ -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. * @@ -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), ]; } } diff --git a/database/migrations/2026_07_25_000001_add_sum_value_to_metric_points_table.php b/database/migrations/2026_07_25_000001_add_sum_value_to_metric_points_table.php new file mode 100644 index 00000000..8eb3a022 --- /dev/null +++ b/database/migrations/2026_07_25_000001_add_sum_value_to_metric_points_table.php @@ -0,0 +1,39 @@ +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'); + }); + } +}; diff --git a/database/migrations/2026_07_25_000002_add_bucket_index_to_metric_points_table.php b/database/migrations/2026_07_25_000002_add_bucket_index_to_metric_points_table.php new file mode 100644 index 00000000..5be29095 --- /dev/null +++ b/database/migrations/2026_07_25_000002_add_bucket_index_to_metric_points_table.php @@ -0,0 +1,103 @@ +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 $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(); + } +}; diff --git a/database/migrations/2026_07_25_000003_add_component_id_to_metrics_table.php b/database/migrations/2026_07_25_000003_add_component_id_to_metrics_table.php new file mode 100644 index 00000000..d00d0f17 --- /dev/null +++ b/database/migrations/2026_07_25_000003_add_component_id_to_metrics_table.php @@ -0,0 +1,35 @@ +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'); + }); + } +}; diff --git a/database/seeders/DemoMetricSeeder.php b/database/seeders/DemoMetricSeeder.php index 89665e58..070b7653 100644 --- a/database/seeders/DemoMetricSeeder.php +++ b/database/seeders/DemoMetricSeeder.php @@ -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; @@ -16,7 +18,7 @@ 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(); @@ -24,9 +26,13 @@ public function run(): void return; } - $metric->metricPoints()->create([ - 'value' => self::valueAt(now()), - ]); + $recordedAt = now(); + + $recorder->record($metric, new MetricObservation( + value: self::valueAt($recordedAt), + recordedAt: $recordedAt, + source: 'cachet-demo', + )); } /** diff --git a/public/build/assets/cachet-CCLiwWgX.js b/public/build/assets/cachet-2UijlQXg.js similarity index 99% rename from public/build/assets/cachet-CCLiwWgX.js rename to public/build/assets/cachet-2UijlQXg.js index 45c02ac2..8e24cc21 100644 --- a/public/build/assets/cachet-CCLiwWgX.js +++ b/public/build/assets/cachet-2UijlQXg.js @@ -2,4 +2,4 @@ var e=!1,t=!1,n=[],r=-1;function i(e){a(e)}function a(e){n.includes(e)||n.push(e ${n?`Expression: "`+n+`" -`:``}`,t),setTimeout(()=>{throw e},0)}var Ne=!0;function Pe(e){let t=Ne;Ne=!1;let n=e();return Ne=t,n}function C(e,t,n={}){let r;return w(e,t)(e=>r=e,n),r}function w(...e){return Fe(...e)}var Fe=Le;function Ie(e){Fe=e}function Le(e,t){let n={};ke(n,e);let r=[n,...be(e)],i=typeof t==`function`?Re(r,t):Ve(r,t,e);return je.bind(null,e,t,i)}function Re(e,t){return(n=()=>{},{scope:r={},params:i=[]}={})=>{He(n,t.apply(xe([r,...e]),i))}}var ze={};function Be(e,t){if(ze[e])return ze[e];let n=Object.getPrototypeOf(async function(){}).constructor,r=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,i=(()=>{try{let t=new n([`__self`,`scope`],`with (scope) { __self.result = ${r} }; __self.finished = true; return __self.result;`);return Object.defineProperty(t,"name",{value:`[Alpine] ${e}`}),t}catch(n){return Me(n,t,e),Promise.resolve()}})();return ze[e]=i,i}function Ve(e,t,n){let r=Be(t,n);return(i=()=>{},{scope:a={},params:o=[]}={})=>{r.result=void 0,r.finished=!1;let s=xe([a,...e]);if(typeof r==`function`){let e=r(r,s).catch(e=>Me(e,n,t));r.finished?(He(i,r.result,s,o,n),r.result=void 0):e.then(e=>{He(i,e,s,o,n)}).catch(e=>Me(e,n,t)).finally(()=>r.result=void 0)}}}function He(e,t,n,r,i){if(Ne&&typeof t==`function`){let a=t.apply(n,r);a instanceof Promise?a.then(t=>He(e,t,n,r)).catch(e=>Me(e,i,t)):e(a)}else typeof t==`object`&&t instanceof Promise?t.then(t=>e(t)):e(t)}var Ue=`x-`;function T(e=``){return Ue+e}function We(e){Ue=e}var Ge={};function E(e,t){return Ge[e]=t,{before(t){if(!Ge[t]){console.warn(String.raw`Cannot find directive \`${t}\`. \`${e}\` will use the default order of execution`);return}let n=D.indexOf(t);D.splice(n>=0?n:D.indexOf(`DEFAULT`),0,e)}}}function Ke(e){return Object.keys(Ge).includes(e)}function qe(e,t,n){if(t=Array.from(t),e._x_virtualDirectives){let n=Object.entries(e._x_virtualDirectives).map(([e,t])=>({name:e,value:t})),r=Je(n);n=n.map(e=>r.find(t=>t.name===e.name)?{name:`x-bind:${e.name}`,value:`"${e.value}"`}:e),t=t.concat(n)}let r={};return t.map(rt((e,t)=>r[e]=t)).filter(ot).map(ct(r,n)).sort(ut).map(t=>et(e,t))}function Je(e){return Array.from(e).map(rt()).filter(e=>!ot(e))}var Ye=!1,Xe=new Map,Ze=Symbol();function Qe(e){Ye=!0;let t=Symbol();Ze=t,Xe.set(t,[]);let n=()=>{for(;Xe.get(t).length;)Xe.get(t).shift()();Xe.delete(t)};e(n),Ye=!1,n()}function $e(e){let t=[],n=e=>t.push(e),[r,i]=_(e);return t.push(i),[{Alpine:Rn,effect:r,cleanup:n,evaluateLater:w.bind(w,e),evaluate:C.bind(C,e)},()=>t.forEach(e=>e())]}function et(e,t){let n=Ge[t.type]||(()=>{}),[r,i]=$e(e);ie(e,t.original,i);let a=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,r),n=n.bind(n,e,t,r),Ye?Xe.get(Ze).push(n):n())};return a.runCleanups=i,a}var tt=(e,t)=>({name:n,value:r})=>(n.startsWith(e)&&(n=n.replace(e,t)),{name:n,value:r}),nt=e=>e;function rt(e=()=>{}){return({name:t,value:n})=>{let{name:r,value:i}=it.reduce((e,t)=>t(e),{name:t,value:n});return r!==t&&e(r,t),{name:r,value:i}}}var it=[];function at(e){it.push(e)}function ot({name:e}){return st().test(e)}var st=()=>RegExp(`^${Ue}([^:^.]+)\\b`);function ct(e,t){return({name:n,value:r})=>{let i=n.match(st()),a=n.match(/:([a-zA-Z0-9\-_:]+)/),o=n.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],s=t||e[n]||n;return{type:i?i[1]:null,value:a?a[1]:null,modifiers:o.map(e=>e.replace(`.`,``)),expression:r,original:s}}}var lt=`DEFAULT`,D=[`ignore`,`ref`,`data`,`id`,`anchor`,`bind`,`init`,`for`,`model`,`modelable`,`transition`,`show`,`if`,lt,`teleport`];function ut(e,t){let n=D.indexOf(e.type)===-1?lt:e.type,r=D.indexOf(t.type)===-1?lt:t.type;return D.indexOf(n)-D.indexOf(r)}function dt(e,t,n={}){e.dispatchEvent(new CustomEvent(t,{detail:n,bubbles:!0,composed:!0,cancelable:!0}))}function O(e,t){if(typeof ShadowRoot==`function`&&e instanceof ShadowRoot){Array.from(e.children).forEach(e=>O(e,t));return}let n=!1;if(t(e,()=>n=!0),n)return;let r=e.firstElementChild;for(;r;)O(r,t,!1),r=r.nextElementSibling}function k(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ft=!1;function pt(){ft&&k(`Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems.`),ft=!0,document.body||k("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's `