feat: explicit POSTHOG_ENABLED gate for self-hosted safety

Self-hosted installs that inherited POSTHOG_API_KEY from an example or
older deploy were still seeing SyncUser/SendEvent jobs run because the
gate was based on the api key alone. Switches the gate to an explicit
'services.posthog.enabled' flag (env: POSTHOG_ENABLED, default false)
and requires both enabled=true AND api_key for tracking to fire.

Backend gating:
- PostHogService::isEnabled() — single static helper used everywhere.
- AppServiceProvider::configurePostHog — skips PostHog::init when off.
- CreateUser::execute — does not enqueue SyncUser when off.
- SyncUser::handle, TrackBilling::handle, SendEvent::handle — early
  return before any DB query so the queue worker does no work.

Frontend gating:
- New VITE_POSTHOG_ENABLED env var mirrored from POSTHOG_ENABLED.
- initializePostHog, syncPostHogContext, capturePageview all gated.

Tests updated to set both flags on the happy path; adds explicit
'CreateUser does not dispatch SyncUser when PostHog is disabled'.

Deploy note: the trypost.it cloud .env must set POSTHOG_ENABLED=true
before this branch is merged or analytics will go dark.
This commit is contained in:
Paulo Castellano 2026-05-07 12:42:35 -03:00
parent 9421717207
commit cd28ac4025
13 changed files with 80 additions and 32 deletions

View file

@ -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}"

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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'),

View file

@ -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) {

View file

@ -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

View file

@ -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<string, mixed> $payload
*/

View file

@ -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'),
],

View file

@ -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 });
};

View file

@ -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);
});

View file

@ -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,

View file

@ -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,

View file

@ -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']);