Skip to content

Commit

Permalink
feat(app): Configure analytics to send Python and JSON protocol info (#…
Browse files Browse the repository at this point in the history
…2946)

Closes #2615, closes #2618
  • Loading branch information
mcous authored Jan 24, 2019
1 parent dc64b0d commit 22f419d
Show file tree
Hide file tree
Showing 11 changed files with 430 additions and 184 deletions.
35 changes: 34 additions & 1 deletion app/src/analytics/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,11 @@ const store = createStore(reducer, middleware)
For a given Redux action, add a `case` to the `action.type` switch in [`app/src/analytics/make-event.js`](./make-event.js). `makeEvent` will be passed the full state and the action. Any new case should return either `null` or an object `{name: string, properties: {}}`

```js
export default function makeEvent (state: State, action: Action): ?Event {
export default function makeEvent (
action: Action,
nextState: State,
prevState: State
): null | AnalyticsEvent | Promise<AnalyticsEvent | null> {
switch (action.type) {
// ...
case 'some-action-type':
Expand All @@ -37,3 +41,32 @@ export default function makeEvent (state: State, action: Action): ?Event {
return null
}
```

## events

| name | redux action | payload |
| ------------------------ | ------------------------------ | --------------------------------------- |
| `robotConnect` | `robot:CONNECT_RESPONSE` | success, method, error |
| `protocolUploadRequest` | `protocol:UPLOAD` | protocol data |
| `protocolUploadResponse` | `robot:SESSION_RESPONSE/ERROR` | protocol data, success, error |
| `runStart` | `robot:RUN` | protocol data |
| `runFinish` | `robot:RUN_RESPONSE` | protocol data, success, error, run time |
| `runPause` | `robot:PAUSE` | protocol, run time data |
| `runResume` | `robot:RESUME` | protocol, run time data |
| `runCancel` | `robot:CANCEL` | protocol, run time data |

### hashing

Some payload fields are [hashed][] for user anonymity while preserving our ability to disambiguate unique values. Fields are hashed with the SHA-256 algorithm and are noted as such in this section.

### protocol data sent

- Protocol type (Python or JSON)
- Application Name (e.g. "Opentrons Protocol Designer")
- Application Version
- Protocol `metadata.source`
- Protocol `metadata.protocolName`
- Protocol `metadata.author` (hashed for anonymity)
- Protocol `metadata.protocolText` (hashed for anonymity)

[hashed]: https://en.wikipedia.org/wiki/Hash_function
252 changes: 152 additions & 100 deletions app/src/analytics/__tests__/make-event.test.js
Original file line number Diff line number Diff line change
@@ -1,26 +1,20 @@
// events map tests
import {LOCATION_CHANGE} from 'react-router-redux'

import makeEvent from '../make-event'
import {actions as robotActions} from '../../robot'
import * as selectors from '../selectors'

