Skip to content

Commit fa25ec2

Browse files
tommasodotNETaaronpowellRubenCerna2079aaronburtleAniruddh25
committed
2554 Enhance REST OTEL instrumentation with custom metrics and traces (#2617)
## Why make this change? - Closes #2554 - Enhances OTEL instrumentation with custom traces and metrics for the REST APIs ## What is this change? This PR enhances the OTEL instrumentation for the REST APIs by adding custom traces and metrics. I have removed ASP NET Core standard instrumentation since it does not provide great value given the custom nature of the webservice. I have written two main Helper classes: `TelemetryMetricsHelper` and `TelemetryTracesHelper` to provide a single point of management for custom traces and metrics. Metrics can be filtered for `status_code`, `api_type`, `endpoint` and `method`. I have also fixed the loggings which are now sent to the configured OTEL endpoint. ### Logs ![image](https://github.com/user-attachments/assets/2b2b21ab-b16e-4678-8a3f-2c6bc3ab7168) ### Metrics ![Screenshot 2025-03-14 190730](https://github.com/user-attachments/assets/322f6111-8580-49c2-b6ff-5cddc191178c) ### Traces ![image (2)](https://github.com/user-attachments/assets/1e9f320a-7445-44d6-a1c6-b0d5eb21fa8f) ![image (3)](https://github.com/user-attachments/assets/efc754c1-a646-4500-9ebf-4f5cfba34b85) ![image (4)](https://github.com/user-attachments/assets/cadc9665-934f-48bd-a1bc-afedbf5fe395) ## How was this tested? - [ ] Integration Tests - [ ] Unit Tests ## Sample Request(s) To test everything locally I recommend using [this repo](https://github.com/tommasodotNET/dab-workbench) that allows to run the local build of the dab cli and send metrics to the .NET Aspire OTEL endoint. --------- Co-authored-by: Aaron Powell <[email protected]> Co-authored-by: RubenCerna2079 <[email protected]> Co-authored-by: aaronburtle <[email protected]> Co-authored-by: Ruben Cerna <[email protected]> Co-authored-by: Aniruddh Munde <[email protected]>
1 parent a8ce711 commit fa25ec2

File tree

8 files changed

+329
-13
lines changed

8 files changed

+329
-13
lines changed

src/Core/Resolvers/SqlQueryEngine.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta
332332
JsonElement result = await _cache.GetOrSetAsync<JsonElement>(queryExecutor, queryMetadata, cacheEntryTtl: runtimeConfig.GetEntityCacheEntryTtl(entityName: structure.EntityName));
333333
byte[] jsonBytes = JsonSerializer.SerializeToUtf8Bytes(result);
334334
JsonDocument cacheServiceResponse = JsonDocument.Parse(jsonBytes);
335+
335336
return cacheServiceResponse;
336337
}
337338
}
@@ -348,6 +349,7 @@ public object ResolveList(JsonElement array, IObjectField fieldSchema, ref IMeta
348349
httpContext: _httpContextAccessor.HttpContext!,
349350
args: null,
350351
dataSourceName: dataSourceName);
352+
351353
return response;
352354
}
353355

src/Service.Tests/Configuration/OpenTelemetryTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ public void CleanUpTelemetryConfig()
6161
File.Delete(CONFIG_WITHOUT_TELEMETRY);
6262
}
6363

64-
Startup.OpenTelemetryOptions = new();
6564
}
6665

6766
/// <summary>

src/Service.Tests/dab-config.CosmosDb_NoSql.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,12 @@
3131
"provider": "StaticWebApps"
3232
},
3333
"mode": "development"
34+
},
35+
"telemetry": {
36+
"app-insights": {
37+
"enabled": true,
38+
"connection-string": "InstrumentationKey=00000000-0000-0000-0000-000000000000;IngestionEndpoint=https://dc.services.visualstudio.com/v2/track"
39+
}
3440
}
3541
},
3642
"entities": {
@@ -794,4 +800,4 @@
794800
]
795801
}
796802
}
797-
}
803+
}

src/Service/Azure.DataApiBuilder.Service.csproj

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
<Project Sdk="Microsoft.NET.Sdk.Web">
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
22

