Skip to content

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');
});

Available assertions

Assertion Notes
assertCounterIncremented($name, ?$labels) labels match exactly
assertCounterNotIncremented($name)
assertGaugeSet($name, ?$labels) push gauges
assertHistogramRecorded($name, ?$labels)
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

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.

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).

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');