Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: Accumulate Events #65

Merged
merged 41 commits into from
Jan 27, 2023
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
ee49b2a
feat: editor config, sane!
whizzzkid Jan 19, 2023
d07079d
Add event accumulator class
whizzzkid Jan 19, 2023
ccece9b
feat: Update types and hookup accumulator
whizzzkid Jan 19, 2023
f1da631
fix: configs
whizzzkid Jan 20, 2023
06ef5a9
feat: moving types to set rootDir
whizzzkid Jan 20, 2023
525234c
adding test folder
whizzzkid Jan 20, 2023
b8cde72
adding relevant types
whizzzkid Jan 20, 2023
882075f
types migration
whizzzkid Jan 20, 2023
6a59727
fix: add_event
whizzzkid Jan 20, 2023
dd6a6cb
feat: adding accumulator test.
whizzzkid Jan 20, 2023
1802c4e
breaking down logic
whizzzkid Jan 20, 2023
9e62d8f
fix: import order
whizzzkid Jan 20, 2023
ded115c
fix: sinon stubs
whizzzkid Jan 20, 2023
719519c
feat: Adding impl using map
whizzzkid Jan 20, 2023
c459dcb
adding flushAll method
whizzzkid Jan 20, 2023
d23fcb6
Hooking up to the beforunload event
whizzzkid Jan 20, 2023
db97c66
lint
whizzzkid Jan 20, 2023
a28bc36
Merge branch 'main' into feat/accumulate-events
whizzzkid Jan 21, 2023
5791d84
fix: :twisted_rightwards_arrows: merged with upstream
whizzzkid Jan 21, 2023
98a7ab2
fix: :package: adding missing package after merge
whizzzkid Jan 21, 2023
90b37ba
fix: :test_tube: EventAccumulator
whizzzkid Jan 21, 2023
eb5ae38
fix: :adhesive_bandage: fix imports
whizzzkid Jan 21, 2023
f638eb8
fix: :truck: rename to spec.ts
whizzzkid Jan 21, 2023
1238a5f
fix: :recycle: cleanup
whizzzkid Jan 21, 2023
6bd68c6
fix: :package: package-lock.json
whizzzkid Jan 21, 2023
7c07a7d
fix: :pencil2: spec naming and scope
whizzzkid Jan 23, 2023
4fe767b
Merge branch 'main' into feat/accumulate-events
whizzzkid Jan 25, 2023
9eea9a5
fix: :wrench: don't disable rules globally
whizzzkid Jan 25, 2023
2722ec2
fix: :wrench: only apply eslint overrides to spec files.
whizzzkid Jan 25, 2023
8eb34e9
fix: :rotating_light: fixes lint.
whizzzkid Jan 26, 2023
d34928b
fix: :recycle: Moving tests to node folder
whizzzkid Jan 26, 2023
772018d
fix: :label: generics
whizzzkid Jan 26, 2023
2313806
fix: :necktie: instantiating at declaration
whizzzkid Jan 26, 2023
8c4f560
fix: :memo: adding missing documentation
whizzzkid Jan 26, 2023
c999b06
fix: :necktie: fixing unload event to handle both browser and node.
whizzzkid Jan 26, 2023
fbaa4f3
fix: :truck: digest -> accumulate
whizzzkid Jan 26, 2023
dfc72df
fix: generics
whizzzkid Jan 26, 2023
c2c4277
fix: :pencil2: dupe imports
whizzzkid Jan 26, 2023
a0242f9
fix: fixing appKey
whizzzkid Jan 26, 2023
ab218ab
feat: :zap: useFakeTimers instead of await
whizzzkid Jan 26, 2023
3549175
fix: :recycle: move call order
whizzzkid Jan 26, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
root=true

