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(scripts): add automated ODD memory usage analysis #16847

Open
wants to merge 1 commit into
base: edge
Choose a base branch
from
Open
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
43 changes: 43 additions & 0 deletions .github/workflows/odd-memory-usage-test.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
name: 'ODD Memory Usage Test'

on:
schedule:
- cron: '30 12 * * *'
workflow_dispatch:
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this line lets us manually run an action on-the-fly from the Actions tab? Is that true?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's correct, yeah. But it may have to be merged to the default branch first so it actually shows up in the actions tab.


jobs:
analyze-memory:
name: 'ODD Memory Usage Test'
runs-on: 'ubuntu-latest'
timeout-minutes: 15

steps:
- name: Checkout repository
uses: 'actions/checkout@v4'

- name: Setup Node.js
uses: 'actions/setup-node@v3'
with:
node-version: '18.19.0'
cache: 'yarn'

- name: Install dependencies
run: |
yarn install

- name: Run memory analysis
env:
MIXPANEL_INGEST_USER: ${{ secrets.MIXPANEL_INGEST_USER }}
MIXPANEL_INGEST_SECRET: ${{ secrets.MIXPANEL_INGEST_SECRET }}
MIXPANEL_PROJECT_ID: ${{ secrets.OT_APP_MIXPANEL_ID }}
run: |
OUTPUT=$(node ./scripts/resource-monitor/perform-memory-analysis "$MIXPANEL_INGEST_USER" "$MIXPANEL_INGEST_SECRET" "$MIXPANEL_PROJECT_ID")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these don't have to be passed through the environment; you can have this be $(note ./scripts/resource-monitor/perform-memory-analysis ${{ secrets.MIXPANEL_INGEST_USER }} for instance.

even better, and more on this later, is to just not put this through a shell action at all and implement this as a node action and use the api that it gives you, and then you don't have to deal with any of this at all


echo "::group::ODD Memory Usage Results"
echo "$OUTPUT"
echo "::endgroup::"

echo "## ODD Memory Usage Results" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
echo "$OUTPUT" >> $GITHUB_STEP_SUMMARY
echo '```' >> $GITHUB_STEP_SUMMARY
84 changes: 84 additions & 0 deletions scripts/resource-monitor/lib/analysis.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Analysis is based on one-tailed, Pearson's correlation coefficient.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if you implement this as a javascript action, you actually bundle locally from a separate deps list (see e.g. the helper action in oe-core; this is a rollup output plus source pair) and you can use a stats package if you want.


const { MINIMUM_VALID_SAMPLE_SIZE } = require('./constants')

/**
*
* @param x An array of a numbers.
* @param y An array of numbers.
* @return {number} The Pearson Correlation.
*/
function calculatePearsonCorrelation(x, y) {
const n = x.length
let sum_x = 0
let sum_y = 0
let sum_xy = 0
let sum_x2 = 0
let sum_y2 = 0

for (let i = 0; i < n; i++) {
sum_x += x[i]
sum_y += y[i]
sum_xy += x[i] * y[i]
sum_x2 += x[i] * x[i]
sum_y2 += y[i] * y[i]
}

const numerator = n * sum_xy - sum_x * sum_y
const denominator = Math.sqrt(
(n * sum_x2 - sum_x * sum_x) * (n * sum_y2 - sum_y * sum_y)
)

return denominator === 0 ? 0 : numerator / denominator
}

/**
* @description Calculate p-value using t-distribution approximation for a one-tailed test.
* If there are too few samples, assume no correlation.
* For positive correlations only.
* @param correlation The Pearson Correlation
* @param sampleSize The total number of samples.
* @return {number} The p-value.
*/
function calculatePValueOneTailed(correlation, sampleSize) {
if (sampleSize < MINIMUM_VALID_SAMPLE_SIZE) {
return 1
}

// The t-statistic
const t =
correlation * Math.sqrt((sampleSize - 2) / (1 - correlation * correlation))

// Approximate p-value using t-distribution (one-tailed test)
const degreesOfFreedom = sampleSize - 2
return 1 - tDistributionCDF(t, degreesOfFreedom)
}

// t-distribution CDF approximation
function tDistributionCDF(t, df) {
const x = df / (df + t * t)
return 1 - 0.5 * Math.pow(x, df / 2)
}

function interpretResults(result) {
if (!result.isSignificant) {
return 'No significant correlation found'
}

const strength = Math.abs(result.correlation)
const direction = result.correlation > 0 ? 'positive' : 'negative'

if (strength > 0.7) {
return `Strong ${direction} correlation (>0.7)`
} else if (strength > 0.3) {
return `Moderate ${direction} correlation (>0.3 and <0.7)`
} else {
return `Weak ${direction} correlation (>=0.3)`
}
}

module.exports = {
calculatePearsonCorrelation,
calculatePValueOneTailed,
interpretResults,
}
41 changes: 41 additions & 0 deletions scripts/resource-monitor/lib/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @description Several processes we care about execute with a lot of unique sub args determined at
* runtime. These processes are aggregated using a regex pattern.
*/
const AGGREGATED_PROCESSES = [
{
pattern: /^\/opt\/opentrons-app\/opentrons --type=renderer/,
key: 'app-renderer-processes',
},
{
pattern: /^\/opt\/opentrons-app\/opentrons --type=zygote/,
key: 'app-zygote-processes',
},
{
pattern: /^python3 -m uvicorn/,
key: 'robot-server-uvicorn-processes',
},
{
pattern: /^\/opt\/opentrons-app\/opentrons --type=utility/,
key: 'app-utility-processes',
},
]

