diff --git a/docker/Dockerfile.dev b/docker/Dockerfile.dev new file mode 100644 index 0000000..147c670 --- /dev/null +++ b/docker/Dockerfile.dev @@ -0,0 +1,25 @@ +FROM centos:7 + +ENV GRAFANA_VERSION 4.1.2-1486989747 +ENV GF_PATHS_DATA /var/lib/grafana/data + +# Install grafana +RUN yum install -y https://grafanarel.s3.amazonaws.com/builds/grafana-${GRAFANA_VERSION}.x86_64.rpm && \ + yum install -y unzip && \ + yum clean all + +COPY dist /var/lib/grafana/plugins/hawkular-datasource + +# Fix permissions so will run on OCP under "restricted" SCC +COPY ./run.sh /run.sh +COPY fix-permissions /usr/bin/fix-permissions + +RUN chmod +x /usr/bin/fix-permissions && \ + /usr/bin/fix-permissions /run.sh && \ + /usr/bin/fix-permissions /var/lib/grafana && \ + /usr/bin/fix-permissions /var/log/grafana && \ + /usr/bin/fix-permissions /etc/grafana + +EXPOSE 3000 + +ENTRYPOINT ["/run.sh"] diff --git a/docker/instructions-dev-build.md b/docker/instructions-dev-build.md new file mode 100644 index 0000000..0c927e5 --- /dev/null +++ b/docker/instructions-dev-build.md @@ -0,0 +1,27 @@ +# Building a development Docker image + +1. Build the plugin from repository root: + +```bash +grunt +``` + +2. Move (or copy) the `dist` directory to `docker/` and `cd` to it. + +```bash +mv dist docker && cd docker +``` + +3. Build the docker image from Dockerfile.dev + +```bash +docker build -f Dockerfile.dev -t hawkular/hawkular-grafana-datasource:dev-build . +``` + +4. Test the image + +```bash +docker run -i -p 3000:3000 --name hawkular-grafana-datasource --rm hawkular/hawkular-grafana-datasource:dev-build +``` + +And login on http://localhost:3000/ diff --git a/spec/datasource-tenant-per-query_spec.js b/spec/datasource-tenant-per-query_spec.js index 4ddaef0..6cafb39 100644 --- a/spec/datasource-tenant-per-query_spec.js +++ b/spec/datasource-tenant-per-query_spec.js @@ -67,6 +67,63 @@ describe('HawkularDatasource tenant per query', () => { }).then(v => done(), err => done(err)); }); + it('should query raw data with templated tenant', done => { + + let options = { + range: { + from: 15, + to: 30 + }, + targets: [{ + id: 'memory', + type: 'gauge', + rate: false, + tenant: '$tenant' + }] + }; + ctx.templateSrv.variables = [{ + name: 'tenant' + }]; + ctx.templateSrv.replace = (target, vars) => { + expect(target).to.equal('$tenant'); + return '{t1,t2}'; + }; + + let ireq = 1; + ctx.backendSrv.datasourceRequest = request => { + expectRequestWithTenant(request, 'POST', 'gauges/raw/query', 't' + ireq); + expect(request.data).to.deep.equal({ + start: options.range.from, + end: options.range.to, + ids: ['memory'], + order: 'ASC' + }); + ireq++; + + return ctx.$q.when({ + status: 200, + data: [{ + id: 'memory', + data: [{ + timestamp: 13, + value: 3 * ireq + }, { + timestamp: 19, + value: 4 * ireq + }] + }] + }); + }; + + ctx.ds.query(options).then(result => { + expect(result.data).to.have.length(2); + expect(result.data[0].target).to.equal('[t1] memory'); + expect(result.data[0].datapoints).to.deep.equal([[6, 13], [8, 19]]); + expect(result.data[1].target).to.equal('[t2] memory'); + expect(result.data[1].datapoints).to.deep.equal([[9, 13], [12, 19]]); + }).then(v => done(), err => done(err)); + }); + it('should query annotations with ad-hoc tenant', done => { let options = { @@ -166,6 +223,70 @@ describe('HawkularDatasource tenant per query', () => { }).then(v => done(), err => done(err)); }); + it('should query stats with templated tenant', done => { + let options = { + range: { + from: 20, + to: 30 + }, + targets: [{ + seriesAggFn: 'none', + stats: ['min','max'], + tags: [{name: 'type', value: 'memory'}], + type: 'gauge', + rate: false, + raw: false, + tenant: '$tenant' + }] + }; + ctx.templateSrv.variables = [{ + name: 'tenant' + }]; + ctx.templateSrv.replace = (target, vars) => { + expect(target).to.equal('$tenant'); + return '{t1,t2}'; + }; + + let ireq = 1; + ctx.backendSrv.datasourceRequest = request => { + expectRequestWithTenant(request, 'POST', 'metrics/stats/query', 't' + ireq); + expect(request.data).to.deep.equal({ + start: options.range.from, + end: options.range.to, + tags: 'type:memory', + buckets: 60, + types: ['gauge'] + }); + ireq++; + + return ctx.$q.when({ + status: 200, + data: {'gauge': + { 'gauge_1': + [{ + start: 20, + end: 25, + min: 3 * ireq, + max: 4 * ireq + }] + } + } + }); + }; + + ctx.ds.query(options).then(result => { + expect(result.data).to.have.length(4); + expect(result.data[0].target).to.equal('[t1] gauge_1 [max]'); + expect(result.data[0].datapoints).to.deep.equal([[8, 20]]); + expect(result.data[1].target).to.equal('[t1] gauge_1 [min]'); + expect(result.data[1].datapoints).to.deep.equal([[6, 20]]); + expect(result.data[2].target).to.equal('[t2] gauge_1 [max]'); + expect(result.data[2].datapoints).to.deep.equal([[12, 20]]); + expect(result.data[3].target).to.equal('[t2] gauge_1 [min]'); + expect(result.data[3].datapoints).to.deep.equal([[9, 20]]); + }).then(v => done(), err => done(err)); + }); + it('should suggest metrics with ad-hoc tenant', done => { ctx.backendSrv.datasourceRequest = request => { expectRequestWithTenant(request, 'GET', 'metrics?type=gauge&tags=host=cartago', 'ad-hoc'); diff --git a/src/datasource.js b/src/datasource.js index c120af7..ea16771 100644 --- a/src/datasource.js +++ b/src/datasource.js @@ -28,8 +28,8 @@ export class HawkularDatasource { }; this.variablesHelper = new VariablesHelper(templateSrv); this.capabilitiesPromise = this.queryVersion().then(version => new Capabilities(version)); - this.queryProcessor = new QueryProcessor($q, backendSrv, this.variablesHelper, this.capabilitiesPromise, this.metricsUrl, - this.getHeaders.bind(this), this.typeResources); + this.queryProcessor = new QueryProcessor($q, this.multiTenantsQuery.bind(this), this.variablesHelper, this.capabilitiesPromise, this.metricsUrl, + this.typeResources); } getHeaders(tenant) { @@ -47,6 +47,23 @@ export class HawkularDatasource { return headers; } + multiTenantsQuery(tenants, url, params, data, method) { + return this.q.all(tenants.map(tenant => { + return this.backendSrv.datasourceRequest({ + url: url, + params: params, + data: data, + method: method, + headers: this.getHeaders(tenant) + }).then(response => { + return { + tenant: tenant, + result: (response.status == 200) ? response.data : null + } + }); + })); + } + query(options) { const validTargets = options.targets .filter(target => !target.hide) @@ -178,6 +195,13 @@ export class HawkularDatasource { }); } + getTargetTenants(target) { + if (target.tenant) { + return this.variablesHelper.resolve(target.tenant, {}); + } + return [null]; + } + suggestMetrics(target) { let url = this.metricsUrl + '/metrics?type=' + target.type; if (target.tagsQL && target.tagsQL.length > 0) { @@ -185,41 +209,71 @@ export class HawkularDatasource { } else if (target.tags && target.tags.length > 0) { url += '&tags=' + tagsModelToString(target.tags, this.variablesHelper, {}); } - return this.backendSrv.datasourceRequest({ - url: url, - method: 'GET', - headers: this.getHeaders(target.tenant) - }).then(response => response.status == 200 ? response.data : []) - .then(result => { - return result.map(m => m.id) - .sort() - .map(id => { - return {text: id, value: id}; + const tenants = this.getTargetTenants(target); + return this.multiTenantsQuery(tenants, url, null, null, 'GET') + .then(multiTenantsData => { + // Eliminate possible duplicates from multi-tenancy + let ids = {}; + multiTenantsData.forEach(tenantData => { + if (tenantData.result) { + tenantData.result.forEach(metric => { + ids[metric.id] = true; + }); + } }); - }); + return Object.keys(ids) + .sort() + .map(id => { + return {text: id, value: id}; + }); + }); } suggestTags(target, key) { if (!key) { return this.q.when([]); } - return this.backendSrv.datasourceRequest({ - url: `${this.metricsUrl}/${this.typeResources[target.type]}/tags/${key}:*`, - method: 'GET', - headers: this.getHeaders(target.tenant) - }).then(result => result.data.hasOwnProperty(key) ? result.data[key] : []) - .then(tags => tags.map(tag => { - return {text: tag, value: tag}; - })); + const tenants = this.getTargetTenants(target); + const url = `${this.metricsUrl}/${this.typeResources[target.type]}/tags/${key}:*`; + return this.multiTenantsQuery(tenants, url, null, null, 'GET') + .then(multiTenantsData => { + // Eliminate possible duplicates from multi-tenancy + let mergedTags = {}; + multiTenantsData.forEach(tenantData => { + if (tenantData.result) { + if (tenantData.result.hasOwnProperty(key)) { + tenantData.result[key].forEach(tag => { + mergedTags[tag] = true; + }); + } + } + }); + return Object.keys(mergedTags) + .sort() + .map(tag => { + return {text: tag, value: tag}; + }); + }); } suggestTagKeys(target) { - return this.backendSrv.datasourceRequest({ - url: this.metricsUrl + '/metrics/tags', - method: 'GET', - headers: this.getHeaders(target.tenant) - }).then(response => response.status == 200 ? response.data : []) - .then(result => result.map(key => ({text: key, value: key}))); + const tenants = this.getTargetTenants(target); + return this.multiTenantsQuery(tenants, this.metricsUrl + '/metrics/tags', null, null, 'GET') + .then(multiTenantsData => { + // Eliminate possible duplicates from multi-tenancy + let mergedTags = {}; + multiTenantsData.forEach(tenantData => { + if (tenantData.result) { + tenantData.result.forEach(tag => { + mergedTags[tag] = true; + }); + } + }); + return Object.keys(mergedTags) + .map(tag => { + return {text: tag, value: tag}; + }); + }); } metricFindQuery(query) { diff --git a/src/partials/query.editor.html b/src/partials/query.editor.html index a2179d7..7992f4a 100644 --- a/src/partials/query.editor.html +++ b/src/partials/query.editor.html @@ -2,7 +2,7 @@
- + diff --git a/src/queryProcessor.js b/src/queryProcessor.js index 6242c0e..b9c9487 100644 --- a/src/queryProcessor.js +++ b/src/queryProcessor.js @@ -1,16 +1,16 @@ +import _ from 'lodash'; import {modelToString as tagsModelToString} from './tagsKVPairsController'; const STATS_BUCKETS = 60; export class QueryProcessor { - constructor(q, backendSrv, variablesHelper, capabilities, url, getHeaders, typeResources) { + constructor(q, multiTenantsQuery, variablesHelper, capabilities, url, typeResources) { this.q = q; - this.backendSrv = backendSrv; + this.multiTenantsQuery = multiTenantsQuery; this.variablesHelper = variablesHelper; this.capabilities = capabilities; this.url = url; - this.getHeaders = getHeaders; this.typeResources = typeResources; this.numericMapping = point => [point.value, point.timestamp]; this.availMapping = point => [point.value == 'up' ? 1 : 0, point.timestamp]; @@ -23,26 +23,30 @@ export class QueryProcessor { end: options.range.to.valueOf(), order: 'ASC' }; + let tenants = [null]; + if (target.tenant) { + tenants = this.variablesHelper.resolve(target.tenant, options); + } if (target.id) { const metricIds = this.variablesHelper.resolve(target.id, options); if (caps.QUERY_POST_ENDPOINTS) { if (target.raw) { postData.ids = metricIds; - return this.rawQuery(target, postData); + return this.rawQuery(target, postData, tenants); } else if (target.timeAggFn == 'live') { // Need to change postData - return this.singleStatLiveQuery(target, {ids: metricIds, limit: 1}); + return this.singleStatLiveQuery(target, {ids: metricIds, limit: 1}, tenants); } else if (target.timeAggFn) { // Query single stat postData.metrics = metricIds; - return this.singleStatQuery(target, postData); + return this.singleStatQuery(target, postData, tenants); } else { // Query stats for chart postData.metrics = metricIds; - return this.statsQuery(target, postData); + return this.statsQuery(target, postData, tenants); } } else { - return this.rawQueryLegacy(target, options.range, metricIds); + return this.rawQueryLegacy(target, options.range, metricIds, tenants); } } else { if (caps.TAGS_QUERY_LANGUAGE) { @@ -59,52 +63,46 @@ export class QueryProcessor { } } if (target.raw) { - return this.rawQuery(target, postData); + return this.rawQuery(target, postData, tenants); } else if (target.timeAggFn == 'live') { // Need to change postData - return this.singleStatLiveQuery(target, {tags: postData.tags, limit: 1}); + return this.singleStatLiveQuery(target, {tags: postData.tags, limit: 1}, tenants); } else if (target.timeAggFn) { // Query single stat - return this.singleStatQuery(target, postData); + return this.singleStatQuery(target, postData, tenants); } else { // Query stats for chart - return this.statsQuery(target, postData); + return this.statsQuery(target, postData, tenants); } } }); } - rawQuery(target, postData) { + rawQuery(target, postData, tenants) { const url = `${this.url}/${this.typeResources[target.type]}/${target.rate ? 'rate' : 'raw'}/query`; - - return this.backendSrv.datasourceRequest({ - url: url, - data: postData, - method: 'POST', - headers: this.getHeaders(target.tenant) - }).then(response => this.processRawResponse(target, response.status == 200 ? response.data : [])); + return this.multiTenantsQuery(tenants, url, null, postData, 'POST') + .then(res => this.tenantsPrefixer(res)) + .then(allSeries => this.processRawResponse(target, allSeries)); } - rawQueryLegacy(target, range, metricIds) { + rawQueryLegacy(target, range, metricIds, tenants) { return this.q.all(metricIds.map(metric => { const url = `${this.url}/${this.typeResources[target.type]}/${encodeURIComponent(metric).replace('+', '%20')}/data`; - return this.backendSrv.datasourceRequest({ - url: url, - params: { - start: range.from.valueOf(), - end: range.to.valueOf() - }, - method: 'GET', - headers: this.getHeaders(target.tenant) - }).then(response => this.processRawResponseLegacy(target, metric, response.status == 200 ? response.data : [])); + const params = { + start: range.from.valueOf(), + end: range.to.valueOf() + }; + return this.multiTenantsQuery(tenants, url, params, null, 'GET') + .then(res => this.tenantsPrefixer(res)) + .then(allSeries => this.processRawResponseLegacy(target, metric, allSeries)); })); } - processRawResponse(target, data) { - return data.map(timeSerie => { + processRawResponse(target, allSeries) { + return allSeries.map(timeSerie => { return { refId: target.refId, - target: timeSerie.id, + target: timeSerie.prefix + timeSerie.id, datapoints: timeSerie.data.map(target.type == 'availability' ? this.availMapping : this.numericMapping) }; }); @@ -141,9 +139,9 @@ export class QueryProcessor { }; } - statsQuery(target, postData) { + statsQuery(target, postData, tenants) { if (target.seriesAggFn === 'none') { - return this.statsQueryUnmerged(target, postData); + return this.statsQueryUnmerged(target, postData, tenants); } const url = `${this.url}/${this.typeResources[target.type]}/stats/query`; delete postData.order; @@ -153,36 +151,40 @@ export class QueryProcessor { if (percentiles.length > 0) { postData.percentiles = percentiles.join(','); } - return this.backendSrv.datasourceRequest({ - url: url, - data: postData, - method: 'POST', - headers: this.getHeaders(target.tenant) - }).then(response => this.processStatsResponse(target, response.status == 200 ? response.data : [])); + return this.multiTenantsQuery(tenants, url, null, postData, 'POST') + .then(multiTenantsData => this.processStatsResponse(target, multiTenantsData)); } - processStatsResponse(target, data) { - // Response example: [{start:1234, end:5678, avg:100.0, min:90.0, max:110.0, (...), percentiles:[{quantile: 90, value: 105.0}]}] - return target.stats.map(stat => { - const percentile = this.getPercentileValue(stat); - if (percentile) { - return { - refId: target.refId, - target: stat, - datapoints: data.filter(bucket => !bucket.empty) - .map(bucket => [this.findQuantileInBucket(percentile, bucket), bucket.start]) - }; - } else { - return { - refId: target.refId, - target: stat, - datapoints: data.filter(bucket => !bucket.empty).map(bucket => [bucket[stat], bucket.start]) - }; + processStatsResponse(target, multiTenantsData) { + // Response example: [ { tenant: 't1', result: [...] }, { tenant: 't2', result: [...] } ] + // Detailed `data[i].result`: [{start:1234, end:5678, avg:100.0, min:90.0, max:110.0, (...), percentiles:[{quantile: 90, value: 105.0}]}] + const flatten = []; + const prefixer = multiTenantsData.length > 1 ? (tenant) => `[${tenant}] ` : (tenant) => ''; + multiTenantsData.forEach(tenantData => { + if (tenantData.result) { + target.stats.forEach(stat => { + const percentile = this.getPercentileValue(stat); + if (percentile) { + flatten.push({ + refId: target.refId, + target: prefixer(tenantData.tenant) + stat, + datapoints: tenantData.result.filter(bucket => !bucket.empty) + .map(bucket => [this.findQuantileInBucket(percentile, bucket), bucket.start]) + }); + } else { + flatten.push({ + refId: target.refId, + target: prefixer(tenantData.tenant) + stat, + datapoints: tenantData.result.filter(bucket => !bucket.empty).map(bucket => [bucket[stat], bucket.start]) + }); + } + }); } }); + return flatten; } - statsQueryUnmerged(target, postData) { + statsQueryUnmerged(target, postData, tenants) { const url = `${this.url}/metrics/stats/query`; delete postData.order; postData.buckets = STATS_BUCKETS; @@ -196,43 +198,46 @@ export class QueryProcessor { if (percentiles.length > 0) { postData.percentiles = percentiles.join(','); } - return this.backendSrv.datasourceRequest({ - url: url, - data: postData, - method: 'POST', - headers: this.getHeaders(target.tenant) - }).then(response => this.processUnmergedStatsResponse(target, response.status == 200 ? response.data : [])); + return this.multiTenantsQuery(tenants, url, null, postData, 'POST') + .then(multiTenantsData => this.processUnmergedStatsResponse(target, multiTenantsData)); } - processUnmergedStatsResponse(target, data) { - // Response example: + processUnmergedStatsResponse(target, multiTenantsData) { + // Response example: [ { tenant: 't1', result: {...} }, { tenant: 't2', result: {...} } ] + // Detailed `data[i].result`: // {"gauge": {"my_metric": [ // {start:1234, end:5678, avg:100.0, min:90.0, max:110.0, (...), percentiles:[{quantile: 90, value: 105.0}]} // ]}} const series = []; - const allMetrics = data[target.type]; - for (let metricId in allMetrics) { - if (allMetrics.hasOwnProperty(metricId)) { - const buckets = allMetrics[metricId]; - target.stats.forEach(stat => { - const percentile = this.getPercentileValue(stat); - if (percentile) { - series.push({ - refId: target.refId, - target: `${metricId} [${stat}]`, - datapoints: buckets.filter(bucket => !bucket.empty) - .map(bucket => [this.findQuantileInBucket(percentile, bucket), bucket.start]) - }); - } else { - series.push({ - refId: target.refId, - target: `${metricId} [${stat}]`, - datapoints: buckets.filter(bucket => !bucket.empty).map(bucket => [bucket[stat], bucket.start]) + const prefixer = multiTenantsData.length > 1 ? (tenant) => `[${tenant}] ` : (tenant) => ''; + multiTenantsData.forEach(tenantData => { + if (tenantData.result) { + const allMetrics = tenantData.result[target.type]; + const prefix = prefixer(tenantData.tenant); + for (let metricId in allMetrics) { + if (allMetrics.hasOwnProperty(metricId)) { + const buckets = allMetrics[metricId]; + target.stats.forEach(stat => { + const percentile = this.getPercentileValue(stat); + if (percentile) { + series.push({ + refId: target.refId, + target: `${prefix}${metricId} [${stat}]`, + datapoints: buckets.filter(bucket => !bucket.empty) + .map(bucket => [this.findQuantileInBucket(percentile, bucket), bucket.start]) + }); + } else { + series.push({ + refId: target.refId, + target: `${prefix}${metricId} [${stat}]`, + datapoints: buckets.filter(bucket => !bucket.empty).map(bucket => [bucket[stat], bucket.start]) + }); + } }); } - }); + } } - } + }); return series; } @@ -255,7 +260,7 @@ export class QueryProcessor { return null; } - singleStatQuery(target, postData) { + singleStatQuery(target, postData, tenants) { // Query for singlestat => we just ask for a single bucket // But because of that we need to override Grafana behaviour, and manage ourselves the min/max/avg/etc. selection let fnBucket; @@ -270,55 +275,71 @@ export class QueryProcessor { delete postData.order; postData.buckets = 1; postData.stacked = target.seriesAggFn === 'sum'; - return this.backendSrv.datasourceRequest({ - url: url, - data: postData, - method: 'POST', - headers: this.getHeaders(target.tenant) - }).then(response => this.processSingleStatResponse(target, fnBucket, response.status == 200 ? response.data : [])); + return this.multiTenantsQuery(tenants, url, null, postData, 'POST') + .then(multiTenantsData => this.processSingleStatResponse(target, fnBucket, multiTenantsData)); } - processSingleStatResponse(target, fnBucket, data) { - return data.map(bucket => { - return { - refId: target.refId, - target: 'Aggregate', - datapoints: [[fnBucket(bucket), bucket.start]] - }; - }); + processSingleStatResponse(target, fnBucket, multiTenantsData) { + return _.flatten(multiTenantsData.map(tenantData => { + if (tenantData.result) { + return tenantData.result.map(bucket => { + return { + refId: target.refId, + target: 'Aggregate', + datapoints: [[fnBucket(bucket), bucket.start]] + }; + }); + } + })); } - singleStatLiveQuery(target, postData) { + singleStatLiveQuery(target, postData, tenants) { const url = `${this.url}/${this.typeResources[target.type]}/${target.rate ? 'rate' : 'raw'}/query`; // Set start to now - 5m postData.start = Date.now() - 300000; - return this.backendSrv.datasourceRequest({ - url: url, - data: postData, - method: 'POST', - headers: this.getHeaders(target.tenant) - }).then(response => this.processSingleStatLiveResponse(target, response.status == 200 ? response.data : [])); + return this.multiTenantsQuery(tenants, url, null, postData, 'POST') + .then(multiTenantsData => this.processSingleStatLiveResponse(target, multiTenantsData)); } - processSingleStatLiveResponse(target, data) { + processSingleStatLiveResponse(target, multiTenantsData) { let reduceFunc; if (target.seriesAggFn === 'sum') { reduceFunc = (presentValues => presentValues.reduce((a,b) => a+b)); } else { reduceFunc = (presentValues => presentValues.reduce((a,b) => a+b) / presentValues.length); } - let datapoints; - const latestPoints = data.filter(timeSeries => timeSeries.data.length > 0) - .map(timeSeries => timeSeries.data[0]); - if (latestPoints.length === 0) { - datapoints = []; - } else { - datapoints = [[reduceFunc(latestPoints.map(dp => dp.value)), latestPoints[0].timestamp]]; - } - return [{ - refId: target.refId, - target: 'Aggregate', - datapoints: datapoints - }]; + return _.flatten(multiTenantsData.map(tenantData => { + if (tenantData.result) { + let datapoints; + const latestPoints = tenantData.result.filter(timeSeries => timeSeries.data.length > 0) + .map(timeSeries => timeSeries.data[0]); + if (latestPoints.length === 0) { + datapoints = []; + } else { + datapoints = [[reduceFunc(latestPoints.map(dp => dp.value)), latestPoints[0].timestamp]]; + } + return [{ + refId: target.refId, + target: 'Aggregate', + datapoints: datapoints + }]; + } + })); + } + + tenantsPrefixer(allTenantTimeSeries) { + // Exemple of input: + // [ { tenant: 't1', result: [ {id: metricA, data: []} ] }, { tenant: 't2', result: [ {id: metricB, data: []} ] } ] + const flatten = []; + const prefixer = allTenantTimeSeries.length > 1 ? (tenant) => `[${tenant}] ` : (tenant) => ''; + allTenantTimeSeries.forEach(oneTenantTimeSeries => { + if (oneTenantTimeSeries.result) { + oneTenantTimeSeries.result.forEach(timeSeries => { + timeSeries.prefix = prefixer(oneTenantTimeSeries.tenant); + flatten.push(timeSeries); + }) + } + }) + return flatten; } }