From 266933218c5ac67a6c988707e850e821dde528db Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 9 Mar 2026 07:16:00 +0100 Subject: [PATCH 1/4] feat: Add anonymous usage tracking via UDP Tracks installer/updater usage to help understand adoption and diagnose issues. Co-Authored-By: Claude Opus 4.6 (1M context) --- Controller/InstallController.php | 14 +++++- Controller/UpdateController.php | 17 ++++++- Listener/TrackingListener.php | 54 ++++++++++++++++++++++ Services/TrackingService.php | 46 ++++++++++++++++++ Tests/Controller/InstallControllerTest.php | 8 ++-- Tests/Controller/UpdateControllerTest.php | 11 +++++ 6 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 Listener/TrackingListener.php create mode 100644 Services/TrackingService.php diff --git a/Controller/InstallController.php b/Controller/InstallController.php index d3ebd2a..3350603 100644 --- a/Controller/InstallController.php +++ b/Controller/InstallController.php @@ -9,6 +9,7 @@ use Shopware\WebInstaller\Services\RecoveryManager; use Shopware\WebInstaller\Services\ReleaseInfoProvider; use Shopware\WebInstaller\Services\StreamedCommandResponseGenerator; +use Shopware\WebInstaller\Services\TrackingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\Request; @@ -28,6 +29,7 @@ public function __construct( private readonly ReleaseInfoProvider $releaseInfoProvider, private readonly ProjectComposerJsonUpdater $projectComposerJsonUpdater, private readonly LanguageProvider $languageProvider, + private readonly TrackingService $trackingService, ) {} #[Route('/install', name: 'install', defaults: ['step' => 2])] @@ -45,6 +47,12 @@ public function index(): Response public function run(Request $request): StreamedResponse { $shopwareVersion = $request->query->get('shopwareVersion', ''); + $trackingId = $request->getSession()->get('trackingId', ''); + + $this->trackingService->track('install.started', $trackingId, [ + 'shopware_version' => $shopwareVersion, + ]); + $folder = $this->recoveryManager->getProjectDir(); $fs = new Filesystem(); @@ -61,7 +69,11 @@ public function run(Request $request): StreamedResponse $shopwareVersion ); - $finish = function (Process $process) use ($request): void { + $finish = function (Process $process) use ($request, $shopwareVersion, $trackingId): void { + $this->trackingService->track($process->isSuccessful() ? 'install.completed' : 'install.failed', $trackingId, [ + 'shopware_version' => $shopwareVersion, + ]); + $data = [ 'success' => $process->isSuccessful(), ]; diff --git a/Controller/UpdateController.php b/Controller/UpdateController.php index e41432e..9a472a5 100644 --- a/Controller/UpdateController.php +++ b/Controller/UpdateController.php @@ -13,6 +13,7 @@ use Shopware\WebInstaller\Services\RecoveryManager; use Shopware\WebInstaller\Services\ReleaseInfoProvider; use Shopware\WebInstaller\Services\StreamedCommandResponseGenerator; +use Shopware\WebInstaller\Services\TrackingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -31,6 +32,7 @@ public function __construct( private readonly StreamedCommandResponseGenerator $streamedCommandResponseGenerator, private readonly ProjectComposerJsonUpdater $projectComposerJsonUpdater, private readonly LanguageProvider $languageProvider, + private readonly TrackingService $trackingService, ) {} #[Route('/update', name: 'update', defaults: ['step' => 2], methods: ['GET'])] @@ -77,7 +79,15 @@ public function run(Request $request): Response $version = $request->query->get('shopwareVersion', ''); $shopwarePath = $this->recoveryManager->getShopwareLocation(); + $currentVersion = $this->recoveryManager->getCurrentShopwareVersion($shopwarePath); $composerJsonPath = $shopwarePath . '/composer.json'; + $trackingId = $request->getSession()->get('trackingId', ''); + + $this->trackingService->track('update.started', $trackingId, [ + 'shopware_version_from' => $currentVersion, + 'shopware_version_to' => $version, + 'is_flex_project' => $this->recoveryManager->isFlexProject($shopwarePath), + ]); $composerJsonBackup = new FileBackup($composerJsonPath); $composerJsonBackup->backup(); @@ -103,10 +113,15 @@ public function run(Request $request): Response '--no-scripts', '-v', '--with-all-dependencies', // update all packages - ], function (Process $process) use ($composerJsonBackup): void { + ], function (Process $process) use ($composerJsonBackup, $trackingId, $currentVersion, $version): void { $process->isSuccessful() ? $composerJsonBackup->remove() : $composerJsonBackup->restore(); + + $this->trackingService->track($process->isSuccessful() ? 'update.completed' : 'update.failed', $trackingId, [ + 'shopware_version_from' => $currentVersion, + 'shopware_version_to' => $version, + ]); }); } diff --git a/Listener/TrackingListener.php b/Listener/TrackingListener.php new file mode 100644 index 0000000..2ad70c5 --- /dev/null +++ b/Listener/TrackingListener.php @@ -0,0 +1,54 @@ +getRequest(); + + if (!$event->isMainRequest()) { + return; + } + + $session = $request->getSession(); + + if ($session->has('trackingId')) { + return; + } + + $trackingId = bin2hex(random_bytes(16)); + $session->set('trackingId', $trackingId); + + $referer = $request->headers->get('referer', ''); + $source = 'direct'; + + if ($referer !== '') { + $path = parse_url($referer, \PHP_URL_PATH); + + if (\is_string($path) && str_contains($path, '/admin')) { + $source = 'admin'; + } + } + + $this->trackingService->track('visit', $trackingId, [ + 'source' => $source, + 'language' => $request->getLocale(), + 'php_version' => \PHP_VERSION, + 'os' => \PHP_OS_FAMILY, + ]); + } +} diff --git a/Services/TrackingService.php b/Services/TrackingService.php new file mode 100644 index 0000000..8492812 --- /dev/null +++ b/Services/TrackingService.php @@ -0,0 +1,46 @@ +socket = @socket_create(\AF_INET, \SOCK_DGRAM, \SOL_UDP); + $this->domain = $_ENV['SHOPWARE_TRACKING_DOMAIN'] ?? $_SERVER['SHOPWARE_TRACKING_DOMAIN'] ?? self::DEFAULT_TRACKING_DOMAIN; + } + + /** + * @param array $tags + */ + public function track(string $eventName, string $userId, array $tags = []): void + { + if (isset($_ENV['DO_NOT_TRACK']) || isset($_SERVER['DO_NOT_TRACK'])) { + return; + } + + if ($this->socket === false) { + return; + } + + $payload = json_encode([ + 'event' => 'web_installer.' . $eventName, + 'tags' => $tags, + 'user_id' => $userId, + 'timestamp' => (new \DateTime())->format(\DateTimeInterface::ATOM), + ], \JSON_THROW_ON_ERROR); + + @socket_sendto($this->socket, $payload, \strlen($payload), 0, $this->domain, 9000); + } +} diff --git a/Tests/Controller/InstallControllerTest.php b/Tests/Controller/InstallControllerTest.php index 2b089f8..42aaa46 100644 --- a/Tests/Controller/InstallControllerTest.php +++ b/Tests/Controller/InstallControllerTest.php @@ -13,6 +13,7 @@ use Shopware\WebInstaller\Services\RecoveryManager; use Shopware\WebInstaller\Services\ReleaseInfoProvider; use Shopware\WebInstaller\Services\StreamedCommandResponseGenerator; +use Shopware\WebInstaller\Services\TrackingService; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\Request; @@ -39,7 +40,7 @@ public function testStartPage(): void $responseGenerator = $this->createMock(StreamedCommandResponseGenerator::class); $responseGenerator->method('runJSON')->willReturn(new StreamedResponse()); - $controller = new InstallController($recovery, $responseGenerator, $this->createMock(ReleaseInfoProvider::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class)); + $controller = new InstallController($recovery, $responseGenerator, $this->createMock(ReleaseInfoProvider::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), $this->createMock(TrackingService::class)); $controller->setContainer($this->buildContainer()); $response = $controller->index(); @@ -75,7 +76,7 @@ public function testInstall(): void ]) ->willReturn(new StreamedResponse()); - $controller = new InstallController($recovery, $responseGenerator, $this->createMock(ReleaseInfoProvider::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class)); + $controller = new InstallController($recovery, $responseGenerator, $this->createMock(ReleaseInfoProvider::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), $this->createMock(TrackingService::class)); $controller->setContainer($this->buildContainer()); $request = new Request(); @@ -190,7 +191,8 @@ private function createInstallControllerAndRequestAndTemporaryDirectory( $responseGenerator, $this->createMock(ReleaseInfoProvider::class), $this->createMock(ProjectComposerJsonUpdater::class), - $this->createMock(LanguageProvider::class) + $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $installController->setContainer($this->buildContainer()); diff --git a/Tests/Controller/UpdateControllerTest.php b/Tests/Controller/UpdateControllerTest.php index f3f8552..11cf0e1 100644 --- a/Tests/Controller/UpdateControllerTest.php +++ b/Tests/Controller/UpdateControllerTest.php @@ -16,6 +16,7 @@ use Shopware\WebInstaller\Services\RecoveryManager; use Shopware\WebInstaller\Services\ReleaseInfoProvider; use Shopware\WebInstaller\Services\StreamedCommandResponseGenerator; +use Shopware\WebInstaller\Services\TrackingService; use Symfony\Component\DependencyInjection\Container; use Symfony\Component\Filesystem\Filesystem; use Symfony\Component\HttpFoundation\Request; @@ -48,6 +49,7 @@ public function testRedirectWhenNotInstalled(): void $this->createMock(StreamedCommandResponseGenerator::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -74,6 +76,7 @@ public function testRedirectToFinishWhenNoUpdateThere(): void $this->createMock(StreamedCommandResponseGenerator::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -99,6 +102,7 @@ public function testUpdateThereRendersTemplate(): void $this->createMock(StreamedCommandResponseGenerator::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -145,6 +149,7 @@ public function testMigrateFlex(): void $this->createMock(StreamedCommandResponseGenerator::class), $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -182,6 +187,7 @@ public function testPrepare(): void $responseGenerator, $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -220,6 +226,7 @@ public function testFinishUpdate(): void $responseGenerator, $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -277,6 +284,7 @@ public function testUpdateChangesComposerJSON(string $shopwareVersion): void $responseGenerator, $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -334,6 +342,7 @@ public function testUpdateToRC(): void $responseGenerator, $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -407,6 +416,7 @@ public function testUpdateChangesComposerJSONInTestMode(): void $responseGenerator, $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); @@ -457,6 +467,7 @@ public function testResetConfig(): void $responseGenerator, $this->createMock(ProjectComposerJsonUpdater::class), $this->createMock(LanguageProvider::class), + $this->createMock(TrackingService::class), ); $controller->setContainer($this->buildContainer()); From 7d1b8ebaaf6ce3a624c8040c87bf2e16b2647f61 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 9 Mar 2026 09:31:19 +0100 Subject: [PATCH 2/4] refactor: Use Platform::getEnv for env var access in TrackingService --- Services/TrackingService.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/Services/TrackingService.php b/Services/TrackingService.php index 8492812..297e085 100644 --- a/Services/TrackingService.php +++ b/Services/TrackingService.php @@ -4,6 +4,8 @@ namespace Shopware\WebInstaller\Services; +use Composer\Util\Platform; + /** * @internal */ @@ -18,7 +20,9 @@ class TrackingService public function __construct() { $this->socket = @socket_create(\AF_INET, \SOCK_DGRAM, \SOL_UDP); - $this->domain = $_ENV['SHOPWARE_TRACKING_DOMAIN'] ?? $_SERVER['SHOPWARE_TRACKING_DOMAIN'] ?? self::DEFAULT_TRACKING_DOMAIN; + + $domain = Platform::getEnv('SHOPWARE_TRACKING_DOMAIN'); + $this->domain = $domain !== false ? $domain : self::DEFAULT_TRACKING_DOMAIN; } /** @@ -26,7 +30,7 @@ public function __construct() */ public function track(string $eventName, string $userId, array $tags = []): void { - if (isset($_ENV['DO_NOT_TRACK']) || isset($_SERVER['DO_NOT_TRACK'])) { + if (Platform::getEnv('DO_NOT_TRACK') !== false) { return; } From da8989f239c207f526c9a9621504a47475aa865d Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 9 Mar 2026 09:40:11 +0100 Subject: [PATCH 3/4] chore: Add DO_NOT_TRACK environment variable for pull request integration --- .github/workflows/integration.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index d96d940..418b075 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -3,6 +3,9 @@ name: Integration on: pull_request: +env: + DO_NOT_TRACK: 1 + jobs: build: runs-on: ubuntu-24.04 From cb8bcbda8472fec0490dc1a3374b22914a16bd30 Mon Sep 17 00:00:00 2001 From: Soner Sayakci Date: Mon, 9 Mar 2026 10:43:26 +0100 Subject: [PATCH 4/4] refactor: Use TrackingEvent enum for tracking event names --- Controller/InstallController.php | 5 +++-- Controller/UpdateController.php | 5 +++-- Listener/TrackingListener.php | 3 ++- Services/TrackingEvent.php | 19 +++++++++++++++++++ Services/TrackingService.php | 4 ++-- 5 files changed, 29 insertions(+), 7 deletions(-) create mode 100644 Services/TrackingEvent.php diff --git a/Controller/InstallController.php b/Controller/InstallController.php index 3350603..05c3f4e 100644 --- a/Controller/InstallController.php +++ b/Controller/InstallController.php @@ -9,6 +9,7 @@ use Shopware\WebInstaller\Services\RecoveryManager; use Shopware\WebInstaller\Services\ReleaseInfoProvider; use Shopware\WebInstaller\Services\StreamedCommandResponseGenerator; +use Shopware\WebInstaller\Services\TrackingEvent; use Shopware\WebInstaller\Services\TrackingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\Filesystem\Filesystem; @@ -49,7 +50,7 @@ public function run(Request $request): StreamedResponse $shopwareVersion = $request->query->get('shopwareVersion', ''); $trackingId = $request->getSession()->get('trackingId', ''); - $this->trackingService->track('install.started', $trackingId, [ + $this->trackingService->track(TrackingEvent::InstallStarted, $trackingId, [ 'shopware_version' => $shopwareVersion, ]); @@ -70,7 +71,7 @@ public function run(Request $request): StreamedResponse ); $finish = function (Process $process) use ($request, $shopwareVersion, $trackingId): void { - $this->trackingService->track($process->isSuccessful() ? 'install.completed' : 'install.failed', $trackingId, [ + $this->trackingService->track($process->isSuccessful() ? TrackingEvent::InstallCompleted : TrackingEvent::InstallFailed, $trackingId, [ 'shopware_version' => $shopwareVersion, ]); diff --git a/Controller/UpdateController.php b/Controller/UpdateController.php index 9a472a5..fbf1ecc 100644 --- a/Controller/UpdateController.php +++ b/Controller/UpdateController.php @@ -13,6 +13,7 @@ use Shopware\WebInstaller\Services\RecoveryManager; use Shopware\WebInstaller\Services\ReleaseInfoProvider; use Shopware\WebInstaller\Services\StreamedCommandResponseGenerator; +use Shopware\WebInstaller\Services\TrackingEvent; use Shopware\WebInstaller\Services\TrackingService; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\Request; @@ -83,7 +84,7 @@ public function run(Request $request): Response $composerJsonPath = $shopwarePath . '/composer.json'; $trackingId = $request->getSession()->get('trackingId', ''); - $this->trackingService->track('update.started', $trackingId, [ + $this->trackingService->track(TrackingEvent::UpdateStarted, $trackingId, [ 'shopware_version_from' => $currentVersion, 'shopware_version_to' => $version, 'is_flex_project' => $this->recoveryManager->isFlexProject($shopwarePath), @@ -118,7 +119,7 @@ public function run(Request $request): Response ? $composerJsonBackup->remove() : $composerJsonBackup->restore(); - $this->trackingService->track($process->isSuccessful() ? 'update.completed' : 'update.failed', $trackingId, [ + $this->trackingService->track($process->isSuccessful() ? TrackingEvent::UpdateCompleted : TrackingEvent::UpdateFailed, $trackingId, [ 'shopware_version_from' => $currentVersion, 'shopware_version_to' => $version, ]); diff --git a/Listener/TrackingListener.php b/Listener/TrackingListener.php index 2ad70c5..fcd4436 100644 --- a/Listener/TrackingListener.php +++ b/Listener/TrackingListener.php @@ -4,6 +4,7 @@ namespace Shopware\WebInstaller\Listener; +use Shopware\WebInstaller\Services\TrackingEvent; use Shopware\WebInstaller\Services\TrackingService; use Symfony\Component\EventDispatcher\Attribute\AsEventListener; use Symfony\Component\HttpKernel\Event\RequestEvent; @@ -44,7 +45,7 @@ public function __invoke(RequestEvent $event): void } } - $this->trackingService->track('visit', $trackingId, [ + $this->trackingService->track(TrackingEvent::Visit, $trackingId, [ 'source' => $source, 'language' => $request->getLocale(), 'php_version' => \PHP_VERSION, diff --git a/Services/TrackingEvent.php b/Services/TrackingEvent.php new file mode 100644 index 0000000..b9377d8 --- /dev/null +++ b/Services/TrackingEvent.php @@ -0,0 +1,19 @@ + $tags */ - public function track(string $eventName, string $userId, array $tags = []): void + public function track(TrackingEvent $eventName, string $userId, array $tags = []): void { if (Platform::getEnv('DO_NOT_TRACK') !== false) { return; @@ -39,7 +39,7 @@ public function track(string $eventName, string $userId, array $tags = []): void } $payload = json_encode([ - 'event' => 'web_installer.' . $eventName, + 'event' => 'web_installer.' . $eventName->value, 'tags' => $tags, 'user_id' => $userId, 'timestamp' => (new \DateTime())->format(\DateTimeInterface::ATOM),