From 470e867db077dae42fc8eead00b5184ce7e5ebd2 Mon Sep 17 00:00:00 2001 From: yanscalyr <57232095+yanscalyr@users.noreply.github.com> Date: Tue, 29 Sep 2020 20:22:11 -0700 Subject: [PATCH] Grafana annotation support (#52) * Basic annotation support * Linting * Add optional field mappings for annotations * Linting * Extra unit tests * Review response and a fix * Commit build * Don't pass along an annotation if there is no timestamp * Test updates * Review responses * Review responses --- dist/module.js | 6 +- dist/plugin.json | 7 ++- src/datasource.js | 64 ++++++++++++++++++++ src/module.js | 3 +- src/partials/annotations.editor.html | 29 +++++++++ src/plugin.json | 7 ++- src/specs/datasource.test.js | 88 ++++++++++++++++++++++++++++ 7 files changed, 198 insertions(+), 6 deletions(-) create mode 100644 src/partials/annotations.editor.html diff --git a/dist/module.js b/dist/module.js index 8192c8f..cd46b89 100644 --- a/dist/module.js +++ b/dist/module.js @@ -106,7 +106,7 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) * /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"GenericDatasource\", function() { return GenericDatasource; });\n/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash */ \"lodash\");\n/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util */ \"./util.js\");\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\n\n\nvar GenericDatasource =\n/*#__PURE__*/\nfunction () {\n /**\n * Constructor\n * @param {*} instanceSettings \n * @param {*} $q \n * @param {*} backendSrv \n * @param {*} templateSrv \n */\n function GenericDatasource(instanceSettings, $q, backendSrv, templateSrv) {\n _classCallCheck(this, GenericDatasource);\n\n this.type = instanceSettings.type;\n this.url = instanceSettings.url;\n this.name = instanceSettings.name;\n this.apiKey = instanceSettings.jsonData.scalyrApiKey;\n this.scalyrUrl = instanceSettings.jsonData.scalyrUrl;\n this.backendSrv = backendSrv;\n this.q = $q;\n this.templateSrv = templateSrv;\n this.headers = {\n 'Content-Type': 'application/json'\n };\n this.queryTypes = {\n POWER_QUERY: 'Power Query',\n STANDARD_QUERY: 'Standard Query'\n };\n this.visualizationType = {\n GRAPH: 'graph',\n TABLE: 'table'\n };\n }\n /**\n * Grafana uses this function to initiate all queries\n * @param {*} options - query settings/options https://grafana.com/docs/plugins/developing/datasources/#query\n */\n\n\n _createClass(GenericDatasource, [{\n key: \"query\",\n value: function query(options) {\n var queryType = options.targets[0].queryType;\n\n if (!options.targets.every(function (x) {\n return x.queryType === queryType;\n })) {\n return {\n status: \"error\",\n message: \"All queries should have the same query type.\"\n };\n }\n\n if (queryType === this.queryTypes.POWER_QUERY) {\n if (options.targets.length === 1) {\n var panelType = options.targets[0].panelType;\n return this.performPowerQuery(options, panelType);\n }\n\n return {\n status: \"error\",\n message: \"You can only have one power query per panel.\"\n };\n }\n\n return this.performTimeseriesQuery(options);\n }\n /**\n * Grafana uses this function to test data source settings. \n * This verifies API key using the facet query API. \n * The endpoint returns 401 if the token is invalid.\n */\n\n }, {\n key: \"testDatasource\",\n value: function testDatasource() {\n return this.backendSrv.datasourceRequest({\n url: this.url + '/facetQuery',\n data: JSON.stringify({\n token: this.apiKey,\n queryType: 'facet',\n filter: '',\n startTime: new Date().getTime(),\n endTime: new Date().getTime(),\n field: 'XYZ'\n }),\n method: 'POST'\n }).then(function (response) {\n if (response && response.status && response.status === 200) {\n return {\n status: \"success\",\n message: \"Successfully connected to Scalyr!\"\n };\n } // We will never hit this but eslint complains about lack of return\n\n\n return {\n status: \"error\",\n message: \"Scalyr returned HTTP code \".concat(response.status)\n };\n })[\"catch\"](function (err) {\n var message = \"Cannot connect to Scalyr!\";\n\n if (err && err.data && err.data.message) {\n message = \"\".concat(message, \" Scalyr repsponse - \").concat(err.data.message);\n }\n\n return {\n status: \"error\",\n message: message\n };\n });\n }\n /**\n * Grafana uses this function to load metric values. \n * @param {*} query - query options\n */\n\n }, {\n key: \"metricFindQuery\",\n value: function metricFindQuery(query) {\n var d = new Date();\n d.setHours(d.getHours() - 6);\n return this.backendSrv.datasourceRequest({\n url: this.url + '/facetQuery',\n data: JSON.stringify({\n token: this.apiKey,\n queryType: 'facet',\n filter: '',\n startTime: d.getTime(),\n endTime: new Date().getTime(),\n field: query\n }),\n method: 'POST'\n }).then(function (response) {\n var values = lodash__WEBPACK_IMPORTED_MODULE_0___default.a.get(response, 'data.values', []);\n\n return values.map(function (value) {\n return {\n text: value.value,\n value: value.value\n };\n });\n });\n }\n /**\n * Default interpolator for Grafana variables for this datasource\n *\n * @param value The value of this variable\n * @param variable The Grafana variable information\n * @returns {string}\n */\n\n }, {\n key: \"createTimeSeriesQuery\",\n\n /**\n * Create a request to the scalyr time series endpoint.\n * @param {*} options \n */\n value: function createTimeSeriesQuery(options) {\n var _this = this;\n\n var queries = [];\n options.targets.forEach(function (target) {\n var queryText = _this.templateSrv.replace(target.queryText, options.scopedVars, GenericDatasource.interpolateVariable);\n\n var facetFunction = '';\n\n if (target.facet) {\n facetFunction = \"\".concat(target[\"function\"] || 'count', \"(\").concat(target.facet, \")\");\n }\n\n var query = {\n startTime: options.range.from.valueOf(),\n endTime: options.range.to.valueOf(),\n buckets: GenericDatasource.getNumberOfBuckets(options),\n filter: queryText,\n \"function\": facetFunction\n };\n queries.push(query);\n });\n return {\n url: this.url + '/timeSeriesApi',\n method: 'POST',\n headers: this.headers,\n data: JSON.stringify({\n token: this.apiKey,\n queries: queries\n })\n };\n }\n /**\n * Get how many buckets to return based on the query time range\n * @param {*} options \n */\n\n }, {\n key: \"performTimeseriesQuery\",\n\n /**\n * Perform the timeseries query using the Grafana proxy.\n * @param {*} options \n */\n value: function performTimeseriesQuery(options) {\n var query = this.createTimeSeriesQuery(options);\n return this.backendSrv.datasourceRequest(query).then(function (response) {\n var data = response.data;\n return GenericDatasource.transformTimeSeriesResults(data.results, options);\n });\n }\n /**\n * Transform data returned by time series query into Grafana timeseries format.\n * https://grafana.com/docs/plugins/developing/datasources/#query\n * @param results\n * @param conversionFactor conversion factor to be applied to each data point. This can be used to for example convert bytes to MB.\n * @returns {{data: Array}}\n */\n\n }, {\n key: \"createPowerQuery\",\n\n /**\n * Create powerquery query to pass to Grafana proxy.\n * @param queryText text of the query\n * @param startTime start time\n * @param endTime end time\n * @returns {{url: string, method: string, headers: {\"Content-Type\": string}, data: string}}\n */\n value: function createPowerQuery(queryText, startTime, endTime) {\n var query = {\n token: this.apiKey,\n query: queryText,\n startTime: startTime,\n endTime: endTime\n };\n return {\n url: this.url + '/powerQuery',\n method: 'POST',\n headers: this.headers,\n data: JSON.stringify(query)\n };\n }\n /**\n * Perform the powerquery using Grafana proxy.\n * @param options\n * @returns {Promise<{data: *[]}> | *}\n */\n\n }, {\n key: \"performPowerQuery\",\n value: function performPowerQuery(options, visualizationType) {\n var _this2 = this;\n\n var target = options.targets[0];\n var query = this.createPowerQuery(target.queryText, options.range.from.valueOf(), options.range.to.valueOf());\n return this.backendSrv.datasourceRequest(query).then(function (response) {\n var data = response && response.data;\n return _this2.transformPowerQueryData(data, visualizationType);\n });\n }\n /**\n * Transform power query data based on the visualization type\n * @param data data returned by the power query API\n * @returns {{data: Object[]}} transformed data that can be used by Grafana\n */\n\n }, {\n key: \"transformPowerQueryData\",\n value: function transformPowerQueryData(data, visualizationType) {\n if (visualizationType === this.visualizationType.TABLE) {\n return this.transformPowerQueryDataToTable(data);\n }\n\n return GenericDatasource.transformPowerQueryDataToGraph(data);\n }\n /**\n * Transform data returned by power query to a graph format.\n * Each row is an individual series; this helps in looking at each value as bar in graphs.\n * @param {*} data \n */\n\n }, {\n key: \"transformPowerQueryDataToTable\",\n\n /**\n * Transform Power Query Data in table format that Grafana needs.\n * https://grafana.com/docs/plugins/developing/datasources/#query\n * @param data\n * @returns {{data: *[]}}\n */\n value: function transformPowerQueryDataToTable(data) {\n var cloneData = lodash__WEBPACK_IMPORTED_MODULE_0___default.a.clone(data);\n\n cloneData.columns.map(function (col) {\n col.text = col.name;\n return col;\n });\n return {\n data: [{\n type: this.visualizationType.TABLE,\n columns: cloneData.columns,\n rows: cloneData.values\n }]\n };\n }\n }], [{\n key: \"interpolateVariable\",\n value: function interpolateVariable(value, variable) {\n if (typeof value === 'string') {\n if (variable.multi || variable.includeAll) {\n return \"'\" + value.replace(/'/g, \"''\") + \"'\";\n }\n\n return value;\n }\n\n if (typeof value === 'number') {\n return value;\n }\n\n var quotedValues = lodash__WEBPACK_IMPORTED_MODULE_0___default.a.map(value, function (val) {\n if (typeof value === 'number') {\n return value;\n }\n\n return \"'\" + val.replace(/'/g, \"''\") + \"'\";\n });\n\n return quotedValues.join(',');\n }\n }, {\n key: \"getNumberOfBuckets\",\n value: function getNumberOfBuckets(options) {\n return Math.floor((options.range.to.valueOf() - options.range.from.valueOf()) / options.intervalMs);\n }\n }, {\n key: \"transformTimeSeriesResults\",\n value: function transformTimeSeriesResults(results, options) {\n var graphs = {\n data: []\n };\n results.forEach(function (result, index) {\n var timeStamp = options.range.from.valueOf();\n var dataValues = result.values;\n var currentTarget = options.targets[index];\n var responseObject = {\n target: currentTarget.label || currentTarget.queryText,\n datapoints: []\n };\n var conversionFactor = Object(_util__WEBPACK_IMPORTED_MODULE_1__[\"getValidConversionFactor\"])(currentTarget.conversionFactor);\n\n for (var i = 0; i < dataValues.length; i += 1) {\n var dataValue = dataValues[i] * conversionFactor;\n responseObject.datapoints.push([dataValue, timeStamp]);\n timeStamp += options.intervalMs;\n }\n\n graphs.data.push(responseObject);\n });\n return graphs;\n }\n }, {\n key: \"transformPowerQueryDataToGraph\",\n value: function transformPowerQueryDataToGraph(data) {\n var result = [];\n var values = data.values;\n\n for (var i = 0; i < values.length; i += 1) {\n var dataValue = values[i];\n\n for (var j = 1; j < dataValue.length; j += 1) {\n var responseObject = {\n target: dataValue[0] + \": \" + data.columns[j].name,\n datapoints: [[dataValue[j], Date.now()]]\n };\n result.push(responseObject);\n }\n }\n\n return {\n data: result\n };\n }\n }]);\n\n return GenericDatasource;\n}();\n\n//# sourceURL=webpack:///./datasource.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"GenericDatasource\", function() { return GenericDatasource; });\n/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! lodash */ \"lodash\");\n/* harmony import */ var lodash__WEBPACK_IMPORTED_MODULE_0___default = /*#__PURE__*/__webpack_require__.n(lodash__WEBPACK_IMPORTED_MODULE_0__);\n/* harmony import */ var _util__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./util */ \"./util.js\");\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\nfunction _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if (\"value\" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } }\n\nfunction _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; }\n\n\n\nvar GenericDatasource =\n/*#__PURE__*/\nfunction () {\n /**\n * Constructor\n * @param {*} instanceSettings \n * @param {*} $q \n * @param {*} backendSrv \n * @param {*} templateSrv \n */\n function GenericDatasource(instanceSettings, $q, backendSrv, templateSrv) {\n _classCallCheck(this, GenericDatasource);\n\n this.type = instanceSettings.type;\n this.url = instanceSettings.url;\n this.name = instanceSettings.name;\n this.apiKey = instanceSettings.jsonData.scalyrApiKey;\n this.scalyrUrl = instanceSettings.jsonData.scalyrUrl;\n this.backendSrv = backendSrv;\n this.q = $q;\n this.templateSrv = templateSrv;\n this.headers = {\n 'Content-Type': 'application/json'\n };\n this.queryTypes = {\n POWER_QUERY: 'Power Query',\n STANDARD_QUERY: 'Standard Query'\n };\n this.visualizationType = {\n GRAPH: 'graph',\n TABLE: 'table'\n };\n }\n /**\n * Grafana uses this function to initiate all queries\n * @param {*} options - query settings/options https://grafana.com/docs/plugins/developing/datasources/#query\n */\n\n\n _createClass(GenericDatasource, [{\n key: \"query\",\n value: function query(options) {\n var queryType = options.targets[0].queryType;\n\n if (!options.targets.every(function (x) {\n return x.queryType === queryType;\n })) {\n return {\n status: \"error\",\n message: \"All queries should have the same query type.\"\n };\n }\n\n if (queryType === this.queryTypes.POWER_QUERY) {\n if (options.targets.length === 1) {\n var panelType = options.targets[0].panelType;\n return this.performPowerQuery(options, panelType);\n }\n\n return {\n status: \"error\",\n message: \"You can only have one power query per panel.\"\n };\n }\n\n return this.performTimeseriesQuery(options);\n }\n }, {\n key: \"annotationQuery\",\n value: function annotationQuery(options) {\n var query = this.createLogsQueryForAnnotation(options);\n return this.backendSrv.datasourceRequest(query).then(function (response) {\n var data = response.data;\n var timeField = options.annotation.timeField || \"timestamp\";\n var timeEndField = options.annotation.timeEndField || null;\n var textField = options.annotation.textField || \"message\";\n return GenericDatasource.transformAnnotationResults(data.matches, timeField, timeEndField, textField);\n });\n }\n /**\n * Grafana uses this function to test data source settings. \n * This verifies API key using the facet query API. \n * The endpoint returns 401 if the token is invalid.\n */\n\n }, {\n key: \"testDatasource\",\n value: function testDatasource() {\n return this.backendSrv.datasourceRequest({\n url: this.url + '/facetQuery',\n data: JSON.stringify({\n token: this.apiKey,\n queryType: 'facet',\n filter: '',\n startTime: new Date().getTime(),\n endTime: new Date().getTime(),\n field: 'XYZ'\n }),\n method: 'POST'\n }).then(function (response) {\n if (response && response.status && response.status === 200) {\n return {\n status: \"success\",\n message: \"Successfully connected to Scalyr!\"\n };\n } // We will never hit this but eslint complains about lack of return\n\n\n return {\n status: \"error\",\n message: \"Scalyr returned HTTP code \".concat(response.status)\n };\n })[\"catch\"](function (err) {\n var message = \"Cannot connect to Scalyr!\";\n\n if (err && err.data && err.data.message) {\n message = \"\".concat(message, \" Scalyr repsponse - \").concat(err.data.message);\n }\n\n return {\n status: \"error\",\n message: message\n };\n });\n }\n /**\n * Grafana uses this function to load metric values. \n * @param {*} query - query options\n */\n\n }, {\n key: \"metricFindQuery\",\n value: function metricFindQuery(query) {\n var d = new Date();\n d.setHours(d.getHours() - 6);\n return this.backendSrv.datasourceRequest({\n url: this.url + '/facetQuery',\n data: JSON.stringify({\n token: this.apiKey,\n queryType: 'facet',\n filter: '',\n startTime: d.getTime(),\n endTime: new Date().getTime(),\n field: query\n }),\n method: 'POST'\n }).then(function (response) {\n var values = lodash__WEBPACK_IMPORTED_MODULE_0___default.a.get(response, 'data.values', []);\n\n return values.map(function (value) {\n return {\n text: value.value,\n value: value.value\n };\n });\n });\n }\n /**\n * Default interpolator for Grafana variables for this datasource\n *\n * @param value The value of this variable\n * @param variable The Grafana variable information\n * @returns {string}\n */\n\n }, {\n key: \"createTimeSeriesQuery\",\n\n /**\n * Create a request to the scalyr time series endpoint.\n * @param {*} options \n */\n value: function createTimeSeriesQuery(options) {\n var _this = this;\n\n var queries = [];\n options.targets.forEach(function (target) {\n var queryText = _this.templateSrv.replace(target.queryText, options.scopedVars, GenericDatasource.interpolateVariable);\n\n var facetFunction = '';\n\n if (target.facet) {\n facetFunction = \"\".concat(target[\"function\"] || 'count', \"(\").concat(target.facet, \")\");\n }\n\n var query = {\n startTime: options.range.from.valueOf(),\n endTime: options.range.to.valueOf(),\n buckets: GenericDatasource.getNumberOfBuckets(options),\n filter: queryText,\n \"function\": facetFunction\n };\n queries.push(query);\n });\n return {\n url: this.url + '/timeSeriesApi',\n method: 'POST',\n headers: this.headers,\n data: JSON.stringify({\n token: this.apiKey,\n queries: queries\n })\n };\n }\n }, {\n key: \"createLogsQueryForAnnotation\",\n value: function createLogsQueryForAnnotation(options) {\n var queryText = this.templateSrv.replace(options.annotation.queryText, options.scopedVars, GenericDatasource.interpolateVariable);\n return {\n url: this.url + '/query',\n method: 'POST',\n headers: this.headers,\n data: JSON.stringify({\n token: this.apiKey,\n queryType: \"log\",\n filter: queryText,\n startTime: options.range.from.valueOf(),\n endTime: options.range.to.valueOf(),\n maxCount: 5000\n })\n };\n }\n /**\n * Get how many buckets to return based on the query time range\n * @param {*} options \n */\n\n }, {\n key: \"performTimeseriesQuery\",\n\n /**\n * Perform the timeseries query using the Grafana proxy.\n * @param {*} options \n */\n value: function performTimeseriesQuery(options) {\n var query = this.createTimeSeriesQuery(options);\n return this.backendSrv.datasourceRequest(query).then(function (response) {\n var data = response.data;\n return GenericDatasource.transformTimeSeriesResults(data.results, options);\n });\n }\n /**\n * Transform data returned by time series query into Grafana timeseries format.\n * https://grafana.com/docs/plugins/developing/datasources/#query\n * @param results\n * @param conversionFactor conversion factor to be applied to each data point. This can be used to for example convert bytes to MB.\n * @returns {{data: Array}}\n */\n\n }, {\n key: \"createPowerQuery\",\n\n /**\n * Create powerquery query to pass to Grafana proxy.\n * @param queryText text of the query\n * @param startTime start time\n * @param endTime end time\n * @returns {{url: string, method: string, headers: {\"Content-Type\": string}, data: string}}\n */\n value: function createPowerQuery(queryText, startTime, endTime) {\n var query = {\n token: this.apiKey,\n query: queryText,\n startTime: startTime,\n endTime: endTime\n };\n return {\n url: this.url + '/powerQuery',\n method: 'POST',\n headers: this.headers,\n data: JSON.stringify(query)\n };\n }\n /**\n * Perform the powerquery using Grafana proxy.\n * @param options\n * @returns {Promise<{data: *[]}> | *}\n */\n\n }, {\n key: \"performPowerQuery\",\n value: function performPowerQuery(options, visualizationType) {\n var _this2 = this;\n\n var target = options.targets[0];\n var query = this.createPowerQuery(target.queryText, options.range.from.valueOf(), options.range.to.valueOf());\n return this.backendSrv.datasourceRequest(query).then(function (response) {\n var data = response && response.data;\n return _this2.transformPowerQueryData(data, visualizationType);\n });\n }\n /**\n * Transform power query data based on the visualization type\n * @param data data returned by the power query API\n * @returns {{data: Object[]}} transformed data that can be used by Grafana\n */\n\n }, {\n key: \"transformPowerQueryData\",\n value: function transformPowerQueryData(data, visualizationType) {\n if (visualizationType === this.visualizationType.TABLE) {\n return this.transformPowerQueryDataToTable(data);\n }\n\n return GenericDatasource.transformPowerQueryDataToGraph(data);\n }\n /**\n * Transform data returned by power query to a graph format.\n * Each row is an individual series; this helps in looking at each value as bar in graphs.\n * @param {*} data \n */\n\n }, {\n key: \"transformPowerQueryDataToTable\",\n\n /**\n * Transform Power Query Data in table format that Grafana needs.\n * https://grafana.com/docs/plugins/developing/datasources/#query\n * @param data\n * @returns {{data: *[]}}\n */\n value: function transformPowerQueryDataToTable(data) {\n var cloneData = lodash__WEBPACK_IMPORTED_MODULE_0___default.a.clone(data);\n\n cloneData.columns.map(function (col) {\n col.text = col.name;\n return col;\n });\n return {\n data: [{\n type: this.visualizationType.TABLE,\n columns: cloneData.columns,\n rows: cloneData.values\n }]\n };\n }\n }], [{\n key: \"interpolateVariable\",\n value: function interpolateVariable(value, variable) {\n if (typeof value === 'string') {\n if (variable.multi || variable.includeAll) {\n return \"'\" + value.replace(/'/g, \"''\") + \"'\";\n }\n\n return value;\n }\n\n if (typeof value === 'number') {\n return value;\n }\n\n var quotedValues = lodash__WEBPACK_IMPORTED_MODULE_0___default.a.map(value, function (val) {\n if (typeof value === 'number') {\n return value;\n }\n\n return \"'\" + val.replace(/'/g, \"''\") + \"'\";\n });\n\n return quotedValues.join(',');\n }\n }, {\n key: \"getNumberOfBuckets\",\n value: function getNumberOfBuckets(options) {\n return Math.floor((options.range.to.valueOf() - options.range.from.valueOf()) / options.intervalMs);\n }\n }, {\n key: \"transformTimeSeriesResults\",\n value: function transformTimeSeriesResults(results, options) {\n var graphs = {\n data: []\n };\n results.forEach(function (result, index) {\n var timeStamp = options.range.from.valueOf();\n var dataValues = result.values;\n var currentTarget = options.targets[index];\n var responseObject = {\n target: currentTarget.label || currentTarget.queryText,\n datapoints: []\n };\n var conversionFactor = Object(_util__WEBPACK_IMPORTED_MODULE_1__[\"getValidConversionFactor\"])(currentTarget.conversionFactor);\n\n for (var i = 0; i < dataValues.length; i += 1) {\n var dataValue = dataValues[i] * conversionFactor;\n responseObject.datapoints.push([dataValue, timeStamp]);\n timeStamp += options.intervalMs;\n }\n\n graphs.data.push(responseObject);\n });\n return graphs;\n }\n /**\n * Transform data returned by time series query into Grafana annotation format.\n * @param results\n * @param options\n * @returns Array\n */\n\n }, {\n key: \"transformAnnotationResults\",\n value: function transformAnnotationResults(results, timeField, timeEndField, textField) {\n var annotations = [];\n results.forEach(function (result) {\n var responseObject = {};\n responseObject.time = Number(result[timeField]) / 1000000;\n\n if (!responseObject.time && result.attributes) {\n responseObject.time = Number(result.attributes[timeField]) / 1000000;\n }\n\n responseObject.text = result[textField];\n\n if (!responseObject.text && result.attributes) {\n responseObject.text = result.attributes[textField];\n }\n\n if (timeEndField) {\n responseObject.timeEnd = Number(result[timeEndField]) / 1000000;\n\n if (!responseObject.timeEnd && result.attributes) {\n responseObject.timeEnd = Number(result.attributes[timeEndField]) / 1000000;\n }\n }\n\n if (responseObject.time) {\n annotations.push(responseObject);\n }\n });\n return annotations;\n }\n }, {\n key: \"transformPowerQueryDataToGraph\",\n value: function transformPowerQueryDataToGraph(data) {\n var result = [];\n var values = data.values;\n\n for (var i = 0; i < values.length; i += 1) {\n var dataValue = values[i];\n\n for (var j = 1; j < dataValue.length; j += 1) {\n var responseObject = {\n target: dataValue[0] + \": \" + data.columns[j].name,\n datapoints: [[dataValue[j], Date.now()]]\n };\n result.push(responseObject);\n }\n }\n\n return {\n data: result\n };\n }\n }]);\n\n return GenericDatasource;\n}();\n\n//# sourceURL=webpack:///./datasource.js?"); /***/ }), @@ -114,11 +114,11 @@ eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) * /*!*******************!*\ !*** ./module.js ***! \*******************/ -/*! exports provided: Datasource, QueryCtrl, ConfigCtrl, QueryOptionsCtrl */ +/*! exports provided: Datasource, QueryCtrl, ConfigCtrl, QueryOptionsCtrl, AnnotationsQueryCtrl */ /***/ (function(module, __webpack_exports__, __webpack_require__) { "use strict"; -eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"QueryOptionsCtrl\", function() { return GenericQueryOptionsCtrl; });\n/* harmony import */ var _datasource__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./datasource */ \"./datasource.js\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"Datasource\", function() { return _datasource__WEBPACK_IMPORTED_MODULE_0__[\"GenericDatasource\"]; });\n\n/* harmony import */ var _query_ctrl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./query_ctrl */ \"./query_ctrl.js\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"QueryCtrl\", function() { return _query_ctrl__WEBPACK_IMPORTED_MODULE_1__[\"GenericDatasourceQueryCtrl\"]; });\n\n/* harmony import */ var _config_ctrl__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./config_ctrl */ \"./config_ctrl.js\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"ConfigCtrl\", function() { return _config_ctrl__WEBPACK_IMPORTED_MODULE_2__[\"GenericConfigCtrl\"]; });\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\n/* eslint-disable max-classes-per-file */\n\n\n\n\nvar GenericQueryOptionsCtrl = function GenericQueryOptionsCtrl() {\n _classCallCheck(this, GenericQueryOptionsCtrl);\n};\n\nGenericQueryOptionsCtrl.templateUrl = 'partials/query.options.html';\n\nvar GenericAnnotationsQueryCtrl = function GenericAnnotationsQueryCtrl() {\n _classCallCheck(this, GenericAnnotationsQueryCtrl);\n};\n\nGenericAnnotationsQueryCtrl.templateUrl = 'partials/annotations.editor.html';\n\n\n//# sourceURL=webpack:///./module.js?"); +eval("__webpack_require__.r(__webpack_exports__);\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"QueryOptionsCtrl\", function() { return GenericQueryOptionsCtrl; });\n/* harmony export (binding) */ __webpack_require__.d(__webpack_exports__, \"AnnotationsQueryCtrl\", function() { return GenericAnnotationsQueryCtrl; });\n/* harmony import */ var _datasource__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./datasource */ \"./datasource.js\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"Datasource\", function() { return _datasource__WEBPACK_IMPORTED_MODULE_0__[\"GenericDatasource\"]; });\n\n/* harmony import */ var _query_ctrl__WEBPACK_IMPORTED_MODULE_1__ = __webpack_require__(/*! ./query_ctrl */ \"./query_ctrl.js\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"QueryCtrl\", function() { return _query_ctrl__WEBPACK_IMPORTED_MODULE_1__[\"GenericDatasourceQueryCtrl\"]; });\n\n/* harmony import */ var _config_ctrl__WEBPACK_IMPORTED_MODULE_2__ = __webpack_require__(/*! ./config_ctrl */ \"./config_ctrl.js\");\n/* harmony reexport (safe) */ __webpack_require__.d(__webpack_exports__, \"ConfigCtrl\", function() { return _config_ctrl__WEBPACK_IMPORTED_MODULE_2__[\"GenericConfigCtrl\"]; });\n\nfunction _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError(\"Cannot call a class as a function\"); } }\n\n/* eslint-disable max-classes-per-file */\n\n\n\n\nvar GenericQueryOptionsCtrl = function GenericQueryOptionsCtrl() {\n _classCallCheck(this, GenericQueryOptionsCtrl);\n};\n\nGenericQueryOptionsCtrl.templateUrl = 'partials/query.options.html';\n\nvar GenericAnnotationsQueryCtrl = function GenericAnnotationsQueryCtrl() {\n _classCallCheck(this, GenericAnnotationsQueryCtrl);\n};\n\nGenericAnnotationsQueryCtrl.templateUrl = 'partials/annotations.editor.html';\n\n\n//# sourceURL=webpack:///./module.js?"); /***/ }), diff --git a/dist/plugin.json b/dist/plugin.json index dd90ec1..6a2e6d2 100644 --- a/dist/plugin.json +++ b/dist/plugin.json @@ -1,7 +1,7 @@ { "type": "datasource", "metrics": true, - "annotations": false, + "annotations": true, "category": "logging", "name": "Scalyr", "id": "scalyr-datasource", @@ -35,6 +35,11 @@ "path": "powerQuery", "method": "POST", "url": "{{.JsonData.scalyrUrl}}/api/powerQuery" + }, + { + "path": "query", + "method": "POST", + "url": "{{.JsonData.scalyrUrl}}/api/query" } ] } diff --git a/src/datasource.js b/src/datasource.js index 61ab330..7bb354a 100644 --- a/src/datasource.js +++ b/src/datasource.js @@ -59,6 +59,19 @@ export class GenericDatasource { return this.performTimeseriesQuery(options); } + annotationQuery(options) { + const query = this.createLogsQueryForAnnotation(options); + return this.backendSrv.datasourceRequest(query) + .then( (response) => { + const data = response.data; + const timeField = options.annotation.timeField || "timestamp" + const timeEndField = options.annotation.timeEndField || null + const textField = options.annotation.textField || "message" + return GenericDatasource.transformAnnotationResults(data.matches, timeField, timeEndField, textField); + } + ); + } + /** * Grafana uses this function to test data source settings. * This verifies API key using the facet query API. @@ -194,6 +207,24 @@ export class GenericDatasource { }; } + createLogsQueryForAnnotation(options) { + const queryText = this.templateSrv.replace(options.annotation.queryText, options.scopedVars, GenericDatasource.interpolateVariable); + + return { + url: this.url + '/query', + method: 'POST', + headers: this.headers, + data: JSON.stringify({ + token: this.apiKey, + queryType: "log", + filter: queryText, + startTime: options.range.from.valueOf(), + endTime: options.range.to.valueOf(), + maxCount: 5000 + }) + }; + } + /** * Get how many buckets to return based on the query time range * @param {*} options @@ -246,6 +277,39 @@ export class GenericDatasource { return graphs; } + /** + * Transform data returned by time series query into Grafana annotation format. + * @param results + * @param options + * @returns Array + */ + static transformAnnotationResults(results, timeField, timeEndField, textField) { + const annotations = []; + results.forEach((result) => { + const responseObject = {}; + responseObject.time = Number(result[timeField]) / 1000000; + if (!responseObject.time && result.attributes) { + responseObject.time = Number(result.attributes[timeField]) / 1000000; + } + + responseObject.text = result[textField]; + if (!responseObject.text && result.attributes) { + responseObject.text = result.attributes[textField]; + } + + if (timeEndField) { + responseObject.timeEnd = Number(result[timeEndField]) / 1000000; + if (!responseObject.timeEnd && result.attributes) { + responseObject.timeEnd = Number(result.attributes[timeEndField]) / 1000000; + } + } + if (responseObject.time) { + annotations.push(responseObject); + } + }); + return annotations; + } + /** * Create powerquery query to pass to Grafana proxy. * @param queryText text of the query diff --git a/src/module.js b/src/module.js index d997806..f088b3e 100644 --- a/src/module.js +++ b/src/module.js @@ -13,5 +13,6 @@ export { GenericDatasource as Datasource, GenericDatasourceQueryCtrl as QueryCtrl, GenericConfigCtrl as ConfigCtrl, - GenericQueryOptionsCtrl as QueryOptionsCtrl + GenericQueryOptionsCtrl as QueryOptionsCtrl, + GenericAnnotationsQueryCtrl as AnnotationsQueryCtrl }; diff --git a/src/partials/annotations.editor.html b/src/partials/annotations.editor.html new file mode 100644 index 0000000..b65b132 --- /dev/null +++ b/src/partials/annotations.editor.html @@ -0,0 +1,29 @@ +
+
+
+ +
+
+
+ +
+
Field mappings
+
+
+ Time + +
+
+ Time End + +
+
+ Text + +
+
+
\ No newline at end of file diff --git a/src/plugin.json b/src/plugin.json index dd90ec1..6a2e6d2 100644 --- a/src/plugin.json +++ b/src/plugin.json @@ -1,7 +1,7 @@ { "type": "datasource", "metrics": true, - "annotations": false, + "annotations": true, "category": "logging", "name": "Scalyr", "id": "scalyr-datasource", @@ -35,6 +35,11 @@ "path": "powerQuery", "method": "POST", "url": "{{.JsonData.scalyrUrl}}/api/powerQuery" + }, + { + "path": "query", + "method": "POST", + "url": "{{.JsonData.scalyrUrl}}/api/query" } ] } diff --git a/src/specs/datasource.test.js b/src/specs/datasource.test.js index 52997ee..34dd94e 100644 --- a/src/specs/datasource.test.js +++ b/src/specs/datasource.test.js @@ -27,6 +27,19 @@ describe('Scalyr datasource tests', () => { ] }; + const annotationQueryOptions = { + range: { + from: sixHoursAgo.toISOString(), + to: now.toISOString() + }, + interval: '5s', + annotation: [ + { + queryText: '$foo=\'bar\'', + } + ] + }; + const variables = [ { multi: true, @@ -135,4 +148,79 @@ describe('Scalyr datasource tests', () => { }); }); + describe('Annotation queries', () => { + let results; + beforeEach(() => { + results = [ + { + timefield: 12345, + messagefield: "testmessage1", + timeendfield: 54321 + }, + { + timefield: 12345, + messagefield: "testmessage2", + timeendfield: 54321, + attributes: { + timefield: 11111, + messagefield: "wrong", + timeendfield: 22222, + } + }, + { + timefield: 12345, + messagefield: "testmessage4", + timeendfield: 54321, + attributes: { + timefield2: 123456, + messagefield2: "testmessage5", + timeendfield2: 543211, + } + } + ]; + }); + it('Should create a query request', () => { + const request = datasource.createLogsQueryForAnnotation(annotationQueryOptions, variables); + expect(request.url).toBe('proxied/query'); + expect(request.method).toBe('POST'); + const requestBody = JSON.parse(request.data); + expect(requestBody.token).toBe('123'); + expect(requestBody.queryType).toBe('log'); + expect(requestBody.startTime).toBe(sixHoursAgo.toISOString()); + expect(requestBody.endTime).toBe(now.toISOString()); + }); + + it('Should transform standard query results to annotations', () => { + const transformedResults = GenericDatasource.transformAnnotationResults(results, "timefield", "timeendfield", "messagefield"); + expect(transformedResults.length).toBe(3); + const resultEntry = transformedResults[0]; + expect(resultEntry.text).toBe("testmessage1"); + expect(resultEntry.time).toBe(0.012345); + expect(resultEntry.timeEnd).toBe(0.054321); + }); + + it('Should transform standard query results to annotations, falling back to attribute fields', () => { + const transformedResults = GenericDatasource.transformAnnotationResults(results, "timefield2", "timeendfield2", "messagefield2"); + expect(transformedResults.length).toBe(1); + const resultEntry = transformedResults[0]; + expect(resultEntry.text).toBe("testmessage5"); + expect(resultEntry.time).toBe(0.123456); + expect(resultEntry.timeEnd).toBe(0.543211); + }); + + it('Should transform standard query results to annotations not from attributes first', () => { + const transformedResults = GenericDatasource.transformAnnotationResults(results, "timefield", "timeendfield", "messagefield"); + expect(transformedResults.length).toBe(3); + const resultEntry = transformedResults[1]; + expect(resultEntry.text).toBe("testmessage2"); + expect(resultEntry.time).toBe(0.012345); + expect(resultEntry.timeEnd).toBe(0.054321); + }); + + it('Shouldn\'t transform standard query results to annotations with bad field names', () => { + const transformedResults = GenericDatasource.transformAnnotationResults(results, "missingField", null, null); + expect(transformedResults.length).toBe(0); + }); + }); + });