Skip to content

Commit 8a7c95c

Browse files
committed
Implement #115: Symfony profiler/toolbar panel for CWA bundle
Add CwaDataCollector and CwaCollectorData to surface per-request bundle activity in the Symfony web profiler. Covers three categories: - JWT/authentication: cookie presence, refresh issued, cookie cleared - Route resolution: resolved path and route IRI from RouteStateProvider - Mercure publications: count and topic list from MercureResourcePublisher Instrument JWTEventListener, JWTClearTokenListener, MercureResourcePublisher, and RouteStateProvider with an optional CwaCollectorData dependency. The DataCollector reads from this shared store at collect() time and renders via @SilverbackApiComponents/Collector/cwa.html.twig. Unit tests added for CwaCollectorData and CwaDataCollector.
1 parent 15f977b commit 8a7c95c

12 files changed

Lines changed: 716 additions & 3 deletions

File tree

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\DataCollector;
13+
14+
use Symfony\Contracts\Service\ResetInterface;
15+
16+
/**
17+
* Shared in-request store that listeners push profiler data into.
18+
* The DataCollector reads from this at collect() time.
19+
*
20+
* @author Daniel West <daniel@silverback.is>
21+
*/
22+
final class CwaCollectorData implements ResetInterface
23+
{
24+
// --- JWT ---
25+
private bool $jwtCookiePresent = false;
26+
private ?string $jwtCookieName = null;
27+
private bool $jwtRefreshIssued = false;
28+
private bool $jwtCookieCleared = false;
29+
30+
// --- Route resolution ---
31+
private ?string $resolvedPath = null;
32+
private ?string $resolvedRouteIri = null;
33+
private bool $pageDataFound = false;
34+
35+
// --- Mercure ---
36+
/** @var list<string> */
37+
private array $publishedTopics = [];
38+
39+
// -----------------------------------------------------------------------
40+
// JWT
41+
// -----------------------------------------------------------------------
42+
43+
public function recordJwtCookiePresent(string $cookieName): void
44+
{
45+
$this->jwtCookiePresent = true;
46+
$this->jwtCookieName = $cookieName;
47+
}
48+
49+
public function recordJwtRefreshIssued(): void
50+
{
51+
$this->jwtRefreshIssued = true;
52+
}
53+
54+
public function recordJwtCookieCleared(): void
55+
{
56+
$this->jwtCookieCleared = true;
57+
}
58+
59+
public function isJwtCookiePresent(): bool
60+
{
61+
return $this->jwtCookiePresent;
62+
}
63+
64+
public function getJwtCookieName(): ?string
65+
{
66+
return $this->jwtCookieName;
67+
}
68+
69+
public function isJwtRefreshIssued(): bool
70+
{
71+
return $this->jwtRefreshIssued;
72+
}
73+
74+
public function isJwtCookieCleared(): bool
75+
{
76+
return $this->jwtCookieCleared;
77+
}
78+
79+
// -----------------------------------------------------------------------
80+
// Route resolution
81+
// -----------------------------------------------------------------------
82+
83+
public function recordPathResolution(string $path, string $routeIri): void
84+
{
85+
$this->resolvedPath = $path;
86+
$this->resolvedRouteIri = $routeIri;
87+
}
88+
89+
public function recordPageDataFound(): void
90+
{
91+
$this->pageDataFound = true;
92+
}
93+
94+
public function getResolvedPath(): ?string
95+
{
96+
return $this->resolvedPath;
97+
}
98+
99+
public function getResolvedRouteIri(): ?string
100+
{
101+
return $this->resolvedRouteIri;
102+
}
103+
104+
public function isPageDataFound(): bool
105+
{
106+
return $this->pageDataFound;
107+
}
108+
109+
// -----------------------------------------------------------------------
110+
// Mercure
111+
// -----------------------------------------------------------------------
112+
113+
public function recordMercurePublication(string $topic): void
114+
{
115+
$this->publishedTopics[] = $topic;
116+
}
117+
118+
/** @return list<string> */
119+
public function getPublishedTopics(): array
120+
{
121+
return $this->publishedTopics;
122+
}
123+
124+
public function getPublishedTopicsCount(): int
125+
{
126+
return \count($this->publishedTopics);
127+
}
128+
129+
// -----------------------------------------------------------------------
130+
// ResetInterface
131+
// -----------------------------------------------------------------------
132+
133+
public function reset(): void
134+
{
135+
$this->jwtCookiePresent = false;
136+
$this->jwtCookieName = null;
137+
$this->jwtRefreshIssued = false;
138+
$this->jwtCookieCleared = false;
139+
140+
$this->resolvedPath = null;
141+
$this->resolvedRouteIri = null;
142+
$this->pageDataFound = false;
143+
144+
$this->publishedTopics = [];
145+
}
146+
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Silverback API Components Bundle Project
5+
*
6+
* (c) Daniel West <daniel@silverback.is>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Silverback\ApiComponentsBundle\DataCollector;
13+
14+
use Symfony\Component\HttpFoundation\Request;
15+
use Symfony\Component\HttpFoundation\Response;
16+
use Symfony\Component\HttpKernel\DataCollector\DataCollector;
17+
18+
/**
19+
* Symfony profiler/toolbar panel for the CWA API Components Bundle.
20+
*
21+
* Shows three categories of per-request information:
22+
* 1. JWT / authentication — cookie presence, refresh, clearance
23+
* 2. Route resolution — path header, resolved route IRI, page data
24+
* 3. Mercure publications — count and topic list
25+
*
26+
* @author Daniel West <daniel@silverback.is>
27+
*/
28+
final class CwaDataCollector extends DataCollector
29+
{
30+
public function __construct(private readonly CwaCollectorData $collectorData)
31+
{
32+
}
33+
34+
public function collect(Request $request, Response $response, ?\Throwable $exception = null): void
35+
{
36+
$this->data = [
37+
// JWT
38+
'jwt_cookie_present' => $this->collectorData->isJwtCookiePresent(),
39+
'jwt_cookie_name' => $this->collectorData->getJwtCookieName(),
40+
'jwt_refresh_issued' => $this->collectorData->isJwtRefreshIssued(),
41+
'jwt_cookie_cleared' => $this->collectorData->isJwtCookieCleared(),
42+
43+
// Route resolution
44+
'resolved_path' => $this->collectorData->getResolvedPath(),
45+
'resolved_route_iri' => $this->collectorData->getResolvedRouteIri(),
46+
'page_data_found' => $this->collectorData->isPageDataFound(),
47+
48+
// Mercure
49+
'published_topics' => $this->collectorData->getPublishedTopics(),
50+
'published_topics_count' => $this->collectorData->getPublishedTopicsCount(),
51+
];
52+
}
53+
54+
public function getName(): string
55+
{
56+
return 'cwa';
57+
}
58+
59+
public function reset(): void
60+
{
61+
$this->data = [];
62+
$this->collectorData->reset();
63+
}
64+
65+
// -----------------------------------------------------------------------
66+
// Accessors used in the Twig template
67+
// -----------------------------------------------------------------------
68+
69+
public function isJwtCookiePresent(): bool
70+
{
71+
return (bool) ($this->data['jwt_cookie_present'] ?? false);
72+
}
73+
74+
public function getJwtCookieName(): ?string
75+
{
76+
return $this->data['jwt_cookie_name'] ?? null;
77+
}
78+
79+
public function isJwtRefreshIssued(): bool
80+
{
81+
return (bool) ($this->data['jwt_refresh_issued'] ?? false);
82+
}
83+
84+
public function isJwtCookieCleared(): bool
85+
{
86+
return (bool) ($this->data['jwt_cookie_cleared'] ?? false);
87+
}
88+
89+
public function getResolvedPath(): ?string
90+
{
91+
return $this->data['resolved_path'] ?? null;
92+
}
93+
94+
public function getResolvedRouteIri(): ?string
95+
{
96+
return $this->data['resolved_route_iri'] ?? null;
97+
}
98+
99+
public function isPageDataFound(): bool
100+
{
101+
return (bool) ($this->data['page_data_found'] ?? false);
102+
}
103+
104+
/** @return list<string> */
105+
public function getPublishedTopics(): array
106+
{
107+
return $this->data['published_topics'] ?? [];
108+
}
109+
110+
public function getPublishedTopicsCount(): int
111+
{
112+
return (int) ($this->data['published_topics_count'] ?? 0);
113+
}
114+
}

src/DataProvider/StateProvider/RouteStateProvider.php

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
use ApiPlatform\Metadata\CollectionOperationInterface;
1717
use ApiPlatform\Metadata\Operation;
1818
use ApiPlatform\State\ProviderInterface;
19+
use Silverback\ApiComponentsBundle\DataCollector\CwaCollectorData;
20+
use Silverback\ApiComponentsBundle\Entity\Core\Route;
1921
use Silverback\ApiComponentsBundle\Repository\Core\RouteRepository;
2022

2123
/**
@@ -26,8 +28,11 @@ class RouteStateProvider implements ProviderInterface
2628
private RouteRepository $routeRepository;
2729
private ProviderInterface $defaultProvider;
2830

29-
public function __construct(RouteRepository $routeRepository, ProviderInterface $defaultProvider)
30-
{
31+
public function __construct(
32+
RouteRepository $routeRepository,
33+
ProviderInterface $defaultProvider,
34+
private readonly ?CwaCollectorData $collectorData = null,
35+
) {
3136
$this->routeRepository = $routeRepository;
3237
$this->defaultProvider = $defaultProvider;
3338
}
@@ -43,6 +48,12 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
4348
return $this->defaultProvider->provide($operation->withProvider(ItemProvider::class), $uriVariables, $context);
4449
}
4550

46-
return $this->routeRepository->findOneByIdOrPath($id);
51+
$route = $this->routeRepository->findOneByIdOrPath($id);
52+
53+
if ($route instanceof Route) {
54+
$this->collectorData?->recordPathResolution($id, $route->getPath());
55+
}
56+
57+
return $route;
4758
}
4859
}

src/EventListener/Jwt/JWTClearTokenListener.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTExpiredEvent;
1515
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTInvalidEvent;
1616
use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Cookie\JWTCookieProvider;
17+
use Silverback\ApiComponentsBundle\DataCollector\CwaCollectorData;
1718
use Silverback\ApiComponentsBundle\Mercure\MercureAuthorization;
1819
use Symfony\Component\HttpFoundation\Response;
1920
use Symfony\Component\HttpKernel\Event\ResponseEvent;
@@ -26,6 +27,7 @@ class JWTClearTokenListener
2627
public function __construct(
2728
private readonly JWTCookieProvider $cookieProvider,
2829
private readonly MercureAuthorization $mercureAuthorization,
30+
private readonly ?CwaCollectorData $collectorData = null,
2931
) {
3032
}
3133

@@ -64,5 +66,6 @@ private function clearJwtCookie(Response $response): void
6466
{
6567
$response->headers->setCookie($this->cookieProvider->createCookie('x.x.x', null, 1));
6668
$response->headers->setCookie($this->mercureAuthorization->getClearAuthorizationCookie());
69+
$this->collectorData?->recordJwtCookieCleared();
6770
}
6871
}

src/EventListener/Jwt/JWTEventListener.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Lexik\Bundle\JWTAuthenticationBundle\Event\AuthenticationSuccessEvent;
1515
use Lexik\Bundle\JWTAuthenticationBundle\Event\JWTCreatedEvent;
1616
use Lexik\Bundle\JWTAuthenticationBundle\Security\Http\Cookie\JWTCookieProvider;
17+
use Silverback\ApiComponentsBundle\DataCollector\CwaCollectorData;
1718
use Silverback\ApiComponentsBundle\Entity\User\AbstractUser;
1819
use Silverback\ApiComponentsBundle\Event\JWTRefreshedEvent;
1920
use Silverback\ApiComponentsBundle\Mercure\MercureAuthorization;
@@ -33,6 +34,7 @@ public function __construct(
3334
private readonly RoleHierarchy $roleHierarchy,
3435
private readonly JWTCookieProvider $cookieProvider,
3536
private readonly MercureAuthorization $mercureAuthorization,
37+
private readonly ?CwaCollectorData $collectorData = null,
3638
) {
3739
}
3840

@@ -65,13 +67,22 @@ public function reset(): void
6567
public function onJWTRefreshed(JWTRefreshedEvent $event): void
6668
{
6769
$this->token = $event->getToken();
70+
$this->collectorData?->recordJwtRefreshIssued();
6871
}
6972

7073
public function onKernelResponse(ResponseEvent $event): void
7174
{
7275
// Consume and clear the token so it is never reused across requests in worker mode
7376
$token = $this->token;
7477
$this->token = null;
78+
79+
// Record whether a JWT cookie was present on the incoming request
80+
$request = $event->getRequest();
81+
$cookieName = $this->cookieProvider->createCookie('x')->getName();
82+
if ($request->cookies->has($cookieName)) {
83+
$this->collectorData?->recordJwtCookiePresent($cookieName);
84+
}
85+
7586
if (!empty($token)) {
7687
$responseHeaders = $event->getResponse()->headers;
7788
$responseHeaders->setCookie($this->cookieProvider->createCookie($token));

src/Mercure/MercureResourcePublisher.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use ApiPlatform\Metadata\UrlGeneratorInterface;
2525
use ApiPlatform\State\SerializerContextBuilderInterface;
2626
use Doctrine\ORM\PersistentCollection;
27+
use Silverback\ApiComponentsBundle\DataCollector\CwaCollectorData;
2728
use Silverback\ApiComponentsBundle\HttpCache\ResourceChangedPropagatorInterface;
2829
use Silverback\ApiComponentsBundle\Utility\ResourceClassInfoTrait;
2930
use Symfony\Component\ExpressionLanguage\ExpressionFunction;
@@ -73,6 +74,7 @@ public function __construct(
7374
private readonly ?GraphQlSubscriptionManagerInterface $graphQlSubscriptionManager = null,
7475
private readonly ?GraphQlMercureSubscriptionIriGeneratorInterface $graphQlMercureSubscriptionIriGenerator = null,
7576
?ExpressionLanguage $expressionLanguage = null,
77+
private readonly ?CwaCollectorData $collectorData = null,
7678
) {
7779
$this->reset();
7880
$this->resourceClassResolver = $resourceClassResolver;
@@ -276,6 +278,11 @@ private function publishUpdate(object $object, array $objectData, string $type):
276278
);
277279

278280
foreach ($updates as $update) {
281+
$topics = $update->getTopics();
282+
foreach ((array) $topics as $topic) {
283+
$this->collectorData?->recordMercurePublication($topic);
284+
}
285+
279286
if ($options['enable_async_update'] && $this->messageBus) {
280287
$this->dispatch($update);
281288
continue;

0 commit comments

Comments
 (0)