Sampling
Sending every trace your application produces gets expensive fast and rarely tells you anything new. Sampling lets you keep a representative slice of traffic and drop the rest. When a trace is dropped, no spans are collected or sent for it.
A sampled trace is not free. Recording spans, capturing query bindings, gathering request and response data, and serialising the trace before it is shipped all add overhead to the request, command, or job that produced it. We work hard to keep this overhead as small as possible, and unsampled traces stop almost all of the work before it starts, but on hot paths the difference between a sampled and an unsampled trace is still measurable. Picking a sample rate is therefore a trade-off between coverage and runtime cost.
Setting a sample rate
The default sampler is the RateSampler, which applies the same rate to every trace. The default rate is 10%. You can change it in config/flare.php:
'sampler' => [
'class' => \Spatie\FlareClient\Sampling\RateSampler::class,
'config' => [
'rate' => 0.5, // 50% of all traces will be sent to Flare
],
],
Setting the rate to 1.0 samples every trace, 0.0 drops every trace.
Sampling per kind of work
When a single uniform rate is too blunt, switch to the DynamicSampler. It applies a base rate to traces that don't match any rule, and lets you override that rate per route, path, command, or job. Health checks can be sampled rarely, checkout flows aggressively, and noisy queue jobs can follow their own logic.
Each rule is expressed as an array with a type (a SamplingRule subclass), a pattern, and a rate. This keeps config/flare.php compatible with php artisan config:cache, which serialises the config to PHP and cannot carry object instances.
use Spatie\FlareClient\Sampling\DynamicSampler;
use Spatie\FlareClient\Sampling\Rules\CommandSamplingRule;
use Spatie\FlareClient\Sampling\Rules\JobSamplingRule;
use Spatie\FlareClient\Sampling\Rules\PathSamplingRule;
use Spatie\LaravelFlare\Sampling\QueueConnectionSamplingRule;
use Spatie\LaravelFlare\Sampling\QueueNameSamplingRule;
use Spatie\LaravelFlare\Sampling\RouteActionSamplingRule;
use Spatie\LaravelFlare\Sampling\RouteNameSamplingRule;
'sampler' => [
'class' => DynamicSampler::class,
'config' => [
'base_rate' => 0.1,
'rules' => [
['type' => PathSamplingRule::class, 'pattern' => '/health-check', 'rate' => 0.0],
['type' => PathSamplingRule::class, 'pattern' => '/admin/*', 'rate' => 1.0],
['type' => RouteNameSamplingRule::class, 'pattern' => 'checkout.*', 'rate' => 1.0],
['type' => RouteActionSamplingRule::class, 'pattern' => 'App\\Http\\Controllers\\Admin\\*', 'rate' => 1.0],
['type' => RouteActionSamplingRule::class, 'pattern' => [App\Http\Controllers\BillingController::class, 'show'], 'rate' => 1.0],
['type' => CommandSamplingRule::class, 'pattern' => 'horizon:work', 'rate' => 0.0],
['type' => JobSamplingRule::class, 'pattern' => 'App\\Jobs\\ProcessPayment', 'rate' => 1.0],
['type' => QueueNameSamplingRule::class, 'pattern' => 'notifications', 'rate' => 0.05],
['type' => QueueConnectionSamplingRule::class, 'pattern' => 'redis*', 'rate' => 1.0],
],
],
],
base_rate is used when no rule matches. Rules are evaluated in order, the first one that matches wins.
Rule types
| Class | Matches against | Entry point type |
|---|---|---|
Spatie\FlareClient\Sampling\Rules\UrlSamplingRule |
The full URL (including scheme and host) | web |
Spatie\FlareClient\Sampling\Rules\PathSamplingRule |
The URL path | web |
Spatie\FlareClient\Sampling\Rules\RouteSamplingRule |
The matched route URI (e.g. users/{id}) |
web |
Spatie\LaravelFlare\Sampling\RouteNameSamplingRule |
The Laravel route name (e.g. users.show) |
web |
Spatie\LaravelFlare\Sampling\RouteActionSamplingRule |
The controller-action string (e.g. App\Http\Controllers\UsersController@show) |
web |
Spatie\FlareClient\Sampling\Rules\CommandSamplingRule |
The command name (e.g. horizon:work) |
cli |
Spatie\FlareClient\Sampling\Rules\JobSamplingRule |
The job name | queue |
Spatie\LaravelFlare\Sampling\QueueNameSamplingRule |
The queue name the job ran on (e.g. notifications) |
queue |
Spatie\LaravelFlare\Sampling\QueueConnectionSamplingRule |
The queue connection name (e.g. redis, sqs) |
queue |
RouteActionSamplingRule also accepts a [Controller::class, 'method'] tuple as its pattern. It is normalised to the Controller@method string Laravel stores internally, so the following two rules are equivalent:
RouteSamplingRule, RouteNameSamplingRule, RouteActionSamplingRule, and CommandSamplingRule wait for routing middleware to match a route or for the command class to be resolved. Every other rule type runs the moment the request, job, or command starts. Flare aims to make the sampling decision as early as possible. See Deferred sampling for details.
Pattern syntax
Patterns are literal strings with * as a wildcard. Any other character is matched literally. There is no support for ?, character classes, or full regex.
['type' => PathSamplingRule::class, 'pattern' => '/api/*', 'rate' => 0.1], // /api/users, /api/orders/123
['type' => RouteNameSamplingRule::class, 'pattern' => 'admin.*', 'rate' => 1.0], // admin.users.index, admin.posts.show
['type' => JobSamplingRule::class, 'pattern' => 'App\\Jobs\\*Email','rate' => 1.0], // SendWelcomeEmail, SendInvoiceEmail
['type' => QueueNameSamplingRule::class, 'pattern' => 'high-*', 'rate' => 1.0], // high-priority, high-throughput
['type' => CommandSamplingRule::class, 'pattern' => 'horizon:*', 'rate' => 0.0], // horizon:work, horizon:status
Deferred sampling
Some rules cannot run when the trace starts. A web framework only knows the matched route after routing middleware runs, and a queue worker may only know the job class after it pulls the payload off the queue.
When a rule needs information that has not been resolved yet, the sampler defers its decision. The trace is sampled tentatively (so spans are still collected), and the sampler is re-evaluated as soon as the framework resolves the handler. If the sampler decides to drop the trace at re-evaluation time, the spans collected so far are discarded.
This happens automatically. The RoutingRecorder, CommandRecorder, and JobRecorder always boot for entry point detection, regardless of your collects config, so route, command, and job rules can fire at the right moment.
Queued jobs never need to defer. A worker knows the job class, the queue, and the connection before the trace starts, so JobSamplingRule, QueueNameSamplingRule, and QueueConnectionSamplingRule are evaluated straight away.
Custom rules
When the built-in rules aren't expressive enough, write your own. Closures aren't an option in config/flare.php because the cached config has to be serialisable, so the path here is a small class.
Extend SamplingRule, declare which entry-point types the rule applies to, and return a rate from getMatchedRate. Add the DeferredSamplerRule marker if your rule depends on the resolved handler.
namespace App\Sampling;
use Spatie\FlareClient\Enums\EntryPointType;
use Spatie\FlareClient\EntryPoint\EntryPoint;
use Spatie\FlareClient\Sampling\DeferredSamplerRule;
use Spatie\FlareClient\Sampling\SamplingRule;
class TenantRule extends SamplingRule implements DeferredSamplerRule
{
public function __construct(
protected string $pattern,
protected float $rate,
) {
}
public function appliesTo(EntryPointType $entryPointType): bool
{
return $entryPointType === EntryPointType::Web;
}
public function getMatchedRate(EntryPoint $entryPoint): ?float
{
return str_starts_with($entryPoint->handlerName ?? '', $this->pattern)
? $this->rate
: null;
}
}
The constructor takes pattern and rate because DynamicSampler instantiates each rule from its array config with those two positional arguments. If your rule needs more (or different) inputs, drop the array form and either subclass DynamicSampler or wire the sampler up in a service provider where you can pass the rule instance directly.
Reference the class from config/flare.php like any other rule:
['type' => \App\Sampling\TenantRule::class, 'pattern' => 'App\\Http\\Controllers\\Tenant\\', 'rate' => 1.0],
Continuing traces from upstream services
When a request reaches your application with a W3C traceparent header (or a job carries one in its payload), Flare extracts the traceId and parent span id so the trace stays connected to the upstream service. The sampler still decides whether the trace is recorded, and it now receives the upstream's sampling decision as a hint.
Each built-in sampler handles that hint differently:
AlwaysSamplerandNeverSamplerignore it (their name is the contract).RateSamplerhonors the upstream decision when present, otherwise it applies the configured rate. This keeps a continued trace consistent with its parent.DynamicSamplerevaluates its rules first. A matching rule wins, even when the upstream said otherwise. If no rule matches, the upstream decision is honored, falling back tobase_rateonly when there is no parent.
This means a job rule will sample the job's trace regardless of whether the dispatching request was sampled. See the PHP sampling docs for the full picture of how upstream decisions interact with rules.
Sampling queued jobs
When you dispatch a job, Flare writes a traceparent into its payload, including the sampling decision. The worker reads it back and follows it. So every job dispatched during one request or command shares that request's decision.
That is usually fine. It stops being fine when one entry point dispatches a lot of jobs. A scheduled command that fans out into 1,800 jobs decides once, so at a 10% rate you trace all 1,800 or none, roughly one burst every ten hours. The rate is 10% of traces, not of jobs.
Add a job, queue, or connection rule and every job decides for itself:
['type' => QueueNameSamplingRule::class, 'pattern' => 'reports', 'rate' => 0.1],
Now about 180 jobs are traced every hour instead of 1,800 once every ten hours. Same volume, spread evenly, which usually makes it safe to lower the rate further.
Two things to keep in mind:
- A rule wins in both directions. The rule above also thins the jobs of a command that was sampled down to about 10%. Want complete traces for an entry point? Keep its jobs out of your rules.
- A job traced against an unsampled dispatcher becomes its own root trace. There is no link back to the command or request that dispatched it, because that parent trace was never sent. Independent per-job sampling and a guaranteed parent are mutually exclusive.
Custom samplers
You can replace the sampler entirely. Implement the Sampler interface:
use Spatie\FlareClient\EntryPoint\EntryPoint;
use Spatie\FlareClient\Sampling\Sampler;
class AlwaysSampler implements Sampler
{
public function __construct(protected array $config) {}
public function shouldSample(EntryPoint $entryPoint, ?bool $parentSampled = null): bool
{
return true;
}
}
$parentSampled carries the upstream sampling decision when the trace was started from a traceparent, or null when there is no parent.
Register it in the Flare config:
'sampler' => [
'class' => AlwaysSampler::class,
'config' => [],
],
The config array is passed to the sampler's constructor.
If your sampler should support deferred sampling, implement DeferrableSampler instead. It adds isDeferred(), reevaluate(EntryPoint $entryPoint), and reset(). The DynamicSampler is the reference implementation. See the PHP sampling docs for more information.