2020年4月17日 星期五

[ASP.NET Core] Profiling - MiniProfiler


 ASP.NET Core   MiniProfiler  


Introduction


MiniProfiler is a simple but effective mini-profiler for .NET, Ruby, Go and Node.js.
For DOTNET developers, it can watch the performance of codes in .NET, ASP.NET, ASP.NET Core, ASP.NET MVC projects.
This article will show how to use MiniProfiler in a ASP.NET Core + Entity Framework Core application, and watching the performance of http requests.

·         MiniProfiler Github
·         MiniProfiler .NET Document





Environment


ASP.NET Core 3.X (.NET Core SDK 3.1.201)
MiniProfiler 4.1.0


Implement


Intall Packages

First install the Nuget packages:

·         MiniProfiler.AspNetCore.Mvc
·         MiniProfiler.Providers.SqlServer (Optional): To save the profiling logs into Sql Server

Currently (~ 2020-04-17) MiniProfiler supports the following storage providers:


Name
Nuget package
Note
MySQL
-
Redis
-
SQL Server
-
SQL Server CE
-
SQLite
-



Enable Profiling

Update Startup:Configure and Startup: ConfigureService to enable MiniProfiler.

Startup.cs: Configure

public void Configure(IApplicationBuilder appIWebHostEnvironment env)
{       
     app.UseMiniProfiler();

     // … skip            
}


Startup.cs: ConfigureService


services.AddMiniProfiler(options =>
{
                options.RouteBasePath = "/profiler";

                #region Storage

                // (Optional) Control storage
                // (default is 30 minutes in MemoryCacheStorage)
                (options.Storage as MemoryCacheStorage).CacheDuration = TimeSpan.FromMinutes(60);
                #endregion

                #region Styles

                // Default: left
                options.PopupRenderPosition = RenderPosition.BottomLeft;

                // Default: 15
                options.PopupMaxTracesToShow = 10;

                // (Optional) Control storage
                // (default is 30 minutes in MemoryCacheStorage)
                (options.Storage as MemoryCacheStorage).CacheDuration = TimeSpan.FromMinutes(60);

                // (Optional) Control which SQL formatter to use, InlineFormatter is the default
                options.SqlFormatter = new StackExchange.Profiling.SqlFormatters.InlineFormatter();

                #endregion

                #region Include/Exclude tracking

                // (Optional) You can disable "Connection Open()", "Connection Close()" (and async variant) tracking.
                // (defaults to true, and connection opening/closing is tracked)
                options.TrackConnectionOpenClose = false;

                // Ignore tracing any class named "MyClass"
                options.ExcludeType("MyClass");
                // options.ExcludedTypes.Add("MyClass");

                // Ignore tracing the assembly named "AspNetCore.Profiler.Core"
                options.ExcludeAssembly("AspNetCore.Profiler.Core");
                // options.ExcludedAssemblies.Add("AspNetCore.Profiler.Core");

                // Ignore tracing the method(s) named "IgnoreMe"
                options.ExcludeMethod("IgnoreMe");
                // options.ExcludedMethods.Add("IgnoreMe");

                // Ignore tracing the request with the url path
                options.IgnorePath("/Home");

                #endregion

                #region Authorization

                // (Optional)To control authorization, you can use the Func<HttpRequest, bool> options:
                // (default is everyone can access profilers)
                options.ResultsAuthorize = request => MyCheckIfCanAccessMiniProfiler(request);
                options.ResultsListAuthorize = request => MyCheckIfCanAccessMiniProfiler(request);

                #endregion
})
.AddEntityFramework(); // Enable Entity Framework tracking



Then we can see the profiling information in these ways:


Url
Description
1
https://<host>/profiler/results
Profiling result for the latest request
2
https://<host>/profiler/results-index
Profiling results for stored requests
3
https://<host>/profiler/results-list
Profiling results in JSON for stored requests



Latest request profiling: https://localhost:5011/profiler/results




All stored requests’ profiling information in web page: https://localhost:5011/profiler/results-index

