Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -548,7 +548,7 @@ private PrometheusMetric GetPrometheusMetric(Metric metric)
// Optimize writing metrics with bounded cache that has pre-calculated Prometheus names.
if (!this.metricsCache.TryGetValue(metric, out var prometheusMetric))
{
prometheusMetric = PrometheusMetric.Create(metric, this.exporter.DisableTotalNameSuffixForCounters);
prometheusMetric = PrometheusMetric.Create(metric, this.exporter.DisableTotalNameSuffixForCounters, this.exporter.AppendSuffixes);

// Add to the cache if there is space.
if (this.metricsCacheCount < MaxCachedMetrics)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public PrometheusExporter(PrometheusExporterOptions options)
this.ScrapeResponseCacheDurationMilliseconds = options.ScrapeResponseCacheDurationMilliseconds;
this.TargetInfoEnabled = options.TargetInfoEnabled;
this.DisableTotalNameSuffixForCounters = options.DisableTotalNameSuffixForCounters;
this.TranslationStrategy = options.TranslationStrategy;
this.ResourceConstantLabels = options.ResourceConstantLabels;
this.MaxScrapeResponseSizeBytes = options.MaxScrapeResponseSizeBytes;

Expand Down Expand Up @@ -54,6 +55,10 @@ public PrometheusExporter(PrometheusExporterOptions options)

internal bool DisableTotalNameSuffixForCounters { get; }

internal PrometheusTranslationStrategy TranslationStrategy { get; }

internal bool AppendSuffixes => this.TranslationStrategy.AppendSuffixes();

internal Func<string, bool>? ResourceConstantLabels { get; }

internal int MaxScrapeResponseSizeBytes { get; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ public PrometheusExporterOptions()
this.ScrapeResponseCacheDurationMilliseconds = 300;
this.TargetInfoEnabled = true;
this.MaxScrapeResponseSizeBytes = DefaultMaxScrapeResponseSizeBytes;
this.TranslationStrategy = PrometheusTranslationStrategy.UnderscoreEscapingWithSuffixes;
}

/// <summary>
Expand Down Expand Up @@ -66,10 +67,17 @@ public int ScrapeResponseCacheDurationMilliseconds
public bool TargetInfoEnabled { get; set; }

/// <summary>
/// Gets or sets a value indicating whether addition of _total suffix for counter metric names is disabled. Default value: <see langword="false"/>.
/// Gets or sets a value indicating whether addition of <c>_total</c> suffix for
/// counter metric names is disabled. Default value: <see langword="false"/>.
/// </summary>
public bool DisableTotalNameSuffixForCounters { get; set; }

/// <summary>
/// Gets or sets the strategy used to translate OpenTelemetry metric and label names into
/// Prometheus names. Default value: <see cref="PrometheusTranslationStrategy.UnderscoreEscapingWithSuffixes"/>.
/// </summary>
public PrometheusTranslationStrategy TranslationStrategy { get; set; }

/// <summary>
/// Gets or sets a predicate used to select which resource attributes are added to each metric as constant labels.
/// The predicate is invoked with the resource attribute key and should return <see langword="true"/> to include the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ internal sealed class PrometheusMetric
{
private readonly string rawName;
private readonly bool disableTotalNameSuffixForCounters;
private readonly bool appendSuffixes;
private readonly NameSet underscoreNames;
private NameSet? allowUtf8Names;
private NameSet? dotsNames;
private NameSet? valuesNames;

public PrometheusMetric(string name, string unit, PrometheusType type, bool disableTotalNameSuffixForCounters)
public PrometheusMetric(string name, string unit, PrometheusType type, bool disableTotalNameSuffixForCounters, bool appendSuffixes = true)
{
this.rawName = name;
this.disableTotalNameSuffixForCounters = disableTotalNameSuffixForCounters;
this.appendSuffixes = appendSuffixes;
this.Type = type;

var sanitizedUnit = string.IsNullOrEmpty(unit) ? null : GetUnit(unit);
Expand All @@ -30,7 +32,7 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa
// formats, which have no negotiated escaping) so they are computed eagerly. Other
// escapings' name sets are computed lazily on first use, so the overhead of an
// escaping scheme is only paid when that scheme is actually scraped from the server.
this.underscoreNames = BuildUnderscoreNames(name, sanitizedUnit, type, disableTotalNameSuffixForCounters);
this.underscoreNames = BuildUnderscoreNames(name, sanitizedUnit, type, disableTotalNameSuffixForCounters, appendSuffixes);
}

public string Name => this.underscoreNames.Name;
Expand All @@ -45,8 +47,8 @@ public PrometheusMetric(string name, string unit, PrometheusType type, bool disa

internal byte[]? UnitBytes { get; }

public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters)
=> new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters);
public static PrometheusMetric Create(Metric metric, bool disableTotalNameSuffixForCounters, bool appendSuffixes = true)
=> new(metric.Name, metric.Unit, GetPrometheusType(metric.MetricType), disableTotalNameSuffixForCounters, appendSuffixes);

internal static string SanitizeMetricUnit(string metricUnit)
{
Expand Down Expand Up @@ -236,9 +238,9 @@ UpDownCounter becomes gauge
/// <returns>The metric name set for the requested escaping scheme.</returns>
internal NameSet GetNameSet(EscapingScheme escaping) => escaping switch
{
EscapingScheme.AllowUtf8 => this.allowUtf8Names ??= BuildAllowUtf8Names(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters),
EscapingScheme.Dots => this.dotsNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, EscapingScheme.Dots),
EscapingScheme.Values => this.valuesNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, EscapingScheme.Values),
EscapingScheme.AllowUtf8 => this.allowUtf8Names ??= BuildAllowUtf8Names(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, this.appendSuffixes),
EscapingScheme.Dots => this.dotsNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, this.appendSuffixes, EscapingScheme.Dots),
EscapingScheme.Values => this.valuesNames ??= BuildEscapedNames(this.rawName, this.Unit, this.Type, this.disableTotalNameSuffixForCounters, this.appendSuffixes, EscapingScheme.Values),
EscapingScheme.Underscores or _ => this.underscoreNames,
};

