Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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 @@ -50,6 +50,14 @@ Notes](../../RELEASENOTES.md).
thresholds are present.
([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221))

* Abort scrape request processing if request exceeds the value specified by the
`X-Prometheus-Scrape-Timeout-Seconds` HTTP request header.
([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252))

* GZip compress scrape endpoint responses when `Accept-Encoding: gzip` is
specified by the HTTP request headers.
([#7274](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7274))
Comment thread
martincostello marked this conversation as resolved.
Outdated

## 1.15.3-beta.1

Released 2026-Apr-21
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<PackageTags>$(PackageTags);prometheus;metrics</PackageTags>
<MinVerTagPrefix>coreunstable-</MinVerTagPrefix>
<DefineConstants>$(DefineConstants);PROMETHEUS_ASPNETCORE</DefineConstants>
<NoWarn>$(NoWarn);CA2007</NoWarn>

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

AspNetCore has no synchronisation context so ConfigureAwait(false) has no effect.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

True, I would consider keeping this calls just in case we need to share with httplistener similar code

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

At the moment the code reuse is one way from HttpListener to AspNetCore, so I don't think we need to worry about that in practice as if it needs to be shared it'll go in the other project which doesn't supress CA2007.

</PropertyGroup>

<ItemGroup Condition="'$(RunningDotNetPack)' != 'true'">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// SPDX-License-Identifier: Apache-2.0

using System.Diagnostics;
using System.Globalization;
using System.IO.Compression;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Headers;
using Microsoft.Net.Http.Headers;
using OpenTelemetry.Exporter.Prometheus;
using OpenTelemetry.Internal;
Expand Down Expand Up @@ -49,11 +52,6 @@ internal PrometheusExporterMiddleware(PrometheusExporter exporter)
this.exporter = exporter;
}

/// <summary>
/// Invoke.
/// </summary>
/// <param name="httpContext"> context.</param>
/// <returns>Task.</returns>
public async Task InvokeAsync(HttpContext httpContext)
{
Debug.Assert(httpContext != null, "httpContext should not be null");
Expand All @@ -62,11 +60,28 @@ public async Task InvokeAsync(HttpContext httpContext)

try
{
var openMetricsRequested = AcceptsOpenMetrics(httpContext.Request);
var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false);
using var requestCancelled = new CancellationTokenSource();

int? scrapeTimeoutSeconds = null;
if (httpContext.Request.Headers.TryGetValue("X-Prometheus-Scrape-Timeout-Seconds", out var value) &&
int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedValue) &&
parsedValue is > 0 and < int.MaxValue / 1_000)
{
scrapeTimeoutSeconds = parsedValue;
requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds.Value));
}

using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, httpContext.RequestAborted);

var requestHeaders = httpContext.Request.GetTypedHeaders();

var openMetricsRequested = AcceptsOpenMetrics(requestHeaders);
var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested);

try
{
linkedCts.Token.ThrowIfCancellationRequested();

var dataView = openMetricsRequested ? collectionResponse.OpenMetricsView : collectionResponse.PlainTextView;

response.StatusCode = StatusCodes.Status200OK;
Expand All @@ -79,14 +94,26 @@ public async Task InvokeAsync(HttpContext httpContext)
? OpenMetricsContentType
: "text/plain; charset=utf-8; version=0.0.4";

await response.Body.WriteAsync(dataView.Array.AsMemory(0, dataView.Count)).ConfigureAwait(false);
await WriteResponseAsync(response, dataView.Array.AsMemory(0, dataView.Count), AcceptsGZip(requestHeaders), linkedCts.Token);
}
else
{
// It's not expected to have no metrics to collect, but it's not necessarily a failure, either.
PrometheusExporterEventSource.Log.NoMetrics();
}
}
catch (OperationCanceledException ex) when (ex.CancellationToken == linkedCts.Token)
{
if (scrapeTimeoutSeconds is { } timeout)
{
PrometheusExporterEventSource.Log.ScrapeTimedOut(timeout);
}

if (!response.HasStarted)
{
response.StatusCode = StatusCodes.Status408RequestTimeout;
}
}
finally
{
this.exporter.CollectionManager.ExitCollect();
Expand All @@ -102,9 +129,9 @@ public async Task InvokeAsync(HttpContext httpContext)
}
}

