trypost/app/Services/PostHogService.php

86 lines
2.2 KiB
PHP
Raw Normal View History

2026-03-31 00:18:07 +00:00
<?php
declare(strict_types=1);
namespace App\Services;
refactor: namespace PostHog jobs + extract billing tracking, add tests Reorganises PostHog plumbing under `App\Jobs\PostHog` and extracts the Stripe billing capture out of `StripeEventListener` into its own job. Adds the missing test coverage that was promised but not delivered in the previous commit. Code changes: - Move `app/Jobs/SendPostHogEvent.php` → `app/Jobs/PostHog/SendEvent.php` (low-level dispatcher). - Move `app/Jobs/SyncUserToPostHog.php` → `app/Jobs/PostHog/SyncUser.php` (high-level user/account/workspace sync). - New `app/Jobs/PostHog/TrackBilling.php` that owns the capture('subscription.*') + SyncUser re-dispatch flow. Receives account id + event name + payload, runs on the `posthog` queue. - `StripeEventListener` slims down to a switch table mapping Stripe event types to PostHog event names and dispatches `TrackBilling`. No more inline tracking logic in the listener. - `resources/js/posthog.ts` now owns `syncPostHogContext(page)` and `capturePageview()`. `resources/js/app.ts` imports them — no behaviour inlined in the bootstrap. - `app/Services/PostHogService.php` and `app/Actions/User/CreateUser.php` updated to the new namespaces. Tests added/updated: - `tests/Feature/Jobs/PostHog/SyncUserTest.php` — identify/group payload shape, account metrics, workspace skip when none, queue assignment, no-op without api key. - `tests/Feature/Jobs/PostHog/TrackBillingTest.php` — capture payload, SyncUser re-dispatch, missing-account/owner handling, api key gate. - `tests/Feature/Jobs/PostHog/SendEventTest.php` — moved from `tests/Feature/SendPostHogEventTest.php` and updated to new namespace. - `tests/Unit/PostHogServiceTest.php` — adds coverage for the account-aware capture (auto-attached `\$groups.account`, `account_id`, `plan`) and the no-account branch. - `tests/Feature/Listeners/StripeEventListenerTest.php` — replaces the old inline-PostHog assertions with `Bus::fake([TrackBilling::class])` and verifies the listener dispatches TrackBilling with the right account id + event name for each subscription type, and skips non-subscription event types. - `tests/Feature/Actions/User/CreateUserTest.php` — verifies signup dispatches `SyncUser` with the new user id. Suite: 1427 passed (+20 net new, including the previous round of metrics-related tests).
2026-05-07 12:41:36 +00:00
use App\Jobs\PostHog\SendEvent;
feat: end-to-end PostHog tracking with reactive group metrics Wire PostHog identify + dual-group context across the stack so events land on the right person and account/workspace groups, with counts kept fresh by Inertia navigations rather than per-domain triggers. - New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised high-level sync — identifies the user and group-identifies their account + current workspace using `$account->usage()` so the metrics reuse the same source of truth Inertia ships in shared props. - `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on signup instead of calling `PostHogService` inline. Keeps the action fast and routes everything through the queue. - `app/Listeners/StripeEventListener.php`: webhook now captures `subscription.created`/`updated`/`cancelled` against the account owner profile (with `account` group auto-attached) and re-dispatches `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial refresh after Stripe state changes. - `app/Services/PostHogService.php`: `capture()` accepts an optional `Account` that auto-attaches `$groups.account`, `account_id`, and `plan` properties. Each public method short-circuits when `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected. - `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage shape (combined `withCount(['socialAccounts','posts'])` query) so posts count is part of the same payload Inertia already ships. - `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the queued PostHog jobs actually drain in production. - `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls it on boot AND on every Inertia navigation, reading the fresh `usage` props. This: - Refreshes account group counts (workspaces, social accounts, posts, members, credits) without per-domain triggers. - Resolves the workspace-switch case where `setup()` does not re-run but `navigate` fires with the new `auth.currentWorkspace`. - Captures the initial `$pageview` so the first page of a session is no longer dropped. - `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on logout so a follow-up login on the same browser doesn't keep events attributed to the previous user. - `resources/js/composables/useFeatureAccess.ts`: TS `Usage` interface gains `postCount`. - `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new usage shape. Full suite: 1407 passed. Hierarchy aligned with the domain model: person = User, group `account` = billing/plan parent, group `workspace` = collaboration child (carries `account_id` for drill-down).
2026-05-07 12:28:02 +00:00
use App\Models\Account;
2026-03-31 00:18:07 +00:00
use Illuminate\Support\Facades\Log;
class PostHogService
{
public static function isEnabled(): bool
{
return (bool) config('services.posthog.enabled')
&& (bool) config('services.posthog.api_key');
}
2026-03-31 00:18:07 +00:00
/**
* @param array<string, mixed> $properties
*/
feat: end-to-end PostHog tracking with reactive group metrics Wire PostHog identify + dual-group context across the stack so events land on the right person and account/workspace groups, with counts kept fresh by Inertia navigations rather than per-domain triggers. - New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised high-level sync — identifies the user and group-identifies their account + current workspace using `$account->usage()` so the metrics reuse the same source of truth Inertia ships in shared props. - `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on signup instead of calling `PostHogService` inline. Keeps the action fast and routes everything through the queue. - `app/Listeners/StripeEventListener.php`: webhook now captures `subscription.created`/`updated`/`cancelled` against the account owner profile (with `account` group auto-attached) and re-dispatches `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial refresh after Stripe state changes. - `app/Services/PostHogService.php`: `capture()` accepts an optional `Account` that auto-attaches `$groups.account`, `account_id`, and `plan` properties. Each public method short-circuits when `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected. - `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage shape (combined `withCount(['socialAccounts','posts'])` query) so posts count is part of the same payload Inertia already ships. - `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the queued PostHog jobs actually drain in production. - `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls it on boot AND on every Inertia navigation, reading the fresh `usage` props. This: - Refreshes account group counts (workspaces, social accounts, posts, members, credits) without per-domain triggers. - Resolves the workspace-switch case where `setup()` does not re-run but `navigate` fires with the new `auth.currentWorkspace`. - Captures the initial `$pageview` so the first page of a session is no longer dropped. - `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on logout so a follow-up login on the same browser doesn't keep events attributed to the previous user. - `resources/js/composables/useFeatureAccess.ts`: TS `Usage` interface gains `postCount`. - `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new usage shape. Full suite: 1407 passed. Hierarchy aligned with the domain model: person = User, group `account` = billing/plan parent, group `workspace` = collaboration child (carries `account_id` for drill-down).
2026-05-07 12:28:02 +00:00
public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void
2026-03-31 00:18:07 +00:00
{
if (! self::isEnabled()) {
feat: end-to-end PostHog tracking with reactive group metrics Wire PostHog identify + dual-group context across the stack so events land on the right person and account/workspace groups, with counts kept fresh by Inertia navigations rather than per-domain triggers. - New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised high-level sync — identifies the user and group-identifies their account + current workspace using `$account->usage()` so the metrics reuse the same source of truth Inertia ships in shared props. - `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on signup instead of calling `PostHogService` inline. Keeps the action fast and routes everything through the queue. - `app/Listeners/StripeEventListener.php`: webhook now captures `subscription.created`/`updated`/`cancelled` against the account owner profile (with `account` group auto-attached) and re-dispatches `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial refresh after Stripe state changes. - `app/Services/PostHogService.php`: `capture()` accepts an optional `Account` that auto-attaches `$groups.account`, `account_id`, and `plan` properties. Each public method short-circuits when `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected. - `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage shape (combined `withCount(['socialAccounts','posts'])` query) so posts count is part of the same payload Inertia already ships. - `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the queued PostHog jobs actually drain in production. - `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls it on boot AND on every Inertia navigation, reading the fresh `usage` props. This: - Refreshes account group counts (workspaces, social accounts, posts, members, credits) without per-domain triggers. - Resolves the workspace-switch case where `setup()` does not re-run but `navigate` fires with the new `auth.currentWorkspace`. - Captures the initial `$pageview` so the first page of a session is no longer dropped. - `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on logout so a follow-up login on the same browser doesn't keep events attributed to the previous user. - `resources/js/composables/useFeatureAccess.ts`: TS `Usage` interface gains `postCount`. - `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new usage shape. Full suite: 1407 passed. Hierarchy aligned with the domain model: person = User, group `account` = billing/plan parent, group `workspace` = collaboration child (carries `account_id` for drill-down).
2026-05-07 12:28:02 +00:00
return;
}
$payload = [
2026-03-31 00:18:07 +00:00
'distinctId' => $distinctId,
'event' => $event,
'properties' => $properties,
feat: end-to-end PostHog tracking with reactive group metrics Wire PostHog identify + dual-group context across the stack so events land on the right person and account/workspace groups, with counts kept fresh by Inertia navigations rather than per-domain triggers. - New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised high-level sync — identifies the user and group-identifies their account + current workspace using `$account->usage()` so the metrics reuse the same source of truth Inertia ships in shared props. - `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on signup instead of calling `PostHogService` inline. Keeps the action fast and routes everything through the queue. - `app/Listeners/StripeEventListener.php`: webhook now captures `subscription.created`/`updated`/`cancelled` against the account owner profile (with `account` group auto-attached) and re-dispatches `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial refresh after Stripe state changes. - `app/Services/PostHogService.php`: `capture()` accepts an optional `Account` that auto-attaches `$groups.account`, `account_id`, and `plan` properties. Each public method short-circuits when `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected. - `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage shape (combined `withCount(['socialAccounts','posts'])` query) so posts count is part of the same payload Inertia already ships. - `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the queued PostHog jobs actually drain in production. - `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls it on boot AND on every Inertia navigation, reading the fresh `usage` props. This: - Refreshes account group counts (workspaces, social accounts, posts, members, credits) without per-domain triggers. - Resolves the workspace-switch case where `setup()` does not re-run but `navigate` fires with the new `auth.currentWorkspace`. - Captures the initial `$pageview` so the first page of a session is no longer dropped. - `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on logout so a follow-up login on the same browser doesn't keep events attributed to the previous user. - `resources/js/composables/useFeatureAccess.ts`: TS `Usage` interface gains `postCount`. - `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new usage shape. Full suite: 1407 passed. Hierarchy aligned with the domain model: person = User, group `account` = billing/plan parent, group `workspace` = collaboration child (carries `account_id` for drill-down).
2026-05-07 12:28:02 +00:00
];
if ($account) {
$payload['properties']['$groups'] = ['account' => (string) $account->id];
$payload['properties']['account_id'] = (string) $account->id;
$payload['properties']['plan'] = $account->plan?->name;
}
$this->dispatch('capture', $payload);
2026-03-31 00:18:07 +00:00
}
/**
* @param array<string, mixed> $properties
*/
public function identify(string $distinctId, array $properties = []): void
{
if (! self::isEnabled()) {
feat: end-to-end PostHog tracking with reactive group metrics Wire PostHog identify + dual-group context across the stack so events land on the right person and account/workspace groups, with counts kept fresh by Inertia navigations rather than per-domain triggers. - New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised high-level sync — identifies the user and group-identifies their account + current workspace using `$account->usage()` so the metrics reuse the same source of truth Inertia ships in shared props. - `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on signup instead of calling `PostHogService` inline. Keeps the action fast and routes everything through the queue. - `app/Listeners/StripeEventListener.php`: webhook now captures `subscription.created`/`updated`/`cancelled` against the account owner profile (with `account` group auto-attached) and re-dispatches `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial refresh after Stripe state changes. - `app/Services/PostHogService.php`: `capture()` accepts an optional `Account` that auto-attaches `$groups.account`, `account_id`, and `plan` properties. Each public method short-circuits when `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected. - `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage shape (combined `withCount(['socialAccounts','posts'])` query) so posts count is part of the same payload Inertia already ships. - `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the queued PostHog jobs actually drain in production. - `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls it on boot AND on every Inertia navigation, reading the fresh `usage` props. This: - Refreshes account group counts (workspaces, social accounts, posts, members, credits) without per-domain triggers. - Resolves the workspace-switch case where `setup()` does not re-run but `navigate` fires with the new `auth.currentWorkspace`. - Captures the initial `$pageview` so the first page of a session is no longer dropped. - `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on logout so a follow-up login on the same browser doesn't keep events attributed to the previous user. - `resources/js/composables/useFeatureAccess.ts`: TS `Usage` interface gains `postCount`. - `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new usage shape. Full suite: 1407 passed. Hierarchy aligned with the domain model: person = User, group `account` = billing/plan parent, group `workspace` = collaboration child (carries `account_id` for drill-down).
2026-05-07 12:28:02 +00:00
return;
}
2026-03-31 00:18:07 +00:00
$this->dispatch('identify', [
'distinctId' => $distinctId,
'properties' => $properties,
]);
}
/**
* @param array<string, mixed> $properties
*/
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
{
if (! self::isEnabled()) {
feat: end-to-end PostHog tracking with reactive group metrics Wire PostHog identify + dual-group context across the stack so events land on the right person and account/workspace groups, with counts kept fresh by Inertia navigations rather than per-domain triggers. - New `app/Jobs/SyncUserToPostHog.php` (queue `posthog`): centralised high-level sync — identifies the user and group-identifies their account + current workspace using `$account->usage()` so the metrics reuse the same source of truth Inertia ships in shared props. - `app/Actions/User/CreateUser.php`: dispatches `SyncUserToPostHog` on signup instead of calling `PostHogService` inline. Keeps the action fast and routes everything through the queue. - `app/Listeners/StripeEventListener.php`: webhook now captures `subscription.created`/`updated`/`cancelled` against the account owner profile (with `account` group auto-attached) and re-dispatches `SyncUserToPostHog` so plan/has_active_subscription/is_on_trial refresh after Stripe state changes. - `app/Services/PostHogService.php`: `capture()` accepts an optional `Account` that auto-attaches `$groups.account`, `account_id`, and `plan` properties. Each public method short-circuits when `POSTHOG_API_KEY` is unset so self-hosted installs are unaffected. - `app/Models/Traits/HasUsage.php`: adds `postCount` to the usage shape (combined `withCount(['socialAccounts','posts'])` query) so posts count is part of the same payload Inertia already ships. - `config/horizon.php`: adds `posthog` to `supervisor-1` queues so the queued PostHog jobs actually drain in production. - `resources/js/app.ts`: extracts `syncPostHogContext(page)` and calls it on boot AND on every Inertia navigation, reading the fresh `usage` props. This: - Refreshes account group counts (workspaces, social accounts, posts, members, credits) without per-domain triggers. - Resolves the workspace-switch case where `setup()` does not re-run but `navigate` fires with the new `auth.currentWorkspace`. - Captures the initial `$pageview` so the first page of a session is no longer dropped. - `resources/js/components/UserMenuContent.vue`: `posthog.reset()` on logout so a follow-up login on the same browser doesn't keep events attributed to the previous user. - `resources/js/composables/useFeatureAccess.ts`: TS `Usage` interface gains `postCount`. - `tests/Feature/Models/HasUsageTraitTest.php`: updated for the new usage shape. Full suite: 1407 passed. Hierarchy aligned with the domain model: person = User, group `account` = billing/plan parent, group `workspace` = collaboration child (carries `account_id` for drill-down).
2026-05-07 12:28:02 +00:00
return;
}
2026-03-31 00:18:07 +00:00
$this->dispatch('groupIdentify', [
'groupType' => $groupType,
'groupKey' => $groupKey,
'properties' => $properties,
]);
}
/**
* @param array<string, mixed> $payload
*/
private function dispatch(string $method, array $payload): void
{
try {
SendEvent::dispatch($method, $payload);
2026-03-31 00:18:07 +00:00
} catch (\Throwable $e) {
Log::warning('PostHogService: failed to dispatch event', ['method' => $method, 'error' => $e->getMessage()]);
}
}
}