-
Notifications
You must be signed in to change notification settings - Fork 1.3k
/
main.js
403 lines (355 loc) · 15.3 KB
/
main.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
/**
* 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);
}