Skip to content

Commit

Permalink
Merge pull request #1640 from rg2011/fix/prom-accept
Browse files Browse the repository at this point in the history
Add support for openmetrics 0.0.1 besides 1.0.0
  • Loading branch information
AlvaroVega authored Aug 20, 2024
2 parents 0e00aba + 679813e commit 9ba35d4
Show file tree
Hide file tree
Showing 3 changed files with 261 additions and 53 deletions.
4 changes: 2 additions & 2 deletions doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<the requested 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.
Expand Down
143 changes: 92 additions & 51 deletions lib/services/stats/statsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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)) {
Expand Down Expand Up @@ -180,3 +220,4 @@ exports.getAllGlobal = getAllGlobal;
exports.globalLoad = globalLoad;
exports.withStats = withStats;
exports.openmetricsHandler = openmetricsHandler;
exports.matchContentType = matchContentType;
167 changes: 167 additions & 0 deletions test/unit/statsRegistry/openmetrics-test.js
Original file line number Diff line number Diff line change
@@ -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::[email protected]
*/

/* 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();
});
}
});
}
});

0 comments on commit 9ba35d4

Please sign in to comment.