diff --git a/CHANGELOG.md b/CHANGELOG.md index 765b92b7..f02eac90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ project adheres to [Semantic Versioning](http://semver.org/). - Record cluster and worker thread scrape failures in internal histograms, including timeouts, worker-reported errors, and failures with no known worker errors. +- Prometheus protobuf registries for classic metrics, with public + content-type constants and TypeScript support for binary output. ## [0.16.0] - 2026-08-24 diff --git a/README.md b/README.md index 35244ccd..8274910c 100644 --- a/README.md +++ b/README.md @@ -397,11 +397,12 @@ enabled. They get a single object with the format `{labels, value, exemplarLabels}`. When using exemplars, the registry used for metrics should be set to OpenMetrics -type (including the global or default registry if no registries are specified). +or Prometheus protobuf (including the global or default registry if no registries +are specified). ### Registry type -The library supports both the old Prometheus format and the OpenMetrics format. +The library supports Prometheus text, OpenMetrics text, and Prometheus protobuf. The format can be set per registry. For default metrics: ```js @@ -419,9 +420,14 @@ this is currently the default registry type. **OPENMETRICS_CONTENT_TYPE** - defaults to version 1.0.0 of the [OpenMetrics standard](https://github.com/OpenObservability/OpenMetrics/blob/d99b705f611b75fec8f450b05e344e02eea6921d/specification/OpenMetrics.md). +**PROMETHEUS_PROTOBUF_CONTENT_TYPE** - length-delimited Prometheus protobuf. +`metrics()` returns a `Buffer` for this format; `getMetricsAsString()` and +`getSingleMetricAsString()` still return Prometheus text. + The HTTP Content-Type string for each registry type is exposed both at module -level (`prometheusContentType` and `openMetricsContentType`) and as static -properties on the `Registry` object. +level (`prometheusContentType`, `openMetricsContentType`, and +`prometheusProtobufContentType`) and as static properties on the `Registry` +object. The `contentType` constant exposed by the module returns the default content type when creating a new registry, currently defaults to Prometheus type. diff --git a/index.d.ts b/index.d.ts index 6e60e8bf..6f394d02 100644 --- a/index.d.ts +++ b/index.d.ts @@ -28,24 +28,32 @@ export type OpenMetricsContentType = export type PrometheusContentType = `${PrometheusMIME}; version=${PrometheusMetricsVersion}; charset=${Charset}`; +export type PrometheusProtobufContentType = + 'application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited'; + export type RegistryContentType = - PrometheusContentType | OpenMetricsContentType; + | PrometheusContentType + | OpenMetricsContentType + | PrometheusProtobufContentType; + +export type RegistryMetrics = + T extends PrometheusProtobufContentType ? Uint8Array : string; /** * Container for all registered metrics */ export class Registry< - BoundRegistryContentType extends RegistryContentType = RegistryContentType, + BoundRegistryContentType extends RegistryContentType = PrometheusContentType, > { /** * @param regContentType The content type of the registry */ - constructor(regContentType?: RegistryContentType); + constructor(regContentType?: BoundRegistryContentType); /** - * Get string representation for all metrics + * Get metrics as text, or a Buffer for a Prometheus protobuf registry */ - metrics(): Promise; + metrics(): Promise>; /** * Remove all metrics from the registry @@ -77,8 +85,8 @@ export class Registry< ): Promise>[]>; /** - * Get string representation for a metric - * @param metric Metric to convert to a string + * Get a metric as text + * @param metric Metric to serialize */ getMetricsAsString(metric: Metric): Promise; @@ -113,7 +121,7 @@ export class Registry< setDefaultLabels(labels: object): void; /** - * Get a string representation of a single metric by name + * Get a single metric as text * @param name The name of the metric */ getSingleMetricAsString(name: string): Promise; @@ -121,22 +129,22 @@ export class Registry< /** * Gets the Content-Type of the metrics for use in the response headers. */ - readonly contentType: PrometheusContentType | OpenMetricsContentType; + readonly contentType: RegistryContentType; /** - * Set the content type of a registry. Used to change between Prometheus and - * OpenMetrics versions. + * Set the content type of a registry. The returned registry has the new + * serialization return type; use it when switching between text and protobuf. * @param contentType The type of the registry */ - setContentType( - contentType: PrometheusContentType | OpenMetricsContentType, - ): void; + setContentType(contentType: T): Registry; /** * Merge registers * @param registers The registers you want to merge together */ - static merge(registers: Registry[]): Registry; + static merge( + registers: Registry[], + ): Registry; /** * Creates a new Registry instance from an array of metrics that were @@ -147,9 +155,10 @@ export class Registry< * `registry.getMetricsAsJSON()`. * @returns {Registry} aggregated registry. */ - static aggregate( + static aggregate( metricsArr: Array, - ): Registry; // TODO Promise? + registryType?: T, + ): Registry; /** * HTTP Prometheus Content-Type for metrics response headers. @@ -160,6 +169,11 @@ export class Registry< * HTTP OpenMetrics Content-Type for metrics response headers. */ static OPENMETRICS_CONTENT_TYPE: OpenMetricsContentType; + + /** + * HTTP Content-Type for length-delimited Prometheus protobuf metrics. + */ + static PROMETHEUS_PROTOBUF_CONTENT_TYPE: PrometheusProtobufContentType; } export type Collector = () => void; @@ -184,15 +198,23 @@ export const prometheusContentType: PrometheusContentType; */ export const openMetricsContentType: OpenMetricsContentType; +/** + * HTTP Content-Type for length-delimited Prometheus protobuf metrics. + */ +export const prometheusProtobufContentType: PrometheusProtobufContentType; + export class ClusterRegistry< - T extends RegistryContentType, + T extends RegistryContentType = PrometheusContentType, > extends Registry { + setContentType( + contentType: U, + ): ClusterRegistry; + /** * Gets aggregated metrics for all workers. - * @returns {Promise} Promise that resolves with the aggregated - * metrics. + * @returns Promise that resolves with text or protobuf bytes. */ - clusterMetrics(): Promise; + clusterMetrics(): Promise>; /** * Sets the registry or registries to be aggregated. Call from workers to @@ -202,22 +224,22 @@ export class ClusterRegistry< * @returns {void} */ static setRegistries( - regs: - | Array< - Registry | Registry - > - | Registry - | Registry, + regs: Registry[] | Registry, ): void; } -export class WorkerRegistry extends Registry { +export class WorkerRegistry< + T extends RegistryContentType = PrometheusContentType, +> extends Registry { + setContentType( + contentType: U, + ): WorkerRegistry; + /** * Gets aggregated metrics for all workers. - * @returns {Promise} Promise that resolves with the aggregated - * metrics. + * @returns Promise that resolves with text or protobuf bytes. */ - workerMetrics(): Promise; + workerMetrics(): Promise>; /** * Orderly shutdown of the registry. @@ -238,12 +260,7 @@ export class WorkerRegistry extends Registry { * @returns {void} */ static setRegistries( - regs: - | Array< - Registry | Registry - > - | Registry - | Registry, + regs: Registry[] | Registry, ): void; } @@ -251,14 +268,17 @@ export class WorkerRegistry extends Registry { * @deprecated */ export class AggregatorRegistry< - T extends RegistryContentType, + T extends RegistryContentType = PrometheusContentType, > extends Registry { + setContentType( + contentType: U, + ): AggregatorRegistry; + /** * Gets aggregated metrics for all workers. - * @returns {Promise} Promise that resolves with the aggregated - * metrics. + * @returns Promise that resolves with text or protobuf bytes. */ - clusterMetrics(): Promise; + clusterMetrics(): Promise>; /** * Orderly shutdown of the registry. @@ -279,12 +299,7 @@ export class AggregatorRegistry< * @returns {void} */ static setRegistries( - regs: - | Array< - Registry | Registry - > - | Registry - | Registry, + regs: Registry[] | Registry, ): void; } @@ -340,9 +355,7 @@ interface MetricConfiguration { name: string; help: string; labelNames?: T[] | readonly T[]; - registers?: ( - Registry | Registry - )[]; + registers?: Registry[]; aggregator?: Aggregator; collect?: CollectFunction; enableExemplars?: boolean; diff --git a/index.js b/index.js index 52ca3631..96319260 100644 --- a/index.js +++ b/index.js @@ -33,6 +33,8 @@ Object.defineProperty(exports, 'contentType', { }); exports.prometheusContentType = exports.Registry.PROMETHEUS_CONTENT_TYPE; exports.openMetricsContentType = exports.Registry.OPENMETRICS_CONTENT_TYPE; +exports.prometheusProtobufContentType = + exports.Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE; exports.validateMetricName = require('./lib/validation').validateMetricName; exports.Counter = require('./lib/counter'); diff --git a/lib/cluster.js b/lib/cluster.js index 21a688d0..51c6b6b2 100644 --- a/lib/cluster.js +++ b/lib/cluster.js @@ -72,10 +72,11 @@ class AggregatorRegistry extends Registry { /** * Gets aggregated metrics for all workers. The optional callback and * returned Promise resolve with the same value; either may be used. - * @returns {Promise} Promise that resolves with the aggregated + * @returns {Promise} Promise that resolves with the aggregated * metrics. */ async clusterMetrics() { + const contentType = this.contentType; const requestId = requestCtr++; const orderedWorkers = [...workers.values()] .filter(worker => worker.isConnected()) @@ -102,7 +103,7 @@ class AggregatorRegistry extends Registry { responseHandlers, workerFailures: 0, promise: waitFor( - this.#gather(requestId, metricSnapshot, responsePromises), + this.#gather(requestId, metricSnapshot, responsePromises, contentType), 5_000, ), }; @@ -149,15 +150,13 @@ class AggregatorRegistry extends Registry { * @param requestId {number} * @param {any[]} historical - Previously collected values * @param promises {Promise[]} - * @returns {Promise} + * @param contentType {string} + * @returns {Promise} */ - async #gather(requestId, historical, promises) { + async #gather(requestId, historical, promises, contentType) { const responses = await Promise.all(promises); const metrics = responses.flatMap(response => response.metrics); - return Registry.aggregate( - [historical, ...metrics], - this.contentType, - ).metrics(); + return Registry.aggregate([historical, ...metrics], contentType).metrics(); } get contentType() { diff --git a/lib/metric.js b/lib/metric.js index 0c519de0..c3cc4f0c 100644 --- a/lib/metric.js +++ b/lib/metric.js @@ -76,7 +76,7 @@ class Metric { register.contentType === Registry.PROMETHEUS_CONTENT_TYPE ) { throw new TypeError( - 'Exemplars are supported only on OpenMetrics registries', + 'Exemplars are supported only on OpenMetrics or Prometheus protobuf registries', ); } register.registerMetric(this); diff --git a/lib/metrics.json b/lib/metrics.json new file mode 100644 index 00000000..2e3d7795 --- /dev/null +++ b/lib/metrics.json @@ -0,0 +1,344 @@ +{ + "nested": { + "io": { + "nested": { + "prometheus": { + "nested": { + "client": { + "options": { + "java_package": "io.prometheus.client", + "go_package": "github.com/prometheus/client_model/go;io_prometheus_client" + }, + "nested": { + "LabelPair": { + "edition": "proto2", + "fields": { + "name": { + "type": "string", + "id": 1 + }, + "value": { + "type": "string", + "id": 2 + } + } + }, + "MetricType": { + "edition": "proto2", + "values": { + "COUNTER": 0, + "GAUGE": 1, + "SUMMARY": 2, + "UNTYPED": 3, + "HISTOGRAM": 4, + "GAUGE_HISTOGRAM": 5 + } + }, + "Gauge": { + "edition": "proto2", + "fields": { + "value": { + "type": "double", + "id": 1 + } + } + }, + "Counter": { + "edition": "proto2", + "fields": { + "value": { + "type": "double", + "id": 1 + }, + "exemplar": { + "type": "Exemplar", + "id": 2 + }, + "createdTimestamp": { + "type": "google.protobuf.Timestamp", + "id": 3, + "protoName": "created_timestamp" + } + } + }, + "Quantile": { + "edition": "proto2", + "fields": { + "quantile": { + "type": "double", + "id": 1 + }, + "value": { + "type": "double", + "id": 2 + } + } + }, + "Summary": { + "edition": "proto2", + "fields": { + "sampleCount": { + "type": "uint64", + "id": 1, + "protoName": "sample_count" + }, + "sampleSum": { + "type": "double", + "id": 2, + "protoName": "sample_sum" + }, + "quantile": { + "rule": "repeated", + "type": "Quantile", + "id": 3 + }, + "createdTimestamp": { + "type": "google.protobuf.Timestamp", + "id": 4, + "protoName": "created_timestamp" + } + } + }, + "Untyped": { + "edition": "proto2", + "fields": { + "value": { + "type": "double", + "id": 1 + } + } + }, + "Histogram": { + "edition": "proto2", + "fields": { + "sampleCount": { + "type": "uint64", + "id": 1, + "protoName": "sample_count" + }, + "sampleCountFloat": { + "type": "double", + "id": 4, + "protoName": "sample_count_float" + }, + "sampleSum": { + "type": "double", + "id": 2, + "protoName": "sample_sum" + }, + "bucket": { + "rule": "repeated", + "type": "Bucket", + "id": 3 + }, + "createdTimestamp": { + "type": "google.protobuf.Timestamp", + "id": 15, + "protoName": "created_timestamp" + }, + "schema": { + "type": "sint32", + "id": 5 + }, + "zeroThreshold": { + "type": "double", + "id": 6, + "protoName": "zero_threshold" + }, + "zeroCount": { + "type": "uint64", + "id": 7, + "protoName": "zero_count" + }, + "zeroCountFloat": { + "type": "double", + "id": 8, + "protoName": "zero_count_float" + }, + "negativeSpan": { + "rule": "repeated", + "type": "BucketSpan", + "id": 9, + "protoName": "negative_span" + }, + "negativeDelta": { + "rule": "repeated", + "type": "sint64", + "id": 10, + "protoName": "negative_delta" + }, + "negativeCount": { + "rule": "repeated", + "type": "double", + "id": 11, + "protoName": "negative_count" + }, + "positiveSpan": { + "rule": "repeated", + "type": "BucketSpan", + "id": 12, + "protoName": "positive_span" + }, + "positiveDelta": { + "rule": "repeated", + "type": "sint64", + "id": 13, + "protoName": "positive_delta" + }, + "positiveCount": { + "rule": "repeated", + "type": "double", + "id": 14, + "protoName": "positive_count" + }, + "exemplars": { + "rule": "repeated", + "type": "Exemplar", + "id": 16 + } + } + }, + "Bucket": { + "edition": "proto2", + "fields": { + "cumulativeCount": { + "type": "uint64", + "id": 1, + "protoName": "cumulative_count" + }, + "cumulativeCountFloat": { + "type": "double", + "id": 4, + "protoName": "cumulative_count_float" + }, + "upperBound": { + "type": "double", + "id": 2, + "protoName": "upper_bound" + }, + "exemplar": { + "type": "Exemplar", + "id": 3 + } + } + }, + "BucketSpan": { + "edition": "proto2", + "fields": { + "offset": { + "type": "sint32", + "id": 1 + }, + "length": { + "type": "uint32", + "id": 2 + } + } + }, + "Exemplar": { + "edition": "proto2", + "fields": { + "label": { + "rule": "repeated", + "type": "LabelPair", + "id": 1 + }, + "value": { + "type": "double", + "id": 2 + }, + "timestamp": { + "type": "google.protobuf.Timestamp", + "id": 3 + } + } + }, + "Metric": { + "edition": "proto2", + "fields": { + "label": { + "rule": "repeated", + "type": "LabelPair", + "id": 1 + }, + "gauge": { + "type": "Gauge", + "id": 2 + }, + "counter": { + "type": "Counter", + "id": 3 + }, + "summary": { + "type": "Summary", + "id": 4 + }, + "untyped": { + "type": "Untyped", + "id": 5 + }, + "histogram": { + "type": "Histogram", + "id": 7 + }, + "timestampMs": { + "type": "int64", + "id": 6, + "protoName": "timestamp_ms" + } + } + }, + "MetricFamily": { + "edition": "proto2", + "fields": { + "name": { + "type": "string", + "id": 1 + }, + "help": { + "type": "string", + "id": 2 + }, + "type": { + "type": "MetricType", + "id": 3 + }, + "metric": { + "rule": "repeated", + "type": "Metric", + "id": 4 + }, + "unit": { + "type": "string", + "id": 5 + } + } + } + } + } + } + } + } + }, + "google": { + "nested": { + "protobuf": { + "nested": { + "Timestamp": { + "fields": { + "seconds": { + "type": "int64", + "id": 1 + }, + "nanos": { + "type": "int32", + "id": 2 + } + } + } + } + } + } + } + } +} diff --git a/lib/metrics.proto b/lib/metrics.proto new file mode 100644 index 00000000..2f2bff0c --- /dev/null +++ b/lib/metrics.proto @@ -0,0 +1,156 @@ +// Copyright 2013 Prometheus Team +// 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. + +syntax = "proto2"; + +package io.prometheus.client; +option java_package = "io.prometheus.client"; +option go_package = "github.com/prometheus/client_model/go;io_prometheus_client"; + +import "google/protobuf/timestamp.proto"; + +message LabelPair { + optional string name = 1; + optional string value = 2; +} + +enum MetricType { + // COUNTER must use the Metric field "counter". + COUNTER = 0; + // GAUGE must use the Metric field "gauge". + GAUGE = 1; + // SUMMARY must use the Metric field "summary". + SUMMARY = 2; + // UNTYPED must use the Metric field "untyped". + UNTYPED = 3; + // HISTOGRAM must use the Metric field "histogram". + HISTOGRAM = 4; + // GAUGE_HISTOGRAM must use the Metric field "histogram". + GAUGE_HISTOGRAM = 5; +} + +message Gauge { + optional double value = 1; +} + +message Counter { + optional double value = 1; + optional Exemplar exemplar = 2; + + optional google.protobuf.Timestamp created_timestamp = 3; +} + +message Quantile { + optional double quantile = 1; + optional double value = 2; +} + +message Summary { + optional uint64 sample_count = 1; + optional double sample_sum = 2; + repeated Quantile quantile = 3; + + optional google.protobuf.Timestamp created_timestamp = 4; +} + +message Untyped { + optional double value = 1; +} + +message Histogram { + optional uint64 sample_count = 1; + optional double sample_count_float = 4; // Overrides sample_count if > 0. + optional double sample_sum = 2; + // Buckets for the conventional histogram. + repeated Bucket bucket = 3; // Ordered in increasing order of upper_bound, +Inf bucket is optional. + + optional google.protobuf.Timestamp created_timestamp = 15; + + // Everything below here is for native histograms (formerly known as sparse histograms). + + // schema defines the bucket schema. Currently, valid numbers are -4 <= n <= 8. + // They are all for base-2 bucket schemas, where 1 is a bucket boundary in each case, and + // then each power of two is divided into 2^n logarithmic buckets. + // Or in other words, each bucket boundary is the previous boundary times 2^(2^-n). + // In the future, more bucket schemas may be added using numbers < -4 or > 8. + optional sint32 schema = 5; + optional double zero_threshold = 6; // Breadth of the zero bucket. + optional uint64 zero_count = 7; // Count in zero bucket. + optional double zero_count_float = 8; // Overrides sb_zero_count if > 0. + + // Negative buckets for the native histogram. + repeated BucketSpan negative_span = 9; + // Use either "negative_delta" or "negative_count", the former for + // regular histograms with integer counts, the latter for float + // histograms. + repeated sint64 negative_delta = 10; // Count delta of each bucket compared to previous one (or to zero for 1st bucket). + repeated double negative_count = 11; // Absolute count of each bucket. + + // Positive buckets for the native histogram. + // Use a no-op span (offset 0, length 0) for a native histogram without any + // observations yet and with a zero_threshold of 0. Otherwise, it would be + // indistinguishable from a classic histogram. + repeated BucketSpan positive_span = 12; + // Use either "positive_delta" or "positive_count", the former for + // regular histograms with integer counts, the latter for float + // histograms. + repeated sint64 positive_delta = 13; // Count delta of each bucket compared to previous one (or to zero for 1st bucket). + repeated double positive_count = 14; // Absolute count of each bucket. + + // Only used for native histograms. These exemplars MUST have a timestamp. + repeated Exemplar exemplars = 16; +} + +// A Bucket of a conventional histogram, each of which is treated as +// an individual counter-like time series by Prometheus. +message Bucket { + optional uint64 cumulative_count = 1; // Cumulative in increasing order. + optional double cumulative_count_float = 4; // Overrides cumulative_count if > 0. + optional double upper_bound = 2; // Inclusive. + optional Exemplar exemplar = 3; +} + +// A BucketSpan defines a number of consecutive buckets in a native +// histogram with their offset. Logically, it would be more +// straightforward to include the bucket counts in the Span. However, +// the protobuf representation is more compact in the way the data is +// structured here (with all the buckets in a single array separate +// from the Spans). +message BucketSpan { + optional sint32 offset = 1; // Gap to previous span, or starting point for 1st span (which can be negative). + optional uint32 length = 2; // Length of consecutive buckets. +} + +message Exemplar { + repeated LabelPair label = 1; + optional double value = 2; + optional google.protobuf.Timestamp timestamp = 3; // OpenMetrics-style. +} + +message Metric { + repeated LabelPair label = 1; + optional Gauge gauge = 2; + optional Counter counter = 3; + optional Summary summary = 4; + optional Untyped untyped = 5; + optional Histogram histogram = 7; + optional int64 timestamp_ms = 6; +} + +message MetricFamily { + optional string name = 1; + optional string help = 2; + optional MetricType type = 3; + repeated Metric metric = 4; + optional string unit = 5; +} diff --git a/lib/protobuf.js b/lib/protobuf.js new file mode 100644 index 00000000..ea06d74f --- /dev/null +++ b/lib/protobuf.js @@ -0,0 +1,167 @@ +// Copyright The Prometheus 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. + +'use strict'; + +const { Buffer } = require('node:buffer'); +const protobuf = require('protobufjs/light'); + +// Loaded only when a registry is scraped as protobuf. The schema is vendored +// from prometheus/client_model/io/prometheus/client/metrics.proto. +const root = protobuf.Root.fromJSON(require('./metrics.json')); +const MetricFamily = root.lookupType('io.prometheus.client.MetricFamily'); +const metricTypes = root.lookupEnum('io.prometheus.client.MetricType').values; + +function encodeMetricFamily(metric, defaultLabels) { + const type = metricTypes[metric.type.toUpperCase()]; + if (type === undefined) { + throw new TypeError( + `Cannot encode metric type ${metric.type} as Prometheus protobuf`, + ); + } + + const groups = new Map(); + const defaultLabelEntries = Object.entries(defaultLabels); + function labelsWithDefaults(labels, sharedLabels) { + const merged = { ...defaultLabels, ...labels, ...sharedLabels }; + for (const [name, value] of defaultLabelEntries) { + merged[name] ??= value; + } + return merged; + } + + function groupFor(labels) { + const key = JSON.stringify(labelPairs(labels)); + let group = groups.get(key); + if (!group) { + group = { label: labelPairs(labels), [metric.type]: {} }; + groups.set(key, group); + } + return group[metric.type]; + } + + for (const value of metric.values ?? []) { + const labels = labelsWithDefaults(value.labels, value.sharedLabels); + const metricName = value.metricName ?? metric.name; + let bound; + let quantile; + if (metric.type === 'histogram' && metricName === `${metric.name}_bucket`) { + bound = labels.le === '+Inf' ? Infinity : Number(labels.le); + delete labels.le; + } else if (metric.type === 'summary' && Object.hasOwn(labels, 'quantile')) { + quantile = Number(labels.quantile); + delete labels.quantile; + } + + const data = groupFor(labels); + switch (metric.type) { + case 'counter': + case 'gauge': + case 'untyped': + data.value = value.value; + if (metric.type === 'counter' && value.exemplar) { + data.exemplar = encodeExemplar(value.exemplar); + } + break; + case 'histogram': + case 'summary': + if (metricName === `${metric.name}_sum`) { + data.sampleSum = value.value; + } else if (metricName === `${metric.name}_count`) { + data.sampleCount = value.value; + } else if (metric.type === 'histogram' && bound !== undefined) { + data.bucket ??= []; + const bucket = { cumulativeCount: value.value, upperBound: bound }; + if (value.exemplar) bucket.exemplar = encodeExemplar(value.exemplar); + data.bucket.push(bucket); + } else if (metric.type === 'summary' && quantile !== undefined) { + data.quantile ??= []; + data.quantile.push({ quantile, value: value.value }); + } + break; + default: + throw new TypeError( + `Cannot encode metric type ${metric.type} as Prometheus protobuf`, + ); + } + } + + for (const group of groups.values()) { + if (group.histogram) { + encodeHistogramCount(group.histogram, 'sampleCount'); + for (const bucket of group.histogram.bucket ?? []) { + encodeHistogramCount(bucket, 'cumulativeCount'); + } + group.histogram.bucket?.sort((a, b) => a.upperBound - b.upperBound); + } + if ( + group.summary?.sampleCount !== undefined && + !Number.isInteger(group.summary.sampleCount) + ) { + throw new TypeError( + `Prometheus protobuf requires an integer sample count for summary ${metric.name}`, + ); + } + group.summary?.quantile?.sort((a, b) => a.quantile - b.quantile); + } + + return Buffer.from( + MetricFamily.encodeDelimited({ + name: metric.name, + help: metric.help, + type, + metric: [...groups.values()], + }).finish(), + ); +} + +// Classic histogram aggregation can produce fractional counts. Protobuf has +// dedicated double fields for them; uint64 encoding would truncate them. +function encodeHistogramCount(message, field) { + const count = message[field]; + if (count !== undefined && !Number.isInteger(count)) { + message[`${field}Float`] = count; + delete message[field]; + } +} + +function labelPairs(labels) { + return Object.keys(labels) + .sort() + .map(name => { + return { + name, + value: String(labels[name]), + }; + }); +} + +function timestamp(seconds) { + const milliseconds = Math.round(seconds * 1000); + const wholeSeconds = Math.floor(milliseconds / 1000); + return { + seconds: wholeSeconds, + nanos: (milliseconds - wholeSeconds * 1000) * 1e6, + }; +} + +function encodeExemplar(exemplar) { + return { + label: labelPairs(exemplar.labelSet), + value: exemplar.value, + timestamp: timestamp(exemplar.timestamp), + }; +} + +module.exports = { encodeMetricFamily }; diff --git a/lib/pushgateway.js b/lib/pushgateway.js index f43487b4..907f981b 100644 --- a/lib/pushgateway.js +++ b/lib/pushgateway.js @@ -75,6 +75,15 @@ async function useGateway(method, job, groupings) { const httpModule = requestParams.protocol === 'https:' ? https : http; const options = { ...this.requestOptions, method }; + if ( + method !== 'DELETE' && + this.registry.contentType === Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE + ) { + options.headers = { + 'Content-Type': this.registry.contentType, + ...options.headers, + }; + } return new Promise((resolve, reject) => { if (method === 'DELETE' && options.headers) { diff --git a/lib/registry.js b/lib/registry.js index 38f3903b..4c54fa8d 100644 --- a/lib/registry.js +++ b/lib/registry.js @@ -26,14 +26,15 @@ class Registry { return 'application/openmetrics-text; version=1.0.0; charset=utf-8'; } + static get PROMETHEUS_PROTOBUF_CONTENT_TYPE() { + return 'application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited'; + } + constructor(regContentType = Registry.PROMETHEUS_CONTENT_TYPE) { this._metrics = new Map(); this._collectors = []; this._defaultLabels = {}; - if ( - regContentType !== Registry.PROMETHEUS_CONTENT_TYPE && - regContentType !== Registry.OPENMETRICS_CONTENT_TYPE - ) { + if (!isSupportedContentType(regContentType)) { throw new TypeError(`Content type ${regContentType} is unsupported`); } this._contentType = regContentType; @@ -60,6 +61,8 @@ class Registry { } async getMetricsAsString(metrics) { + const isOpenMetrics = + this.contentType === Registry.OPENMETRICS_CONTENT_TYPE; const metric = typeof metrics.getForPromString === 'function' ? await metrics.getForPromString() @@ -75,9 +78,6 @@ class Registry { defaultLabelNames = undefined; } - const isOpenMetrics = - this.contentType === Registry.OPENMETRICS_CONTENT_TYPE; - for (const val of metric.values ?? []) { const { metricName = name, labels = {}, sharedLabels } = val; const seriesName = @@ -124,21 +124,38 @@ class Registry { } async metrics() { - const isOpenMetrics = - this.contentType === Registry.OPENMETRICS_CONTENT_TYPE; - - const promises = this.getMetricsAsArray().map(metric => { - if (isOpenMetrics && metric.type === 'counter') { - metric.name = standardizeCounterName(metric.name); - } - return this.getMetricsAsString(metric); - }); - - const resolves = await Promise.all(promises); - - return isOpenMetrics - ? `${resolves.join('\n')}\n# EOF\n` - : `${resolves.join('\n\n')}\n`; + const contentType = this.contentType; + let output; + if (contentType === Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE) { + const { encodeMetricFamily } = require('./protobuf'); + const buffers = await Promise.all( + this.getMetricsAsArray().map(async metric => { + const data = + typeof metric.getForPromString === 'function' + ? await metric.getForPromString() + : await metric.get(); + return encodeMetricFamily(data, this._defaultLabels); + }), + ); + output = Buffer.concat(buffers); + } else { + const strings = await Promise.all( + this.getMetricsAsArray().map(metric => { + if ( + contentType === Registry.OPENMETRICS_CONTENT_TYPE && + metric.type === 'counter' + ) { + metric.name = standardizeCounterName(metric.name); + } + return this.getMetricsAsString(metric); + }), + ); + output = + contentType === Registry.OPENMETRICS_CONTENT_TYPE + ? `${strings.join('\n')}\n# EOF\n` + : `${strings.join('\n\n')}\n`; + } + return output; } registerMetric(metric) { @@ -221,11 +238,9 @@ class Registry { } setContentType(metricsContentType) { - if ( - metricsContentType === Registry.OPENMETRICS_CONTENT_TYPE || - metricsContentType === Registry.PROMETHEUS_CONTENT_TYPE - ) { + if (isSupportedContentType(metricsContentType)) { this._contentType = metricsContentType; + return this; } else { throw new Error(`Content type ${metricsContentType} is unsupported`); } @@ -303,6 +318,14 @@ class Registry { } } +function isSupportedContentType(contentType) { + return [ + Registry.PROMETHEUS_CONTENT_TYPE, + Registry.OPENMETRICS_CONTENT_TYPE, + Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE, + ].includes(contentType); +} + function formatLabels(labels, exclude) { const formatted = []; for (const [name, value] of Object.entries(labels)) { diff --git a/lib/worker.js b/lib/worker.js index 4d727256..bf604da5 100644 --- a/lib/worker.js +++ b/lib/worker.js @@ -78,10 +78,11 @@ class WorkerRegistry extends Registry { /** * Gets aggregated metrics for all workers. The optional callback and * returned Promise resolve with the same value; either may be used. - * @returns {Promise} Promise that resolves with the aggregated + * @returns {Promise} Promise that resolves with the aggregated * metrics. */ async workerMetrics() { + const contentType = this.contentType; const requestId = requestCtr++; const orderedWorkers = [...workers.values()].sort( (left, right) => left.threadId - right.threadId, @@ -90,7 +91,9 @@ class WorkerRegistry extends Registry { if (orderedWorkers.length === 0) { if (historicMetrics.length === 0) { debug('No data found for requestId', requestId); - return ''; + return contentType === Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE + ? Buffer.alloc(0) + : ''; } else { debug('No workers found for requestId', requestId); } @@ -112,7 +115,7 @@ class WorkerRegistry extends Registry { responseHandlers, workerFailures: 0, promise: waitFor( - this.#gather(requestId, metricSnapshot, responsePromises), + this.#gather(requestId, metricSnapshot, responsePromises, contentType), 5_000, ), }; @@ -155,17 +158,15 @@ class WorkerRegistry extends Registry { * @param requestId {number} * @param {any[]} historical - Previously collected values * @param promises {Promise[]} - * @returns {Promise} + * @param contentType {string} + * @returns {Promise} */ - async #gather(requestId, historical, promises) { + async #gather(requestId, historical, promises, contentType) { debug('Gathering data...', requestId); const responses = await Promise.all(promises); debug('Aggregating data...', requestId); const metrics = responses.flatMap(response => response.metrics); - return Registry.aggregate( - [historical, ...metrics], - this.contentType, - ).metrics(); + return Registry.aggregate([historical, ...metrics], contentType).metrics(); } get contentType() { diff --git a/package.json b/package.json index 03493dd2..0c79e6f6 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "run-prettier": "prettier .", "check-prettier": "npm run run-prettier -- --check", "compile-typescript": "tsc --project .", + "generate-protobuf": "node scripts/generate-protobuf.js", "prepare": "husky" }, "repository": { @@ -53,6 +54,7 @@ }, "dependencies": { "@opentelemetry/api": "^1.4.0", + "protobufjs": "^8.8.0", "tdigest": "^0.1.1" }, "types": "./index.d.ts", diff --git a/scripts/generate-protobuf.js b/scripts/generate-protobuf.js new file mode 100644 index 00000000..fb63c234 --- /dev/null +++ b/scripts/generate-protobuf.js @@ -0,0 +1,28 @@ +// Copyright The Prometheus 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. + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const protobuf = require('protobufjs'); + +// Keep the runtime descriptor in sync with the vendored upstream schema. +// Encoding uses protobufjs/light and JSON so bundlers can include it without +// runtime filesystem access or parsing .proto files. +const root = protobuf.loadSync(path.join(__dirname, '../lib/metrics.proto')); +fs.writeFileSync( + path.join(__dirname, '../lib/metrics.json'), + `${JSON.stringify(root.toJSON(), null, '\t')}\n`, +); diff --git a/test/clusterTest.js b/test/clusterTest.js index e1c2ca71..3fa87756 100644 --- a/test/clusterTest.js +++ b/test/clusterTest.js @@ -115,6 +115,38 @@ describe.each([ } }); + it('keeps pending scrapes in their original content types', async () => { + const { Registry: LocalRegistry, Histogram } = require('../index'); + const { decodeMetricFamilies } = require('./helpers/protobuf'); + const local = new LocalRegistry(); + let finishCollecting; + const collected = new Promise(resolve => { + finishCollecting = resolve; + }); + new Histogram({ + name: 'pending_histogram', + help: 'Pending histogram', + registers: [local], + async collect() { + await collected; + }, + }).observe(1); + AggregatorRegistry.setRegistries(local); + try { + const registry = new AggregatorRegistry(regType); + const text = registry.clusterMetrics(); + registry.setContentType(Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE); + const binary = registry.clusterMetrics(); + finishCollecting(); + const [textBody, binaryBody] = await Promise.all([text, binary]); + expect(textBody).toContain('pending_histogram_count 1'); + const [family] = decodeMetricFamilies(binaryBody); + expect(family.metric[0].histogram.sampleCount).toBe(1); + } finally { + AggregatorRegistry.setRegistries(LocalRegistry.globalRegistry); + } + }); + it("listeners don't accumulate", () => { for (let i = 0; i < 30; i++) { jest.resetModules(); diff --git a/test/exemplarsTest.js b/test/exemplarsTest.js index a349de39..cd900b71 100644 --- a/test/exemplarsTest.js +++ b/test/exemplarsTest.js @@ -31,7 +31,9 @@ describe('Exemplars', () => { labelNames: ['method', 'code'], enableExemplars: true, }); - }).toThrow('Exemplars are supported only on OpenMetrics registries'); + }).toThrow( + 'Exemplars are supported only on OpenMetrics or Prometheus protobuf registries', + ); }); describe.each([['OpenMetrics', Registry.OPENMETRICS_CONTENT_TYPE]])( 'with %s registry', diff --git a/test/helpers/protobuf.js b/test/helpers/protobuf.js new file mode 100644 index 00000000..5b605605 --- /dev/null +++ b/test/helpers/protobuf.js @@ -0,0 +1,39 @@ +// Copyright The Prometheus 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. + +'use strict'; + +const path = require('node:path'); +const protobuf = require('protobufjs'); + +// Decode against the upstream .proto, independently of the runtime JSON +// descriptor and the client's conversion of metric snapshots to protobuf. +const root = protobuf.loadSync(path.join(__dirname, '../../lib/metrics.proto')); +const MetricFamily = root.lookupType('io.prometheus.client.MetricFamily'); + +function decodeMetricFamilies(buffer) { + const reader = protobuf.Reader.create(buffer); + const families = []; + while (reader.pos < reader.len) { + families.push( + MetricFamily.toObject(MetricFamily.decodeDelimited(reader), { + longs: Number, + enums: String, + }), + ); + } + return families; +} + +module.exports = { decodeMetricFamilies }; diff --git a/test/protobufTest.js b/test/protobufTest.js new file mode 100644 index 00000000..e8f98e53 --- /dev/null +++ b/test/protobufTest.js @@ -0,0 +1,299 @@ +// Copyright The Prometheus 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. + +'use strict'; + +const client = require('../index'); +const { Registry, Counter, Gauge, Histogram, Summary, Pushgateway } = client; +const { decodeMetricFamilies } = require('./helpers/protobuf'); +const nock = require('nock'); + +const contentType = Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE; + +afterEach(() => { + jest.useRealTimers(); + nock.cleanAll(); +}); + +describe('Prometheus protobuf exposition', () => { + test('exposes the content type and returns an empty Buffer for an empty registry', async () => { + const register = new Registry(contentType); + expect(register.contentType).toBe(client.prometheusProtobufContentType); + expect(contentType).toBe( + 'application/vnd.google.protobuf; proto=io.prometheus.client.MetricFamily; encoding=delimited', + ); + expect(await register.metrics()).toEqual(Buffer.alloc(0)); + }); + + test('encodes counters, gauges, classic histograms and summaries', async () => { + const register = new Registry(contentType); + new Counter({ + name: 'requests_total', + help: 'Requests', + registers: [register], + }).inc(4.5); + new Gauge({ + name: 'temperature', + help: 'Temperature', + registers: [register], + }).set(-7.5); + new Histogram({ + name: 'classic', + help: 'Classic histogram', + buckets: [1, 2], + registers: [register], + }).observe(1.5); + const summary = new Summary({ + name: 'summary', + help: 'Summary', + percentiles: [0.5], + registers: [register], + }); + summary.observe(1); + summary.observe(3); + const families = decodeMetricFamilies(await register.metrics()); + expect(families).toHaveLength(4); + expect(families[0]).toEqual({ + name: 'requests_total', + help: 'Requests', + type: 'COUNTER', + metric: [{ counter: { value: 4.5 } }], + }); + expect(families[1].metric).toEqual([{ gauge: { value: -7.5 } }]); + expect(families[2].metric).toEqual([ + { + histogram: { + sampleCount: 1, + sampleSum: 1.5, + bucket: [ + { upperBound: 1, cumulativeCount: 0 }, + { upperBound: 2, cumulativeCount: 1 }, + { upperBound: Infinity, cumulativeCount: 1 }, + ], + }, + }, + ]); + expect(families[3].metric).toEqual([ + { + summary: { + sampleCount: 2, + sampleSum: 4, + quantile: [{ quantile: 0.5, value: 2 }], + }, + }, + ]); + }); + + test('preserves labels and keeps AsString methods text-only', async () => { + const register = new Registry(contentType); + register.setDefaultLabels({ service: 'frontend', route: 'default' }); + const histogram = new Histogram({ + name: 'duration', + help: 'Unicode: café "help"\nwith newline', + labelNames: ['route'], + buckets: [1], + registers: [register], + }); + const route = '/api/π\n"quoted"\\path'; + histogram.observe({ route }, 0.5); + const [family] = decodeMetricFamilies(await register.metrics()); + expect(family.help).toBe(histogram.help); + expect(family.metric[0].label).toEqual([ + { name: 'route', value: route }, + { name: 'service', value: 'frontend' }, + ]); + const text = await register.getMetricsAsString(histogram); + expect(typeof text).toBe('string'); + expect(text).toContain('duration_count'); + expect(await register.getSingleMetricAsString(histogram.name)).toBe(text); + }); + + test.each([ + [[0.5], [1.5], 1, [0.5, 1, 1]], + [[0.5], [0.5, 1.5], 1.5, [1, 1.5, 1.5]], + ])( + 'preserves fractional classic histogram counts after averaging %p and %p', + async (firstValues, secondValues, expectedCount, expectedBuckets) => { + const snapshots = await Promise.all( + [firstValues, secondValues].map(async values => { + const histogram = new Histogram({ + name: 'averaged', + help: 'Averaged histogram', + buckets: [1, 2], + aggregator: 'average', + registers: [], + }); + values.forEach(value => histogram.observe(value)); + return [await histogram.get()]; + }), + ); + const register = Registry.aggregate(snapshots, contentType); + const data = decodeMetricFamilies(await register.metrics())[0].metric[0] + .histogram; + expect(data.sampleCountFloat ?? data.sampleCount).toBe(expectedCount); + expect( + data.bucket.map( + bucket => bucket.cumulativeCountFloat ?? bucket.cumulativeCount, + ), + ).toEqual(expectedBuckets); + }, + ); + + test('rejects fractional summary counts that protobuf cannot represent', async () => { + const snapshots = await Promise.all( + [[1], [1, 2]].map(async values => { + const summary = new Summary({ + name: 'averaged_summary', + help: 'Summary', + aggregator: 'average', + registers: [], + }); + values.forEach(value => summary.observe(value)); + return [await summary.get()]; + }), + ); + await expect( + Registry.aggregate(snapshots, contentType).metrics(), + ).rejects.toThrow('Prometheus protobuf requires an integer sample count'); + }); + + test.each([undefined, null])( + 'uses defaults for nullish labels: %p', + async value => { + const register = new Registry(contentType); + register.setDefaultLabels({ service: 'frontend' }); + const histogram = new Histogram({ + name: 'defaults', + help: 'Defaults', + labelNames: ['service'], + registers: [register], + }); + histogram.observe({ service: value }, 1); + const [family] = decodeMetricFamilies(await register.metrics()); + expect(family.metric).toHaveLength(1); + expect(family.metric[0].label).toEqual([ + { name: 'service', value: 'frontend' }, + ]); + }, + ); + + test('awaits the collector exactly once per protobuf scrape', async () => { + const register = new Registry(contentType); + const collect = jest.fn(async function () { + await Promise.resolve(); + this.observe(2); + }); + new Histogram({ + name: 'collected', + help: 'Collected', + registers: [register], + collect, + }); + const [family] = decodeMetricFamilies(await register.metrics()); + expect(collect).toHaveBeenCalledTimes(1); + expect(family.metric[0].histogram.sampleCount).toBe(1); + }); + + test('keeps each pending scrape in its original format', async () => { + const register = new Registry(); + let finishCollecting; + const collected = new Promise(resolve => { + finishCollecting = resolve; + }); + new Counter({ + name: 'async_requests_total', + help: 'Requests', + registers: [register], + async collect() { + await collected; + }, + }).inc(1); + const text = register.metrics(); + register.setContentType(contentType); + const binary = register.metrics(); + finishCollecting(); + expect(await text).toContain('async_requests_total 1'); + expect(decodeMetricFamilies(await binary)[0].metric[0].counter.value).toBe( + 1, + ); + }); + + test('merges protobuf registries', async () => { + const one = new Registry(contentType); + const two = new Registry(contentType); + new Counter({ name: 'requests', help: 'Requests', registers: [one] }).inc(); + new Gauge({ + name: 'temperature', + help: 'Temperature', + registers: [two], + }).set(5); + expect( + decodeMetricFamilies(await Registry.merge([one, two]).metrics()).map( + metric => metric.name, + ), + ).toEqual(['requests', 'temperature']); + expect(() => Registry.merge([one, new Registry()])).toThrow( + 'same content type', + ); + }); + + test('encodes exemplars for counters and classic histogram buckets', async () => { + jest.useFakeTimers(); + jest.setSystemTime(1234); + const register = new Registry(contentType); + new Counter({ + name: 'requests_total', + help: 'Requests', + enableExemplars: true, + registers: [register], + }).inc({ value: 2, exemplarLabels: { trace_id: 'counter' } }); + new Histogram({ + name: 'duration', + help: 'Duration', + enableExemplars: true, + buckets: [1], + registers: [register], + }).observe({ value: 0.5, exemplarLabels: { trace_id: 'histogram' } }); + const [counter, histogram] = decodeMetricFamilies(await register.metrics()); + const timestamp = { seconds: 1, nanos: 234000000 }; + expect(counter.metric[0].counter.exemplar).toEqual({ + label: [{ name: 'trace_id', value: 'counter' }], + value: 2, + timestamp, + }); + expect(histogram.metric[0].histogram.bucket[0].exemplar).toEqual({ + label: [{ name: 'trace_id', value: 'histogram' }], + value: 0.5, + timestamp, + }); + }); + + test('sends protobuf to Pushgateway with the matching content type', async () => { + const register = new Registry(contentType); + new Gauge({ + name: 'temperature', + help: 'Temperature', + registers: [register], + }).set(1); + const body = await register.metrics(); + const gateway = nock('http://localhost:9091') + .matchHeader('Content-Type', contentType) + .put('/metrics/job/protobuf', body) + .reply(202); + await new Pushgateway('http://localhost:9091', register).push({ + jobName: 'protobuf', + }); + expect(gateway.isDone()).toBe(true); + }); +}); diff --git a/test/protobufTypes.ts b/test/protobufTypes.ts new file mode 100644 index 00000000..cd8841db --- /dev/null +++ b/test/protobufTypes.ts @@ -0,0 +1,96 @@ +// Copyright The Prometheus 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 { + AggregatorRegistry, + ClusterRegistry, + Histogram, + PrometheusProtobufContentType, + Pushgateway, + Registry, + RegistryContentType, + WorkerRegistry, + prometheusProtobufContentType, +} from '../index'; + +const registry = new Registry(Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE); +const histogram = new Histogram({ + name: 'typescript_protobuf_histogram', + help: 'Protobuf histogram TypeScript test', + registers: [registry], + labelNames: ['route'] as const, + buckets: [1, 2], +}); + +histogram.observe({ route: '/' }, 0.2); +histogram.labels('/').observe(0.3); +histogram.zero({ route: '/' }); +const type: PrometheusProtobufContentType = prometheusProtobufContentType; +const binary: Promise = registry.metrics(); +const oneText: Promise = registry.getMetricsAsString(histogram); +const singleText: Promise = registry.getSingleMetricAsString( + 'typescript_protobuf_histogram', +); +const merged: Promise = Registry.merge([registry]).metrics(); +const aggregate: Promise = Registry.aggregate([], type).metrics(); +const switched: Promise = new Registry() + .setContentType(type) + .metrics(); +const text: Promise = new Registry().metrics(); +const openMetrics: Promise = new Registry( + Registry.OPENMETRICS_CONTENT_TYPE, +).metrics(); +const unknownFormat: Registry = registry; +const unknownBody: Promise = unknownFormat.metrics(); +void [ + binary, + oneText, + singleText, + merged, + aggregate, + switched, + text, + openMetrics, + unknownBody, +]; + +const cluster = new ClusterRegistry(type); +const workers = new WorkerRegistry(type); +const clusterBytes: Promise = cluster.clusterMetrics(); +const workerBytes: Promise = workers.workerMetrics(); +const switchedClusterBytes: Promise = new ClusterRegistry() + .setContentType(type) + .clusterMetrics(); +const switchedWorkerBytes: Promise = new WorkerRegistry() + .setContentType(type) + .workerMetrics(); +const switchedAggregatorBytes: Promise = new AggregatorRegistry() + .setContentType(type) + .clusterMetrics(); +ClusterRegistry.setRegistries([registry, new Registry()]); +WorkerRegistry.setRegistries(registry); +void [ + clusterBytes, + workerBytes, + switchedClusterBytes, + switchedWorkerBytes, + switchedAggregatorBytes, +]; + +new Pushgateway('http://localhost:9091', registry); +// @ts-expect-error A protobuf registry returns bytes, not text. +const invalidText: Promise = registry.metrics(); +void invalidText; +// @ts-expect-error Label names remain checked for protobuf registries. +histogram.observe({ method: 'GET' }, 1); diff --git a/test/registerTest.js b/test/registerTest.js index b9cfcdd6..2f779255 100644 --- a/test/registerTest.js +++ b/test/registerTest.js @@ -33,6 +33,21 @@ describe('Register', () => { }).toThrow(expectedContentTypeErrStr); }); + it.each([ + Registry.PROMETHEUS_CONTENT_TYPE, + Registry.OPENMETRICS_CONTENT_TYPE, + Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE, + ])('accepts %s in both the constructor and setter', contentType => { + expect(new Registry(contentType).contentType).toBe(contentType); + const registry = new Registry(); + expect(registry.setContentType(contentType)).toBe(registry); + expect(registry.contentType).toBe(contentType); + expect(() => registry.setContentType(contentTypeTestStr)).toThrow( + expectedContentTypeErrStr, + ); + expect(registry.contentType).toBe(contentType); + }); + describe.each([ ['Prometheus', Registry.PROMETHEUS_CONTENT_TYPE], ['OpenMetrics', Registry.OPENMETRICS_CONTENT_TYPE], diff --git a/test/workerTest.js b/test/workerTest.js index 5a3015ae..2309ca9d 100644 --- a/test/workerTest.js +++ b/test/workerTest.js @@ -121,6 +121,52 @@ describe.each([ } }); + it('keeps pending scrapes in their original content types', async () => { + const { Histogram } = require('../index'); + const { decodeMetricFamilies } = require('./helpers/protobuf'); + const histogram = new Histogram({ + name: 'pending_histogram', + help: 'Pending histogram', + registers: [], + }); + histogram.observe(1); + const snapshot = await histogram.get(); + const threadId = 212; + const name = `@prometheus-io/client:worker:${threadId}`; + const channel = new BroadcastChannel(name).unref(); + try { + announcementChannel.postMessage({ type: ANNOUNCEMENT, name, threadId }); + for ( + let attempt = 0; + attempt < 100 && AggregatorRegistry.workerCount() === 0; + attempt++ + ) { + await delay(5); + } + expect(AggregatorRegistry.workerCount()).toBeGreaterThan(0); + announcementChannel.addEventListener('message', event => { + if (event.data.type !== GET_METRICS_REQ) return; + channel.postMessage({ + type: GET_METRICS_RES, + requestId: event.data.requestId, + threadId, + metrics: [[snapshot]], + }); + }); + const text = registry.workerMetrics(); + registry.setContentType(Registry.PROMETHEUS_PROTOBUF_CONTENT_TYPE); + const binary = registry.workerMetrics(); + const [textBody, binaryBody] = await Promise.all([text, binary]); + expect(textBody).toContain('pending_histogram_count 1'); + const family = decodeMetricFamilies(binaryBody).find( + metric => metric.name === 'pending_histogram', + ); + expect(family.metric[0].histogram.sampleCount).toBe(1); + } finally { + channel.close(); + } + }); + it('aggregates worker responses in thread id order', async () => { const responders = [1, 2, 3].map(threadId => { const name = `@prometheus-io/client:worker:${threadId}`;