监控系统简介(二):使用 App Metrics 在 ASP.NET Web API 中记录指标
回顧
在《監控系統簡介:使用 Prometheus 與 Grafana》一文中,我們了解了什么是監控系統,Prometheus 這一監控工具及它提供的數據類型、PromQL 以及 Grafana 可視化工具的基本用法。今天這一篇我們將在 ASP.NET Web API 項目中進行實戰,將 Web API 接口的請求次數、響應耗時、錯誤率等指標記錄下來,并提供給 Prometheus 和 Grafana,用于分析和呈現。
我們主要采用一個名為 App Metrics 的類庫記錄指標。App Metrics 是以 Apache v2 協議開源的一款類庫,支持 .NET Framework 4.5.2 以上,以及 .NET Core 的應用程序。除了記錄各種程序生成的指標,它還提供健康檢查的功能,但這不在本文的范圍內。
為什么沒有使用 Prometheus 推薦的 .NET 類庫,主要是因為 App Metrics 在 GitHub 的 star 比較多,另外 API 用起來比較順手而已……
本文示例代碼已提交至 Github https://github.com/huhubun/AppMetricsPrometheusSample 歡迎一同討論。
在 ASP.NET Web API 中記錄指標
因為還有一些項目在 .NET Framework 下,所以先以 .NET Framework 的 ASP.NET Web API 開始,通過 Visual Studio 創建“ASP.NET Web 應用程序(.NET Framework)”,框架版本高于或等于 .NET Framework 4.5.2 即可,然后選擇 “Web API”。
首先,通過 nuget,將 App Metrics 添加至項目中
Install-Package App.Metrics Install-Package App.Metrics.Formatters.PrometheusApp Metrics 支持各種各樣的監控系統或時序數據庫。因為我們最終要將數據提供給 Prometheus,所以除了 App Metrics 的包外,還需要安裝一個用于格式化數據的包?App.Metrics.Formatters.Prometheus。
由于這是一個新建的項目,簡單起見這里創建一個名為?ApiMetrics?的類,保證 Web API 整個生命周期中只初始化一次 App Metrics。如果項目中有依賴注入容器(例如 AutoFac),則直接將?IMetricsRoot?注冊為單例即可(通過?InitAppMetrics()?的代碼來創建)。
public class ApiMetrics {private static IMetricsRoot _metrics;public static IMetricsRoot GetMetrics(){if (_metrics == null){_metrics = InitAppMetrics();}return _metrics;}private static IMetricsRoot InitAppMetrics(){var metrics = new MetricsBuilder().Configuration.Configure(options =>{options.DefaultContextLabel = "API";options.AddAppTag(Assembly.GetExecutingAssembly().GetName().Name);options.AddServerTag(Environment.MachineName);#if DEBUGoptions.AddEnvTag("Dev"); #elseoptions.AddEnvTag("Release"); #endifoptions.GlobalTags.Add("my_custom_tag", "MyCustomValue");}).Build();return metrics;} }DefaultContextLabel?的值會成為指標的前綴,這里設置成?API,則默認所有指標都為?api_?開頭
AddAppTag()?會為所有指標添加一個名為?app?的 tag,內容為當前程序的名稱
AddServerTag()?會為所有指標添加一個名為?server?的 tag,內容是運行程序的機器名稱
AddEnvTag()?會為所有指標添加一個名為?env?的 tag,用于區分運行程序的環境
也可以通過?GlobalTags?屬性,來添加自定義的 tag
因為沒有依賴注入容器,還需要在?Global.asax?的?Application_Start()?中手動調用一下?GetMetrics()?方法以完成初始化。
protected void Application_Start() {// 省略其他內容ApiMetrics.GetMetrics(); }記錄程序啟動時間
我們把程序啟動的時間作為一項指標,在 Grafana 中就能顯示出程序已經運行了多長時間。Prometheus 通過?time()?能得到當前時間的 unix 時間戳,所以我們只需要將程序啟動時的時間以 unix 時間戳的方式記錄下來即可。
在?Application_Start()?中,當一切準備就緒后通過 App Metrics 創建一個 Gauge:
var metrics = ApiMetrics.GetMetrics(); // 如果有依賴注入容器,請替換為注入 IMetricsRoot 的代碼var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();metrics.Measure.Gauge.SetValue(new GaugeOptions{Name = "Boot Time Seconds"}, unixTimestamp);通過 App Metrics 的?Measure?屬性可以找到?Gauge?屬性,然后通過?SetValue()?方法即可記錄指標。指標的各種設置(例如名稱)通過參數傳入。指標名稱?Name?我習慣按可讀性高的方式來寫,因為 App Metrics 的 Prometheus 格式化器會自動幫我們處理它,后文會說明。
另外,雖然我們創建的是 Gauge,但對于啟動時間而言,除了這時的賦值外,這個指標的值是不會改變的。
添加 /metrics 終結點
現在我們已經有一個內容為程序啟動時間的指標了,還缺少一個能讓 Prometheus 抓取指標數據的地方。因為這是一個 Web API 項目,很簡單來創建一個 Web API 控制器?MetricsController:
[RoutePrefix("metrics")]public class MetricsController : ApiController{[HttpGet][Route("")]public async Task<HttpResponseMessage> GetMetricsAsync(){var formatter = new App.Metrics.Formatters.Prometheus.MetricsPrometheusTextOutputFormatter();var snapshot = ApiMetrics.GetMetrics().Snapshot.Get();using (var ms = new MemoryStream()){await formatter.WriteAsync(ms, snapshot);var result = Encoding.UTF8.GetString(ms.ToArray());var response = Request.CreateResponse(HttpStatusCode.OK);response.Content = new StringContent(result, Encoding.UTF8, formatter.MediaType.ContentType);return response;}}}現在啟動程序,訪問?localhost:端口/metrics?就能看到類似這樣的效果:
# HELP api_boot_time_seconds # TYPE api_boot_time_seconds gauge api_boot_time_seconds{app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustonValue"} 1580913792App Metrics 的指標類型及轉換
由于 App Metrics 的指標類型與 Prometheus 的并不是一一對應的,我們先看看 App Metrics 中提供的類型有哪些:
Apdex 應用性能指數評分,它的含義可以參考?《應用性能指標apdex》?https://www.cnblogs.com/tetu/p/4968666.html
Counter 計數器
Gauge gauge
Histogram 直方圖
Meter 一個可增減的計數器,一般用于統計次數和速率
Timer 計時器,根據統計的時間,自動進行分組
可以看到,Apdex、Meter?和?Timer?是 Prometheus 中沒有的。通過?App.Metrics.Formatters.Prometheus?可以轉換成 Prometheus 的指標:
Apdex -> Gauge
Counter -> Counter
Gauge -> Gauge
Histogram -> Histogram
Meter -> Counter,用起來和 Counter 好像也沒什么區別…
Timer -> Summary,會自動幫我們計算好 0.5、0.75、0.95、0.99 的分位數
還需要提到的是,通過 App Metrics Prometheus 格式化器,指標的名稱也會發生變化,指標名稱?Boot Time Seconds?會被轉換為?api_boot_time_seconds,空格會自動變為下劃線,大寫也會被轉為小寫。所以代碼中可以按習慣的方式編寫,只要統一即可。
App Metrics 的 API
在?IMetricsRoot?下,我們常用的有這兩個屬性:
Measure
Provider
通過?Measure?和?Provider?屬性都可以訪問到所有的指標類型,仔細觀察可以發現, 通過?Measure?操作指標,方法返回的都是?XXXContext?或者?void,而?Provider?返回的都是?IXXX,來看看方法的定義:
void IMetricsRoot.Measure.Counter.Increment(CounterOptions options, long amount),只能通過參數列表直接傳入值
ICounter IMetricsRoot.Provider.Counter.Instance(CounterOptions options),可以對該計數器執行?Increment()?增加值、Decrement()?減少值、Reset()?重置等操作(當然,Prometheus 的計數器應該是只增不減的,但因為 App Metrics 并不是專為 Prometheus 設計,所以它的 API 可以這樣操作也是可以理解的)
總的來說,區別在于?Measure?中的 API 相當于去測量某些指標,而?Provider?的 API 可以直接為指標賦值。通過 Timer 來看更為明顯:
void IMetricsRoot.Measure.Timer.Time(TimerOptions options, Action action)?要求將要統計時間的操作,直接在 Action 中執行,這個 API 會自動開始計時,當 Action 執行完畢后停止計時
TimerContext IMetricsRoot.Measure.Timer.Time(TimerOptions options)?當創建?TimerContext?后開始計時,通過?TimerContext?提供的?Dispose()?方法來停止計時
ITimer IMetricsRoot.Provider.Timer.Instance(TimerOptions options)?通過?Record()?直接設置時間,另外也有?StartRecording()、EndRecording()?等方法手動開始和停止計時
記錄 API 響應耗時和請求次數
在 Web API 中,可以通過消息處理程序在請求進入控制器之前,以及響應被生成后,執行一些操作。我們可以通過一個計時器,在收到請求時計時,處理完請求后停止計時的方式,統計一次 HTTP 請求所需要的時間。
確定計時的方案后,需要確定維度。對于 API 的響應耗時,我們應該關注 API 的請求方式(GET、POST、PUT、DELETE等)、API 的路由(/api/values、/api/values/{id}等)、響應狀態碼這些信息。所以需要在指標中,體現出這幾個標簽。
最后確認使用何種數據類型。App Metrics 提供了 Timer 類型,能自動生成 0.5、0.99 等分位數,并且轉換為 Prometheus 后,它是 summary 類型,意味著還會產生?XXX_sum?和?XXX_count?兩個指標。通過?XXX_count?,我們順便還能把請求次數給計算出來。
新建一個?MetricsHandler?類,代碼如下:
public class MetricsHandler : DelegatingHandler{private const string API_METRICS_RESPONSE_TIME_KEY = "__ApiMetrics.ResponseTime__";private const string API_METRICS_ROUTE = "metrics";protected async override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken){var routeTemplate = GetRouteTemplate(request);// 如果訪問的是 /metrics ,則不計入統計中if (routeTemplate == API_METRICS_ROUTE){return await base.SendAsync(request, cancellationToken);}StartRecordingResponseTime(request);var response = await base.SendAsync(request, cancellationToken);EndRecordingResponseTime(routeTemplate, request, response);return response;}private string GetRouteTemplate(HttpRequestMessage request){// MS_SubRoutes 適用于 Route Attribute 的情況request.GetRouteData().Values.TryGetValue("MS_SubRoutes", out var routes);return (routes as System.Web.Http.Routing.IHttpRouteData[])?.FirstOrDefault()?.Route?.RouteTemplate ?? "unknown";}#region Response Time/// <summary>/// 開始記錄響應時間/// </summary>/// <param name="request"></param>/// <param name="routeTemplate"></param>private void StartRecordingResponseTime(HttpRequestMessage request){var stopwatch = new Stopwatch();stopwatch.Start();request.Properties.Add(API_METRICS_RESPONSE_TIME_KEY, stopwatch);}/// <summary>/// 停止記錄響應時間/// </summary>/// <param name="response"></param>private void EndRecordingResponseTime(string routeTemplate, HttpRequestMessage request, HttpResponseMessage response){var stopwatch = response.RequestMessage.Properties[API_METRICS_RESPONSE_TIME_KEY] as Stopwatch;ApiMetrics.GetMetrics().Provider.Timer.Instance(new TimerOptions{Name = "Response Time",Tags = new MetricTags(new string[] { "method", "route", "status" },new string[] { request.Method.Method, routeTemplate, ((int)response.StatusCode).ToString() }),DurationUnit = TimeUnit.Milliseconds,RateUnit = TimeUnit.Milliseconds,MeasurementUnit = Unit.Requests}).Record(stopwatch.ElapsedMilliseconds, TimeUnit.Milliseconds);response.RequestMessage.Properties.Remove(API_METRICS_RESPONSE_TIME_KEY);}#endregion}MetricsHandler?的原理是:
請求進入后,首先觸發?StartRecordingResponseTime()?方法,該方法創建了一個?Stopwatch?并開始計時,同時將?Stopwatch?儲存在當前請求的緩存中
等待?await base.SendAsync()?完成,這會執行其它的 Handler、Filter 以及 Action 中的內容,這里執行完成意味著所有的操作都已經完成,并且響應體也已經生成
觸發?EndRecordingResponseTime()?停止計時,并將記錄的時間直接儲存到 App Metrics 的 Timer 類型的?Response Time?指標中
需要注意的是,GetRouteTemplate()?方法通過?MS_SubRoutes?獲取路由的方式僅適用于使用特性路由的方式,根據需要可以使用不同的獲取路由的方式。
為了使?MetricsHandler?能正常工作,首先修改默認生成的?ValuesController,將其修改為使用特性路由的方式注冊路由:
[RoutePrefix("api/values")]public class ValuesController : ApiController{// GET api/values[HttpGet, Route("")]public IEnumerable<string> Get(){return new string[] { "value1", "value2" };}// GET api/values/5[HttpGet, Route("{id:int}")]public string Get([FromUri]int id){return "value" + id;}// POST api/values[HttpPost, Route("")]public void Post([FromBody]string value){}// PUT api/values/5[HttpPut, Route("{id:int}")]public void Put([FromUri]int id, [FromBody]string value){}// DELETE api/values/5[HttpDelete, Route("{id:int}")]public void Delete([FromUri]int id){}}接著修改?WebApiConfig?的?Register()?,將?config.Routes.MapHttpRoute()?路由模板注釋掉,然后注冊?MetricsHandler。現在?Register()?看起來類似這樣:
public static void Register(HttpConfiguration config){config.MapHttpAttributeRoutes();// 注釋掉這部分代碼//config.Routes.MapHttpRoute(// name: "DefaultApi",// routeTemplate: "api/{controller}/{id}",// defaults: new { id = RouteParameter.Optional }//);// Metrics Handlerconfig.MessageHandlers.Add(new MetricsHandler());}完成后我們啟動程序,先通過瀏覽器或者 Postman 隨意訪問幾個接口,例如?localhost:端口/api/values?,之后再訪問 /metrics,就能看到我們新增的?api_response_time?指標了:
# HELP api_response_time # TYPE api_response_time summary api_response_time_sum{method="GET",route="api/values",status="200",app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustomValue"} 0.158 api_response_time_count{method="GET",route="api/values",status="200",app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustomValue"} 1 api_response_time{method="GET",route="api/values",status="200",app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustomValue",quantile="0.5"} 0.158 api_response_time{method="GET",route="api/values",status="200",app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustomValue",quantile="0.75"} 0.158 api_response_time{method="GET",route="api/values",status="200",app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustomValue",quantile="0.95"} 0.158 api_response_time{method="GET",route="api/values",status="200",app="WebAPISample",server="BUNPC",env="Dev",my_custom_tag="MyCustomValue",quantile="0.99"} 0.158雖然我們的例子是基于 .NET Framework 的,但其實對于 .NET Core 而言也是類似。App Metrics 的 API 是一致的,?MetricsHandler?由?Middleware?實現即可,這里就不展開說了。
通過 Prometheus 分析
Prometheus 的配置參考上一篇文章,這里直接通過 PromQL 來查詢,默認地址為 http://localhost:9090/ 打開 Graph 頁面。
計算每個接口總請求數量,因為?api_response_time_count?中包含響應狀態,同一個 method 和 route 有時可能返回 200,有時可能返回 400,所以我們需要根據 method 和 route 進行分組再求和:
sum by (method, route)(api_response_time_count)還可以統計1分鐘內的錯誤率,我們對“錯誤”的定義為所有非 2XX 的響應,所以非 2 開頭的 status 都屬于錯誤:
sum(rate(api_response_time_count{status!~'2.*'}[1m]))請注意,一定要先?rate()?再?sum(),參考文章?Rate then sum, never sum then rate?https://www.robustperception.io/rate-then-sum-never-sum-then-rate
統計每個接口 95% 情況下的響應時間
api_response_time{quantile='0.95'}與 Grafana 圖表結合的例子,可以參考本文 demo 的?https://github.com/huhubun/AppMetricsPrometheusSample
鏈接
App Metrics 官方網站?https://www.app-metrics.io/
總結
以上是生活随笔為你收集整理的监控系统简介(二):使用 App Metrics 在 ASP.NET Web API 中记录指标的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 祝福!微软 46 周年生日快乐!
- 下一篇: .NET 开源配置组件 AgileCon