PS. We can click on the link on the Name field to see the details.





The detail will be show as below with the url like https://localhost:5011/profiler/results?id=1b842295-5d46-41db-aeca-8151dcd8abc8






All stored requests’ profiling information in JSON: https://localhost:5011/profiler/results-list






Enable showing real-time profiling result on MVC view

To show the embedded profiling result on the MVC page as following, there are some additional steps.





Add Tag helper

Open _ViewImports.cshtml and add MiniProfiler’s tag helper,


@using AspNetCore.Profiler.Mvc
@using AspNetCore.Profiler.Mvc.Models
@using StackExchange.Profiling
@addTagHelper *, MiniProfiler.AspNetCore.Mvc
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers






Add MiniProfiler tag to your master layout


Open the master layout (Views/Shared/_Layout.cshtml in default), and add <mini-profiler /> inside.



<body>
    <!-- ... skip other HTML -->

    <mini-profiler />
</body>

For more options on the <mini-profiler> tag, see source code.



Now we will be able to see the real-time profiling result on every page.





Styles

Here are some of the options for styling:


PopupRenderPosition

options.PopupRenderPosition = RenderPosition.BottomLeft// Left|Right|BottomLeft|BottomRight

When setting it to RenderPosition.BottomLeft:





When setting it to RenderPosition.Left:






SqlFormatter



When setting it to InlineFormatter:






When setting it to SqlServerFormatter:






Profiling/Tracking

Database connection

Disable tracking Database connection open/close by

// (Optional) You can disable "Connection Open()", "Connection Close()" (and async variant) tracking.
// (defaults to true, and connection opening/closing is tracked)
options.TrackConnectionOpenClose = false;

The default value is “true”, that will result in showing the following information (in red block):







Exclude Type/Assembly/Method

Furthermore, we can exclude tracking specified Type/Assembly/Method by setting ExcludeType("MyClass") / ExcludeAssembly("MyAssembly")
 / ExcludeMethod("MyMethod") or use ExcludedTypes.Add("MyClass") / ExcludedAssemblies.Add("MyAssembly") / ExcludedMethods.Add("MyMethod").

For example,

options.ExcludeType("MyClass");

options.ExcludedTypes.Add("MyClass");


We can chain the exclude methods like following,

options.ExcludeType("MyClass").ExcludeAssembly("MyAssembly");



Ignore path


// Ignore tracing the request with the url path
options.IgnorePath("/Home");


Which will result not tracking the request to

https://myserver/home/index
https://myserver/home/query
https://myserver/home/....



Storage

The profiling logs are stored in Memory cache (see source code) in default.
We can save them into database as the persistent storage.

I will take storing to Microsoft SQL Server as example.
First, we have to create the MiniProfiler’s tables in the database.


How to create required tables for MiniProfiler


There is the “GetTableCreationScripts” method inside MiniProfiler’s source code: dotnet/src/MiniProfiler.Providers.SqlServer/SqlServerStorage.cs, that has the creation SQL script.





You can just copy the full sql below and execute in your database to create them.
PS. Watch out the changes for newer MiniProfiler, I am using MiniProfiler.Providers.SqlServer 4.1.0 when writing the code.


