Skip to content

Commit 5082325

Browse files
committed
feat: trace event listener execution and add a global tracing kill switch
Event listener tracing: - TracingEventDispatcher extends Laravel's Dispatcher and wraps each non-wildcard, non-queued listener in EventListenerTracer::trace(), recording a `listener` span per invocation (name, completion/failure status). Wildcard and queued listeners are left unwrapped, and listeners run normally with no tracing overhead when there's no active trace. - Bound as the app's 'events' singleton in the service provider. Global enabled switch: - New `laravel-trace.enabled` config key. Tracer checks it in start() before establishing a trace context, so downstream consumers (DatabaseQueryListener, EventListenerTracer) that already guard on `context() === null` naturally no-op everywhere. - The check reads the live ConfigRepository rather than a boolean captured at construction time: Tracer is resolved early via the container's 'events' -> TracingEventDispatcher -> EventListenerTracer dependency chain, well before a test's config()->set() override (or any runtime config change) would take effect against a frozen value. - TraceRequest middleware now guards its span() call, since context() can legitimately be null after a disabled start(). Also folds in and fixes a duplicate test: a stray tests/DatabaseTracingTest.php had been added at the wrong path (should live under tests/Feature/Tracing/, where an equivalently-named file already existed) and imported the concrete Tracing\Tracer class instead of the Contracts\Tracer interface used everywhere else. Merged its "globally disabled" case into the existing tests/Feature/Tracing/DatabaseTracingTest.php instead of keeping two same-named files, and removed the stray one.
1 parent 81331ae commit 5082325

13 files changed

Lines changed: 480 additions & 32 deletions

config/laravel-trace.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
declare(strict_types=1);
44

