diff --git a/.env.example b/.env.example index 21b6c017..4300c9d4 100644 --- a/.env.example +++ b/.env.example @@ -193,7 +193,8 @@ GIPHY_API_KEY= # Google Tag Manager (optional - analytics) GTM_ID= -# PostHog (optional - analytics) +# PostHog (optional - analytics; off by default, self-hosted installs can ignore) +POSTHOG_ENABLED=false POSTHOG_API_KEY= POSTHOG_HOST=https://us.i.posthog.com @@ -203,5 +204,6 @@ VITE_REVERB_APP_KEY="${REVERB_APP_KEY}" VITE_REVERB_HOST="${REVERB_HOST}" VITE_REVERB_PORT="${REVERB_PORT}" VITE_REVERB_SCHEME="${REVERB_SCHEME}" +VITE_POSTHOG_ENABLED="${POSTHOG_ENABLED}" VITE_POSTHOG_API_KEY="${POSTHOG_API_KEY}" VITE_POSTHOG_HOST="${POSTHOG_HOST}" diff --git a/app/Actions/User/CreateUser.php b/app/Actions/User/CreateUser.php index 8ae1e0ca..ae81ae07 100644 --- a/app/Actions/User/CreateUser.php +++ b/app/Actions/User/CreateUser.php @@ -7,6 +7,7 @@ use App\Jobs\PostHog\SyncUser; use App\Models\Account; use App\Models\User; +use App\Services\PostHogService; use Illuminate\Support\Facades\DB; class CreateUser @@ -41,7 +42,9 @@ public static function execute(array $data, array $utmParameters = []): User return $user; }); - SyncUser::dispatch((string) $user->id); + if (PostHogService::isEnabled()) { + SyncUser::dispatch((string) $user->id); + } return $user; } diff --git a/app/Jobs/PostHog/SendEvent.php b/app/Jobs/PostHog/SendEvent.php index 159f0941..e21acbd6 100644 --- a/app/Jobs/PostHog/SendEvent.php +++ b/app/Jobs/PostHog/SendEvent.php @@ -4,6 +4,7 @@ namespace App\Jobs\PostHog; +use App\Services\PostHogService; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -18,8 +19,9 @@ * namespace (`SyncUser`, `TrackBilling`) build the payloads and queue this * job to do the network work. * - * No-op when `POSTHOG_API_KEY` is unset so self-hosted installs are - * unaffected. + * No-op when `POSTHOG_ENABLED` is false (or `POSTHOG_API_KEY` is unset), + * via `PostHogService::isEnabled()`, so self-hosted installs never reach + * the SDK. */ class SendEvent implements ShouldQueue { @@ -41,7 +43,7 @@ public function __construct( public function handle(): void { - if (! config('services.posthog.api_key')) { + if (! PostHogService::isEnabled()) { return; } diff --git a/app/Jobs/PostHog/SyncUser.php b/app/Jobs/PostHog/SyncUser.php index 7d3290b8..2a9a8c19 100644 --- a/app/Jobs/PostHog/SyncUser.php +++ b/app/Jobs/PostHog/SyncUser.php @@ -25,8 +25,8 @@ * - group `account` → Account (billing/plan, parent of workspaces) * - group `workspace` → Workspace (collaboration unit, child of account) * - * No-op when POSTHOG_API_KEY is unset (PostHogService short-circuits), so - * self-hosted installs are unaffected. + * No-op when PostHog is disabled (`POSTHOG_ENABLED=false` or no API key) + * via `PostHogService::isEnabled()`, so self-hosted installs are unaffected. */ class SyncUser implements ShouldQueue { @@ -43,6 +43,10 @@ public function __construct(public string $userId) public function handle(PostHogService $postHog): void { + if (! PostHogService::isEnabled()) { + return; + } + $user = User::with([ 'account.plan', 'currentWorkspace' => fn ($query) => $query->withCount('socialAccounts'), diff --git a/app/Jobs/PostHog/TrackBilling.php b/app/Jobs/PostHog/TrackBilling.php index 799539ed..e1f8d033 100644 --- a/app/Jobs/PostHog/TrackBilling.php +++ b/app/Jobs/PostHog/TrackBilling.php @@ -19,7 +19,7 @@ * group properties (plan, has_active_subscription, is_on_trial) reflect * the new Stripe state. Dispatched by `StripeEventListener`. * - * No-op when `POSTHOG_API_KEY` is unset. + * No-op when PostHog is disabled (`POSTHOG_ENABLED=false` or no API key). */ class TrackBilling implements ShouldQueue { @@ -43,6 +43,10 @@ public function __construct( public function handle(PostHogService $postHog): void { + if (! PostHogService::isEnabled()) { + return; + } + $account = Account::with('plan')->find($this->accountId); if (! $account || ! $account->owner_id) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 4f72bd78..dbe4bcea 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -37,6 +37,7 @@ use App\Models\WorkspaceInvite; use App\Models\WorkspaceLabel; use App\Models\WorkspaceSignature; +use App\Services\PostHogService; use App\Services\PostTemplate\Registry as PostTemplateRegistry; use App\Socialite\InstagramProvider; use App\Socialite\LinkedInPageExtendSocialite; @@ -187,13 +188,13 @@ protected function configureMorphMap(): void protected function configurePostHog(): void { - $apiKey = config('services.posthog.api_key'); - - if ($apiKey) { - PostHog::init($apiKey, [ - 'host' => config('services.posthog.host'), - ]); + if (! PostHogService::isEnabled()) { + return; } + + PostHog::init(config('services.posthog.api_key'), [ + 'host' => config('services.posthog.host'), + ]); } protected function configureRateLimiting(): void diff --git a/app/Services/PostHogService.php b/app/Services/PostHogService.php index 7c46c214..cde8fdd4 100644 --- a/app/Services/PostHogService.php +++ b/app/Services/PostHogService.php @@ -10,6 +10,18 @@ class PostHogService { + /** + * Whether PostHog tracking should run at all. Controlled by the explicit + * `POSTHOG_ENABLED` env var so self-hosted installs that inherited an + * example `POSTHOG_API_KEY` from a config snippet do not silently send + * events upstream. The SaaS deployment opts in via `POSTHOG_ENABLED=true`. + */ + public static function isEnabled(): bool + { + return (bool) config('services.posthog.enabled') + && (bool) config('services.posthog.api_key'); + } + /** * Capture an event for the given distinct id. When `$account` is supplied, * the workspace/plan group is auto-attached so the event is filterable in @@ -19,7 +31,7 @@ class PostHogService */ public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void { - if (! $this->shouldSend()) { + if (! self::isEnabled()) { return; } @@ -43,7 +55,7 @@ public function capture(string $distinctId, string $event, array $properties = [ */ public function identify(string $distinctId, array $properties = []): void { - if (! $this->shouldSend()) { + if (! self::isEnabled()) { return; } @@ -58,7 +70,7 @@ public function identify(string $distinctId, array $properties = []): void */ public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void { - if (! $this->shouldSend()) { + if (! self::isEnabled()) { return; } @@ -69,11 +81,6 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper ]); } - private function shouldSend(): bool - { - return (bool) config('services.posthog.api_key'); - } - /** * @param array $payload */ diff --git a/config/services.php b/config/services.php index af7517ce..b6c194cc 100644 --- a/config/services.php +++ b/config/services.php @@ -116,6 +116,7 @@ ], 'posthog' => [ + 'enabled' => (bool) env('POSTHOG_ENABLED', false), 'api_key' => env('POSTHOG_API_KEY'), 'host' => env('POSTHOG_HOST', 'https://us.i.posthog.com'), ], diff --git a/resources/js/posthog.ts b/resources/js/posthog.ts index 11f818a8..f6532b25 100644 --- a/resources/js/posthog.ts +++ b/resources/js/posthog.ts @@ -5,13 +5,16 @@ import type { Auth, Usage } from './types'; const apiKey = import.meta.env.VITE_POSTHOG_API_KEY as string | undefined; const host = import.meta.env.VITE_POSTHOG_HOST as string | undefined; +// Vite stringifies env vars, so explicit equality with 'true' avoids the +// gotcha where 'false' would be truthy. Mirrors backend `POSTHOG_ENABLED`. +const enabled = import.meta.env.VITE_POSTHOG_ENABLED === 'true' && !!apiKey; export const initializePostHog = (): void => { - if (!apiKey) { + if (!enabled) { return; } - posthog.init(apiKey, { + posthog.init(apiKey as string, { api_host: host || 'https://us.i.posthog.com', ui_host: 'https://us.posthog.com', capture_pageview: false, @@ -40,6 +43,10 @@ export const initializePostHog = (): void => { * group `workspace` → collaboration child (carries `account_id`). */ export const syncPostHogContext = (page: Page): void => { + if (!enabled) { + return; + } + const auth = page.props.auth as Auth | undefined; const usage = page.props.usage as Usage | null | undefined; @@ -77,6 +84,9 @@ export const syncPostHogContext = (page: Page): void => { * every navigation (and once at boot for the first page). */ export const capturePageview = (): void => { + if (!enabled) { + return; + } posthog.capture('$pageview', { $current_url: window.location.href }); }; diff --git a/tests/Feature/Actions/User/CreateUserTest.php b/tests/Feature/Actions/User/CreateUserTest.php index eb46dee1..353f47cc 100644 --- a/tests/Feature/Actions/User/CreateUserTest.php +++ b/tests/Feature/Actions/User/CreateUserTest.php @@ -45,7 +45,8 @@ expect(Workspace::count())->toBe(0); }); -test('CreateUser dispatches SyncUser with the new user id', function () { +test('CreateUser dispatches SyncUser with the new user id when PostHog is enabled', function () { + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); Bus::fake([SyncUser::class]); $user = CreateUser::execute([ @@ -59,3 +60,16 @@ fn ($job) => $job->userId === (string) $user->id, ); }); + +test('CreateUser does not dispatch SyncUser when PostHog is disabled', function () { + config(['services.posthog.enabled' => false]); + Bus::fake([SyncUser::class]); + + CreateUser::execute([ + 'name' => 'Jane Doe', + 'email' => 'jane.posthog.disabled@example.com', + 'password' => 'secret123', + ]); + + Bus::assertNotDispatched(SyncUser::class); +}); diff --git a/tests/Feature/Jobs/PostHog/SyncUserTest.php b/tests/Feature/Jobs/PostHog/SyncUserTest.php index 74d9608f..ec4fcd41 100644 --- a/tests/Feature/Jobs/PostHog/SyncUserTest.php +++ b/tests/Feature/Jobs/PostHog/SyncUserTest.php @@ -15,7 +15,7 @@ use Illuminate\Support\Str; beforeEach(function () { - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $this->account = Account::factory()->create([ 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, diff --git a/tests/Feature/Jobs/PostHog/TrackBillingTest.php b/tests/Feature/Jobs/PostHog/TrackBillingTest.php index faee87ce..793043f7 100644 --- a/tests/Feature/Jobs/PostHog/TrackBillingTest.php +++ b/tests/Feature/Jobs/PostHog/TrackBillingTest.php @@ -14,7 +14,7 @@ use Illuminate\Support\Facades\Queue; beforeEach(function () { - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $this->account = Account::factory()->create([ 'plan_id' => Plan::query()->where('slug', 'starter')->first()?->id, diff --git a/tests/Unit/PostHogServiceTest.php b/tests/Unit/PostHogServiceTest.php index 4db925df..babe0f93 100644 --- a/tests/Unit/PostHogServiceTest.php +++ b/tests/Unit/PostHogServiceTest.php @@ -13,7 +13,7 @@ test('capture dispatches job when api key is configured', function () { Queue::fake(); - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $service = new PostHogService; $service->capture('user-123', 'test_event', ['foo' => 'bar']); @@ -37,7 +37,7 @@ test('identify dispatches job when api key is configured', function () { Queue::fake(); - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $service = new PostHogService; $service->identify('user-123', ['$email' => 'test@example.com']); @@ -60,7 +60,7 @@ test('group identify dispatches job when api key is configured', function () { Queue::fake(); - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $service = new PostHogService; $service->groupIdentify('workspace', 'ws-123', ['name' => 'Test Workspace']); @@ -84,7 +84,7 @@ test('capture auto-attaches account groups when account is supplied', function () { Queue::fake(); - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $plan = Plan::query()->where('slug', 'starter')->first(); $account = Account::factory()->create(['plan_id' => $plan?->id]); @@ -105,7 +105,7 @@ test('capture without account does not attach group properties', function () { Queue::fake(); - config(['services.posthog.api_key' => 'phc_test_key']); + config(['services.posthog.enabled' => true, 'services.posthog.api_key' => 'phc_test_key']); $service = new PostHogService; $service->capture('user-123', 'page.viewed', ['url' => '/dashboard']);