diff --git a/config/boost.php b/config/boost.php index d99e4c65..61abb349 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', false), + 'url' => env('BOOST_TELEMETRY_URL', 'https://boost.laravel.com/api/telemetry'), + ], + ]; diff --git a/src/BoostServiceProvider.php b/src/BoostServiceProvider.php index 0cbba269..9b256012 100644 --- a/src/BoostServiceProvider.php +++ b/src/BoostServiceProvider.php @@ -16,6 +16,7 @@ use Laravel\Boost\Install\GuidelineConfig; use Laravel\Boost\Mcp\Boost; use Laravel\Boost\Middleware\InjectBoost; +use Laravel\Boost\Telemetry\TelemetryCollector; use Laravel\Mcp\Facades\Mcp; use Laravel\Roster\Roster; @@ -67,6 +68,8 @@ public function register(): void $app->make(Roster::class), $app->make(GuidelineConfig::class) )); + + $this->app->singleton(TelemetryCollector::class); } public function boot(Router $router): void diff --git a/src/Console/InstallCommand.php b/src/Console/InstallCommand.php index 99c3c4da..eabace0b 100644 --- a/src/Console/InstallCommand.php +++ b/src/Console/InstallCommand.php @@ -8,6 +8,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Arr; use Illuminate\Support\Collection; +use Illuminate\Support\Facades\File; use Illuminate\Support\Str; use InvalidArgumentException; use Laravel\Boost\Contracts\Agent; @@ -101,6 +102,7 @@ public function handle( $this->discoverEnvironment(); $this->collectInstallationPreferences(); $this->performInstallation(); + $this->enableTelemetry(); $this->outro(); return self::SUCCESS; @@ -193,6 +195,40 @@ protected function discoverTools(): array return $tools; } + protected function enableTelemetry(): void + { + $envPath = base_path('.env'); + + if (! File::exists($envPath)) { + return; + } + + try { + $envContent = File::get($envPath); + $envKey = 'BOOST_TELEMETRY_ENABLED'; + + if (preg_match("/^{$envKey}=.*/m", $envContent)) { + return; + } + + $enableTelemetry = confirm( + label: 'Enable anonymous telemetry to help improve Laravel Boost?', + hint: 'You can change this anytime by updating `BOOST_TELEMETRY_ENABLED` in your .env file' + ); + + $envValue = $enableTelemetry ? 'true' : 'false'; + $status = $enableTelemetry ? 'enabled' : 'disabled'; + + $updatedContent = rtrim($envContent).PHP_EOL.PHP_EOL."{$envKey}={$envValue}".PHP_EOL; + + File::put($envPath, $updatedContent); + + $this->newLine(); + } catch (Exception $exception) { + $this->warn("Unable to configure telemetry: {$exception->getMessage()}"); + } + } + protected function outro(): void { $label = 'https://boost.laravel.com/installed'; diff --git a/src/Mcp/Boost.php b/src/Mcp/Boost.php index c1d2ab8a..c6eb680e 100644 --- a/src/Mcp/Boost.php +++ b/src/Mcp/Boost.php @@ -6,6 +6,8 @@ use InvalidArgumentException; use Laravel\Boost\Mcp\Methods\CallToolWithExecutor; +use Laravel\Boost\Mcp\Methods\GetPromptWithTelemetry; +use Laravel\Boost\Mcp\Methods\ReadResourceWithTelemetry; use Laravel\Boost\Mcp\Prompts\PackageGuidelinePrompt; use Laravel\Boost\Mcp\Resources\PackageGuidelineResource; use Laravel\Boost\Mcp\Tools\ApplicationInfo; @@ -81,6 +83,8 @@ protected function boot(): void // Override the tools/call method to use our ToolExecutor $this->methods['tools/call'] = CallToolWithExecutor::class; + $this->methods['resources/read'] = ReadResourceWithTelemetry::class; + $this->methods['prompts/get'] = GetPromptWithTelemetry::class; } /** diff --git a/src/Mcp/Methods/GetPromptWithTelemetry.php b/src/Mcp/Methods/GetPromptWithTelemetry.php new file mode 100644 index 00000000..35f91444 --- /dev/null +++ b/src/Mcp/Methods/GetPromptWithTelemetry.php @@ -0,0 +1,31 @@ +|JsonRpcResponse + */ + public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse + { + $promptName = (string) $request->get('name'); + + try { + return parent::handle($request, $context); + } finally { + if (config('boost.telemetry.enabled')) { + app(TelemetryCollector::class)->recordPrompt($promptName); + } + } + } +} diff --git a/src/Mcp/Methods/ReadResourceWithTelemetry.php b/src/Mcp/Methods/ReadResourceWithTelemetry.php new file mode 100644 index 00000000..38ed83ba --- /dev/null +++ b/src/Mcp/Methods/ReadResourceWithTelemetry.php @@ -0,0 +1,32 @@ +|JsonRpcResponse + */ + public function handle(JsonRpcRequest $request, ServerContext $context): Generator|JsonRpcResponse + { + $uri = (string) $request->get('uri'); + + try { + return parent::handle($request, $context); + } finally { + if (config('boost.telemetry.enabled')) { + Container::getInstance()->make(TelemetryCollector::class)->recordResource($uri); + } + } + } +} diff --git a/src/Mcp/ToolExecutor.php b/src/Mcp/ToolExecutor.php index 06076484..719a7640 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)->recordTool($toolClass, $wordCount); + } } } diff --git a/src/Telemetry/TelemetryCollector.php b/src/Telemetry/TelemetryCollector.php new file mode 100644 index 00000000..62a86268 --- /dev/null +++ b/src/Telemetry/TelemetryCollector.php @@ -0,0 +1,144 @@ +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 recordTool(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]; + } + + public function recordResource(string $resourceUri): void + { + if (! $this->enabled) { + return; + } + + $this->resourceData[$resourceUri] = ($this->resourceData[$resourceUri] ?? 0) + 1; + } + + public function recordPrompt(string $promptName): void + { + if (! $this->enabled) { + return; + } + + $this->promptData[$promptName] = ($this->promptData[$promptName] ?? 0) + 1; + } + + protected function calculateTokens(int $wordCount): int + { + return (int) round($wordCount * 1.3); + } + + public function flush(): void + { + if (($this->toolData === [] && $this->resourceData === [] && $this->promptData === []) || ! $this->enabled) { + return; + } + + try { + $this->client() + ->timeout(5) + ->post($this->url, ['data' => $this->buildPayload()]); + } catch (Throwable) { + // + } finally { + $this->toolData = []; + $this->resourceData = []; + $this->promptData = []; + $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(), + 'resources' => $this->resourceData, + 'prompts' => $this->promptData, + '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..987658d2 --- /dev/null +++ b/tests/Unit/Telemetry/TelemetryCollectorTest.php @@ -0,0 +1,214 @@ +forgetInstance(TelemetryCollector::class); + + config(['boost.telemetry.enabled' => true]); + Http::fake(['*' => Http::response(['status' => 'ok'])]); + $this->collector = app(TelemetryCollector::class); + $this->collector->toolData = []; + $this->collector->resourceData = []; + $this->collector->promptData = []; +}); + +it('records tool invocations', function (): void { + $this->collector->recordTool(DatabaseQuery::class, 100); + $this->collector->recordTool(DatabaseQuery::class, 200); + $this->collector->recordTool(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('records resource invocations', function (): void { + $this->collector->recordResource('file://instructions/application-info.md'); + $this->collector->recordResource('file://instructions/application-info.md'); + $this->collector->recordResource('file://instructions/laravel/framework.md'); + + expect($this->collector->resourceData)->toBe([ + 'file://instructions/application-info.md' => 2, + 'file://instructions/laravel/framework.md' => 1, + ]); +}); + +it('records prompt invocations', function (): void { + $this->collector->recordPrompt('laravel/framework'); + $this->collector->recordPrompt('laravel/framework'); + $this->collector->recordPrompt('laravel/pint'); + + expect($this->collector->promptData)->toBe([ + 'laravel/framework' => 2, + 'laravel/pint' => 1, + ]); +}); + +it('does not record when disabled via config', function (): void { + config(['boost.telemetry.enabled' => false]); + + $collector = new TelemetryCollector; + $collector->recordTool(DatabaseQuery::class, 100); + + expect($collector->toolData)->toBe([]); +}); + +it('flush sends data and clears counts', function (): void { + $this->collector->recordTool(Tinker::class, 150); + $this->collector->recordResource('file://instructions/application-info.md'); + $this->collector->recordPrompt('laravel/framework'); + $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($payload['resources']['file://instructions/application-info.md'])->toBe(1) + ->and($payload['prompts']['laravel/framework'])->toBe(1) + ->and($this->collector->toolData)->toBe([]) + ->and($this->collector->resourceData)->toBe([]) + ->and($this->collector->promptData)->toBe([]); +}); + +it('flush does nothing when toolData is empty', function (): void { + $this->collector->flush(); + + expect(Http::recorded())->toHaveCount(0); +}); + +it('flush does nothing when telemetry is disabled', function (): void { + config(['boost.telemetry.enabled' => false]); + + $collector = new TelemetryCollector; + $collector->toolData = ['SomeTool' => [['tokens' => 100]]]; + $collector->flush(); + + expect(Http::recorded())->toHaveCount(0); +}); + +it('flush fails silently on network error', function (): void { + Http::fake(['*' => Http::response(null, 500)]); + + $this->collector->recordTool(Tinker::class, 100); + $this->collector->flush(); + + expect($this->collector->toolData)->toBe([]); +}); + +it('flush fails silently on connection timeout', function (): void { + Http::fake(function (): void { + throw new \Exception('Connection timeout'); + }); + + $this->collector->recordTool(Tinker::class, 100); + $this->collector->flush(); + + expect($this->collector->toolData)->toBe([]); +}); + +it('includes buildPayload as the correct structure', function (): void { + $this->collector->recordTool(Tinker::class, 100); + $this->collector->recordResource('file://instructions/application-info.md'); + $this->collector->recordPrompt('laravel/framework'); + $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', + 'resources', + 'prompts', + '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($payload['resources']['file://instructions/application-info.md'])->toBe(1) + ->and($payload['prompts']['laravel/framework'])->toBe(1) + ->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 { + $expectedSessionId = hash('sha256', base_path()); + + $this->collector->recordTool(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 { + $this->collector->recordTool(Tinker::class, 128); + $this->collector->recordTool(Tinker::class, 256); + $this->collector->recordResource('file://instructions/laravel/framework.md'); + $this->collector->recordPrompt('laravel/framework'); + + $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($payload['resources']['file://instructions/laravel/framework.md'])->toBe(1) + ->and($payload['prompts']['laravel/framework'])->toBe(1) + ->and($this->collector->toolData)->toBe([]) + ->and($this->collector->resourceData)->toBe([]) + ->and($this->collector->promptData)->toBe([]); +}); + +it('uses boost_version as InstalledVersions', function (): void { + $expectedVersion = InstalledVersions::getVersion('laravel/boost'); + + $this->collector->recordTool(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); +}); + +it('resets resource and prompts data after a flush', function (): void { + $this->collector->recordResource('file://instructions/application-info.md'); + $this->collector->recordPrompt('laravel/framework'); + $this->collector->flush(); + + expect($this->collector->resourceData)->toBe([]) + ->and($this->collector->promptData)->toBe([]); +});