spartan_apm 0.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (50) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +9 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +55 -0
  5. data/VERSION +1 -0
  6. data/app/assets/flatpickr-4.6.9/LICENSE.md +21 -0
  7. data/app/assets/flatpickr-4.6.9/flatpickr.min.css +13 -0
  8. data/app/assets/flatpickr-4.6.9/flatpickr.min.js +2 -0
  9. data/app/assets/nice-select2-2.0.0/LICENSE +21 -0
  10. data/app/assets/nice-select2-2.0.0/nice-select2.min.css +1 -0
  11. data/app/assets/nice-select2-2.0.0/nice-select2.min.js +1 -0
  12. data/app/assets/spartan.svg +5 -0
  13. data/app/views/_help.html.erb +147 -0
  14. data/app/views/index.html.erb +231 -0
  15. data/app/views/scripts.js +911 -0
  16. data/app/views/styles.css +332 -0
  17. data/config.ru +36 -0
  18. data/lib/spartan_apm/engine.rb +45 -0
  19. data/lib/spartan_apm/error_info.rb +17 -0
  20. data/lib/spartan_apm/instrumentation/active_record.rb +13 -0
  21. data/lib/spartan_apm/instrumentation/base.rb +36 -0
  22. data/lib/spartan_apm/instrumentation/bunny.rb +24 -0
  23. data/lib/spartan_apm/instrumentation/cassandra.rb +13 -0
  24. data/lib/spartan_apm/instrumentation/curb.rb +13 -0
  25. data/lib/spartan_apm/instrumentation/dalli.rb +13 -0
  26. data/lib/spartan_apm/instrumentation/elasticsearch.rb +18 -0
  27. data/lib/spartan_apm/instrumentation/excon.rb +13 -0
  28. data/lib/spartan_apm/instrumentation/http.rb +13 -0
  29. data/lib/spartan_apm/instrumentation/httpclient.rb +13 -0
  30. data/lib/spartan_apm/instrumentation/net_http.rb +13 -0
  31. data/lib/spartan_apm/instrumentation/redis.rb +13 -0
  32. data/lib/spartan_apm/instrumentation/typhoeus.rb +13 -0
  33. data/lib/spartan_apm/instrumentation.rb +71 -0
  34. data/lib/spartan_apm/measure.rb +172 -0
  35. data/lib/spartan_apm/metric.rb +26 -0
  36. data/lib/spartan_apm/middleware/rack/end_middleware.rb +29 -0
  37. data/lib/spartan_apm/middleware/rack/start_middleware.rb +57 -0
  38. data/lib/spartan_apm/middleware/sidekiq/end_middleware.rb +25 -0
  39. data/lib/spartan_apm/middleware/sidekiq/start_middleware.rb +34 -0
  40. data/lib/spartan_apm/middleware.rb +16 -0
  41. data/lib/spartan_apm/persistence.rb +648 -0
  42. data/lib/spartan_apm/report.rb +436 -0
  43. data/lib/spartan_apm/string_cache.rb +27 -0
  44. data/lib/spartan_apm/web/api_request.rb +133 -0
  45. data/lib/spartan_apm/web/helpers.rb +88 -0
  46. data/lib/spartan_apm/web/router.rb +90 -0
  47. data/lib/spartan_apm/web.rb +10 -0
  48. data/lib/spartan_apm.rb +399 -0
  49. data/spartan_apm.gemspec +39 -0
  50. metadata +161 -0
