Skip to content

Commit

Permalink
fix: dont spam IPFS_INIT_FAILED events to countly (#2133)
Browse files Browse the repository at this point in the history
* fix: dont spam IPFS_INIT_FAILED events to countly

* fix(hof/functions): jsdoc types are valid

* fix(hofs/functions): types and add debounce function

* chore: cleanup code

* fix: typecheck

see https://github.com/ipfs/ipfs-webui/actions/runs/5418218369/jobs/9850182385\?pr\=2133

* test(hofs/functions): add tests

* fix: typecheck succeeds

* feat: fn first hofs, add param guards

* chore: wrap check for limited addEvent
  • Loading branch information
SgtPooki committed Jul 1, 2023
1 parent c5eaecc commit b8cf74a
Show file tree
Hide file tree
Showing 8 changed files with 385 additions and 25 deletions.
49 changes: 44 additions & 5 deletions src/bundles/analytics.js
Expand Up @@ -10,6 +10,7 @@ import { ACTIONS as CONIFG } from './config-save.js'
import { ACTIONS as INIT } from './ipfs-provider.js'
import { ACTIONS as EXP } from './experiments.js'
import { getDeploymentEnv } from '../env.js'
import { onlyOnceAfter } from '../lib/hofs/functions.js'

/**
* @typedef {import('./ipfs-provider').Init} Init
Expand Down Expand Up @@ -148,6 +149,45 @@ function removeConsent (consent, store) {
}
}

/**
* Add an event to countly.
*
* @param {Object} param0
* @param {string} param0.id
* @param {number} param0.duration
*/
function addEvent ({ id, duration }) {
root.Countly.q.push(['add_event', {
key: id,
count: 1,
dur: duration
}])
}

/**
* You can limit how many times an event is recorded by adding them here.
*/
const addEventLimitedFns = new Map([
['IPFS_INIT_FAILED', onlyOnceAfter(addEvent, 5)]
])

/**
* Add an event to by using a limited addEvent fn if one is defined, or calling
* `addEvent` directly.
*
* @param {Object} param0
* @param {string} param0.id
* @param {number} param0.duration
*/
function addEventWrapped ({ id, duration }) {
const fn = addEventLimitedFns.get(id)
if (fn) {
fn({ id, duration })
} else {
addEvent({ id, duration })
}
}

/**
* @typedef {import('redux-bundler').Selectors<typeof selectors>} Selectors
*/
Expand Down Expand Up @@ -306,6 +346,7 @@ const createAnalyticsBundle = ({
* @param {Store} store
*/
init: async (store) => {
// LogRocket.init('sfqf1k/ipfs-webui')
// test code sets a mock Counly instance on the global.
if (!root.Countly) {
root.Countly = {}
Expand Down Expand Up @@ -375,16 +416,14 @@ const createAnalyticsBundle = ({
const payload = parseTask(action)
if (payload) {
const { id, duration, error } = payload
root.Countly.q.push(['add_event', {
key: id,
count: 1,
dur: duration
}])
addEventWrapped({ id, duration })

// Record errors. Only from explicitly selected actions.
if (error) {
root.Countly.q.push(['add_log', action.type])
root.Countly.q.push(['log_error', error])
// LogRocket.error(error)
// logger.error('Error in action', action.type, error)
}
}

Expand Down
6 changes: 5 additions & 1 deletion src/bundles/ipfs-provider.js
Expand Up @@ -366,7 +366,11 @@ const actions = {
}

const result = await getIpfs({
// @ts-ignore - TS can't seem to infer connectionTest option
/**
*
* @param {import('kubo-rpc-client').IPFSHTTPClient} ipfs
* @returns {Promise<boolean>}
*/
connectionTest: async (ipfs) => {
// ipfs connection is working if can we fetch the bw stats.
// See: https://github.com/ipfs-shipyard/ipfs-webui/issues/835#issuecomment-466966884
Expand Down
87 changes: 70 additions & 17 deletions src/bundles/retry-init.js
Expand Up @@ -2,17 +2,46 @@ import { createSelector } from 'redux-bundler'
import { ACTIONS } from './ipfs-provider.js'

/**
* @typedef {import('./ipfs-provider').Message} Message
*
* @typedef {Object} AppIdle
* @property {'APP_IDLE'} type
*
* @typedef {Object} DisableRetryInit
* @property {'RETRY_INIT_DISABLE'} type
*
* @typedef {import('./ipfs-provider').Message | AppIdle | DisableRetryInit} Message
*
* @typedef {Object} Model
* @property {number} [startedAt]
* @property {number} [failedAt]
* @property {number} [tryCount]
* @property {boolean} [needToRetry]
* @property {number} [intervalId]
* @property {boolean} currentlyTrying
*
* @typedef {Object} State
* @property {Model} retryInit
*
*/

const retryTime = 2500
const maxRetries = 5

/**
* @returns {Model}
*/
const initialState = () => ({ tryCount: 0, needToRetry: true, startedAt: undefined, failedAt: undefined, currentlyTrying: false })

/**
* @returns {Model}
*/
const disabledState = () => {
return ({ ...initialState(), needToRetry: false })
}

// We ask for the stats every few seconds, so that gives a good indication
// that ipfs things are working (or not), without additional polling of the api.

const retryInit = {
name: 'retryInit',

Expand All @@ -21,19 +50,30 @@ const retryInit = {
* @param {Message} action
* @returns {Model}
*/
reducer: (state = {}, action) => {
reducer: (state = initialState(), action) => {
switch (action.type) {
case 'RETRY_INIT_DISABLE': {
return disabledState()
}
case ACTIONS.IPFS_INIT: {
const { task } = action
switch (task.status) {
case 'Init': {
return { ...state, startedAt: Date.now() }
const startedAt = Date.now()
return {
...state,
currentlyTrying: true,
startedAt, // new init attempt, set startedAt
tryCount: (state.tryCount || 0) + 1 // increase tryCount
}
}
case 'Exit': {
if (task.result.ok) {
return state
// things are okay, reset the state
return disabledState()
} else {
return { ...state, failedAt: Date.now() }
const failedAt = Date.now()
return { ...state, failedAt, currentlyTrying: false }
}
}
default: {
Expand All @@ -48,27 +88,40 @@ const retryInit = {
},

/**
* @param {State} state
* @returns {(context: import('redux-bundler').Context<Model, Message, unknown>) => void}
*/
selectInitStartedAt: state => state.retryInit.startedAt,
doDisableRetryInit: () => (context) => {
// we should emit IPFS_INIT_FAILED at this point
context.dispatch({
type: 'RETRY_INIT_DISABLE'
})
},

/**
* @param {State} state
*/
selectInitFailedAt: state => state.retryInit.failedAt,
selectRetryInitState: state => state.retryInit,

/**
* This is continuously called by the app
* @see https://reduxbundler.com/api-reference/bundle#bundle.reactx
*/
reactConnectionInitRetry: createSelector(
'selectAppTime',
'selectInitStartedAt',
'selectInitFailedAt',
'selectAppTime', // this is the current time of the app.. we need this to compare against startedAt
'selectIpfsReady',
'selectRetryInitState',
/**
* @param {number} appTime
* @param {number|void} startedAt
* @param {number|void} failedAt
* @param {number|void} appTime
* @param {boolean} ipfsReady
* @param {Model} state
*/
(appTime, startedAt, failedAt) => {
if (!failedAt || failedAt < startedAt) return false
if (appTime - failedAt < 3000) return false
(appTime, ipfsReady, { failedAt, tryCount, needToRetry, currentlyTrying }) => {
if (currentlyTrying) return false // if we are currently trying, don't try again
if (!appTime) return false // This should never happen; see https://reduxbundler.com/api-reference/included-bundles#apptimebundle
if (!needToRetry) return false // we should not be retrying, so don't.
if (tryCount != null && tryCount > maxRetries) return { actionCreator: 'doDisableRetryInit' }
if (ipfsReady) return { actionCreator: 'doDisableRetryInit' } // when IPFS is ready, we don't need to retry
if (!failedAt || appTime - failedAt < retryTime) return false
return { actionCreator: 'doTryInitIpfs' }
}
)
Expand Down
23 changes: 23 additions & 0 deletions src/lib/guards.js
@@ -0,0 +1,23 @@
/**
*
* @param {any} value
* @param {boolean} [throwOnFalse]
* @returns {value is Function}
*/
export function isFunction (value, throwOnFalse = true) {
if (typeof value === 'function') { return true }
if (throwOnFalse) { throw new TypeError('Expected a function') }
return false
}

/**
*
* @param {any} value
* @param {boolean} [throwOnFalse]
* @returns {value is number}
*/
export function isNumber (value, throwOnFalse = true) {
if (typeof value === 'number') { return true }
if (throwOnFalse) { throw new TypeError('Expected a number') }
return false
}
31 changes: 31 additions & 0 deletions src/lib/guards.test.js
@@ -0,0 +1,31 @@
import { isFunction, isNumber } from './guards.js'

describe('lib/guards', function () {
describe('isFunction', function () {
it('should return true if the passed value is a function', function () {
expect(isFunction(() => {})).toBe(true)
})

it('should throw an error if the passed value is not a function', function () {
expect(() => isFunction('not a function')).toThrow(TypeError)
})

it('should return false if the passed value is not a function and throwOnFalse is false', function () {
expect(isFunction('not a function', false)).toBe(false)
})
})

describe('isNumber', function () {
it('should return true if the passed value is a function', function () {
expect(isNumber(1)).toBe(true)
})

it('should throw an error if the passed value is not a function', function () {
expect(() => isNumber('not a number')).toThrow(TypeError)
})

it('should return false if the passed value is not a function and throwOnFalse is false', function () {
expect(isNumber('not a number', false)).toBe(false)
})
})
})
92 changes: 92 additions & 0 deletions src/lib/hofs/functions.js
@@ -0,0 +1,92 @@
import { isFunction, isNumber } from '../guards.js'

/**
* This method creates a function that invokes func once it’s called n or more times.
* @see https://youmightnotneed.com/lodash#after
* @template A
* @template R
* @param {number} times
* @param {(...args: A[]) => R} fn
* @returns {(...args: A[]) => void | R}
*/
export const after = (fn, times) => {
isFunction(fn) && isNumber(times)
let counter = 0
/**
* @type {(...args: A[]) => void | R}
*/
return (...args) => {
counter++
if (counter >= times) {
return fn(...args)
}
}
}

/**
* @see https://youmightnotneed.com/lodash#once
* @template A
* @template R
* @param {(...args: A[]) => R} fn
* @returns {(...args: A[]) => R}
*/
export const once = (fn) => {
isFunction(fn)
let called = false
/**
* @type {R}
*/
let result

/**
* @type {(...args: A[]) => R}
*/
return (...args) => {
if (!called) {
result = fn(...args)
called = true
}
return result
}
}

/**
* @see https://youmightnotneed.com/lodash#debounce
*
* @template A
* @template R
* @param {(...args: A[]) => R} fn - The function to debounce.
* @param {number} delay - The number of milliseconds to delay.
* @param {Object} options
* @param {boolean} [options.leading]
* @returns {(...args: A[]) => void}
*/
export const debounce = (fn, delay, { leading = false } = {}) => {
isFunction(fn) && isNumber(delay)
/**
* @type {NodeJS.Timeout}
*/
let timerId

return (...args) => {
if (!timerId && leading) {
fn(...args)
}
clearTimeout(timerId)

timerId = setTimeout(() => fn(...args), delay)
}
}

/**
* Call a function only once on the nth time it was called
* @template A
* @template R
* @param {number} nth - The nth time the function should be called when it is actually invoked.
* @param {(...args: A[]) => R} fn - The function to call.
* @returns {(...args: A[]) => void | R}
*/
export const onlyOnceAfter = (fn, nth) => {
isFunction(fn) && isNumber(nth)
return after(once(fn), nth)
}

0 comments on commit b8cf74a

Please sign in to comment.