From 83a6053eb3a3466fa584c466cac455935fe98894 Mon Sep 17 00:00:00 2001 From: Mike Conrad Date: Mon, 19 May 2025 16:57:02 -0400 Subject: [PATCH] Finalize sql query and add webhook endpoint --- .env.example | 1 + app/controllers/replays_controller.ts | 214 ++++++++++++++++---------- start/env.ts | 2 + 3 files changed, 134 insertions(+), 83 deletions(-) diff --git a/.env.example b/.env.example index 931db85..f9a00d6 100644 --- a/.env.example +++ b/.env.example @@ -13,3 +13,4 @@ SENTRY_ORG= REDIS_HOST=sentry-redis-1 REDIS_PORT=6379 REDIS_PASSWORD= +WEBHOOK_URL= \ No newline at end of file diff --git a/app/controllers/replays_controller.ts b/app/controllers/replays_controller.ts index 0732aff..2e56a1b 100644 --- a/app/controllers/replays_controller.ts +++ b/app/controllers/replays_controller.ts @@ -6,9 +6,7 @@ const SENTRY_TOKEN = env.get('SENTRY_TOKEN') const SENTRY_ORG = env.get('SENTRY_ORG') let recordsUpdated = 0 import redis from '@adonisjs/redis/services/main' -import { DateTime } from 'luxon' -const thirtyDaysAgo = DateTime.now().minus({ days: 30 }).toSQL() interface ApiResponse { data: T; @@ -23,24 +21,74 @@ interface SentryPagination { } export default class ReplaysController { - public async search({ response}: HttpContext) { - console.log('thir', thirtyDaysAgo) - let results = await db.rawQuery(`SELECT - "user" ->> 'display_name' AS user_email, - SUM(duration) AS duration, - COUNT(duration) AS sessions, - AVG(duration) AS avg_duration - FROM - replays - WHERE - finished_at >= ? - GROUP BY - user_email - ORDER BY - SUM(duration) desc`, [thirtyDaysAgo] - ) - response.json(results) -} + public async search({ response }: HttpContext) { + let results = await db.rawQuery(` +SELECT + u.display_name, + u.sessions, + u.total_time_seconds, + u.total_time_readable, + u.average_session_time_readable, + u.average_time_seconds, + r.id AS last_session_id, + r.finished_at AS last_session_time + +FROM ( + -- Aggregate sessions in the last 30 days + SELECT + "user" ->> 'display_name' AS display_name, + COUNT(duration) AS sessions, + SUM(duration) AS total_time_seconds, + AVG(duration) AS average_time_seconds, + CONCAT( + FLOOR(SUM(duration) / 86400), 'd ', + FLOOR(MOD(SUM(duration), 86400) / 3600), 'h ', + FLOOR(MOD(SUM(duration), 3600) / 60), 'm' + ) AS total_time_readable, + CONCAT( + FLOOR(COUNT(duration) / 86400), 'd ', + FLOOR(MOD(COUNT(duration), 86400) / 3600), 'h ', + FLOOR(MOD(COUNT(duration), 3600) / 60), 'm' + ) AS average_session_time_readable + FROM + replays + WHERE + finished_at >= NOW() - INTERVAL '30 days' + GROUP BY + "user" ->> 'display_name' +) u + +-- LATERAL JOIN to get latest session (either within 30d or fallback to latest overall) +JOIN LATERAL ( + SELECT id, finished_at + FROM replays + WHERE "user" ->> 'display_name' = u.display_name + ORDER BY + CASE WHEN finished_at >= NOW() - INTERVAL '30 days' THEN 0 ELSE 1 END, + finished_at DESC + LIMIT 1 +) r ON true + +ORDER BY + u.total_time_seconds DESC;` + ) + try { + + await fetch(env.get('WEBHOOK_URL'), + { + headers: + { + 'content-type': 'application/json' + }, + method: 'POST', + body: JSON.stringify(results.rows) + } + ) + } catch(e) { + console.error('error sending webhook data', e) + } + response.json(results.rows) + } public async list({ request, inertia }: HttpContext) { const page = request.input('page', 1) const perPage = 20 @@ -75,88 +123,88 @@ export default class ReplaysController { }) -} + } - async index({ request, response }: HttpContext) { - const {statsPeriod, start, end} = request.qs() - recordsUpdated = 0 + async index({ request, response }: HttpContext) { + const { statsPeriod, start, end } = request.qs() + recordsUpdated = 0 - let queryString: string = '?statsPeriod=24h'// Default in case none is provided - if (statsPeriod) { - queryString = `?statsPeriod=${statsPeriod}` - } else if (start && end) { - queryString = `?start=${start}&end=${end}` - } - const replays = await fetchBatch(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/replays/${queryString}`) - return response.json(replays) + let queryString: string = '?statsPeriod=24h'// Default in case none is provided + if (statsPeriod) { + queryString = `?statsPeriod=${statsPeriod}` + } else if (start && end) { + queryString = `?start=${start}&end=${end}` } + const replays = await fetchBatch(`https://sentry.io/api/0/organizations/${SENTRY_ORG}/replays/${queryString}`) + return response.json(replays) + } } async function fetchBatch(url: string) { - const options: RequestInit = { - headers: { - Authorization: `Bearer ${SENTRY_TOKEN}` - } - } - const req = await fetch(url, options) - if (!req.ok) { - throw new Error(`Request failed with status ${req.status}`); + const options: RequestInit = { + headers: { + Authorization: `Bearer ${SENTRY_TOKEN}` } + } + const req = await fetch(url, options) + if (!req.ok) { + throw new Error(`Request failed with status ${req.status}`); + } - const resp = await req.json() as ApiResponse; - const replays = resp.data; - const headers = req.headers + const resp = await req.json() as ApiResponse; + const replays = resp.data; + const headers = req.headers - const cleanedData = replays.map(record => sanitizeInput(record, Replay.allowedFields)) + const cleanedData = replays.map(record => sanitizeInput(record, Replay.allowedFields)) - let updated = await Replay.updateOrCreateMany('id', cleanedData ) - recordsUpdated = recordsUpdated + updated.length - const linkHeader = headers.get('link') - if (!linkHeader) { - return {error: 'link header missing from Sentry API response'} - } - const pagination: SentryPagination = parseSentryLinkHeader(linkHeader) + let updated = await Replay.updateOrCreateMany('id', cleanedData) + recordsUpdated = recordsUpdated + updated.length + const linkHeader = headers.get('link') + if (!linkHeader) { + return { error: 'link header missing from Sentry API response' } + } + const pagination: SentryPagination = parseSentryLinkHeader(linkHeader) - if (pagination.hasNextResults == true) { - console.log('fetching', pagination.next) - await fetchBatch(pagination.next) - } - console.log('no more results') - return {recordsUpdated} + if (pagination.hasNextResults == true) { + console.log('fetching', pagination.next) + await fetchBatch(pagination.next) + } + console.log('no more results') + return { recordsUpdated } } -function parseSentryLinkHeader(header:string): SentryPagination { - const links = header.split(',').map(part => part.trim()) - - let result = {} as SentryPagination - for (const link of links) { - const match = link.match(/<([^>]+)>;\s*rel="([^"]+)";\s*results="([^"]+)";\s*cursor="([^"]+)"/) - if (!match) continue - - const [, url, rel, results] = match - - if (rel === 'previous') { - result.previous = url - result.hasPreviousResults = results === 'true' - } else if (rel === 'next') { - result.next = url - result.hasNextResults = results === 'true' - } +function parseSentryLinkHeader(header: string): SentryPagination { + const links = header.split(',').map(part => part.trim()) + + let result = {} as SentryPagination + for (const link of links) { + const match = link.match(/<([^>]+)>;\s*rel="([^"]+)";\s*results="([^"]+)";\s*cursor="([^"]+)"/) + if (!match) continue + + const [, url, rel, results] = match + + if (rel === 'previous') { + result.previous = url + result.hasPreviousResults = results === 'true' + } else if (rel === 'next') { + result.next = url + result.hasNextResults = results === 'true' } - - return result } - function sanitizeInput(data: Record, allowedFields: string[]) { - return allowedFields.reduce((acc, key) => { - if (key in data) acc[key] = data[key] - return acc - }, {} as Record) - } + return result +} - function buildPaginationLinks(meta: { previousPageUrl: string, lastPage: number; currentPage: number; nextPageUrl: string}) { +function sanitizeInput(data: Record, allowedFields: string[]) { + return allowedFields.reduce((acc, key) => { + if (key in data) acc[key] = data[key] + return acc + }, {} as Record) +} + +function buildPaginationLinks(meta: { previousPageUrl: string, lastPage: number; currentPage: number; nextPageUrl: string }) { const links = [] // Previous diff --git a/start/env.ts b/start/env.ts index 8c2f1bd..54fa2df 100644 --- a/start/env.ts +++ b/start/env.ts @@ -33,4 +33,6 @@ export default await Env.create(new URL('../', import.meta.url), { PG_HOST: Env.schema.string(), PG_USER: Env.schema.string(), PG_PASSWORD: Env.schema.string(), + + WEBHOOK_URL: Env.schema.string() })