[*]
end_of_line = lf
insert_final_newline = true
charset = utf-8
indent_style = space
indent_size = 2
13 changes: 10 additions & 3 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
"sourceType": "module"
},
"rules": {
"sort-imports": ["error"],
"@typescript-eslint/no-unused-expressions": "off"
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved
},
"overrides": [
{
"files": "test/**/*.spec.ts",
"rules": {
"@typescript-eslint/no-unused-expressions": "off"
"files": [
"test/**/*.ts"
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved
],
"parserOptions": {
"project": "test/tsconfig.json"
},
"env": {
"mocha": true
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved
}
}
]
Expand Down
9 changes: 9 additions & 0 deletions .mocharc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"extensions": ["ts"],
"spec": ["test/**/*.spec.*"],
"require": ["ts-node/register"],
"node-option": [
"experimental-specifier-resolution=node",
Copy link
Member

Choose a reason for hiding this comment

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

do we need this if we're using ts-node/esm loader?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

for some reason, it wants both.

"loader=ts-node/esm"
]
}
Comment on lines +1 to +9
Copy link
Member

Choose a reason for hiding this comment

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

this change for running the tests without build should probably be in a separate PR

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

yes, but I started when we did not have any testing in place, apologies, if you want I can spend sometime splitting these, but I hope it's fine for this time?

5,621 changes: 3,402 additions & 2,219 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 3 additions & 8 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,6 @@
"import": "./dist/src/*.js"
}
},
"eslintConfig": {
"extends": "ipfs",
"parserOptions": {
"sourceType": "module"
}
},
Comment on lines -57 to -62
Copy link
Member

Choose a reason for hiding this comment

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

+1. but should be separate PR