CREATE TABLE {MiniProfilersTable}
(
    RowId                                integer not null identity constraint PK_{MiniProfilersTable} primary key clustered, -- Need a clustered primary key for SQL Azure
    Id                                   uniqueidentifier not null-- don't cluster on a guid
    RootTimingId                         uniqueidentifier null,
    Name                                 nvarchar(200null,
    Started                              datetime not null,
    DurationMilliseconds                 decimal(15,1not null,
    [User]                               nvarchar(100null,
    HasUserViewed                        bit not null,
    MachineName                          nvarchar(100null,
    CustomLinksJson                      nvarchar(max),
    ClientTimingsRedirectCount           int null
);
-- displaying results selects everything based on the main MiniProfilers.Id column
CREATE UNIQUE NONCLUSTERED INDEX IX_{MiniProfilersTable}_Id ON {MiniProfilersTable} (Id);
                
-- speeds up a query that is called on every .Stop()
CREATE NONCLUSTERED INDEX IX_{MiniProfilersTable}_User_HasUserViewed_Includes ON {MiniProfilersTable} ([User], HasUserViewed) INCLUDE (Id, [Started]); 
CREATE TABLE {MiniProfilerTimingsTable}
(
    RowId                               integer not null identity constraint PK_{MiniProfilerTimingsTable} primary key clustered,
    Id                                  uniqueidentifier not null,
    MiniProfilerId                      uniqueidentifier not null,
    ParentTimingId                      uniqueidentifier null,
    Name                                nvarchar(200not null,
    DurationMilliseconds                decimal(15,3not null,
    StartMilliseconds                   decimal(15,3not null,
    IsRoot                              bit not null,
    Depth                               smallint not null,
    CustomTimingsJson                   nvarchar(maxnull
);
CREATE UNIQUE NONCLUSTERED INDEX IX_{MiniProfilerTimingsTable}_Id ON {MiniProfilerTimingsTable} (Id);
CREATE NONCLUSTERED INDEX IX_{MiniProfilerTimingsTable}_MiniProfilerId ON {MiniProfilerTimingsTable} (MiniProfilerId);
CREATE TABLE {MiniProfilerClientTimingsTable}
(
    RowId                               integer not null identity constraint PK_{MiniProfilerClientTimingsTable} primary key clustered,
    Id                                  uniqueidentifier not null,
    MiniProfilerId                      uniqueidentifier not null,
    Name                                nvarchar(200not null,
    Start                               decimal(93not null,
    Duration                            decimal(93not null
);
CREATE UNIQUE NONCLUSTERED INDEX IX_{MiniProfilerClientTimingsTable}_Id on {MiniProfilerClientTimingsTable} (Id);
CREATE NONCLUSTERED INDEX IX_{MiniProfilerClientTimingsTable}_MiniProfilerId on {MiniProfilerClientTimingsTable} (MiniProfilerId);             
";

 
Or we can do a little trick to use the GetTableCreationScripts method from StackExchange.Profiling.Storage.SqlServerStorage.
First lets create a class to inherit class: SqlServerStorage, and expose the creation SQL script:

public class CustomSqlServerStorage : SqlServerStorage
{
        public CustomSqlServerStorage(string connectionString) : base(connectionString)
        {

        }

        public IEnumerable<stringCreateSqls
        {
            get {
                return base.GetTableCreationScripts();
            }
        }
}


Then we can create the tables after startup, open Program.cs and update as following,

public class Program
    {
        public static void Main(string[] args)
        {
            var host = CreateHostBuilder(args).Build();

            using (var scope = host.Services.CreateScope())
            {
                var services = scope.ServiceProvider;

                // Create MiniProfiler's profiling table
                var configuration = services.GetRequiredService<IConfiguration>();
                var connectionString = configuration.GetConnectionString("DefaultConnection");
                var dbContext = services.GetRequiredService<DemoDbContext>() as DemoDbContext;

                var tableQueryRslt = dbContext.Payments.FromSqlRaw(
                    "SELECT NEWID() as Id FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = 'dbo' AND  TABLE_NAME = 'MiniProfilers'");

                var isExist = tableQueryRslt.Count() > 0;
                if (!isExist)
                {
                    using (var sqlserverStorage = new CustomSqlServerStorage(connectionString))
                    {
                        IEnumerable<stringcreateSqls = sqlserverStorage.CreateSqls;
                        foreach (string sql in createSqls)
                        {
                            _ = dbContext.Database.ExecuteSqlRaw(sql);
                        }
                    }
                }
            }

            host.Run();
        }

        public static IHostBuilder CreateHostBuilder(string[] args) =>
            Host.CreateDefaultBuilder(args)
                .ConfigureWebHostDefaults(webBuilder =>
                {
                    webBuilder.UseStartup<Startup>();
                });
    }



Either of the ways will create the following tables in SQL Server:







Enable SQL Server Storage

After creating the tables, enable SQL Server Storage in the MiniProfiler options,

services.AddMiniProfiler(options =>
{
        // … skip other options

        options.Storage = new SqlServerStorage(Configuration.GetConnectionString("DefaultConnection"));
}



Result:






Custom authorization

The profiling result contains sensitive information, such as Stacktrace, SQL and etc. It’s better to allow only authorized users to access the profiling result.

MiniProfiler has 2 options to set the authorization callbacks that will check the requests:

·         ResultsAuthorize: which will restrict accessing to the single profiling result
·         ResultsListAuthorize: which will restrict accessing to the profiling results(list)


Notice that if we enable the MiniProfiler tag on the master page, the ResultsAuthorize’s callback will be triggered not only on the MiniProfiler’s single result page (i.e. https://<host>/profiler/results/[?id=xxxx]) when viewing any page.



services.AddMiniProfiler(options =>
{
                options.RouteBasePath = "/profiler";

                // … skip other settings
               
                // (Optional)To control authorization, you can use the Func<HttpRequest, bool> options:
                // (default is everyone can access profilers)
                options.ResultsAuthorize = request => MyCheckIfCanAccessMiniProfiler(request);
                options.ResultsListAuthorize = request => MyCheckIfCanAccessMiniProfiler(request);

})
.AddEntityFramework(); // Enable Entity Framework tracking




Here is an example that I use JWT to authenticate the request from users.

1.  Store the JWT in cookie to have it validated when viewing profiling result page in browser.
2.  Validate the Authorization header (Bearer token) when trying to send the request without browser (such as thru RESTful API).


Create a custom Authorization Callback


I created the Authorization callback as HttpRequestExtensions an can be set like this:

services.AddMiniProfiler(options =>
{
                options.RouteBasePath = "/profiler";

                // … skip other settings

                // (Optional)To control authorization, you can use the Func<HttpRequest, bool> options:
                // (default is everyone can access profilers)
                options.ResultsAuthorize = request => request.IsAuthorizedToMiniProfiler();
                options.ResultsListAuthorize = request => request.IsAuthorizedToMiniProfiler();
})
.AddEntityFramework(); // Enable Entity Framework tracking




The HttpRequestExtensions implementation:

public static class HttpRequestExtensions
{
        /// <summary>
        /// Check if the request is authorized to access MiniProfiler
        /// </summary>
        /// <param name="request">HttpRequest</param>
        /// <returns>True(OK)/False(Not allowed)</returns>
        public static bool IsAuthorizedToMiniProfiler(this HttpRequest request)
        {
            if (!request.Path.ToString().StartsWith("/profiler"StringComparison.InvariantCultureIgnoreCase))
            {
                return true;
            }

            var bearerTokenPrefix = "Bearer";
            var accessToken = string.Empty;
            
            var authorizationHeader = request.Headers["Authorization"].ToString();

            // Get Access token from header
            if (!string.IsNullOrEmpty(authorizationHeader) &&
                authorizationHeader.StartsWith(bearerTokenPrefixStringComparison.OrdinalIgnoreCase))
            {
                accessToken = authorizationHeader.Replace(bearerTokenPrefixstring.EmptyStringComparison.OrdinalIgnoreCase).Trim();
            }
            // Get Access token from cookie
            else if (request.Cookies.TryGetValue("access-token"out accessToken))
            { 
            }
            else
            {
                return false;
            }

            // Validate JWT
            return AccessTokenValidator.ValidateAsync(accessToken).Result;
        }
}

Key points:

1.  Only validating the request when it is trying to access MiniProfiler’s path: “/profile/…”
2.  Validate the access token (JWT) that comes from cookie or Http header.




Demo

Now the MiniProfiler can only be accessed on valid access token (JWT).

If there is a valid access token:





Or we will get a 401 Unauthorized response as following:






And if we send a request with Authorization header and valid access token, we will get a success response:





Or 401 Unauthorized response without access token or invalid token:








Make sure that you had protected the profiling information when rocking on production!




Reference









沒有留言:

張貼留言