diff --git a/index.ts b/index.ts index 3bd9d49..074afd2 100644 --- a/index.ts +++ b/index.ts @@ -1,72 +1,67 @@ -import { PluginEvent, PluginMeta, PluginAttachment } from '@posthog/plugin-scaffold' +import { Plugin } from '@posthog/plugin-scaffold' declare var posthog: { - capture: (eventName: string, properties: Record) => void + capture: (eventName: string, properties: Record) => void } -async function processEvent(event: PluginEvent, { storage }: PluginMeta) { - if (['session_started', 'session_ended'].includes(event.event)) { - return event +type SessionTrackerPlugin = Plugin<{ + config: { + sessionLength: string + sessionStartEvent: string + sessionEndEvent: string } - - const THIRTY_MINUTES = 1000*60*30 - - // Last event by the user - const userLastSeen = await storage.get(`last_seen_${event.distinct_id}`, 0) as number - - // Last registered session_start by the user - const userLastSessionStarted = await storage.get(`last_session_started_${event.distinct_id}`, 0) as number - - let isFirstEventInSession = false - - if (!event.properties) { - event['properties'] = {} + global: { + sessionLength: number + sessionStartEvent: string + sessionEndEvent: string } + jobs: { + checkIfSessionIsOver: { distinct_id: string } + } +}> - const timestamp = event.timestamp - - if (timestamp) { - const parsedTimestamp = new Date(timestamp).getTime() - const timeSinceLastSeen = parsedTimestamp - userLastSeen - isFirstEventInSession = timeSinceLastSeen > THIRTY_MINUTES - - storage.set(`last_seen_${event.distinct_id}`, parsedTimestamp) - - // If it's been over 30min since the user had an event, it's a new session - if (isFirstEventInSession) { - posthog.capture( - 'session_started', - { - distinct_id: event.distinct_id, - time_since_last_seen: !userLastSeen ? 0 : timeSinceLastSeen, - // backdate to when session _actually_ started - timestamp: new Date(timestamp).toISOString(), - trigger_event: event.event - } - ) - storage.set(`last_session_started_${event.distinct_id}`, parsedTimestamp) +export const setupPlugin: SessionTrackerPlugin['setupPlugin'] = ({ global, config }) => { + global.sessionLength = parseInt(config.sessionLength) || 30 + global.sessionStartEvent = config.sessionStartEvent || 'Session start' + global.sessionEndEvent = config.sessionEndEvent || 'Session end' +} - // If we've started a new session, another session must have ended - if (userLastSessionStarted) { - posthog.capture( - 'session_ended', - { - // backdate the session end to the timestamp last event before the new session - timestamp: new Date(userLastSeen).toISOString(), - distinct_id: event.distinct_id, - session_duration: userLastSeen - userLastSessionStarted - } - ) - } - } - +export const onEvent: SessionTrackerPlugin['onEvent'] = async (event, { cache, global, jobs }) => { + // skip this for the session start/end events + if (event.event === global.sessionStartEvent || event.event === global.sessionEndEvent) { + return + } + // check if we're the first one to increment this key in the last ${global.sessionLength} minutes + if ((await cache.incr(`session_${event.distinct_id}`)) === 1) { + // if so, dispatch a session start event + posthog.capture(global.sessionStartEvent, { distinct_id: event.distinct_id, timestamp: event.timestamp }) + // and launch a job to check in 30min if the session is still alive + await jobs.checkIfSessionIsOver({ distinct_id: event.distinct_id }).runIn(global.sessionLength, 'minutes') } + // make the key expire in ${global.sessionLength} min + await cache.expire(`session_${event.distinct_id}`, global.sessionLength * 60) + await cache.set( + `last_timestamp_${event.distinct_id}`, + event.timestamp || event.now || event.sent_at || new Date().toISOString() + ) +} - event.properties['is_first_event_in_session'] = isFirstEventInSession +export const jobs: SessionTrackerPlugin['jobs'] = { + // a background job to check if a session is still in progress + checkIfSessionIsOver: async ({ distinct_id }, { jobs, cache, global }) => { + // check if there's a key that has not expired + const ping = await cache.get(`session_${distinct_id}`, undefined) + if (!ping) { + // if it expired, dispatch the session end event + const timestamp = + (await cache.get(`last_timestamp_${distinct_id}`, undefined)) || + new Date(new Date().valueOf() - global.sessionLength * 60000).toISOString() - return event + await cache.set(`last_timestamp_${distinct_id}`, undefined) + posthog.capture(global.sessionEndEvent, { distinct_id, timestamp }) + } else { + // if the key is still there, check again in a minute + await jobs.checkIfSessionIsOver({ distinct_id }).runIn(1, 'minute') + } + }, } - -module.exports = { - processEvent, -} \ No newline at end of file diff --git a/package.json b/package.json index da319dc..1e50165 100644 --- a/package.json +++ b/package.json @@ -25,11 +25,10 @@ } }, "devDependencies": { - "jest": "^26.6.3", - "posthog-plugins": "^0.2.2" + "jest": "^26.6.3" }, "dependencies": { - "@posthog/plugin-scaffold": "^0.5.0", + "@posthog/plugin-scaffold": "^0.10.0", "@types/jest": "^26.0.22", "@types/node": "^14.14.41", "ts-jest": "^26.5.5", diff --git a/plugin.json b/plugin.json index 17d3a89..8d17cfc 100644 --- a/plugin.json +++ b/plugin.json @@ -1,11 +1,30 @@ { - "name": "Session Tracker", - "url": "https://github.com/PostHog/session-tracker-plugin", - "description": "Advanced session tracking for PostHog.", - "main": "index.ts", - "config": [ - { - "markdown": "## Important Note For Self-Hosted Users \nThis plugin leverages database storage to work. We estimate that it will use about 3MB of storage for every 10000 users tracked." - } - ] + "name": "Session Tracker", + "url": "https://github.com/PostHog/session-tracker-plugin", + "description": "Advanced session tracking for PostHog.", + "main": "index.ts", + "posthogVersion": ">= 1.25.0", + "config": [ + { + "key": "sessionLength", + "name": "Session length in minutes", + "type": "string", + "default": "30", + "required": true + }, + { + "key": "sessionStartEvent", + "name": "Session start event", + "type": "string", + "default": "Session started", + "required": true + }, + { + "key": "sessionEndEvent", + "name": "Session end event", + "type": "string", + "default": "Session ended", + "required": true + } + ] } diff --git a/yarn.lock b/yarn.lock index 1418143..b9406a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -466,22 +466,10 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@maxmind/geoip2-node@^2.3.1": - version "2.3.2" - resolved "https://registry.yarnpkg.com/@maxmind/geoip2-node/-/geoip2-node-2.3.2.tgz#a0aaf8452693491e815021fe16e692959490ba6d" - integrity sha512-a1TSZt3uWe7yrgE2WMdTItK6XuG2Aj125cAXA+JuT2nomv3oqIK8XXg9iJVpzNXAYRV72XO0+zY+3HluOGgF3w== - dependencies: - camelcase-keys "^6.0.1" - ip6addr "^0.2.3" - lodash.set "^4.3.2" - maxmind "^4.2.0" - -"@posthog/plugin-scaffold@^0.5.0": - version "0.5.0" - resolved "https://registry.yarnpkg.com/@posthog/plugin-scaffold/-/plugin-scaffold-0.5.0.tgz#0d46d0f39700ebf3cf613ba80a2c9a4dc6d13805" - integrity sha512-gE12hy/gYdMOYLP/PhbC2XfqvCeoXrdkFeR9HFfSK31RWU+aW+JMK7pi+CATatXfNpMCvjGxIYJk3QBPwm0WDA== - dependencies: - "@maxmind/geoip2-node" "^2.3.1" +"@posthog/plugin-scaffold@^0.10.0": + version "0.10.0" + resolved "https://registry.yarnpkg.com/@posthog/plugin-scaffold/-/plugin-scaffold-0.10.0.tgz#e80f57c5d3833753632607bed3ca71ebf272b12b" + integrity sha512-c8lNzQTBMJ0X3SCjcaD+mXZIx2thc+ptf8G4gbfT9YBFFD6TMaxs+/APMUab2aRJRbVwOsCGj9poxwuF4wxPpA== "@sinonjs/commons@^1.7.0": version "1.8.1" @@ -901,15 +889,6 @@ callsites@^3.0.0: resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camelcase-keys@^6.0.1: - version "6.2.2" - resolved "https://registry.yarnpkg.com/camelcase-keys/-/camelcase-keys-6.2.2.tgz#5e755d6ba51aa223ec7d3d52f25778210f9dc3c0" - integrity sha512-YrwaA0vEKazPBkn0ipTiMpSajYDSe+KjQfrjhcBMxJt/znbvlHd8Pw/Vamaz5EB4Wfhs3SUR3Z9mwRu/P3s3Yg== - dependencies: - camelcase "^5.3.1" - map-obj "^4.0.0" - quick-lru "^4.0.1" - camelcase@^5.0.0, camelcase@^5.3.1: version "5.3.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" @@ -1657,14 +1636,6 @@ ip-regex@^2.1.0: resolved "https://registry.yarnpkg.com/ip-regex/-/ip-regex-2.1.0.tgz#fa78bf5d2e6913c911ce9f819ee5146bb6d844e9" integrity sha1-+ni/XS5pE8kRzp+BnuUUa7bYROk= -ip6addr@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/ip6addr/-/ip6addr-0.2.3.tgz#660df0d27092434f0aadee025aba8337c6d7d4d4" - integrity sha512-qA9DXRAUW+lT47/i/4+Q3GHPwZjGt/atby1FH/THN6GVATA6+Pjp2nztH7k6iKeil7hzYnBwfSsxjthlJ8lJKw== - dependencies: - assert-plus "^1.0.0" - jsprim "^1.4.0" - is-accessor-descriptor@^0.1.6: version "0.1.6" resolved "https://registry.yarnpkg.com/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz#a9e12cb3ae8d876727eeef3843f8a0897b5c98d6" @@ -2343,7 +2314,7 @@ json5@^2.1.2: dependencies: minimist "^1.2.5" -jsprim@^1.2.2, jsprim@^1.4.0: +jsprim@^1.2.2: version "1.4.1" resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" integrity sha1-MT5mvB5cwG5Di8G3SZwuXFastqI= @@ -2407,11 +2378,6 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" -lodash.set@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23" - integrity sha1-2HV7HagH3eJIFrDWqEvqGnYjCyM= - lodash.sortby@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" @@ -2458,11 +2424,6 @@ map-cache@^0.2.2: resolved "https://registry.yarnpkg.com/map-cache/-/map-cache-0.2.2.tgz#c32abd0bd6525d9b051645bb4f26ac5dc98a0dbf" integrity sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8= -map-obj@^4.0.0: - version "4.2.1" - resolved "https://registry.yarnpkg.com/map-obj/-/map-obj-4.2.1.tgz#e4ea399dbc979ae735c83c863dd31bdf364277b7" - integrity sha512-+WA2/1sPmDj1dlvvJmB5G6JKfY9dpn7EVBUL06+y6PoljPkh+6V1QihwxNkbcGxCRjt2b0F9K0taiCuo7MbdFQ== - map-visit@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/map-visit/-/map-visit-1.0.0.tgz#ecdca8f13144e660f1b5bd41f12f3479d98dfb8f" @@ -2470,14 +2431,6 @@ map-visit@^1.0.0: dependencies: object-visit "^1.0.0" -maxmind@^4.2.0: - version "4.3.1" - resolved "https://registry.yarnpkg.com/maxmind/-/maxmind-4.3.1.tgz#b20103f19e9fc12e4d1618814380d89a00f65770" - integrity sha512-0CxAgwWIwQy4zF1/qCMOeUPleMTYgfnsuIsZ4Otzx6hzON4PCqivPiH6Kz7iWrC++KOGCbSB3nxkJMvDEdWt6g== - dependencies: - mmdb-lib "1.2.0" - tiny-lru "7.0.6" - merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -2552,11 +2505,6 @@ mkdirp@1.x: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== -mmdb-lib@1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/mmdb-lib/-/mmdb-lib-1.2.0.tgz#0ecd93f4942f65a2d09be0502fa9126939606727" - integrity sha512-3XYebkStxqCgWJjsmT9FCaE19Yi4Tvs2SBPKhUks3rJJh52oF1AKAd9kei+LTutud3a6RCZ0o2Um96Fn7o3zVA== - ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" @@ -2814,11 +2762,6 @@ posix-character-classes@^0.1.0: resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" integrity sha1-AerA/jta9xoqbAL+q7jB/vfgDqs= -posthog-plugins@^0.2.2: - version "0.2.2" - resolved "https://registry.yarnpkg.com/posthog-plugins/-/posthog-plugins-0.2.2.tgz#84bf2c7c50a3fea7a585187c9febda59ab774dd9" - integrity sha512-AFOKFexaCwKQ7+zjpM7g6Cj/75YF3QMx513xFahCHGAZrqrZuIPyY+xPvg1U4TA0J4P5OfzPz5tQYmPYcuppyw== - prelude-ls@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.1.2.tgz#21932a549f5e52ffd9a827f570e04be62a97da54" @@ -2865,11 +2808,6 @@ qs@~6.5.2: resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== -quick-lru@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/quick-lru/-/quick-lru-4.0.1.tgz#5b8878f113a58217848c6482026c73e1ba57727f" - integrity sha512-ARhCpm70fzdcvNQfPoy49IaanKkTlRWF2JMzqhcJbhSFRZv7nPTvZJdcY7301IPmvW+/p0RgIWnQDLJxifsQ7g== - react-is@^17.0.1: version "17.0.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.1.tgz#5b3531bd76a645a4c9fb6e693ed36419e3301339" @@ -3366,11 +3304,6 @@ throat@^5.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-5.0.0.tgz#c5199235803aad18754a667d659b5e72ce16764b" integrity sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA== -tiny-lru@7.0.6: - version "7.0.6" - resolved "https://registry.yarnpkg.com/tiny-lru/-/tiny-lru-7.0.6.tgz#b0c3cdede1e5882aa2d1ae21cb2ceccf2a331f24" - integrity sha512-zNYO0Kvgn5rXzWpL0y3RS09sMK67eGaQj9805jlK9G6pSadfriTczzLHFXa/xcW4mIRfmlB9HyQ/+SgL0V1uow== - tmpl@1.0.x: version "1.0.4" resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"