From d3e566c96ce8e6c8aac04428c0d51b415b0c67f5 Mon Sep 17 00:00:00 2001 From: Sylvester Damgaard Date: Wed, 23 Sep 2026 09:40:14 +0200 Subject: [PATCH] test: pin label vocabularies with laravel-telemetry 2.4 metric inspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Membership assertions ("a series with label X exists") pass on a single branch of an attribution, which is the wrong question wherever the label value is the thing under test. Replace them — and the hand-rolled collect() loops — with recordedMetrics() and exact label-set assertions: - FrontendRequestTest: http.route on the request histogram is now pinned to exactly the logical content route / not_found, one series. - ListenersTest: glide ad-hoc manipulations must collapse into one "custom" bucket (three distinct param sets, two series); the auth vocabulary is the explicit map, nothing else. - PublishStateTest: a new test saves all four publish states in one run and pins the observed action set, covering `expired` — which the status snapshot supports but no single-state test exercised. - StaticCacheTest: miss/write and invalidate/flush are exact sets, so a purge path counted twice under the wrong outcome fails. - MetricsProviderTest: the entries gauge is one series per collection, not just whatever samples[0] happens to be. - CacheKeyGroupsTest (new): drives a Stache-scale keyspace through the real cache counter, proving Hooks::register installs the classifier at all and that ~70 raw keys stay under a 25-series budget with nothing dropped. --- tests/Feature/CacheKeyGroupsTest.php | 90 +++++++++++++++++++++++++++ tests/Feature/FrontendRequestTest.php | 34 +++------- tests/Feature/ListenersTest.php | 25 ++++++-- tests/Feature/MetricsProviderTest.php | 15 ++--- tests/Feature/PublishStateTest.php | 29 +++++++++ tests/Feature/StaticCacheTest.php | 16 +++-- 6 files changed, 168 insertions(+), 41 deletions(-) create mode 100644 tests/Feature/CacheKeyGroupsTest.php diff --git a/tests/Feature/CacheKeyGroupsTest.php b/tests/Feature/CacheKeyGroupsTest.php new file mode 100644 index 0000000..792d813 --- /dev/null +++ b/tests/Feature/CacheKeyGroupsTest.php @@ -0,0 +1,90 @@ +set('telemetry.instrument.cache', true); + $app['config']->set('cache.default', 'array'); + } + + public function test_a_stache_scale_keyspace_collapses_to_bounded_key_groups(): void + { + $fake = $this->fakeTelemetry(); + + $keys = $this->keyspace(); + + // Three operations per key: a cold read, a write, a warm read. + foreach ($keys as $key) { + Cache::get($key); + Cache::put($key, 'value'); + Cache::get($key); + } + + $operations = $fake->recordedMetrics('cache.operations'); + + $operations + ->assertLabelValues('key_group', [ + 'stache.index', + 'stache.item', + 'stache.meta', + 'static_cache', + 'static_cache.nocache', + 'app', + ]) + // Six groups x three operations x one store. Leaking the raw + // key — or any per-collection/per-id fragment of it — into the + // label turns this into hundreds of series. + ->assertCardinalityBelow(25) + ->assertLabelCardinalityBelow('key_group', 10); + + // Nothing was dropped: a classifier that returned null for a key + // would silently lose that key's operations from the counter, and + // the labelset would stop being comparable across stores. + $this->assertSame(3.0 * count($keys), $operations->total()); + } + + /** + * A warm Stache's cache traffic, at the shape (not the volume) a real + * site produces: many ids under a few bounded prefixes. + * + * @return list + */ + private function keyspace(): array + { + $keys = ['stache::timing', 'stache::timestamps::entries', 'some-app-key']; + + foreach (['blog', 'pages', 'docs', 'events'] as $collection) { + foreach (['title', 'slug', 'uri', 'published'] as $index) { + $keys[] = "stache::indexes::collections::{$collection}::{$index}"; + } + + foreach (range(1, 10) as $id) { + $keys[] = "stache::items::collections::{$collection}::entry-{$id}"; + } + + $keys[] = "static-cache:responses:{$collection}-index"; + $keys[] = "nocache::session.{$collection}"; + } + + return $keys; + } +} diff --git a/tests/Feature/FrontendRequestTest.php b/tests/Feature/FrontendRequestTest.php index e3a5485..7004651 100644 --- a/tests/Feature/FrontendRequestTest.php +++ b/tests/Feature/FrontendRequestTest.php @@ -48,18 +48,11 @@ // http.route now carries the logical content route (via the core // resolveRouteUsing hook), so route tables and histograms group by it - // instead of the /{segments?} catch-all. - $routes = []; - foreach ($fake->collect() as $family) { - if ($family->name() === 'http.server.request.duration') { - foreach ($family->samples as $sample) { - $routes[] = $sample->labels['http.route'] ?? null; - } - } - } - - expect($routes)->toContain('entry:pages.page') - ->and($routes)->not->toContain('/{segments?}'); + // instead of the /{segments?} catch-all. Exactly one series — the + // catch-all must not survive *beside* the logical route either. + $fake->recordedMetrics('http.server.request.duration') + ->assertLabelValues('http.route', ['entry:pages.page']) + ->assertSeriesCount(1); }); test('a Statamic frontend 404 is bucketed as not_found', function () { @@ -67,19 +60,12 @@ $this->get('/definitely-missing')->assertNotFound(); - $routes = []; - foreach ($fake->collect() as $family) { - if ($family->name() === 'http.server.request.duration') { - foreach ($family->samples as $sample) { - $routes[] = $sample->labels['http.route'] ?? null; - } - } - } - // 404 traffic (broken links, bots) gets its own bounded bucket instead - // of polluting the /{segments?} catch-all. - expect($routes)->toContain('not_found') - ->and($routes)->not->toContain('/{segments?}'); + // of polluting the /{segments?} catch-all — and gets nothing else: a + // per-URI fallback would show up here as extra series. + $fake->recordedMetrics('http.server.request.duration') + ->assertLabelValues('http.route', ['not_found']) + ->assertSeriesCount(1); // The raw catch-all template is still preserved on the span. $span = array_values(array_filter( diff --git a/tests/Feature/ListenersTest.php b/tests/Feature/ListenersTest.php index 7bf65c5..2218801 100644 --- a/tests/Feature/ListenersTest.php +++ b/tests/Feature/ListenersTest.php @@ -24,14 +24,22 @@ use Statamic\Facades\Search; use Statamic\Facades\User; -test('glide generations are counted per preset', function () { +test('glide generations are counted per preset, ad-hoc manipulations grouped', function () { $fake = $this->fakeTelemetry(); event(new GlideImageGenerated('img/hero.jpg', ['p' => 'thumbnail'])); + + // Ad-hoc manipulations are unbounded in the wild — every width a + // template asks for is another param set. They must all collapse into + // the one "custom" bucket instead of becoming label values, so the + // three below have to stay two series in total. event(new GlideImageGenerated('img/hero.jpg', ['w' => 100])); + event(new GlideImageGenerated('img/hero.jpg', ['w' => 640, 'h' => 480])); + event(new GlideImageGenerated('img/other.jpg', ['fit' => 'crop_focal'])); - $fake->assertCounterIncremented('statamic.glide.generations', ['preset' => 'thumbnail']); - $fake->assertCounterIncremented('statamic.glide.generations', ['preset' => 'custom']); + $fake->recordedMetrics('statamic.glide.generations') + ->assertLabelValues('preset', ['thumbnail', 'custom']) + ->assertSeriesCount(2); }); test('form submissions are counted per form', function () { @@ -71,9 +79,14 @@ event(new TwoFactorAuthenticationFailed($user)); event(new ImpersonationStarted($user, $user)); - $fake->assertCounterIncremented('statamic.auth.events', ['event' => 'user_registered']); - $fake->assertCounterIncremented('statamic.auth.events', ['event' => 'two_factor_failed']); - $fake->assertCounterIncremented('statamic.auth.events', ['event' => 'impersonation_started']); + // The vocabulary is the explicit map in RecordAuthEvent — three events + // in, exactly three label values out. A fallback that labelled an + // unmapped event by its class name would show up as a fourth. + $fake->assertMetricLabelValues('statamic.auth.events', 'event', [ + 'user_registered', + 'two_factor_failed', + 'impersonation_started', + ]); }); test('glide cache clears are counted by scope', function () { diff --git a/tests/Feature/MetricsProviderTest.php b/tests/Feature/MetricsProviderTest.php index 4f20973..b3cf357 100644 --- a/tests/Feature/MetricsProviderTest.php +++ b/tests/Feature/MetricsProviderTest.php @@ -16,13 +16,14 @@ Entry::make()->collection('pages')->slug('one')->save(); Entry::make()->collection('pages')->slug('two')->save(); - $families = collect($fake->collect()); + // One series per collection. Reading samples[0] could not see a + // duplicate `pages` series or a stray extra collection — either of + // which doubles the entry total on a dashboard. + $entries = $fake->recordedMetrics('statamic.entries.count') + ->assertLabelValues('collection', ['pages']) + ->assertSeriesCount(1); - $entries = $families->first(fn ($family) => $family->name() === 'statamic.entries.count'); + expect($entries->total())->toBe(2.0); - expect($entries)->not->toBeNull() - ->and($entries->samples[0]->labels)->toBe(['collection' => 'pages']) - ->and($entries->samples[0]->value)->toBe(2.0); - - expect($families->first(fn ($family) => $family->name() === 'statamic.users.count'))->not->toBeNull(); + expect($fake->recordedMetrics('statamic.users.count'))->not->toBeEmpty(); }); diff --git a/tests/Feature/PublishStateTest.php b/tests/Feature/PublishStateTest.php index 98bdb2c..50cc005 100644 --- a/tests/Feature/PublishStateTest.php +++ b/tests/Feature/PublishStateTest.php @@ -55,3 +55,32 @@ $fake->assertCounterIncremented('statamic.content.changes', ['type' => 'collection', 'action' => 'saved']); }); + +test('the publish-state vocabulary is complete and mutually exclusive in one run', function () { + // The per-state tests above each save one entry, so they cannot see a + // status that is resolved once and reused, or a state that quietly + // collapses into a neighbour: every one of them still passes when all + // four saves land on the same action. Saving all four in one run and + // pinning the observed set does catch that — and covers `expired`, + // which the snapshot supports but no single-state test exercises. + Collection::make('news')->dated(true) + ->futureDateBehavior('private') + ->pastDateBehavior('private') + ->save(); + + $fake = $this->fakeTelemetry(); + + Entry::make()->collection('pages')->slug('e')->published(true)->save(); + Entry::make()->collection('pages')->slug('f')->published(false)->save(); + Entry::make()->collection('news')->slug('g')->published(true) + ->date(now()->addWeek()->format('Y-m-d-Hi'))->save(); + Entry::make()->collection('news')->slug('h')->published(true) + ->date(now()->subWeek()->format('Y-m-d-Hi'))->save(); + + $fake->recordedMetrics('statamic.content.changes') + ->assertLabelValues('action', ['published', 'draft', 'scheduled', 'expired']) + // Entry saves never fall back to the generic `entry/saved` pair, + // and nothing else got attributed to this counter. + ->assertLabelValues('type', ['entry']) + ->assertSeriesCount(4); +}); diff --git a/tests/Feature/StaticCacheTest.php b/tests/Feature/StaticCacheTest.php index c973460..bc9ee05 100644 --- a/tests/Feature/StaticCacheTest.php +++ b/tests/Feature/StaticCacheTest.php @@ -58,8 +58,12 @@ function serving(Request $request): Request $span->end(); $fake->flush(); - $fake->assertCounterIncremented('statamic.static_cache.operations', ['operation' => 'miss']); - $fake->assertCounterIncremented('statamic.static_cache.operations', ['operation' => 'write']); + // A probe then a write: exactly those two outcomes. The per-request + // dedupe only remembers the *last* outcome, so a miss recorded again + // after the write would slip past a membership assertion. + $fake->recordedMetrics('statamic.static_cache.operations') + ->assertLabelValues('operation', ['miss', 'write']) + ->assertSeriesCount(2); $fake->assertSpanRecorded('GET /about', fn ($span) => $span->attributes()['statamic.static_cache'] === 'write'); }); @@ -108,8 +112,12 @@ function serving(Request $request): Request $cacher->invalidateUrl('/about'); $cacher->flush(); - $fake->assertCounterIncremented('statamic.static_cache.operations', ['operation' => 'invalidate']); - $fake->assertCounterIncremented('statamic.static_cache.operations', ['operation' => 'flush']); + // Two purge calls, two outcomes. Statamic's invalidation paths call + // into each other, so the point is as much that invalidateUrl did not + // *also* get counted as a flush (or a miss) as that both fired. + $fake->recordedMetrics('statamic.static_cache.operations') + ->assertLabelValues('operation', ['invalidate', 'flush']) + ->assertSeriesCount(2); }); test('the trace id header is stripped before the application cacher snapshots headers', function () {