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

feat(app): Configure analytics to send Python and JSON protocol info #2946

Merged
merged 1 commit into from
Jan 24, 2019
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
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