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).
This commit is contained in:
parent
4241e03753
commit
e3538df2f0
10 changed files with 238 additions and 34 deletions
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
namespace App\Actions\User;
|
||||
|
||||
use App\Jobs\SyncUserToPostHog;
|
||||
use App\Models\Account;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
|
@ -16,7 +17,7 @@ class CreateUser
|
|||
*/
|
||||
public static function execute(array $data, array $utmParameters = []): User
|
||||
{
|
||||
return DB::transaction(function () use ($data, $utmParameters): User {
|
||||
$user = DB::transaction(function () use ($data, $utmParameters): User {
|
||||
$isInviteRegistration = data_get($data, 'is_invite', false);
|
||||
|
||||
$account = Account::create([
|
||||
|
|
@ -39,5 +40,9 @@ public static function execute(array $data, array $utmParameters = []): User
|
|||
|
||||
return $user;
|
||||
});
|
||||
|
||||
SyncUserToPostHog::dispatch((string) $user->id);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
87
app/Jobs/SyncUserToPostHog.php
Normal file
87
app/Jobs/SyncUserToPostHog.php
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Centralised PostHog sync for a single user. Dispatched from places the
|
||||
* frontend can't observe (signup, Stripe webhooks) so the person profile
|
||||
* and account/workspace groups carry up-to-date properties without blocking
|
||||
* the calling request. Inertia navigations refresh group counts on the
|
||||
* client (see `syncPostHogContext` in resources/js/app.ts), so this job is
|
||||
* not needed on every domain trigger.
|
||||
*
|
||||
* Hierarchy mirrors the domain model:
|
||||
* - person → User
|
||||
* - 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.
|
||||
*/
|
||||
class SyncUserToPostHog implements ShouldQueue
|
||||
{
|
||||
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public function __construct(public string $userId)
|
||||
{
|
||||
$this->onQueue('posthog');
|
||||
}
|
||||
|
||||
public function handle(PostHogService $postHog): void
|
||||
{
|
||||
$user = User::with(['account.plan', 'currentWorkspace'])->find($this->userId);
|
||||
|
||||
if (! $user) {
|
||||
return;
|
||||
}
|
||||
|
||||
$postHog->identify((string) $user->id, [
|
||||
'email' => $user->email,
|
||||
'name' => $user->name,
|
||||
'$set_once' => ['signed_up_at' => $user->created_at?->toIso8601String()],
|
||||
]);
|
||||
|
||||
if ($account = $user->account) {
|
||||
$usage = $account->usage();
|
||||
|
||||
$postHog->groupIdentify('account', (string) $account->id, [
|
||||
'name' => $account->name,
|
||||
'plan' => $account->plan?->name,
|
||||
'plan_slug' => $account->plan?->slug,
|
||||
'has_active_subscription' => $account->hasActiveSubscription(),
|
||||
'is_on_trial' => $account->isOnTrial(),
|
||||
'workspaces_count' => $usage['workspaceCount'],
|
||||
'members_count' => $usage['memberCount'],
|
||||
'social_accounts_count' => $usage['socialAccountCount'],
|
||||
'posts_count' => $usage['postCount'],
|
||||
'pending_invites_count' => $usage['pendingInviteCount'],
|
||||
'credits_used' => $usage['creditsUsed'],
|
||||
'created_at' => $account->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($workspace = $user->currentWorkspace) {
|
||||
$postHog->groupIdentify('workspace', (string) $workspace->id, [
|
||||
'name' => $workspace->name,
|
||||
'account_id' => (string) $workspace->account_id,
|
||||
'social_accounts_count' => $workspace->socialAccounts()->count(),
|
||||
'posts_count' => $workspace->posts()->count(),
|
||||
'created_at' => $workspace->created_at?->toIso8601String(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,9 @@
|
|||
namespace App\Listeners;
|
||||
|
||||
use App\Events\SubscriptionCreated;
|
||||
use App\Jobs\SyncUserToPostHog;
|
||||
use App\Models\Account;
|
||||
use App\Services\PostHogService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Laravel\Cashier\Events\WebhookReceived;
|
||||
|
||||
|
|
@ -21,16 +23,16 @@ public function handle(WebhookReceived $event): void
|
|||
return;
|
||||
}
|
||||
|
||||
$workspace = Account::where('stripe_id', $stripeCustomerId)->first();
|
||||
$account = Account::where('stripe_id', $stripeCustomerId)->first();
|
||||
|
||||
if (! $workspace) {
|
||||
if (! $account) {
|
||||
return;
|
||||
}
|
||||
|
||||
match ($type) {
|
||||
'customer.subscription.created' => $this->handleSubscriptionCreated($workspace, $event->payload),
|
||||
'customer.subscription.updated' => $this->handleSubscriptionUpdated($workspace, $event->payload),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($workspace, $event->payload),
|
||||
'customer.subscription.created' => $this->handleSubscriptionCreated($account, $event->payload),
|
||||
'customer.subscription.updated' => $this->handleSubscriptionUpdated($account, $event->payload),
|
||||
'customer.subscription.deleted' => $this->handleSubscriptionDeleted($account, $event->payload),
|
||||
default => null,
|
||||
};
|
||||
} catch (\Exception $e) {
|
||||
|
|
@ -41,18 +43,58 @@ public function handle(WebhookReceived $event): void
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function handleSubscriptionCreated(Account $account, array $payload): void
|
||||
{
|
||||
SubscriptionCreated::dispatch($account);
|
||||
|
||||
$this->trackBilling($account, 'subscription.created', $payload);
|
||||
}
|
||||
|
||||
protected function handleSubscriptionUpdated(Account $workspace, array $payload): void
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function handleSubscriptionUpdated(Account $account, array $payload): void
|
||||
{
|
||||
//
|
||||
$this->trackBilling($account, 'subscription.updated', $payload);
|
||||
}
|
||||
|
||||
protected function handleSubscriptionDeleted(Account $workspace, array $payload): void
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
protected function handleSubscriptionDeleted(Account $account, array $payload): void
|
||||
{
|
||||
//
|
||||
$this->trackBilling($account, 'subscription.cancelled', $payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture the lifecycle event on the account owner's profile and trigger
|
||||
* a fresh `SyncUserToPostHog` so the account group properties (plan,
|
||||
* has_active_subscription, is_on_trial) reflect the new Stripe state.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
private function trackBilling(Account $account, string $event, array $payload): void
|
||||
{
|
||||
if (! $account->owner_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$properties = [
|
||||
'stripe_status' => data_get($payload, 'data.object.status'),
|
||||
'plan' => $account->plan?->name,
|
||||
'plan_slug' => $account->plan?->slug,
|
||||
];
|
||||
|
||||
app(PostHogService::class)->capture(
|
||||
(string) $account->owner_id,
|
||||
$event,
|
||||
$properties,
|
||||
$account,
|
||||
);
|
||||
|
||||
SyncUserToPostHog::dispatch((string) $account->owner_id);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,20 +22,22 @@
|
|||
trait HasUsage
|
||||
{
|
||||
/**
|
||||
* @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, creditsUsed: int}
|
||||
* @return array{workspaceCount: int, socialAccountCount: int, memberCount: int, pendingInviteCount: int, postCount: int, creditsUsed: int}
|
||||
*/
|
||||
public function usage(): array
|
||||
{
|
||||
$workspaces = $this->workspaces()
|
||||
->withCount(['socialAccounts', 'posts'])
|
||||
->get();
|
||||
|
||||
return [
|
||||
'workspaceCount' => $this->workspaces()->count(),
|
||||
'socialAccountCount' => $this->workspaces()
|
||||
->withCount('socialAccounts')
|
||||
->get()
|
||||
->sum('social_accounts_count'),
|
||||
'workspaceCount' => $workspaces->count(),
|
||||
'socialAccountCount' => (int) $workspaces->sum('social_accounts_count'),
|
||||
'memberCount' => $this->users()->count(),
|
||||
'pendingInviteCount' => Invite::where('account_id', $this->id)
|
||||
->whereNull('accepted_at')
|
||||
->count(),
|
||||
'postCount' => (int) $workspaces->sum('posts_count'),
|
||||
'creditsUsed' => AiUsageLog::monthlyCredits($this->id),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,20 +5,37 @@
|
|||
namespace App\Services;
|
||||
|
||||
use App\Jobs\SendPostHogEvent;
|
||||
use App\Models\Account;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class PostHogService
|
||||
{
|
||||
/**
|
||||
* 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
|
||||
* PostHog by `$groups.account` and the `account_id` / `plan` properties.
|
||||
*
|
||||
* @param array<string, mixed> $properties
|
||||
*/
|
||||
public function capture(string $distinctId, string $event, array $properties = []): void
|
||||
public function capture(string $distinctId, string $event, array $properties = [], ?Account $account = null): void
|
||||
{
|
||||
$this->dispatch('capture', [
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'distinctId' => $distinctId,
|
||||
'event' => $event,
|
||||
'properties' => $properties,
|
||||
]);
|
||||
];
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -26,6 +43,10 @@ public function capture(string $distinctId, string $event, array $properties = [
|
|||
*/
|
||||
public function identify(string $distinctId, array $properties = []): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('identify', [
|
||||
'distinctId' => $distinctId,
|
||||
'properties' => $properties,
|
||||
|
|
@ -37,6 +58,10 @@ public function identify(string $distinctId, array $properties = []): void
|
|||
*/
|
||||
public function groupIdentify(string $groupType, string $groupKey, array $properties = []): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->dispatch('groupIdentify', [
|
||||
'groupType' => $groupType,
|
||||
'groupKey' => $groupKey,
|
||||
|
|
@ -49,10 +74,6 @@ public function groupIdentify(string $groupType, string $groupKey, array $proper
|
|||
*/
|
||||
private function dispatch(string $method, array $payload): void
|
||||
{
|
||||
if (! config('services.posthog.api_key')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
SendPostHogEvent::dispatch([
|
||||
['method' => $method, 'payload' => $payload],
|
||||
|
|
|
|||
|
|
@ -214,7 +214,7 @@
|
|||
'defaults' => [
|
||||
'supervisor-1' => [
|
||||
'connection' => 'redis',
|
||||
'queue' => ['default'],
|
||||
'queue' => ['default', 'posthog'],
|
||||
'balance' => 'auto',
|
||||
'autoScalingStrategy' => 'time',
|
||||
'maxProcesses' => 1,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import '../css/app.css';
|
||||
|
||||
import { createInertiaApp, router } from '@inertiajs/vue3';
|
||||
import type { Page } from '@inertiajs/core';
|
||||
import { configureEcho } from '@laravel/echo-vue';
|
||||
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
|
||||
import { i18nVue } from 'laravel-vue-i18n';
|
||||
|
|
@ -12,12 +13,51 @@ import dayjs from './dayjs';
|
|||
import posthog from './posthog';
|
||||
import type { Auth } from './types';
|
||||
|
||||
interface Usage {
|
||||
workspaceCount: number;
|
||||
socialAccountCount: number;
|
||||
memberCount: number;
|
||||
pendingInviteCount: number;
|
||||
postCount: number;
|
||||
creditsUsed: number;
|
||||
}
|
||||
|
||||
configureEcho({
|
||||
broadcaster: 'reverb',
|
||||
});
|
||||
|
||||
const appName = import.meta.env.VITE_APP_NAME || 'TryPost.it';
|
||||
|
||||
// Re-applies the PostHog account/workspace group context with fresh metrics.
|
||||
// Called on every Inertia navigation so `usage` counts (workspaces, social
|
||||
// accounts, posts, members) stay reactive — the backend already ships these
|
||||
// counts on every Inertia request, so this leverages props that are already
|
||||
// loaded without firing extra queries.
|
||||
const syncPostHogContext = (page: Page): void => {
|
||||
const auth = page.props.auth as Auth | undefined;
|
||||
const usage = page.props.usage as Usage | null;
|
||||
|
||||
if (!auth?.user) return;
|
||||
|
||||
if (auth.account) {
|
||||
posthog.group('account', auth.account.id, {
|
||||
name: auth.account.name,
|
||||
workspaces_count: usage?.workspaceCount,
|
||||
social_accounts_count: usage?.socialAccountCount,
|
||||
members_count: usage?.memberCount,
|
||||
posts_count: usage?.postCount,
|
||||
credits_used: usage?.creditsUsed,
|
||||
});
|
||||
}
|
||||
|
||||
if (auth.currentWorkspace) {
|
||||
posthog.group('workspace', auth.currentWorkspace.id, {
|
||||
name: auth.currentWorkspace.name,
|
||||
account_id: auth.account?.id,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
createInertiaApp({
|
||||
title: (title) => (title ? `${title} - ${appName}` : appName),
|
||||
resolve: (name) =>
|
||||
|
|
@ -49,18 +89,21 @@ createInertiaApp({
|
|||
$email: auth.user.email,
|
||||
$name: auth.user.name,
|
||||
});
|
||||
|
||||
if (auth.currentWorkspace) {
|
||||
posthog.group('workspace', auth.currentWorkspace.id, {
|
||||
name: auth.currentWorkspace.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
router.on('navigate', () => {
|
||||
posthog.capture('$pageview', {
|
||||
$current_url: window.location.href,
|
||||
});
|
||||
// Initial group context + initial pageview. The backend mirrors this
|
||||
// hierarchy in app/Jobs/SyncUserToPostHog.php so events emitted from
|
||||
// the server land on the same person + group identifiers.
|
||||
syncPostHogContext(props.initialPage);
|
||||
posthog.capture('$pageview', { $current_url: window.location.href });
|
||||
|
||||
router.on('navigate', (event) => {
|
||||
// Re-sync group context on every navigation: refreshes the count
|
||||
// metrics on the `account` group AND covers workspace switches
|
||||
// (which update auth.currentWorkspace via Inertia's prop refresh
|
||||
// without triggering setup() again).
|
||||
syncPostHogContext(event.detail.page);
|
||||
posthog.capture('$pageview', { $current_url: window.location.href });
|
||||
});
|
||||
|
||||
createApp({ render: () => h(App, props) })
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import {
|
|||
} from '@/components/ui/dropdown-menu';
|
||||
import UserInfo from '@/components/UserInfo.vue';
|
||||
import dayjs from '@/dayjs';
|
||||
import posthog from '@/posthog';
|
||||
import { logout } from '@/routes';
|
||||
import { edit } from '@/routes/app/profile';
|
||||
import type { User } from '@/types';
|
||||
|
|
@ -59,6 +60,7 @@ const switchLanguage = (code: string) => {
|
|||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
posthog.reset();
|
||||
router.flushAll();
|
||||
};
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ interface Usage {
|
|||
socialAccountCount: number;
|
||||
memberCount: number;
|
||||
pendingInviteCount: number;
|
||||
postCount: number;
|
||||
creditsUsed: number;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
'socialAccountCount' => 3,
|
||||
'memberCount' => 3,
|
||||
'pendingInviteCount' => 2,
|
||||
'postCount' => 0,
|
||||
'creditsUsed' => 0,
|
||||
]);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue