diff --git a/doc/api.md b/doc/api.md index 425e01059..4a2860526 100644 --- a/doc/api.md +++ b/doc/api.md @@ -2324,8 +2324,8 @@ _**Response body**_ Returns the current value of the server stats, -- If `Accept` header contains `application/openmetrics-text`, the response has content-type - `application/openmetrics-text; version=1.0.0; charset=utf-8` +- If `Accept` header contains `application/openmetrics-text; version=(1.0.0|0.0.1)`, the response has content-type + `application/openmetrics-text; version=; charset=utf-8` - Else, If `Accept` header is missing or supports `text/plain` (explicitly or by `*/*`) , the response has content-type `text/plain; version=0.0.4; charset=utf-8` (legacy format for [prometheus](https://prometheus.io)) - In any other case, returns an error message with `406` status. diff --git a/lib/services/stats/statsRegistry.js b/lib/services/stats/statsRegistry.js index 9bf1923d6..1ce1fdd7b 100644 --- a/lib/services/stats/statsRegistry.js +++ b/lib/services/stats/statsRegistry.js @@ -72,6 +72,80 @@ function globalLoad(values, callback) { callback(null); } +/** + * Chooses the appropiate content type and version based on Accept header + * + * @param {String} accepts The accepts header + */ +function matchContentType(accepts) { + const requestedType = []; + const vlabel = 'version='; + const clabel = 'charset='; + const qlabel = 'q='; + for (const expression of accepts.split(',')) { + const parts = expression.split(';').map((part) => part.trim()); + const mediaType = parts[0]; + let version = null; + let charset = null; + let preference = null; + for (let part of parts.slice(1)) { + if (part.startsWith(vlabel)) { + version = part.substring(vlabel.length).trim(); + } else if (part.startsWith(clabel)) { + charset = part.substring(clabel.length).trim(); + } else if (part.startsWith(qlabel)) { + preference = parseFloat(part.substring(qlabel.length).trim()); + } + } + requestedType.push({ + mediaType: mediaType, + version: version, + charset: charset, + preference: preference || 1.0 + }); + } + // If both text/plain and openmetrics are accepted, + // prefer openmetrics + const mediaTypePref = { + 'application/openmetrics-text': 1.0, + 'text/plain': 0.5 + } + // sort requests by priority descending + requestedType.sort(function (a, b) { + if (a.preference === b.preference) { + // same priority, sort by media type. + return (mediaTypePref[b.mediaType] || 0) - (mediaTypePref[a.mediaType] || 0); + } + return b.preference - a.preference; + }); + for (const req of requestedType) { + switch(req.mediaType) { + case 'application/openmetrics-text': + req.version = req.version || '1.0.0'; + req.charset = req.charset || 'utf-8'; + if ( + (req.version === '1.0.0' || req.version === '0.0.1') && + (req.charset === 'utf-8')) { + return req; + } + break; + case 'text/plain': + case 'text/*': + case '*/*': + req.version = req.version || '0.0.4'; + req.charset = req.charset || 'utf-8'; + if ( + (req.version === '0.0.4') && + (req.charset === 'utf-8')) { + req.mediaType = 'text/plain'; + return req; + } + break; + } + } + return null; +} + /** * Predefined http handler that returns current openmetrics data */ @@ -82,60 +156,26 @@ function openmetricsHandler(req, res) { // https://github.com/OpenObservability/OpenMetrics/blob/main/specification/OpenMetrics.md#overall-structure // - For prometheus compatible collectors, it SHOULD BE 'text/plain; version=0.0.4; charset=utf-8'. See: // https://github.com/prometheus/docs/blob/main/content/docs/instrumenting/exposition_formats.md - let contentType = 'application/openmetrics-text; version=1.0.0; charset=utf-8'; - let version = null; - let charset = null; - // To identify openmetrics collectors, we need to parse the `Accept` header. - // An openmetrics-based collectors SHOULD use an `Accept` header such as: - // `Accept: application/openmetrics-text; version=1.0.0; charset=utf-8' - // See: https://github.com/open-telemetry/opentelemetry-collector-contrib/issues/18913 - // - // WORKAROUND: express version 4 does not parse properly the openmetrics Accept header, - // it won't match the regular expressions supported by `express.accepts`. - // So we must parse these key-value pairs ourselves, and remove them from the - // header before handling it to `requests.accept`. - if (req.headers.accept) { - const parts = req.headers.accept.split(';'); - let unparsed = []; - for (let i = 0; i < parts.length; i++) { - const current = parts[i]; - const trimmed = current.trim(); - if (trimmed.startsWith('version=')) { - version = trimmed.substring(8); - } else if (trimmed.startsWith('charset=')) { - charset = trimmed.substring(8); - } else { - unparsed.push(current); - } - } - if (unparsed.length < parts.length) { - delete req.headers['accept']; - req.headers['accept'] = unparsed.join(';'); - } - } - // charset MUST BE utf-8 - if (charset && charset !== 'utf-8') { - logger.error(statsContext, 'Unsupported charset: %s', charset); - res.status(406).send('Unsupported charset'); - return; + // - Caveat: Some versions of prometheus have been observed to send multivalued Accept headers such as + // Accept: application/openmetrics-text; version=0.0.1,text/plain;version=0.0.4;q=0.5,*/*;q=0.1 + let reqType = { + mediaType: 'application/openmetrics-text', + version: '1.0.0', + charset: 'utf-8' } - switch (req.accepts(['text/plain', 'application/openmetrics-text'])) { - case 'application/openmetrics-text': - // Version MUST BE 1.0.0 for openmetrics - if (version && version !== '1.0.0') { - logger.error(statsContext, 'Unsupported openmetrics version: %s', version); - res.status(406).send('Unsupported openmetrics version'); - return; - } - break; - case 'text/plain': - contentType = 'text/plain; version=0.0.4; charset=utf-8'; - break; - default: - logger.error(statsContext, 'Unsupported accept header: %s', req.headers.accept); - res.status(406).send('Unsupported accept header'); + if (req.headers.accept) { + // WORKAROUND: express version 4 does not parse properly the openmetrics Accept header, + // it won't match the regular expressions supported by `express.accepts`. + // So we must parse these key-value pairs ourselves. + reqType = matchContentType(req.headers.accept); + if (reqType === null) { + logger.error(statsContext, 'Unsupported media type: %s', req.headers.accept); + res.status(406).send('Not Acceptable'); return; + } } + const contentType = `${reqType.mediaType};version=${reqType.version};charset=${reqType.charset}`; + // The actual payload is the same for all supported content types const metrics = []; for (const key in globalStats) { if (globalStats.hasOwnProperty(key)) { @@ -180,3 +220,4 @@ exports.getAllGlobal = getAllGlobal; exports.globalLoad = globalLoad; exports.withStats = withStats; exports.openmetricsHandler = openmetricsHandler; +exports.matchContentType = matchContentType; diff --git a/test/unit/statsRegistry/openmetrics-test.js b/test/unit/statsRegistry/openmetrics-test.js new file mode 100644 index 000000000..dc8a0c7e7 --- /dev/null +++ b/test/unit/statsRegistry/openmetrics-test.js @@ -0,0 +1,167 @@ +/* + * Copyright 2024 Telefonica Investigación y Desarrollo, S.A.U + * + * This file is part of fiware-iotagent-lib + * + * fiware-iotagent-lib is free software: you can redistribute it and/or + * modify it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the License, + * or (at your option) any later version. + * + * fiware-iotagent-lib is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. + * See the GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public + * License along with fiware-iotagent-lib. + * If not, see http://www.gnu.org/licenses/. + * + * For those usages not covered by the GNU Affero General Public License + * please contact with::daniel.moranjimenez@telefonica.com + */ + +/* eslint-disable no-unused-vars */ + +const statsRegistry = require('../../../lib/services/stats/statsRegistry'); +const should = require('should'); + +describe('statsRegistry - openmetrics endpoint', function () { + + const testCases = [ + { + description: 'Should accept standard openmetrics 0.0.1 header', + accept: 'application/openmetrics-text; version=0.0.1; charset=utf-8', + contentType: { + mediaType: 'application/openmetrics-text', + version: '0.0.1', + charset: 'utf-8' + } + }, + { + description: 'Should accept standard openmetrics 1.0.0 header', + accept: 'application/openmetrics-text; version=1.0.0; charset=utf-8', + contentType: { + mediaType: 'application/openmetrics-text', + version: '1.0.0', + charset: 'utf-8' + } + }, + { + description: 'Should accept openmetrics with no version', + accept: 'application/openmetrics-text', + contentType: { + mediaType: 'application/openmetrics-text', + version: '1.0.0', + charset: 'utf-8' + } + }, + { + description: 'Should accept text/plain header with version', + accept: 'text/plain; version=0.0.4', + contentType: { + mediaType: 'text/plain', + version: '0.0.4', + charset: 'utf-8' + } + }, + { + description: 'Should accept wildcard header', + accept: '*/*', + contentType: { + mediaType: 'text/plain', + version: '0.0.4', + charset: 'utf-8' + } + }, + { + description: 'Should accept both openmetrics and text/plain, prefer openmetrics', + accept: 'application/openmetrics-text; version=0.0.1; charset=utf-8,text/plain;version=0.0.4', + contentType: { + mediaType: 'application/openmetrics-text', + version: '0.0.1', + charset: 'utf-8' + } + }, + { + description: 'Should accept both text/plain and openmetrics, prefer openmetrics', + accept: 'text/plain,application/openmetrics-text; version=0.0.1; charset=utf-8', + contentType: { + mediaType: 'application/openmetrics-text', + version: '0.0.1', + charset: 'utf-8' + } + }, + { + description: 'Should accept both openmetrics and text/plain, prefer text if preference set', + accept: 'application/openmetrics-text; version=0.0.1; charset=utf-8;q=0.5,text/plain;q=0.7', + contentType: { + mediaType: 'text/plain', + version: '0.0.4', + charset: 'utf-8' + } + }, + { + description: 'Should match version to content-type', + accept: 'application/openmetrics-text; version=0.0.1; charset=utf-8, text/plain;version=1.0.0', + contentType: { + mediaType: 'application/openmetrics-text', + version: '0.0.1', + charset: 'utf-8' + } + }, + { + description: 'Should set default q to 1.0', + accept: 'application/openmetrics-text; version=0.0.1; q=0.5,text/plain;version=0.0.4', + contentType: { + mediaType: 'text/plain', + version: '0.0.4', + charset: 'utf-8' + } + }, + { + description: 'Should accept mixture of content-types and q', + accept: 'application/openmetrics-text; version=0.0.1,text/plain;version=0.0.4;q=0.5,*/*;q=0.1', + contentType: { + mediaType: 'application/openmetrics-text', + version: '0.0.1', + charset: 'utf-8' + } + }, + { + description: 'Should reject Invalid charset', + accept: '*/*; charset=utf-16', + contentType: null + }, + { + description: 'Should reject Invalid openmetrics version', + accept: 'application/openmetrics-text; version=0.0.5', + contentType: null + }, + { + description: 'Should reject Invalid text/plain', + accept: 'text/plain; version=0.0.2', + contentType: null + } + ] + + for (const testCase of testCases) { + describe(testCase.description, function () { + const result = statsRegistry.matchContentType(testCase.accept); + if (testCase.contentType) { + it('should match', function (done) { + should.exist(result); + result.mediaType.should.equal(testCase.contentType.mediaType); + result.version.should.equal(testCase.contentType.version); + result.charset.should.equal(testCase.contentType.charset); + done(); + }); + } else { + it('should not match', function (done) { + should.not.exist(result); + done(); + }); + } + }); + } +});