Testing
Testing
Swap the manager for an in-memory fake — no Redis, no HTTP, full sampling:
use Cbox\Telemetry\Facades\Telemetry;
it('tracks the order', function () {
$fake = Telemetry::fake();
app(PlaceOrder::class)->handle();
$fake->assertCounterIncremented('orders.created', ['tenant' => 'acme']);
$fake->assertHistogramRecorded('checkout.duration');
$fake->assertSpanRecorded('checkout.payment',
fn ($span) => $span->attributes()['gateway'] === 'stripe');
$fake->assertEventEmitted('order.placed');
});
What the fake does not do
Redaction. The engine is the last thing that touches a batch before an exporter sees it, and the fake collects spans in place of exporting them — so the assertions below show what was recorded, not what would be sent. A test asserting that a secret was redacted passes here whether or not the engine would have caught it.
To assert redaction, use a real manager with a CollectingExporter and
flush it.
Available assertions
| Assertion | Notes |
|---|---|
assertCounterIncremented($name, ?$labels) |
labels match exactly |
assertCounterNotIncremented($name) |
|
assertGaugeSet($name, ?$labels) |
push gauges |
assertGaugeNotSet($name) |
|
assertHistogramRecorded($name, ?$labels) |
|
assertHistogramNotRecorded($name) |
|
assertSpanRecorded($name, ?$callback) |
callback receives each Span |
assertSpanNotRecorded($name) |
|
assertEventEmitted($name, ?$callback) |
callback receives each TelemetryEvent |
assertEventNotEmitted($name) |
|
assertMetricLabelValues($name, $label, $values) |
the label's observed values, exactly |
assertQueryCount($op, $n), assertNoNPlusOne(), assertNoDuplicateQueries(), assertNoSlowQueries(?$ms), assertMaxDuration($ms) |
per recorded trace — see Performance assertions |
Reading values: counterValue, gaugeValue, histogramCount, histogramSum.
On recordedMetrics() (below): assertLabelValues, assertSeriesCount,
assertCardinalityBelow, assertLabelCardinalityBelow.
Reading values
$fake->counterValue('orders.created', ['tenant' => 'acme']); // 2.0
$fake->gaugeValue('queue.depth'); // observables too
$fake->histogramCount('checkout.duration');
$fake->recordedSpans('import.customers'); // list<Span>
$fake->recordedEvents(); // list<TelemetryEvent>
$fake->recordedMetrics('orders.created'); // RecordedMetrics
$fake->metricLabelValues('orders.created', 'tenant'); // ['acme', 'globex']
Inspecting metrics
assertCounterIncremented($name, $labels) answers does this series
exist? That is the wrong question whenever a label value is itself the
thing under test: code that attributes a counter across five stages
produces a valid label value on every branch, so an assertion that only
checks one — or checks membership of the vocabulary — passes while four
branches are dead.
recordedMetrics() is the metric counterpart to recordedSpans(): every
recorded series, with the metric it belongs to, its labels and its value.
$fake->recordedMetrics('transit.pairs')
->assertLabelValues('stage', ['cache', 'estimated', 'provider', 'unresolved'])
->assertCardinalityBelow(50);
assertLabelValues() is exact — every expected value must have been
observed and nothing else may appear, so a stage that never fired fails
the test by name. It also requires every series in the set to carry the
label: "exactly these stage values" is a lie by omission if some series
have no stage at all. When a label really is optional, scope the set
first with withLabels() or filter().
Filters return a new set; assertions return the set, so both chain:
$metrics = $fake->recordedMetrics(); // everything recorded
$metrics->forMetric('orders.created'); // one metric
$metrics->ofType(MetricType::Histogram); // one instrument type
$metrics->withLabels(['tenant' => 'acme']); // series carrying these labels
$metrics->filter(fn ($sample) => $sample->value > 100);
$metrics->samples(); // list<RecordedSample>
$metrics->names(); // distinct metric names
$metrics->labelValues('stage'); // distinct values, sorted
$metrics->labelSets(); // list<array<string, string>>
$metrics->labelCardinality(); // ['user' => 4012, 'tenant' => 3]
$metrics->seriesCount(); // how many series exist
$metrics->total(); // sum across every labelset
Each RecordedSample carries name, type, labels, value (the sum,
for a histogram), count (histograms only) and the raw histogram
sample with its bounds and exemplar. The set is Countable and
iterable.
Cardinality
Cardinality is the reason these labels are worth testing at all, so the budget assertions are first-class — and a failure names the label that blew it:
$fake->recordedMetrics('http.requests')
->assertCardinalityBelow(200) // total series
->assertLabelCardinalityBelow('route', 100) // one label
->assertSeriesCount(12); // or pin it exactly
Metric [http.requests] recorded 4013 series, which is not below the budget
of 200. Distinct values per label: user (4012), method (3), status (2).
A budget on a metric that recorded nothing would pass for the worst
possible reason — the instrumentation never ran — so it fails instead.
Assert an absence deliberately with assertSeriesCount(0) or
assertCounterNotIncremented().
Performance assertions
The query instrumentation already counts every query, groups them by SQL shape, tells an N+1 from a duplicate and records the line of code that ran it. The fake reads that back, so a test can hold a code path to a budget:
use Cbox\Telemetry\Facades\Telemetry;
it('lists orders without an N+1', function () {
Telemetry::fake();
$this->get('/orders')->assertOk();
Telemetry::assertQueryCount('<=', 10);
Telemetry::assertNoNPlusOne();
Telemetry::assertNoDuplicateQueries();
Telemetry::assertNoSlowQueries(); // traces.details.slow_span_ms, or pass a threshold
Telemetry::assertMaxDuration(200); // ms, the request's root span
});
Each assertion checks every trace recorded since fake(), one at a
time. Two requests in one test are two budgets, and the failure names
the one that blew it. A failure lists each SQL shape with how often it
ran and where:
[GET /orders] has 1 N+1 query pattern — one statement run again and again
with different values, typically once per row of something loaded earlier.
Eager-load the relation (with()/load()) or fetch the set with one whereIn().
20× select * from "order_items" where "order_items"."order_id" = ? (3.1 ms)
at app/Http/Controllers/OrderController.php:42 (App\Http\Controllers\OrderController::index)
assertQueryCount(3) means exactly three; with an operator, use one of
=, !=, <, <=, > or >=.
A budget around a closure
budget() runs a closure and checks everything it did as one profile,
however many requests or traces that covers. It returns what the closure
returns:
$orders = Telemetry::budget(
fn () => Order::with('items')->latest()->take(50)->get(),
queries: 2,
ms: 100, // wall time of the closure
);
A budget fails on any N+1 or duplicate query even when the count fits. If
the loop is intended, pass allowNPlusOne: true or allowDuplicates: true.
Code that runs outside a request is wrapped in a telemetry.budget span,
because the query instrumentation only records queries inside a trace.
Scoping to one request
profiles() returns one PerformanceProfile per trace, in the order the
traces started. Each profile has the same assertions, and they chain:
$this->get('/orders');
$this->get('/orders/1');
[$index, $show] = Telemetry::profiles();
$index->assertQueryCount('<=', 10)->assertNoNPlusOne();
$show->assertQueryCount('<=', 3);
$index->queryCount(); // int
$index->queryGroups(); // list<QueryGroup>: sql, count, durationMs, origin, nPlusOne, duplicate
$index->nPlusOnes(); // the flagged groups
$index->durationMs();
Pest expectations
Pest is not a dependency of this package, so its expectations are opt-in.
Register them once in tests/Pest.php:
\Cbox\Telemetry\Testing\PestExpectations::register();
Telemetry::fake();
expect(fn () => $this->get('/orders'))->toStayWithinBudget(queries: 10, ms: 200);
expect(fn () => $this->get('/orders'))->toQueryAtMost(10);
expect(fn () => $this->get('/orders'))->toHaveNoNPlusOne();
expect(fn () => $this->get('/orders'))->toHaveNoDuplicateQueries();
Each expectation runs the closure once, through budget().
What these read, and when they refuse
- Query count: the root span's
db.query.counttally, or the number ofdb.queryspans, whichever is higher. The tally still counts queries that fall underinstrument.queries_min_duration. - N+1 and duplicates: whatever the detector flagged. A shape counts
once it repeats
instrument.query_duplicates_thresholdtimes (default 3) in one trace. To catch a two-row loop in tests, setTELEMETRY_INSTRUMENT_QUERY_DUPLICATES_THRESHOLD=2inphpunit.xml. - Origins: by default only flagged queries (slow or repeated) carry
code.*. To get a line for every shape in a query-count failure, setTELEMETRY_INSTRUMENT_QUERY_ORIGIN=allinphpunit.xml.
An assertion that cannot see anything fails rather than passing. That
happens in two cases: no trace was recorded (the request was made before
fake(), or request instrumentation is off), or the switch it reads is
off (telemetry.enabled, instrument.queries, or, for N+1 and
duplicates, instrument.query_duplicates). The failure message names the
switch to turn on.
Span resource attributes
The fake measures span resources, exactly as the service provider does for
the real tracer when instrument.resources is on (the default), so every
recorded span carries php.cpu.time_ms and php.memory.delta_bytes:
$fake->span('import.customers', fn () => $importer->run());
$span = $fake->recordedSpans('import.customers')[0];
$span->attributes()['php.cpu.time_ms']; // float
$span->attributes()['php.memory.delta_bytes']; // int, may be negative
To model an app that turned the measurement off, turn it off on the fake's tracer too:
$fake->tracer()->measureSpanResources(false);
Peak-memory and OS-level numbers (php.memory.peak_bytes,
process.memory.rss_peak_bytes, process.cpu.utilization) are per unit of
work, not per span — the request middleware and the queue instrumentation
put them on the root span. See Traces.
Testing a telemetry provider
Package authors can test their provider without booting Laravel:
use Cbox\Telemetry\Testing\TelemetryFake;
it('publishes queue metrics', function () {
$fake = new TelemetryFake;
$fake->provider(new QueueMetricsProvider);
$families = collect($fake->collect())->keyBy(fn ($f) => $f->name());
expect($families['queue.depth']->samples[0]->value)->toBe(12.0);
});
Testing what happens when the backend says no
Telemetry::fake() collects batches and answers ok() to every export,
so a suite built only on it can never prove that a rejection is noticed.
Testing\RejectingExporter is the other half:
use Cbox\Telemetry\Facades\Telemetry;
use Cbox\Telemetry\Support\ExportResult;
use Cbox\Telemetry\Testing\RejectingExporter;
it('fails the scheduled flush when the collector rejects the batch', function () {
Telemetry::addExporter(new RejectingExporter);
$this->artisan('telemetry:flush')->assertFailed();
});
It defaults to a permanent HTTP 400; pass any ExportResult to model a
different answer — ExportResult::retryable('HTTP 503'),
ExportResult::partial(3, 'out-of-order sample') — and name: to make it
stand in for a specific exporter. batches() returns what it was offered,
because a rejected export still happened.
Flushes return an ExportReport you can assert on directly:
$report = Telemetry::flushMetrics();
expect($report->successful())->toBeFalse()
->and($report->summary())->toBe('1 of 2 exporters accepted the batch')
->and($report->failures()[0]->describe())->toContain('HTTP 400');