ASP.NET MVC日志报告仪表板






4.94/5 (60投票s)
为Log4Net, NLog, ELMAH 和 ASP.NET Health Monitoring 提供日志报告仪表板。
目录
引言
日志记录是每个生产网站都应具备的最有用的服务之一。
当您的网站出现错误时,您应该收到通知。虽然您可能认为自己编写了完美的代码并尽最大努力进行了单元测试,但错误仍然会发生。数据库服务器可能会宕机,第三方网站可能离线,您的网站所在的共享托管环境可能出现中断,之前未被发现的 bug 可能出现,等等。
拥有一个出色的日志记录系统可以让您及时了解发生的错误。
在本文中,我们将把Log4Net, NLog, Elmah 和 ASP.NET Health Monitoring 集成到一个ASP.NET MVC 2.0 网站中,并提供一个日志报告仪表板,允许我们按时间段、日志级别和日志提供商过滤消息。
乍一看,您可能会问,为什么要在网站上实施所有这四种工具来记录日志?这是一个公平的问题。答案是您不需要。
ELMAH 被选中是因为它只专注于一件事——记录和通知未处理的异常,并且它做得非常好。但它不是为通用日志记录目的而设计的——这正是Log4Net 和NLog 的用武之地。
在大多数情况下,Log4Net 和NLog 是等效的,所以您通常只需要一个。当然,如果您愿意,也可以使用 Enterprise Services、另一个日志记录框架,或者您自己的自定义日志记录器。
健康监视呢?它可以记录其他工具未涵盖的许多信息。它真的有必要吗?这取决于您,但如果您处于共享托管环境或您的网站位于不受您控制的服务器上,它可能是在诊断生产问题时一个有用的调试工具。
以下是日志报告仪表板的快速预览

示例项目还为仪表板增加了图表和RSS feed。
背景
Rob Conery 在CodePlex 网站上创建了一个精彩的 MVC 入门项目。在他的入门项目中,他包含了一个日志记录接口,并选择 NLog 作为他的日志记录器。
本文扩展了Rob 的入门项目中的日志记录功能,增加了Log4Net、ELMAH 和Health Monitoring 的额外日志记录器(NLog 的日志记录器也保留了下来,并进行了一些小的添加),并提供了一个 UI 来查看和过滤所有或任意日志提供商的日志消息。
在一个项目中同时使用 NLog 和 Log4Net 的可能性很小,因为它们都执行类似的任务,但如果您使用的是也使用其中一种的第三方程序集,那么您可以在您的 web.config 文件中快速配置您的网站以使用相同的日志提供商。
Rob 的 MVC 入门项目包含的内容远不止日志记录,所以也一定要看看。(本文底部有链接。)
为MVC设置和配置ELMAH
ELMAH 是一个跟踪项目中未处理异常的优秀工具。我不会详细介绍 ELMAH 的信息,因为已经有很多相关信息了。如果您不熟悉它,请查看本文底部的链接。
对于普通的 ASP.NET Web Forms 项目,配置 ELMAH 有两个非常简单的步骤
- 下载并添加 ELMAH 程序集的引用
- 修改您的 web.config 文件
……就这样!很简单!
然而,对于 ASP.NET MVC 网站,还需要进行三个额外的步骤
- 实现自定义异常属性
- 实现自定义操作调用器
- 实现自定义控制器工厂(可选,但强烈推荐)
自定义错误属性
以下两个代码片段取自 Stack Overflow 上的这个问题
//From http://stackoverflow.com/questions/766610/
public class HandleErrorWithELMAHAttribute : HandleErrorAttribute
{
    public override void OnException(ExceptionContext context)
    {
        base.OnException(context);
        var e = context.Exception;
        if (!context.ExceptionHandled   // if unhandled, will be logged anyhow
                || RaiseErrorSignal(e)      // prefer signaling, if possible
                || IsFiltered(context))     // filtered?
            return;
        LogException(e);
    }
    private static bool RaiseErrorSignal(Exception e)
    {
        var context = HttpContext.Current;
        if (context == null)
            return false;
        var signal = ErrorSignal.FromContext(context);
        if (signal == null)
            return false;
        signal.Raise(e, context);
        return true;
    }
    private static bool IsFiltered(ExceptionContext context)
    {
        var config = context.HttpContext.GetSection("elmah/errorFilter")
                                 as ErrorFilterConfiguration;
        if (config == null)
            return false;
        var testContext = new ErrorFilterModule.AssertionHelperContext(
                              context.Exception, HttpContext.Current);
        return config.Assertion.Test(testContext);
    }
    private static void LogException(Exception e)
    {
        var context = HttpContext.Current;
        ErrorLog.GetDefault(context).Log(new Error(e, context));            
    }
}
自定义操作过滤器
/// <summary>
/// This class allows an Exception filter to be injected when an MVC action is invoked
/// </summary>
public class ErrorHandlingActionInvoker : ControllerActionInvoker
{
    private readonly IExceptionFilter filter;
    /// <summary>
    /// Constructor
    /// </summary>
    /// <param name="filter">The exception filter to inject</param>
    public ErrorHandlingActionInvoker(IExceptionFilter filter)
    {
        if (filter == null)
        {
            throw new ArgumentNullException("filter");
        }
        this.filter = filter;
    }
    /// <summary>
    /// This methods returns all of the normal filters used
    /// PLUS it appends our custom filter to the end of the list 
    /// </summary>
    /// <param name="controllerContext">The context of the controller</param>
    /// <param name="actionDescriptor">The action descriptor</param>
    /// <returns>All of the action filters</returns>
    protected override FilterInfo GetFilters(
        ControllerContext controllerContext,
        ActionDescriptor actionDescriptor)
    {
        var filterInfo =
            base.GetFilters(controllerContext,
            actionDescriptor);
        filterInfo.ExceptionFilters.Add(this.filter);
        return filterInfo;
    }
}
自定义控制器工厂
以下代码片段取自 Rajan 的博客文章
/// <summary>
/// This custom controller factory injects a custom attribute 
/// on every action that is invoked by the controller
/// </summary>
public class ErrorHandlingControllerFactory : DefaultControllerFactory
{
    /// <summary>
    /// Injects a custom attribute 
    /// on every action that is invoked by the controller
    /// </summary>
    /// <param name="requestContext">The request context</param>
    /// <param name="controllerName">The name of the controller</param>
    /// <returns>An instance of a controller</returns>
    public override IController CreateController(
        RequestContext requestContext,
        string controllerName)
    {
        var controller =
            base.CreateController(requestContext,
            controllerName);
        var c = controller as Controller;
        if (c != null)
        {
            c.ActionInvoker =
                new ErrorHandlingActionInvoker(
                    new HandleErrorWithELMAHAttribute());
        }
        return controller;
    }
}
最后一步是在您的 global.asax.cs 文件中的 application_start 事件中添加以下内容,以便 MVC 知道使用新的自定义控制器工厂
// Setup our custom controller factory so that the [HandleErrorWithElmah] attribute
// is automatically injected into all of the controllers
ControllerBuilder.Current.SetControllerFactory(new ErrorHandlingControllerFactory());
设置和配置健康监视
点击 关于健康监视的官方文档 并按照说明进行操作,以设置并运行健康监视。
这很简单,所以我不会在本文中重复。您可以下载相关代码了解更多信息,或者在我的博客系列中查看更详细的说明,如果您遇到困难(请参阅底部的链接)。
ASP.NET 健康监视记录各种不同类型的信息,但无法区分消息仅用于信息目的还是需要关注的错误消息。因此,为了解决这个问题,让我们创建一个名为“aspnet_WebEvent_ErrorCodes”的新表,并引入一个名为“Level”的列,将每个消息事件代码映射到“Info”或“Error”。
这样做的原因是,我们为所有日志提供商拥有一个通用的“Level”属性,这将使我们以后能够按日志级别过滤所有消息。例如,ELMAH 只用于记录未处理的异常,因此我们的 ELMAH 消息的日志级别将始终是“Error”。
这是将新表添加到我们数据库所需的数据库脚本
/****** Object:  Table [dbo].[aspnet_WebEvent_ErrorCodes]
        Script Date: 07/29/2010 09:56:45 ******/
