opentelemetry-dotnet/src/OpenTelemetry.Exporter.Prom.../Implementation/PrometheusMetricBuilder.cs

274 lines
12 KiB
C#

// <copyright file="PrometheusMetricBuilder.cs" company="OpenTelemetry Authors">
// 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
//
// 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.
// </copyright>
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
namespace OpenTelemetry.Exporter.Prometheus.Implementation
{
internal class PrometheusMetricBuilder
{
public const string ContentType = "text/plain; version = 0.0.4";
private static readonly char[] FirstCharacterNameCharset =
{
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'_', ':',
};
private static readonly char[] NameCharset =
{
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'_', ':',
};
private static readonly char[] FirstCharacterLabelCharset =
{
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'_',
};
private static readonly char[] LabelCharset =
{
'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9',
'_',
};
private readonly ICollection<PrometheusMetricValueBuilder> values = new List<PrometheusMetricValueBuilder>();
private string name;
private string description;
private string type;
public PrometheusMetricBuilder WithName(string name)
{
this.name = name;
return this;
}
public PrometheusMetricBuilder WithDescription(string description)
{
this.description = description;
return this;
}
public PrometheusMetricBuilder WithType(string type)
{
this.type = type;
return this;
}
public PrometheusMetricValueBuilder AddValue()
{
var val = new PrometheusMetricValueBuilder();
this.values.Add(val);
return val;
}
public void Write(StreamWriter writer)
{
// https://prometheus.io/docs/instrumenting/exposition_formats/
if (string.IsNullOrEmpty(this.name))
{
throw new InvalidOperationException("Metric name should not be empty");
}
this.name = GetSafeMetricName(this.name);
if (!string.IsNullOrEmpty(this.description))
{
// Lines with a # as the first non-whitespace character are comments.
// They are ignored unless the first token after # is either HELP or TYPE.
// Those lines are treated as follows: If the token is HELP, at least one
// more token is expected, which is the metric name. All remaining tokens
// are considered the docstring for that metric name. HELP lines may contain
// any sequence of UTF-8 characters (after the metric name), but the backslash
// and the line feed characters have to be escaped as \\ and \n, respectively.
// Only one HELP line may exist for any given metric name.
writer.Write("# HELP ");
writer.Write(this.name);
writer.Write(GetSafeMetricDescription(this.description));
writer.Write("\n");
}
if (!string.IsNullOrEmpty(this.type))
{
// If the token is TYPE, exactly two more tokens are expected. The first is the
// metric name, and the second is either counter, gauge, histogram, summary, or
// untyped, defining the type for the metric of that name. Only one TYPE line
// may exist for a given metric name. The TYPE line for a metric name must appear
// before the first sample is reported for that metric name. If there is no TYPE
// line for a metric name, the type is set to untyped.
writer.Write("# TYPE ");
writer.Write(this.name);
writer.Write(" ");
writer.Write(this.type);
writer.Write("\n");
}
// The remaining lines describe samples (one per line) using the following syntax (EBNF):
// metric_name [
// "{" label_name "=" `"` label_value `"` { "," label_name "=" `"` label_value `"` } [ "," ] "}"
// ] value [ timestamp ]
// In the sample syntax:
var now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(CultureInfo.InvariantCulture);
foreach (var m in this.values)
{
// metric_name and label_name carry the usual Prometheus expression language restrictions.
writer.Write(m.Name != null ? GetSafeMetricName(m.Name) : this.name);
// label_value can be any sequence of UTF-8 characters, but the backslash
// (\, double-quote ("}, and line feed (\n) characters have to be escaped
// as \\, \", and \n, respectively.
if (m.Labels.Count > 0)
{
writer.Write(@"{");
writer.Write(string.Join(",", m.Labels.Select(x => GetLabelAndValue(x.Item1, x.Item2))));
writer.Write(@"}");
}
// value is a float represented as required by Go's ParseFloat() function. In addition to
// standard numerical values, Nan, +Inf, and -Inf are valid values representing not a number,
// positive infinity, and negative infinity, respectively.
writer.Write(" ");
writer.Write(m.Value.ToString(CultureInfo.InvariantCulture));
writer.Write(" ");
// The timestamp is an int64 (milliseconds since epoch, i.e. 1970-01-01 00:00:00 UTC, excluding
// leap seconds), represented as required by Go's ParseInt() function.
writer.Write(now);
// Prometheus' text-based format is line oriented. Lines are separated
// by a line feed character (\n). The last line must end with a line
// feed character. Empty lines are ignored.
writer.Write("\n");
}
static string GetLabelAndValue(string label, string value)
{
var safeKey = GetSafeLabelName(label);
var safeValue = GetSafeLabelValue(value);
return $"{safeKey}=\"{safeValue}\"";
}
}
private static string GetSafeName(string name, char[] firstCharNameCharset, char[] charNameCharset)
{
// https://prometheus.io/docs/concepts/data_model/#metric-names-and-labels
//
// Metric names and labels
// Every time series is uniquely identified by its metric name and a set of key-value pairs, also known as labels.
// The metric name specifies the general feature of a system that is measured (e.g. http_requests_total - the total number of HTTP requests received). It may contain ASCII letters and digits, as well as underscores and colons. It must match the regex [a-zA-Z_:][a-zA-Z0-9_:]*.
// Note: The colons are reserved for user defined recording rules. They should not be used by exporters or direct instrumentation.
// Labels enable Prometheus's dimensional data model: any given combination of labels for the same metric name identifies a particular dimensional instantiation of that metric (for example: all HTTP requests that used the method POST to the /api/tracks handler). The query language allows filtering and aggregation based on these dimensions. Changing any label value, including adding or removing a label, will create a new time series.
// Label names may contain ASCII letters, numbers, as well as underscores. They must match the regex [a-zA-Z_][a-zA-Z0-9_]*. Label names beginning with __ are reserved for internal use.
// Label values may contain any Unicode characters.
var sb = new StringBuilder();
var firstChar = name[0];
sb.Append(firstCharNameCharset.Contains(firstChar)
? firstChar
: GetSafeChar(char.ToLowerInvariant(firstChar), firstCharNameCharset));
for (var i = 1; i < name.Length; ++i)
{
sb.Append(GetSafeChar(name[i], charNameCharset));
}
return sb.ToString();
static char GetSafeChar(char c, char[] charset) => charset.Contains(c) ? c : '_';
}
private static string GetSafeMetricName(string name) => GetSafeName(name, FirstCharacterNameCharset, NameCharset);
private static string GetSafeLabelName(string name) => GetSafeName(name, FirstCharacterLabelCharset, LabelCharset);
private static string GetSafeLabelValue(string value)
{
// label_value can be any sequence of UTF-8 characters, but the backslash
// (\), double-quote ("), and line feed (\n) characters have to be escaped
// as \\, \", and \n, respectively.
var result = value.Replace("\\", "\\\\");
result = result.Replace("\n", "\\n");
result = result.Replace("\"", "\\\"");
return result;
}
private static string GetSafeMetricDescription(string description)
{
// HELP lines may contain any sequence of UTF-8 characters(after the metric name), but the backslash
// and the line feed characters have to be escaped as \\ and \n, respectively.Only one HELP line may
// exist for any given metric name.
var result = description.Replace(@"\", @"\\");
result = result.Replace("\n", @"\n");
return result;
}
internal class PrometheusMetricValueBuilder
{
public readonly ICollection<Tuple<string, string>> Labels = new List<Tuple<string, string>>();
public double Value;
public string Name;
public PrometheusMetricValueBuilder WithLabel(string name, string value)
{
this.Labels.Add(new Tuple<string, string>(name, value));
return this;
}
public PrometheusMetricValueBuilder WithValue(long metricValue)
{
this.Value = metricValue;
return this;
}
public PrometheusMetricValueBuilder WithValue(double metricValue)
{
this.Value = metricValue;
return this;
}
public PrometheusMetricValueBuilder WithName(string name)
{
this.Name = name;
return this;
}
}
}
}