diff --git a/config/boost.php b/config/boost.php index d99e4c65..2d18d098 100644 --- a/config/boost.php +++ b/config/boost.php @@ -29,4 +29,20 @@ 'browser_logs_watcher' => env('BOOST_BROWSER_LOGS_WATCHER', true), + /* + |-------------------------------------------------------------------------- + | Telemetry + |-------------------------------------------------------------------------- + | + | Boost collects anonymous usage telemetry to help improve the tool. + | Only tool names and invocation counts are collected - no file paths, + | code, or identifying information is ever sent to telemetry. + | + */ + + 'telemetry' => [ + 'enabled' => env('BOOST_TELEMETRY_ENABLED', true), + 'url' => env('BOOST_TELEMETRY_URL', 'https://boost.laravel.com/api/telemetry'), + ], + ]; diff --git a/rector.php b/rector.php index b9c1690f..5f495026 100644 --- a/rector.php +++ b/rector.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector; -use Rector\CodingStyle\Rector\FunctionLike\FunctionLikeToFirstClassCallableRector; use Rector\Config\RectorConfig; use Rector\Php81\Rector\Property\ReadOnlyPropertyRector; use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector; @@ -17,7 +16,6 @@ ReadOnlyPropertyRector::class, EncapsedStringsToSprintfRector::class, DisallowedEmptyRuleFixerRector::class, - FunctionLikeToFirstClassCallableRector::class, ]) ->withPreparedSets( deadCode: true, diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index 32df690d..30a47983 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -14,6 +14,7 @@ use Illuminate\View\Compilers\BladeCompiler; use Laravel\Boost\Mcp\Boost; use Laravel\Boost\Middleware\InjectBoost; +use Laravel\Boost\Telemetry\TelemetryCollector; use Laravel\Mcp\Facades\Mcp; use Laravel\Roster\Roster; @@ -58,6 +59,8 @@ public function register(): void return $roster; }); + + $this->app->singleton(TelemetryCollector::class); } public function boot(Router $router): void diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 06076484..d4f767f5 100644 --- a/src/Mcp/ToolExecutor.php +++ b/src/Mcp/ToolExecutor.php @@ -6,6 +6,7 @@ use Dotenv\Dotenv; use Illuminate\Support\Env; +use Laravel\Boost\Telemetry\TelemetryCollector; use Laravel\Mcp\Response; use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessTimedOutException; @@ -41,11 +42,14 @@ protected function executeInSubprocess(string $toolClass, array $arguments): Res timeout: $this->getTimeout($arguments) ); + $wordCount = 0; + try { $process->mustRun(); $output = $process->getOutput(); $decoded = json_decode($output, true); + $wordCount = str_word_count($output); if (json_last_error() !== JSON_ERROR_NONE) { return Response::error('Invalid JSON output from tool process: '.json_last_error_msg()); @@ -61,6 +65,10 @@ protected function executeInSubprocess(string $toolClass, array $arguments): Res $errorOutput = $process->getErrorOutput().$process->getOutput(); return Response::error("Process tool execution failed: {$errorOutput}"); + } finally { + if (config('boost.telemetry.enabled')) { + app(TelemetryCollector::class)->record($toolClass, $wordCount); + } } } diff --git a/src/Telemetry/TelemetryCollector.php b/src/Telemetry/TelemetryCollector.php new file mode 100644 index 00000000..cf191fdb --- /dev/null +++ b/src/Telemetry/TelemetryCollector.php @@ -0,0 +1,118 @@ +sessionStartTime = microtime(true); + $this->enabled = config('boost.telemetry.enabled', false); + if ($this->enabled) { + $this->url = config('boost.telemetry.url', 'https://boost.laravel.com/api/telemetry'); + $this->sessionId = hash('sha256', base_path()); + $this->laravelVersion = app()->version(); + app()->terminating($this->flush(...)); + + if (extension_loaded('pcntl')) { + pcntl_async_signals(true); + pcntl_signal(SIGINT, $this->flush(...)); + pcntl_signal(SIGTERM, $this->flush(...)); + } + } + } + + public function __destruct() + { + $this->flush(); + } + + public function record(string $toolName, int $wordCount): void + { + if (! $this->enabled) { + return; + } + + if (! isset($this->toolData[$toolName])) { + $this->toolData[$toolName] = []; + } + + $tokens = $this->calculateTokens($wordCount); + $this->toolData[$toolName][] = ['tokens' => $tokens]; + } + + protected function calculateTokens(int $wordCount): int + { + return (int) round($wordCount * 1.3); + } + + public function flush(): void + { + if ($this->toolData === [] || ! $this->enabled) { + return; + } + + try { + $this->client() + ->timeout(5) + ->post($this->url, ['data' => $this->buildPayload()]); + } catch (Throwable) { + // + } finally { + $this->toolData = []; + $this->sessionStartTime = microtime(true); + } + } + + protected function buildPayload(): string + { + $version = InstalledVersions::getVersion('laravel/boost'); + $sessionEndTime = microtime(true); + + return base64_encode(json_encode([ + 'session_id' => $this->sessionId, + 'boost_version' => $version, + 'php_version' => PHP_VERSION, + 'os' => PHP_OS_FAMILY, + 'laravel_version' => $this->laravelVersion, + 'session_start' => date('c', (int) $this->sessionStartTime), + 'session_end' => date('c', (int) $sessionEndTime), + 'tools' => $this->formatToolsData(), + 'timestamp' => date('c'), + ])); + } + + protected function formatToolsData(): array + { + $formatted = []; + + foreach ($this->toolData as $toolName => $invocations) { + $formatted[$toolName] = []; + foreach ($invocations as $index => $invocation) { + $formatted[$toolName][(string) ($index + 1)] = $invocation; + } + } + + return $formatted; + } +} diff --git a/tests/Unit/Telemetry/TelemetryCollectorTest.php b/tests/Unit/Telemetry/TelemetryCollectorTest.php new file mode 100644 index 00000000..bee6857e --- /dev/null +++ b/tests/Unit/Telemetry/TelemetryCollectorTest.php @@ -0,0 +1,207 @@ +collector = app(TelemetryCollector::class); + $this->collector->toolData = []; +}); + +it('records tool invocations', function (): void { + config(['boost.telemetry.enabled' => true]); + + $this->collector->record(DatabaseQuery::class, 100); + $this->collector->record(DatabaseQuery::class, 200); + $this->collector->record(Tinker::class, 150); + + expect($this->collector->toolData)->toBe([ + DatabaseQuery::class => [ + ['tokens' => 130], // 100 * 1.3 + ['tokens' => 260], // 200 * 1.3 + ], + Tinker::class => [ + ['tokens' => 195], // 150 * 1.3 + ], + ]); +}); + +it('does not record when disabled via config', function (): void { + config(['boost.telemetry.enabled' => false]); + + $collector = new TelemetryCollector; + $collector->record(DatabaseQuery::class, 100); + + expect($collector->toolData)->toBe([]); +}); + +it('flush sends data and clears counts', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $this->collector->record(Tinker::class, 150); + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(1); + + $request = Http::recorded()[0][0]; + $payload = json_decode(base64_decode((string) $request['data'], true), true); + + expect($request->url())->toBe(config('boost.telemetry.url')) + ->and($payload['tools'][Tinker::class]['1'])->toBe(['tokens' => 195]) // 150 * 1.3 + ->and($this->collector->toolData)->toBe([]); +}); + +it('flush does nothing when toolData is empty', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(0); +}); + +it('flush does nothing when telemetry is disabled', function (): void { + config(['boost.telemetry.enabled' => false]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $collector = new TelemetryCollector; + $collector->toolData = ['SomeTool' => [['tokens' => 100]]]; + $collector->flush(); + + expect(Http::recorded())->toHaveCount(0); +}); + +it('flush fails silently on network error', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(null, 500), + ]); + + $this->collector->record(Tinker::class, 100); + $this->collector->flush(); + + expect($this->collector->toolData)->toBe([]); +}); + +it('flush fails silently on connection timeout', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake(function (): void { + throw new \Exception('Connection timeout'); + }); + + $this->collector->record(Tinker::class, 100); + $this->collector->flush(); + + expect($this->collector->toolData)->toBe([]); +}); + +it('includes buildPayload as the correct structure', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $this->collector->record(Tinker::class, 100); + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(1); + + $request = Http::recorded()[0][0]; + $payload = json_decode(base64_decode((string) $request['data'], true), true); + + expect($payload)->toHaveKeys([ + 'session_id', + 'boost_version', + 'php_version', + 'os', + 'laravel_version', + 'session_start', + 'session_end', + 'tools', + 'timestamp', + ]) + ->and($payload['php_version'])->toBe(PHP_VERSION) + ->and($payload['os'])->toBe(PHP_OS_FAMILY) + ->and($payload['tools'])->toBeArray() + ->and($payload['tools'][Tinker::class]['1']['tokens'])->toBe(130) // 100 * 1.3 + ->and(strtotime((string) $payload['session_start']))->not->toBeFalse() + ->and(strtotime((string) $payload['session_end']))->not->toBeFalse(); +}); + +it('sends session_id as a consistent hash of base_path', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $expectedSessionId = hash('sha256', base_path()); + + $this->collector->record(Tinker::class, 100); + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(1); + + $request = Http::recorded()[0][0]; + $payload = json_decode(base64_decode((string) $request['data'], true), true); + + expect($payload['session_id'])->toBe($expectedSessionId); +}); + +it('records tool response sizes and resets after flush', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $this->collector->record(Tinker::class, 128); + $this->collector->record(Tinker::class, 256); + + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(1); + + $request = Http::recorded()[0][0]; + $payload = json_decode(base64_decode((string) $request['data'], true), true); + + expect($payload['tools'][Tinker::class]['1']['tokens'])->toBe(166) // 128 * 1.3 + ->and($payload['tools'][Tinker::class]['2']['tokens'])->toBe(333) // 256 * 1.3 + ->and($this->collector->toolData)->toBe([]); +}); + +it('uses boost_version as InstalledVersions', function (): void { + config(['boost.telemetry.enabled' => true]); + + Http::fake([ + '*' => Http::response(['status' => 'ok'], 200), + ]); + + $expectedVersion = InstalledVersions::getVersion('laravel/boost'); + + $this->collector->record(Tinker::class, 100); + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(1); + + $request = Http::recorded()[0][0]; + $payload = json_decode(base64_decode((string) $request['data'], true), true); + + expect($payload['boost_version'])->toBe($expectedVersion); +});