IF  EXISTS (SELECT * FROM sys.objects WHERE 
    object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]') AND type in (N'U'))
DROP TABLE [dbo].[aspnet_WebEvent_ErrorCodes]
GO
/****** Object:  Default [DF_aspnet_WebEvent_ErrorCodes_Level]
        Script Date: 07/29/2010 09:56:45 ******/
IF  EXISTS (SELECT * FROM sys.default_constraints WHERE 
   object_id = OBJECT_ID(N'[dbo].[DF_aspnet_WebEvent_ErrorCodes_Level]') 
   AND parent_object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]'))
Begin
IF  EXISTS (SELECT * FROM dbo.sysobjects WHERE 
    id = OBJECT_ID(N'[DF_aspnet_WebEvent_ErrorCodes_Level]') AND type = 'D')
BEGIN
ALTER TABLE [dbo].[aspnet_WebEvent_ErrorCodes] 
      DROP CONSTRAINT [DF_aspnet_WebEvent_ErrorCodes_Level]
END
End
GO
/****** Object:  Table [dbo].[aspnet_WebEvent_ErrorCodes]
        Script Date: 07/29/2010 09:56:45 ******/
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
IF NOT EXISTS (SELECT * FROM sys.objects WHERE 
   object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]') AND type in (N'U'))
