Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for openmetrics 0.0.1 besides 1.0.0 #1640

Merged
merged 10 commits into from
Aug 20, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions doc/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -2321,8 +2321,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
140 changes: 89 additions & 51 deletions lib/services/stats/statsRegistry.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,77 @@ 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 = [];
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('version=')) {
version = part.substring(8).trim();
} else if (part.startsWith('charset=')) {
charset = part.substring(8).trim();
} else if (part.startsWith('q=')) {
preference = parseFloat(part.substring(2).trim());
Copy link
Member

@fgalan fgalan Aug 8, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In order to avoid magic numbers maybe it's better this way:

Suggested change
for (let part of parts.slice(1)) {
if (part.startsWith('version=')) {
version = part.substring(8).trim();
} else if (part.startsWith('charset=')) {
charset = part.substring(8).trim();
} else if (part.startsWith('q=')) {
preference = parseFloat(part.substring(2).trim());
let versionTokenLength = 'version='.length;
let qTokenLength = 'q='.length;
for (let part of parts.slice(1)) {
if (part.startsWith('version=')) {
version = part.substring(versionTokenLength ).trim();
} else if (part.startsWith('charset=')) {
charset = part.substring(versionTokenLength ).trim();
} else if (part.startsWith('q=')) {
preference = parseFloat(part.substring(qTokenLength).trim());

(Code not actually tested, it may not run, but you get the idea :)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix in 679813e

}
}
requestedType.push({
mediaType: mediaType,
version: version,
charset: charset,
preference: preference || 1
});
}
// If both text/plain and openmetrics are accepted,
// prefer openmetrics
const mediaTypePref = {
'application/openmetrics-text': 1,
'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 +153,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 +217,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::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();
});
}
});
}
});
Loading