diff --git a/packages/opentelemetry-exporter-prometheus/package.json b/packages/opentelemetry-exporter-prometheus/package.json index 8ccb14430e..5c7de77d31 100644 --- a/packages/opentelemetry-exporter-prometheus/package.json +++ b/packages/opentelemetry-exporter-prometheus/package.json @@ -55,7 +55,6 @@ "dependencies": { "@opentelemetry/api": "^0.10.2", "@opentelemetry/core": "^0.10.2", - "@opentelemetry/metrics": "^0.10.2", - "prom-client": "^11.5.3" + "@opentelemetry/metrics": "^0.10.2" } } diff --git a/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts b/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts new file mode 100644 index 0000000000..3d6035cc4f --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/src/PrometheusExporter.ts @@ -0,0 +1,184 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import * as api from '@opentelemetry/api'; +import { ExportResult, NoopLogger } from '@opentelemetry/core'; +import { MetricExporter, MetricRecord } from '@opentelemetry/metrics'; +import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; +import * as url from 'url'; +import { ExporterConfig } from './export/types'; +import { PrometheusSerializer } from './PrometheusSerializer'; +import { PrometheusLabelsBatcher } from './PrometheusLabelsBatcher'; + +export class PrometheusExporter implements MetricExporter { + static readonly DEFAULT_OPTIONS = { + port: 9464, + startServer: false, + endpoint: '/metrics', + prefix: '', + }; + + private readonly _logger: api.Logger; + private readonly _port: number; + private readonly _endpoint: string; + private readonly _server: Server; + private readonly _prefix?: string; + private _serializer: PrometheusSerializer; + private _batcher = new PrometheusLabelsBatcher(); + + // This will be required when histogram is implemented. Leaving here so it is not forgotten + // Histogram cannot have a label named 'le' + // private static readonly RESERVED_HISTOGRAM_LABEL = 'le'; + + /** + * Constructor + * @param config Exporter configuration + * @param callback Callback to be called after a server was started + */ + constructor(config: ExporterConfig = {}, callback?: () => void) { + this._logger = config.logger || new NoopLogger(); + this._port = config.port || PrometheusExporter.DEFAULT_OPTIONS.port; + this._prefix = config.prefix || PrometheusExporter.DEFAULT_OPTIONS.prefix; + this._server = createServer(this._requestHandler); + this._serializer = new PrometheusSerializer(this._prefix); + + this._endpoint = ( + config.endpoint || PrometheusExporter.DEFAULT_OPTIONS.endpoint + ).replace(/^([^/])/, '/$1'); + + if (config.startServer || PrometheusExporter.DEFAULT_OPTIONS.startServer) { + this.startServer(callback); + } else if (callback) { + callback(); + } + } + + /** + * Saves the current values of all exported {@link MetricRecord}s so that + * they can be pulled by the Prometheus backend. + * + * In its current state, the exporter saves the current values of all metrics + * when export is called and returns them when the export endpoint is called. + * In the future, this should be a no-op and the exporter should reach into + * the metrics when the export endpoint is called. As there is currently no + * interface to do this, this is our only option. + * + * @param records Metrics to be sent to the prometheus backend + * @param cb result callback to be called on finish + */ + export(records: MetricRecord[], cb: (result: ExportResult) => void) { + if (!this._server) { + // It is conceivable that the _server may not be started as it is an async startup + // However unlikely, if this happens the caller may retry the export + cb(ExportResult.FAILED_RETRYABLE); + return; + } + + this._logger.debug('Prometheus exporter export'); + + for (const record of records) { + this._batcher.process(record); + } + + cb(ExportResult.SUCCESS); + } + + /** + * Shuts down the export server and clears the registry + * + * @param cb called when server is stopped + */ + shutdown(cb?: () => void) { + this.stopServer(cb); + } + + /** + * Stops the Prometheus export server + * @param callback A callback that will be executed once the server is stopped + */ + stopServer(callback?: () => void) { + if (!this._server) { + this._logger.debug( + 'Prometheus stopServer() was called but server was never started.' + ); + if (callback) { + callback(); + } + } else { + this._server.close(() => { + this._logger.debug('Prometheus exporter was stopped'); + if (callback) { + callback(); + } + }); + } + } + + /** + * Starts the Prometheus export server + * + * @param callback called once the server is ready + */ + startServer(callback?: () => void) { + this._server.listen(this._port, () => { + this._logger.debug( + `Prometheus exporter started on port ${this._port} at endpoint ${this._endpoint}` + ); + if (callback) { + callback(); + } + }); + } + + /** + * Request handler used by http library to respond to incoming requests + * for the current state of metrics by the Prometheus backend. + * + * @param request Incoming HTTP request to export server + * @param response HTTP response object used to respond to request + */ + private _requestHandler = ( + request: IncomingMessage, + response: ServerResponse + ) => { + if (url.parse(request.url!).pathname === this._endpoint) { + this._exportMetrics(response); + } else { + this._notFound(response); + } + }; + + /** + * Responds to incoming message with current state of all metrics. + */ + private _exportMetrics = (response: ServerResponse) => { + response.statusCode = 200; + response.setHeader('content-type', 'text/plain'); + if (!this._batcher.hasMetric) { + response.end('# no registered metrics'); + return; + } + response.end(this._serializer.serialize(this._batcher.checkPointSet())); + }; + + /** + * Responds with 404 status code to all requests that do not match the configured endpoint. + */ + private _notFound = (response: ServerResponse) => { + response.statusCode = 404; + response.end(); + }; +} diff --git a/packages/opentelemetry-exporter-prometheus/src/PrometheusLabelsBatcher.ts b/packages/opentelemetry-exporter-prometheus/src/PrometheusLabelsBatcher.ts new file mode 100644 index 0000000000..5d123d4ef4 --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/src/PrometheusLabelsBatcher.ts @@ -0,0 +1,65 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + MetricRecord, + MetricDescriptor, + AggregatorKind, +} from '@opentelemetry/metrics'; +import { PrometheusCheckpoint } from './types'; + +interface BatcherCheckpoint { + descriptor: MetricDescriptor; + aggregatorKind: AggregatorKind; + records: Map; +} + +export class PrometheusLabelsBatcher { + private _batchMap = new Map(); + + get hasMetric() { + return this._batchMap.size > 0; + } + + process(record: MetricRecord) { + const name = record.descriptor.name; + let item = this._batchMap.get(name); + if (item === undefined) { + item = { + descriptor: record.descriptor, + aggregatorKind: record.aggregator.kind, + records: new Map(), + }; + this._batchMap.set(name, item); + } + const recordMap = item.records; + const labels = Object.keys(record.labels) + .map(k => `${k}=${record.labels[k]}`) + .join(','); + recordMap.set(labels, record); + } + + checkPointSet(): PrometheusCheckpoint[] { + return Array.from(this._batchMap.values()).map( + ({ descriptor, aggregatorKind, records }) => { + return { + descriptor, + aggregatorKind, + records: Array.from(records.values()), + }; + } + ); + } +} diff --git a/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts b/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts new file mode 100644 index 0000000000..7e70eb986f --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/src/PrometheusSerializer.ts @@ -0,0 +1,259 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + MetricRecord, + AggregatorKind, + Distribution, + MetricKind, +} from '@opentelemetry/metrics'; +import { PrometheusCheckpoint } from './types'; +import { Labels } from '@opentelemetry/api'; +import { hrTimeToMilliseconds } from '@opentelemetry/core'; + +type PrometheusDataTypeLiteral = + | 'counter' + | 'gauge' + | 'histogram' + | 'summary' + | 'untyped'; + +function escapeString(str: string) { + return str.replace(/\\/g, '\\\\').replace(/\n/g, '\\n'); +} + +function escapeLabelValue(str: string) { + if (typeof str !== 'string') { + str = String(str); + } + return escapeString(str).replace(/"/g, '\\"'); +} + +const invalidCharacterRegex = /[^a-z0-9_]/gi; +/** + * Ensures metric names are valid Prometheus metric names by removing + * characters allowed by OpenTelemetry but disallowed by Prometheus. + * + * https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels + * + * 1. Names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` + * + * 2. Colons are reserved for user defined recording rules. + * They should not be used by exporters or direct instrumentation. + * + * OpenTelemetry metric names are already validated in the Meter when they are created, + * and they match the format `[a-zA-Z][a-zA-Z0-9_.\-]*` which is very close to a valid + * prometheus metric name, so we only need to strip characters valid in OpenTelemetry + * but not valid in prometheus and replace them with '_'. + * + * @param name name to be sanitized + */ +function sanitizePrometheusMetricName(name: string): string { + return name.replace(invalidCharacterRegex, '_'); // replace all invalid characters with '_' +} + +function valueString(value: number) { + if (Number.isNaN(value)) { + return 'Nan'; + } else if (!Number.isFinite(value)) { + if (value < 0) { + return '-Inf'; + } else { + return '+Inf'; + } + } else { + return `${value}`; + } +} + +function toPrometheusType( + metricKind: MetricKind, + aggregatorKind: AggregatorKind +): PrometheusDataTypeLiteral { + switch (aggregatorKind) { + case AggregatorKind.SUM: + if ( + metricKind === MetricKind.COUNTER || + metricKind === MetricKind.SUM_OBSERVER + ) { + return 'counter'; + } + /** MetricKind.UP_DOWN_COUNTER and MetricKind.UP_DOWN_SUM_OBSERVER */ + return 'gauge'; + case AggregatorKind.LAST_VALUE: + return 'gauge'; + case AggregatorKind.DISTRIBUTION: + return 'summary'; + case AggregatorKind.HISTOGRAM: + return 'histogram'; + default: + return 'untyped'; + } +} + +function stringify( + metricName: string, + labels: Labels, + value: number, + timestamp?: number, + additionalLabels?: Labels +) { + let hasLabel = false; + let labelsStr = ''; + + for (const [key, val] of Object.entries(labels)) { + hasLabel = true; + labelsStr += `${labelsStr.length > 0 ? ',' : ''}${key}="${escapeLabelValue( + val + )}"`; + } + if (additionalLabels) { + for (const [key, val] of Object.entries(additionalLabels)) { + hasLabel = true; + labelsStr += `${ + labelsStr.length > 0 ? ',' : '' + }${key}="${escapeLabelValue(val)}"`; + } + } + + if (hasLabel) { + metricName += `{${labelsStr}}`; + } + + return `${metricName} ${valueString(value)}${ + timestamp !== undefined ? ' ' + String(timestamp) : '' + }\n`; +} + +export class PrometheusSerializer { + private _prefix: string | undefined; + private _appendTimestamp: boolean; + + constructor(prefix?: string, appendTimestamp = true) { + if (prefix) { + this._prefix = prefix + '_'; + } + this._appendTimestamp = appendTimestamp; + } + + serialize(checkpointSet: PrometheusCheckpoint[]): string { + let str = ''; + for (const checkpoint of checkpointSet) { + str += this.serializeCheckpointSet(checkpoint) + '\n'; + } + return str; + } + + serializeCheckpointSet(checkpoint: PrometheusCheckpoint): string { + let name = sanitizePrometheusMetricName( + escapeString(checkpoint.descriptor.name) + ); + if (this._prefix) { + name = `${this._prefix}${name}`; + } + const help = `# HELP ${name} ${escapeString( + checkpoint.descriptor.description || 'description missing' + )}`; + const type = `# TYPE ${name} ${toPrometheusType( + checkpoint.descriptor.metricKind, + checkpoint.aggregatorKind + )}`; + + const results = checkpoint.records + .map(it => this.serializeRecord(name, it)) + .join(''); + + return `${help}\n${type}\n${results}`.trim(); + } + + serializeRecord(name: string, record: MetricRecord): string { + let results = ''; + switch (record.aggregator.kind) { + case AggregatorKind.SUM: + case AggregatorKind.LAST_VALUE: { + const { value, timestamp: hrtime } = record.aggregator.toPoint(); + const timestamp = hrTimeToMilliseconds(hrtime); + results += stringify( + name, + record.labels, + value, + this._appendTimestamp ? timestamp : undefined, + undefined + ); + break; + } + case AggregatorKind.DISTRIBUTION: { + const { value, timestamp: hrtime } = record.aggregator.toPoint(); + const timestamp = hrTimeToMilliseconds(hrtime); + for (const key of ['count', 'sum'] as (keyof Distribution)[]) { + results += stringify( + name + '_' + key, + record.labels, + value[key], + this._appendTimestamp ? timestamp : undefined, + undefined + ); + } + results += stringify( + name, + record.labels, + value.min, + this._appendTimestamp ? timestamp : undefined, + { + quantile: '0', + } + ); + results += stringify( + name, + record.labels, + value.max, + this._appendTimestamp ? timestamp : undefined, + { + quantile: '1', + } + ); + break; + } + case AggregatorKind.HISTOGRAM: { + const { value, timestamp: hrtime } = record.aggregator.toPoint(); + const timestamp = hrTimeToMilliseconds(hrtime); + /** Histogram["bucket"] is not typed with `number` */ + for (const key of ['count', 'sum'] as ('count' | 'sum')[]) { + results += stringify( + name + '_' + key, + record.labels, + value[key], + this._appendTimestamp ? timestamp : undefined, + undefined + ); + } + for (const [idx, val] of value.buckets.counts.entries()) { + const upperBound = value.buckets.boundaries[idx]; + results += stringify( + name + '_bucket', + record.labels, + val, + this._appendTimestamp ? timestamp : undefined, + { + le: upperBound === undefined ? '+Inf' : String(upperBound), + } + ); + } + break; + } + } + return results; + } +} diff --git a/packages/opentelemetry-exporter-prometheus/src/index.ts b/packages/opentelemetry-exporter-prometheus/src/index.ts index be7bd5f868..bcf661b337 100644 --- a/packages/opentelemetry-exporter-prometheus/src/index.ts +++ b/packages/opentelemetry-exporter-prometheus/src/index.ts @@ -14,5 +14,5 @@ * limitations under the License. */ -export * from './prometheus'; +export * from './PrometheusExporter'; export * from './export/types'; diff --git a/packages/opentelemetry-exporter-prometheus/src/prometheus.ts b/packages/opentelemetry-exporter-prometheus/src/prometheus.ts deleted file mode 100644 index e58499c40b..0000000000 --- a/packages/opentelemetry-exporter-prometheus/src/prometheus.ts +++ /dev/null @@ -1,325 +0,0 @@ -/* - * Copyright The OpenTelemetry 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 - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import * as api from '@opentelemetry/api'; -import { - ExportResult, - hrTimeToMilliseconds, - NoopLogger, -} from '@opentelemetry/core'; -import { - Distribution, - Histogram, - MetricDescriptor, - MetricExporter, - MetricKind, - MetricRecord, - Sum, -} from '@opentelemetry/metrics'; -import { createServer, IncomingMessage, Server, ServerResponse } from 'http'; -import { Counter, Gauge, Metric, Registry } from 'prom-client'; -import * as url from 'url'; -import { ExporterConfig } from './export/types'; - -export class PrometheusExporter implements MetricExporter { - static readonly DEFAULT_OPTIONS = { - port: 9464, - startServer: false, - endpoint: '/metrics', - prefix: '', - }; - - private readonly _registry = new Registry(); - private readonly _logger: api.Logger; - private readonly _port: number; - private readonly _endpoint: string; - private readonly _server: Server; - private readonly _prefix?: string; - private readonly _invalidCharacterRegex = /[^a-z0-9_]/gi; - - // This will be required when histogram is implemented. Leaving here so it is not forgotten - // Histogram cannot have a label named 'le' - // private static readonly RESERVED_HISTOGRAM_LABEL = 'le'; - - /** - * Constructor - * @param config Exporter configuration - * @param callback Callback to be called after a server was started - */ - constructor(config: ExporterConfig = {}, callback?: () => void) { - this._logger = config.logger || new NoopLogger(); - this._port = config.port || PrometheusExporter.DEFAULT_OPTIONS.port; - this._prefix = config.prefix || PrometheusExporter.DEFAULT_OPTIONS.prefix; - this._server = createServer(this._requestHandler); - - this._endpoint = ( - config.endpoint || PrometheusExporter.DEFAULT_OPTIONS.endpoint - ).replace(/^([^/])/, '/$1'); - - if (config.startServer || PrometheusExporter.DEFAULT_OPTIONS.startServer) { - this.startServer(callback); - } else if (callback) { - callback(); - } - } - - /** - * Saves the current values of all exported {@link MetricRecord}s so that - * they can be pulled by the Prometheus backend. - * - * In its current state, the exporter saves the current values of all metrics - * when export is called and returns them when the export endpoint is called. - * In the future, this should be a no-op and the exporter should reach into - * the metrics when the export endpoint is called. As there is currently no - * interface to do this, this is our only option. - * - * @param records Metrics to be sent to the prometheus backend - * @param cb result callback to be called on finish - */ - export(records: MetricRecord[], cb: (result: ExportResult) => void) { - if (!this._server) { - // It is conceivable that the _server may not be started as it is an async startup - // However unlikely, if this happens the caller may retry the export - cb(ExportResult.FAILED_RETRYABLE); - return; - } - - this._logger.debug('Prometheus exporter export'); - - for (const record of records) { - this._updateMetric(record); - } - - cb(ExportResult.SUCCESS); - } - - /** - * Shuts down the export server and clears the registry - * - * @param cb called when server is stopped - */ - shutdown(cb?: () => void) { - this._registry.clear(); - this.stopServer(cb); - } - - /** - * Updates the value of a single metric in the registry - * - * @param record Metric value to be saved - */ - private _updateMetric(record: MetricRecord) { - const metric = this._registerMetric(record); - if (!metric) return; - - const point = record.aggregator.toPoint(); - - const labels = record.labels; - - if (metric instanceof Counter) { - // Prometheus counter saves internal state and increments by given value. - // MetricRecord value is the current state, not the delta to be incremented by. - // Currently, _registerMetric creates a new counter every time the value changes, - // so the increment here behaves as a set value (increment from 0) - metric.inc( - labels, - point.value as Sum, - hrTimeToMilliseconds(point.timestamp) - ); - } - - if (metric instanceof Gauge) { - if (typeof point.value === 'number') { - if ( - record.descriptor.metricKind === MetricKind.VALUE_OBSERVER || - record.descriptor.metricKind === MetricKind.VALUE_RECORDER - ) { - metric.set( - labels, - point.value, - hrTimeToMilliseconds(point.timestamp) - ); - } else { - metric.set(labels, point.value); - } - } else if ((point.value as Histogram).buckets) { - metric.set( - labels, - (point.value as Histogram).sum, - hrTimeToMilliseconds(point.timestamp) - ); - } else if (typeof (point.value as Distribution).last === 'number') { - metric.set( - labels, - (point.value as Distribution).last, - hrTimeToMilliseconds(point.timestamp) - ); - } - } - } - - private _registerMetric(record: MetricRecord): Metric | undefined { - const metricName = this._getPrometheusMetricName(record.descriptor); - const metric = this._registry.getSingleMetric(metricName); - - /** - * Prometheus library does aggregation, which means its inc method must be called with - * the value to be incremented by. It does not have a set method. As our MetricRecord - * contains the current value, not the value to be incremented by, we destroy and - * recreate counters when they are updated. - * - * This works because counters are identified by their name and no other internal ID - * https://prometheus.io/docs/instrumenting/exposition_formats/ - */ - if (metric instanceof Counter) { - metric.remove(...Object.values(record.labels)); - } - - if (metric) return metric; - - return this._newMetric(record, metricName); - } - - private _newMetric(record: MetricRecord, name: string): Metric | undefined { - const metricObject = { - name, - // prom-client throws with empty description which is our default - help: record.descriptor.description || 'description missing', - labelNames: Object.keys(record.labels), - // list of registries to register the newly created metric - registers: [this._registry], - }; - - switch (record.descriptor.metricKind) { - case MetricKind.COUNTER: - return new Counter(metricObject); - case MetricKind.UP_DOWN_COUNTER: - return new Gauge(metricObject); - case MetricKind.VALUE_RECORDER: - return new Gauge(metricObject); - case MetricKind.SUM_OBSERVER: - return new Counter(metricObject); - case MetricKind.UP_DOWN_SUM_OBSERVER: - return new Gauge(metricObject); - case MetricKind.VALUE_OBSERVER: - return new Gauge(metricObject); - default: - // Other metric types are currently unimplemented - return undefined; - } - } - - private _getPrometheusMetricName(descriptor: MetricDescriptor): string { - return this._sanitizePrometheusMetricName( - this._prefix ? `${this._prefix}_${descriptor.name}` : descriptor.name - ); - } - - /** - * Ensures metric names are valid Prometheus metric names by removing - * characters allowed by OpenTelemetry but disallowed by Prometheus. - * - * https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels - * - * 1. Names must match `[a-zA-Z_:][a-zA-Z0-9_:]*` - * - * 2. Colons are reserved for user defined recording rules. - * They should not be used by exporters or direct instrumentation. - * - * OpenTelemetry metric names are already validated in the Meter when they are created, - * and they match the format `[a-zA-Z][a-zA-Z0-9_.\-]*` which is very close to a valid - * prometheus metric name, so we only need to strip characters valid in OpenTelemetry - * but not valid in prometheus and replace them with '_'. - * - * @param name name to be sanitized - */ - private _sanitizePrometheusMetricName(name: string): string { - return name.replace(this._invalidCharacterRegex, '_'); // replace all invalid characters with '_' - } - - /** - * Stops the Prometheus export server - * @param callback A callback that will be executed once the server is stopped - */ - stopServer(callback?: () => void) { - if (!this._server) { - this._logger.debug( - 'Prometheus stopServer() was called but server was never started.' - ); - if (callback) { - callback(); - } - } else { - this._server.close(() => { - this._logger.debug('Prometheus exporter was stopped'); - if (callback) { - callback(); - } - }); - } - } - - /** - * Starts the Prometheus export server - * - * @param callback called once the server is ready - */ - startServer(callback?: () => void) { - this._server.listen(this._port, () => { - this._logger.debug( - `Prometheus exporter started on port ${this._port} at endpoint ${this._endpoint}` - ); - if (callback) { - callback(); - } - }); - } - - /** - * Request handler used by http library to respond to incoming requests - * for the current state of metrics by the Prometheus backend. - * - * @param request Incoming HTTP request to export server - * @param response HTTP response object used to respond to request - */ - private _requestHandler = ( - request: IncomingMessage, - response: ServerResponse - ) => { - if (url.parse(request.url!).pathname === this._endpoint) { - this._exportMetrics(response); - } else { - this._notFound(response); - } - }; - - /** - * Responds to incoming message with current state of all metrics. - */ - private _exportMetrics = (response: ServerResponse) => { - response.statusCode = 200; - response.setHeader('content-type', this._registry.contentType); - response.end(this._registry.metrics() || '# no registered metrics'); - }; - - /** - * Responds with 404 status code to all requests that do not match the configured endpoint. - */ - private _notFound = (response: ServerResponse) => { - response.statusCode = 404; - response.end(); - }; -} diff --git a/packages/opentelemetry-exporter-prometheus/src/types.ts b/packages/opentelemetry-exporter-prometheus/src/types.ts new file mode 100644 index 0000000000..343dc99197 --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/src/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + MetricDescriptor, + AggregatorKind, + MetricRecord, +} from '@opentelemetry/metrics'; + +export interface PrometheusCheckpoint { + descriptor: MetricDescriptor; + aggregatorKind: AggregatorKind; + records: MetricRecord[]; +} diff --git a/packages/opentelemetry-exporter-prometheus/test/ExactBatcher.ts b/packages/opentelemetry-exporter-prometheus/test/ExactBatcher.ts new file mode 100644 index 0000000000..4d4a6fa972 --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/test/ExactBatcher.ts @@ -0,0 +1,49 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + Batcher, + MetricDescriptor, + Aggregator, + MetricRecord, +} from '@opentelemetry/metrics'; + +type Constructor = new (...args: T[]) => R; + +export class ExactBatcher extends Batcher { + private readonly args: ConstructorParameters>; + public aggregators: R[] = []; + + constructor( + private readonly aggregator: Constructor, + ...args: ConstructorParameters> + ) { + super(); + this.args = args; + } + + aggregatorFor(metricDescriptor: MetricDescriptor): Aggregator { + const aggregator = new this.aggregator(...this.args); + this.aggregators.push(aggregator); + return aggregator; + } + + process(record: MetricRecord): void { + const labels = Object.keys(record.labels) + .map(k => `${k}=${record.labels[k]}`) + .join(','); + this._batchMap.set(record.descriptor.name + labels, record); + } +} diff --git a/packages/opentelemetry-exporter-prometheus/test/prometheus.test.ts b/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts similarity index 88% rename from packages/opentelemetry-exporter-prometheus/test/prometheus.test.ts rename to packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts index e642aba620..d3e50edfa0 100644 --- a/packages/opentelemetry-exporter-prometheus/test/prometheus.test.ts +++ b/packages/opentelemetry-exporter-prometheus/test/PrometheusExporter.test.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { HrTime, ObserverResult } from '@opentelemetry/api'; +import { ObserverResult } from '@opentelemetry/api'; import { notifyOnGlobalShutdown, _invokeGlobalShutdown, @@ -24,30 +24,18 @@ import { SumAggregator, Meter, MeterProvider, - Point, - Sum, + MinMaxLastSumCountAggregator, } from '@opentelemetry/metrics'; import * as assert from 'assert'; import * as http from 'http'; import { PrometheusExporter } from '../src'; - -const mockedHrTime: HrTime = [1586347902211, 0]; -const mockedTimeMS = 1586347902211000; +import { mockAggregator, mockedHrTimeMs } from './util'; describe('PrometheusExporter', () => { - let toPoint: () => Point; let removeEvent: Function | undefined; - before(() => { - toPoint = SumAggregator.prototype.toPoint; - SumAggregator.prototype.toPoint = function (): Point { - const point = toPoint.apply(this); - point.timestamp = mockedHrTime; - return point; - }; - }); - after(() => { - SumAggregator.prototype.toPoint = toPoint; - }); + mockAggregator(SumAggregator); + mockAggregator(MinMaxLastSumCountAggregator); + describe('constructor', () => { it('should construct an exporter', () => { const exporter = new PrometheusExporter(); @@ -222,6 +210,7 @@ describe('PrometheusExporter', () => { boundCounter.add(10); meter.collect().then(() => { exporter.export(meter.getBatcher().checkPointSet(), () => { + // TODO: Remove this special case once the PR is ready. // This is to test the special case where counters are destroyed // and recreated in the exporter in order to get around prom-client's // aggregation and use ours. @@ -241,7 +230,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter a test description', '# TYPE counter counter', - `counter{key1="labelValue1"} 20 ${mockedTimeMS}`, + `counter{key1="labelValue1"} 20 ${mockedHrTimeMs}`, '', ]); @@ -256,7 +245,7 @@ describe('PrometheusExporter', () => { it('should export an observer aggregation', done => { function getCpuUsage() { - return Math.random(); + return 0.999; } meter.createValueObserver( @@ -281,20 +270,15 @@ describe('PrometheusExporter', () => { const body = chunk.toString(); const lines = body.split('\n'); - assert.strictEqual( - lines[0], - '# HELP metric_observer a test description' - ); - assert.strictEqual(lines[1], '# TYPE metric_observer gauge'); - - const line3 = lines[2].split(' '); - assert.strictEqual( - line3[0], - 'metric_observer{pid="123",core="1"}' - ); - assert.ok( - parseFloat(line3[1]) >= 0 && parseFloat(line3[1]) <= 1 - ); + assert.deepStrictEqual(lines, [ + '# HELP metric_observer a test description', + '# TYPE metric_observer summary', + `metric_observer_count{pid="123",core="1"} 1 ${mockedHrTimeMs}`, + `metric_observer_sum{pid="123",core="1"} 0.999 ${mockedHrTimeMs}`, + `metric_observer{pid="123",core="1",quantile="0"} 0.999 ${mockedHrTimeMs}`, + `metric_observer{pid="123",core="1",quantile="1"} 0.999 ${mockedHrTimeMs}`, + '', + ]); done(); }); @@ -323,8 +307,8 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter a test description', '# TYPE counter counter', - `counter{counterKey1="labelValue1"} 10 ${mockedTimeMS}`, - `counter{counterKey1="labelValue2"} 20 ${mockedTimeMS}`, + `counter{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, + `counter{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, '', ]); @@ -355,9 +339,9 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter a test description', '# TYPE counter counter', - `counter{counterKey1="labelValue1"} 10 ${mockedTimeMS}`, - `counter{counterKey1="labelValue2"} 20 ${mockedTimeMS}`, - `counter{counterKey1="labelValue3"} 30 ${mockedTimeMS}`, + `counter{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, + `counter{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, + `counter{counterKey1="labelValue3"} 30 ${mockedHrTimeMs}`, '', ]); @@ -387,9 +371,9 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter a test description', '# TYPE counter counter', - `counter{counterKey1="labelValue1"} 10 ${mockedTimeMS}`, - `counter{counterKey1="labelValue2"} 20 ${mockedTimeMS}`, - `counter{counterKey1="labelValue3"} 30 ${mockedTimeMS}`, + `counter{counterKey1="labelValue1"} 10 ${mockedHrTimeMs}`, + `counter{counterKey1="labelValue2"} 20 ${mockedHrTimeMs}`, + `counter{counterKey1="labelValue3"} 30 ${mockedHrTimeMs}`, '', ]); @@ -433,7 +417,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter description missing', '# TYPE counter counter', - `counter{key1="labelValue1"} 10 ${mockedTimeMS}`, + `counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -460,7 +444,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter_bad_name description missing', '# TYPE counter_bad_name counter', - `counter_bad_name{key1="labelValue1"} 10 ${mockedTimeMS}`, + `counter_bad_name{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -486,7 +470,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(chunk.toString().split('\n'), [ '# HELP counter a test description', '# TYPE counter gauge', - 'counter{key1="labelValue1"} 20', + `counter{key1="labelValue1"} 20 ${mockedHrTimeMs}`, '', ]); @@ -526,7 +510,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP sum_observer a test description', '# TYPE sum_observer counter', - `sum_observer{key1="labelValue1"} 20 ${mockedTimeMS}`, + `sum_observer{key1="labelValue1"} 20 ${mockedHrTimeMs}`, '', ]); }); @@ -566,7 +550,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP updown_observer a test description', '# TYPE updown_observer gauge', - 'updown_observer{key1="labelValue1"} 20', + `updown_observer{key1="labelValue1"} 20 ${mockedHrTimeMs}`, '', ]); }); @@ -578,7 +562,7 @@ describe('PrometheusExporter', () => { }); }); - it('should export a ValueRecorder as a gauge', done => { + it('should export a ValueRecorder as a summary', done => { const valueRecorder = meter.createValueRecorder('value_recorder', { description: 'a test description', }); @@ -593,18 +577,15 @@ describe('PrometheusExporter', () => { const body = chunk.toString(); const lines = body.split('\n'); - assert.strictEqual( - lines[0], - '# HELP value_recorder a test description' - ); - assert.strictEqual(lines[1], '# TYPE value_recorder gauge'); - - const line3 = lines[2].split(' '); - assert.strictEqual( - line3[0], - 'value_recorder{key1="labelValue1"}' - ); - assert.equal(line3[1], 20); + assert.deepStrictEqual(lines, [ + '# HELP value_recorder a test description', + '# TYPE value_recorder summary', + `value_recorder_count{key1="labelValue1"} 1 ${mockedHrTimeMs}`, + `value_recorder_sum{key1="labelValue1"} 20 ${mockedHrTimeMs}`, + `value_recorder{key1="labelValue1",quantile="0"} 20 ${mockedHrTimeMs}`, + `value_recorder{key1="labelValue1",quantile="1"} 20 ${mockedHrTimeMs}`, + '', + ]); done(); }); @@ -652,7 +633,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP test_prefix_counter description missing', '# TYPE test_prefix_counter counter', - `test_prefix_counter{key1="labelValue1"} 10 ${mockedTimeMS}`, + `test_prefix_counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -681,7 +662,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter description missing', '# TYPE counter counter', - `counter{key1="labelValue1"} 10 ${mockedTimeMS}`, + `counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); @@ -710,7 +691,7 @@ describe('PrometheusExporter', () => { assert.deepStrictEqual(lines, [ '# HELP counter description missing', '# TYPE counter counter', - `counter{key1="labelValue1"} 10 ${mockedTimeMS}`, + `counter{key1="labelValue1"} 10 ${mockedHrTimeMs}`, '', ]); diff --git a/packages/opentelemetry-exporter-prometheus/test/PrometheusLabelsBatcher.test.ts b/packages/opentelemetry-exporter-prometheus/test/PrometheusLabelsBatcher.test.ts new file mode 100644 index 0000000000..27a500700b --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/test/PrometheusLabelsBatcher.test.ts @@ -0,0 +1,84 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import * as assert from 'assert'; +import { PrometheusLabelsBatcher } from '../src/PrometheusLabelsBatcher'; +import { + CounterMetric, + AggregatorKind, + MeterProvider, + Meter, +} from '@opentelemetry/metrics'; +import { Labels } from '@opentelemetry/api'; + +describe('PrometheusBatcher', () => { + let meter: Meter; + before(() => { + meter = new MeterProvider({}).getMeter('test'); + }); + + describe('constructor', () => { + it('should construct a batcher', () => { + const batcher = new PrometheusLabelsBatcher(); + assert(batcher instanceof PrometheusLabelsBatcher); + }); + }); + + describe('process', () => { + it('should aggregate metric records with same metric name', async () => { + const batcher = new PrometheusLabelsBatcher(); + const counter = meter.createCounter('test_counter') as CounterMetric; + counter.bind({ val: '1' }).add(1); + counter.bind({ val: '2' }).add(1); + + const records = await counter.getMetricRecord(); + records.forEach(it => batcher.process(it)); + + const checkPointSet = batcher.checkPointSet(); + assert.strictEqual(checkPointSet.length, 1); + assert.strictEqual(checkPointSet[0].descriptor.name, 'test_counter'); + assert.strictEqual(checkPointSet[0].aggregatorKind, AggregatorKind.SUM); + assert.strictEqual(checkPointSet[0].records.length, 2); + }); + + it('should recognize identical labels with different key-insertion order', async () => { + const batcher = new PrometheusLabelsBatcher(); + const counter = meter.createCounter('test_counter') as CounterMetric; + + const label1: Labels = {}; + label1.key1 = '1'; + label1.key2 = '2'; + + const label2: Labels = {}; + label2.key2 = '2'; + label2.key1 = '1'; + + counter.bind(label1).add(1); + counter.bind(label2).add(1); + + const records = await counter.getMetricRecord(); + records.forEach(it => batcher.process(it)); + + const checkPointSet = batcher.checkPointSet(); + assert.strictEqual(checkPointSet.length, 1); + const checkPoint = checkPointSet[0]; + assert.strictEqual(checkPoint.descriptor.name, 'test_counter'); + assert.strictEqual(checkPoint.aggregatorKind, AggregatorKind.SUM); + assert.strictEqual(checkPoint.records.length, 1); + const record = checkPoint.records[0]; + assert.strictEqual(record.aggregator.toPoint().value, 2); + }); + }); +}); diff --git a/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts b/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts new file mode 100644 index 0000000000..b706e635d9 --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/test/PrometheusSerializer.test.ts @@ -0,0 +1,462 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { + SumAggregator, + MinMaxLastSumCountAggregator, + HistogramAggregator, + MeterProvider, + CounterMetric, + ValueRecorderMetric, + UpDownCounterMetric, +} from '@opentelemetry/metrics'; +import * as assert from 'assert'; +import { Labels } from '@opentelemetry/api'; +import { PrometheusSerializer } from '../src/PrometheusSerializer'; +import { PrometheusLabelsBatcher } from '../src/PrometheusLabelsBatcher'; +import { ExactBatcher } from './ExactBatcher'; +import { mockedHrTimeMs, mockAggregator } from './util'; + +const labels = { + foo1: 'bar1', + foo2: 'bar2', +}; + +describe('PrometheusSerializer', () => { + describe('constructor', () => { + it('should construct a serializer', () => { + const serializer = new PrometheusSerializer(); + assert(serializer instanceof PrometheusSerializer); + }); + }); + + describe('serialize a metric record', () => { + describe('with SumAggregator', () => { + mockAggregator(SumAggregator); + + it('should serialize metric record with sum aggregator', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter.bind(labels).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + `test{foo1="bar1",foo2="bar2"} 1 ${mockedHrTimeMs}\n` + ); + }); + + it('serialize metric record with sum aggregator without timestamp', async () => { + const serializer = new PrometheusSerializer(undefined, false); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter.bind(labels).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual(result, 'test{foo1="bar1",foo2="bar2"} 1\n'); + }); + }); + + describe('with MinMaxLastSumCountAggregator', () => { + mockAggregator(MinMaxLastSumCountAggregator); + + it('should serialize metric record with sum aggregator', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(MinMaxLastSumCountAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter.bind(labels).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + `test_count{foo1="bar1",foo2="bar2"} 1 ${mockedHrTimeMs}\n` + + `test_sum{foo1="bar1",foo2="bar2"} 1 ${mockedHrTimeMs}\n` + + `test{foo1="bar1",foo2="bar2",quantile="0"} 1 ${mockedHrTimeMs}\n` + + `test{foo1="bar1",foo2="bar2",quantile="1"} 1 ${mockedHrTimeMs}\n` + ); + }); + + it('serialize metric record with sum aggregator without timestamp', async () => { + const serializer = new PrometheusSerializer(undefined, false); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(MinMaxLastSumCountAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter.bind(labels).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + 'test_count{foo1="bar1",foo2="bar2"} 1\n' + + 'test_sum{foo1="bar1",foo2="bar2"} 1\n' + + 'test{foo1="bar1",foo2="bar2",quantile="0"} 1\n' + + 'test{foo1="bar1",foo2="bar2",quantile="1"} 1\n' + ); + }); + }); + + describe('with HistogramAggregator', () => { + mockAggregator(HistogramAggregator); + + it('should serialize metric record with sum aggregator', async () => { + const serializer = new PrometheusSerializer(); + + const batcher = new ExactBatcher(HistogramAggregator, [1, 10, 100]); + const meter = new MeterProvider({ batcher }).getMeter('test'); + const recorder = meter.createValueRecorder('test', { + description: 'foobar', + }) as ValueRecorderMetric; + recorder.bind(labels).record(5); + + const records = await recorder.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + `test_count{foo1="bar1",foo2="bar2"} 1 ${mockedHrTimeMs}\n` + + `test_sum{foo1="bar1",foo2="bar2"} 5 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",le="1"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",le="10"} 1 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",le="100"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{foo1="bar1",foo2="bar2",le="+Inf"} 0 ${mockedHrTimeMs}\n` + ); + }); + + it('serialize metric record with sum aggregator without timestamp', async () => { + const serializer = new PrometheusSerializer(undefined, false); + + const batcher = new ExactBatcher(HistogramAggregator, [1, 10, 100]); + const meter = new MeterProvider({ batcher }).getMeter('test'); + const recorder = meter.createValueRecorder('test', { + description: 'foobar', + }) as ValueRecorderMetric; + recorder.bind(labels).record(5); + + const records = await recorder.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + 'test_count{foo1="bar1",foo2="bar2"} 1\n' + + 'test_sum{foo1="bar1",foo2="bar2"} 5\n' + + 'test_bucket{foo1="bar1",foo2="bar2",le="1"} 0\n' + + 'test_bucket{foo1="bar1",foo2="bar2",le="10"} 1\n' + + 'test_bucket{foo1="bar1",foo2="bar2",le="100"} 0\n' + + 'test_bucket{foo1="bar1",foo2="bar2",le="+Inf"} 0\n' + ); + }); + }); + }); + + describe('serialize a checkpoint set', () => { + describe('with SumAggregator', () => { + mockAggregator(SumAggregator); + + it('should serialize metric record with sum aggregator', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const batcher = new PrometheusLabelsBatcher(); + const counter = meter.createCounter('test', { + description: 'foobar', + }) as CounterMetric; + counter.bind({ val: '1' }).add(1); + counter.bind({ val: '2' }).add(1); + + const records = await counter.getMetricRecord(); + records.forEach(it => batcher.process(it)); + const checkPointSet = batcher.checkPointSet(); + + const result = serializer.serialize(checkPointSet); + assert.strictEqual( + result, + '# HELP test foobar\n' + + '# TYPE test counter\n' + + `test{val="1"} 1 ${mockedHrTimeMs}\n` + + `test{val="2"} 1 ${mockedHrTimeMs}\n` + ); + }); + + it('serialize metric record with sum aggregator without timestamp', async () => { + const serializer = new PrometheusSerializer(undefined, false); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const batcher = new PrometheusLabelsBatcher(); + const counter = meter.createCounter('test', { + description: 'foobar', + }) as CounterMetric; + counter.bind({ val: '1' }).add(1); + counter.bind({ val: '2' }).add(1); + + const records = await counter.getMetricRecord(); + records.forEach(it => batcher.process(it)); + const checkPointSet = batcher.checkPointSet(); + + const result = serializer.serialize(checkPointSet); + assert.strictEqual( + result, + '# HELP test foobar\n' + + '# TYPE test counter\n' + + 'test{val="1"} 1\n' + + 'test{val="2"} 1\n' + ); + }); + }); + + describe('with MinMaxLastSumCountAggregator', () => { + mockAggregator(MinMaxLastSumCountAggregator); + + it('serialize metric record with MinMaxLastSumCountAggregator aggregator', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(MinMaxLastSumCountAggregator), + }).getMeter('test'); + const batcher = new PrometheusLabelsBatcher(); + const counter = meter.createCounter('test', { + description: 'foobar', + }) as CounterMetric; + counter.bind({ val: '1' }).add(1); + counter.bind({ val: '2' }).add(1); + + const records = await counter.getMetricRecord(); + records.forEach(it => batcher.process(it)); + const checkPointSet = batcher.checkPointSet(); + + const result = serializer.serialize(checkPointSet); + assert.strictEqual( + result, + '# HELP test foobar\n' + + '# TYPE test summary\n' + + `test_count{val="1"} 1 ${mockedHrTimeMs}\n` + + `test_sum{val="1"} 1 ${mockedHrTimeMs}\n` + + `test{val="1",quantile="0"} 1 ${mockedHrTimeMs}\n` + + `test{val="1",quantile="1"} 1 ${mockedHrTimeMs}\n` + + `test_count{val="2"} 1 ${mockedHrTimeMs}\n` + + `test_sum{val="2"} 1 ${mockedHrTimeMs}\n` + + `test{val="2",quantile="0"} 1 ${mockedHrTimeMs}\n` + + `test{val="2",quantile="1"} 1 ${mockedHrTimeMs}\n` + ); + }); + }); + + describe('with HistogramAggregator', () => { + mockAggregator(HistogramAggregator); + + it('serialize metric record with MinMaxLastSumCountAggregator aggregator', async () => { + const serializer = new PrometheusSerializer(); + + const batcher = new ExactBatcher(HistogramAggregator, [1, 10, 100]); + const meter = new MeterProvider({ batcher }).getMeter('test'); + const recorder = meter.createValueRecorder('test', { + description: 'foobar', + }) as ValueRecorderMetric; + recorder.bind({ val: '1' }).record(5); + recorder.bind({ val: '2' }).record(5); + + const records = await recorder.getMetricRecord(); + const labelBatcher = new PrometheusLabelsBatcher(); + records.forEach(it => labelBatcher.process(it)); + const checkPointSet = labelBatcher.checkPointSet(); + + const result = serializer.serialize(checkPointSet); + assert.strictEqual( + result, + '# HELP test foobar\n' + + '# TYPE test histogram\n' + + `test_count{val="1"} 1 ${mockedHrTimeMs}\n` + + `test_sum{val="1"} 5 ${mockedHrTimeMs}\n` + + `test_bucket{val="1",le="1"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{val="1",le="10"} 1 ${mockedHrTimeMs}\n` + + `test_bucket{val="1",le="100"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{val="1",le="+Inf"} 0 ${mockedHrTimeMs}\n` + + `test_count{val="2"} 1 ${mockedHrTimeMs}\n` + + `test_sum{val="2"} 5 ${mockedHrTimeMs}\n` + + `test_bucket{val="2",le="1"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{val="2",le="10"} 1 ${mockedHrTimeMs}\n` + + `test_bucket{val="2",le="100"} 0 ${mockedHrTimeMs}\n` + + `test_bucket{val="2",le="+Inf"} 0 ${mockedHrTimeMs}\n` + ); + }); + }); + }); + + describe('serialize non-normalized values', () => { + describe('with SumAggregator', () => { + mockAggregator(SumAggregator); + + it('should serialize records without labels', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter.bind({}).add(1); + + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual(result, `test 1 ${mockedHrTimeMs}\n`); + }); + + it('should serialize non-string label values', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter + .bind(({ + object: {}, + NaN: NaN, + null: null, + undefined: undefined, + } as unknown) as Labels) + .add(1); + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + `test{object="[object Object]",NaN="NaN",null="null",undefined="undefined"} 1 ${mockedHrTimeMs}\n` + ); + }); + + it('should serialize non-finite values', async () => { + const serializer = new PrometheusSerializer(); + const cases = [ + [NaN, 'Nan'], + [-Infinity, '-Inf'], + [+Infinity, '+Inf'], + ] as [number, string][]; + + for (const esac of cases) { + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const counter = meter.createUpDownCounter( + 'test' + ) as UpDownCounterMetric; + counter.bind(labels).add(esac[0]); + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + `test{foo1="bar1",foo2="bar2"} ${esac[1]} ${mockedHrTimeMs}\n` + ); + } + }); + + it('should escape backslash (\\), double-quote ("), and line feed (\\n) in label values', async () => { + const serializer = new PrometheusSerializer(); + + const meter = new MeterProvider({ + batcher: new ExactBatcher(SumAggregator), + }).getMeter('test'); + const counter = meter.createCounter('test') as CounterMetric; + counter + .bind(({ + backslash: '\u005c', // \ => \\ (\u005c\u005c) + doubleQuote: '\u0022', // " => \" (\u005c\u0022) + lineFeed: '\u000a', // ↵ => \n (\u005c\u006e) + backslashN: '\u005c\u006e', // \n => \\n (\u005c\u005c\u006e) + backslashDoubleQuote: '\u005c\u0022', // \" => \\\" (\u005c\u005c\u005c\u0022) + backslashLineFeed: '\u005c\u000a', // \↵ => \\\n (\u005c\u005c\u005c\u006e) + } as unknown) as Labels) + .add(1); + const records = await counter.getMetricRecord(); + const record = records[0]; + + const result = serializer.serializeRecord( + record.descriptor.name, + record + ); + assert.strictEqual( + result, + 'test{' + + 'backslash="\u005c\u005c",' + + 'doubleQuote="\u005c\u0022",' + + 'lineFeed="\u005c\u006e",' + + 'backslashN="\u005c\u005c\u006e",' + + 'backslashDoubleQuote="\u005c\u005c\u005c\u0022",' + + 'backslashLineFeed="\u005c\u005c\u005c\u006e"' + + `} 1 ${mockedHrTimeMs}\n` + ); + }); + }); + }); +}); diff --git a/packages/opentelemetry-exporter-prometheus/test/util.ts b/packages/opentelemetry-exporter-prometheus/test/util.ts new file mode 100644 index 0000000000..697dd8ee54 --- /dev/null +++ b/packages/opentelemetry-exporter-prometheus/test/util.ts @@ -0,0 +1,34 @@ +/* + * Copyright The OpenTelemetry 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { Point, Sum } from '@opentelemetry/metrics'; +import { HrTime } from '@opentelemetry/api'; + +export const mockedHrTime: HrTime = [1586347902, 211_000_000]; +export const mockedHrTimeMs = 1586347902211; +export function mockAggregator(Aggregator: any) { + let toPoint: () => Point; + before(() => { + toPoint = Aggregator.prototype.toPoint; + Aggregator.prototype.toPoint = function (): Point { + const point = toPoint.apply(this); + point.timestamp = mockedHrTime; + return point; + }; + }); + after(() => { + Aggregator.prototype.toPoint = toPoint; + }); +} diff --git a/renovate.json b/renovate.json index d2b949af99..d95f24142e 100644 --- a/renovate.json +++ b/renovate.json @@ -12,8 +12,7 @@ "ignoreDeps": [ "gcp-metadata", "got", - "mocha", - "prom-client" + "mocha" ], "assignees": [ "@dyladan",