diff --git a/.changeset/khaki-toys-cheat.md b/.changeset/khaki-toys-cheat.md new file mode 100644 index 000000000..4065ff41e --- /dev/null +++ b/.changeset/khaki-toys-cheat.md @@ -0,0 +1,10 @@ +--- +'@powersync/service-module-postgres-storage': patch +'@powersync/service-module-mongodb-storage': patch +'@powersync/service-core-tests': patch +'@powersync/service-core': patch +'@powersync/service-types': patch +--- + +General client connections analytics added + diff --git a/.github/workflows/packages_release.yaml b/.github/workflows/packages_release.yaml index 392a139be..86efd0763 100644 --- a/.github/workflows/packages_release.yaml +++ b/.github/workflows/packages_release.yaml @@ -22,6 +22,9 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@v5 + with: + # check out full history. development packages need this for changesets + fetch-depth: 0 - name: Enable Corepack run: corepack enable - name: Setup Node.js diff --git a/modules/module-mongodb-storage/src/storage/MongoReportStorage.ts b/modules/module-mongodb-storage/src/storage/MongoReportStorage.ts index 4b9c332bf..1f11a39b4 100644 --- a/modules/module-mongodb-storage/src/storage/MongoReportStorage.ts +++ b/modules/module-mongodb-storage/src/storage/MongoReportStorage.ts @@ -2,6 +2,7 @@ import { storage } from '@powersync/service-core'; import { event_types } from '@powersync/service-types'; import { PowerSyncMongo } from './implementation/db.js'; import { logger } from '@powersync/lib-services-framework'; +import { createPaginatedConnectionQuery } from '../utils/util.js'; export class MongoReportStorage implements storage.ReportStorage { public readonly db: PowerSyncMongo; @@ -43,6 +44,27 @@ export class MongoReportStorage implements storage.ReportStorage { return result[0]; } + async getGeneralClientConnectionAnalytics( + data: event_types.ClientConnectionAnalyticsRequest + ): Promise> { + const { cursor, date_range } = data; + const limit = data?.limit || 100; + + const connected_at = date_range ? { connected_at: { $lte: date_range.end, $gte: date_range.start } } : undefined; + const user_id = data.user_id ? { user_id: data.user_id } : undefined; + const client_id = data.client_id ? { client_id: data.client_id } : undefined; + return (await createPaginatedConnectionQuery( + { + ...client_id, + ...user_id, + ...connected_at + }, + this.db.connection_report_events, + limit, + cursor + )) as event_types.PaginatedResponse; + } + async reportClientConnection(data: event_types.ClientConnectionBucketData): Promise { const updateFilter = this.updateDocFilter(data.user_id, data.client_id!); await this.db.connection_report_events.findOneAndUpdate( diff --git a/modules/module-mongodb-storage/src/utils/util.ts b/modules/module-mongodb-storage/src/utils/util.ts index a5350cb7b..0e89baede 100644 --- a/modules/module-mongodb-storage/src/utils/util.ts +++ b/modules/module-mongodb-storage/src/utils/util.ts @@ -114,3 +114,44 @@ export function setSessionSnapshotTime(session: mongo.ClientSession, time: bson. throw new ServiceAssertionError(`Session snapshotTime is already set`); } } + +export const createPaginatedConnectionQuery = async ( + query: mongo.Filter, + collection: mongo.Collection, + limit: number, + cursor?: string +) => { + const createQuery = (cursor?: string) => { + if (!cursor) { + return query; + } + const connected_at = query.connected_at + ? { $lt: new Date(cursor), $gte: query.connected_at.$gte } + : { $lt: new Date(cursor) }; + return { + ...query, + connected_at + } as mongo.Filter; + }; + + const findCursor = collection.find(createQuery(cursor), { + sort: { + /** We are sorting by connected at date descending to match cursor Postgres implementation */ + connected_at: -1 + } + }); + + const items = await findCursor.limit(limit).toArray(); + const count = items.length; + /** The returned total has been defaulted to 0 due to the overhead using documentCount from the mogo driver. + * cursor.count has been deprecated. + * */ + return { + items, + total: 0, + count, + /** Setting the cursor to the connected at date of the last item in the list */ + cursor: count === limit ? items[items.length - 1].connected_at.toISOString() : undefined, + more: !(count !== limit) + }; +}; diff --git a/modules/module-mongodb-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap b/modules/module-mongodb-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap index 3d70e5582..35065792f 100644 --- a/modules/module-mongodb-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap +++ b/modules/module-mongodb-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap @@ -3,13 +3,13 @@ exports[`Connection reporting storage > Should create a connection report if its after a day 1`] = ` [ { - "client_id": "client_week", + "client_id": "client_one", "sdk": "powersync-js/1.24.5", "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", "user_id": "user_week", }, { - "client_id": "client_week", + "client_id": "client_one", "sdk": "powersync-js/1.24.5", "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", "user_id": "user_week", @@ -213,3 +213,247 @@ exports[`Report storage tests > Should show currently connected users 1`] = ` "users": 2, } `; + +exports[`Report storage tests > Should show paginated response of all connections of specified client_id 1`] = ` +{ + "count": 1, + "cursor": undefined, + "items": [ + { + "client_id": "client_two", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit 1`] = ` +{ + "count": 4, + "cursor": "", + "items": [ + { + "client_id": "client_one", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_four", + "sdk": "powersync-js/1.21.4", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_four", + }, + { + "client_id": "", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_two", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + ], + "more": true, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit 2`] = ` +{ + "count": 4, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_four", + "sdk": "powersync-js/1.21.4", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_four", + }, + { + "client_id": "", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_two", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 1`] = ` +{ + "count": 4, + "cursor": "", + "items": [ + { + "client_id": "", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_two", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "client_three", + "sdk": "powersync-js/1.21.2", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_three", + }, + { + "client_id": "client_one", + "sdk": "powersync-js/1.24.5", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_week", + }, + ], + "more": true, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 2`] = ` +{ + "count": 2, + "cursor": undefined, + "items": [ + { + "client_id": "", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_two", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "client_three", + "sdk": "powersync-js/1.21.2", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_three", + }, + { + "client_id": "client_one", + "sdk": "powersync-js/1.24.5", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_week", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of connections of specified user_id 1`] = ` +{ + "count": 2, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of connections over a date range 1`] = ` +{ + "count": 6, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_four", + "sdk": "powersync-js/1.21.4", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_four", + }, + { + "client_id": "client_two", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_three", + "sdk": "powersync-js/1.21.2", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_three", + }, + { + "client_id": "client_one", + "sdk": "powersync-js/1.24.5", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_week", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of connections over a date range of specified client_id and user_id 1`] = ` +{ + "count": 1, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + ], + "more": false, + "total": 0, +} +`; diff --git a/modules/module-postgres-storage/package.json b/modules/module-postgres-storage/package.json index 29b0c543c..5069eb107 100644 --- a/modules/module-postgres-storage/package.json +++ b/modules/module-postgres-storage/package.json @@ -17,16 +17,16 @@ }, "exports": { ".": { + "types": "./dist/@types/index.d.ts", "import": "./dist/index.js", "require": "./dist/index.js", - "default": "./dist/index.js", - "types": "./dist/@types/index.d.ts" + "default": "./dist/index.js" }, "./types": { + "types": "./dist/@types/index.d.ts", "import": "./dist/types/types.js", "require": "./dist/types/types.js", - "default": "./dist/types/types.js", - "types": "./dist/@types/index.d.ts" + "default": "./dist/types/types.js" } }, "dependencies": { diff --git a/modules/module-postgres-storage/src/storage/PostgresReportStorage.ts b/modules/module-postgres-storage/src/storage/PostgresReportStorage.ts index 22187760c..f4f4646fb 100644 --- a/modules/module-postgres-storage/src/storage/PostgresReportStorage.ts +++ b/modules/module-postgres-storage/src/storage/PostgresReportStorage.ts @@ -9,6 +9,7 @@ import { toInteger } from 'ix/util/tointeger.js'; import { logger } from '@powersync/lib-services-framework'; import { getStorageApplicationName } from '../utils/application-name.js'; import { STORAGE_SCHEMA_NAME } from '../utils/db.js'; +import { ClientConnectionResponse } from '@powersync/service-types/dist/reports.js'; export type PostgresReportStorageOptions = { config: NormalizedPostgresStorageConfig; @@ -29,10 +30,10 @@ export class PostgresReportStorage implements storage.ReportStorage { } private parseJsDate(date: Date) { - const year = date.getFullYear(); - const month = date.getMonth(); - const today = date.getDate(); - const day = date.getDay(); + const year = date.getUTCFullYear(); + const month = date.getUTCMonth(); + const today = date.getUTCDate(); + const day = date.getUTCDay(); return { year, month, @@ -106,8 +107,70 @@ export class PostgresReportStorage implements storage.ReportStorage { const { year, month, today } = this.parseJsDate(new Date()); const nextDay = today + 1; return { - gte: new Date(year, month, today).toISOString(), - lt: new Date(year, month, nextDay).toISOString() + gte: new Date(Date.UTC(year, month, today)).toISOString(), + lt: new Date(Date.UTC(year, month, nextDay)).toISOString() + }; + } + + private clientsConnectionPagination(params: event_types.ClientConnectionAnalyticsRequest): { + mainQuery: pg_wire.Statement; + countQuery: pg_wire.Statement; + } { + const { cursor, limit, client_id, user_id, date_range } = params; + const queryLimit = limit || 100; + const queryParams: pg_wire.StatementParam[] = []; + let countQuery = `SELECT COUNT(*) AS total FROM connection_report_events`; + let query = `SELECT id, user_id, client_id, user_agent, sdk, jwt_exp::text AS jwt_exp, disconnected_at, connected_at::text AS connected_at, disconnected_at::text AS disconnected_at FROM connection_report_events`; + let intermediateQuery = ''; + /** Create a user_id/ client_id filter is they exist */ + if (client_id || user_id) { + if (client_id && !user_id) { + intermediateQuery += ` WHERE client_id = $1`; + queryParams.push({ type: 'varchar', value: client_id }); + } else if (!client_id && user_id) { + intermediateQuery += ` WHERE user_id = $1`; + queryParams.push({ type: 'varchar', value: user_id }); + } else { + intermediateQuery += ' WHERE client_id = $1 AND user_id = $2'; + queryParams.push({ type: 'varchar', value: client_id! }); + queryParams.push({ type: 'varchar', value: user_id! }); + } + } + + /** Create a date range filter if it exists */ + if (date_range) { + const { start, end } = date_range; + intermediateQuery += + queryParams.length === 0 + ? ` WHERE connected_at >= $1 AND connected_at <= $2` + : ` AND connected_at >= $${queryParams.length + 1} AND connected_at <= $${queryParams.length + 2}`; + queryParams.push({ type: 1184, value: start.toISOString() }); + queryParams.push({ type: 1184, value: end.toISOString() }); + } + + countQuery += intermediateQuery; + + /** Create a cursor filter if it exists. The cursor in postgres is the last item connection date, the id is an uuid so we cant use the same logic as in MongoReportStorage.ts */ + if (cursor) { + intermediateQuery += + queryParams.length === 0 ? ` WHERE connected_at < $1` : ` AND connected_at < $${queryParams.length + 1}`; + queryParams.push({ type: 1184, value: new Date(cursor).toISOString() }); + } + + /** Order in descending connected at range to match Mongo sort=-1*/ + intermediateQuery += ` ORDER BY connected_at DESC`; + query += intermediateQuery; + + return { + mainQuery: { + statement: query, + params: queryParams, + limit: queryLimit + }, + countQuery: { + statement: countQuery, + params: queryParams + } }; } @@ -225,6 +288,36 @@ export class PostgresReportStorage implements storage.ReportStorage { .first(); return this.mapListCurrentConnectionsResponse(result); } + + async getGeneralClientConnectionAnalytics( + data: event_types.ClientConnectionAnalyticsRequest + ): Promise> { + const limit = data.limit || 100; + const statement = this.clientsConnectionPagination(data); + + const result = await this.db.queryRows(statement.mainQuery); + const items = result.map((item) => ({ + ...item, + /** JS Date conversion to match document schema used for Mongo storage */ + connected_at: new Date(item.connected_at), + disconnected_at: item.disconnected_at ? new Date(item.disconnected_at) : undefined, + jwt_exp: item.jwt_exp ? new Date(item.jwt_exp) : undefined + /** */ + })); + const count = items.length; + /** The returned total has been defaulted to 0 due to the overhead using documentCount from the mogo driver this is just to keep consistency with Mongo implementation. + * cursor.count has been deprecated. + * */ + return { + items, + total: 0, + /** Setting the cursor to the connected at date of the last item in the list */ + cursor: count === limit ? items[items.length - 1].connected_at.toISOString() : undefined, + count, + more: !(count !== limit) + }; + } + async deleteOldConnectionData(data: event_types.DeleteOldConnectionData): Promise { const { date } = data; const result = await this.db.sql` diff --git a/modules/module-postgres-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap b/modules/module-postgres-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap index 9c2d0b20e..c28f61b45 100644 --- a/modules/module-postgres-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap +++ b/modules/module-postgres-storage/test/src/__snapshots__/connection-report-storage.test.ts.snap @@ -3,13 +3,13 @@ exports[`Connection report storage > Should create a connection event if its after a day 1`] = ` [ { - "client_id": "client_week", + "client_id": "client_one", "sdk": "powersync-js/1.24.5", "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", "user_id": "user_week", }, { - "client_id": "client_week", + "client_id": "client_one", "sdk": "powersync-js/1.24.5", "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", "user_id": "user_week", @@ -213,3 +213,273 @@ exports[`Report storage tests > Should show currently connected users 1`] = ` "users": 2, } `; + +exports[`Report storage tests > Should show paginated response of all connections of specified client_id 1`] = ` +{ + "count": 1, + "cursor": undefined, + "items": [ + { + "client_id": "client_two", + "id": "2", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit 1`] = ` +{ + "count": 4, + "cursor": "", + "items": [ + { + "client_id": "client_one", + "id": "1", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_four", + "id": "4", + "sdk": "powersync-js/1.21.4", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_four", + }, + { + "client_id": "client_two", + "id": "2", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "", + "id": "5", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + ], + "more": true, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit 2`] = ` +{ + "count": 4, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "id": "1", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_four", + "id": "4", + "sdk": "powersync-js/1.21.4", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_four", + }, + { + "client_id": "client_two", + "id": "2", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "", + "id": "5", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 1`] = ` +{ + "count": 4, + "cursor": "", + "items": [ + { + "client_id": "client_two", + "id": "2", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "", + "id": "5", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_three", + "id": "3", + "sdk": "powersync-js/1.21.2", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_three", + }, + { + "client_id": "client_one", + "id": "week", + "sdk": "powersync-js/1.24.5", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_week", + }, + ], + "more": true, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of all connections with a limit with date range 2`] = ` +{ + "count": 2, + "cursor": undefined, + "items": [ + { + "client_id": "client_two", + "id": "2", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "", + "id": "5", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_three", + "id": "3", + "sdk": "powersync-js/1.21.2", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_three", + }, + { + "client_id": "client_one", + "id": "week", + "sdk": "powersync-js/1.24.5", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_week", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of connections of specified user_id 1`] = ` +{ + "count": 2, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "id": "1", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "", + "id": "5", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of connections over a date range 1`] = ` +{ + "count": 6, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "id": "1", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_four", + "id": "4", + "sdk": "powersync-js/1.21.4", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_four", + }, + { + "client_id": "client_two", + "id": "2", + "sdk": "powersync-js/1.21.1", + "user_agent": "powersync-js/1.21.0 powersync-web Chromium/138 linux", + "user_id": "user_two", + }, + { + "client_id": "", + "id": "5", + "sdk": "unknown", + "user_agent": "Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + { + "client_id": "client_three", + "id": "3", + "sdk": "powersync-js/1.21.2", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_three", + }, + { + "client_id": "client_one", + "id": "week", + "sdk": "powersync-js/1.24.5", + "user_agent": "powersync-js/1.21.0 powersync-web Firefox/141 linux", + "user_id": "user_week", + }, + ], + "more": false, + "total": 0, +} +`; + +exports[`Report storage tests > Should show paginated response of connections over a date range of specified client_id and user_id 1`] = ` +{ + "count": 1, + "cursor": undefined, + "items": [ + { + "client_id": "client_one", + "id": "1", + "sdk": "powersync-dart/1.6.4", + "user_agent": "powersync-dart/1.6.4 Dart (flutter-web) Chrome/128 android", + "user_id": "user_one", + }, + ], + "more": false, + "total": 0, +} +`; diff --git a/modules/module-postgres-storage/test/src/connection-report-storage.test.ts b/modules/module-postgres-storage/test/src/connection-report-storage.test.ts index 3a0f1df74..011775769 100644 --- a/modules/module-postgres-storage/test/src/connection-report-storage.test.ts +++ b/modules/module-postgres-storage/test/src/connection-report-storage.test.ts @@ -185,7 +185,6 @@ describe('Connection report storage', async () => { const sdk = await factory.db .sql`SELECT * FROM connection_report_events WHERE user_id = ${{ type: 'varchar', value: userData.user_three.user_id }}`.rows(); expect(sdk).toHaveLength(1); - console.log(sdk[0]); expect(new Date((sdk[0].disconnected_at! as unknown as DateTimeValue).iso8601Representation).toISOString()).toEqual( disconnectAt.toISOString() ); diff --git a/packages/service-core-tests/src/tests/register-report-tests.ts b/packages/service-core-tests/src/tests/register-report-tests.ts index 0e95e3143..14af33752 100644 --- a/packages/service-core-tests/src/tests/register-report-tests.ts +++ b/packages/service-core-tests/src/tests/register-report-tests.ts @@ -62,7 +62,7 @@ const user_four = { const user_old = { user_id: 'user_one', client_id: '', - connected_at: now, + connected_at: nowLess5minutes, sdk: 'unknown', user_agent: 'Dart (flutter-web) Chrome/128 android', jwt_exp: nowAdd5minutes @@ -70,7 +70,7 @@ const user_old = { const user_week = { user_id: 'user_week', - client_id: 'client_week', + client_id: 'client_one', connected_at: weekAgo, sdk: 'powersync-js/1.24.5', user_agent: 'powersync-js/1.21.0 powersync-web Firefox/141 linux', @@ -106,6 +106,16 @@ export const REPORT_TEST_USERS = { }; export type ReportUserData = typeof REPORT_TEST_USERS; +type LocalConnection = Partial; +const removeVolatileFields = (connections: LocalConnection[]) => { + return connections.map((sdk: Partial) => { + const { _id, disconnected_at, connected_at, jwt_exp, ...rest } = sdk; + return { + ...rest + }; + }); +}; + export async function registerReportTests(factory: storage.ReportStorage) { it('Should show currently connected users', async () => { const current = await factory.getConnectedClients(); @@ -133,4 +143,113 @@ export async function registerReportTests(factory: storage.ReportStorage) { }); expect(sdk).toMatchSnapshot(); }); + + it('Should show paginated response of all connections of specified client_id', async () => { + const connections = await factory.getGeneralClientConnectionAnalytics({ + client_id: user_two.client_id + }); + const cleaned = { + ...connections, + items: removeVolatileFields(connections.items) + }; + expect(cleaned).toMatchSnapshot(); + }); + + it('Should show paginated response of connections of specified user_id', async () => { + const connections = await factory.getGeneralClientConnectionAnalytics({ + user_id: user_one.user_id + }); + + const cleaned = { + ...connections, + items: removeVolatileFields(connections.items) + }; + expect(cleaned).toMatchSnapshot(); + }); + + it('Should show paginated response of connections over a date range', async () => { + const connections = await factory.getGeneralClientConnectionAnalytics({ + date_range: { + start: weekAgo, + end: now + } + }); + + const cleaned = { + ...connections, + items: removeVolatileFields(connections.items) + }; + expect(cleaned).toMatchSnapshot(); + }); + + it('Should show paginated response of connections over a date range of specified client_id and user_id', async () => { + const connections = await factory.getGeneralClientConnectionAnalytics({ + client_id: user_one.client_id, + user_id: user_one.user_id, + date_range: { + start: weekAgo, + end: now + } + }); + + const cleaned = { + ...connections, + items: removeVolatileFields(connections.items) + }; + expect(cleaned).toMatchSnapshot(); + }); + + it('Should show paginated response of all connections with a limit', async () => { + const initial = await factory.getGeneralClientConnectionAnalytics({ + limit: 4 + }); + + const cursor = initial.cursor; + expect(cursor).toBeDefined(); + const cleanedInitial = { + ...initial, + cursor: '', + items: removeVolatileFields(initial.items) + }; + expect(cleanedInitial).toMatchSnapshot(); + const connections = await factory.getGeneralClientConnectionAnalytics({ + cursor + }); + const cleaned = { + ...connections, + items: removeVolatileFields(initial.items) + }; + expect(cleaned).toMatchSnapshot(); + }); + + it('Should show paginated response of all connections with a limit with date range', async () => { + const date_range = { + start: monthAgo, + end: nowLess5minutes + }; + const initial = await factory.getGeneralClientConnectionAnalytics({ + limit: 4, + date_range + }); + + const cursor = initial.cursor; + expect(cursor).toBeDefined(); + + const cleanedInitial = { + ...initial, + cursor: '', + items: removeVolatileFields(initial.items) + }; + expect(cleanedInitial).toMatchSnapshot(); + const connections = await factory.getGeneralClientConnectionAnalytics({ + cursor, + date_range + }); + + const cleaned = { + ...connections, + items: removeVolatileFields(initial.items) + }; + expect(cleaned).toMatchSnapshot(); + }); } diff --git a/packages/service-core/src/storage/ReportStorage.ts b/packages/service-core/src/storage/ReportStorage.ts index b75c67763..08b37dba4 100644 --- a/packages/service-core/src/storage/ReportStorage.ts +++ b/packages/service-core/src/storage/ReportStorage.ts @@ -31,6 +31,13 @@ export interface ReportStorage extends AsyncDisposable { getClientConnectionReports( data: event_types.ClientConnectionReportRequest ): Promise; + /** + * Get a paginated list of client connection events + * This will return a paginated list of connections for a client/ user ID or all if neither is provided, within a date range if provided + */ + getGeneralClientConnectionAnalytics( + data: event_types.ClientConnectionAnalyticsRequest + ): Promise>; /** * Delete old connection data based on a specific date. * This is used to clean up old connection data that is no longer needed. diff --git a/packages/types/src/reports.ts b/packages/types/src/reports.ts index 266b5942a..79e701ccb 100644 --- a/packages/types/src/reports.ts +++ b/packages/types/src/reports.ts @@ -70,6 +70,17 @@ export type ClientConnection = { disconnected_at?: Date; }; +export type ClientConnectionResponse = { + id?: string; + sdk: string; + user_agent: string; + client_id: string; + user_id: string; + jwt_exp?: string; + connected_at: string; + disconnected_at?: string; +}; + export type ClientConnectionReportResponse = { users: number; sdks: { @@ -83,3 +94,18 @@ export type ClientConnectionReportRequest = { start: Date; end: Date; }; + +export type ClientConnectionAnalyticsRequest = { + client_id?: string; + user_id?: string; + cursor?: string; + limit?: number; + date_range?: ClientConnectionReportRequest; +}; +export type PaginatedResponse = { + items: T[]; + total: number; + count: number; + cursor?: string; + more: boolean; +};