internal static bool AcceptsOpenMetrics(HttpRequest request)
internal static bool AcceptsOpenMetrics(RequestHeaders headers)
{
var acceptHeader = request.GetTypedHeaders().Accept;
var acceptHeader = headers.Accept;

if (acceptHeader is not { Count: > 0 })
{
Expand Down Expand Up @@ -163,4 +190,44 @@ private static bool HasSupportedOpenMetricsParameters(MediaTypeHeaderValue value

return hasSupportedOpenMetricsVersion && hasSupportedOpenMetricsEscaping;
}

private static bool AcceptsGZip(RequestHeaders headers)
{
if (headers.AcceptEncoding is { Count: > 0 } acceptEncoding)
{
foreach (var parameter in acceptEncoding)
{
if (parameter.Value.Equals("gzip", StringComparison.OrdinalIgnoreCase))
Comment thread
martincostello marked this conversation as resolved.
Outdated
{
return true;
}
}
}

return false;
}

private static async Task WriteResponseAsync(
HttpResponse response,
ReadOnlyMemory<byte> content,
bool compress,
CancellationToken cancellationToken)
{
Comment thread
martincostello marked this conversation as resolved.
if (compress)
{
response.Headers.Append("Content-Encoding", "gzip");

await using var gzip = new GZipStream(
response.Body,
CompressionLevel.Optimal,
Comment thread
martincostello marked this conversation as resolved.
Outdated
leaveOpen: true);

await gzip.WriteAsync(content, cancellationToken);
await gzip.FlushAsync(cancellationToken);
}
else
{
await response.BodyWriter.WriteAsync(content, cancellationToken);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ Notes](../../RELEASENOTES.md).
thresholds are present.
([#7221](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7221))

* Abort scrape request processing if request exceeds the value specified by the
`X-Prometheus-Scrape-Timeout-Seconds` HTTP request header.
([#7252](https://github.com/open-telemetry/opentelemetry-dotnet/issues/7252))

## 1.15.3-beta.1

Released 2026-Apr-21
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,4 +85,8 @@ public void ConflictingHelp(string metricName, string firstHelp, string conflict
[Event(8, Message = "Dropping duplicate UNIT metadata for metric family '{0}' because values '{1}' and '{2}' conflict.", Level = EventLevel.Warning)]
public void ConflictingUnit(string metricName, string firstUnit, string conflictingUnit)
=> this.WriteEvent(8, metricName, firstUnit, conflictingUnit);

[Event(9, Message = "Metrics scrape request timed out after {0} seconds.", Level = EventLevel.Warning)]
public void ScrapeTimedOut(int scrapeTimeoutSeconds)
=> this.WriteEvent(9, scrapeTimeoutSeconds);
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

using System.Globalization;
using System.Net;
using OpenTelemetry.Exporter.Prometheus;
using OpenTelemetry.Internal;
Expand Down Expand Up @@ -258,12 +259,25 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation

try
{
using var requestCancelled = new CancellationTokenSource();

int? scrapeTimeoutSeconds = null;
if (context.Request.Headers["X-Prometheus-Scrape-Timeout-Seconds"] is { Length: > 0 } value &&
int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var parsedValue) &&
parsedValue is > 0 and < int.MaxValue / 1_000)
{
scrapeTimeoutSeconds = parsedValue;
requestCancelled.CancelAfter(TimeSpan.FromSeconds(scrapeTimeoutSeconds.Value));
}

using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(requestCancelled.Token, cancellationToken);

var openMetricsRequested = AcceptsOpenMetrics(context.Request);
var collectionResponse = await this.exporter.CollectionManager.EnterCollect(openMetricsRequested).ConfigureAwait(false);

try
{
cancellationToken.ThrowIfCancellationRequested();
requestCancelled.Token.ThrowIfCancellationRequested();

context.Response.Headers.Add("Server", string.Empty);

Expand All @@ -278,9 +292,9 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation
: "text/plain; charset=utf-8; version=0.0.4";

#if NET
await context.Response.OutputStream.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), cancellationToken).ConfigureAwait(false);
await context.Response.OutputStream.WriteAsync(dataView.Array.AsMemory(0, dataView.Count), linkedCts.Token).ConfigureAwait(false);
#else
await context.Response.OutputStream.WriteAsync(dataView.Array, 0, dataView.Count, cancellationToken).ConfigureAwait(false);
await context.Response.OutputStream.WriteAsync(dataView.Array, 0, dataView.Count, linkedCts.Token).ConfigureAwait(false);
#endif
}
else
Expand All @@ -290,6 +304,16 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation
PrometheusExporterEventSource.Log.NoMetrics();
}
}
catch (OperationCanceledException ex) when (ex.CancellationToken == requestCancelled.Token)
{
if (scrapeTimeoutSeconds is { } timeout)
{
PrometheusExporterEventSource.Log.ScrapeTimedOut(timeout);
}

context.Response.StatusCode = 408;
context.Response.ContentLength64 = 0;
}
finally
{
this.exporter.CollectionManager.ExitCollect();
Expand All @@ -303,7 +327,6 @@ private async Task ProcessRequestAsync(HttpListenerContext context, Cancellation
catch (Exception ex)
{
PrometheusExporterEventSource.Log.FailedExport(ex);

context.Response.StatusCode = 500;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ public void PrometheusExporterMiddlewareAcceptsOpenMetrics_UsesTypedAcceptHeader
var context = new DefaultHttpContext();
context.Request.Headers.Accept = header;

var result = PrometheusExporterMiddleware.AcceptsOpenMetrics(context.Request);
var result = PrometheusExporterMiddleware.AcceptsOpenMetrics(context.Request.GetTypedHeaders());

Assert.Equal(expected, result);
}
Expand Down Expand Up @@ -378,6 +378,68 @@ public async Task PrometheusExporterMiddlewareInvokeAsync_WhenExceptionOccursAft
Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}

[Fact]
public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequest_TimesOut_Returns408()
{
using var exporter = new PrometheusExporter(new PrometheusExporterOptions());
exporter.Collect = _ => true;
var middleware = new PrometheusExporterMiddleware(exporter);

var context = new DefaultHttpContext()
{
RequestAborted = new CancellationToken(canceled: true),
};

await middleware.InvokeAsync(context);

Assert.Equal(StatusCodes.Status408RequestTimeout, context.Response.StatusCode);
}

[Fact]
public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequestDeadlineExceeded_Returns408()
{
using var exporter = new PrometheusExporter(new PrometheusExporterOptions());

exporter.Collect = _ =>
{
Thread.Sleep(TimeSpan.FromSeconds(2));
return true;
};

var middleware = new PrometheusExporterMiddleware(exporter);

var context = new DefaultHttpContext();

context.Request.Headers.Append("X-Prometheus-Scrape-Timeout-Seconds", "1");

await middleware.InvokeAsync(context);

Assert.Equal(StatusCodes.Status408RequestTimeout, context.Response.StatusCode);
}

[Theory]
[InlineData("-1")]
[InlineData("0")]
[InlineData("0.9")]
[InlineData("1.1")]
[InlineData("2147484")]
[InlineData("foo")]
public async Task PrometheusExporterMiddlewareInvokeAsync_WhenRequestDeadlineInvalid_Returns200(string scrapeTimeoutSeconds)
{
using var exporter = new PrometheusExporter(new PrometheusExporterOptions());
exporter.Collect = _ => true;

var middleware = new PrometheusExporterMiddleware(exporter);

var context = new DefaultHttpContext();

context.Request.Headers.Append("X-Prometheus-Scrape-Timeout-Seconds", scrapeTimeoutSeconds);

await middleware.InvokeAsync(context);

Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode);
}

private static async Task RunPrometheusExporterMiddlewareIntegrationTestWithBothFormats(KeyValuePair<string, object?>[]? meterTags = null)
{
using var host = await StartTestHostAsync(
Expand Down
Loading
Loading