Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/ANALYTICS.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The browser may send only these event names:

- `app_opened`
- `activity_created`
- `friend_added`
- `expense_added`
- `live_share_clicked`
- `live_activity_created`
Expand All @@ -29,6 +30,8 @@ Do not add arbitrary metadata to this contract. Analytics must never receive URL

Opening the app records its initial surface. Successful product actions are measured only after their local state update or live revision save succeeds. A failed expense or settlement save does not produce a success event. Currency selection is intentionally an interaction event: it records a deliberate change in either currency selector, even if the person later cancels activity creation or a live update cannot be saved.

`friend_added` records one event after a successful friend-add action, including activity creation when at least one initial friend is supplied. Adding several friends in one submission still records one event. Failed Live saves do not count, and the request never includes friend names, IDs, or a friend count.

`live_share_clicked` is also an intentional interaction event. It records when someone chooses **Start live activity**, before the backend request begins. Compare it with `live_activity_created` to distinguish sharing intent from successful Live activity creation. It contains no activity or link data.

## Reports in Supabase
Expand Down Expand Up @@ -137,6 +140,22 @@ group by hour_of_day
order by hour_of_day;
```

To see how often people successfully add friends:

```sql
select
surface,
count(*)::bigint as additions,
count(distinct session_hash)::bigint as sessions
from private.analytics_events
where event_name = 'friend_added'
and occurred_at >= now() - interval '30 days'
group by surface
order by surface;
```

One submission can add several friends but counts as one addition event. Use `surface` to compare browser-local and Live activity additions.

These are anonymous sessions, not authenticated users. One person can create multiple sessions, a selected UI language is not proof of physical location, and offline or self-hosted development use is not measured.

## Retention and availability
Expand Down
7 changes: 5 additions & 2 deletions e2e/activity-lifecycle.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -410,20 +410,23 @@ test('tracks local outcomes without sending local activity data or loading third
await page.getByRole('button', { name: 'Create an activity' }).click()
await page.getByLabel('Activity name').fill('Secret local weekend')
await page.getByLabel(/Activity currency/).selectOption('CNY')
await page.getByLabel(/Add friends/).fill('Private Friend')
await page.getByRole('button', { name: 'Create activity' }).click()
await page.getByRole('button', { name: 'Add friend' }).click()
await page.getByLabel(/Friend names/).fill('Private Friend')
await page.getByRole('button', { name: 'Add friends' }).click()
await chooseActivityCurrency(page, 'CNY', 'EUR')
await page.getByRole('button', { name: 'Add expense' }).click()
await page.getByLabel('Description').fill('Private dinner description')
await page.getByRole('spinbutton', { name: 'Amount' }).fill('42.37')
await page.getByRole('button', { name: 'Save expense' }).click()

await expect.poll(() => events.length).toBe(5)
await expect.poll(() => events.length).toBe(6)
const sessionTokens = new Set(events.map(event => event.p_session_token))
expect(events.map(({ p_event_name, p_surface, p_locale, p_currency }) => ({ p_event_name, p_surface, p_locale, p_currency }))).toEqual([
{ p_event_name: 'app_opened', p_surface: 'local', p_locale: 'en', p_currency: null },
{ p_event_name: 'currency_selected', p_surface: 'local', p_locale: 'en', p_currency: 'CNY' },
{ p_event_name: 'activity_created', p_surface: 'local', p_locale: 'en', p_currency: null },
{ p_event_name: 'friend_added', p_surface: 'local', p_locale: 'en', p_currency: null },
{ p_event_name: 'currency_selected', p_surface: 'local', p_locale: 'en', p_currency: 'EUR' },
{ p_event_name: 'expense_added', p_surface: 'local', p_locale: 'en', p_currency: null },
])
Expand Down
15 changes: 12 additions & 3 deletions src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,9 @@ describe('complete app workflows', () => {
await user.type(screen.getByLabelText('Activity name'), 'Analytics trip')
await user.type(screen.getByLabelText(/Add friends/), 'Maya')
await user.click(screen.getByRole('button', { name: 'Create activity' }))
await user.click(screen.getAllByRole('button', { name: 'Add friend' })[0])
await user.type(screen.getByLabelText(/Friend names/), 'Jordan')
await user.click(screen.getByRole('button', { name: 'Add friends' }))
await user.click(screen.getByRole('button', { name: 'Add expense' }))
await user.type(screen.getByLabelText('Description'), 'Dinner')
await user.type(screen.getByLabelText('Amount'), '20')
Expand All @@ -807,6 +810,8 @@ describe('complete app workflows', () => {
expect(analyticsClient.track.mock.calls).toEqual([
['app_opened', 'local', 'en'],
['activity_created', 'local', 'en'],
['friend_added', 'local', 'en'],
['friend_added', 'local', 'en'],
['expense_added', 'local', 'en'],
['settlement_recorded', 'local', 'en'],
])
Expand Down Expand Up @@ -1674,7 +1679,8 @@ describe('complete app workflows', () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
window.history.replaceState(null, '', `/${LIVE_ACTIVITY_HASH_PREFIX}${credentials.code}.${credentials.editToken}`)
const { unmount } = render(<App liveActivityClient={client} />)
const analyticsClient = { track: vi.fn() } satisfies AnalyticsClient
const { unmount } = render(<App analyticsClient={analyticsClient} liveActivityClient={client} />)

expect(await screen.findByLabelText('Live activity')).toBeVisible()
expect(await screen.findByText('Live · revision 1')).toBeVisible()
Expand All @@ -1692,6 +1698,7 @@ describe('complete app workflows', () => {
await user.click(screen.getByRole('button', { name: 'Add friends' }))
expect(await screen.findByText('Sam')).toBeVisible()
expect(screen.getByText('Live · revision 2')).toBeVisible()
expect(analyticsClient.track).toHaveBeenCalledWith('friend_added', 'live', 'en')

await user.click(screen.getByRole('button', { name: 'Add expense' }))
await user.type(screen.getByLabelText('Description'), 'Parking')
Expand Down Expand Up @@ -1729,7 +1736,7 @@ describe('complete app workflows', () => {

unmount()
window.history.replaceState(null, '', '/')
render(<App liveActivityClient={client} />)
render(<App analyticsClient={analyticsClient} liveActivityClient={client} />)
expect(await screen.findByText('Live · revision 5')).toBeVisible()
expect(window.location.hash).toContain(`${LIVE_ACTIVITY_HASH_PREFIX}${credentials.code}.`)

Expand Down Expand Up @@ -1837,7 +1844,8 @@ describe('complete app workflows', () => {
} satisfies LiveActivityClient

window.history.replaceState(null, '', `/${LIVE_ACTIVITY_HASH_PREFIX}${credentials.code}.${credentials.editToken}`)
render(<App liveActivityClient={client} />)
const analyticsClient = { track: vi.fn() } satisfies AnalyticsClient
render(<App analyticsClient={analyticsClient} liveActivityClient={client} />)
expect(await screen.findByText('Live · revision 1')).toBeVisible()

await user.click(screen.getAllByRole('button', { name: 'Add friend' })[0])
Expand All @@ -1847,6 +1855,7 @@ describe('complete app workflows', () => {
expect(await screen.findByText('Live sharing has ended')).toBeVisible()
expect(screen.getByText('Dinner', { exact: true })).toBeVisible()
expect(screen.queryByRole('button', { name: 'Add expense' })).not.toBeInTheDocument()
expect(analyticsClient.track).not.toHaveBeenCalledWith('friend_added', 'live', 'en')
})

it('serializes saves from one live browser tab', async () => {
Expand Down
7 changes: 6 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ function LocalizedApp({ analyticsClient = null, liveActivityClient }: AppProps =
const createGroup = (name: string, friendNames: string[], currency: CurrencyCode) => {
setState(current => createLocalActivity(current, name, friendNames, currency))
analyticsClient?.track('activity_created', 'local', locale)
if (friendNames.length > 0) analyticsClient?.track('friend_added', 'local', locale)
setModal(null)
}

Expand Down Expand Up @@ -232,10 +233,14 @@ function LocalizedApp({ analyticsClient = null, liveActivityClient }: AppProps =
friends: [...liveActivity.friends, ...newFriends],
group: { ...liveActivity.group, memberIds: [...liveActivity.group.memberIds, ...newFriends.map(friend => friend.id)] },
}, addedFriendsFeedback, JSON.stringify(['add-friends', names]))
if (saved) setModal(null)
if (saved) {
analyticsClient?.track('friend_added', 'live', locale)
setModal(null)
}
return
}
setState(current => addLocalFriends(current, activeGroup.id, names))
analyticsClient?.track('friend_added', 'local', locale)
setActivityFeedback({ groupId: activeGroup.id, message: addedFriendsFeedback })
setModal(null)
}
Expand Down
23 changes: 23 additions & 0 deletions src/analytics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,29 @@ describe('first-party analytics', () => {
})
})

it('records a friend addition without friend details or counts', () => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
const client = createConfiguredAnalyticsClient({
VITE_SUPABASE_URL: 'https://project.supabase.co',
VITE_SUPABASE_PUBLISHABLE_KEY: 'publishable-key',
}, {
enabled: true,
fetcher,
storage: null,
crypto: deterministicCrypto(6),
})!

client.track('friend_added', 'live', 'en')

expect(JSON.parse(fetcher.mock.calls[0][1].body as string)).toEqual({
p_event_name: 'friend_added',
p_surface: 'live',
p_session_token: '06'.repeat(16),
p_locale: 'en',
p_currency: null,
})
})

it('uses browser fetch, crypto, and session storage by default', () => {
const fetcher = vi.fn().mockResolvedValue(new Response(null, { status: 204 }))
Object.defineProperty(window, 'fetch', { configurable: true, value: fetcher })
Expand Down
1 change: 1 addition & 0 deletions src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export const ANALYTICS_SESSION_KEY = 'tally:analytics-session:v1'
export const ANALYTICS_EVENTS = [
'app_opened',
'activity_created',
'friend_added',
'expense_added',
'live_share_clicked',
'live_activity_created',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
alter table private.analytics_events
drop constraint analytics_events_event_name_check;

alter table private.analytics_events
add constraint analytics_events_event_name_check
check (event_name in (
'app_opened',
'activity_created',
'friend_added',
'expense_added',
'live_share_clicked',
'live_activity_created',
'live_activity_opened',
'settlement_recorded',
'currency_selected'
));

create or replace function private.record_analytics_event(
p_event_name text,
p_surface text,
p_session_token text,
p_locale text,
p_currency text
)
returns void
language plpgsql
security definer
set search_path = ''
set statement_timeout = '2s'
as $$
begin
perform private.enforce_shared_activity_rate_limit('analytics', 300, interval '5 minutes');

if p_event_name is null or p_event_name not in (
'app_opened',
'activity_created',
'friend_added',
'expense_added',
'live_share_clicked',
'live_activity_created',
'live_activity_opened',
'settlement_recorded',
'currency_selected'
)
or p_surface is null
or p_surface not in ('local', 'live', 'snapshot')
or p_session_token is null
or p_session_token !~ '^[a-f0-9]{32}$'
or p_locale is null
or p_locale not in ('en', 'zh-CN', 'unknown')
or (
p_event_name = 'currency_selected'
and (
p_currency is null
or p_currency not in (
'USD', 'EUR', 'GBP', 'CNY', 'JPY',
'CAD', 'AUD', 'HKD', 'SGD', 'KRW',
'INR', 'CHF', 'NZD', 'TWD', 'THB'
)
)
)
or (p_event_name <> 'currency_selected' and p_currency is not null) then
perform private.set_anonymous_rpc_status(400);
return;
end if;

if p_event_name = 'app_opened' then
delete from private.analytics_events
where id in (
select id
from private.analytics_events
where occurred_at < clock_timestamp() - interval '90 days'
order by occurred_at
limit 500
);
end if;

insert into private.analytics_events (
event_name,
surface,
session_hash,
locale,
currency
)
values (
p_event_name,
p_surface,
extensions.digest(p_session_token, 'sha256'),
p_locale,
p_currency
);
end;
$$;

revoke all on function private.record_analytics_event(text, text, text, text, text)
from public, anon, authenticated, service_role;

comment on constraint analytics_events_event_name_check on private.analytics_events is
'Allowlisted anonymous product outcomes, including successful friend additions.';
28 changes: 27 additions & 1 deletion supabase/tests/analytics.sql
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
begin;
create extension if not exists pgtap with schema extensions;
select plan(57);
select plan(60);

select has_table('private', 'analytics_events', 'private analytics storage exists');
select columns_are(
Expand Down Expand Up @@ -173,6 +173,32 @@ select is(
'Live sharing clicks retain their server-side event time without activity data'
);

select lives_ok(
$$select public.record_analytics_event('friend_added', 'local', '3456789abcdef0123456789abcdef012', 'en')$$,
'a successful friend addition is recorded'
);
select is(
(
select count(*)
from private.analytics_events
where event_name = 'friend_added'
and surface = 'local'
and currency is null
and occurred_at is not null
),
1::bigint,
'friend additions retain only the approved anonymous event fields'
);
select is(
(
select events
from private.analytics_daily
where event_name = 'friend_added' and surface = 'local'
),
1::bigint,
'daily analytics reports include successful friend additions'
);

create temporary table analytics_count_before_invalid as
select count(*) as event_count
from private.analytics_events;
Expand Down