Skip to content

Commit ab84b98

Browse files
committed
Add telemetry feature to collect anonymous usage data
Signed-off-by: Pushpak Chhajed <[email protected]>
1 parent a1553c6 commit ab84b98

File tree

6 files changed

+308
-2
lines changed

6 files changed

+308
-2
lines changed

config/boost.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,20 @@
2929

3030
'browser_logs_watcher' => env('BOOST_BROWSER_LOGS_WATCHER', true),
3131

32+
/*
33+
|--------------------------------------------------------------------------
34+
| Telemetry
35+
|--------------------------------------------------------------------------
36+
|
37+
| Boost collects anonymous usage telemetry to help improve the tool.
38+
| Only tool names and invocation counts are collected - no file paths,
39+
| code, or identifying information is ever sent to telemetry.
40+
|
41+
*/
42+
43+
'telemetry' => [
44+
'enabled' => env('BOOST_TELEMETRY_ENABLED', true),
45+
'url' => env('BOOST_TELEMETRY_URL', 'https://boost.laravel.com/api/telemetry'),
46+
],
47+
3248
];

rector.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
declare(strict_types=1);
44

55
use Rector\CodingStyle\Rector\Encapsed\EncapsedStringsToSprintfRector;
6-
use Rector\CodingStyle\Rector\FunctionLike\FunctionLikeToFirstClassCallableRector;
76
use Rector\Config\RectorConfig;
87
use Rector\Php81\Rector\Property\ReadOnlyPropertyRector;
98
use Rector\Strict\Rector\Empty_\DisallowedEmptyRuleFixerRector;
@@ -17,7 +16,6 @@
1716
ReadOnlyPropertyRector::class,
1817
EncapsedStringsToSprintfRector::class,
1918
DisallowedEmptyRuleFixerRector::class,
20-
FunctionLikeToFirstClassCallableRector::class,
2119
])
2220
->withPreparedSets(
2321
deadCode: true,

src/BoostServiceProvider.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Illuminate\View\Compilers\BladeCompiler;
1515
use Laravel\Boost\Mcp\Boost;
1616
use Laravel\Boost\Middleware\InjectBoost;
17+
use Laravel\Boost\Telemetry\TelemetryCollector;
1718
use Laravel\Mcp\Facades\Mcp;
1819
use Laravel\Roster\Roster;
1920

@@ -58,6 +59,8 @@ public function register(): void
5859

5960
return $roster;
6061
});
62+
63+
$this->app->singleton(TelemetryCollector::class);
6164
}
6265

6366
public function boot(Router $router): void

src/Mcp/ToolExecutor.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
use Dotenv\Dotenv;
88
use Illuminate\Support\Env;
9+
use Laravel\Boost\Telemetry\TelemetryCollector;
910
use Laravel\Mcp\Response;
1011
use Symfony\Component\Process\Exception\ProcessFailedException;
1112
use Symfony\Component\Process\Exception\ProcessTimedOutException;
@@ -19,6 +20,10 @@ public function execute(string $toolClass, array $arguments = []): Response
1920
return Response::error("Tool not registered or not allowed: {$toolClass}");
2021
}
2122