33
<PropertyGroup>
44
<TargetFrameworks>net8.0</TargetFrameworks>
@@ -77,7 +77,7 @@
7777
<PackageReference Include="System.CommandLine" />
7878
<PackageReference Include="System.IO.Abstractions" />
7979
<PackageReference Include="ZiggyCreatures.FusionCache" />
80-
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
80+
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
8181
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
8282
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
8383
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />

src/Service/Controllers/RestController.cs

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,20 @@
22
// Licensed under the MIT License.
33

44
using System;
5+
using System.Diagnostics;
6+
using System.Linq;
57
using System.Net;
68
using System.Net.Mime;
79
using System.Threading.Tasks;
810
using Azure.DataApiBuilder.Config.ObjectModel;
11+
using Azure.DataApiBuilder.Core.Configurations;
912
using Azure.DataApiBuilder.Core.Models;
1013
using Azure.DataApiBuilder.Core.Services;
1114
using Azure.DataApiBuilder.Service.Exceptions;
15+
using Azure.DataApiBuilder.Service.Telemetry;
1216
using Microsoft.AspNetCore.Http;
1317
using Microsoft.AspNetCore.Mvc;
18+
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http;
1419
using Microsoft.Extensions.Logging;
1520

1621
namespace Azure.DataApiBuilder.Service.Controllers
@@ -47,11 +52,14 @@ public class RestController : ControllerBase
4752

4853
private readonly ILogger<RestController> _logger;
4954

