Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: daily & monthly participants #10

Merged
merged 8 commits into from
Jan 23, 2024
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
20 changes: 19 additions & 1 deletion lib/handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import { json } from 'http-responders'
import Sentry from '@sentry/node'
import { URLSearchParams } from 'node:url'

import { fetchRetrievalSuccessRate } from './stats-fetchers.js'
import {
fetchDailyParticipants,
fetchMonthlyParticipants,
fetchRetrievalSuccessRate
} from './stats-fetchers.js'

/**
* @param {object} args
Expand Down Expand Up @@ -42,6 +46,20 @@ const handler = async (req, res, pgPool) => {
res,
pgPool,
fetchRetrievalSuccessRate)
} else if (req.method === 'GET' && segs[0] === 'participants' && segs[1] === 'daily' && segs.length === 2) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} else if (req.method === 'GET' && segs[0] === 'participants' && segs[1] === 'daily' && segs.length === 2) {
} else if (req.method === 'GET' && pathname === '/participants/daily') {

await getStatsWithFilterAndCaching(
pathname,
searchParams,
res,
pgPool,
fetchDailyParticipants)
} else if (req.method === 'GET' && segs[0] === 'participants' && segs[1] === 'monthly' && segs.length === 2) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
} else if (req.method === 'GET' && segs[0] === 'participants' && segs[1] === 'monthly' && segs.length === 2) {
} else if (req.method === 'GET' && pathname === '/participants/monthly') {

no need to use segments if we have static urls

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(also applies above and below)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I was wondering about this too.

But then your suggestion changes how we handle paths like /participants//monthly and thus it would introduce inconsistency with the rest of our API 😢

Maybe I can have a "normalised path string" created via segments.join('/') and us that.

WDYT?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If it gets complicated like this, let's just switch to fastify 🤷‍♂️ I don't mind all these edge cases, since I don't yet see any problems arise from them. If you want this to be tighter, I propose let's just make the switch

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I don't have an appetite to spend more time on this. I know the current code could be improved; maybe we can also simplify it by not worrying about inconsistencies in edge case handling. Either way, I feel it's a minor detail, and we have bigger fish to fry.

I am going to land what we have now; I added the "Migrate our REST APIs to Fastify" task to our M4.2 milestone.

await getStatsWithFilterAndCaching(
pathname,
searchParams,
res,
pgPool,
fetchMonthlyParticipants)
} else if (req.method === 'GET' && segs.length === 0) {
// health check - required by Grafana datasources
res.end('OK')
Expand Down
42 changes: 42 additions & 0 deletions lib/stats-fetchers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,45 @@ export const fetchRetrievalSuccessRate = async (pgPool, filter) => {
}))
return stats
}

/**
* @param {import('pg').Pool} pgPool
* @param {import('./typings').Filter} filter
*/
export const fetchDailyParticipants = async (pgPool, filter) => {
// Fetch the "day" (DATE) as a string (TEXT) to prevent node-postgres from converting it into
// a JavaScript Date with a timezone, as that could change the date one day forward or back.
const { rows } = await pgPool.query(`
SELECT day::TEXT, COUNT(DISTINCT participant_id)::INT as participants
FROM daily_participants
WHERE day >= $1 AND day <= $2
GROUP BY day
`, [
filter.from,
filter.to
])
return rows
}

/**
* @param {import('pg').Pool} pgPool
* @param {import('./typings').Filter} filter
*/
export const fetchMonthlyParticipants = async (pgPool, filter) => {
// Fetch the "day" (DATE) as a string (TEXT) to prevent node-postgres from converting it into
// a JavaScript Date with a timezone, as that could change the date one day forward or back.
const { rows } = await pgPool.query(`
SELECT
date_trunc('month', day)::DATE::TEXT as month,
COUNT(DISTINCT participant_id)::INT as participants
FROM daily_participants
WHERE
day >= date_trunc('month', $1::DATE)
AND day < date_trunc('month', $2::DATE) + INTERVAL '1 month'
GROUP BY month
`, [
filter.from,
filter.to
])
return rows
}
66 changes: 66 additions & 0 deletions test/handler.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { once } from 'node:events'
import assert, { AssertionError } from 'node:assert'
import pg from 'pg'
import createDebug from 'debug'
import { mapParticipantsToIds } from 'spark-evaluate/lib/public-stats.js'

import { createHandler, today } from '../lib/handler.js'
import { DATABASE_URL } from '../lib/config.js'
Expand Down Expand Up @@ -132,6 +133,59 @@ describe('HTTP request handler', () => {
assert.strictEqual(res.headers.get('cache-control'), 'public, max-age=31536000, immutable')
})
})

describe('GET /participants/daily', () => {
it('returns daily active participants for the given date range', async () => {
await givenDailyParticipants(pgPool, '2024-01-10', ['0x10', '0x20'])
await givenDailyParticipants(pgPool, '2024-01-11', ['0x10', '0x20', '0x30'])
await givenDailyParticipants(pgPool, '2024-01-12', ['0x10', '0x20', '0x40', '0x50'])
await givenDailyParticipants(pgPool, '2024-01-13', ['0x10'])

const res = await fetch(
new URL(
'/participants/daily?from=2024-01-11&to=2024-01-12',
baseUrl
), {
redirect: 'manual'
}
)
await assertResponseStatus(res, 200)
const stats = await res.json()
assert.deepStrictEqual(stats, [
{ day: '2024-01-11', participants: 3 },
{ day: '2024-01-12', participants: 4 }
])
})
})

describe('GET /participants/monthly', () => {
it('returns montly active participants for the given date range ignoring the day number', async () => {
// before the range
await givenDailyParticipants(pgPool, '2023-12-31', ['0x01', '0x02'])
// in the range
await givenDailyParticipants(pgPool, '2024-01-10', ['0x10', '0x20'])
await givenDailyParticipants(pgPool, '2024-01-11', ['0x10', '0x20', '0x30'])
await givenDailyParticipants(pgPool, '2024-01-12', ['0x10', '0x20', '0x40', '0x50'])
await givenDailyParticipants(pgPool, '2024-02-13', ['0x10', '0x60'])
// after the range
await givenDailyParticipants(pgPool, '2024-03-01', ['0x99'])

const res = await fetch(
new URL(
'/participants/monthly?from=2024-01-12&to=2024-02-12',
baseUrl
), {
redirect: 'manual'
}
)
await assertResponseStatus(res, 200)
const stats = await res.json()
assert.deepStrictEqual(stats, [
{ month: '2024-01-01', participants: 5 },
{ month: '2024-02-01', participants: 2 }
])
})
})
})

const assertResponseStatus = async (res, status) => {
Expand All @@ -150,3 +204,15 @@ const givenRetrievalStats = async (pgPool, { day, total, successful }) => {
[day, total, successful]
)
}

const givenDailyParticipants = async (pgPool, day, participantAddresses) => {
const ids = await mapParticipantsToIds(pgPool, new Set(participantAddresses))
await pgPool.query(`
INSERT INTO daily_participants (day, participant_id)
SELECT $1 as day, UNNEST($2::INT[]) AS participant_id
ON CONFLICT DO NOTHING
`, [
day,
ids
])
}