feat: add analytics package
This commit is contained in:
parent
12fa94539b
commit
14e4ca70b4
29 changed files with 1095 additions and 302 deletions
Binary file not shown.
1
packages/analytics/.eslintignore
Normal file
1
packages/analytics/.eslintignore
Normal file
|
@ -0,0 +1 @@
|
|||
dist
|
6
packages/analytics/.eslintrc
Normal file
6
packages/analytics/.eslintrc
Normal file
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"extends": "../../.eslintrc",
|
||||
"parserOptions": {
|
||||
"project": "./linter.tsconfig.json"
|
||||
}
|
||||
}
|
86
packages/analytics/CHANGELOG.md
Normal file
86
packages/analytics/CHANGELOG.md
Normal file
|
@ -0,0 +1,86 @@
|
|||
# Change Log
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
||||
|
||||
## [1.6.1](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.6.0...@standardnotes/analytics@1.6.1) (2022-07-04)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing reflect-metadata package to all packages ([ce3a5bb](https://github.com/standardnotes/snjs/commit/ce3a5bbf3f1d2276ac4abc3eec3c6a44c8c3ba9b))
|
||||
|
||||
# [1.6.0](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.5.0...@standardnotes/analytics@1.6.0) (2022-06-02)
|
||||
|
||||
### Features
|
||||
|
||||
* refactor analytics store to handle different periods of time ([00d4f3f](https://github.com/standardnotes/snjs/commit/00d4f3f2f742b0deb5ef4cd415c672574cb3a911))
|
||||
|
||||
# [1.5.0](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.4.1...@standardnotes/analytics@1.5.0) (2022-06-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add unmarking activities ([09cea1d](https://github.com/standardnotes/snjs/commit/09cea1d8e97dd83f2eaafaef5ff680aef8c5c3ff))
|
||||
|
||||
## [1.4.1](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.4.0...@standardnotes/analytics@1.4.1) (2022-06-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* rename analytics activity backup to email backup ([30d2db6](https://github.com/standardnotes/snjs/commit/30d2db63e5dec05b3f0976211c661d8aa0e04139))
|
||||
|
||||
# [1.4.0](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.3.1...@standardnotes/analytics@1.4.0) (2022-06-01)
|
||||
|
||||
### Features
|
||||
|
||||
* add calculating total counts of activites in analytics ([6ed659a](https://github.com/standardnotes/snjs/commit/6ed659a7c4411ce2555e4af96dc5473c3d03fd41))
|
||||
|
||||
## [1.3.1](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.3.0...@standardnotes/analytics@1.3.1) (2022-05-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add backup analytics activity ([cf7ae68](https://github.com/standardnotes/snjs/commit/cf7ae68e13aa9403702340da8a3bca35e9273784))
|
||||
|
||||
# [1.3.0](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.2.0...@standardnotes/analytics@1.3.0) (2022-05-26)
|
||||
|
||||
### Features
|
||||
|
||||
* add calculating activity retention in analytics ([bc2f26d](https://github.com/standardnotes/snjs/commit/bc2f26d63a8ff0e2750b37bc1ee56297f6a8c98d))
|
||||
|
||||
# [1.2.0](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.1.0...@standardnotes/analytics@1.2.0) (2022-05-26)
|
||||
|
||||
### Features
|
||||
|
||||
* add activity indicators in analytics ([e6f5b5a](https://github.com/standardnotes/snjs/commit/e6f5b5afbff1f5f96adfcba42f4708fa74ac7f80))
|
||||
|
||||
# [1.1.0](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.7...@standardnotes/analytics@1.1.0) (2022-05-24)
|
||||
|
||||
### Features
|
||||
|
||||
* add marking activity in analytics ([#750](https://github.com/standardnotes/snjs/issues/750)) ([2a68fa6](https://github.com/standardnotes/snjs/commit/2a68fa6636c24e79443359d31a8427d50ca87cca))
|
||||
|
||||
## [1.0.7](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.5...@standardnotes/analytics@1.0.7) (2022-05-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
||||
|
||||
## [1.0.6](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.5...@standardnotes/analytics@1.0.6) (2022-05-04)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
||||
|
||||
## [1.0.5](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.4...@standardnotes/analytics@1.0.5) (2022-04-22)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
||||
|
||||
## [1.0.4](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.3...@standardnotes/analytics@1.0.4) (2022-04-15)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
||||
|
||||
## [1.0.3](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.2...@standardnotes/analytics@1.0.3) (2022-04-11)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
||||
|
||||
## [1.0.2](https://github.com/standardnotes/snjs/compare/@standardnotes/analytics@1.0.1...@standardnotes/analytics@1.0.2) (2022-03-31)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
||||
|
||||
## 1.0.1 (2022-03-10)
|
||||
|
||||
**Note:** Version bump only for package @standardnotes/analytics
|
11
packages/analytics/jest.config.js
Normal file
11
packages/analytics/jest.config.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const base = require('../../jest.config');
|
||||
|
||||
module.exports = {
|
||||
...base,
|
||||
globals: {
|
||||
'ts-jest': {
|
||||
tsconfig: 'tsconfig.json',
|
||||
},
|
||||
},
|
||||
};
|
4
packages/analytics/linter.tsconfig.json
Normal file
4
packages/analytics/linter.tsconfig.json
Normal file
|
@ -0,0 +1,4 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["dist"]
|
||||
}
|
39
packages/analytics/package.json
Normal file
39
packages/analytics/package.json
Normal file
|
@ -0,0 +1,39 @@
|
|||
{
|
||||
"name": "@standardnotes/analytics",
|
||||
"version": "1.7.0",
|
||||
"engines": {
|
||||
"node": ">=14.0.0 <17.0.0"
|
||||
},
|
||||
"description": "Analytics tools for Standard Notes projects",
|
||||
"main": "dist/src/index.js",
|
||||
"author": "Standard Notes",
|
||||
"types": "dist/src/index.d.ts",
|
||||
"files": [
|
||||
"dist/src"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"scripts": {
|
||||
"clean": "rm -fr dist",
|
||||
"prestart": "yarn clean",
|
||||
"start": "tsc -p tsconfig.json --watch",
|
||||
"prebuild": "yarn clean",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"lint": "eslint . --ext .ts",
|
||||
"test:unit": "jest spec --coverage"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ioredis": "^4.28.8",
|
||||
"@types/jest": "^27.4.1",
|
||||
"@typescript-eslint/eslint-plugin": "^5.30.0",
|
||||
"eslint-plugin-prettier": "^4.2.1",
|
||||
"ioredis": "^4.28.5",
|
||||
"jest": "^27.5.1",
|
||||
"ts-jest": "^27.1.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"reflect-metadata": "^0.1.13"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export enum AnalyticsActivity {
|
||||
EditingItems = 'editing-items',
|
||||
Login = 'login',
|
||||
EmailUnbackedUpData = 'email-unbacked-up-data',
|
||||
EmailBackup = 'email-backup',
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { Period } from '../Time/Period'
|
||||
import { AnalyticsActivity } from './AnalyticsActivity'
|
||||
|
||||
export interface AnalyticsStoreInterface {
|
||||
unmarkActivity(activities: AnalyticsActivity[], analyticsId: number, periods: Period[]): Promise<void>
|
||||
markActivity(activities: AnalyticsActivity[], analyticsId: number, periods: Period[]): Promise<void>
|
||||
wasActivityDone(activity: AnalyticsActivity, analyticsId: number, period: Period): Promise<boolean>
|
||||
calculateActivityRetention(activity: AnalyticsActivity, firstPeriod: Period, secondPeriod: Period): Promise<number>
|
||||
calculateActivityTotalCount(activity: AnalyticsActivity, period: Period): Promise<number>
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
export interface StatisticsStoreInterface {
|
||||
incrementSNJSVersionUsage(snjsVersion: string): Promise<void>
|
||||
incrementApplicationVersionUsage(applicationVersion: string): Promise<void>
|
||||
incrementOutOfSyncIncidents(): Promise<void>
|
||||
getYesterdaySNJSUsage(): Promise<Array<{ version: string; count: number }>>
|
||||
getYesterdayApplicationUsage(): Promise<Array<{ version: string; count: number }>>
|
||||
getYesterdayOutOfSyncIncidents(): Promise<number>
|
||||
}
|
10
packages/analytics/src/Domain/Time/Period.ts
Normal file
10
packages/analytics/src/Domain/Time/Period.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
export enum Period {
|
||||
Today,
|
||||
Yesterday,
|
||||
DayBeforeYesterday,
|
||||
ThisWeek,
|
||||
LastWeek,
|
||||
WeekBeforeLastWeek,
|
||||
ThisMonth,
|
||||
LastMonth,
|
||||
}
|
|
@ -0,0 +1,58 @@
|
|||
import { Period } from './Period'
|
||||
import { PeriodKeyGenerator } from './PeriodKeyGenerator'
|
||||
|
||||
describe('PeriodKeyGenerator', () => {
|
||||
const createGenerator = () => new PeriodKeyGenerator()
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers('modern')
|
||||
jest.setSystemTime(1653395155000)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should generate a period key for today', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.Today)).toEqual('2022-5-24')
|
||||
})
|
||||
|
||||
it('should generate a period key for yesterday', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.Yesterday)).toEqual('2022-5-23')
|
||||
})
|
||||
|
||||
it('should generate a period key for the day before yesterday', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.DayBeforeYesterday)).toEqual('2022-5-22')
|
||||
})
|
||||
|
||||
it('should generate a period key for this week', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.ThisWeek)).toEqual('2022-week-21')
|
||||
})
|
||||
|
||||
it('should generate a period key for last week', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.LastWeek)).toEqual('2022-week-20')
|
||||
})
|
||||
|
||||
it('should generate a period key for the week before last week', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.WeekBeforeLastWeek)).toEqual('2022-week-19')
|
||||
})
|
||||
|
||||
it('should generate a period key for this month', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.ThisMonth)).toEqual('2022-5')
|
||||
})
|
||||
|
||||
it('should generate a period key for last month', () => {
|
||||
expect(createGenerator().getPeriodKey(Period.LastMonth)).toEqual('2022-4')
|
||||
})
|
||||
|
||||
it('should throw error on unsupported period', () => {
|
||||
let error = null
|
||||
try {
|
||||
createGenerator().getPeriodKey(42 as Period)
|
||||
} catch (caughtError) {
|
||||
error = caughtError
|
||||
}
|
||||
|
||||
expect(error).not.toBeNull()
|
||||
})
|
||||
})
|
99
packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts
Normal file
99
packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import { Period } from './Period'
|
||||
import { PeriodKeyGeneratorInterface } from './PeriodKeyGeneratorInterface'
|
||||
|
||||
export class PeriodKeyGenerator implements PeriodKeyGeneratorInterface {
|
||||
getPeriodKey(period: Period): string {
|
||||
switch (period) {
|
||||
case Period.Today:
|
||||
return this.getDailyKey()
|
||||
case Period.Yesterday:
|
||||
return this.getDailyKey(this.getYesterdayDate())
|
||||
case Period.DayBeforeYesterday:
|
||||
return this.getDailyKey(this.getDayBeforeYesterdayDate())
|
||||
case Period.ThisWeek:
|
||||
return this.getWeeklyKey()
|
||||
case Period.LastWeek:
|
||||
return this.getWeeklyKey(this.getLastWeekDate())
|
||||
case Period.WeekBeforeLastWeek:
|
||||
return this.getWeeklyKey(this.getWeekBeforeLastWeekDate())
|
||||
case Period.ThisMonth:
|
||||
return this.getMonthlyKey()
|
||||
case Period.LastMonth:
|
||||
return this.getMonthlyKey(this.getLastMonthDate())
|
||||
default:
|
||||
throw new Error(`Unsuporrted period: ${period}`)
|
||||
}
|
||||
}
|
||||
|
||||
private getMonthlyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
return `${this.getYear(date)}-${this.getMonth(date)}`
|
||||
}
|
||||
|
||||
private getDailyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
return `${this.getYear(date)}-${this.getMonth(date)}-${this.getDayOfTheMonth(date)}`
|
||||
}
|
||||
|
||||
private getWeeklyKey(date?: Date): string {
|
||||
date = date ?? new Date()
|
||||
|
||||
const firstJanuary = new Date(date.getFullYear(), 0, 1)
|
||||
|
||||
const numberOfDaysPassed = Math.floor((date.getTime() - firstJanuary.getTime()) / (24 * 60 * 60 * 1000))
|
||||
|
||||
const weekNumber = Math.ceil((date.getDay() + 1 + numberOfDaysPassed) / 7)
|
||||
|
||||
return `${this.getYear(date)}-week-${weekNumber}`
|
||||
}
|
||||
|
||||
private getYear(date: Date): string {
|
||||
return date.getFullYear().toString()
|
||||
}
|
||||
|
||||
private getMonth(date: Date): string {
|
||||
return (date.getMonth() + 1).toString()
|
||||
}
|
||||
|
||||
private getDayOfTheMonth(date: Date): string {
|
||||
return date.getDate().toString()
|
||||
}
|
||||
|
||||
private getYesterdayDate(): Date {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(new Date().getDate() - 1)
|
||||
|
||||
return yesterday
|
||||
}
|
||||
|
||||
private getDayBeforeYesterdayDate(): Date {
|
||||
const dayBeforeYesterday = new Date()
|
||||
dayBeforeYesterday.setDate(new Date().getDate() - 2)
|
||||
|
||||
return dayBeforeYesterday
|
||||
}
|
||||
|
||||
private getLastWeekDate(): Date {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(new Date().getDate() - 7)
|
||||
|
||||
return yesterday
|
||||
}
|
||||
|
||||
private getLastMonthDate(): Date {
|
||||
const lastMonth = new Date()
|
||||
lastMonth.setDate(1)
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1)
|
||||
|
||||
return lastMonth
|
||||
}
|
||||
|
||||
private getWeekBeforeLastWeekDate(): Date {
|
||||
const yesterday = new Date()
|
||||
yesterday.setDate(new Date().getDate() - 14)
|
||||
|
||||
return yesterday
|
||||
}
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { Period } from './Period'
|
||||
|
||||
export interface PeriodKeyGeneratorInterface {
|
||||
getPeriodKey(period: Period): string
|
||||
}
|
6
packages/analytics/src/Domain/index.ts
Normal file
6
packages/analytics/src/Domain/index.ts
Normal file
|
@ -0,0 +1,6 @@
|
|||
export * from './Analytics/AnalyticsActivity'
|
||||
export * from './Analytics/AnalyticsStoreInterface'
|
||||
export * from './Statistics/StatisticsStoreInterface'
|
||||
export * from './Time/Period'
|
||||
export * from './Time/PeriodKeyGenerator'
|
||||
export * from './Time/PeriodKeyGeneratorInterface'
|
131
packages/analytics/src/Infra/Redis/RedisAnalyticsStore.spec.ts
Normal file
131
packages/analytics/src/Infra/Redis/RedisAnalyticsStore.spec.ts
Normal file
|
@ -0,0 +1,131 @@
|
|||
import * as IORedis from 'ioredis'
|
||||
import { Period } from '../../Domain'
|
||||
import { AnalyticsActivity } from '../../Domain/Analytics/AnalyticsActivity'
|
||||
import { PeriodKeyGeneratorInterface } from '../../Domain/Time/PeriodKeyGeneratorInterface'
|
||||
|
||||
import { RedisAnalyticsStore } from './RedisAnalyticsStore'
|
||||
|
||||
describe('RedisAnalyticsStore', () => {
|
||||
let redisClient: IORedis.Redis
|
||||
let pipeline: IORedis.Pipeline
|
||||
let periodKeyGenerator: PeriodKeyGeneratorInterface
|
||||
|
||||
const createStore = () => new RedisAnalyticsStore(periodKeyGenerator, redisClient)
|
||||
|
||||
beforeEach(() => {
|
||||
pipeline = {} as jest.Mocked<IORedis.Pipeline>
|
||||
pipeline.incr = jest.fn()
|
||||
pipeline.setbit = jest.fn()
|
||||
pipeline.exec = jest.fn()
|
||||
|
||||
redisClient = {} as jest.Mocked<IORedis.Redis>
|
||||
redisClient.pipeline = jest.fn().mockReturnValue(pipeline)
|
||||
redisClient.incr = jest.fn()
|
||||
redisClient.setbit = jest.fn()
|
||||
redisClient.getbit = jest.fn().mockReturnValue(1)
|
||||
redisClient.send_command = jest.fn()
|
||||
|
||||
periodKeyGenerator = {} as jest.Mocked<PeriodKeyGeneratorInterface>
|
||||
periodKeyGenerator.getPeriodKey = jest.fn().mockReturnValue('period-key')
|
||||
})
|
||||
|
||||
it('should calculate total count of activities', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValue(70)
|
||||
|
||||
expect(await createStore().calculateActivityTotalCount(AnalyticsActivity.EditingItems, Period.Yesterday)).toEqual(
|
||||
70,
|
||||
)
|
||||
|
||||
expect(redisClient.bitcount).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:period-key')
|
||||
})
|
||||
|
||||
it('should calculate activity retention', async () => {
|
||||
redisClient.bitcount = jest.fn().mockReturnValueOnce(7).mockReturnValueOnce(10)
|
||||
|
||||
expect(
|
||||
await createStore().calculateActivityRetention(
|
||||
AnalyticsActivity.EditingItems,
|
||||
Period.DayBeforeYesterday,
|
||||
Period.Yesterday,
|
||||
),
|
||||
).toEqual(70)
|
||||
|
||||
expect(redisClient.send_command).toHaveBeenCalledWith(
|
||||
'BITOP',
|
||||
'AND',
|
||||
'bitmap:action:editing-items:timespan:period-key-period-key',
|
||||
'bitmap:action:editing-items:timespan:period-key',
|
||||
'bitmap:action:editing-items:timespan:period-key',
|
||||
)
|
||||
})
|
||||
|
||||
it('shoud tell if activity was done', async () => {
|
||||
await createStore().wasActivityDone(AnalyticsActivity.EditingItems, 123, Period.Yesterday)
|
||||
|
||||
expect(redisClient.getbit).toHaveBeenCalledWith('bitmap:action:editing-items:timespan:period-key', 123)
|
||||
})
|
||||
|
||||
it('should mark activity as done', async () => {
|
||||
await createStore().markActivity([AnalyticsActivity.EditingItems], 123, [Period.Today])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 1)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should mark activities as done', async () => {
|
||||
await createStore().markActivity([AnalyticsActivity.EditingItems, AnalyticsActivity.EmailUnbackedUpData], 123, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(4)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(2, 'bitmap:action:editing-items:timespan:period-key', 123, 1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
1,
|
||||
)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
1,
|
||||
)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should unmark activity as done', async () => {
|
||||
await createStore().unmarkActivity([AnalyticsActivity.EditingItems], 123, [Period.Today])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(1)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 0)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should unmark activities as done', async () => {
|
||||
await createStore().unmarkActivity([AnalyticsActivity.EditingItems, AnalyticsActivity.EmailUnbackedUpData], 123, [
|
||||
Period.Today,
|
||||
Period.ThisWeek,
|
||||
])
|
||||
|
||||
expect(pipeline.setbit).toBeCalledTimes(4)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(1, 'bitmap:action:editing-items:timespan:period-key', 123, 0)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(2, 'bitmap:action:editing-items:timespan:period-key', 123, 0)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
0,
|
||||
)
|
||||
expect(pipeline.setbit).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'bitmap:action:email-unbacked-up-data:timespan:period-key',
|
||||
123,
|
||||
0,
|
||||
)
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
84
packages/analytics/src/Infra/Redis/RedisAnalyticsStore.ts
Normal file
84
packages/analytics/src/Infra/Redis/RedisAnalyticsStore.ts
Normal file
|
@ -0,0 +1,84 @@
|
|||
import * as IORedis from 'ioredis'
|
||||
|
||||
import { Period } from '../../Domain/Time/Period'
|
||||
import { PeriodKeyGeneratorInterface } from '../../Domain/Time/PeriodKeyGeneratorInterface'
|
||||
import { AnalyticsActivity } from '../../Domain/Analytics/AnalyticsActivity'
|
||||
import { AnalyticsStoreInterface } from '../../Domain/Analytics/AnalyticsStoreInterface'
|
||||
|
||||
export class RedisAnalyticsStore implements AnalyticsStoreInterface {
|
||||
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
|
||||
|
||||
async markActivity(activities: AnalyticsActivity[], analyticsId: number, periods: Period[]): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
for (const activity of activities) {
|
||||
for (const period of periods) {
|
||||
pipeline.setbit(
|
||||
`bitmap:action:${activity}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
analyticsId,
|
||||
1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async unmarkActivity(activities: AnalyticsActivity[], analyticsId: number, periods: Period[]): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
for (const activity of activities) {
|
||||
for (const period of periods) {
|
||||
pipeline.setbit(
|
||||
`bitmap:action:${activity}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
analyticsId,
|
||||
0,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async wasActivityDone(activity: AnalyticsActivity, analyticsId: number, period: Period): Promise<boolean> {
|
||||
const bitValue = await this.redisClient.getbit(
|
||||
`bitmap:action:${activity}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
analyticsId,
|
||||
)
|
||||
|
||||
return bitValue === 1
|
||||
}
|
||||
|
||||
async calculateActivityRetention(
|
||||
activity: AnalyticsActivity,
|
||||
firstPeriod: Period,
|
||||
secondPeriod: Period,
|
||||
): Promise<number> {
|
||||
const initialPeriodKey = this.periodKeyGenerator.getPeriodKey(firstPeriod)
|
||||
const subsequentPeriodKey = this.periodKeyGenerator.getPeriodKey(secondPeriod)
|
||||
|
||||
const diffKey = `bitmap:action:${activity}:timespan:${initialPeriodKey}-${subsequentPeriodKey}`
|
||||
|
||||
await this.redisClient.send_command(
|
||||
'BITOP',
|
||||
'AND',
|
||||
diffKey,
|
||||
`bitmap:action:${activity}:timespan:${initialPeriodKey}`,
|
||||
`bitmap:action:${activity}:timespan:${subsequentPeriodKey}`,
|
||||
)
|
||||
|
||||
const retainedTotalInActivity = await this.redisClient.bitcount(diffKey)
|
||||
|
||||
const initialTotalInActivity = await this.redisClient.bitcount(
|
||||
`bitmap:action:${activity}:timespan:${initialPeriodKey}`,
|
||||
)
|
||||
|
||||
return Math.ceil((retainedTotalInActivity * 100) / initialTotalInActivity)
|
||||
}
|
||||
|
||||
async calculateActivityTotalCount(activity: AnalyticsActivity, period: Period): Promise<number> {
|
||||
return this.redisClient.bitcount(
|
||||
`bitmap:action:${activity}:timespan:${this.periodKeyGenerator.getPeriodKey(period)}`,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,92 @@
|
|||
import * as IORedis from 'ioredis'
|
||||
import { PeriodKeyGeneratorInterface } from '../../Domain'
|
||||
|
||||
import { RedisStatisticsStore } from './RedisStatisticsStore'
|
||||
|
||||
describe('RedisStatisticsStore', () => {
|
||||
let redisClient: IORedis.Redis
|
||||
let periodKeyGenerator: PeriodKeyGeneratorInterface
|
||||
let pipeline: IORedis.Pipeline
|
||||
|
||||
const createStore = () => new RedisStatisticsStore(periodKeyGenerator, redisClient)
|
||||
|
||||
beforeEach(() => {
|
||||
pipeline = {} as jest.Mocked<IORedis.Pipeline>
|
||||
pipeline.incr = jest.fn()
|
||||
pipeline.setbit = jest.fn()
|
||||
pipeline.exec = jest.fn()
|
||||
|
||||
redisClient = {} as jest.Mocked<IORedis.Redis>
|
||||
redisClient.pipeline = jest.fn().mockReturnValue(pipeline)
|
||||
redisClient.incr = jest.fn()
|
||||
redisClient.setbit = jest.fn()
|
||||
redisClient.getbit = jest.fn().mockReturnValue(1)
|
||||
redisClient.send_command = jest.fn()
|
||||
|
||||
periodKeyGenerator = {} as jest.Mocked<PeriodKeyGeneratorInterface>
|
||||
periodKeyGenerator.getPeriodKey = jest.fn().mockReturnValue('period-key')
|
||||
})
|
||||
|
||||
it('should get yesterday out of sync incidents', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValue(1)
|
||||
|
||||
expect(await createStore().getYesterdayOutOfSyncIncidents()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should default to 0 yesterday out of sync incidents', async () => {
|
||||
redisClient.get = jest.fn().mockReturnValue(null)
|
||||
|
||||
expect(await createStore().getYesterdayOutOfSyncIncidents()).toEqual(0)
|
||||
})
|
||||
|
||||
it('should get yesterday application version usage', async () => {
|
||||
redisClient.keys = jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
'count:action:application-request:1.2.3:timespan:2022-3-10',
|
||||
'count:action:application-request:2.3.4:timespan:2022-3-10',
|
||||
])
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(3).mockReturnValueOnce(4)
|
||||
|
||||
expect(await createStore().getYesterdayApplicationUsage()).toEqual([
|
||||
{ count: 3, version: '1.2.3' },
|
||||
{ count: 4, version: '2.3.4' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should get yesterday snjs version usage', async () => {
|
||||
redisClient.keys = jest
|
||||
.fn()
|
||||
.mockReturnValue([
|
||||
'count:action:snjs-request:1.2.3:timespan:2022-3-10',
|
||||
'count:action:snjs-request:2.3.4:timespan:2022-3-10',
|
||||
])
|
||||
redisClient.get = jest.fn().mockReturnValueOnce(3).mockReturnValueOnce(4)
|
||||
|
||||
expect(await createStore().getYesterdaySNJSUsage()).toEqual([
|
||||
{ count: 3, version: '1.2.3' },
|
||||
{ count: 4, version: '2.3.4' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should increment application version usage', async () => {
|
||||
await createStore().incrementApplicationVersionUsage('1.2.3')
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should increment snjs version usage', async () => {
|
||||
await createStore().incrementSNJSVersionUsage('1.2.3')
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should increment out of sync incedent count', async () => {
|
||||
await createStore().incrementOutOfSyncIncidents()
|
||||
|
||||
expect(pipeline.incr).toHaveBeenCalled()
|
||||
expect(pipeline.exec).toHaveBeenCalled()
|
||||
})
|
||||
})
|
99
packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts
Normal file
99
packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts
Normal file
|
@ -0,0 +1,99 @@
|
|||
import * as IORedis from 'ioredis'
|
||||
|
||||
import { Period, PeriodKeyGeneratorInterface } from '../../Domain'
|
||||
|
||||
import { StatisticsStoreInterface } from '../../Domain/Statistics/StatisticsStoreInterface'
|
||||
|
||||
export class RedisStatisticsStore implements StatisticsStoreInterface {
|
||||
constructor(private periodKeyGenerator: PeriodKeyGeneratorInterface, private redisClient: IORedis.Redis) {}
|
||||
|
||||
async getYesterdayOutOfSyncIncidents(): Promise<number> {
|
||||
const count = await this.redisClient.get(
|
||||
`count:action:out-of-sync:timespan:${this.periodKeyGenerator.getPeriodKey(Period.Yesterday)}`,
|
||||
)
|
||||
|
||||
if (count === null) {
|
||||
return 0
|
||||
}
|
||||
|
||||
return +count
|
||||
}
|
||||
|
||||
async incrementOutOfSyncIncidents(): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
pipeline.incr(`count:action:out-of-sync:timespan:${this.periodKeyGenerator.getPeriodKey(Period.Today)}`)
|
||||
pipeline.incr(`count:action:out-of-sync:timespan:${this.periodKeyGenerator.getPeriodKey(Period.ThisWeek)}`)
|
||||
pipeline.incr(`count:action:out-of-sync:timespan:${this.periodKeyGenerator.getPeriodKey(Period.ThisMonth)}`)
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async getYesterdaySNJSUsage(): Promise<{ version: string; count: number }[]> {
|
||||
const keys = await this.redisClient.keys(
|
||||
`count:action:snjs-request:*:timespan:${this.periodKeyGenerator.getPeriodKey(Period.Yesterday)}`,
|
||||
)
|
||||
|
||||
return this.getRequestCountPerVersion(keys)
|
||||
}
|
||||
|
||||
async getYesterdayApplicationUsage(): Promise<{ version: string; count: number }[]> {
|
||||
const keys = await this.redisClient.keys(
|
||||
`count:action:application-request:*:timespan:${this.periodKeyGenerator.getPeriodKey(Period.Yesterday)}`,
|
||||
)
|
||||
|
||||
return this.getRequestCountPerVersion(keys)
|
||||
}
|
||||
|
||||
async incrementApplicationVersionUsage(applicationVersion: string): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
pipeline.incr(
|
||||
`count:action:application-request:${applicationVersion}:timespan:${this.periodKeyGenerator.getPeriodKey(
|
||||
Period.Today,
|
||||
)}`,
|
||||
)
|
||||
pipeline.incr(
|
||||
`count:action:application-request:${applicationVersion}:timespan:${this.periodKeyGenerator.getPeriodKey(
|
||||
Period.ThisWeek,
|
||||
)}`,
|
||||
)
|
||||
pipeline.incr(
|
||||
`count:action:application-request:${applicationVersion}:timespan:${this.periodKeyGenerator.getPeriodKey(
|
||||
Period.ThisMonth,
|
||||
)}`,
|
||||
)
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
async incrementSNJSVersionUsage(snjsVersion: string): Promise<void> {
|
||||
const pipeline = this.redisClient.pipeline()
|
||||
|
||||
pipeline.incr(
|
||||
`count:action:snjs-request:${snjsVersion}:timespan:${this.periodKeyGenerator.getPeriodKey(Period.Today)}`,
|
||||
)
|
||||
pipeline.incr(
|
||||
`count:action:snjs-request:${snjsVersion}:timespan:${this.periodKeyGenerator.getPeriodKey(Period.ThisWeek)}`,
|
||||
)
|
||||
pipeline.incr(
|
||||
`count:action:snjs-request:${snjsVersion}:timespan:${this.periodKeyGenerator.getPeriodKey(Period.ThisMonth)}`,
|
||||
)
|
||||
|
||||
await pipeline.exec()
|
||||
}
|
||||
|
||||
private async getRequestCountPerVersion(keys: string[]): Promise<{ version: string; count: number }[]> {
|
||||
const statistics = []
|
||||
for (const key of keys) {
|
||||
const count = await this.redisClient.get(key)
|
||||
const version = key.split(':')[3]
|
||||
statistics.push({
|
||||
version,
|
||||
count: +(count as string),
|
||||
})
|
||||
}
|
||||
|
||||
return statistics
|
||||
}
|
||||
}
|
2
packages/analytics/src/Infra/index.ts
Normal file
2
packages/analytics/src/Infra/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './Redis/RedisAnalyticsStore'
|
||||
export * from './Redis/RedisStatisticsStore'
|
2
packages/analytics/src/index.ts
Normal file
2
packages/analytics/src/index.ts
Normal file
|
@ -0,0 +1,2 @@
|
|||
export * from './Domain'
|
||||
export * from './Infra'
|
11
packages/analytics/tsconfig.json
Normal file
11
packages/analytics/tsconfig.json
Normal file
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist",
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"references": []
|
||||
}
|
|
@ -24,7 +24,7 @@
|
|||
"dependencies": {
|
||||
"@newrelic/winston-enricher": "^2.1.0",
|
||||
"@sentry/node": "^7.3.0",
|
||||
"@standardnotes/analytics": "^1.6.0",
|
||||
"@standardnotes/analytics": "workspace:*",
|
||||
"@standardnotes/auth": "3.19.4",
|
||||
"@standardnotes/domain-events": "workspace:*",
|
||||
"@standardnotes/domain-events-infra": "workspace:*",
|
||||
|
|
|
@ -33,7 +33,7 @@
|
|||
"dependencies": {
|
||||
"@newrelic/winston-enricher": "^2.1.0",
|
||||
"@sentry/node": "^7.3.0",
|
||||
"@standardnotes/analytics": "^1.6.0",
|
||||
"@standardnotes/analytics": "workspace:*",
|
||||
"@standardnotes/api": "^1.1.19",
|
||||
"@standardnotes/auth": "^3.19.4",
|
||||
"@standardnotes/common": "^1.23.1",
|
||||
|
|
|
@ -1,2 +1 @@
|
|||
node_modules
|
||||
dist
|
||||
|
|
|
@ -26,7 +26,7 @@
|
|||
"dependencies": {
|
||||
"@newrelic/winston-enricher": "^2.1.0",
|
||||
"@sentry/node": "^7.3.0",
|
||||
"@standardnotes/analytics": "^1.6.0",
|
||||
"@standardnotes/analytics": "workspace:*",
|
||||
"@standardnotes/auth": "^3.19.4",
|
||||
"@standardnotes/common": "^1.23.1",
|
||||
"@standardnotes/domain-events": "workspace:*",
|
||||
|
|
|
@ -23,7 +23,10 @@
|
|||
"exclude": ["**/*.spec.ts", "dist", "test-setup.ts"],
|
||||
"references": [
|
||||
{
|
||||
"path": "./packages/scheduler"
|
||||
"path": "./packages/analytics"
|
||||
},
|
||||
{
|
||||
"path": "./packages/api-gateway"
|
||||
},
|
||||
{
|
||||
"path": "./packages/auth"
|
||||
|
@ -34,14 +37,14 @@
|
|||
{
|
||||
"path": "./packages/domain-events-infra"
|
||||
},
|
||||
{
|
||||
"path": "./packages/syncing-server"
|
||||
},
|
||||
{
|
||||
"path": "./packages/files"
|
||||
},
|
||||
{
|
||||
"path": "./packages/api-gateway"
|
||||
"path": "./packages/scheduler"
|
||||
},
|
||||
{
|
||||
"path": "./packages/syncing-server"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
26
yarn.lock
26
yarn.lock
|
@ -1939,12 +1939,20 @@ __metadata:
|
|||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@standardnotes/analytics@npm:^1.6.0":
|
||||
version: 1.6.0
|
||||
resolution: "@standardnotes/analytics@npm:1.6.0"
|
||||
checksum: 6a5e86152673ce9ddce43c52b5f699a1b3ba5141e58a944bda8eaa88fc2f4169df27239f82633226752bf3f10f9804f426721b9c919d10fbfbb51f952430eb1f
|
||||
languageName: node
|
||||
linkType: hard
|
||||
"@standardnotes/analytics@workspace:*, @standardnotes/analytics@workspace:packages/analytics":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@standardnotes/analytics@workspace:packages/analytics"
|
||||
dependencies:
|
||||
"@types/ioredis": ^4.28.8
|
||||
"@types/jest": ^27.4.1
|
||||
"@typescript-eslint/eslint-plugin": ^5.30.0
|
||||
eslint-plugin-prettier: ^4.2.1
|
||||
ioredis: ^4.28.5
|
||||
jest: ^27.5.1
|
||||
reflect-metadata: ^0.1.13
|
||||
ts-jest: ^27.1.3
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@standardnotes/api-gateway@workspace:packages/api-gateway":
|
||||
version: 0.0.0-use.local
|
||||
|
@ -1952,7 +1960,7 @@ __metadata:
|
|||
dependencies:
|
||||
"@newrelic/winston-enricher": ^2.1.0
|
||||
"@sentry/node": ^7.3.0
|
||||
"@standardnotes/analytics": ^1.6.0
|
||||
"@standardnotes/analytics": "workspace:*"
|
||||
"@standardnotes/auth": 3.19.4
|
||||
"@standardnotes/domain-events": "workspace:*"
|
||||
"@standardnotes/domain-events-infra": "workspace:*"
|
||||
|
@ -2008,7 +2016,7 @@ __metadata:
|
|||
dependencies:
|
||||
"@newrelic/winston-enricher": ^2.1.0
|
||||
"@sentry/node": ^7.3.0
|
||||
"@standardnotes/analytics": ^1.6.0
|
||||
"@standardnotes/analytics": "workspace:*"
|
||||
"@standardnotes/api": ^1.1.19
|
||||
"@standardnotes/auth": ^3.19.4
|
||||
"@standardnotes/common": ^1.23.1
|
||||
|
@ -2351,7 +2359,7 @@ __metadata:
|
|||
dependencies:
|
||||
"@newrelic/winston-enricher": ^2.1.0
|
||||
"@sentry/node": ^7.3.0
|
||||
"@standardnotes/analytics": ^1.6.0
|
||||
"@standardnotes/analytics": "workspace:*"
|
||||
"@standardnotes/auth": ^3.19.4
|
||||
"@standardnotes/common": ^1.23.1
|
||||
"@standardnotes/domain-events": "workspace:*"
|
||||
|
|
Loading…
Reference in a new issue