/**
* @description Generally don't include any variation of external processes in analysis.
*/
const BLACKLISTED_PROCESSES = [/^nmcli/, /^\/usr\/bin\/python3/]

/**
* @description For Pearson's, it's generally recommended to use a sample size of at least n=30.
*/
const MINIMUM_VALID_SAMPLE_SIZE = 30

const P_VALUE_SIGNIFICANCE_THRESHOLD = 0.05

module.exports = {
AGGREGATED_PROCESSES,
BLACKLISTED_PROCESSES,
MINIMUM_VALID_SAMPLE_SIZE,
P_VALUE_SIGNIFICANCE_THRESHOLD,
}
22 changes: 22 additions & 0 deletions scripts/resource-monitor/lib/helpers/date.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/**
* @description Get ISO date strings for the past month from yesterday.
*/
function getISODatesForPastMonth() {
const now = new Date()
// Don't use today's data, because the Mixpanel API seemingly doesn't use UTC timestamps, and
// it's easy to fail a request depending on the time of day it's made.
const yesterday = new Date(now.setDate(now.getDate() - 1))
const formatDate = date => date.toISOString().split('T')[0]

const monthAgo = new Date(yesterday)
monthAgo.setMonth(yesterday.getMonth() - 1)
mjhuff marked this conversation as resolved.
Show resolved Hide resolved

return {
from: formatDate(monthAgo),
to: formatDate(yesterday),
}
}

module.exports = {
getISODatesForPastMonth,
}
5 changes: 5 additions & 0 deletions scripts/resource-monitor/lib/helpers/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
module.exports = {
...require('./date'),
...require('./manifest'),
...require('./mixpanel'),
}
52 changes: 52 additions & 0 deletions scripts/resource-monitor/lib/helpers/manifest.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
const fetch = require('node-fetch')

const APP_MANIFEST = 'https://builds.opentrons.com/app/releases.json'
mjhuff marked this conversation as resolved.
Show resolved Hide resolved

async function downloadAppManifest() {
const response = await fetch(APP_MANIFEST)
return await response.json()
}

/**
* @description Get the most recent app version that is not revoked.
* @param manifest The app manifest
*/
function latestValidVersionFromManifest(manifest) {
const versions = Object.keys(manifest.production)
const latestValidVersion = versions.findLast(
version => manifest.production[version].revoked === false
)

if (latestValidVersion != null) {
return latestValidVersion
} else {
throw new Error('No valid versions found')
}
}

/**
* @description Get `count` latest, previous non revoked versions relative to the latest version.
* @param manifest The app manifest
* @param latestVersion The latest valid version
* @param count Number of previous versions to return
* @returns {string[]} Array of version strings, ordered from newest to oldest
*/
function getPrevValidVersions(manifest, latestVersion, count) {
const versions = Object.keys(manifest.production)
const latestIndex = versions.indexOf(latestVersion)

if (latestIndex === -1) {
throw new Error('Latest version not found in manifest')
}

return versions
.slice(0, latestIndex)
.filter(version => !manifest.production[version].revoked)
.slice(-count)
.reverse()
}
module.exports = {
downloadAppManifest,
latestValidVersionFromManifest,
getPrevValidVersions,
}
65 changes: 65 additions & 0 deletions scripts/resource-monitor/lib/helpers/mixpanel.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
const fetch = require('node-fetch')

const MIXPANEL_URL = 'https://data.mixpanel.com/api/2.0/export'

/**
* @description Base64 encode a username and password in
* @param uname Mixpanel service account username.
* @param pwd Mixpanel service account password.
* @return {string}
*/
function encodeCredentialsForMixpanel(uname, pwd) {
return Buffer.from(`${uname}:${pwd}`).toString('base64')
}

/**
* @description Cleans up Mixpanel data for post-processing.
* @param data Mixpanel data
*/
function parseMixpanelData(data) {
const lines = data.split('\n').filter(line => line.trim())
return lines.map(line => JSON.parse(line))
}

/**
* @description Make the network request to Mixpanel.
*/
async function getMixpanelResourceMonitorDataFor({
uname,
pwd,
projectId,
fromDate,
toDate,
where,
}) {
const params = new URLSearchParams({
project_id: projectId,
from_date: fromDate,
to_date: toDate,
event: '["resourceMonitorReport"]',
where,
})

const options = {
method: 'GET',
headers: {
'Accept-Encoding': 'gzip',
accept: 'text/plain',
authorization: `Basic ${encodeCredentialsForMixpanel(uname, pwd)}`,
},
}

const response = await fetch(`${MIXPANEL_URL}?${params}`, options)
const text = await response.text()
if (!response.ok) {
throw new Error(
`Mixpanel request failed: ${response.status}, ${response.statusText}, ${text}`
)
}
return text
}

module.exports = {
getMixpanelResourceMonitorDataFor,
parseMixpanelData,
}
Loading
Loading