describe('analytics events map', () => {
test('@@router/LOCATION_CHANGE -> url event', () => {
const state = {}
// TODO(mc, 2018-05-28): this type has changed since @beta.6
const action = {type: LOCATION_CHANGE, payload: {pathname: '/foo'}}
jest.mock('../selectors')

expect(makeEvent(state, action)).toEqual({
name: 'url',
properties: {pathname: '/foo'},
})
describe('analytics events map', () => {
beforeEach(() => {
jest.resetAllMocks()
})

test('robot:CONNECT_RESPONSE -> robotConnected event', () => {
const state = name => ({
robot: {
connection: {
connectRequest: {name},
connectedTo: name,
},
},
discovery: {
Expand Down Expand Up @@ -56,133 +50,191 @@ describe('analytics events map', () => {
const success = robotActions.connectResponse()
const failure = robotActions.connectResponse(new Error('AH'))

expect(makeEvent(state('wired'), success)).toEqual({
expect(makeEvent(success, state('wired'))).toEqual({
name: 'robotConnect',
properties: {method: 'usb', success: true, error: ''},
})

expect(makeEvent(state('wired'), failure)).toEqual({
expect(makeEvent(failure, state('wired'))).toEqual({
name: 'robotConnect',
properties: {method: 'usb', success: false, error: 'AH'},
})

expect(makeEvent(state('wireless'), success)).toEqual({
expect(makeEvent(success, state('wireless'))).toEqual({
name: 'robotConnect',
properties: {method: 'wifi', success: true, error: ''},
})

expect(makeEvent(state('wireless'), failure)).toEqual({
expect(makeEvent(failure, state('wireless'))).toEqual({
name: 'robotConnect',
properties: {method: 'wifi', success: false, error: 'AH'},
})
})

test('robot:SESSION_RESPONSE/ERROR -> protocolUpload event', () => {
const state = {}
const success = {type: 'robot:SESSION_RESPONSE', payload: {}}
const failure = {
type: 'robot:SESSION_ERROR',
payload: {error: new Error('AH')},
}
describe('events with protocol data', () => {
var protocolData = {foo: 'bar'}

expect(makeEvent(state, success)).toEqual({
name: 'protocolUpload',
properties: {success: true, error: ''},
beforeEach(() => {
selectors.getProtocolAnalyticsData.mockResolvedValue(protocolData)
})

expect(makeEvent(state, failure)).toEqual({
name: 'protocolUpload',
properties: {success: false, error: 'AH'},
test('robot:PROTOCOL_UPLOAD > protocolUploadRequest', () => {
const prevState = {}
const nextState = {}
const success = {type: 'protocol:UPLOAD', payload: {}}

return expect(makeEvent(success, nextState, prevState)).resolves.toEqual({
name: 'protocolUploadRequest',
properties: protocolData,
})
})
})

test('robot:RUN -> runStart event', () => {
const state = {}
const action = {type: 'robot:RUN'}
test('robot:SESSION_RESPONSE with upload in flight', () => {
const prevState = {robot: {session: {sessionRequest: {inProgress: true}}}}
const nextState = {}
const success = {type: 'robot:SESSION_RESPONSE', payload: {}}

expect(makeEvent(state, action)).toEqual({
name: 'runStart',
properties: {},
return expect(makeEvent(success, nextState, prevState)).resolves.toEqual({
name: 'protocolUploadResponse',
properties: {success: true, error: '', ...protocolData},
})
})
})

test('robot:PAUSE_RESPONSE -> runPause event', () => {
const state = {}
const success = {type: 'robot:PAUSE_RESPONSE'}
const failure = {type: 'robot:PAUSE_RESPONSE', error: new Error('AH')}
test('robot:SESSION_ERROR with upload in flight', () => {
const prevState = {robot: {session: {sessionRequest: {inProgress: true}}}}
const nextState = {}
const failure = {
type: 'robot:SESSION_ERROR',
payload: {error: new Error('AH')},
}

return expect(makeEvent(failure, nextState, prevState)).resolves.toEqual({
name: 'protocolUploadResponse',
properties: {success: false, error: 'AH', ...protocolData},
})
})

expect(makeEvent(state, success)).toEqual({
name: 'runPause',
properties: {
success: true,
error: '',
},
test('robot:SESSION_RESPONSE/ERROR with no upload in flight', () => {
const prevState = {
robot: {session: {sessionRequest: {inProgress: false}}},
}
const nextState = {}
const success = {type: 'robot:SESSION_RESPONSE', payload: {}}
const failure = {
type: 'robot:SESSION_ERROR',
payload: {error: new Error('AH')},
}

expect(makeEvent(success, nextState, prevState)).toBeNull()
expect(makeEvent(failure, nextState, prevState)).toBeNull()
})

expect(makeEvent(state, failure)).toEqual({
name: 'runPause',
properties: {
success: false,
error: 'AH',
},
test('robot:RUN -> runStart event', () => {
const state = {}
const action = {type: 'robot:RUN'}

return expect(makeEvent(action, state)).resolves.toEqual({
name: 'runStart',
properties: protocolData,
})
})
})

test('robot:CANCEL_REPSONSE -> runCancel event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
test('robot:RUN_RESPONSE success -> runFinish event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
},
},
},
}
const success = {type: 'robot:CANCEL_RESPONSE'}
const failure = {type: 'robot:CANCEL_RESPONSE', error: new Error('AH')}

expect(makeEvent(state, success)).toEqual({
name: 'runCancel',
properties: {
runTime: 4,
success: true,
error: '',
},
})
}
const action = {type: 'robot:RUN_RESPONSE', error: false}

expect(makeEvent(state, failure)).toEqual({
name: 'runCancel',
properties: {
runTime: 4,
success: false,
error: 'AH',
},
return expect(makeEvent(action, state)).resolves.toEqual({
name: 'runFinish',
properties: {...protocolData, runTime: 4, success: true, error: ''},
})
})
})

test('robot:RUN_RESPONSE success -> runFinish event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
test('robot:RUN_RESPONSE error -> runFinish event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
},
},
},
}
const action = {type: 'robot:RUN_RESPONSE', error: false}
}
const action = {
type: 'robot:RUN_RESPONSE',
error: true,
payload: new Error('AH'),
}

return expect(makeEvent(action, state)).resolves.toEqual({
name: 'runFinish',
properties: {...protocolData, runTime: 4, success: false, error: 'AH'},
})
})

expect(makeEvent(state, action)).toEqual({
name: 'runFinish',
properties: {runTime: 4},
test('robot:PAUSE -> runPause event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
},
},
}
const action = {type: 'robot:PAUSE'}

return expect(makeEvent(action, state)).resolves.toEqual({
name: 'runPause',
properties: {
...protocolData,
runTime: 4,
},
})
})
})

test('robot:RUN_RESPONSE error -> runError event', () => {
const state = {}
const action = {type: 'robot:RUN_RESPONSE', error: new Error('AH')}
test('robot:RESUME -> runResume event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
},
},
}
const action = {type: 'robot:RESUME'}

return expect(makeEvent(action, state)).resolves.toEqual({
name: 'runResume',
properties: {
...protocolData,
runTime: 4,
},
})
})

expect(makeEvent(state, action)).toEqual({
name: 'runError',
properties: {error: 'AH'},
test('robot:CANCEL-> runCancel event', () => {
const state = {
robot: {
session: {
startTime: 1000,
runTime: 5000,
},
},
}
const action = {type: 'robot:CANCEL'}

return expect(makeEvent(action, state)).resolves.toEqual({
name: 'runCancel',
properties: {
...protocolData,
runTime: 4,
},
})
})
})
})
22 changes: 22 additions & 0 deletions app/src/analytics/hash.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
// @flow
// hash strings for an amount of anonymity
// note: values will be _hashed_, not _enctrypted_; hashed values should not be
// considered secure nor should they ever be released publicly
const ALGORITHM = 'SHA-256'

export default function hash (source: string): Promise<string> {
const encoder = new TextEncoder()
const data = encoder.encode(source)

// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest
return global.crypto.subtle
.digest(ALGORITHM, data)
.then((digest: ArrayBuffer) => arrayBufferToHex(digest))
}

// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#Converting_a_digest_to_a_hex_string
function arrayBufferToHex (source: ArrayBuffer): string {
const bytes = new Uint8Array(source)

return [...bytes].map(b => b.toString(16).padStart(2, '0')).join('')
}
Loading

0 comments on commit 22f419d

Please sign in to comment.