Skip to content

Commit b896edb

Browse files
committed
test: cover nested listener span hierarchy and context restoration
An outer listener that dispatches a second event (handled by an inner listener) must produce Trace -> outer listener span -> inner listener span, and each EventListenerTracer scope must restore its parent's context as it unwinds. - success path: inner span is parented to the outer span, the live context each listener observes is its own span, the outer listener's context is restored once the inner listener returns, and the bare trace context is restored once the outer listener returns. - failure path: an inner listener throwing fails the inner span, restores the outer listener's context before the exception unwinds further, fails the outer span as the exception propagates through it, and leaves no span context on the tracer afterwards. Adds the OrderShipped fixture event for the nested dispatch. No production changes: the existing implementation already behaves correctly.
1 parent 53bf6b8 commit b896edb

2 files changed

Lines changed: 149 additions & 0 deletions

File tree

tests/Feature/Tracing/EventTracingTest.php

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
use Illuminate\Support\Facades\Event;
77
use Illuminate\Support\Facades\Queue;
88
use LaravelTrace\LaravelTrace\Contracts\Tracer;
9+
use LaravelTrace\LaravelTrace\Span\Span;
910
use LaravelTrace\LaravelTrace\Span\SpanStatus;
1011
use LaravelTrace\LaravelTrace\Span\SpanType;
1112
use LaravelTrace\LaravelTrace\Tests\Fixtures\Events\OrderCreated;
13+
use LaravelTrace\LaravelTrace\Tests\Fixtures\Events\OrderShipped;
1214
use LaravelTrace\LaravelTrace\Tests\Fixtures\Listeners\QueuedOrderNotifier;
1315
use LaravelTrace\LaravelTrace\Tests\Fixtures\Listeners\SendOrderConfirmation;
1416
use LaravelTrace\LaravelTrace\Tracing\InMemorySpanRecorder;
@@ -127,3 +129,138 @@ function (OrderCreated $event) use (&$executed): void {
127129
expect(app(InMemorySpanRecorder::class)->all())
128130
->toBeEmpty();
129131
});
132+
133+
it('nests listener spans and restores each parent context as listeners finish', function (): void {
134+
$tracer = app(Tracer::class);
135+
136+
$contextInsideOuter = null;
137+
$contextInsideInner = null;
138+
$contextInOuterAfterInner = null;
139+
140+
Event::listen(
141+
OrderShipped::class,
142+
function () use ($tracer, &$contextInsideInner): void {
143+
$contextInsideInner = $tracer->context();
144+
},
145+
);
146+
147+
Event::listen(
148+
OrderCreated::class,
149+
function () use ($tracer, &$contextInsideOuter, &$contextInOuterAfterInner): void {
150+
$contextInsideOuter = $tracer->context();
151+
152+
Event::dispatch(new OrderShipped(orderId: 1));
153+
154+
$contextInOuterAfterInner = $tracer->context();
155+
},
156+
);
157+
158+
$trace = $tracer->start('test');
159+
160+
Event::dispatch(new OrderCreated(orderId: 1));
161+
162+
$spans = app(InMemorySpanRecorder::class)->all();
163+
164+
$outerSpan = collect($spans)
165+
->first(fn (Span $span): bool => $span->parentId === null);
166+
167+
$innerSpan = collect($spans)
168+
->first(fn (Span $span): bool => $span->parentId !== null);
169+
170+
// Trace -> outer listener span -> inner listener span
171+
expect($spans)
172+
->toHaveCount(2)
173+
->and($outerSpan->type)
174+
->toBe(SpanType::Listener)
175+
->and($innerSpan->type)
176+
->toBe(SpanType::Listener)
177+
->and($outerSpan->traceId->value)
178+
->toBe($trace->id->value)
179+
->and($innerSpan->traceId->value)
180+
->toBe($trace->id->value)
181+
->and($innerSpan->parentId->value)
182+
->toBe($outerSpan->id->value)
183+
->and($outerSpan->status)
184+
->toBe(SpanStatus::Completed)
185+
->and($innerSpan->status)
186+
->toBe(SpanStatus::Completed);
187+
188+
// The live context each listener saw while running.
189+
expect($contextInsideOuter?->spanId?->value)
190+
->toBe($outerSpan->id->value)
191+
->and($contextInsideInner?->spanId?->value)
192+
->toBe($innerSpan->id->value);
193+
194+
// When the inner listener finished, the outer listener's context was restored.
195+
expect($contextInOuterAfterInner?->spanId?->value)
196+
->toBe($outerSpan->id->value);
197+
198+
// When the outer listener finished, the original trace context was restored.
199+
expect($tracer->context()?->traceId->value)
200+
->toBe($trace->id->value)
201+
->and($tracer->context()?->spanId)
202+
->toBeNull();
203+
});
204+
205+
it('fails the inner listener span, propagates, and restores the outer listener context', function (): void {
206+
$tracer = app(Tracer::class);
207+
208+
$contextInOuterWhenInnerThrew = null;
209+
210+
Event::listen(
211+
OrderShipped::class,
212+
function (): void {
213+
throw new RuntimeException('Inner listener failed.');
214+
},
215+
);
216+
217+
Event::listen(
218+
OrderCreated::class,
219+
function () use ($tracer, &$contextInOuterWhenInnerThrew): void {
220+
try {
221+
Event::dispatch(new OrderShipped(orderId: 1));
222+
} catch (RuntimeException $exception) {
223+
$contextInOuterWhenInnerThrew = $tracer->context();
224+
225+
throw $exception;
226+
}
227+
},
228+
);
229+
230+
$trace = $tracer->start('test');
231+
232+
expect(fn () => Event::dispatch(new OrderCreated(orderId: 1)))
233+
->toThrow(RuntimeException::class, 'Inner listener failed.');
234+
235+
$spans = app(InMemorySpanRecorder::class)->all();
236+
237+
$outerSpan = collect($spans)
238+
->first(fn (Span $span): bool => $span->parentId === null);
239+
240+
$innerSpan = collect($spans)
241+
->first(fn (Span $span): bool => $span->parentId !== null);
242+
243+
expect($spans)
244+
->toHaveCount(2)
245+
->and($innerSpan->parentId->value)
246+
->toBe($outerSpan->id->value)
247+
->and($innerSpan->status)
248+
->toBe(SpanStatus::Failed)
249+
->and($innerSpan->error?->type)
250+
->toBe(RuntimeException::class)
251+
->and($outerSpan->status)
252+
->toBe(SpanStatus::Failed)
253+
->and($outerSpan->error?->type)
254+
->toBe(RuntimeException::class);
255+
256+
// The inner span's failure restored the outer listener's context before the
257+
// exception unwound any further.
258+
expect($contextInOuterWhenInnerThrew?->spanId?->value)
259+
->toBe($outerSpan->id->value);
260+
261+
// The outer span's failure restored the original trace context: nothing leaked.
262+
expect($tracer->context()?->traceId->value)
263+
->toBe($trace->id->value)
264+
->and($tracer->context()?->spanId)
265+
->toBeNull();
266+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace LaravelTrace\LaravelTrace\Tests\Fixtures\Events;
6+
7+
final readonly class OrderShipped
8+
{
9+
public function __construct(
10+
public int $orderId,
11+
) {}
12+
}

0 commit comments

Comments
 (0)