feat: end-to-end PostHog tracking with reactive group metrics - #20
Merged
Conversation
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).
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).
The previous version of `handleSubscriptionUpdated` and `handleSubscriptionDeleted` were no-ops, so a plan swap or cancellation on Stripe never reflected in `accounts.plan_id`. Mirrors sendkit's listener structure and adds full coverage. Listener: - `handleSubscriptionCreated` / `handleSubscriptionUpdated` capture the previous plan name, resolve the new plan from the subscription items' price ids, and update `account.plan_id` if it changed. Pennant feature caches are forgotten automatically by `Account::booted()`. - `handleSubscriptionDeleted` clears `account.plan_id` so the UI and authorization checks reflect "no plan" instead of keeping the previous one attached. Idempotent. - Helpers extracted: `resolvePlanFromSubscriptionItems(payload, account)` (pure resolver, logs a warning when no plan matches) and `trackPlanChange(account, event, previousPlan, payload)` (delegates to the queued `TrackBilling` job). - Restored the `match($type) => handler...` shape with explicit protected handler methods for each subscription type. TrackBilling: - Adds optional `?string $previousPlan` constructor param. Forwarded as `previous_plan` event property so PostHog funnels can tell upgrade from downgrade from cancellation. Tests: - New: subscription updated/created sync `plan_id` from price ids (monthly + yearly), idempotent when price already matches, ignored when price ids are unknown, deletion clears plan_id, deletion idempotent when already null. Tests override the seeded plans' Stripe price ids inline so they don't depend on `.env.testing` having `STRIPE_*_MONTHLY/YEARLY` set. - New: previousPlan is forwarded to `TrackBilling` for updated and deleted, and is null for first-activation. - TrackBilling test: covers the previous_plan property in capture payload (both supplied and default-null cases). Suite: 1438 passed (+11). Plus a tiny cleanup: replaced the `'PostHog\\SendEvent: ...'` namespace-look-alike in the SendEvent log warning with the cleaner `'PostHog SendEvent: ...'`.
Posts have no plan-quota gating, so a brief staleness on the count is acceptable. Avoids the heavy aggregate query on every authenticated request. Empty-account case skips cache writes entirely.
- New BillingEvent enum replaces 'subscription.{created,updated,cancelled}'
strings across StripeEventListener, TrackBilling and tests.
- SendEvent now takes (method, payload) directly instead of an array of
single-call shapes — overhead with no batching benefit.
- PostHogService consolidates the 3 api-key short-circuits into shouldSend().
- SyncUser eager-loads currentWorkspace.withCount('socialAccounts') and
drops the redundant posts_count from the workspace group identify.
- Frontend Usage interface centralised in resources/js/types — was
duplicated in posthog.ts and useFeatureAccess.ts.
- posthog.init moved out of module-import side-effect into
initializePostHog() called explicitly from app.ts.
- SyncUserTest cleans up the convoluted assertion that merged
$job->calls with Queue::pushed().
- Drop tests/Feature/StripeEventListenerTest.php (orphan, fully covered
by tests/Feature/Listeners/StripeEventListenerTest.php).
- Revert .github/FUNDING.yml to match origin/main.
Replaces the implicit Account::booted() observer with an explicit Account::forgetPlanFeatureCache() method called from each plan_id mutation site (StripeEventListener x3, BillingController x2). Self-hosted installs naturally never reach any of these callsites — Stripe webhooks do not fire and the billing controllers redirect to /calendar before any plan mutation happens — so the Pennant flush is now guaranteed to be a cloud-only operation. Adds integration coverage proving the full chain webhook -> plan_id update -> Pennant flush -> next Feature::value resolves against the new plan limit.
Two issues from review:
1. BillingController::checkout was setting plan_id immediately after
creating the Stripe Checkout session, before the user actually paid.
If the user abandoned checkout, the account ended up with a plan it
never paid for. Plan activation is now driven exclusively by the
customer.subscription.created webhook, which fires only after a
successful payment.
2. The 'if (\$account->wasChanged('plan_id')) { ... }' guards around
forgetPlanFeatureCache() were tautological — Eloquent's update()
already short-circuits when nothing changed, and Pennant forget()
is idempotent, so an extra cache clear when the plan didn't move
is harmless. Removing the guards keeps the listener and swap path
readable.
UpgradeDialog was reading 'currentPlan.stripe_yearly_price_id' from
'auth.plan', but auth.plan is built by AuthPlanResource which only
exposes {id, slug, name, interval} — the price ids are absent. The
'isOnYearly' computation always compared against undefined and returned
false, so users on a yearly subscription saw the dialog as if they were
on monthly: no current-plan badge match, the toggle was forced visible,
and the wrong price/billing label was shown.
Switches both UpgradeDialog and the Billing settings page to read
auth.plan.interval directly (the authoritative field already present
in shared props), drops the now-unused 'currentPriceId' from the Inertia
middleware and the Auth type. Yearly is now the default selection on
dialog open and the toggle stays visible regardless of current cadence.
Drops the abort_if guard that blocked switching from a yearly billing cadence to monthly. The product decision was reversed — users should be free to move in either direction without going through support. Removes the corresponding 'swap blocks yearly to monthly downgrade' test.
Passes the account's @username as confirmText to ConfirmDeleteModal so the user has to type it to enable the destructive action. Mirrors the pattern already used elsewhere in the app for high-impact deletes.
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.
Three fixes from a fresh code review: 1. SyncUser identify used 'email' / 'name' instead of the PostHog special person properties '\$email' / '\$name'. The frontend already used the correct keys; the backend identify (sole source for users who sign up but never log in) would have populated only custom properties, leaving the built-in person profile email/name blank in the PostHog UI. 2. handleSubscriptionDeleted now short-circuits when plan_id is already null. Stripe re-delivers webhooks on transient failures, and the prior version would dispatch a duplicate 'subscription.cancelled' event and re-flush the (already empty) Pennant cache on each retry. 3. useTracking composable called posthog.capture directly, bypassing the new enabled gate. While posthog-js queues calls before init (so no events leaked over the network in self-hosted mode), the buffer grew unbounded and would fire all queued events in bulk if init was ever called. Replaced with a gated captureEvent helper exported from posthog.ts. Plus: drop the now-trivial 'updating non-plan fields does not flush the pennant cache' test (no observer to test against), refresh stale doc comments referencing the removed SyncUserToPostHog filename, and add a Bus::assertNotDispatched check to the deletion-idempotency test.
Two follow-ups from the final pre-merge review: 1. SyncUser and TrackBilling shipped \$account->plan?->slug, which is a PlanSlug backed enum. json_encode renders it correctly on the wire, but the queue payload carries an enum instance — anyone introspecting the job (Bus::fake, future workers) and string-comparing the slug would fail silently. Cast to ->slug->value at the call sites. 2. PostHogServiceTest only covered the api_key=null negative case. The primary scenario the gate exists to defend (self-hosted with a stale POSTHOG_API_KEY but POSTHOG_ENABLED=false) was not asserted. Added four tests covering capture/identify/groupIdentify with enabled=false and an api key present, plus a direct truth-table check on isEnabled() requiring both flags.
The TrackBilling job was being enqueued on every Stripe webhook event even with POSTHOG_ENABLED=false. handle() short-circuited via the isEnabled() check, so nothing reached PostHog, but the job still consumed queue worker cycles (20-300ms each) on accounts where Stripe fires its frequent customer.subscription.updated events. Adds the missing isEnabled() check at the dispatch site in trackPlanChange, plus the two test cases that would have caught this the first time around (TrackBilling not dispatched when enabled=false, and not dispatched when api_key is missing). Updates the listener test beforeEach to opt the suite into the enabled path so the existing assertDispatched() assertions continue to fire.
User reported the social-account OAuth callback popup ('Threads account
connected!') stayed in English regardless of the active locale. The
hardcoded message was wired through SocialController and 11 platform
controllers (Bluesky, Facebook, Instagram, InstagramFacebook, LinkedIn,
LinkedInPage, Mastodon, Pinterest, Threads, TikTok, YouTube), plus the
Blade view that the popup renders.
Adds an accounts.popup_callback i18n block (en/pt-BR/es) covering:
- The popup chrome (title, closing/close-now status text).
- Generic success/reconnect messages (one shared 'Account connected!'
/ 'Account reconnected!' line — the popup already shows a checkmark
and lives for ~2s so platform-specific wording wasn't pulling weight).
- Error variants (account/page/channel) and contextual edge cases
(page not found, no Facebook pages, no YouTube channels, etc.).
Updates every popupCallback() callsite to read from these keys, plus
the Blade view's title and submessage. Regenerates the JSON locale
bundle so laravel-vue-i18n stays in sync.
4 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wire PostHog identify + dual-group context across the stack so every event lands on the right person AND the right account/workspace groups, with count metrics kept fresh by Inertia navigations rather than per-domain triggers.
Hierarchy (mirrors the domain model)
User(distinct_id = user.id)account→Account(billing/plan parent)workspace→Workspace(collaboration child, carriesaccount_idfor drill-down)Both groups are attached to every event so PostHog can slice by either dimension.
Backend
app/Jobs/SyncUserToPostHog.php(new, queueposthog) — high-level sync: identifies the user + group-identifies the account and current workspace using$account->usage()(same source of truth Inertia ships).app/Actions/User/CreateUser.php— dispatchesSyncUserToPostHogon signup. No inline calls.app/Listeners/StripeEventListener.php— capturessubscription.created/updated/cancelledon the owner profile with theaccountgroup auto-attached, then re-dispatches the sync soplan/has_active_subscription/is_on_trialrefresh.app/Services/PostHogService.php—capture()accepts an optionalAccountthat auto-attaches\$groups.account,account_id,plan. Every public method short-circuits whenPOSTHOG_API_KEYis unset (open-source/self-hosted safe).app/Models/Traits/HasUsage.php— addspostCountvia combinedwithCount(['socialAccounts','posts'])(one query for both counts).config/horizon.php—supervisor-1now drains theposthogqueue.Frontend
resources/js/app.ts— extractssyncPostHogContext(page)and calls it at boot AND on everyrouter.on('navigate')reading fromevent.detail.page.props.usage. This:auth.currentWorkspace).resources/js/components/UserMenuContent.vue—posthog.reset()on logout so the next user in the same browser doesn't inherit events.resources/js/composables/useFeatureAccess.ts— TSUsageinterface includespostCount.Open-source friendly
PostHogServicemethod early-returns withoutPOSTHOG_API_KEY. No exception, no log spam.posthog-jsonly initialises whenVITE_POSTHOG_API_KEYis set; calls before init are no-op queue-only.Test plan
accountwithworkspaces_count: 0,posts_count: 0.accountupdatesworkspaces_count: 1.social_accounts_countupdates.workspacereflects new workspace, events from that point on attributed to it.subscription.createdevent captured + group plan refreshes.POSTHOG_API_KEY=empty (self-hosted) → no errors, no outbound traffic to PostHog.php artisan test --compact --parallel(1407 passed locally).