Expand All @@ -255,14 +257,28 @@ private static byte[] ConvertToBytes(string value)
return bytes;
}

private static NameSet BuildUnderscoreNames(string name, string? sanitizedUnit, PrometheusType type, bool disableTotalNameSuffixForCounters)
private static NameSet BuildUnderscoreNames(string name, string? sanitizedUnit, PrometheusType type, bool disableTotalNameSuffixForCounters, bool appendSuffixes)
{
// The metric name is
// required to match the regex: `[a-zA-Z_:]([a-zA-Z0-9_:])*`. Invalid characters
// in the metric name MUST be replaced with the `_` character. Multiple
// consecutive `_` characters MUST be replaced with a single `_` character.
// https://github.com/open-telemetry/opentelemetry-specification/blob/b2f923fb1650dde1f061507908b834035506a796/specification/compatibility/prometheus_and_openmetrics.md#L230-L233
var sanitizedName = SanitizeMetricName(name);

if (!appendSuffixes)
{
// The suffix axis is disabled, so no unit suffix and no '_total' counter suffix are
// appended; only the escaping axis (underscore sanitization) is applied. As in the
// with-suffixes path below, the classic name is sanitized with SanitizeMetricName and
// the OpenMetrics names with EscapeOpenMetricsName; without suffixes to distinguish
// them the OpenMetrics name and its metadata name are the same. Note that even
// OpenMetrics counters do not receive the '_total' suffix that the format would
// otherwise mandate; this is the caller's explicit choice not to translate names.
var escapedName = EscapeOpenMetricsName(name);
return new(sanitizedName, escapedName, escapedName);
}

var openMetricsName = type == PrometheusType.Counter
? RemoveOpenMetricsCounterNameSuffix(name)
: name;
Expand Down Expand Up @@ -321,8 +337,17 @@ private static NameSet BuildEscapedNames(
string? sanitizedUnit,
PrometheusType type,
bool disableTotalNameSuffixForCounters,
bool appendSuffixes,
EscapingScheme escaping)
{
if (!appendSuffixes)
{
// The suffix axis is disabled, so the escaped name carries neither a unit suffix nor a
// '_total' counter suffix and the three names collapse to the escaped original name.
var escapedNameWithoutSuffixes = PrometheusEscaping.EscapeName(name, escaping);
return new(escapedNameWithoutSuffixes, escapedNameWithoutSuffixes, escapedNameWithoutSuffixes);
}

// The escaping scheme is applied to the metric family name (the original name plus any
// unit suffix). The type-specific '_total' suffix is a structural suffix that Prometheus
// strips to find the family (like the '_bucket'/'_sum'/'_count' suffixes added during
Expand Down Expand Up @@ -369,8 +394,18 @@ private static NameSet BuildEscapedNames(
return new(escapedName, openMetricsName, openMetricsMetadataName);
}

private static NameSet BuildAllowUtf8Names(string name, string? sanitizedUnit, PrometheusType type, bool disableTotalNameSuffixForCounters)
private static NameSet BuildAllowUtf8Names(string name, string? sanitizedUnit, PrometheusType type, bool disableTotalNameSuffixForCounters, bool appendSuffixes)
{
if (!appendSuffixes)
{
// The suffix axis is disabled and the allow-utf-8 scheme does not escape the name, so the
// name passes through completely unaltered (the "no translation" case). The three names
// are identical; only whether the original name is a valid legacy name determines whether
// the quoted exposition format is required.
var isLegacyValidWithoutSuffixes = PrometheusEscaping.IsValidLegacyName(name);
return new(name, name, name, isLegacyValidWithoutSuffixes, encodeUtf8: true);
}

// The allow-utf-8 scheme does not escape the name; the original UTF-8 name is kept and only
// the unit and the structural '_total' suffix are appended. The family name determines
// whether the quoted exposition format is required (the structural suffixes are legacy
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

namespace OpenTelemetry.Exporter.Prometheus;

/// <summary>
/// Controls how OpenTelemetry metric and label names are translated into Prometheus names.
/// </summary>
/// <remarks>
/// This enum models the OpenTelemetry specification's <c>translation_strategy</c> option.
/// </remarks>
internal enum PrometheusTranslationStrategy
{
/// <summary>
/// Discouraged characters are escaped to <c>_</c> and unit and type (e.g. <c>_total</c>)
/// suffixes are appended. This is the default and matches the classic Prometheus behaviour.
/// </summary>
UnderscoreEscapingWithSuffixes = 0,

/// <summary>
/// Discouraged characters are escaped to <c>_</c> but no unit or type suffixes are appended.
/// </summary>
UnderscoreEscapingWithoutSuffixes,

/// <summary>
/// Names are not escaped (UTF-8 is passed through) and unit and type suffixes are appended.
/// </summary>
NoUTF8EscapingWithSuffixes,

/// <summary>
/// Names are passed through completely unaltered: names are not escaped and no suffixes are appended.
/// </summary>
NoTranslation,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

namespace OpenTelemetry.Exporter.Prometheus;

internal static class PrometheusTranslationStrategyExtensions
{
/// <summary>
/// Gets the default name escaping scheme implied by the strategy's escaping axis.
/// </summary>
/// <param name="strategy">The translation strategy.</param>
/// <returns>The escaping scheme the strategy defaults to.</returns>
/// <remarks>
/// This is the escaping scheme the exporter intends to use when a scrape request does not
/// negotiate one of its own. It is not yet consumed: layering content negotiation on top of
/// this default (so a negotiated <c>escaping=</c> preference overrides it) is deferred to a
/// follow-up change. The suffix axis (see <see cref="AppendSuffixes"/>) is static and is not
/// subject to negotiation.
/// </remarks>
public static EscapingScheme GetDefaultEscapingScheme(this PrometheusTranslationStrategy strategy) => strategy switch
{
PrometheusTranslationStrategy.NoTranslation => EscapingScheme.AllowUtf8,
PrometheusTranslationStrategy.NoUTF8EscapingWithSuffixes => EscapingScheme.AllowUtf8,
PrometheusTranslationStrategy.UnderscoreEscapingWithSuffixes => EscapingScheme.Underscores,
PrometheusTranslationStrategy.UnderscoreEscapingWithoutSuffixes => EscapingScheme.Underscores,
_ => EscapingScheme.Underscores,
};

/// <summary>
/// Gets a value indicating whether unit and type (e.g. <c>_total</c>) suffixes are appended to
/// metric names.
/// </summary>
/// <param name="strategy">The translation strategy.</param>
/// <returns>
/// <see langword="true"/> if unit and type suffixes are appended; otherwise, <see langword="false"/>.
/// </returns>
public static bool AppendSuffixes(this PrometheusTranslationStrategy strategy) => strategy switch
{
PrometheusTranslationStrategy.NoTranslation => false,
PrometheusTranslationStrategy.NoUTF8EscapingWithSuffixes => true,
PrometheusTranslationStrategy.UnderscoreEscapingWithSuffixes => true,
PrometheusTranslationStrategy.UnderscoreEscapingWithoutSuffixes => false,
_ => true,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,123 @@ public void GetNames_AllowUtf8_GaugeNamedExactlyTotal_IsNotEmptied()
Assert.Equal("_total", names.OpenMetricsMetadataName);
}

[Fact]
public void AppendSuffixes_False_Underscores_DropsUnitAndTotalAndCollapsesNames()
{
var metric = new PrometheusMetric(
"http.server.duration",
"s",
PrometheusType.Counter,
disableTotalNameSuffixForCounters: false,
appendSuffixes: false);

// The escaping axis (underscore sanitization) still applies, but no unit suffix and no
// '_total' counter suffix are appended, so all three names collapse to the escaped name.
Assert.Equal("http_server_duration", metric.Name);
Assert.Equal("http_server_duration", metric.OpenMetricsName);
Assert.Equal("http_server_duration", metric.OpenMetricsMetadataName);
}

[Fact]
public void AppendSuffixes_False_Underscores_PreservesUserAuthoredTotalSuffix()
{
// "without suffixes" means suffixes are not added; a '_total' the user authored is not removed.
var metric = new PrometheusMetric(
"db_bytes_total",
"By",
PrometheusType.Counter,
disableTotalNameSuffixForCounters: false,
appendSuffixes: false);

Assert.Equal("db_bytes_total", metric.Name);
Assert.Equal("db_bytes_total", metric.OpenMetricsName);
Assert.Equal("db_bytes_total", metric.OpenMetricsMetadataName);
}

[Fact]
public void AppendSuffixes_False_Counter_IgnoresDisableTotalNameSuffixForCounters()
{
// When suffixes are disabled the '_total' suffix is never added regardless of the value of
// disableTotalNameSuffixForCounters.
var enabled = new PrometheusMetric("requests", "1", PrometheusType.Counter, disableTotalNameSuffixForCounters: false, appendSuffixes: false);
var disabled = new PrometheusMetric("requests", "1", PrometheusType.Counter, disableTotalNameSuffixForCounters: true, appendSuffixes: false);

Assert.Equal("requests", enabled.Name);
Assert.Equal("requests", disabled.Name);
Assert.Equal("requests", enabled.OpenMetricsName);
Assert.Equal("requests", disabled.OpenMetricsName);
}

[Fact]
public void AppendSuffixes_False_Dots_EscapesNameButAppendsNoSuffixes()
{
var metric = new PrometheusMetric(
"http.server.duration",
"s",
PrometheusType.Counter,
disableTotalNameSuffixForCounters: false,
appendSuffixes: false);

var names = metric.GetNameSet(EscapingScheme.Dots);

Assert.Equal("http_dot_server_dot_duration", names.Name);
Assert.Equal("http_dot_server_dot_duration", names.OpenMetricsName);
Assert.Equal("http_dot_server_dot_duration", names.OpenMetricsMetadataName);
}

[Fact]
public void AppendSuffixes_False_Values_EscapesNameButAppendsNoSuffixes()
{
var metric = new PrometheusMetric(
"http.server.duration",
"s",
PrometheusType.Counter,
disableTotalNameSuffixForCounters: false,
appendSuffixes: false);

var names = metric.GetNameSet(EscapingScheme.Values);

Assert.Equal("U__http_2e_server_2e_duration", names.Name);
Assert.Equal("U__http_2e_server_2e_duration", names.OpenMetricsName);
Assert.Equal("U__http_2e_server_2e_duration", names.OpenMetricsMetadataName);
}

[Fact]
public void AppendSuffixes_False_AllowUtf8_PassesNameThroughUnalteredAndFlagsNonLegacyName()
{
var metric = new PrometheusMetric(
"http.server.duration",
"s",
PrometheusType.Counter,
disableTotalNameSuffixForCounters: false,
appendSuffixes: false);

var names = metric.GetNameSet(EscapingScheme.AllowUtf8);

// The allow-utf-8 scheme with suffixes disabled is the "no translation" case: the name is
// completely unaltered and its (non-)legacy validity drives the quoted exposition format.
Assert.Equal("http.server.duration", names.Name);
Assert.Equal("http.server.duration", names.OpenMetricsName);
Assert.Equal("http.server.duration", names.OpenMetricsMetadataName);
Assert.False(names.IsLegacyValid);
}

[Fact]
public void AppendSuffixes_False_AllowUtf8_LegacyNameIsFlaggedValid()
{
var metric = new PrometheusMetric(
"http_server_requests",
"By",
PrometheusType.Counter,
disableTotalNameSuffixForCounters: false,
appendSuffixes: false);

var names = metric.GetNameSet(EscapingScheme.AllowUtf8);

Assert.Equal("http_server_requests", names.Name);
Assert.True(names.IsLegacyValid);
}

[Theory]
[InlineData(PrometheusType.Counter)]
[InlineData(PrometheusType.Gauge)]
Expand Down
Loading