From 2b318a66575c0242e164e1f3f37d3f333bbf3372 Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 21 Apr 2026 16:15:37 +0200 Subject: [PATCH 1/2] feat(backend): add PostHog organization groups and $groupidentify sendEventToTracking now carries an optional `groups` field and forwards it as `$groups` to PostHog so every server-side event is attributed to an organization (and future group types). Previously all org-scoped events landed on an anonymous person record keyed by the org UUID with no way to aggregate by org in PostHog dashboards. - utils/posthog.ts: add `$groups` to capture body; add `groupIdentifyPosthog` helper for `$groupidentify` events. - utils/tracking.ts: add `groups?: PostHogGroups` to the payload and forward it to `trackPosthogEvent`. LogSnag ignores unknown fields. - on_organization_create: `$groupidentify` the new org with name, management_email, customer_id, created_by, created_at, website. - stripe_event: `$groupidentify` with plan_name / plan_status / plan_type on subscription created/updated, and plan_status=canceled on cancel. - Pass `groups: { organization: orgId }` on every existing tracking call across triggers and private endpoints (on_app_create, on_version_create, on_deploy_history_create, credit_usage_alerts, upload_link, delete_failed_version, events, plans.ts, stripe_event). This unblocks org/plan breakdowns in PostHog dashboards (e.g. LogSnag KPI port) without needing a backend data-warehouse join. --- .../_backend/private/delete_failed_version.ts | 1 + supabase/functions/_backend/private/events.ts | 1 + .../functions/_backend/private/upload_link.ts | 1 + .../_backend/triggers/credit_usage_alerts.ts | 1 + .../_backend/triggers/on_app_create.ts | 1 + .../triggers/on_deploy_history_create.ts | 1 + .../triggers/on_organization_create.ts | 16 ++++ .../_backend/triggers/on_version_create.ts | 1 + .../_backend/triggers/stripe_event.ts | 28 +++++++ supabase/functions/_backend/utils/plans.ts | 5 ++ supabase/functions/_backend/utils/posthog.ts | 76 +++++++++++++++++++ supabase/functions/_backend/utils/tracking.ts | 5 +- 12 files changed, 136 insertions(+), 1 deletion(-) diff --git a/supabase/functions/_backend/private/delete_failed_version.ts b/supabase/functions/_backend/private/delete_failed_version.ts index d89aa7a451..a263e9b98a 100644 --- a/supabase/functions/_backend/private/delete_failed_version.ts +++ b/supabase/functions/_backend/private/delete_failed_version.ts @@ -91,6 +91,7 @@ app.delete('/', middlewareKey(['all', 'write', 'upload']), async (c) => { channel: 'upload-failed', event: 'Failed to upload a bundle', user_id: version.owner_org, + groups: { organization: version.owner_org }, icon: '💀', }) diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index 4448c3303f..cec1cb612b 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -146,6 +146,7 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { ...trackedBody, bento: onboardingBentoEvent, sentToBento: Boolean(onboardingBentoEvent), + groups: trackingUserId ? { organization: trackingUserId } : undefined, }) return c.json(BRES) diff --git a/supabase/functions/_backend/private/upload_link.ts b/supabase/functions/_backend/private/upload_link.ts index b0cb1f1303..56e41206e5 100644 --- a/supabase/functions/_backend/private/upload_link.ts +++ b/supabase/functions/_backend/private/upload_link.ts @@ -79,6 +79,7 @@ app.post('/', middlewareKey(['all', 'write', 'upload']), async (c) => { event: 'Upload via single file', icon: '🏛️', user_id: app.owner_org, + groups: { organization: app.owner_org }, notify: false, }) diff --git a/supabase/functions/_backend/triggers/credit_usage_alerts.ts b/supabase/functions/_backend/triggers/credit_usage_alerts.ts index 3d3a003e9c..97c23c7673 100644 --- a/supabase/functions/_backend/triggers/credit_usage_alerts.ts +++ b/supabase/functions/_backend/triggers/credit_usage_alerts.ts @@ -77,6 +77,7 @@ app.post('/', middlewareAPISecret, async (c) => { event: `Credit usage ${threshold}%+`, icon: '⚡️', user_id: orgId, + groups: { organization: orgId }, notify: threshold >= 100, tags: { alert_cycle: alertCycle.toString(), diff --git a/supabase/functions/_backend/triggers/on_app_create.ts b/supabase/functions/_backend/triggers/on_app_create.ts index 4e3feb3488..45bde4fcbd 100644 --- a/supabase/functions/_backend/triggers/on_app_create.ts +++ b/supabase/functions/_backend/triggers/on_app_create.ts @@ -136,6 +136,7 @@ app.post('/', middlewareAPISecret, triggerValidator('apps', 'INSERT'), async (c) icon: isDemo ? '🎮' : isPendingOnboarding ? '🧭' : '🎉', sentToBento: Boolean(appCreatedBentoEvent), user_id: ownerOrg, + groups: { organization: ownerOrg }, tags: { app_id: record.app_id, is_demo: isDemo ? 'true' : 'false', diff --git a/supabase/functions/_backend/triggers/on_deploy_history_create.ts b/supabase/functions/_backend/triggers/on_deploy_history_create.ts index b6a2584390..50567eb99c 100644 --- a/supabase/functions/_backend/triggers/on_deploy_history_create.ts +++ b/supabase/functions/_backend/triggers/on_deploy_history_create.ts @@ -58,6 +58,7 @@ app.post('/', middlewareAPISecret, triggerValidator('deploy_history', 'INSERT'), event: 'Bundle Deployed', icon: '🚀', user_id: version.owner_org, + groups: { organization: version.owner_org }, tags: { app_id: record.app_id, bundle_name: version.name, diff --git a/supabase/functions/_backend/triggers/on_organization_create.ts b/supabase/functions/_backend/triggers/on_organization_create.ts index 7be704dd5b..5fb63c8c3d 100644 --- a/supabase/functions/_backend/triggers/on_organization_create.ts +++ b/supabase/functions/_backend/triggers/on_organization_create.ts @@ -3,8 +3,10 @@ import type { Database } from '../utils/supabase.types.ts' import { Hono } from 'hono/tiny' import { BRES, middlewareAPISecret, simpleError, triggerValidator } from '../utils/hono.ts' import { cloudlog } from '../utils/logging.ts' +import { groupIdentifyPosthog } from '../utils/posthog.ts' import { createStripeCustomer, finalizePendingStripeCustomer } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' +import { backgroundTask } from '../utils/utils.ts' export const app = new Hono() @@ -24,6 +26,19 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) await finalizePendingStripeCustomer(c, record) } + await backgroundTask(c, groupIdentifyPosthog(c, { + groupType: 'organization', + groupKey: record.id, + properties: { + name: record.name, + management_email: record.management_email, + customer_id: record.customer_id, + created_by: record.created_by, + created_at: record.created_at, + website: record.website, + }, + })) + await sendEventToTracking(c, { bento: { cron: '* * * * *', @@ -40,6 +55,7 @@ app.post('/', middlewareAPISecret, triggerValidator('orgs', 'INSERT'), async (c) icon: '🎉', sentToBento: true, user_id: record.id, + groups: { organization: record.id }, notify: false, }) diff --git a/supabase/functions/_backend/triggers/on_version_create.ts b/supabase/functions/_backend/triggers/on_version_create.ts index 84750d7777..05aca77a05 100644 --- a/supabase/functions/_backend/triggers/on_version_create.ts +++ b/supabase/functions/_backend/triggers/on_version_create.ts @@ -52,6 +52,7 @@ app.post('/', middlewareAPISecret, triggerValidator('app_versions', 'INSERT'), a event: 'Bundle Created', icon: '🎉', user_id: record.owner_org, + groups: { organization: record.owner_org }, tags: { app_id: record.app_id, bundle_name: record.name, diff --git a/supabase/functions/_backend/triggers/stripe_event.ts b/supabase/functions/_backend/triggers/stripe_event.ts index 938ebe1b57..38b8324d48 100644 --- a/supabase/functions/_backend/triggers/stripe_event.ts +++ b/supabase/functions/_backend/triggers/stripe_event.ts @@ -12,9 +12,11 @@ import { middlewareStripeWebhook } from '../utils/hono_middleware_stripe.ts' import { cloudlog } from '../utils/logging.ts' import { closeClient, getDrizzleClient, getPgClient } from '../utils/pg.ts' import * as schema from '../utils/postgres_schema.ts' +import { groupIdentifyPosthog } from '../utils/posthog.ts' import { ensureCustomerMetadata, getCreditCheckoutDetails, syncStripeCustomerCountry } from '../utils/stripe.ts' import { customerToSegmentOrg, supabaseAdmin } from '../utils/supabase.ts' import { sendEventToTracking } from '../utils/tracking.ts' +import { backgroundTask } from '../utils/utils.ts' export const app = new Hono() @@ -276,6 +278,7 @@ async function customerSourceCreated(c: Context, org: Org, stripeEvent: Stripe.C icon: '💳', sentToBento: true, user_id: org.id, + groups: { organization: org.id }, notify: false, }) return c.json(BRES) @@ -295,6 +298,7 @@ async function customerSourceExpiring(c: Context, org: Org) { icon: '⚠️', sentToBento: true, user_id: org.id, + groups: { organization: org.id }, notify: false, }) return c.json(BRES) @@ -332,6 +336,7 @@ async function invoiceUpcoming(c: Context, org: Org, stripeEvent: Stripe.Invoice icon: '📄', sentToBento: true, user_id: org.id, + groups: { organization: org.id }, notify: false, }) return c.json(BRES) @@ -384,6 +389,7 @@ async function createdOrUpdated( icon: '💰', sentToBento: true, user_id: org.id, + groups: { organization: org.id }, notify: true, tags: { plan_name: plan.name, @@ -418,11 +424,23 @@ async function createdOrUpdated( icon: '💰', sentToBento: true, user_id: org.id, + groups: { organization: org.id }, notify: isNewSubscription, tags: { plan_name: plan.name, }, }) + + await backgroundTask(c, groupIdentifyPosthog(c, { + groupType: 'organization', + groupKey: org.id, + properties: { + plan_name: plan.name, + plan_status: status, + plan_type: isMonthly ? 'monthly' : 'yearly', + subscription_status_name: statusName, + }, + })) } else { const segment = await customerToSegmentOrg(c, org.id, stripeData.data.price_id) @@ -458,8 +476,18 @@ async function didCancel(c: Context, org: Org) { icon: '⚠️', sentToBento: true, user_id: org.id, + groups: { organization: org.id }, notify: true, }) + + await backgroundTask(c, groupIdentifyPosthog(c, { + groupType: 'organization', + groupKey: org.id, + properties: { + plan_status: 'canceled', + canceled_at: new Date().toISOString(), + }, + })) } async function getOrg(c: Context, stripeData: StripeData) { diff --git a/supabase/functions/_backend/utils/plans.ts b/supabase/functions/_backend/utils/plans.ts index 4c6be16e71..0b80c2015c 100644 --- a/supabase/functions/_backend/utils/plans.ts +++ b/supabase/functions/_backend/utils/plans.ts @@ -320,6 +320,7 @@ async function userAbovePlan(c: Context, org: { event: `User need upgrade to ${bestPlanKey}`, icon: '⚠️', user_id: orgId, + groups: { organization: orgId }, notify: false, }).catch() } @@ -344,6 +345,7 @@ async function userIsAtPlanUsage(c: Context, orgId: string, customerId: string | event: 'User is at 90% of plan usage', icon: '⚠️', user_id: orgId, + groups: { organization: orgId }, notify: false, }).catch() } @@ -357,6 +359,7 @@ async function userIsAtPlanUsage(c: Context, orgId: string, customerId: string | event: 'User is at 70% of plan usage', icon: '⚠️', user_id: orgId, + groups: { organization: orgId }, notify: false, }).catch() } @@ -369,6 +372,7 @@ async function userIsAtPlanUsage(c: Context, orgId: string, customerId: string | event: 'User is at 50% of plan usage', icon: '⚠️', user_id: orgId, + groups: { organization: orgId }, notify: false, }).catch() } @@ -455,6 +459,7 @@ export async function handleOrgNotificationsAndEvents(c: Context, org: any, orgI event: 'User need onboarding', icon: '🥲', user_id: orgId, + groups: { organization: orgId }, notify: false, }).catch() } diff --git a/supabase/functions/_backend/utils/posthog.ts b/supabase/functions/_backend/utils/posthog.ts index a1eab4927e..48e7220f23 100644 --- a/supabase/functions/_backend/utils/posthog.ts +++ b/supabase/functions/_backend/utils/posthog.ts @@ -6,8 +6,11 @@ import { existInEnv, getEnv } from './utils.ts' const POSTHOG_CAPTURE_URL = 'https://eu.i.posthog.com/capture/' const POSTHOG_EXCEPTION_URL = 'https://eu.i.posthog.com/i/v0/e/' +export type PostHogGroups = Record + interface PostHogCapturePayload extends Pick, Pick { distinct_id?: string + groups?: PostHogGroups ip?: string setPersonProperties?: boolean tags?: Record @@ -28,11 +31,14 @@ export async function trackPosthogEvent(c: Context, payload: PostHogCapturePaylo const distinctId = payload.user_id || payload.distinct_id || 'anonymous' + const hasGroups = payload.groups && Object.keys(payload.groups).length > 0 + const properties = { ...(payload.tags || {}), channel: payload.channel, description: payload.description, ...(payload.setPersonProperties === false ? {} : { $set: payload.tags }), + ...(hasGroups ? { $groups: payload.groups } : {}), } const body = { @@ -228,3 +234,73 @@ export async function capturePosthogException(c: Context, payload: { return false } } + +export interface PostHogGroupIdentifyPayload { + groupType: string + groupKey: string + properties?: Record +} + +export async function groupIdentifyPosthog(c: Context, payload: PostHogGroupIdentifyPayload) { + const apiKey = getEnv(c, 'POSTHOG_API_KEY') + if (!apiKey || !existInEnv(c, 'POSTHOG_API_KEY')) { + cloudlog({ requestId: c.get('requestId'), message: 'PostHog not configured' }) + return false + } + + const host = getEnv(c, 'POSTHOG_API_HOST') || POSTHOG_CAPTURE_URL + const posthogUrl = host.endsWith('/capture/') + ? host + : new URL('capture/', host.endsWith('/') ? host : `${host}/`).toString() + + const body = { + api_key: apiKey, + event: '$groupidentify', + distinct_id: `$${payload.groupType}_${payload.groupKey}`, + properties: { + $group_type: payload.groupType, + $group_key: payload.groupKey, + $group_set: payload.properties ?? {}, + }, + timestamp: new Date().toISOString(), + } + + try { + const res = await fetch(posthogUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + + if (!res.ok) { + const error = await res.text() + cloudlogErr({ + requestId: c.get('requestId'), + message: 'PostHog $groupidentify error', + status: res.status, + error, + groupType: payload.groupType, + groupKey: payload.groupKey, + }) + return false + } + + cloudlog({ + requestId: c.get('requestId'), + message: 'PostHog $groupidentify sent', + groupType: payload.groupType, + groupKey: payload.groupKey, + }) + return true + } + catch (e) { + cloudlogErr({ + requestId: c.get('requestId'), + message: 'PostHog $groupidentify fetch failed', + error: serializeError(e), + groupType: payload.groupType, + groupKey: payload.groupKey, + }) + return false + } +} diff --git a/supabase/functions/_backend/utils/tracking.ts b/supabase/functions/_backend/utils/tracking.ts index 6cb01734c3..68f1c94325 100644 --- a/supabase/functions/_backend/utils/tracking.ts +++ b/supabase/functions/_backend/utils/tracking.ts @@ -1,5 +1,6 @@ import type { TrackOptions } from '@logsnag/node' import type { Context } from 'hono' +import type { PostHogGroups } from './posthog.ts' import { cloudlogErr, serializeError } from './logging.ts' import { logsnag } from './logsnag.ts' import { sendNotifToOrgMembers } from './org_email_notifications.ts' @@ -17,6 +18,7 @@ export interface BentoTrackingPayload { export interface SendEventToTrackingPayload extends TrackOptions { bento?: BentoTrackingPayload + groups?: PostHogGroups sentToBento?: boolean } @@ -46,7 +48,7 @@ function getTrackingIp(c: Context, ip?: string) { return c.req.header('cf-connecting-ip') ?? c.req.header('x-forwarded-for')?.split(',')[0]?.trim() } -async function executeTracking(c: Context, payload: TrackOptions, options: SendEventToTrackingOptions) { +async function executeTracking(c: Context, payload: SendEventToTrackingPayload, options: SendEventToTrackingOptions) { const tasks: Array> = [ runTrackedCall(c, 'logsnag', () => logsnag(c).track(payload)), runTrackedCall(c, 'posthog', () => trackPosthogEvent(c, { @@ -55,6 +57,7 @@ async function executeTracking(c: Context, payload: TrackOptions, options: SendE tags: payload.tags, channel: payload.channel, description: payload.description, + groups: payload.groups, ip: getTrackingIp(c, options.ip), })), ] From af77bb1ba950b9469d38c9d2d773c40500cabbee Mon Sep 17 00:00:00 2001 From: WcaleNieWolny Date: Tue, 21 Apr 2026 16:25:34 +0200 Subject: [PATCH 2/2] fix(events): only set organization group when caller supplies a verified org id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveTrackingUserId can return the authenticated user id (common API-key flow and the /events login tracking path) when the caller omits user_id or passes their own user UUID. The previous patch passed that id through as `groups: { organization: trackingUserId }`, mislabeling person-scoped events as organization-scoped and polluting the PostHog organization group with user UUIDs. resolveTrackingUserId now returns { trackingUserId, orgId? } — orgId is only populated in the two branches that actually verify an organization (app owner_org match or org.read permission). The caller forwards orgId to PostHog groups and falls back to undefined otherwise. Also switches the notifyConsole broadcast to use the already-verified `requestedOrgId` for `org_id` instead of trackingUserId, since that path is guarded by an explicit org id precondition. --- supabase/functions/_backend/private/events.ts | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index cec1cb612b..557ebae47f 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -15,17 +15,26 @@ export const app = new Hono() app.use('/', useCors) +interface ResolvedTrackingId { + trackingUserId: string + // Only set when we've verified the id refers to an organization the caller + // can access. Events for a bare authenticated user (no requestedUserId, or + // requestedUserId === authUserId) leave this undefined so we don't pollute + // the PostHog `organization` group with user UUIDs. + orgId?: string +} + async function resolveTrackingUserId( c: Context, requestedUserId: string | undefined, appId: string | undefined, notifyConsole = false, -) { +): Promise { const forbiddenError = notifyConsole ? 'Forbidden' : 'no_permission' const authUserId = c.get('auth')?.userId ?? '' if (!requestedUserId || requestedUserId === authUserId) { - return authUserId + return { trackingUserId: authUserId } } if (appId) { @@ -44,11 +53,11 @@ async function resolveTrackingUserId( throw quickError(403, forbiddenError, 'You cannot send events for this organization') } - return requestedUserId + return { trackingUserId: requestedUserId, orgId: requestedUserId } } if (await checkPermission(c, 'org.read', { orgId: requestedUserId })) { - return requestedUserId + return { trackingUserId: requestedUserId, orgId: requestedUserId } } throw quickError(403, forbiddenError, 'You cannot send events for this organization') @@ -83,7 +92,7 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { : typeof body.tags?.app_id === 'string' ? body.tags.app_id : undefined - const trackingUserId = await resolveTrackingUserId(c, requestedUserId, appId, Boolean(body.notifyConsole)) + const { trackingUserId, orgId: verifiedOrgId } = await resolveTrackingUserId(c, requestedUserId, appId, Boolean(body.notifyConsole)) const trackedBody = requestedUserId ? { ...trackOptions, user_id: trackingUserId } : trackOptions // notifyConsole: broadcast to Supabase Realtime only, skip all tracking @@ -99,7 +108,7 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { description: trackedBody.description, icon: trackedBody.icon, app_id: appId, - org_id: trackingUserId, + org_id: requestedOrgId, channel_name: typeof trackedBody.tags?.channel === 'string' ? trackedBody.tags.channel : undefined, bundle_name: typeof trackedBody.tags?.bundle === 'string' ? trackedBody.tags.bundle : undefined, timestamp: new Date().toISOString(), @@ -146,7 +155,7 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { ...trackedBody, bento: onboardingBentoEvent, sentToBento: Boolean(onboardingBentoEvent), - groups: trackingUserId ? { organization: trackingUserId } : undefined, + groups: verifiedOrgId ? { organization: verifiedOrgId } : undefined, }) return c.json(BRES)