55+
private readonly RuntimeConfigProvider _runtimeConfigProvider;
56+
5057
/// <summary>
5158
/// Constructor.
5259
/// </summary>
53-
public RestController(RestService restService, IOpenApiDocumentor openApiDocumentor, ILogger<RestController> logger)
60+
public RestController(RuntimeConfigProvider runtimeConfigProvider, RestService restService, IOpenApiDocumentor openApiDocumentor, ILogger<RestController> logger)
5461
{
62+
_runtimeConfigProvider = runtimeConfigProvider;
5563
_restService = restService;
5664
_openApiDocumentor = openApiDocumentor;
5765
_logger = logger;
@@ -185,11 +193,29 @@ private async Task<IActionResult> HandleOperation(
185193
string route,
186194
EntityActionOperation operationType)
187195
{
196+
if (route.Equals(REDIRECTED_ROUTE))
197+
{
198+
return NotFound();
199+
}
200+
201+
Stopwatch stopwatch = Stopwatch.StartNew();
202+
// This activity tracks the entire REST request.
203+
using Activity? activity = TelemetryTracesHelper.DABActivitySource.StartActivity($"{HttpContext.Request.Method} {(route.Split('/').Length > 1 ? route.Split('/')[1] : string.Empty)}");
204+
188205
try
189206
{
190-
if (route.Equals(REDIRECTED_ROUTE))
207+
TelemetryMetricsHelper.IncrementActiveRequests(ApiType.REST);
208+
209+
if (activity is not null)
191210
{
192-
return NotFound();
211+
activity.TrackRestControllerActivityStarted(
212+
Enum.Parse<HttpMethod>(HttpContext.Request.Method, ignoreCase: true),
213+
HttpContext.Request.Headers["User-Agent"].ToString(),
214+
operationType.ToString(),
215+
route,
216+
HttpContext.Request.QueryString.ToString(),
217+
HttpContext.Request.Headers["X-MS-API-ROLE"].FirstOrDefault() ?? HttpContext.User.FindFirst("role")?.Value,
218+
ApiType.REST);
193219
}
194220

195221
// Validate the PathBase matches the configured REST path.
@@ -208,8 +234,21 @@ private async Task<IActionResult> HandleOperation(
208234

209235
(string entityName, string primaryKeyRoute) = _restService.GetEntityNameAndPrimaryKeyRouteFromRoute(routeAfterPathBase);
210236

237+
// This activity tracks the query execution. This will create a new activity nested under the REST request activity.
238+
using Activity? queryActivity = TelemetryTracesHelper.DABActivitySource.StartActivity($"QUERY {entityName}");
211239
IActionResult? result = await _restService.ExecuteAsync(entityName, operationType, primaryKeyRoute);
212240

241+
RuntimeConfig runtimeConfig = _runtimeConfigProvider.GetConfig();
242+
string dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName);
243+
DatabaseType databaseType = runtimeConfig.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType;
244+
245+
if (queryActivity is not null)
246+
{
247+
queryActivity.TrackQueryActivityStarted(
248+
databaseType,
249+
dataSourceName);
250+
}
251+
213252
if (result is null)
214253
{
215254
throw new DataApiBuilderException(
@@ -218,6 +257,13 @@ private async Task<IActionResult> HandleOperation(
218257
subStatusCode: DataApiBuilderException.SubStatusCodes.EntityNotFound);
219258
}
220259

260+
int statusCode = (result as ObjectResult)?.StatusCode ?? (result as StatusCodeResult)?.StatusCode ?? (result as JsonResult)?.StatusCode ?? 200;
261+
if (activity is not null && activity.IsAllDataRequested)
262+
{
263+
HttpStatusCode httpStatusCode = Enum.Parse<HttpStatusCode>(statusCode.ToString(), ignoreCase: true);
264+
activity.TrackRestControllerActivityFinished(httpStatusCode);
265+
}
266+
221267
return result;
222268
}
223269
catch (DataApiBuilderException ex)
@@ -228,6 +274,10 @@ private async Task<IActionResult> HandleOperation(
228274
HttpContextExtensions.GetLoggerCorrelationId(HttpContext));
229275

230276
Response.StatusCode = (int)ex.StatusCode;
277+
activity?.TrackRestControllerActivityFinishedWithException(ex, ex.StatusCode);
278+
279+
HttpMethod method = Enum.Parse<HttpMethod>(HttpContext.Request.Method, ignoreCase: true);
280+
TelemetryMetricsHelper.TrackError(method, ex.StatusCode, route, ApiType.REST, ex);
231281
return ErrorResponse(ex.SubStatusCode.ToString(), ex.Message, ex.StatusCode);
232282
}
233283
catch (Exception ex)
@@ -238,11 +288,26 @@ private async Task<IActionResult> HandleOperation(
238288
HttpContextExtensions.GetLoggerCorrelationId(HttpContext));
239289

240290
Response.StatusCode = (int)HttpStatusCode.InternalServerError;
291+
292+
HttpMethod method = Enum.Parse<HttpMethod>(HttpContext.Request.Method, ignoreCase: true);
293+
activity?.TrackRestControllerActivityFinishedWithException(ex, HttpStatusCode.InternalServerError);
294+
295+
TelemetryMetricsHelper.TrackError(method, HttpStatusCode.InternalServerError, route, ApiType.REST, ex);
241296
return ErrorResponse(
242297
DataApiBuilderException.SubStatusCodes.UnexpectedError.ToString(),
243298
SERVER_ERROR,
244299
HttpStatusCode.InternalServerError);
245300
}
301+
finally
302+
{
303+
stopwatch.Stop();
304+
HttpMethod method = Enum.Parse<HttpMethod>(HttpContext.Request.Method, ignoreCase: true);
305+
HttpStatusCode httpStatusCode = Enum.Parse<HttpStatusCode>(Response.StatusCode.ToString(), ignoreCase: true);
306+
TelemetryMetricsHelper.TrackRequest(method, httpStatusCode, route, ApiType.REST);
307+
TelemetryMetricsHelper.TrackRequestDuration(method, httpStatusCode, route, ApiType.REST, stopwatch.Elapsed);
308+
309+
TelemetryMetricsHelper.DecrementActiveRequests(ApiType.REST);
310+
}
246311
}
247312

248313
/// <summary>

src/Service/Startup.cs

Lines changed: 53 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ public class Startup
6161
public static LogLevel MinimumLogLevel = LogLevel.Error;
6262

6363
public static bool IsLogLevelOverriddenByCli;
64-
public static OpenTelemetryOptions OpenTelemetryOptions = new();
6564