BEGIN
CREATE TABLE [dbo].[aspnet_WebEvent_ErrorCodes](
 [Id] [int] IDENTITY(1,1) NOT NULL,
 [Name] [nvarchar](255) COLLATE Latin1_General_CI_AS NOT NULL,
 [EventCode] [int] NOT NULL,
 [Level] [nvarchar](10) COLLATE Latin1_General_CI_AS NOT NULL,
 CONSTRAINT [PK_aspnet_WebEvent_ErrorCodes] PRIMARY KEY CLUSTERED
(
 [Id] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, 
       IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON)
)
END
GO
SET IDENTITY_INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ON
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (1, N'InvalidEventCode', -1, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (2, N'UndefinedEventCode/UndefinedEventDetailCode', 0, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (3, N'Not used', -9999, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (4, N'ApplicationCodeBase', 1000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (5, N'ApplicationStart', 1001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (6, N'ApplicationShutdown', 1002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (7, N'ApplicationCompilationStart', 1003, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (8, N'ApplicationCompilationEnd', 1004, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (9, N'ApplicationHeartbeat', 1005, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (10, N'RequestCodeBase', 2000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (11, N'RequestTransactionComplete', 2001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (12, N'RequestTransactionAbort', 2002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (13, N'ErrorCodeBase', 3000, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (14, N'RuntimeErrorRequestAbort', 3001, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (15, N'RuntimeErrorViewStateFailure', 3002, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (16, N'RuntimeErrorValidationFailure', 3003, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (17, N'RuntimeErrorPostTooLarge', 3004, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (18, N'RuntimeErrorUnhandledException', 3005, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (19, N'WebErrorParserError', 3006, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (20, N'WebErrorCompilationError', 3007, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (21, N'WebErrorConfigurationError', 3008, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (22, N'WebErrorOtherError', 3009, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (23, N'WebErrorPropertyDeserializationError', 3010, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (24, N'WebErrorObjectStateFormatterDeserializationError', 
                3011, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (25, N'AuditCodeBase', 4000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (26, N'AuditFormsAuthenticationSuccess', 4001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (27, N'AuditMembershipAuthenticationSuccess', 4002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (28, N'AuditUrlAuthorizationSuccess', 4003, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (29, N'AuditFileAuthorizationSuccess', 4004, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (30, N'AuditFormsAuthenticationFailure', 4005, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (31, N'AuditMembershipAuthenticationFailure', 4006, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (32, N'AuditUrlAuthorizationFailure', 4007, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (33, N'AuditFileAuthorizationFailure', 4008, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (34, N'AuditInvalidViewStateFailure', 4009, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (35, N'AuditUnhandledSecurityException', 4010, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (36, N'AuditUnhandledAccessException', 4011, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (37, N'MiscCodeBase', 6000, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (38, N'WebEventProviderInformation', 6001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (39, N'ApplicationDetailCodeBase', 50000, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (40, N'ApplicationShutdownUnknown', 50001, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (41, N'ApplicationShutdownHostingEnvironment', 50002, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (42, N'ApplicationShutdownChangeInGlobalAsax', 50003, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (43, N'ApplicationShutdownConfigurationChange', 50004, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (44, N'ApplicationShutdownUnloadAppDomainCalled', 50005, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (45, N'ApplicationShutdownChangeInSecurityPolicyFile', 
                50006, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (46, N'ApplicationShutdownBinDirChangeOrDirectoryRename', 
                50007, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (47, N'ApplicationShutdownBrowsersDirChangeOrDirectoryRename', 
                50008, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (48, N'ApplicationShutdownCodeDirChangeOrDirectoryRename', 
                50009, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (49, N'ApplicationShutdownResourcesDirChangeOrDirectoryRename', 
                50010, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (50, N'ApplicationShutdownIdleTimeout', 50011, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (51, N'ApplicationShutdownPhysicalApplicationPathChanged', 
                50012, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (52, N'ApplicationShutdownHttpRuntimeClose', 50013, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (53, N'ApplicationShutdownInitializationError', 50014, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (54, N'ApplicationShutdownMaxRecompilationsReached', 50015, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (55, N'StateServerConnectionError', 50016, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (56, N'AuditDetailCodeBase', 50200, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (57, N'InvalidTicketFailure', 50201, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (58, N'ExpiredTicketFailure', 50202, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (59, N'InvalidViewStateMac', 50203, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (60, N'InvalidViewState', 50204, N'Error')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (61, N'WebEventDetailCodeBase', 50300, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (62, N'SqlProviderEventsDropped', 50301, N'Info')
INSERT [dbo].[aspnet_WebEvent_ErrorCodes] ([Id], [Name], [EventCode], [Level]) 
        VALUES (63, N'WebExtendedBase', 100000, N'Info')
SET IDENTITY_INSERT [dbo].[aspnet_WebEvent_ErrorCodes] OFF
/****** Object:  Default [DF_aspnet_WebEvent_ErrorCodes_Level]
        Script Date: 07/29/2010 09:56:45 ******/
IF Not EXISTS (SELECT * FROM sys.default_constraints WHERE 
   object_id = OBJECT_ID(N'[dbo].[DF_aspnet_WebEvent_ErrorCodes_Level]') 
   AND parent_object_id = OBJECT_ID(N'[dbo].[aspnet_WebEvent_ErrorCodes]'))
Begin
IF NOT EXISTS (SELECT * FROM dbo.sysobjects WHERE 
   id = OBJECT_ID(N'[DF_aspnet_WebEvent_ErrorCodes_Level]') AND type = 'D')
BEGIN
ALTER TABLE [dbo].[aspnet_WebEvent_ErrorCodes] 
      ADD CONSTRAINT [DF_aspnet_WebEvent_ErrorCodes_Level] 
      DEFAULT ('Info') FOR [Level]
END
End
GO
这是为我们的健康监视事件创建新视图的脚本
CREATE VIEW vw_aspnet_WebEvents_extended
AS
SELECT
 webEvent.EventId
 , webEvent.EventTimeUtc
 , webEvent.EventTime
 , webEvent.EventType
 , webEvent.EventSequence
 , webEvent.EventOccurrence
 , webEvent.EventCode
 , webEvent.EventDetailCode
 , webEvent.Message
 , webEvent.ApplicationPath
 , webEvent.ApplicationVirtualPath
 , webEvent.MachineName
 , webEvent.RequestUrl
 , webEvent.ExceptionType
 , webEvent.Details
 , webEventCodes.Level
FROM
 dbo.aspnet_WebEvent_Events AS webEvent
INNER JOIN
 dbo.aspnet_WebEvent_ErrorCodes AS webEventCodes ON 
 webEvent.EventCode = webEventCodes.EventCode
设置和配置Log4Net
Log4Net 是一个流行的日志记录框架。有关 Log4Net 的更多信息,请访问 Log4Net 官方网站。
设置 Log4Net 需要以下步骤
- 下载 Log4Net。
- 添加 Log4Net 的引用。
- 在我们的数据库中添加一个表来存储 Log4Net 日志。
- 修改 web.config 文件以配置 Log4Net。
- 实现 Log4NetLogger,它实现了我们的ILogger接口。
这是创建表以存储 Log4Net 消息的脚本
SET ANSI_NULLS ON
GO
SET QUOTED_IDENTIFIER ON
GO
SET ANSI_PADDING ON
GO
CREATE TABLE [dbo].[Log4Net_Error](
    [Id] [int] IDENTITY(1,1) NOT NULL,
    [Date] [datetime] NOT NULL,
    [Thread] [varchar](255) NOT NULL,
    [Level] [varchar](50) NOT NULL,
    [Logger] [varchar](255) NOT NULL,
    [Message] [varchar](4000) NOT NULL,
    [Exception] [varchar](2000) NULL
) ON [PRIMARY]
GO
SET ANSI_PADDING OFF
GO
我不会在本篇文章中包含 Log4Net 的 web.config 设置,因为文章已经很长了——您可以在本文的可下载代码中看到所需的设置。
最后一步是创建一个实现 ILogger 接口的日志记录器类,该接口用于 Rob Connery 的 MVC 入门网站。这是代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using log4net;
namespace MvcLoggingDemo.Services.Logging.Log4Net
{
public class Log4NetLogger : ILogger
{
    private ILog _logger;
    public Log4NetLogger()
    {
        _logger = LogManager.GetLogger(this.GetType());
    }
    public void Info(string message)
    {
        _logger.Info(message);
    }
    public void Warn(string message)
    {
        _logger.Warn(message);
    }
    public void Debug(string message)
    {
        _logger.Debug(message);
    }
    public void Error(string message)
    {
        _logger.Error(message);
    }
    public void Error(Exception x)
    {
        Error(LogUtility.BuildExceptionMessage(x));
    }
    public void Error(string message, Exception x)
    {
        _logger.Error(message, x);
    }
    public void Fatal(string message)
    {
        _logger.Fatal(message);
    }
    public void Fatal(Exception x)
    {
        Fatal(LogUtility.BuildExceptionMessage(x));
    }
}
}
正如您所见,将日志记录器添加到您的网站非常容易!
设置和配置NLog
NLog 是另一个流行的日志记录框架。有关 NLog 的更多信息,请访问 NLog 官方网站。
以下是 NLog 所需的步骤
- 下载 NLog。
- 在我们的数据库中创建一个表来存储 NLog 消息。
- 配置我们的 NLog 配置文件。
- 为我们的网站设置日志记录接口。
- 实现一个 NLog 日志记录器,该日志记录器使用我们的接口将消息记录到步骤 2 中的数据库表中。
- 添加一些我们将需要的 NLog 附加布局渲染器。
除了最后一项之外,我将跳过所有其他项目,因为其他项目都很简单,并且与我们为其他日志记录器所做的操作类似。
然而,请注意,我将在我的博客(请参阅本文底部的链接)上更详细地介绍所有内容。
NLog 布局渲染器
布局渲染器 就像模板占位符,您可以使用它们将某些信息输出到您的 NLog 日志文件或数据库表中。
例如,`${date}` 会将日期/时间输出到您的日志。但是,NLog 使用本地日期/时间,而我需要一种方法来记录通用时间,以使其与其他日志提供商保持一致。我还想像 ELMAH 一样记录信息——即服务器变量和 HTTP cookie 信息。
解决这两个问题的方法是为 NLog 创建两个新的自定义布局渲染器——一个用于 `${utc_date}`,另一个用于 `${web_variables}`。
这是 `${utc_date}` 布局渲染器的代码
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Web;
using NLog;
using NLog.Config;
namespace MySampleApp.Services.Logging.NLog
{
[LayoutRenderer("utc_date")]
public class UtcDateRenderer : LayoutRenderer
{
    ///
    /// Initializes a new instance of the  class.
    ///
    public UtcDateRenderer()
    {
        this.Format = "G";
        this.Culture = CultureInfo.InvariantCulture;
    }
    protected override int GetEstimatedBufferSize(LogEventInfo ev)
    {
        // Dates can be 6, 8, 10 bytes so let's go with 10
        return 10;
    }
    ///
    /// Gets or sets the culture used for rendering.
    ///
    ///
    public CultureInfo Culture { get; set; }
    ///
    /// Gets or sets the date format. Can be any 
    /// argument accepted by DateTime.ToString(format).
    ///
    ///
    [DefaultParameter]
    public string Format { get; set; }
    ///
    /// Renders the current date and appends it to the specified .
    ///
    /// <param name="builder">The  to append the rendered data to.
    /// <param name="logEvent">Logging event.
    protected override void Append(StringBuilder builder, LogEventInfo logEvent)
    {
        builder.Append(logEvent.TimeStamp.ToUniversalTime().ToString(
                       this.Format, this.Culture));
    }
}
}
这是 `${web_variables}` 布局渲染器的代码
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Web;
using System.Xml;
using NLog;
using NLog.Config;
namespace MySampleApp.Services.Logging.NLog
{
[LayoutRenderer("web_variables")]
public class WebVariablesRenderer : LayoutRenderer
{
    ///
    /// Initializes a new instance of the  class.
    ///
    public WebVariablesRenderer()
    {
        this.Format = "";
        this.Culture = CultureInfo.InvariantCulture;
    }
    protected override int GetEstimatedBufferSize(LogEventInfo ev)
    {
        // This will be XML of an unknown size
        return 10000;
    }
    ///
    /// Gets or sets the culture used for rendering.
    ///
    ///
    public CultureInfo Culture { get; set; }
    ///
    /// Gets or sets the date format. Can be any
    /// argument accepted by DateTime.ToString(format).
    ///
    ///
    [DefaultParameter]
    public string Format { get; set; }
    ///
    /// Renders the current date and appends it to the specified .
    ///
    /// <param name="builder">The  to append the rendered data to.
    /// <param name="logEvent">Logging event.
    protected override void Append(StringBuilder builder, LogEventInfo logEvent)
    {
        StringBuilder sb = new StringBuilder();
        XmlWriter writer = XmlWriter.Create(sb);
        writer.WriteStartElement("error");
        // -----------------------------------------
        // Server Variables
        // -----------------------------------------
        writer.WriteStartElement("serverVariables");
        foreach (string key in 
                 HttpContext.Current.Request.ServerVariables.AllKeys)
        {
            writer.WriteStartElement("item");
            writer.WriteAttributeString("name", key);
            writer.WriteStartElement("value");
            writer.WriteAttributeString("string", 
              HttpContext.Current.Request.ServerVariables[key].ToString());
            writer.WriteEndElement();
            writer.WriteEndElement();
        }
        writer.WriteEndElement();
        // -----------------------------------------
        // Cookies
        // -----------------------------------------
        writer.WriteStartElement("cookies");
        foreach (string key in HttpContext.Current.Request.Cookies.AllKeys)
        {
            writer.WriteStartElement("item");
            writer.WriteAttributeString("name", key);
            writer.WriteStartElement("value");
            writer.WriteAttributeString("string", 
              HttpContext.Current.Request.Cookies[key].Value.ToString());
            writer.WriteEndElement();
            writer.WriteEndElement();
        }
        writer.WriteEndElement();
        // -----------------------------------------
        writer.WriteEndElement();
        // -----------------------------------------
        writer.Flush();
        writer.Close();
        string xml = sb.ToString();
        builder.Append(xml);
    }
}
}
最后一步是在您的 global.asax.cs 文件中的 application_start 事件中注册您的自定义布局渲染器,如下所示
// Register custom NLog Layout renderers
LayoutRendererFactory.AddLayoutRenderer("utc_date", 
   typeof(MySampleApp.Services.Logging.NLog.UtcDateRenderer));
LayoutRendererFactory.AddLayoutRenderer("web_variables", 
   typeof(MySampleApp.Services.Logging.NLog.WebVariablesRenderer));
创建模型
此时,我们已经设置好了 ELMAH、NLog、Log4Net 和 ASP.NET Health Monitoring,并将它们记录到了数据库各自的表中。
以下是我们数据层需要执行的操作概述
- 使用 Entity Designer 创建我们的 LINQ-to-Entity 类
- 创建一个通用的 LogEvent类,我们将用它来存储所有日志提供商的消息
- 创建一个 ILogReportingRepository接口
- 为我们网站中的每个日志提供商(ELMAH、Log4Net、NLog、Health Monitoring)实现一个 LogReportingRepository
- 创建一个 LogReportingFacade类,它将从我们已安装的一个或所有日志存储库中拉取结果
这是我们的 LogEvent 类的代码,它将保存日志消息的信息
/// <summary>
/// This represents a generic log message that can store log information about
/// any logger implemented. Eg: Log4Net, NLog, Health Monitoring, Elmah
/// </summary>
public class LogEvent
{
    private string _Id = string.Empty;
    /// <summary>
    /// String representation of the event log id
    /// </summary>
    public string Id 
    {
        get
        {
            switch (IdType)
            {
                case "number":
                    return IdAsInteger.ToString();
                case "guid":
                    return IdAsGuid.ToString();
                default:
                    return _Id;
            }
        }
        set
        {
            _Id = value;
        }
    }
    /// <summary>
    /// Stores the Id of the log event as a GUID 
    /// </summary>
    internal Guid IdAsGuid { get; set; }
    /// <summary>
    /// Stores the Id of the log event as an integer
    /// </summary>
    internal int IdAsInteger { get; set; }
    /// <summary>
    /// Stores the base type of the id 
    /// Valid values are : number, guid, string
    /// </summary>
    internal string IdType { get; set; }
    /// <summary>
    /// The date of the log event
    /// </summary>
    public DateTime LogDate { get; set; }
    /// <summary>
    /// The name of the log provider
    /// Example values are NLog, Log4Net, Elmah, Health Monitoring
    /// </summary>
    public string LoggerProviderName { get; set; }
    /// <summary>
    /// Information about where the error occurred
    /// </summary>
    public string Source { get; set; }
    /// <summary>
    /// The machine where the error occured
    /// </summary>
    public string MachineName { get; set; }
    /// <summary>
    /// The Type name of the class that logged the error
    /// </summary>
    public string Type { get; set; }
    /// <summary>
    /// The level of the message logged
    /// Valid values are : Debug, Info, Warning, Error, Fatal
    /// </summary>
    public string Level { get; set; }
    /// <summary>
    /// The message that was logged
    /// </summary>
    public string Message { get; set; }                
    /// <summary>
    /// If the message was from an error this value
    /// will contain details of the stack trace. 
    /// Otherwise it will be empty
    /// </summary>
    public string StackTrace { get; set; }
    /// <summary>
    /// If the message was from an error this value will
    /// contain details of the HTTP Server variables and Cookies. 
    /// Otherwise it will be empty
    /// </summary>
    public string AllXml { get; set; }        
}
这个类的唯一技巧是,我必须为“ID”使用一些内部属性,并结合一个“IdType”属性,以便它可以容纳 Integer、String 和 GUID 主键。起初,我尝试使用“Object”类型并使用单个“ID”属性,但在使用 LINQ-to-Entity UNION 运算符连接所有 IQueryable 结果时遇到了问题(请参阅下面的“LogReportingFacade”代码)。如果有人知道更好的方法,请在下面的评论中发布。
这是 NLogRepository 类的代码(其他存储库的代码非常相似)
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using MvcLoggingDemo.Models.Entities;
using MvcLoggingDemo.Services.Paging;
using MvcLoggingDemo.Helpers;
using System.Data.SqlClient;
namespace MvcLoggingDemo.Models.Repository
{
/// <summary>
/// This class extracts information that NLog stores so that we can report on it
/// </summary>
public class NLogRepository : ILogReportingRepository
{
    MvcLoggingDemoContainer _context = null;
    /// <summary>
    /// Default Constructor uses the default Entity Container
    /// </summary>
    public NLogRepository()
    {
        _context = new MvcLoggingDemoContainer();
    }
    /// <summary>
    /// Overloaded constructor that can take an EntityContainer
    /// as a parameter so that it can be mocked out by our tests
    /// </summary>
    /// <param name="context">The Entity context</param>
    public NLogRepository(MvcLoggingDemoContainer context)
    {
        _context = context;
    }
    /// <summary>
    /// Gets a filtered list of log events
    /// </summary>
    /// <param name="pageIndex">0 based page index</param>
    /// <param name="pageSize">max number of records to return</param>
    /// <param name="start">start date</param>
    /// <param name="end">end date</param>
    /// <param name="logLevel">The level of the log messages</param>
    /// <returns>A filtered list of log events</returns>
    public IQueryable<LogEvent> GetByDateRangeAndType(int pageIndex, 
           int pageSize, DateTime start, DateTime end, string logLevel)
    {
        IQueryable<LogEvent> list = (from b in _context.NLog_Error
        where b.time_stamp >= start && b.time_stamp <= end
        && (b.level == logLevel || logLevel == "All")
        select new LogEvent { IdType = "number"
        , Id = ""
        , IdAsInteger = b.Id
        , IdAsGuid = Guid.NewGuid()
        , LoggerProviderName = "NLog"
        , LogDate = b.time_stamp
        , MachineName = b.host
        , Message = b.message
        , Type = b.type
        , Level = b.level
        , Source = b.source
        , StackTrace = b.stacktrace });
        return list;
    }
    /// <summary>
    /// Returns a single Log event
    /// </summary>
    /// <param name="id">Id of the log event as a string</param>
    /// <returns>A single Log event</returns>
    public LogEvent GetById(string id)
    {
        int logEventId = Convert.ToInt32(id);
        LogEvent logEvent = (from b in _context.NLog_Error
        where b.Id == logEventId
        select new LogEvent { IdType = "number"
        , IdAsInteger = b.Id
        , LoggerProviderName = "NLog"
        , LogDate = b.time_stamp
        , MachineName = b.host
        , Message = b.message
        , Type = b.type
        , Level = b.level
        , Source = b.source
        , StackTrace = b.stacktrace
        , AllXml = b.allxml })
        .SingleOrDefault();
        return logEvent;
    }
    /// <summary>
    /// Clears log messages between a date range and for specified log levels
    /// </summary>
    /// <param name="start">start date</param>
    /// <param name="end">end date</param>
    /// <param name="logLevels">string array of log levels</param>
    public void ClearLog(DateTime start, DateTime end, string[] logLevels)
    {
        string logLevelList = "";
        foreach (string logLevel in logLevels)
        {
            logLevelList += ",'" + logLevel + "'";
        }
        if (logLevelList.Length > 0)
        {
            logLevelList = logLevelList.Substring(1);
        }
        string commandText = "delete from NLog_Error WHERE time_stamp " + 
          ">= @p0 and time_stamp <= @p1 and level in (@p2)";
        SqlParameter paramStartDate = new SqlParameter { ParameterName = "p0", 
           Value = start.ToUniversalTime(), DbType = System.Data.DbType.DateTime };
        SqlParameter paramEndDate = new SqlParameter { ParameterName = "p1", 
           Value = end.ToUniversalTime(), DbType = System.Data.DbType.DateTime };
        SqlParameter paramLogLevelList = 
           new SqlParameter { ParameterName = "p2", Value = logLevelList };
        _context.ExecuteStoreCommand(commandText, paramStartDate, 
                                     paramEndDate, paramLogLevelList);
    }
}
}
这是 LogReportingFacade 类中的一个片段
public IPagedList<LogEvent> GetByDateRangeAndType(int pageIndex, int pageSize, 
       DateTime start, DateTime end, string logProviderName, string logLevel)
{
    IQueryable<LogEvent> list = null;
    switch (logProviderName)
    {
        case "All":
            foreach (string providerName in logProviders.Keys)
            {
                IQueryable<LogEvent> logList = 
                   GetProvider(providerName).GetByDateRangeAndType(pageIndex, 
                   pageSize, start, end, logLevel);
                list = (list == null) ? logList : list.Union(logList);
            }                    
            break;
        default:
            list = GetProvider(logProviderName).GetByDateRangeAndType(
                          pageIndex, pageSize, start, end, logLevel);
            break;
    }
    list = list.OrderByDescending(d => d.LogDate);
    return new PagedList<LogEvent>(list, pageIndex, pageSize);            
}
在上面的方法中,如果需要查询所有日志提供商,那么我们会遍历所有已配置的日志提供商,并为它们查询过滤后的日志消息。然后,我们使用 LINQ-to-Objects UNION 运算符将所有查询连接在一起。
另一方面,如果调用客户端只想查询其中一个日志提供商,那么我们只需实例化该特定日志提供商并获取该提供商的 IQueryable 结果。
在方法结束时,我们进行排序和分页处理,最后返回合并的日志消息列表,我们可以在视图上显示这些消息。
ViewModel
LogEvent 类允许我们表示来自任何已实现日志提供商的日志消息。但是,在我们的仪表板页面上,我们将需要比 LogEvent 列表更多的信息。我们需要跟踪过滤条件以及用于跟踪分页的字段。为了让视图能够访问这些数据,我们创建了一个名为 LoggingIndexModel 的类,它将保存我们需要在视图上显示的所有信息。这是代码:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using MvcLoggingDemo.Models;
using MvcLoggingDemo.Services.Paging;
namespace MvcLoggingDemo.ViewModels
{
public class LoggingIndexModel
{
    public IPagedList<LogEvent> LogEvents { get; set; }
    public string LoggerProviderName { get; set; }
    public string LogLevel { get; set; }
    public string Period { get; set; }
    public int CurrentPageIndex { get; set; }
    public int PageSize { get; set; }
    public LoggingIndexModel()
    {
        CurrentPageIndex = 0;
        PageSize = 20;
    }
}
}
LogEvents 属性保存我们分页的日志消息列表。LoggerProviderName、LogLevel 和 Period 保存我们的过滤信息。CurrentPageIndex 和 PageSize 保存我们的分页信息。
创建控制器
这是我们 Logging Controller 中 Index 操作的源代码
public ActionResult Index(string Period, string LoggerProviderName, 
                          string LogLevel, int? page, int? PageSize)
{
    // Set up our default values
    string defaultPeriod = Session["Period"] == null ? 
                                "Today" : Session["Period"].ToString();
    string defaultLogType = Session["LoggerProviderName"] == null ? 
                                "All" : Session["LoggerProviderName"].ToString();
    string defaultLogLevel = Session["LogLevel"] == null ? 
                                "Error" : Session["LogLevel"].ToString();
    // Set up our view model
    LoggingIndexModel model = new LoggingIndexModel();
    model.Period = (Period == null) ? defaultPeriod : Period;
    model.LoggerProviderName = (LoggerProviderName == null) ? 
                                   defaultLogType : LoggerProviderName;
    model.LogLevel = (LogLevel == null) ? defaultLogLevel : LogLevel;
    model.CurrentPageIndex = page.HasValue ? page.Value - 1 : 0;
    model.PageSize = PageSize.HasValue ? PageSize.Value : 20;
    TimePeriod timePeriod = TimePeriodHelper.GetUtcTimePeriod(model.Period);            
    // Grab the data from the database
    model.LogEvents = loggingRepository.GetByDateRangeAndType(model.CurrentPageIndex, 
                      model.PageSize, timePeriod.Start, timePeriod.End, 
                      model.LoggerProviderName, model.LogLevel);
    // Put this into the ViewModel so our Pager can get at these values
    ViewData["Period"] = model.Period;
    ViewData["LoggerProviderName"] = model.LoggerProviderName;
    ViewData["LogLevel"] = model.LogLevel;
    ViewData["PageSize"] = model.PageSize;
    // Put the info into the Session so that when we browse away
    // from the page and come back that the last settings are rememberd and used.
    Session["Period"] = model.Period;
    Session["LoggerProviderName"] = model.LoggerProviderName;
    Session["LogLevel"] = model.LogLevel;
    return View(model);
}
所有代码都是不言自明的,除了我添加了一个名为 TimePeriod 的类,以及一个名为 TimePeriodHelper 的辅助类,它将接受一个基于字符串的周期,如“今天”、“上周”、“上个月”等,并返回该周期的开始和结束日期。
创建视图
这是我们将所有内容组合在一起并创建我们的日志报告仪表板页面的有趣部分。
在我们的视图顶部,我们将提供按列表、图表或 RSS feed 显示日志消息的选项。以下是代码:
<div>
 View :
 <strong>List</strong>
 | <%: Html.ActionLink("Chart", "Chart")%>
 | <%: Html.ActionLink("RSS", "RssFeed", 
        new { LoggerProviderName = Model.LoggerProviderName, 
              Period = Model.Period, LogLevel = Model.LogLevel }, 
              new { target = "_blank" })%>
</div>
由于我们的索引页将是列表或网格视图,我们将需要一种方法来过滤要显示的错误消息。这是过滤器的 HTML:
<div>
 <div>
 Logger : <%: Html.DropDownList("LoggerProviderName", 
                 new SelectList(MvcLoggingDemo.Helpers.FormsHelper.LogProviderNames, 
                 "Value", "Text"))%>
 Level : <%: Html.DropDownList("LogLevel", 
                new SelectList(MvcLoggingDemo.Helpers.FormsHelper.LogLevels, 
                "Value", "Text"))%>
 For : <%: Html.DropDownList("Period", 
       new SelectList(MvcLoggingDemo.Helpers.FormsHelper.CommonTimePeriods, 
       "Value", "Text"))%>
 <input id="btnGo" name="btnGo" type="submit" value="Apply Filter" />
 </div>
</div>
我们还需要一个网格标题,显示找到的消息数量,并允许用户更改每页显示的记录数。这是网格标题的代码:
<div>
 <div>
 <div>
 <span style="float: left">
 <%: string.Format("{0} records found. Page {1} of {2}", 
          Model.LogEvents.TotalItemCount, Model.LogEvents.PageNumber, 
          Model.LogEvents.PageCount)%>
 </span>
 <span style="float: right">
 Show <%: Html.DropDownList("PageSize", 
    new SelectList(MvcLoggingDemo.Helpers.FormsHelper.PagingPageSizes, "Value", "Text"), 
    new { onchange = "document.getElementById('myform').submit()" })%> results per page
 </span>
 <div style="clear: both"></div>
 </div>
 </div>
 <div>
 <div>
 <%= Html.Pager(ViewData.Model.LogEvents.PageSize, 
         ViewData.Model.LogEvents.PageNumber, 
         ViewData.Model.LogEvents.TotalItemCount, 
         new { LogType = ViewData["LogType"], 
               Period = ViewData["Period"], 
               PageSize = ViewData["PageSize"] })%>
 </div>
 </div>
 </div>
<% } %>
视图的最后一部分只是将日志消息显示在一个表中,您可以在本文的可下载代码中看到。
可下载的代码还包含一个使用 Google Visualization API 的图表视图和一个日志消息的 RSS feed。
屏幕截图




使用代码
可下载代码中的项目是独立的,并在 app_data 文件夹中使用自己的数据库。第一次运行应用程序时,您需要注册自己为用户。登录后,您应该会在主选项卡菜单中看到一个指向日志记录仪表板的链接。
更多信息
以下是本文中使用的各种日志记录工具的有用链接列表
ELMAH
- ELMAH on CodePlex
- Atif Aziz 的博客 - ELMAH 的创建者
- ASP.NET 上设置 ELMAH 的官方指南
- StackOverlfow 关于在 MVC 上使用 ELMAH 的问题
- Rajan 的博客文章,关于使用自定义控制器
NLog
Log4Net
健康监视
其他有用链接
结论
我希望本文能帮助那些在 MVC 网站上设置 ELMAH、Log4Net、NLog 或 Health Monitoring 的朋友,并鼓励那些目前在生产网站上没有日志记录功能的朋友们抓紧时间进行日志记录!