55
return [
6+
'enabled' => true,
67

78
'database' => [
89
'enabled' => true,

src/Config/TraceConfig.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaravelTrace\LaravelTrace\Config;
6+
7+
final readonly class TraceConfig
8+
{
9+
public function __construct(
10+
private bool $enabled,
11+
private bool $databaseEnabled,
12+
) {}
13+
14+
public function enabled(): bool
15+
{
16+
return $this->enabled;
17+
}
18+
19+
public function databaseEnabled(): bool
20+
{
21+
return $this->databaseEnabled;
22+
}
23+
}

src/Http/Middleware/TraceRequest.php

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -32,29 +32,27 @@ public function handle(
3232
],
3333
);
3434

35-
$span = $this->tracer->span(
36-
name: 'http.request',
37-
type: SpanType::Http,
38-
attributes: [
39-
'http.method' => $request->method(),
40-
'http.path' => $request->path(),
41-
],
42-
);
35+
$scope = $this->tracer->context() !== null
36+
? $this->tracer->span(
37+
name: 'http.request',
38+
type: SpanType::Http,
39+
)
40+
: null;
4341

4442
try {
4543
$response = $next($request);
4644

47-
$span->attributes([
45+
$scope?->attributes([
4846
'http.status_code' => $response->getStatusCode(),
4947
]);
5048

51-
$span->close();
49+
$scope?->close();
5250

5351
$this->tracer->completeTrace($trace);
5452

5553
return $response;
5654
} catch (Throwable $exception) {
57-
$span->fail($exception);
55+
$scope?->fail($exception);
5856

5957
$this->tracer->failTrace(
6058
trace: $trace,

src/LaravelTraceServiceProvider.php

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@
44

55
namespace LaravelTrace\LaravelTrace;
66

7+
use Illuminate\Contracts\Config\Repository as ConfigRepository;
78
use Illuminate\Contracts\Foundation\Application;
9+
use Illuminate\Contracts\Queue\Factory as QueueFactoryContract;
10+
use Illuminate\Contracts\Queue\Queue;
811
use Illuminate\Database\Events\QueryExecuted;
912
use Illuminate\Support\Facades\Event;
1013
use Illuminate\Support\ServiceProvider;
@@ -15,9 +18,11 @@
1518
use LaravelTrace\LaravelTrace\Contracts\Tracer as TracerContract;
1619
use LaravelTrace\LaravelTrace\Contracts\TraceRecorder;
1720
use LaravelTrace\LaravelTrace\Tracing\DatabaseQueryListener;
21+
use LaravelTrace\LaravelTrace\Tracing\EventListenerTracer;
1822
use LaravelTrace\LaravelTrace\Tracing\InMemorySpanRecorder;
1923
use LaravelTrace\LaravelTrace\Tracing\InMemoryTraceRecorder;
2024
use LaravelTrace\LaravelTrace\Tracing\Tracer;
25+
use LaravelTrace\LaravelTrace\Tracing\TracingEventDispatcher;
2126

2227
class LaravelTraceServiceProvider extends ServiceProvider
2328
{
@@ -56,6 +61,36 @@ public function register(): void
5661
),
5762
);
5863

64+
$this->app->singleton(
65+
TracerContract::class,
66+
function (Application $app): Tracer {
67+
return new Tracer(
68+
contextStore: $app->make(TraceContextStore::class),
69+
spanRecorder: $app->make(SpanRecorder::class),
70+
traceRecorder: $app->make(TraceRecorder::class),
71+
config: $app->make(ConfigRepository::class),
72+
);
73+
},
74+
);
75+
76+
$this->app->singleton(
77+
'events',
78+
function (Application $app): TracingEventDispatcher {
79+
return (new TracingEventDispatcher(
80+
listenerTracer: $app->make(EventListenerTracer::class),
81+
container: $app,
82+
))
83+
->setQueueResolver(
84+
fn (): Queue => $app->make(QueueFactoryContract::class)->connection(),
85+
)
86+
->setTransactionManagerResolver(
87+
fn (): mixed => $app->bound('db.transactions')
88+
? $app->make('db.transactions')
89+
: null,
90+
);
91+
},
92+
);
93+
5994
$this->app->singleton(
6095
DatabaseQueryListener::class,
6196
function (Application $app): DatabaseQueryListener {
@@ -68,17 +103,6 @@ function (Application $app): DatabaseQueryListener {
68103
);
69104
},
70105
);
71-
72-
$this->app->singleton(
73-
TracerContract::class,
74-
function (Application $app): Tracer {
75-
return new Tracer(
76-
contextStore: $app->make(TraceContextStore::class),
77-
spanRecorder: $app->make(SpanRecorder::class),
78-
traceRecorder: $app->make(TraceRecorder::class),
79-
);
80-
},
81-
);
82106
}
83107

84108
/**
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaravelTrace\LaravelTrace\Tracing;
6+
7+
use LaravelTrace\LaravelTrace\Contracts\Tracer;
8+
use LaravelTrace\LaravelTrace\Span\SpanType;
9+
use Throwable;
10+
11+
final readonly class EventListenerTracer
12+
{
13+
public function __construct(
14+
private Tracer $tracer,
15+
) {}
16+
17+
/**
18+
* @param callable(): mixed $listener
19+
*
20+
* @throws Throwable
21+
*/
22+
public function trace(
23+
callable $listener,
24+
string $name,
25+
): mixed {
26+
if ($this->tracer->context() === null) {
27+
return $listener();
28+
}
29+
30+
$scope = $this->tracer->span(
31+
name: $name,
32+
type: SpanType::Listener,
33+
);
34+
35+
try {
36+
$result = $listener();
37+
38+
$scope->close();
39+
40+
return $result;
41+
} catch (Throwable $exception) {
42+
$scope->fail($exception);
43+
44+
throw $exception;
45+
}
46+
}
47+
}

src/Tracing/Tracer.php

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace LaravelTrace\LaravelTrace\Tracing;
66

77
use DateTimeImmutable;
8+
use Illuminate\Contracts\Config\Repository as ConfigRepository;
89
use LaravelTrace\LaravelTrace\Context\TraceContext;
910
use LaravelTrace\LaravelTrace\Contracts\SpanRecorder;
1011
use LaravelTrace\LaravelTrace\Contracts\TraceContextStore;
@@ -22,6 +23,7 @@ public function __construct(
2223
private TraceContextStore $contextStore,
2324
private SpanRecorder $spanRecorder,
2425
private TraceRecorder $traceRecorder,
26+
private ?ConfigRepository $config = null,
2527
) {}
2628

2729
/**
@@ -31,15 +33,29 @@ public function start(string $name, array $attributes = []): Trace
3133
{
3234
$trace = Trace::start($name, $attributes);
3335

34-
$this->setContext(
35-
new TraceContext(
36-
traceId: $trace->id,
37-
),
38-
);
36+
if ($this->isEnabled()) {
37+
$this->setContext(
38+
new TraceContext(
39+
traceId: $trace->id,
40+
),
41+
);
42+
}
3943

4044
return $trace;
4145
}
4246

47+
/**
48+
* Read live rather than capturing a boolean at construction time: this
49+
* class is resolved early via the container's 'events' -> event
50+
* dispatcher -> listener tracer dependency chain, before test/runtime
51+
* config overrides to 'laravel-trace.enabled' would have taken effect.
52+
*/
53+
private function isEnabled(): bool
54+
{
55+
return $this->config === null
56+
|| (bool) $this->config->get('laravel-trace.enabled', true);
57+
}
58+
4359
/**
4460
* @param array<string, string|int|float|bool|null> $attributes
4561
*/
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaravelTrace\LaravelTrace\Tracing;
6+
7+
use Closure;
8+
use Illuminate\Contracts\Container\Container;
9+
use Illuminate\Events\Dispatcher;
10+
11+
final class TracingEventDispatcher extends Dispatcher
12+
{
13+
public function __construct(
14+
private readonly EventListenerTracer $listenerTracer,
15+
?Container $container = null,
16+
) {
17+
parent::__construct($container);
18+
}
19+
20+
/**
21+
* @param Closure|string|array{class-string, string} $listener
22+
*/
23+
public function makeListener(
24+
$listener,
25+
$wildcard = false,
26+
): Closure {
27+
$callable = parent::makeListener(
28+
$listener,
29+
$wildcard,
30+
);
31+
32+
if ($wildcard || $this->isQueuedListener($listener)) {
33+
return $callable;
34+
}
35+
36+
return function (mixed $event, array $payload) use (
37+
$callable,
38+
$listener,
39+
) {
40+
return $this->listenerTracer->trace(
41+
listener: fn () => $callable($event, $payload),
42+
name: $this->listenerName($listener),
43+
);
44+
};
45+
}
46+
47+
/**
48+
* @param Closure|string|array{class-string, string} $listener
49+
*/
50+
private function isQueuedListener(mixed $listener): bool
51+
{
52+
$class = match (true) {
53+
is_string($listener) => $this->parseClassCallable($listener)[0],
54+
is_array($listener) => $listener[0],
55+
default => null,
56+
};
57+
58+
if ($class === null) {
59+
return false;
60+
}
61+
62+
return $this->handlerShouldBeQueued($class);
63+
}
64+
65+
private function listenerName(mixed $listener): string
66+
{
67+
if (is_string($listener)) {
68+
return 'listener.'.$listener;
69+
}
70+
71+
if (
72+
is_array($listener)
73+
&& isset($listener[0])
74+
&& is_string($listener[0])
75+
) {
76+
return 'listener.'.$listener[0];
77+
}
78+
79+
return 'listener.closure';
80+
}
81+
}

tests/Feature/Tracing/DatabaseTracingTest.php

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@
1616

1717
$spans = app(InMemorySpanRecorder::class)->all();
1818

19-
expect($spans)
20-
->toHaveCount(1);
19+
$span = collect($spans)->firstWhere('type', SpanType::Database);
2120

22-
$span = $spans[0];
21+
expect($span)
22+
->not->toBeNull();
2323

2424
expect($span->attributes)
2525
->toMatchArray([
@@ -53,6 +53,23 @@
5353

5454
DB::select('select 1');
5555

56-
expect(app(InMemorySpanRecorder::class)->all())
57-
->toBeEmpty();
56+
$spans = app(InMemorySpanRecorder::class)->all();
57+
58+
expect(collect($spans)->firstWhere('type', SpanType::Database))
59+
->toBeNull();
60+
});
61+
62+
it('does not trace database queries when tracing is globally disabled', function (): void {
63+
config()->set('laravel-trace.enabled', false);
64+
65+
$tracer = app(Tracer::class);
66+
67+
$tracer->start('DatabaseTest', []);
68+
69+
DB::select('select 1');
70+
71+
$spans = app(InMemorySpanRecorder::class)->all();
72+
73+
expect(collect($spans)->firstWhere('type', SpanType::Database))
74+
->toBeNull();
5875
});

0 commit comments

Comments
 (0)