Преглед изворни кода

feat: add analytics package

Karol Sójko пре 3 година
родитељ
комит
14e4ca70b4
29 измењених фајлова са 1061 додато и 231 уклоњено
  1. 264 214
      .pnp.cjs
  2. BIN
      .yarn/cache/@standardnotes-analytics-npm-1.6.0-39bec110e3-6a5e861526.zip
  3. 1 0
      packages/analytics/.eslintignore
  4. 6 0
      packages/analytics/.eslintrc
  5. 86 0
      packages/analytics/CHANGELOG.md
  6. 11 0
      packages/analytics/jest.config.js
  7. 4 0
      packages/analytics/linter.tsconfig.json
  8. 39 0
      packages/analytics/package.json
  9. 6 0
      packages/analytics/src/Domain/Analytics/AnalyticsActivity.ts
  10. 10 0
      packages/analytics/src/Domain/Analytics/AnalyticsStoreInterface.ts
  11. 8 0
      packages/analytics/src/Domain/Statistics/StatisticsStoreInterface.ts
  12. 10 0
      packages/analytics/src/Domain/Time/Period.ts
  13. 58 0
      packages/analytics/src/Domain/Time/PeriodKeyGenerator.spec.ts
  14. 99 0
      packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts
  15. 5 0
      packages/analytics/src/Domain/Time/PeriodKeyGeneratorInterface.ts
  16. 6 0
      packages/analytics/src/Domain/index.ts
  17. 131 0
      packages/analytics/src/Infra/Redis/RedisAnalyticsStore.spec.ts
  18. 84 0
      packages/analytics/src/Infra/Redis/RedisAnalyticsStore.ts
  19. 92 0
      packages/analytics/src/Infra/Redis/RedisStatisticsStore.spec.ts
  20. 99 0
      packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts
  21. 2 0
      packages/analytics/src/Infra/index.ts
  22. 2 0
      packages/analytics/src/index.ts
  23. 11 0
      packages/analytics/tsconfig.json
  24. 1 1
      packages/api-gateway/package.json
  25. 1 1
      packages/auth/package.json
  26. 0 1
      packages/domain-events/.eslintignore
  27. 1 1
      packages/syncing-server/package.json
  28. 7 4
      tsconfig.json
  29. 17 9
      yarn.lock

Разлика између датотеке није приказан због своје велике величине
+ 264 - 214
.pnp.cjs


BIN
.yarn/cache/@standardnotes-analytics-npm-1.6.0-39bec110e3-6a5e861526.zip


+ 1 - 0
packages/analytics/.eslintignore

@@ -0,0 +1 @@
+dist

+ 6 - 0
packages/analytics/.eslintrc

@@ -0,0 +1,6 @@
+{
+  "extends": "../../.eslintrc",
+  "parserOptions": {
+    "project": "./linter.tsconfig.json"
+  }
+}

+ 86 - 0
packages/analytics/CHANGELOG.md

@@ -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 - 0
packages/analytics/jest.config.js

@@ -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 - 0
packages/analytics/linter.tsconfig.json

@@ -0,0 +1,4 @@
+{
+  "extends": "./tsconfig.json",
+  "exclude": ["dist"]
+}

+ 39 - 0
packages/analytics/package.json

@@ -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"
+  }
+}

+ 6 - 0
packages/analytics/src/Domain/Analytics/AnalyticsActivity.ts

@@ -0,0 +1,6 @@
+export enum AnalyticsActivity {
+  EditingItems = 'editing-items',
+  Login = 'login',
+  EmailUnbackedUpData = 'email-unbacked-up-data',
+  EmailBackup = 'email-backup',
+}

+ 10 - 0
packages/analytics/src/Domain/Analytics/AnalyticsStoreInterface.ts

@@ -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>
+}

+ 8 - 0
packages/analytics/src/Domain/Statistics/StatisticsStoreInterface.ts

@@ -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 - 0
packages/analytics/src/Domain/Time/Period.ts

@@ -0,0 +1,10 @@
+export enum Period {
+  Today,
+  Yesterday,
+  DayBeforeYesterday,
+  ThisWeek,
+  LastWeek,
+  WeekBeforeLastWeek,
+  ThisMonth,
+  LastMonth,
+}

+ 58 - 0
packages/analytics/src/Domain/Time/PeriodKeyGenerator.spec.ts

@@ -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 - 0
packages/analytics/src/Domain/Time/PeriodKeyGenerator.ts

@@ -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
+  }
+}

+ 5 - 0
packages/analytics/src/Domain/Time/PeriodKeyGeneratorInterface.ts

@@ -0,0 +1,5 @@
+import { Period } from './Period'
+
+export interface PeriodKeyGeneratorInterface {
+  getPeriodKey(period: Period): string
+}

+ 6 - 0
packages/analytics/src/Domain/index.ts

@@ -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 - 0
packages/analytics/src/Infra/Redis/RedisAnalyticsStore.spec.ts

@@ -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 - 0
packages/analytics/src/Infra/Redis/RedisAnalyticsStore.ts

@@ -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)}`,
+    )
+  }
+}

+ 92 - 0
packages/analytics/src/Infra/Redis/RedisStatisticsStore.spec.ts

@@ -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 - 0
packages/analytics/src/Infra/Redis/RedisStatisticsStore.ts

@@ -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 - 0
packages/analytics/src/Infra/index.ts

@@ -0,0 +1,2 @@
+export * from './Redis/RedisAnalyticsStore'
+export * from './Redis/RedisStatisticsStore'

+ 2 - 0
packages/analytics/src/index.ts

@@ -0,0 +1,2 @@
+export * from './Domain'
+export * from './Infra'

+ 11 - 0
packages/analytics/tsconfig.json

@@ -0,0 +1,11 @@
+{
+  "extends": "../../tsconfig.json",
+  "compilerOptions": {
+    "composite": true,
+    "outDir": "./dist",
+  },
+  "include": [
+    "src/**/*"
+  ],
+  "references": []
+}

+ 1 - 1
packages/api-gateway/package.json

@@ -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:*",

+ 1 - 1
packages/auth/package.json

@@ -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",

+ 0 - 1
packages/domain-events/.eslintignore

@@ -1,2 +1 @@
-node_modules
 dist

+ 1 - 1
packages/syncing-server/package.json

@@ -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:*",

+ 7 - 4
tsconfig.json

@@ -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"
@@ -35,13 +38,13 @@
       "path": "./packages/domain-events-infra"
     },
     {
-      "path": "./packages/syncing-server"
+      "path": "./packages/files"
     },
     {
-      "path": "./packages/files"
+      "path": "./packages/scheduler"
     },
     {
-      "path": "./packages/api-gateway"
+      "path": "./packages/syncing-server"
     }
   ]
 }

+ 17 - 9
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:*"

Неке датотеке нису приказане због велике количине промена