From d9d778de2d31e77078877f1443bfb031f89b5a45 Mon Sep 17 00:00:00 2001 From: Francois Best Date: Sat, 14 Mar 2020 14:11:10 +0100 Subject: [PATCH] feat: Initial commit Extracted code from monorepo --- README.md | 16 +-- package.json | 13 ++- src/index.ts | 275 ++++++++++++++++++++++++++++++++++++++++++++++++++- yarn.lock | 17 ++++ 4 files changed, 307 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 4f3db0e..7fc74fa 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ -# @chiffre/template-library +# @chiffre/analytics-processing -[![NPM](https://img.shields.io/npm/v/@chiffre/template-library?color=red)](https://www.npmjs.com/package/@chiffre/template-library) -[![MIT License](https://img.shields.io/github/license/chiffre-io/template-library.svg?color=blue)](https://github.com/chiffre-io/template-library/blob/next/LICENSE) -[![Continuous Integration](https://github.com/chiffre-io/template-library/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/chiffre-io/template-library/actions) -[![Coverage Status](https://coveralls.io/repos/github/chiffre-io/template-library/badge.svg?branch=next)](https://coveralls.io/github/chiffre-io/template-library?branch=next) -[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=chiffre-io/template-library)](https://dependabot.com) +[![NPM](https://img.shields.io/npm/v/@chiffre/analytics-processing?color=red)](https://www.npmjs.com/package/@chiffre/analytics-processing) +[![MIT License](https://img.shields.io/github/license/chiffre-io/analytics-processing.svg?color=blue)](https://github.com/chiffre-io/analytics-processing/blob/next/LICENSE) +[![Continuous Integration](https://github.com/chiffre-io/analytics-processing/workflows/Continuous%20Integration/badge.svg?branch=next)](https://github.com/chiffre-io/analytics-processing/actions) +[![Coverage Status](https://coveralls.io/repos/github/chiffre-io/analytics-processing/badge.svg?branch=next)](https://coveralls.io/github/chiffre-io/analytics-processing?branch=next) +[![Dependabot Status](https://api.dependabot.com/badges/status?host=github&repo=chiffre-io/analytics-processing)](https://dependabot.com) -Template for Chiffre libraries +Transform raw analytics data points into insightful metrics ## License -[MIT](https://github.com/chiffre-io/template-library/blob/next/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com). +[MIT](https://github.com/chiffre-io/analytics-processing/blob/next/LICENSE) - Made with ❤️ by [François Best](https://francoisbest.com). diff --git a/package.json b/package.json index aaf20ea..7ba6a03 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "@chiffre/template-library", + "name": "@chiffre/analytis-processing", "version": "0.0.0-semantically-released", - "description": "Template for Chiffre libraries", + "description": "Transform raw analytics data points into insightful metrics", "main": "dist/index.js", "license": "MIT", "author": { @@ -11,11 +11,11 @@ }, "repository": { "type": "git", - "url": "https://github.com/chiffre-io/template-library" + "url": "https://github.com/chiffre-io/analytis-processing" }, "keywords": [ "chiffre", - "template" + "analytics" ], "publishConfig": { "access": "public" @@ -28,7 +28,10 @@ "build": "run-s build:clean build:ts", "ci": "run-s build test" }, - "dependencies": {}, + "dependencies": { + "@chiffre/analytics-core": "^1.0.1", + "bowser": "^2.9.0" + }, "devDependencies": { "@commitlint/config-conventional": "^8.3.4", "@types/jest": "^25.1.4", diff --git a/src/index.ts b/src/index.ts index b21024d..f671564 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1 +1,274 @@ -export default (name: string) => `Hello, ${name} !` +import { + BrowserEvent, + SessionData, + GenericEvent, + isSessionStartEvent, + isGenericStringEvent, + isGenericNumberEvent, + isGenericNumbersEvent, + isGenericStringsEvent, + isBrowserEvent, + isSessionEndEvent +} from '@chiffre/analytics-core' +import Bowser from 'bowser' + +export interface LeaderboardEntry { + key: K + score: number + percent: number +} + +export class CounterMap { + private _map: Map + + constructor() { + this._map = new Map() + } + + public count(key: K) { + this._map.set(key, (this._map.get(key) || 0) + 1) + } + + public get leaderboard(): LeaderboardEntry[] { + const sum = Array.from(this._map.values()).reduce((s, c) => s + c, 0) + return Array.from(this._map.entries()) + .map(([key, count]) => ({ + key, + score: count, + percent: (100 * count) / sum + })) + .sort((a, b) => b.score - a.score) + } +} + +// -- + +function findSessionStart(events: BrowserEvent[]) { + return events.find(isSessionStartEvent) +} + +export interface NumericStats { + min: number + max: number + avg: number +} + +export interface ReturningVisitorInfo { + info: SessionData + timeSinceLastVisit: number + session: E[] +} + +export class BrowserEventsProcessor { + private _sessionMap: Map + browsers: CounterMap + pageCount: CounterMap + referrers: CounterMap + userAgents: CounterMap + lang: CounterMap + os: CounterMap + osWithVersion: CounterMap + viewportWidth: CounterMap + + constructor() { + this._sessionMap = new Map() + this.pageCount = new CounterMap() + this.referrers = new CounterMap() + this.userAgents = new CounterMap() + this.os = new CounterMap() + this.osWithVersion = new CounterMap() + this.lang = new CounterMap() + this.viewportWidth = new CounterMap() + this.browsers = new CounterMap() + } + + public process(event: BrowserEvent) { + if (!isBrowserEvent(event) || !event.data) { + return + } + const key = event.data.sid + const sessionEvents = this._sessionMap.get(key) || [] + this._sessionMap.set( + key, + [...sessionEvents, event].sort((a, b) => a.time - b.time) + ) + this.pageCount.count(event.data.path) + if (isSessionStartEvent(event)) { + const ua = Bowser.parse(event.data.ua) + this.referrers.count(event.data.ref) + this.userAgents.count( + `${ua.browser.name} ${ua.browser.version || '(unknown)'}` + ) + this.osWithVersion.count(`${ua.os.name} ${ua.os.version || '(unknown)'}`) + this.os.count(ua.os.name || 'N.A.') + this.lang.count(event.data.lang) + this.viewportWidth.count(event.data.vp.w) + this.browsers.count(ua.browser.name || 'N.A.') + } + } + + public get sessions(): Map { + return this._sessionMap + } + + public get sessionDurations(): Map { + return new Map( + Array.from(this._sessionMap.entries()) + .map<[string, number]>(([sid, events]) => { + if (events.length === 0) { + return [sid, 0] + } + // For events sorted by time: + const min = events[0].time + const max = events[events.length - 1].time + // For unsorted events: + // const { min, max } = events.reduce( + // ({ min, max }, event) => ({ + // max: Math.max(max, event.time), + // min: Math.min(min, event.time) + // }), + // { min: Infinity, max: 0 } + // ) + return [sid, max - min] + }) + .sort((a, b) => a[1] - b[1]) + ) + } + + public get returningVisitors(): Map< + string, + ReturningVisitorInfo + > { + return new Map( + Array.from(this.sessions.entries()) + .filter(([_sid, events]) => { + // Keep only sessions with a start event with last visited date + const start = findSessionStart(events) + return start && start.data.lvd + }) + .map<[string, ReturningVisitorInfo]>(([sid, events]) => { + const start = findSessionStart(events)! + return [ + sid, + { + info: start.data, + session: events, + timeSinceLastVisit: + start.time - new Date(start.data.lvd!).valueOf() + } + ] + }) + ) + } + + public get timeOnSite(): NumericStats { + if (this.sessions.size === 0) { + return { + min: 0, + max: 0, + avg: 0 + } + } + const sessionLengths = Array.from(this.sessions.values()).map( + session => session[session.length - 1].time - session[0].time + ) + const { min, max, sum } = sessionLengths.reduce( + ({ min, max, sum }, d) => ({ + min: Math.min(d, min), + max: Math.max(d, max), + sum: d + sum + }), + { min: Infinity, max: 0, sum: 0 } + ) + return { + min, + max, + avg: sum / sessionLengths.length + } + } + + public get timeOnPage(): Map { + const rawDurations = new Map() + for (const session of this.sessions.values()) { + interface ReduceVisitor { + currentPath?: string + startTime?: number + } + session.reduce((visitor, event, i) => { + const eventPath = event.data?.path || 'N.A.' + if (i === 0) { + return { + currentPath: eventPath, + startTime: event.time + } + } + if ( + event.data!.path === visitor.currentPath || + isSessionEndEvent(event) + ) { + return visitor + } + const key = visitor.currentPath || 'N.A.' + const timeOnPage = event.time - visitor.startTime! + rawDurations.set(key, [...(rawDurations.get(key) || []), timeOnPage]) + return { + currentPath: eventPath, + startTime: event.time + } + }, {}) + } + return new Map( + Array.from(rawDurations.entries()).map(([path, durations]) => { + const { min, max, sum } = durations.reduce( + ({ min, max, sum }, d) => ({ + min: Math.min(d, min), + max: Math.max(d, max), + sum: d + sum + }), + { min: Infinity, max: 0, sum: 0 } + ) + return [ + path, + { + min, + max, + avg: sum / durations.length + } + ] + }) + ) + } + + public get timeOnPageLeaderboard(): LeaderboardEntry[] { + const sum = Array.from(this.timeOnPage.values()).reduce( + (s, c) => s + c.avg, + 0 + ) + return Array.from(this.timeOnPage.entries()) + .map(([key, stats]) => ({ + key, + score: stats.avg, + percent: (100 * stats.avg) / sum + })) + .sort((a, b) => b.score - a.score) + } +} + +// -- + +export function extractGenericsNames(events: GenericEvent[]) { + const map = new Map() + for (const event of events) { + if (isGenericNumberEvent(event) || isGenericStringEvent(event)) { + const existing = map.get(event.data.name) || [] + map.set(event.data.name, [...existing, event]) + } + if (isGenericNumbersEvent(event) || isGenericStringsEvent(event)) { + for (const datapoint of event.data) { + const existing = map.get(datapoint.name) || [] + map.set(datapoint.name, [...existing, event]) + } + } + } + return map +} diff --git a/yarn.lock b/yarn.lock index a3def73..b457548 100644 --- a/yarn.lock +++ b/yarn.lock @@ -150,6 +150,13 @@ resolved "https://registry.yarnpkg.com/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz#75a2e8b51cb758a7553d6804a5932d7aace75c39" integrity sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw== +"@chiffre/analytics-core@^1.0.1": + version "1.0.1" + resolved "https://registry.yarnpkg.com/@chiffre/analytics-core/-/analytics-core-1.0.1.tgz#519f49162266f93fb4598c9f058f999b83a958fa" + integrity sha512-OWrXpePCWreNr4ioryRv8YGyCANChiHiyaR+GPcyP+B388sE6a6NXN4Cb7o67Ow2TuVOkxdLlnKNcr+h0Yecxg== + dependencies: + nanoid "^2.1.11" + "@cnakazawa/watch@^1.0.3": version "1.0.4" resolved "https://registry.yarnpkg.com/@cnakazawa/watch/-/watch-1.0.4.tgz#f864ae85004d0fcab6f50be9141c4da368d1656a" @@ -846,6 +853,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +bowser@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/bowser/-/bowser-2.9.0.tgz#3bed854233b419b9a7422d9ee3e85504373821c9" + integrity sha512-2ld76tuLBNFekRgmJfT2+3j5MIrP6bFict8WAIT3beq+srz1gcKNAdNKMqHqauQt63NmAa88HfP1/Ypa9Er3HA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" @@ -2871,6 +2883,11 @@ ms@^2.1.1: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +nanoid@^2.1.11: + version "2.1.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280" + integrity sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA== + nanomatch@^1.2.9: version "1.2.13" resolved "https://registry.yarnpkg.com/nanomatch/-/nanomatch-1.2.13.tgz#b87a8aa4fc0de8fe6be88895b38983ff265bd119"