From 773cb5464ecdd062cdb3af1a69e646f88d7de22e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A1lm=C3=A1n=20Tarnay?= Date: Mon, 23 Mar 2020 22:35:42 +0100 Subject: [PATCH] Implement metrics command (google/clasp#353) --- README.md | 8 ++++ src/auth.ts | 2 + src/commands/login.ts | 1 + src/commands/metrics.ts | 87 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 11 +++++ src/utils.ts | 2 + tests/commands/metrics.ts | 25 +++++++++++ 7 files changed, 136 insertions(+) create mode 100644 src/commands/metrics.ts create mode 100644 tests/commands/metrics.ts diff --git a/README.md b/README.md index 0494bf75..fcf6769b 100644 --- a/README.md +++ b/README.md @@ -335,6 +335,14 @@ Lists your most recent Apps Script projects. - `clasp list`: Prints `helloworld1 – xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx ...` +### Metrics + +Show metrics for a script. + +#### Examples + +- `clasp metrics` + ## Advanced Commands > **NOTE**: These commands require Project ID/credentials setup (see below). diff --git a/src/auth.ts b/src/auth.ts index 9121755c..93f37e66 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -113,6 +113,7 @@ export async function authorize(options: { 'https://www.googleapis.com/auth/script.deployments', // Apps Script deployments 'https://www.googleapis.com/auth/script.projects', // Apps Script management 'https://www.googleapis.com/auth/script.webapp.deploy', // Apps Script Web Apps + 'https://www.googleapis.com/auth/script.metrics', // Apps Script Metrics 'https://www.googleapis.com/auth/drive.metadata.readonly', // Drive metadata 'https://www.googleapis.com/auth/drive.file', // Create Drive files 'https://www.googleapis.com/auth/service.management', // Cloud Project Service Management API @@ -129,6 +130,7 @@ export async function authorize(options: { 'https://www.googleapis.com/auth/script.deployments', // Apps Script deployments 'https://www.googleapis.com/auth/script.projects', // Apps Script management 'https://www.googleapis.com/auth/script.webapp.deploy', // Apps Script Web Apps + 'https://www.googleapis.com/auth/script.metrics', // Apps Script Metrics 'https://www.googleapis.com/auth/drive.metadata.readonly', // Drive metadata 'https://www.googleapis.com/auth/drive.file', // Create Drive files 'https://www.googleapis.com/auth/service.management', // Cloud Project Service Management API diff --git a/src/commands/login.ts b/src/commands/login.ts index 935b1d68..2ef72aea 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -80,6 +80,7 @@ export default async (options: { localhost?: boolean; creds?: string; status?: b 'https://www.googleapis.com/auth/script.deployments', // Apps Script deployments 'https://www.googleapis.com/auth/script.projects', // Apps Script management 'https://www.googleapis.com/auth/script.webapp.deploy', // Apps Script Web Apps + 'https://www.googleapis.com/auth/script.metrics', // Apps Script Metrics 'https://www.googleapis.com/auth/drive.metadata.readonly', // Drive metadata 'https://www.googleapis.com/auth/drive.file', // Create Drive files 'https://www.googleapis.com/auth/service.management', // Cloud Project Service Management API diff --git a/src/commands/metrics.ts b/src/commands/metrics.ts new file mode 100644 index 00000000..160e422f --- /dev/null +++ b/src/commands/metrics.ts @@ -0,0 +1,87 @@ +import { loadAPICredentials, script } from '../auth'; +import { checkIfOnline, getProjectSettings, LOG, logError, spinner, ERROR } from '../utils'; +import { script_v1 } from 'googleapis'; + +/** + * Displays metrics for the current script + * @param cmd.json {boolean} Displays the status in json format. + */ +export default async (): Promise => { + await checkIfOnline(); + await loadAPICredentials(); + const { scriptId } = await getProjectSettings(); + if (!scriptId) return; + spinner.setSpinnerTitle(LOG.METRICS(scriptId)).start(); + const metrics = await script.projects.getMetrics({ + scriptId, + metricsGranularity: 'DAILY', + }); + if (spinner.isSpinning()) spinner.stop(true); + if (metrics.status !== 200) logError(metrics.statusText); + const { data } = metrics; + + type Maybe = T | undefined | null; + // Function to format a time range into a user friendly format. + // API appears to always returns whole UTC days. Bail out if this assumption doesn't hold. + const formatTime = ({startTime, endTime}: {startTime: Maybe, endTime: Maybe}) => + (startTime?.endsWith('T00:00:00Z') && endTime?.endsWith('T00:00:00Z')) ? + startTime.slice(0, 10) : + logError(ERROR.METRICS_UNEXPECTED_RANGE); + + // Function to create a Map from an array of MetricsValues (time range -> value) + const array2map = (metricsValues: script_v1.Schema$MetricsValue[]) => + new Map(metricsValues.map(({startTime, endTime, value}) => ([ + formatTime({ startTime , endTime }), + value || '0', + ])), + ); + + // Turn raw data array into range (string) -> value (string) Maps + const activeUsers = array2map(data.activeUsers || []); + const failedExecutions = array2map(data.failedExecutions || []); + const totalExecutions = array2map(data.totalExecutions || []); + + // Create a sorted array of unique time ranges + const timeRanges = Array.from(new Set([ + ...activeUsers.keys(), + ...failedExecutions.keys(), + ...totalExecutions.keys(), + ])).sort().reverse(); + + // Turn the dataset into a table + const table = timeRanges.map(timeRange => { + const get = (map: Map) => (map.get(timeRange) || '0'); + return [ + timeRange, + ' ' + get(activeUsers), + get(activeUsers) === '1' ? 'user' : 'users', + ' ' + get(totalExecutions), + get(totalExecutions) === '1' ? 'execution' : 'executions', + ' ' + get(failedExecutions), + 'failed', + ]; + }); + + const padders = [ + String.prototype.padEnd, // for time range + String.prototype.padStart, // for number of user(s) + String.prototype.padEnd, // for 'user' / 'users' + String.prototype.padStart, // for number of executions + String.prototype.padEnd, // for 'execution' / 'executions' + String.prototype.padStart, // for number of failed executions + String.prototype.padEnd, // for 'failed' + ]; + + // Determine padding for each column + const paddings = padders.map( + (_, columnIndex) => Math.max(...table.map(row => row[columnIndex].length)), + ); + + // Metrics API only supports UTC, and users might expect local time, let them know it's UTC. + console.error('UTC Date'); + + // Print results + for (const row of table) { + console.log(row.map((v, i) => padders[i].apply(v, [paddings[i]])).join(' ')); + } +}; diff --git a/src/index.ts b/src/index.ts index 1a38ae82..eadd4a41 100755 --- a/src/index.ts +++ b/src/index.ts @@ -43,6 +43,7 @@ import status from './commands/status'; import undeploy from './commands/undeploy'; import version from './commands/version'; import versions from './commands/versions'; +import metrics from './commands/metrics'; import { handleError, PROJECT_NAME } from './utils'; // CLI @@ -338,6 +339,16 @@ commander .description('Update in .clasp.json') .action(handleError(setting)); +/** + * Show metrics + * @name metrics + * @example metrics + */ +commander + .command('metrics') + .description('Show metrics') + .action(handleError(metrics)); + /** * All other commands are given a help message. * @example random diff --git a/src/utils.ts b/src/utils.ts index 2dca6950..3a9bc613 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -94,6 +94,7 @@ Forgot ${PROJECT_NAME} commands? Get help:\n ${PROJECT_NAME} --help`, NO_NESTED_PROJECTS: '\nNested clasp projects are not supported.', NO_VERSIONED_DEPLOYMENTS: 'No versioned deployments found in project.', NO_WEBAPP: (deploymentId: string) => `Deployment "${deploymentId}" is not deployed as WebApp.`, + METRICS_UNEXPECTED_RANGE: 'Error: Unexpected time range returned by the Metrics API.', OFFLINE: 'Error: Looks like you are offline.', ONE_DEPLOYMENT_CREATE: 'Currently just one deployment can be created at a time.', PAYLOAD_UNKNOWN: 'Unknown StackDriver payload.', @@ -163,6 +164,7 @@ Cloned ${fileNum} ${pluralize('files', fileNum)}.`, LOGIN: (isLocal: boolean) => `Logging in ${isLocal ? 'locally' : 'globally'}...`, LOGS_SETUP: 'Finished setting up logs.\n', NO_GCLOUD_PROJECT: `No projectId found. Running ${PROJECT_NAME} logs --setup.`, + METRICS: (scriptId: string) => `Getting metrics...`, OPEN_CREDS: (projectId: string) => `Opening credentials page: ${URL.CREDS(projectId)}`, OPEN_LINK: (link: string) => `Open this link: ${link}`, OPEN_PROJECT: (scriptId: string) => `Opening script: ${URL.SCRIPT(scriptId)}`, diff --git a/tests/commands/metrics.ts b/tests/commands/metrics.ts new file mode 100644 index 00000000..c0662ca5 --- /dev/null +++ b/tests/commands/metrics.ts @@ -0,0 +1,25 @@ +import { spawnSync } from 'child_process'; +import { expect } from 'chai'; +import { describe, it } from 'mocha'; +import { + CLASP, +} from '../constants'; +import { cleanup, setup } from '../functions'; + +describe('Test clasp metrics function', () => { + before(setup); + it('should display metrics', () => { + const today = new Date(); + const yesterday = new Date(); + yesterday.setDate(today.getDate() - 1); + + const result = spawnSync( + CLASP, ['metrics'], { encoding: 'utf8' }, + ); + + expect(result.stderr).to.contain('UTC Date'); + expect(result.stdout).to.contain(yesterday.toISOString().slice(0, 10)); + expect(result.status).to.equal(0); + }); + after(cleanup); +}); \ No newline at end of file