23+
if (config('boost.telemetry.enabled')) {
24+
app(TelemetryCollector::class)->record($toolClass);
25+
}
26+
2227
return $this->executeInSubprocess($toolClass, $arguments);
2328
}
2429

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Laravel\Boost\Telemetry;
6+
7+
use const PHP_OS_FAMILY;
8+
use const PHP_VERSION;
9+
10+
use Composer\InstalledVersions;
11+
use Illuminate\Support\Facades\Http;
12+
use Throwable;
13+
14+
class TelemetryCollector
15+
{
16+
protected const MAX_TOOLS_PER_FLUSH = 20;
17+
18+
public array $toolCounts = [];
19+
20+
protected bool $shutdownRegistered = false;
21+
22+
public function record(string $toolName): void
23+
{
24+
if (! config('boost.telemetry.enabled')) {
25+
return;
26+
}
27+
28+
$totalCount = array_sum($this->toolCounts);
29+
if ($totalCount >= self::MAX_TOOLS_PER_FLUSH) {
30+
$this->flush();
31+
}
32+
33+
if (! $this->shutdownRegistered) {
34+
if (extension_loaded('pcntl')) {
35+
pcntl_async_signals(true);
36+
pcntl_signal(SIGINT, $this->flush(...));
37+
pcntl_signal(SIGTERM, $this->flush(...));
38+
}
39+
40+
register_shutdown_function([$this, 'flush']);
41+
42+
app()->terminating($this->flush(...));
43+
44+
$this->shutdownRegistered = true;
45+
}
46+
47+
$this->toolCounts[$toolName] = ($this->toolCounts[$toolName] ?? 0) + 1;
48+
}
49+
50+
public function flush(): void
51+
{
52+
if ($this->toolCounts === [] || ! config('boost.telemetry.enabled', true)) {
53+
return;
54+
}
55+
56+
try {
57+
Http::timeout(5)
58+
->withHeaders(['User-Agent' => 'Laravel Boost Telemetry'])
59+
->post(config('boost.telemetry.url'), ['data' => $this->buildPayload()]);
60+
} catch (Throwable) {
61+
//
62+
} finally {
63+
$this->toolCounts = [];
64+
}
65+
66+
}
67+
68+
protected function buildPayload(): string
69+
{
70+
$version = InstalledVersions::getVersion('laravel/boost');
71+
72+
return base64_encode(json_encode([
73+
'session_id' => hash('sha256', base_path()),
74+
'boost_version' => $version,
75+
'php_version' => PHP_VERSION,
76+
'os' => PHP_OS_FAMILY,
77+
'laravel_version' => app()->version(),
78+
'tools' => $this->toolCounts,
79+
'timestamp' => now()->toIso8601String(),
80+
]));
81+
}
82+
}
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
<?php
2+
3+
use Composer\InstalledVersions;
4+
use Illuminate\Support\Facades\Http;
5+
use Laravel\Boost\Mcp\Tools\DatabaseQuery;
6+
use Laravel\Boost\Mcp\Tools\Tinker;
7+
use Laravel\Boost\Telemetry\TelemetryCollector;
8+
9+
beforeEach(function (): void {
10+
$this->collector = app(TelemetryCollector::class);
11+
$this->collector->toolCounts = [];
12+
});
13+
14+
it('records tool invocations', function (): void {
15+
config(['boost.telemetry.enabled' => true]);
16+
17+
$this->collector->record(DatabaseQuery::class);
18+
$this->collector->record(DatabaseQuery::class);
19+
$this->collector->record(Tinker::class);
20+
21+
expect($this->collector->toolCounts)->toBe([
22+
DatabaseQuery::class => 2,
23+
Tinker::class => 1,
24+
]);
25+
});
26+
27+
it('does not record when disabled via config', function (): void {
28+
config(['boost.telemetry.enabled' => false]);
29+
30+
$this->collector->record(DatabaseQuery::class);
31+
32+
expect($this->collector->toolCounts)->toBe([]);
33+
});
34+
35+
it('auto-flushes when reaching MAX_TOOLS_PER_FLUSH', function (): void {
36+
config(['boost.telemetry.enabled' => true]);
37+
38+
Http::fake([
39+
'*' => Http::response(['status' => 'ok'], 200),
40+
]);
41+
42+
for ($i = 0; $i < 20; $i++) {
43+
$this->collector->record(Tinker::class);
44+
}
45+
46+
expect($this->collector->toolCounts)->toHaveCount(1)
47+
->and($this->collector->toolCounts[Tinker::class])->toBe(20);
48+
49+
$this->collector->record(Tinker::class);
50+
51+
expect(Http::recorded())->toHaveCount(1)
52+
->and($this->collector->toolCounts)->toHaveCount(1)
53+
->and($this->collector->toolCounts[Tinker::class])->toBe(1);
54+
});
55+
56+
it('does not auto-flush below MAX_TOOLS_PER_FLUSH', function (): void {
57+
config(['boost.telemetry.enabled' => true]);
58+
59+
Http::fake([
60+
'*' => Http::response(['status' => 'ok'], 200),
61+
]);
62+
63+
for ($i = 0; $i < 19; $i++) {
64+
$this->collector->record(Tinker::class);
65+
}
66+
67+
expect(Http::recorded())->toHaveCount(0)
68+
->and($this->collector->toolCounts)->toHaveCount(1)
69+
->and($this->collector->toolCounts[Tinker::class])->toBe(19);
70+
});
71+
72+
it('flush sends data and clears counts', function (): void {
73+
config(['boost.telemetry.enabled' => true]);
74+
75+
Http::fake([
76+
'*' => Http::response(['status' => 'ok'], 200),
77+
]);
78+
79+
$this->collector->record(Tinker::class);
80+
$this->collector->flush();
81+
82+
expect(Http::recorded())->toHaveCount(1);
83+
84+
$request = Http::recorded()[0][0];
85+
$payload = json_decode(base64_decode((string) $request['data'], true), true);
86+
87+
expect($request->url())->toBe(config('boost.telemetry.url'))
88+
->and($payload['tools'][Tinker::class])->toBe(1)
89+
->and($this->collector->toolCounts)->toBe([]);
90+
});
91+
92+
it('flush does nothing when toolCounts is empty', function (): void {
93+
config(['boost.telemetry.enabled' => true]);
94+
95+
Http::fake([
96+
'*' => Http::response(['status' => 'ok'], 200),
97+
]);
98+
99+
$this->collector->flush();
100+
101+
expect(Http::recorded())->toHaveCount(0);
102+
});
103+
104+
it('flush does nothing when telemetry is disabled', function (): void {
105+
config(['boost.telemetry.enabled' => false]);
106+
107+
Http::fake([
108+
'*' => Http::response(['status' => 'ok'], 200),
109+
]);
110+
111+
$this->collector->toolCounts = ['SomeTool' => 1];
112+
$this->collector->flush();
113+
114+
expect(Http::recorded())->toHaveCount(0);
115+
});
116+
117+
it('flush fails silently on network error', function (): void {
118+
config(['boost.telemetry.enabled' => true]);
119+
120+
Http::fake([
121+
'*' => Http::response(null, 500),
122+
]);
123+
124+
$this->collector->record(Tinker::class);
125+
$this->collector->flush();
126+
127+
expect($this->collector->toolCounts)->toBe([]);
128+
});
129+
130+
it('flush fails silently on connection timeout', function (): void {
131+
config(['boost.telemetry.enabled' => true]);
132+
133+
Http::fake(function (): void {
134+
throw new \Exception('Connection timeout');
135+
});
136+
137+
$this->collector->record(Tinker::class);
138+
$this->collector->flush();
139+
140+
expect($this->collector->toolCounts)->toBe([]);
141+
});
142+
143+
it('includes buildPayload as the correct structure', function (): void {
144+
config(['boost.telemetry.enabled' => true]);
145+
146+
Http::fake([
147+
'*' => Http::response(['status' => 'ok'], 200),
148+
]);
149+
150+
$this->collector->record(Tinker::class);
151+
$this->collector->flush();
152+
153+
expect(Http::recorded())->toHaveCount(1);
154+
155+
$request = Http::recorded()[0][0];
156+
$payload = json_decode(base64_decode((string) $request['data'], true), true);
157+
158+
expect($payload)->toHaveKeys(['session_id', 'boost_version', 'php_version', 'os', 'laravel_version', 'tools', 'timestamp'])
159+
->and($payload['php_version'])->toBe(PHP_VERSION)
160+
->and($payload['os'])->toBe(PHP_OS_FAMILY)
161+
->and($payload['tools'])->toBeArray();
162+
});
163+
164+
it('sends session_id as a consistent hash of base_path', function (): void {
165+
config(['boost.telemetry.enabled' => true]);
166+
167+
Http::fake([
168+
'*' => Http::response(['status' => 'ok'], 200),
169+
]);
170+
171+
$expectedSessionId = hash('sha256', base_path());
172+
173+
$this->collector->record(Tinker::class);
174+
$this->collector->flush();
175+
176+
expect(Http::recorded())->toHaveCount(1);
177+
178+
$request = Http::recorded()[0][0];
179+
$payload = json_decode(base64_decode((string) $request['data'], true), true);
180+
181+
expect($payload['session_id'])->toBe($expectedSessionId);
182+
});
183+
184+
it('uses boost_version as InstalledVersions', function (): void {
185+
config(['boost.telemetry.enabled' => true]);
186+
187+
Http::fake([
188+
'*' => Http::response(['status' => 'ok'], 200),
189+
]);
190+
191+
$expectedVersion = InstalledVersions::getVersion('laravel/boost');
192+
193+
$this->collector->record(Tinker::class);
194+
$this->collector->flush();
195+
196+
expect(Http::recorded())->toHaveCount(1);
197+
198+
$request = Http::recorded()[0][0];
199+
$payload = json_decode(base64_decode((string) $request['data'], true), true);
200+
201+
expect($payload['boost_version'])->toBe($expectedVersion);
202+
});

0 commit comments

Comments
 (0)