"release": {
"branches": [
"main"
Expand Down Expand Up @@ -149,9 +143,8 @@
"lint": "aegir lint",
"release": "aegir release",
"build": "aegir build",
"pretest": "run-s build",
"test": "run-s test:*",
"test:node": "aegir test -t node -f 'dist/test/node.spec.js'",
"test:node": "aegir test --target node --files 'test/**/*.spec.ts'",
Copy link
Member

Choose a reason for hiding this comment

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

should be a separate PR.

we will need a way to separate node and browser tests because of the countly import. if you're proposing we just find all tests in node/ then we need to update this

Suggested change
"test:node": "aegir test --target node --files 'test/**/*.spec.ts'",
"test:node": "aegir test --target node --files 'test/node/**/*.spec.ts'",

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

tsconfig can have both node and browser types, just wondering if it still be a problem, moving for now.

"storybook": "start-storybook -p 6006",
"build-storybook": "build-storybook"
},
Expand All @@ -178,6 +171,7 @@
"@storybook/testing-library": "^0.0.13",
"@types/mocha": "^10.0.1",
"@types/react-dom": "^18.0.10",
"@types/sinon": "^10.0.13",
"@types/sinon-chai": "^3.2.9",
"@types/styled-components": "^5.1.26",
"aegir": "^38.1.0",
Expand All @@ -196,6 +190,7 @@
"sinon": "^15.0.1",
"sinon-chai": "^3.7.0",
"style-loader": "^3.3.1",
"ts-node": "^10.9.1",
"typescript": "^4.9.4"
},
"peerDependencies": {
Expand Down
4 changes: 2 additions & 2 deletions src/BrowserMetricsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import Countly from 'countly-sdk-web'
import type { MetricProviderOptionalConstructorArgs, WithOptional } from '../types/index.js'
import MetricsProvider, { MetricsProviderConstructorOptions } from './MetricsProvider.js'
import { BrowserStorageProvider } from './BrowserStorageProvider.js'
import type { MetricProviderOptionalConstructorArgs, WithOptional } from './types/index.js'
import Countly from 'countly-sdk-web'
Comment on lines +1 to +4
Copy link
Member

Choose a reason for hiding this comment

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

nit: 3p imports appearing after relative imports is so weird. we should eventually update with #74

Copy link
Member

Choose a reason for hiding this comment

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

nit: should be separate PR

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

I can look into #74 later and fix this.


export class BrowserMetricsProvider extends MetricsProvider<typeof Countly> {
constructor (args: WithOptional<MetricsProviderConstructorOptions<typeof Countly>, MetricProviderOptionalConstructorArgs>) {
Expand Down
2 changes: 1 addition & 1 deletion src/BrowserStorageProvider.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { consentTypes } from './types/index.js'
import type { StorageProvider } from './StorageProvider.js'
import type { consentTypes } from '../types/index.js'
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved

export class BrowserStorageProvider implements StorageProvider {
setStore (consentArray: consentTypes[]): void {
Expand Down
145 changes: 145 additions & 0 deletions src/EventAccumulator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import type { CountlyEvent, CountlyEventData, CountlyWebSdk, IEventAccumulator } from 'countly-sdk-web'
import type { CountlyNodeSdk } from 'countly-sdk-nodejs'

const eventDefaults: CountlyEventData = {
key: '',
count: 1,
sum: 1,
dur: 0,
segmentation: {}
}

interface eventStore {
eventData: CountlyEventData
startTime: number
timeout: NodeJS.Timeout
}

/**
* EventAccumulator is a class that accumulates events and flushes them to the Countly server.
*/
export class EventAccumulator implements IEventAccumulator {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
export class EventAccumulator implements IEventAccumulator {
export class EventAccumulator<T extends CountlyWebSdk | CountlyNodeSdk> implements IEventAccumulator {

private readonly metricsService: CountlyWebSdk | CountlyNodeSdk
Copy link
Member

Choose a reason for hiding this comment

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

I'd prefer a generic approach. Without generics this is going to cause some typing issues.

Suggested change
private readonly metricsService: CountlyWebSdk | CountlyNodeSdk
private readonly metricsService: T

private readonly events: Map<string, eventStore>
private readonly flushInterval: number

/**
* Create a new EventAccumulator
*
* @param {CountlyWebSdk} metricsService - instance
* @param {number} flushInterval - in milliseconds
*/
constructor (metricsService: CountlyWebSdk | CountlyNodeSdk, flushInterval: number = 5 * 60 * 1000) {
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
constructor (metricsService: CountlyWebSdk | CountlyNodeSdk, flushInterval: number = 5 * 60 * 1000) {
constructor (metricsService: T, flushInterval: number = 5 * 60 * 1000) {

this.metricsService = metricsService
this.flushInterval = flushInterval
this.events = new Map()
Copy link
Member

Choose a reason for hiding this comment

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

nit: we could instantiate at the declaration

this.setupUnloadEvent()
}

private setupUnloadEvent (): void {
globalThis.addEventListener('beforeunload', () => {
this.flushAll()
})
}
Copy link
Member

Choose a reason for hiding this comment

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

this will break when using nodemetricsprovider

╰─ ✔ ❯ node
Welcome to Node.js v18.12.1.
Type ".help" for more information.
> globalThis.addEventListener()
Uncaught TypeError: globalThis.addEventListener is not a function

we should at least do

  private setupUnloadEvent (): void {
    globalThis.addEventListener?.('beforeunload', () => {
      this.flushAll()
    })
  }

however, we should probably provide a way for node implementations to flush:

Suggested change
private setupUnloadEvent (): void {
globalThis.addEventListener('beforeunload', () => {
this.flushAll()
})
}
private setupUnloadEvent (): void {
if (typeof globalThis.addEventListener === 'function') {
// "browser" usecase
globalThis.addEventListener('beforeunload', () => {
this.flushAll()
})
} else if (typeof globalThis.process?.on === 'function') {
// node usecase
globalThis.process.on('beforeExit', () => { this.flushAll() })
}
}


/**
* Pad events with default values
*
* @param {CountlyEvent} event - event to add defaults to
* @returns {CountlyEventData} - event with defaults added
*/
private addEventDefaults (event: CountlyEvent): CountlyEventData {
return { ...eventDefaults, ...event }
}

/**
* Setup the event accumulator for a key type for the first time.
*
* @param {CountlyEventData} eventData - event data
*/
private setupEventAccumulator (eventData: CountlyEventData): void {
const { key } = eventData
this.events.set(key, {
eventData,
// set start time to now. This will be updated when the event is flushed.
startTime: Date.now(),
// set a timeout to flush the event after the flush interval.
timeout: setTimeout(() => {
this.flush(key)
}, this.flushInterval)
})
}

/**
* Digest only the event data from an event.
*
* @param {CountlyEventData} newEventData - event data
*/
private digestEventData (newEventData: CountlyEventData): void {
Copy link
Member

Choose a reason for hiding this comment

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

nit: digest is a significantly overloaded term in our space and doesn't really communicate what this function does under most of the common definitions. Could we use accumulate (not accurate for new events) or process (vague), or even updateEventData (somewhat vague)?

A better description in the jsdoc that doesn't use the term "digest" could help a lot here.

const { key, count, segmentation } = newEventData
// if event is in the store, update the event data.
const eventStore = this.events.get(key)
if (eventStore == null) {
this.setupEventAccumulator(newEventData)
return
}
const { eventData } = eventStore
eventData.count += count
eventData.sum += 1
eventData.segmentation = { ...eventData.segmentation, ...segmentation }
}

/**
* Add an event to the accumulator
*
* @param {CountlyEvent} event - event to add
* @param {boolean} flush - optionally whether to flush the event immediately
*/
addEvent (event: CountlyEvent, flush: boolean = false): void {
const eventData = this.addEventDefaults(event)
const { key } = eventData

// validate event
if (key === '') {
throw new Error('Event key is required.')
}

this.digestEventData(eventData)

// flush the event if flush is true.
if (flush) {
this.flush(key)
}
}

/**
* Flush an event from the accumulator
*
* @param {string} key - event key
*/
flush (key: string): void {
Copy link
Member

Choose a reason for hiding this comment

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

should we have a flush_all?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

added with tests

const eventStore = this.events.get(key)
if (eventStore == null) {
return
}

const { eventData, startTime, timeout } = eventStore

// update duration to ms from start.
eventData.dur = Date.now() - startTime
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Member

Choose a reason for hiding this comment

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

I think we should remove duration from the submitted event though, because countly doesn't really display them the way we think they should.. maybe we should chat about this while taking a peek at existing webui data in our countly server

// add event to the async queue.
this.metricsService.add_event(eventData)
Copy link
Member

Choose a reason for hiding this comment

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

kinda-nit-kinda-not: calling metricsService directly here will force us to keep two classes up to date instead of having MetricsProvider be the primary abstraction. I'm fine with this for now because countly will probably be our metrics service for a while but we should try to stick to using MetricsProviders.addEvent() method instead.

i.e. constructor for this should take MetricsProvider instance as argument instead of the metricsService instance.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

we can discuss this.

clearTimeout(timeout)
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete
this.events.delete(key)
}

/**
* Flush all events from the accumulator
*/
flushAll (): void {
this.events.forEach((_, key) => {
this.flush(key)
})
}
}
12 changes: 7 additions & 5 deletions src/MetricsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import type { CountlyWebSdk, metricFeatures } from 'countly-sdk-web'
import type { consentTypes, consentTypesExceptAll } from '../types/index.js'
import { COUNTLY_SETUP_DEFAULTS } from './config.js'

import type { metricFeatures, CountlyWebSdk } from 'countly-sdk-web'
import type { CountlyNodeSdk } from 'countly-sdk-nodejs'
import type { consentTypes, consentTypesExceptAll } from './types/index.js'
import { EventAccumulator } from './EventAccumulator.js'
import type { StorageProvider } from './StorageProvider.js'

export interface MetricsProviderConstructorOptions<T> {
appKey: string
autoTrack?: boolean
interval?: number
max_events?: number
metricsService: T
queue_size?: number
session_update?: number
url?: string
metricsService: T
storageProvider?: StorageProvider | null
}

export default class MetricsProvider<T extends CountlyWebSdk | CountlyNodeSdk> {
public readonly accumulate: EventAccumulator
private readonly groupedFeatures: Record<consentTypes, metricFeatures[]> = this.mapAllEvents({
minimal: ['sessions', 'views', 'events'],
performance: ['crashes', 'apm'],
Expand All @@ -41,7 +42,8 @@ export default class MetricsProvider<T extends CountlyWebSdk | CountlyNodeSdk> {
const { autoTrack, metricsService, storageProvider } = serviceConfig
this.metricsService = metricsService
this.storageProvider = storageProvider ?? null

this.accumulate = new EventAccumulator(metricsService)
this.storageProvider = storageProvider ?? null
Copy link
Member

Choose a reason for hiding this comment

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

remove, we have this on line 44 already

Suggested change
this.storageProvider = storageProvider ?? null

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

bad merge, one is from you one is from me lol.

this.metricsService.init(serviceConfig)
Copy link
Member

Choose a reason for hiding this comment

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

we need to revert this. appKey is not a valid argument for metricsService.init

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

it's not typechecked as expected, I fixed it like:

    const { appKey, ...remainderConfig } = config
    const serviceConfig = {
      ...COUNTLY_SETUP_DEFAULTS,
      ...remainderConfig,
      app_key: appKey
    }

I should throw if appKey is missing?

Copy link
Member

Choose a reason for hiding this comment

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

yea we should. we can handle in a separate PR though.

this.metricsService.group_features(this.groupedFeatures)

Expand Down
5 changes: 2 additions & 3 deletions src/NodeMetricsProvider.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import Countly from 'countly-sdk-nodejs'

import type { MetricProviderOptionalConstructorArgs, WithOptional } from '../types/index.js'
import MetricsProvider, { MetricsProviderConstructorOptions } from './MetricsProvider.js'
import type { MetricProviderOptionalConstructorArgs, WithOptional } from './types/index.js'
import Countly from 'countly-sdk-nodejs'
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved

export class NodeMetricsProvider extends MetricsProvider<typeof Countly> {
constructor (args: WithOptional<MetricsProviderConstructorOptions<typeof Countly>, MetricProviderOptionalConstructorArgs>) {
Expand Down
2 changes: 1 addition & 1 deletion src/StorageProvider.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { consentTypes } from './types/index.js'
import type { consentTypes } from '../types/index.js'
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved

export interface StorageProviderInterface {
setStore: (values: consentTypes[]) => void
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,4 @@ export * from './config.js'
export * from './BrowserMetricsProvider.js'
export * from './MetricsProvider.js'
export * from './NodeMetricsProvider.js'
export * from './types/index.js'
export * from '../types/index.js'
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved
1 change: 0 additions & 1 deletion test/node.spec.ts

This file was deleted.

6 changes: 3 additions & 3 deletions test/node/NodeMetricsProvider.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import CountlyNodeSdk from 'countly-sdk-nodejs'

import { ensureCall, expect, sinon } from '../testUtils.js'
import CountlyNodeSdk from 'countly-sdk-nodejs'
import { NodeMetricsProvider } from '../../src/NodeMetricsProvider.js'
import type { StorageProvider } from '../../src/StorageProvider.js'
import type { consentTypes } from '../../src/types/index.js'
import type { consentTypes } from '../../types/index.js'
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved

const sandbox = sinon.createSandbox()

Expand All @@ -18,6 +17,7 @@ describe('NodeMetricsProvider', function () {
afterEach(function () {
sandbox.restore()
})

describe('instantiation', function () {
it('with defaults', function () {
const telemetry = new NodeMetricsProvider({ appKey: 'foo', url: 'bar' })
Expand Down
3 changes: 2 additions & 1 deletion test/testUtils.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import chai from 'chai'
import sinonChai from 'sinon-chai'
import sinon from 'sinon'
import sinonChai from 'sinon-chai'
whizzzkid marked this conversation as resolved.
Show resolved Hide resolved

chai.use(sinonChai)
const expect = chai.expect
Expand All @@ -20,6 +20,7 @@ export const ensureCall = <T extends readonly any[], R>(config: EnsureCallOption
expect(config.spy.getCall(config.callIndex).args[config.argsIndex ?? 0]).to.deep.equal(config.expectedArgs)
}
}

whizzzkid marked this conversation as resolved.
Show resolved Hide resolved
export {
expect,
chai,
Expand Down
12 changes: 12 additions & 0 deletions test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node", "mocha", "chai", "sinon"]
},
"include": [
"../src",
"../types",
"./**/*.ts",
"node/NodeMetricsProvider.spec.ts"
]
}
Comment on lines +1 to +12
Copy link
Member

Choose a reason for hiding this comment

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

+1

Loading