6665
public static ApplicationInsightsOptions AppInsightsOptions = new();
66+
public static OpenTelemetryOptions OpenTelemetryOptions = new();
6767
public const string NO_HTTPS_REDIRECT_FLAG = "--no-https-redirect";
6868
private HotReloadEventHandler<HotReloadEventArgs> _hotReloadEventHandler = new();
6969
private RuntimeConfigProvider? _configProvider;
@@ -119,31 +119,46 @@ public void ConfigureServices(IServiceCollection services)
119119
&& runtimeConfig?.Runtime?.Telemetry?.OpenTelemetry is not null
120120
&& runtimeConfig.Runtime.Telemetry.OpenTelemetry.Enabled)
121121
{
122+
services.Configure<OpenTelemetryLoggerOptions>(options =>
123+
{
124+
options.IncludeScopes = true;
125+
options.ParseStateValues = true;
126+
options.IncludeFormattedMessage = true;
127+
});
122128
services.AddOpenTelemetry()
129+
.WithLogging(logging =>
130+
{
131+
logging.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!))
132+
.AddOtlpExporter(configure =>
133+
{
134+
configure.Endpoint = new Uri(runtimeConfig.Runtime.Telemetry.OpenTelemetry.Endpoint!);
135+
configure.Headers = runtimeConfig.Runtime.Telemetry.OpenTelemetry.Headers;
136+
configure.Protocol = OtlpExportProtocol.Grpc;
137+
});
138+
139+
})
123140
.WithMetrics(metrics =>
124141
{
125142
metrics.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!))
126-
.AddAspNetCoreInstrumentation()
127-
.AddHttpClientInstrumentation()
128143
.AddOtlpExporter(configure =>
129144
{
130145
configure.Endpoint = new Uri(runtimeConfig.Runtime.Telemetry.OpenTelemetry.Endpoint!);
131146
configure.Headers = runtimeConfig.Runtime.Telemetry.OpenTelemetry.Headers;
132147
configure.Protocol = OtlpExportProtocol.Grpc;
133148
})
134-
.AddRuntimeInstrumentation();
149+
.AddMeter(TelemetryMetricsHelper.MeterName);
135150
})
136151
.WithTracing(tracing =>
137152
{
138153
tracing.SetResourceBuilder(ResourceBuilder.CreateDefault().AddService(runtimeConfig.Runtime.Telemetry.OpenTelemetry.ServiceName!))
139-
.AddAspNetCoreInstrumentation()
140154
.AddHttpClientInstrumentation()
141155
.AddOtlpExporter(configure =>
142156
{
143157
configure.Endpoint = new Uri(runtimeConfig.Runtime.Telemetry.OpenTelemetry.Endpoint!);
144158
configure.Headers = runtimeConfig.Runtime.Telemetry.OpenTelemetry.Headers;
145159
configure.Protocol = OtlpExportProtocol.Grpc;
146-
});
160+
})
161+
.AddSource(TelemetryTracesHelper.DABActivitySource.Name);
147162
});
148163
}
149164

@@ -395,6 +410,7 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RuntimeC
395410
{
396411
// Configure Application Insights Telemetry
397412
ConfigureApplicationInsightsTelemetry(app, runtimeConfig);
413+
ConfigureOpenTelemetry(runtimeConfig);
398414

399415
// Config provided before starting the engine.
400416
isRuntimeReady = PerformOnConfigChangeAsync(app).Result;
@@ -712,6 +728,37 @@ private void ConfigureApplicationInsightsTelemetry(IApplicationBuilder app, Runt
712728
}
713729
}
714730

