diff --git a/packages/opencensus-core/src/stats/types.ts b/packages/opencensus-core/src/stats/types.ts index 286b6aad3..b22187d70 100644 --- a/packages/opencensus-core/src/stats/types.ts +++ b/packages/opencensus-core/src/stats/types.ts @@ -82,7 +82,7 @@ export interface View { */ readonly name: string; /** Describes the view, e.g. "RPC latency distribution" */ - readonly description?: string; + readonly description: string; /** The Measure to which this view is applied. */ readonly measure: Measure; /** @@ -98,7 +98,18 @@ export interface View { endTime: number; /** true if the view was registered */ registered: boolean; - /** Returns a snapshot of an AggregationData for that tags/labels values */ + /** + * Records a measurement in the proper view's row. This method is used by + * Stats. User should prefer using Stats.record() instead. + * + * Measurements with measurement type INT64 will have its value truncated. + * @param measurement The measurement to record + */ + recordMeasurement(measurement: Measurement): void; + /** + * Returns a snapshot of an AggregationData for that tags/labels values. + * @param tags The desired data's tags + */ getSnapshot(tags: Tags): AggregationData; } diff --git a/packages/opencensus-core/src/stats/view.ts b/packages/opencensus-core/src/stats/view.ts index dcf7f3b5a..2093ff81d 100644 --- a/packages/opencensus-core/src/stats/view.ts +++ b/packages/opencensus-core/src/stats/view.ts @@ -14,7 +14,14 @@ * limitations under the License. */ -import {AggregationData, AggregationType, Measure, Tags, View} from './types'; +import {Recorder} from './recorder'; +import {AggregationData, AggregationMetadata, AggregationType, Bucket, CountData, DistributionData, LastValueData, Measure, Measurement, MeasureType, SumData, Tags, View} from './types'; + +const RECORD_SEPARATOR = String.fromCharCode(30); +const UNIT_SEPARATOR = String.fromCharCode(31); + +// String that has only printable characters +const invalidString = /[^\u0020-\u007e]/; export class BaseView implements View { /** @@ -32,7 +39,11 @@ export class BaseView implements View { * If no Tags are provided, then, all data is recorded in a single * aggregation. */ - private rows: {[key: string]: AggregationData}; + private rows: {[key: string]: AggregationData} = {}; + /** + * A list of tag keys that represents the possible column labels + */ + private columns: string[]; /** * An Aggregation describes how data collected is aggregated. * There are four aggregation types: count, sum, lastValue and distirbution. @@ -40,17 +51,145 @@ export class BaseView implements View { readonly aggregation: AggregationType; /** The start time for this view */ readonly startTime: number; + /** The bucket boundaries in a Distribution Aggregation */ + private bucketBoundaries?: number[]; /** * The end time for this view - represents the last time a value was recorded */ endTime: number; /** true if the view was registered */ - registered: boolean; + registered = false; + /** + * Creates a new View instance. This constructor is used by Stats. User should + * prefer using Stats.createView() instead. + * @param name The view name + * @param measure The view measure + * @param aggregation The view aggregation type + * @param tagsKeys The Tags' keys that view will have + * @param description The view description + * @param bucketBoundaries The view bucket boundaries for a distribution + * aggregation type + */ constructor( name: string, measure: Measure, aggregation: AggregationType, - tagKeys: string[], description?: string) { - throw new Error('Not Implemented'); + tagsKeys: string[], description: string, bucketBoundaries?: number[]) { + if (aggregation === AggregationType.DISTRIBUTION && !bucketBoundaries) { + throw new Error('No bucketBoundaries specified'); + } + this.name = name; + this.description = description; + this.measure = measure; + this.columns = tagsKeys; + this.aggregation = aggregation; + this.startTime = Date.now(); + this.bucketBoundaries = bucketBoundaries; + } + + /** Gets the view's tag keys */ + getColumns(): string[] { + return this.columns; + } + + /** + * Records a measurement in the proper view's row. This method is used by + * Stats. User should prefer using Stats.record() instead. + * + * Measurements with measurement type INT64 will have its value truncated. + * @param measurement The measurement to record + */ + recordMeasurement(measurement: Measurement) { + // Checks if measurement has valid tags + if (this.invalidTags(measurement.tags)) { + return; + } + + // Checks if measurement has all tags in views + for (const tagKey of this.columns) { + if (!Object.keys(measurement.tags).some((key) => key === tagKey)) { + return; + } + } + + const encodedTags = this.encodeTags(measurement.tags); + if (!this.rows[encodedTags]) { + this.rows[encodedTags] = this.createAggregationData(measurement.tags); + } + Recorder.addMeasurement(this.rows[encodedTags], measurement); + } + + /** + * Encodes a Tags object into a key sorted string. + * @param tags The tags to encode + */ + private encodeTags(tags: Tags): string { + return Object.keys(tags) + .sort() + .map(tagKey => { + return tagKey + UNIT_SEPARATOR + tags[tagKey]; + }) + .join(RECORD_SEPARATOR); + } + + /** + * Checks if tag keys and values have only printable characters. + * @param tags The tags to be checked + */ + private invalidTags(tags: Tags): boolean { + return Object.keys(tags).some(tagKey => { + return invalidString.test(tagKey) || invalidString.test(tags[tagKey]); + }); + } + + /** + * Creates an empty aggregation data for a given tags. + * @param tags The tags for that aggregation data + */ + private createAggregationData(tags: Tags): AggregationData { + const aggregationMetadata = {tags, timestamp: Date.now()}; + + switch (this.aggregation) { + case AggregationType.DISTRIBUTION: + return { + ...aggregationMetadata, + type: AggregationType.DISTRIBUTION, + startTime: this.startTime, + count: 0, + sum: 0, + max: Number.MIN_SAFE_INTEGER, + min: Number.MAX_SAFE_INTEGER, + mean: null as number, + stdDeviation: null as number, + sumSquaredDeviations: null as number, + buckets: this.createBuckets(this.bucketBoundaries) + }; + case AggregationType.SUM: + return {...aggregationMetadata, type: AggregationType.SUM, value: 0}; + case AggregationType.COUNT: + return {...aggregationMetadata, type: AggregationType.COUNT, value: 0}; + default: + return { + ...aggregationMetadata, + type: AggregationType.LAST_VALUE, + value: undefined + }; + } + } + + /** + * Creates empty Buckets, given a list of bucket boundaries. + * @param bucketBoundaries a list with the bucket boundaries + */ + private createBuckets(bucketBoundaries: number[]): Bucket[] { + return bucketBoundaries.map((boundary, boundaryIndex) => { + return { + count: 0, + lowBoundary: boundaryIndex ? boundary : -Infinity, + highBoundary: (boundaryIndex === bucketBoundaries.length - 1) ? + Infinity : + bucketBoundaries[boundaryIndex + 1] + }; + }); } /** @@ -58,6 +197,6 @@ export class BaseView implements View { * @param tags The desired data's tags */ getSnapshot(tags: Tags): AggregationData { - throw new Error('Not Implemented'); + return this.rows[this.encodeTags(tags)]; } } diff --git a/packages/opencensus-core/test/test-view.ts b/packages/opencensus-core/test/test-view.ts new file mode 100644 index 000000000..652ebd7fb --- /dev/null +++ b/packages/opencensus-core/test/test-view.ts @@ -0,0 +1,212 @@ +/** + * Copyright 2018, OpenCensus Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as assert from 'assert'; +import * as mocha from 'mocha'; + +import {BaseView} from '../src'; +import {AggregationType, CountData, DistributionData, LastValueData, Measure, Measurement, MeasureType, MeasureUnit, SumData, Tags, View} from '../src/stats/types'; + +/** The order of how close values must be to be considerated almost equal */ +const EPSILON = 6; + +interface AggregationTestCase { + aggregationType: AggregationType; + description: string; +} + +interface MeasurementsTestCase { + values: number[]; + bucketBoundaries: number[]; + description: string; +} + +function isAlmostEqual( + actual: number, expected: number, epsilon: number): boolean { + return Math.abs(actual - expected) < Math.pow(10, -epsilon); +} + +function assertDistributionData( + distributionData: DistributionData, values: number[]) { + const valuesSum = values.reduce((acc, cur) => acc + cur); + + assert.strictEqual(distributionData.max, Math.max(...values)); + assert.strictEqual(distributionData.min, Math.min(...values)); + assert.strictEqual(distributionData.count, values.length); + assert.strictEqual(distributionData.sum, valuesSum); + + for (const bucket of distributionData.buckets) { + const expectedBucketCount = values + .filter( + value => bucket.lowBoundary <= value && + value < bucket.highBoundary) + .length; + assert.strictEqual(bucket.count, expectedBucketCount); + } + + const expectedMean = valuesSum / values.length; + assert.ok(isAlmostEqual(distributionData.mean, expectedMean, EPSILON)); + + const expectedSumSquaredDeviations = + values.map(value => Math.pow(value - expectedMean, 2)) + .reduce((acc, curr) => acc + curr); + assert.ok(isAlmostEqual( + distributionData.sumSquaredDeviations, expectedSumSquaredDeviations, + EPSILON)); + + const expectedStdDeviation = + Math.sqrt(expectedSumSquaredDeviations / values.length); + assert.ok(isAlmostEqual( + distributionData.stdDeviation, expectedStdDeviation, EPSILON)); +} + +function assertView( + view: View, measurement: Measurement, recordedValues: number[], + aggregationType: AggregationType) { + assert.strictEqual(view.aggregation, aggregationType); + const aggregationData = view.getSnapshot(measurement.tags); + + switch (aggregationData.type) { + case AggregationType.SUM: + const acc = recordedValues.reduce((acc, cur) => acc + cur); + assert.strictEqual(aggregationData.value, acc); + break; + case AggregationType.COUNT: + assert.strictEqual(aggregationData.value, recordedValues.length); + break; + case AggregationType.DISTRIBUTION: + assertDistributionData(aggregationData, recordedValues); + break; + default: + assert.strictEqual( + aggregationData.value, recordedValues[recordedValues.length - 1]); + break; + } +} + +describe('BaseView', () => { + const measure: Measure = { + name: 'Test Measure', + type: MeasureType.DOUBLE, + unit: MeasureUnit.UNIT + }; + + const aggregationTestCases: AggregationTestCase[] = [ + {aggregationType: AggregationType.SUM, description: 'Sum'}, + {aggregationType: AggregationType.COUNT, description: 'Count'}, + {aggregationType: AggregationType.LAST_VALUE, description: 'Last Value'}, { + aggregationType: AggregationType.DISTRIBUTION, + description: 'Distribution' + } + ]; + + describe('getColumns()', () => { + it('should access the given tag keys', () => { + const tagKeys = ['testKey1', 'testKey2']; + const view = new BaseView( + 'test/view/name', measure, AggregationType.LAST_VALUE, tagKeys, + 'description test'); + + assert.strictEqual(view.getColumns(), tagKeys); + }); + }); + + describe('recordMeasurement()', () => { + const measurementValues = [1.1, -2.3, 3.2, -4.3, 5.2]; + const bucketBoundaries = [0, 2, 4, 6]; + const emptyAggregation = {}; + + for (const aggregationTestCase of aggregationTestCases) { + const tags: Tags = {testKey1: 'testValue', testKey2: 'testValue'}; + const view = new BaseView( + 'test/view/name', measure, aggregationTestCase.aggregationType, + ['testKey1', 'testKey2'], 'description test', bucketBoundaries); + + it(`should record measurements on a View with ${ + aggregationTestCase.description} Aggregation Data type`, + () => { + const recordedValues = []; + for (const value of measurementValues) { + recordedValues.push(value); + const measurement = {measure, tags, value}; + view.recordMeasurement(measurement); + assertView( + view, measurement, recordedValues, + aggregationTestCase.aggregationType); + } + }); + } + + const view = new BaseView( + 'test/view/name', measure, AggregationType.LAST_VALUE, + ['testKey1', 'testKey2'], 'description test'); + + it('should not record a measurement when it has wrong tag keys', () => { + const measurement = {measure, tags: {testKey3: 'testValue'}, value: 10}; + view.recordMeasurement(measurement); + assert.ok(!view.getSnapshot(measurement.tags)); + }); + + it('should not record a measurement when tags are not valid', () => { + const measurement = { + measure, + tags: {testKey3: String.fromCharCode(30) + 'testValue'}, + value: 10 + }; + view.recordMeasurement(measurement); + assert.ok(!view.getSnapshot(measurement.tags)); + }); + + it('should not record a measurement when it has not enough tag keys', + () => { + const measurement = { + measure, + tags: {testKey1: 'testValue'}, + value: 10 + }; + view.recordMeasurement(measurement); + assert.ok(!view.getSnapshot(measurement.tags)); + }); + }); + + describe('getSnapshots()', () => { + const tags: Tags = {testKey1: 'testValue', testKey2: 'testValue'}; + let view: View; + + before(() => { + view = new BaseView( + 'test/view/name', measure, AggregationType.LAST_VALUE, + ['testKey1', 'testKey2'], 'description test'); + + const measurement = {measure, tags, value: 10}; + view.recordMeasurement(measurement); + }); + + it('should not get aggregation data when wrong tags values are given', + () => { + assert.ok(!view.getSnapshot( + {testKey1: 'wrongTagValue', testKey2: 'wrongTagValue'})); + }); + + it('should not get aggregation data when not enough tags are given', () => { + assert.ok(!view.getSnapshot({testKey1: 'testValue'})); + }); + + it('should get aggregation data when tags are correct', () => { + assert.ok(view.getSnapshot(tags)); + }); + }); +});