@@ -0,0 +1,911 @@
1
+ document.addEventListener("DOMContentLoaded", () => {
2
+ const COMPONENT_COLORS = {
3
+ queue: "#AED581",
4
+ middleware: "#BA68C8",
5
+ memcache: "#4DD0E1",
6
+ redis: "#E57373",
7
+ database: "#FFD54F",
8
+ mongodb: "#A1887F",
9
+ cassandra: "#4DB6AC",
10
+ rabbitmq: "#F06292",
11
+ elasticsearch: "#FF8A65",
12
+ http: "#90A4AE",
13
+ app: "#64B5F6"
14
+ };
15
+ const OTHER_COMPONENT_COLORS = ["#81C784", "#4FC3F7", "#FFF176", "#7986CB", "#9575CD", "#FFB74D", "#DCE775"];
16
+
17
+ // Update all charts on the page.
18
+ function updateCharts(updateLocation) {
19
+ const selectedHost = param("host");
20
+ const charts = document.getElementById("charts");
21
+ const minutes = parseInt(selectedValue(document.getElementById("minutes")), 10);
22
+ const hostMenu = document.getElementById("host");
23
+ const actionMenu = document.getElementById("action");
24
+ const aggregated = (minutes >= 24 * 60);
25
+
26
+ charts.style.display = "none";
27
+
28
+ if (aggregated) {
29
+ hostMenu.selectedIndex = 0;
30
+ hostMenu.disabled = true;
31
+ select2Menus["host"].disable();
32
+ actionMenu.selectedIndex = 0;
33
+ actionMenu.disabled = true;
34
+ select2Menus["action"].disable();
35
+ } else {
36
+ hostMenu.disabled = false;
37
+ select2Menus["host"].enable();
38
+ actionMenu.disabled = false;
39
+ select2Menus["action"].enable();
40
+ }
41
+
42
+ document.getElementById("show-error-details").style.display = (selectedHost || aggregated ? "none" : null);
43
+ document.getElementById("error-details").style.display = "none";
44
+ document.getElementById("show-action-details").style.display = (selectedHost || aggregated ? "none" : null);
45
+ document.getElementById("action-details").style.display = "none";
46
+
47
+ if (updateLocation !== false) {
48
+ updateWindowLocation();
49
+ }
50
+
51
+ currentParams = selectedParams()
52
+ callAPI("metrics", currentParams, "loading-spinner", (data) => {
53
+ charts.style.display = "block";
54
+ updateMetricData(data);
55
+ const live = document.getElementById("live");
56
+ if (live.checked && liveUpdateId === null) {
57
+ liveUpdateId = setInterval(liveUpdate, 1000 * 10);
58
+ }
59
+
60
+ document.getElementById("previous").disabled = false;
61
+ if (live.checked || document.getElementById("time").value === "") {
62
+ document.getElementById("next").disabled = true;
63
+ } else {
64
+ document.getElementById("next").disabled = false;
65
+ }
66
+ });
67
+ }
68
+
69
+ // Handler called by setInterval for live updates to charts.
70
+ function liveUpdate() {
71
+ if (document.getElementById("live").checked == false || metricData === null || metricData.times.length == 0) {
72
+ return;
73
+ }
74
+
75
+ const params = new URLSearchParams();
76
+ ["env", "app", "host", "action", "minutes"].forEach((name) => {
77
+ if (metricData[name] !== null && metricData[name] !== "") {
78
+ params.set(name, metricData[name]);
79
+ }
80
+ });
81
+ params.set("measurement", selectedValue(document.getElementById("measurement")));
82
+ params.set("live_time", metricData.times[metricData.times.length - 1].toISOString());
83
+ callAPI("live_metrics", params, null, (data) => {
84
+ if (data && data.times && data.times.length > 0) {
85
+ updateMetricData(data);
86
+ }
87
+ });
88
+ }
89
+
90
+ function updateChartsOnZoom(eventData) {
91
+ const startTimeStr = eventData["xaxis.range[0]"];
92
+ const endTimeStr = eventData["xaxis.range[1]"];
93
+ if (!(startTimeStr && endTimeStr)) {
94
+ return;
95
+ }
96
+ const startTime = Date.parse(startTimeStr.replace(" ", "T"));
97
+ const endTime = Date.parse(endTimeStr.replace(" ", "T"));
98
+ if (startTime === NaN || endTime === NaN) {
99
+ return;
100
+ }
101
+ let minutes = Math.round((endTime - startTime) / 60000);
102
+ if (!minutes || minutes <= 0) {
103
+ return;
104
+ }
105
+ setTime(new Date(startTime));
106
+ setMinutes(minutes)
107
+ updateCharts();
108
+ }
109
+
110
+ // Update the charts that are based on metrics data.
111
+ function updateMetricData(data) {
112
+ setMenuOptions("host", data.hosts);
113
+ setMenuOptions("action", data.actions);
114
+
115
+ for (let i = 0; i < data.times.length; i++) {
116
+ data.times[i] = new Date(data.times[i]);
117
+ }
118
+ const startTime = data.times[0];
119
+ const endTime = new Date(data.times[data.times.length - 1].getTime() + (data.interval_minutes * 60000));
120
+ document.getElementById("start-time").innerText = new Intl.DateTimeFormat(navigator.language, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "numeric"}).format(startTime);
121
+ document.getElementById("end-time").innerText = new Intl.DateTimeFormat(navigator.language, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "numeric", timeZoneName: "short"}).format(endTime);
122
+
123
+ metricData = data;
124
+ plotRequestTime();
125
+ plotThroughput();
126
+ plotErrorRate();
127
+ plotErrorCounts();
128
+ }
129
+
130
+ // Update the location href to match the state of the form fields.
131
+ function updateWindowLocation() {
132
+ const params = new URLSearchParams();
133
+ ["env", "app", "host", "action", "minutes", "measurement"].forEach((name) => {
134
+ const element = document.getElementById(name);
135
+ const value = selectedValue(element);
136
+ if (value && value !== "") {
137
+ params.set(name, value);
138
+ }
139
+ });
140
+ if (document.getElementById("live").checked) {
141
+ params.set("live", "1");
142
+ } else {
143
+ const time = timePicker.selectedDates[0];
144
+ if (time) {
145
+ params.set("time", time.toISOString());
146
+ } else {
147
+ params.delete("time");
148
+ }
149
+ }
150
+
151
+ window.history.pushState("", document.title, window.location.pathname + "?" + params.toString());
152
+ }
153
+
154
+ // Hide or show the time form field based on the value of the live checkbox.
155
+ function updateDisplayOfLiveOrTime(liveUpdated) {
156
+ const minutes = parseInt(selectedValue(document.getElementById("minutes")), 10);
157
+ const liveCheckboxContainer = document.getElementById("live-checkbox");
158
+ const liveCheckbox = document.getElementById("live");
159
+ const timeInput = document.getElementById("time");
160
+ const mobileTimeInput = document.querySelector(".flatpickr-mobile");
161
+ const updateChartsBtn = document.getElementById("update-charts");
162
+ if (minutes > 60) {
163
+ liveCheckboxContainer.style.display = "none";
164
+ liveCheckbox.checked = false;
165
+ timeInput.style.display = null;
166
+ if (mobileTimeInput) {
167
+ mobileTimeInput.style.display = null;
168
+ }
169
+ updateChartsBtn.style.display = null;
170
+ } else {
171
+ liveCheckboxContainer.style.display = null;
172
+ if (liveCheckbox.checked) {
173
+ timeInput.style.display = "none";
174
+ if (mobileTimeInput) {
175
+ mobileTimeInput.style.display = "none";
176
+ }
177
+ updateChartsBtn.style.display = "none";
178
+ } else {
179
+ timeInput.style.display = null;
180
+ if (mobileTimeInput) {
181
+ mobileTimeInput.style.display = null;
182
+ }
183
+ updateChartsBtn.style.display = null;
184
+ if (liveUpdated) {
185
+ if (metricData && metricData.times && metricData.times.length > 0) {
186
+ setTime(metricData.times[0])
187
+ } else {
188
+ timeInput.value = "";
189
+ }
190
+ }
191
+ }
192
+ }
193
+ }
194
+
195
+ // Plot the request time chart.
196
+ function plotRequestTime() {
197
+ const measurement = (param("measurement") || "avg");
198
+ const measurementData = metricData[measurement];
199
+ document.getElementById("avg-request-time").innerText = measurementData.avg.toLocaleString();
200
+ let interval = metricData.interval_minutes;
201
+ let units = "minute";
202
+ if (interval == 60) {
203
+ interval = 1;
204
+ units = "hour"
205
+ } else if (interval == 60 * 24) {
206
+ interval = 1
207
+ units = "day"
208
+ }
209
+ document.getElementById("interval-minutes").innerText = "" + interval + " " + units;
210
+ const data = [];
211
+ if (measurement === "avg") {
212
+ if (Object.keys(measurementData.data).length > 0) {
213
+ forEachComponentColor(Object.keys(measurementData.data), (name, color) => {
214
+ data.push({
215
+ name: name,
216
+ type: "bar",
217
+ marker: {
218
+ color: color
219
+ },
220
+ hovertemplate: name + " - %{y:,}ms; %{text} calls/request; %{x} (" + metricData.interval_minutes + "m)",
221
+ textposition: "none",
222
+ x: metricData.times,
223
+ y: measurementData.data[name]["time"],
224
+ text: measurementData.data[name]["count"]
225
+ });
226
+ });
227
+ } else {
228
+ data.push({
229
+ name: measurement,
230
+ type: "bar",
231
+ marker: {
232
+ color: "#00838F"
233
+ },
234
+ x: metricData.times,
235
+ y: metricData.times.map((t) => { return 0 })
236
+ });
237
+ }
238
+ } else {
239
+ data.push({
240
+ name: measurement,
241
+ type: "bar",
242
+ marker: {
243
+ color: "#00838F"
244
+ },
245
+ hovertemplate: "" + metricData.interval_minutes + "m: %{x} %{y:,}ms",
246
+ x: metricData.times,
247
+ y: measurementData.data
248
+ });
249
+ }
250
+
251
+ const layout = {
252
+ barmode: "stack",
253
+ xaxis: {
254
+ tickformat: "%I:%M%p\n%b %e, %Y"
255
+ },
256
+ yaxis: {
257
+ title: measurement + " ms",
258
+ fixedrange: true
259
+ },
260
+ margin: {
261
+ t: 10,
262
+ b: 50
263
+ }
264
+ };
265
+
266
+ const div = document.getElementById("request-timing");
267
+ Plotly.newPlot(div, data, layout, {displayModeBar: false, responsive: true});
268
+ div.on('plotly_relayout', updateChartsOnZoom);
269
+ }
270
+
271
+ // Plot the throughput chart.
272
+ function plotThroughput() {
273
+ document.getElementById("avg-requests-per-minute").innerText = metricData.throughput.avg.toLocaleString();
274
+ const data = {
275
+ name: "request / minute",
276
+ type: "bar",
277
+ marker: {
278
+ color: "#3949AB"
279
+ },
280
+ hovertemplate: "%{y:,} requests / minute; %{x} (" + metricData.interval_minutes + "m)",
281
+ x: metricData.times,
282
+ y: metricData.throughput.data,
283
+ }
284
+ const layout = {
285
+ xaxis: {
286
+ tickformat: "%I:%M%p\n%b %e, %Y"
287
+ },
288
+ yaxis: {
289
+ title: "Request / Minute",
290
+ fixedrange: true
291
+ },
292
+ margin: {
293
+ t: 10,
294
+ b: 50
295
+ }
296
+ };
297
+ const div = document.getElementById("throughput");
298
+ Plotly.newPlot(div, [data], layout, {displayModeBar: false, responsive: true});
299
+ div.on('plotly_relayout', updateChartsOnZoom);
300
+ }
301
+
302
+ // Plot the error count chart.
303
+ function plotErrorCounts() {
304
+ document.getElementById("avg-errors-per-minute").innerText = metricData.errors.avg.toLocaleString();
305
+ const data = {
306
+ name: "errors",
307
+ type: "bar",
308
+ marker: {
309
+ color: "#D32F2F"
310
+ },
311
+ hovertemplate: "%{y:,} errors; %{x} (" + metricData.interval_minutes + "m)",
312
+ x: metricData.times,
313
+ y: metricData.errors.data,
314
+ }
315
+ const layout = {
316
+ xaxis: {
317
+ tickformat: "%I:%M%p\n%b %e, %Y"
318
+ },
319
+ yaxis: {
320
+ title: "Errors",
321
+ fixedrange: true
322
+ },
323
+ margin: {
324
+ t: 10,
325
+ b: 50
326
+ }
327
+ };
328
+ const div = document.getElementById("error-count");
329
+ Plotly.newPlot(div, [data], layout, {displayModeBar: false, responsive: true});
330
+ div.on('plotly_relayout', updateChartsOnZoom);
331
+ }
332
+
333
+ // Plot the error rate chart.
334
+ function plotErrorRate() {
335
+ const avgErrorRateStr = metricData.error_rate.avg.toLocaleString(navigator.language, {style: "percent", minimumFractionDigits: 2});
336
+ document.getElementById("avg-error-rate").innerText = avgErrorRateStr;
337
+ const data = {
338
+ name: "error rate",
339
+ type: "line",
340
+ marker: {
341
+ color: "#D32F2F"
342
+ },
343
+ hovertemplate: "%{y:.3f} / minute; %{x} (" + metricData.interval_minutes + "m)",
344
+ x: metricData.times,
345
+ y: metricData.error_rate.data,
346
+ }
347
+ const layout = {
348
+ xaxis: {
349
+ tickformat: "%I:%M%p\n%b %e, %Y"
350
+ },
351
+ yaxis: {
352
+ tickformat: ".3%",
353
+ fixedrange: true
354
+ },
355
+ margin: {
356
+ t: 10,
357
+ b: 50
358
+ }
359
+ }
360
+ const div = document.getElementById("error-rate");
361
+ Plotly.newPlot(div, [data], layout, {displayModeBar: false, responsive: true});
362
+ div.on('plotly_relayout', updateChartsOnZoom);
363
+ }
364
+
365
+ // Plot the action load percentages chart.
366
+ function plotActionLoad() {
367
+ const actions = [];
368
+ const loads = [];
369
+ const n = (actionData.actions.length < 25 ? actionData.actions.length : 25);
370
+ for (let i = 0; i < n; i++) {
371
+ actions.push(actionData.actions[i].name);
372
+ loads.push(actionData.actions[i].load);
373
+ }
374
+ actions.reverse();
375
+ loads.reverse();
376
+ const data = {
377
+ name: "actions",
378
+ type: "bar",
379
+ orientation: "h",
380
+ marker: {
381
+ color: "#00796B"
382
+ },
383
+ y: actions,
384
+ x: loads
385
+ }
386
+ const layout = {
387
+ autosize: false,
388
+ width: document.getElementById("action-load").clientWidth,
389
+ height: 180 + (actions.length * 25),
390
+ xaxis: {
391
+ tickformat: ".1%",
392
+ fixedrange: true
393
+ },
394
+ yaxis: {
395
+ automargin: true,
396
+ fixedrange: true
397
+ },
398
+ margin: {
399
+ t: 10,
400
+ b: 50
401
+ }
402
+ }
403
+ Plotly.newPlot('action-load', [data], layout, {displayModeBar: false, responsive: true});
404
+ }
405
+
406
+ // Show captured error information in a table.
407
+ function showErrors() {
408
+ const rowTemplate = document.getElementById("error-table-row");
409
+ const tbody = document.getElementById("error-table").querySelector("tbody");
410
+ tbody.innerHTML = "";
411
+ errorData.errors.forEach((error) => {
412
+ const template = document.createElement('template');
413
+ template.innerHTML = rowTemplate.innerHTML.trim();
414
+ const row = template.content.firstChild;
415
+ let errorDescription = error.class_name;
416
+ if (error.message && error.message !== "") {
417
+ errorDescription += " (" + error.message + ")";
418
+ }
419
+ row.querySelector(".error-class-name").innerText = errorDescription;
420
+ row.querySelector(".error-count").innerText = error.count;
421
+ const backtrace = row.querySelector(".error-backtrace");
422
+ error.backtrace.forEach((line) => {
423
+ const div = document.createElement("div");
424
+ div.classList.add("backtrace-line")
425
+ div.innerText = line;
426
+ backtrace.append(div);
427
+ })
428
+ tbody.append(row);
429
+ });
430
+ }
431
+
432
+ // Iterate over components with consistent colors for well know components.
433
+ function forEachComponentColor(names, callback) {
434
+ const usedNames = {};
435
+ Object.keys(COMPONENT_COLORS).forEach((name) => {
436
+ if (names.includes(name)) {
437
+ usedNames[name] = true;
438
+ callback(name, COMPONENT_COLORS[name]);
439
+ }
440
+ });
441
+ index = 0;
442
+ names.forEach((name) => {
443
+ if (!usedNames[name]) {
444
+ const color = OTHER_COMPONENT_COLORS[index % OTHER_COMPONENT_COLORS.length];
445
+ callback(name, color);
446
+ }
447
+ });
448
+ }
449
+
450
+ // Return a query parameter by name.
451
+ function param(name) {
452
+ return new URLSearchParams(window.location.search).get(name);
453
+ }
454
+
455
+ // Serialize the current form state into query parameters.
456
+ function selectedParams() {
457
+ const params = new URLSearchParams();
458
+ ["env", "app", "host", "action", "minutes"].forEach((name) => {
459
+ params.set(name, selectedValue(document.getElementById(name)));
460
+ });
461
+ if (!document.getElementById("live").checked) {
462
+ const time = timePicker.selectedDates[0]
463
+ if (time) {
464
+ params.set("time", time.toISOString());
465
+ } else {
466
+ params.delete("time")
467
+ }
468
+ }
469
+ return params
470
+ }
471
+
472
+ // Get the current value from a form element.
473
+ function selectedValue(element) {
474
+ if (element.type === "select-one") {
475
+ const option = element.options[element.selectedIndex];
476
+ if (option) {
477
+ return option.value;
478
+ } else {
479
+ return "";
480
+ }
481
+ } else if (element.type === "checkbox") {
482
+ if (element.checked) {
483
+ return element.value;
484
+ } else {
485
+ return null;
486
+ }
487
+ } else {
488
+ return element.value;
489
+ }
490
+ }
491
+
492
+ // Set the value of a form element.
493
+ function setSelectedValue(paramName) {
494
+ const value = (param(paramName) || "");
495
+ const element = document.getElementById(paramName);
496
+ if (element.type === "select-one") {
497
+ for (let i = 0; i < element.options.length; i++) {
498
+ if (element.options[i].value === value) {
499
+ element.selectedIndex = i;
500
+ setSelect2Value(paramName);
501
+ break;
502
+ }
503
+ }
504
+ } else if (element.type === "checkbox") {
505
+ element.checked = (value === "1");
506
+ } else if (paramName === "time") {
507
+ if (value && value !== "") {
508
+ const time = new Date(Date.parse(value));
509
+ timePicker.setDate(time);
510
+ } else {
511
+ element.value = "";
512
+ }
513
+ } else {
514
+ element.value = value;
515
+ }
516
+ }
517
+
518
+ function setSelect2Value(id) {
519
+ const select2 = select2Menus[id];
520
+ if (!select2) {
521
+ return;
522
+ }
523
+ const selectedIndex = document.getElementById(id).selectedIndex;
524
+ select2.selectedOptions = [select2.options[selectedIndex]];
525
+ for (let i = 0; i < select2.options.length; i++) {
526
+ if (i === selectedIndex) {
527
+ select2.options[i].element.classList.add("selected");
528
+ } else {
529
+ select2.options[i].element.classList.remove("selected");
530
+ }
531
+ }
532
+ select2._renderSelectedItems();
533
+ }
534
+
535
+ // Set menu options on a select element to the specified values. Options with
536
+ // empty values will be retained since these are for "All" selectors.
537
+ function setMenuOptions(id, values) {
538
+ const menu = document.getElementById(id);
539
+ const selection = param(id);
540
+ if (selection && values.indexOf(selection) < 0) {
541
+ values.push(selection);
542
+ }
543
+ while (menu.options.length > 0 && menu.options[menu.options.length - 1].value !== "") {
544
+ menu.options.remove(menu.options.length - 1)
545
+ }
546
+ values.forEach((value) => {
547
+ const option = document.createElement("option");
548
+ option.value = value;
549
+ option.text = value;
550
+ menu.options.add(option);
551
+ if (selection === value) {
552
+ menu.selectedIndex = menu.options.length - 1;
553
+ }
554
+ });
555
+ select2Menus[id].update();
556
+ setSelect2Value(id);
557
+ }
558
+
559
+ // Set the time field from a Date object.
560
+ function setTime(date) {
561
+ if (!date) {
562
+ timePicker.clear();
563
+ } else {
564
+ if (typeof date === "string") {
565
+ date = new Date(Date.parse(value));
566
+ }
567
+ timePicker.setDate(date);
568
+ }
569
+ }
570
+
571
+ function setMinutes(value) {
572
+ if (!value) {
573
+ value = 30;
574
+ }
575
+ value = "" + value;
576
+ const minutes = document.getElementById("minutes");
577
+ let foundIndex = -1;
578
+ for (let i = 0; i < minutes.options.length; i++) {
579
+ const option = minutes.options[i];
580
+ if (option.value === value) {
581
+ foundIndex = i;
582
+ break;
583
+ }
584
+ }
585
+ if (foundIndex < 0) {
586
+ const customOption = document.createElement("option");
587
+ customOption.value = value;
588
+ customOption.innerText = value + " minutes";
589
+ customOption.classList.add("custom-minutes");
590
+ minutes.prepend(customOption);
591
+ foundIndex = 0
592
+ }
593
+ minutes.selectedIndex = foundIndex;
594
+ removeCustomMinutes();
595
+ select2Menus.minutes.update();
596
+ setSelect2Value("minutes");
597
+ }
598
+
599
+ function removeCustomMinutes() {
600
+ document.querySelectorAll("#minutes .custom-minutes").forEach((element) => {
601
+ if (!element.selected) {
602
+ element.remove();
603
+ }
604
+ });
605
+ }
606
+
607
+ // Pad a number with the specified number of zeros.
608
+ function lpadNumber(number) {
609
+ const padded = "0" + number;
610
+ return padded.substring(padded.length - 2, padded.length);
611
+ }
612
+
613
+ // Get the URL for making API calls.
614
+ function apiURL(action, params) {
615
+ let url = window.location.pathname;
616
+ if (!url.endsWith("/")) {
617
+ url += "/";
618
+ }
619
+ url += action + "?" + params.toString();
620
+ return url;
621
+ }
622
+
623
+ // Call the API with the path and params specified. If spinnerId is provided,
624
+ // that element will be shown and then hidden to indicate something is happening.
625
+ // The callback function will be called with the API response.
626
+ function callAPI(path, params, spinnerId, callback) {
627
+ const spinner = (spinnerId ? document.getElementById(spinnerId) : null);
628
+
629
+ const fetchOptions = {credentials: "same-origin"};
630
+ const headers = new Headers({"Accept": "application/json"});
631
+ const accessToken = window.sessionStorage.getItem("access_token")
632
+ if (accessToken) {
633
+ headers["Authorization"] = "Bearer " + accessToken;
634
+ }
635
+ fetchOptions["headers"] = headers;
636
+ const url = apiURL(path, params);
637
+
638
+ if (spinner) {
639
+ spinner.style.display = "block";
640
+ }
641
+
642
+ fetch(url, fetchOptions)
643
+ .then((response) => {
644
+ if (response.ok) {
645
+ return response.json();
646
+ } else {
647
+ throw(response)
648
+ }
649
+ })
650
+ .then(callback)
651
+ .then(hideAlert)
652
+ .catch((error) => {
653
+ console.error(error)
654
+ if (error.status === 401 || error.status === 403) {
655
+ if (authenticationUrl()) {
656
+ window.location = authenticationUrl();
657
+ } else {
658
+ showAlert("Access denied. You need to re-authenticate to continue.");
659
+ }
660
+ } else {
661
+ showAlert("Sorry, an error occurred fetching data.");
662
+ }
663
+ })
664
+ .finally(() => {
665
+ if (spinner) {
666
+ spinner.style.display = "none";
667
+ }
668
+ });
669
+ }
670
+
671
+ // Support integration into single page applications where OAuth2 access tokens are used.
672
+ // The access token can be passed either in the access_token query parameter per the
673
+ // OAuth2 standard, or in the URL hash. Passing it in the hash will prevent it from ever
674
+ // being sent to the backend and is a bit more secure since there's no chance a web server
675
+ // will accidentally log it with the request URL.
676
+ function storeAccessToken() {
677
+ let accessToken = null;
678
+ if (param("access_token")) {
679
+ accessToken = param("access_token");
680
+ }
681
+ if (window.location.hash.startsWith("#access_token=")) {
682
+ accessToken = window.location.hash.replace("#access_token=", "");
683
+ }
684
+ if (accessToken) {
685
+ window.sessionStorage.setItem("access_token", accessToken);
686
+ const params = new URLSearchParams(window.location.search);
687
+ params.delete("access_token");
688
+ window.location.hash = null;
689
+ window.history.replaceState("", document.title, window.location.pathname + "?" + params.toString());
690
+ }
691
+ }
692
+
693
+ // Generate a CSV dump of the current data.
694
+ function generateCSV() {
695
+ const rows = []
696
+ const componentNames = Object.keys(metricData.avg.data);
697
+ const headers = ["time", "requests per minute", "errors", "error rate", "p50 request time", "p90 request time", "p99 request time", "average request time"]
698
+ componentNames.forEach((name) => {
699
+ headers.push("average " + name + " time");
700
+ headers.push("average " + name + " count");
701
+ });
702
+ rows.push(headers);
703
+ for (let i = 0; i < metricData.times.length; i += 1) {
704
+ const row = [metricData.times[i], metricData.throughput.data[i], metricData.errors.data[i], metricData.error_rate.data[i], metricData.p50.data[i], metricData.p90.data[i], metricData.p99.data[i]];
705
+ let total = 0;
706
+ let componentData = [];
707
+ componentNames.forEach((name) => {
708
+ const t = metricData.avg.data[name]["time"][i];
709
+ const c = metricData.avg.data[name]["count"][i];
710
+ componentData.push(t);
711
+ componentData.push(c);
712
+ total += t;
713
+ });
714
+ row.push(total);
715
+ rows.push(row.concat(componentData));
716
+ }
717
+ return rows.join("\n")
718
+ }
719
+
720
+ function showAlert(message) {
721
+ const alertDiv = document.getElementById("alert");
722
+ alertDiv.innerText = message;
723
+ alertDiv.style.display = "block";
724
+ }
725
+
726
+ function hideAlert() {
727
+ document.getElementById("alert").style.display = "none";
728
+ }
729
+
730
+ // Show a modal window overlayed on the page.
731
+ function showModal() {
732
+ const modal = document.querySelector("#modal");
733
+ modal.style.display = "block";
734
+ modal.setAttribute("aria-hidden", "false");
735
+ modal.activator = document.activeElement;
736
+ focusableElements(document).forEach(function(element) {
737
+ if (!modal.contains(element)) {
738
+ element.dataset.saveTabIndex = element.getAttribute("tabindex");
739
+ element.setAttribute("tabindex", -1);
740
+ }
741
+ });
742
+ document.querySelector("body").style.overflow = "hidden";
743
+ }
744
+
745
+ // Hide the modal window overlayed on the page.
746
+ function hideModal() {
747
+ const modal = document.querySelector("#modal");
748
+ modal.style.display = "none";
749
+ modal.setAttribute("aria-hidden", "true");
750
+ focusableElements(document).forEach(function(element) {
751
+ const tabIndex = element.dataset.saveTabIndex;
752
+ delete element.dataset.saveTabIndex;
753
+ if (tabIndex) {
754
+ element.setAttribute("tabindex", tabIndex);
755
+ }
756
+ });
757
+ if (modal.activator) {
758
+ modal.activator.focus();
759
+ delete modal.activator;
760
+ }
761
+ document.querySelector("body").style.overflow = "visible";
762
+ }
763
+
764
+ // Returns a list of all focusable elements so that they can be set to not take the focus
765
+ // when a modal is opened.
766
+ function focusableElements(parent) {
767
+ return parent.querySelectorAll("a[href], area[href], button, input:not([type=hidden]), select, textarea, iframe, [tabindex], [contentEditable=true]")
768
+ }
769
+
770
+ function authenticationUrl() {
771
+ document.querySelector("body").dataset["authenticationUrl"];
772
+ }
773
+
774
+ // Add event listeners.
775
+ document.getElementById("update-charts").addEventListener("click", (event) => {
776
+ updateCharts();
777
+ });
778
+
779
+ document.getElementById("previous").addEventListener("click", (event) => {
780
+ document.getElementById("live").checked = false;
781
+ const time = new Date(metricData.times[0].getTime() - (metricData.minutes * 60000));
782
+ setTime(time);
783
+ updateCharts();
784
+ });
785
+
786
+ document.getElementById("next").addEventListener("click", (event) => {
787
+ document.getElementById("live").checked = false;
788
+ const time = new Date(metricData.times[metricData.times.length - 1].getTime() + (metricData.interval_minutes * 60000));
789
+ setTime(time);
790
+ updateCharts();
791
+ });
792
+
793
+ document.getElementById("env").addEventListener("change", () => {
794
+ document.getElementById("host").selectedIndex = 0;
795
+ document.getElementById("action").selectedIndex = 0;
796
+ updateCharts();
797
+ });
798
+ document.getElementById("app").addEventListener("change", () => {
799
+ document.getElementById("host").selectedIndex = 0;
800
+ document.getElementById("action").selectedIndex = 0;
801
+ updateCharts();
802
+ });
803
+ document.getElementById("host").addEventListener("change", () => {
804
+ updateCharts();
805
+ });
806
+ document.getElementById("action").addEventListener("change", () => {
807
+ updateCharts();
808
+ });
809
+ document.getElementById("time").addEventListener("change", () => {
810
+ updateCharts();
811
+ });
812
+ document.getElementById("live").addEventListener("change", () => {
813
+ updateDisplayOfLiveOrTime(true);
814
+ updateCharts();
815
+ });
816
+ document.getElementById("minutes").addEventListener("change", () => {
817
+ updateDisplayOfLiveOrTime(false);
818
+ removeCustomMinutes();
819
+ updateCharts();
820
+ });
821
+
822
+ document.getElementById("measurement").addEventListener("change", (event) => {
823
+ updateWindowLocation();
824
+ plotRequestTime();
825
+ });
826
+
827
+ document.getElementById("show-error-details").addEventListener("click", (event) => {
828
+ event.target.style.display = "none";
829
+ callAPI("errors", currentParams, "details-loading-spinner", (data) => {
830
+ errorData = data;
831
+ document.getElementById("error-details").style.display = "block";
832
+ showErrors();
833
+ });
834
+ });
835
+
836
+ document.getElementById("show-action-details").addEventListener("click", (event) => {
837
+ event.target.style.display = "none";
838
+ const params = new URLSearchParams(window.location.search);
839
+ callAPI("actions", currentParams, "details-loading-spinner", (data) => {
840
+ actionData = data;
841
+ document.getElementById("action-details").style.display = "block";
842
+ plotActionLoad();
843
+ });
844
+ });
845
+
846
+ document.getElementById("download-data").addEventListener("click", (event) => {
847
+ const link = document.createElement("a");
848
+ const blob = new Blob([generateCSV()],{type: "text/csv; charset=utf-8;"});
849
+ const url = URL.createObjectURL(blob);
850
+ link.href = url;
851
+ link.setAttribute("download", "apm-data.csv");
852
+ link.click();
853
+ });
854
+
855
+ document.getElementById("help").addEventListener("click", (event) => {
856
+ event.preventDefault();
857
+ showModal();
858
+ });
859
+
860
+ document.getElementById("modal").addEventListener("click", (event) => {
861
+ if (event.target.classList.contains("js-close-modal")) {
862
+ event.preventDefault();
863
+ hideModal();
864
+ }
865
+ });
866
+
867
+ function initializeSettings() {
868
+ // Set up default values from the current page URL.
869
+ setSelectedValue("env");
870
+ setSelectedValue("app");
871
+ setSelectedValue("host");
872
+ setSelectedValue("action");
873
+ setMinutes(param("minutes"));
874
+ setSelectedValue("live");
875
+ if (document.getElementById("live").checked) {
876
+ document.getElementById("time").value = "";
877
+ document.getElementById("time").style.display = "none";
878
+ } else {
879
+ setSelectedValue("time");
880
+ }
881
+ setSelectedValue("measurement");
882
+ updateDisplayOfLiveOrTime(false);
883
+ updateCharts(false);
884
+ }
885
+
886
+ window.addEventListener("popstate", initializeSettings);
887
+
888
+ // Initialize the application
889
+ let metricData = null;
890
+ let errorData = null;
891
+ let actionData = null;
892
+ let liveUpdateId = null;
893
+ let currentParams = null;
894
+
895
+ const select2Menus = {}
896
+ document.querySelectorAll("select").forEach((select) => {
897
+ const searchable = select.dataset.searchable;
898
+ const select2 = NiceSelect.bind(select, {searchable: searchable});
899
+ if (select.id) {
900
+ select2Menus[select.id] = select2;
901
+ }
902
+ });
903
+
904
+ const timePicker = flatpickr("#time", {
905
+ enableTime: true,
906
+ dateFormat: "M j, Y, h:iK"
907
+ });
908
+
909
+ storeAccessToken();
910
+ initializeSettings();
911
+ });