731+
/// <summary>
732+
/// Configure Open Telemetry based on the loaded runtime configuration. If Open Telemetry
733+
/// is enabled, we can track different events and metrics.
734+
/// </summary>
735+
/// <param name="runtimeConfigurationProvider">The provider used to load runtime configuration.</param>
736+
/// <seealso cref="https://docs.microsoft.com/en-us/azure/azure-monitor/app/asp-net-core#enable-application-insights-telemetry-collection"/>
737+
private void ConfigureOpenTelemetry(RuntimeConfig runtimeConfig)
738+
{
739+
if (runtimeConfig?.Runtime?.Telemetry is not null
740+
&& runtimeConfig.Runtime.Telemetry.OpenTelemetry is not null)
741+
{
742+
OpenTelemetryOptions = runtimeConfig.Runtime.Telemetry.OpenTelemetry;
743+
744+
if (!OpenTelemetryOptions.Enabled)
745+
{
746+
_logger.LogInformation("Open Telemetry are disabled.");
747+
return;
748+
}
749+
750+
if (string.IsNullOrWhiteSpace(OpenTelemetryOptions?.Endpoint))
751+
{
752+
_logger.LogWarning("Logs won't be sent to Open Telemetry because an Open Telemetry connection string is not available in the runtime config.");
753+
return;
754+
}
755+
756+
// Updating Startup Logger to Log from Startup Class.
757+
ILoggerFactory? loggerFactory = Program.GetLoggerFactoryForLogLevel(MinimumLogLevel);
758+
_logger = loggerFactory.CreateLogger<Startup>();
759+
}
760+
}
761+
715762
/// <summary>
716763
/// Sets Static Web Apps EasyAuth as the authentication scheme for the engine.
717764
/// </summary>
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Diagnostics.Metrics;
7+
using System.Net;
8+
using Azure.DataApiBuilder.Config.ObjectModel;
9+
using Kestral = Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Http.HttpMethod;
10+
11+
namespace Azure.DataApiBuilder.Service.Telemetry
12+
{
13+
/// <summary>
14+
/// Helper class for tracking telemetry metrics such as active requests, errors, total requests,
15+
/// and request durations using the .NET Meter and Counter APIs.
16+
/// </summary>
17+
public static class TelemetryMetricsHelper
18+
{
19+
public static readonly string MeterName = "DataApiBuilder.Metrics";
20+
private static readonly Meter _meter = new(MeterName);
21+
private static readonly UpDownCounter<long> _activeRequests = _meter.CreateUpDownCounter<long>("active_requests");
22+
private static readonly Counter<long> _errorCounter = _meter.CreateCounter<long>("total_errors");
23+
private static readonly Counter<long> _totalRequests = _meter.CreateCounter<long>("total_requests");
24+
private static readonly Histogram<double> _requestDuration = _meter.CreateHistogram<double>("request_duration", "ms");
25+
26+
public static void IncrementActiveRequests(ApiType kind) => _activeRequests.Add(1, new KeyValuePair<string, object?>("api_type", kind));
27+
28+
public static void DecrementActiveRequests(ApiType kind) => _activeRequests.Add(-1, new KeyValuePair<string, object?>("api_type", kind));
29+
30+
/// <summary>
31+
/// Tracks a request by incrementing the total requests counter and associating it with metadata.
32+
/// </summary>
33+
/// <param name="method">The HTTP method of the request (e.g., GET, POST).</param>
34+
/// <param name="statusCode">The HTTP status code of the response.</param>
35+
/// <param name="endpoint">The endpoint being accessed.</param>
36+
/// <param name="apiType">The type of API being used (e.g., REST, GraphQL).</param>
37+
public static void TrackRequest(Kestral method, HttpStatusCode statusCode, string endpoint, ApiType apiType)
38+
{
39+
_totalRequests.Add(1,
40+
new("method", method),
41+
new("status_code", statusCode),
42+
new("endpoint", endpoint),
43+
new("api_type", apiType));
44+
}
45+
46+
/// <summary>
47+
/// Tracks an error by incrementing the error counter and associating it with metadata.
48+
/// </summary>
49+
/// <param name="method">The HTTP method of the request (e.g., GET, POST).</param>
50+
/// <param name="statusCode">The HTTP status code of the response.</param>
51+
/// <param name="endpoint">The endpoint being accessed.</param>
52+
/// <param name="apiType">The type of API being used (e.g., REST, GraphQL).</param>
53+
/// <param name="ex">The exception that occurred.</param>
54+
public static void TrackError(Kestral method, HttpStatusCode statusCode, string endpoint, ApiType apiType, Exception ex)
55+
{
56+
_errorCounter.Add(1,
57+
new("method", method),
58+
new("status_code", statusCode),
59+
new("endpoint", endpoint),
60+
new("api_type", apiType),
61+
new("error_type", ex.GetType().Name));
62+
}
63+
64+
/// <summary>
65+
/// Tracks the duration of a request by recording it in a histogram and associating it with metadata.
66+
/// </summary>
67+
/// <param name="method">The HTTP method of the request (e.g., GET, POST).</param>
68+
/// <param name="statusCode">The HTTP status code of the response.</param>
69+
/// <param name="endpoint">The endpoint being accessed.</param>
70+
/// <param name="apiType">The type of API being used (e.g., REST, GraphQL).</param>
71+
/// <param name="duration">The duration of the request in milliseconds.</param>
72+
public static void TrackRequestDuration(Kestral method, HttpStatusCode statusCode, string endpoint, ApiType apiType, TimeSpan duration)
73+
{
74+
_requestDuration.Record(duration.TotalMilliseconds,
75+
new("method", method),
76+
new("status_code", statusCode),
77+
new("endpoint", endpoint),
78+
new("api_type", apiType));
79+
}
80+
}
81+
}

0 commit comments

Comments
 (0)