tools/capacity-planner-sheets-extension/main.js (220 lines of code) (raw):
/**
* Copyright 2023 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*
* Apps Script equivalent of https://github.com/GoogleCloudPlatform/professional-services/blob/main/tools/capacity-planner-cli
*
* Sends the queries listed in queries.gs to the project specified in the Google Sheet
* to caclulate peak resource usage.
*/
const MONITORING_API_BASE_URL = "https://monitoring.googleapis.com/v3/";
const TOKEN = ScriptApp.getOAuthToken();
/**
* Cleans the MQL query in preparation for sending it in the API payload.
* @param {string} MQL query
* @returns {string} MQL query
*/
function cleanQuery_(query) {
// Just removes newlines for now
// If the Monitoring API also cares about extra spaces, remove them here.
return query.replace("\n", " ");
}
/**
* Builds a datetime filter to add to the end of the MQL query.
* Filter example: | within 60m, d'2022/12/21 10:15+07:00'
* See https://cloud.google.com/monitoring/mql/reference#within-tabop
*
* Timezone must follow the format expected by Utilities.formatDate,
* otherwise it is ignored and time is assumed to be UTC.
* See https://developers.google.com/apps-script/reference/utilities/utilities#formatDate(Date,String,String)
* and "General time zone" in https://docs.oracle.com/javase/7/docs/api/java/text/SimpleDateFormat.html
*
* @param {Date} End time.
* @param {Number} Duration in minutes
* @param {string} Timezone in format GMT+HH:mm (example: GMT+09:00)
* @returns {string} A MQL filter string with the specified end time and duration.
*/
function buildWithinFilter_(endTime, durationMinutes, timezone) {
const timeStamp = Utilities.formatDate(endTime, timezone, "yyyy/MM/dd HH:mm:ssZZZZ")
return `| within ${durationMinutes}m, d'${timeStamp}'`;
}
/**
* Converts timestamps from the Monitoring API with format '2022-12-22T02:20:00Z'
* to the specified timezone and returns a string like '2022-12-22 22:20:00 +09:00'
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.snoozes#TimeInterval
*/
function formatTimestamp_(timeStr, timezone) {
// All Monitoring API responses use UTC "Zulu" time
// NOTE: This will error for timestamps with nanosecond precision.
const datetime = Utilities.parseDate(timeStr, "GMT", "yyyy-MM-dd'T'HH:mm:ss'Z'");
return Utilities.formatDate(datetime, timezone, "yyyy-MM-dd HH:mm:ss") + timezone.replace("GMT", "");
}
/**
* Sends the MQL query to the Cloud Monitoring API and yields the response.
* This function is a generator and yields multiple responses if the API response
* is paginated.
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/query
* @param {string} project ID
* @param {string} query in MQL
* @yields {Object} yields the full response object from the API.
*/
function* sendQuery_(projectId, query) {
const url = `${MONITORING_API_BASE_URL}projects/${projectId}/timeSeries:query`;
const headers = {
Authorization: "Bearer " + TOKEN,
Accept: "application/json"
};
let payload = { "query": query };
Logger.log(query)
let response;
do {
// TODO: Add automatic retry for retriable errors.
// https://google.aip.dev/194
const rawResponse = UrlFetchApp.fetch(url, {
"headers": headers,
"payload": payload,
"muteHttpExceptions": true
});
response = JSON.parse(rawResponse.getContentText());
// Check for common error response codes and wrap the errpo
// with more user-friendly messages.
if (rawResponse.getResponseCode() != 200) {
const genericMessage = `\n\nThe payload and error response are shared for debugging purposes.
\n\nPAYLOAD: ${JSON.stringify(payload)}\n\nERROR: ${rawResponse.getContentText()}`;
if (response.error.status == "PERMISSION_DENIED") {
throw Error(`Project "${projectId}" could not be found.
Check that the project exists and that you have permissions to view metrics in it.` + genericMessage);
}
else if (response.error.status == "INVALID_ARGUMENT") {
throw Error("The query has an invalid argument." + genericMessage);
}
// Some other error was raised
throw Error(genericMessage);
}
if ("timeSeriesData" in response) {
yield response;
}
if ("nextPageToken" in response) {
payload["pageToken"] = response.nextPageToken;
}
}
while ('nextPageToken' in response)
}
/**
* Loads the default queries.
* @yields {array} 3-item array with the product name, metric name and query.
*/
function* loadQueries_() {
for (product in QUERIES) {
const productName = QUERIES[product]['product_name'];
for (metric in QUERIES[product]['metrics']) {
const metricName = QUERIES[product]['metrics'][metric]['metric_name'];
const query = QUERIES[product]['metrics'][metric]['query'];
yield [productName, metricName, query];
}
}
}
/**
* Returns the label descriptors keys from the timeSeriesDescriptor.
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/LabelDescriptor
* @param {Object} query response
* @returns {Array} an array of the label descriptors
*/
function extractLabelDescriptors_(response) {
if ("labelDescriptors" in response.timeSeriesDescriptor) {
return response.timeSeriesDescriptor.labelDescriptors.map(l => l.key);
}
else {
return [];
}
}
/**
* Extracts the unit of the points in the timeSeriesDescriptor.
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/query#timeseriesdescriptor
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/query#ValueDescriptor
* @param {Object} query response
* @returns {string} The unit
*/
function extractUnit_(response) {
return response.timeSeriesDescriptor.pointDescriptors[0].unit;
}
/**
* Extract the label value, which can be a bool, int or string.
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/query#labelvalue
* @param {Object} LabelValue
* @returns boolean, integer, or string
*/
function extractLabelValue_(labelValue) {
if ("boolValue" in labelValue) {
return labelValue.boolValue;
}
else if ("int64Value" in labelValue) {
return labelValue.int64Value;
}
else if ("stringValue" in labelValue) {
return labelValue.stringValue;
}
throw Error("Expected field boolValue, int64Value, or stringValue. Actual field is:" + JSON.stringify(typedValue))
}
/**
* Unpack TypedValue to the equivalent javascript value.
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/TypedValue
* Throws an error for bool, string, and distribution types as those do
* not logically make sense for the queries this tool can send.
* @param {Object} TypedValue
* @returns integer or double
*/
function extractValue_(typedValue) {
// NOTE: TypedValue is a union field so the API will never send a response
// with multiple fields set.
if ("int64Value" in typedValue) {
return typedValue.int64Value;
}
else if ("doubleValue" in typedValue) {
return typedValue.doubleValue;
}
// There is some other type or no type set.
throw Error("Expected type int64Value or doubleValue. Actual type is:" + JSON.stringify(typedValue))
}
/**
* Finds the PointData with the highest value in the time series.
* If multiple points have the max value, the first point is returned.
* https://cloud.google.com/monitoring/api/ref_v3/rest/v3/projects.timeSeries/query#pointdata
* @param {Object} timeSeriesData field in query response
* @returns {Object} PointData with the max value
*/
function findPeak_(timeSeriesData) {
const max = timeSeriesData.pointData.reduce(
(a, b) => extractValue_(a.values[0]) > extractValue_(b.values[0]) ? a : b
);
return max;
}
/**
* Loads metrics data for a single project from a {project_id}_raw sheet.
* @param {Sheet} A sheet with peak values for a particular project.
* @returns {Map} A map with the metrics data.
*/
function loadProjectMetricsData_(sheet) {
const data = sheet.getDataRange().getValues();
let metricsData = new Map();
for (let i = 1; i < data.length; i++) {
// i starts from 1 to skip the header row
const productName = data[i][0];
const metricName = data[i][1];
const unit = data[i][2];
const labels = JSON.parse(data[i][3]);
const peakValue = data[i][4];
const peakTime = data[i][5];
let timeSeries = new Map();
timeSeries.set("labels", labels);
timeSeries.set("peakValue", peakValue);
timeSeries.set("peakTime", peakTime);
if (!(Array.from(metricsData.keys()).includes(productName))) {
metricsData.set(productName, new Map());
}
if (!(Array.from(metricsData.get(productName).keys()).includes(metricName))) {
let metricsEntry = new Map();
metricsEntry.set("unit", unit);
metricsEntry.set("timeSeries", []);
metricsData.get(productName).set(metricName, metricsEntry);
}
metricsData.get(productName).get(metricName).get("timeSeries").push(timeSeries);
}
return metricsData
}
/**
* Takes the simple CSV output in {project_id}_raw sheets and puts it in a
* easier to use combined view for capacity planning.
*/
function createCapacityPlannerSheet() {
// TODO: Let user choose the name.
const sheetName = "Combined Planning Sheet"
const ss = SpreadsheetApp.getActive();
let allMetricsData = new Map();
for (s of ss.getSheets()) {
// Search for sheets with name {project_id}_raw and use as input
if (s.getName().includes("_raw")) {
const metricsData = loadProjectMetricsData_(s);
const projectId = s.getName().replace("_raw", "");
allMetricsData.set(projectId, metricsData);
}
}
// Now output the data into a combined view with all projects
let newSheet = createSheet_(sheetName);
// 3 main sections are:
// 1. Metrics description (4 columns)
// 2. Estimate/CCU (5 columns)
// 3. Actual data and gap calculations (6 columns)
newSheet.appendRow([
"Project", "Product", "Metrics", "Labels",
"Estimate / CCU", "", "", "", "",
`${sheetName} YYYY-MM-DD`, "", "", "", "", ""
]);
newSheet.appendRow([
"", "", "", "",
"Per CCU", "-", "-", "-", "-",
"Estimate for:", "Actual", "Peak timestamp", "Gap", "Gap %", "Notes"
]);
newSheet.appendRow([
"", "", "", "",
"", "100,000", "200,000", "500,000", "1,000,000",
"1,000,000", "", "", "", "", ""
]);
let rowCounter = newSheet.getLastRow() + 1;
// Save ranges to merge to this array
let rangesToMerge = [];
for (let [projectId, metricsData] of allMetricsData) {
const projectStartRowIndex = rowCounter;
for (product of metricsData.keys()) {
const productStartRowIndex = rowCounter;
for (metricName of metricsData.get(product).keys()) {
const metricStartRowIndex = rowCounter;
for (timeSeries of metricsData.get(product).get(metricName).get("timeSeries")) {
newSheet.appendRow([
projectId, product, metricName, timeSeries.get("labels"),
"", "", "", "", "",
"", timeSeries.get("peakValue"), timeSeries.get("peakTime"), "", "", ""
]);
rowCounter += 1;
}
const metricRange = newSheet.getRange(metricStartRowIndex, 3, rowCounter - metricStartRowIndex);
rangesToMerge.push(metricRange);
}
const productRange = newSheet.getRange(productStartRowIndex, 2, rowCounter - productStartRowIndex);
rangesToMerge.push(productRange);
}
const projectRange = newSheet.getRange(projectStartRowIndex, 1, rowCounter - projectStartRowIndex);
rangesToMerge.push(projectRange)
}
rangesToMerge.forEach(range => range.mergeVertically());
addFormulasToSheet_(newSheet);
formatSheet_(newSheet);
addBorder_(newSheet, firstRow=4, firstColumn=2);
addBorder_(newSheet, firstRow=4, firstColumn=1);
}
function getProjectMetrics() {
let ss = SpreadsheetApp.getActive();
const inputsSheet = ss.getSheetByName("README and Inputs");
const data = inputsSheet.getDataRange().getValues();
const projectId = data[2][1]; // cell B3
// Apps Script date utility functions only recognize "GMT", not "UTC".
// Javascript itself understands both, so use GMT to appease Utilities.parseDate/Utilities.formatDate
const timezone = data[5][1].replace("UTC", "GMT")
const endTime = new Date(data[3][1] + timezone)
// TODO: Consider capping the duration at 2-3 days so the script completes in a reasonable amount of time.
const duration = data[4][1];
let csvOutput = [["Product Name", "Metric Name", "Unit", "Labels", "Peak Value", `Peak Time (${timezone})`]];
for ([productName, metricName, query] of loadQueries_()) {
query = cleanQuery_(query) + buildWithinFilter_(endTime, duration, timezone);
for (response of sendQuery_(projectId, query)) {
const labelDescriptors = extractLabelDescriptors_(response);
const unit = extractUnit_(response);
for (timeSeriesData of response.timeSeriesData) {
const peak = findPeak_(timeSeriesData);
const peakValue = extractValue_(peak.values[0]);
const peakTime = formatTimestamp_(peak.timeInterval.endTime, timezone);
// Creates map from labelName to the value
// For example: {"resource.region": "global"}
// Labels are in the same order in labelDescriptors and timesSeriesData.labelValues
let labels = {};
for (let i = 0; i < labelDescriptors.length; i++) {
labels[labelDescriptors[i]] = extractLabelValue_(timeSeriesData.labelValues[i]);
}
// NOTE: This currently skips products with no associated timeSeries.
csvOutput.push([productName, metricName, unit, JSON.stringify(labels), peakValue, peakTime]);
}
}
}
// Create a new sheet and write the data to it
const sheetName = `${projectId}_raw`;
writeToSheet_(sheetName, csvOutput);
}
/**
* A function that runs when the spreadsheet is open, used to add a
* custom menu to the spreadsheet.
*/
function onOpen() {
let spreadsheet = SpreadsheetApp.getActive();
const menuItems = [
{ name: "Get Project Metrics", functionName: "getProjectMetrics" },
{ name: "Create Planning Sheet", functionName: "createCapacityPlannerSheet" }
];
spreadsheet.addMenu("Capacity Planner", menuItems);
}