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

fix: dont spam IPFS_INIT_FAILED events to countly #2133

Merged
merged 9 commits into from
Jul 1, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 44 additions & 5 deletions src/bundles/analytics.js
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
* @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)
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
}
}
}

/**
* @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 } = {}) => {
SgtPooki marked this conversation as resolved.
Show resolved Hide resolved
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)
}
Loading