Click here to Skip to main content
15,867,453 members
Articles / Web Development / ASP.NET

Logging and Exception handling, Versioning in ASP.NET WEB API

Rate me:
Please Sign up or sign in to vote.
4.93/5 (24 votes)
19 Oct 2021CPOL8 min read 73.3K   2.5K   58   9
Log each request and response of an API in a way that it helps to maintain logs

Introduction

In this article, we are going to learn how to log each request and response of an API such that it helps to maintain logs. Next, we are going to handle all API exceptions such that if an error occurs, we can store errors and fix it as soon as possible, and the last part is versioning of the API.

  1. Exception handling
  2. Logging
  3. Versioning

All these parts are key when you are developing a production API.

Image 1

Icons made by Freepik from www.flaticon.com are licensed by CC 3.0 BY:

Image 2

1. Exception Handling

To begin, we create a simple Web API application “WebDemoAPI”.

Image 3

After creating a simple Web API solution, you will get a default Home controller and the Values API Controller. Let’s first run application and call get request.

Note: You can use any Rest Client for sending a request for this demo, I am going to use POSTMAN Rest client.

URL: http://localhost:50664/api/values

Sending Get Request

Image 4

After sending a request to API, we got a response.

Now let’s make a change in Get method, here, I am going to throw an exception.

C#
public class ValuesController : ApiController
{
    // GET api/values
    public IEnumerable<string> Get()
    {
        throw new NotImplementedException("");
        //return new string[] { "value1", "value2" };
    }
   }

Now if we send request to values API get request, then it will throw error in response.

Response Before Handling the Exception

Image 5

Now we got the error, let’s see how to handle this error globally.

Handling API Exception using ExceptionHandler class:

Image 6

For handling exceptions, we are going to create a class “GlobalExceptionHandler” which will inherit from “ExceptionHandlerabstract class. Inside this, we are going to implement Handle method. Before that, we are going to create “CustomHandler” folder. In this folder, we are going to add “GlobalExceptionHandler” class.

Image 7

C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using System.Web.Http;
using System.Web.Http.ExceptionHandling;

namespace WebDemoAPI.CustomHandler
{
    public class GlobalExceptionHandler : ExceptionHandler
    {
        public override void Handle(ExceptionHandlerContext context)
        {
            var result = new HttpResponseMessage(HttpStatusCode.InternalServerError)
            {
                Content = new StringContent("Internal Server Error Occurred"),
                ReasonPhrase = "Exception"
            };

            context.Result = new ErrorMessageResult(context.Request, result);
        }

        public class ErrorMessageResult : IHttpActionResult
        {
            private HttpRequestMessage _request;
            private readonly HttpResponseMessage _httpResponseMessage;

            public ErrorMessageResult
            (HttpRequestMessage request, HttpResponseMessage httpResponseMessage)
            {
                _request = request;
                _httpResponseMessage = httpResponseMessage;
            }

            public Task<HttpResponseMessage> 
                   ExecuteAsync(CancellationToken cancellationToken)
            {
                return Task.FromResult(_httpResponseMessage);
            }
        }
    }
}

Now we have implemented Handle method from ExceptionHandler class.

Before doing it, first we go to create HttpResponseMessage. For that, we are going to add a class “ErrorMessageResult” which will inherit from “IHttpActionResult” interface. This class will have a Parameterized Constructor which takes two parameters:

  1. HttpRequestMessage
  2. HttpResponseMessage

The HttpResponseMessage which we took parameters will be used by ExecuteAsync to create HttpResponseMessage.

Then, this HttpResponseMessage we are going to assign it to “context.Result”.

After handling the exception, next we need to register this handler.

Registering Exception Handler

We are going to Register “GlobalExceptionHandler” in WebApiConfig class, such that any web API exception can be handled globally.

C#
//Registering GlobalExceptionHandler
config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());
C#
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using System.Web.Http.ExceptionHandling;
using WebDemoAPI.CustomHandler;

namespace WebDemoAPI
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API configuration and services
            // Web API routes
            config.MapHttpAttributeRoutes();
            //Registering GlobalExceptionHandler
            config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());
            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );
        }
    }
}

Now let’s run this application and check whether we handle exception now.

Snapshot of Exception Thrown

Image 8

After throwing an exception, now we display proper error message not error stack trace to consumers.

Response After Handling the Exception

Image 9

Now we have handled the exception, but we have not logged the exception.

Exception Logging

Image 10

In this part, we are going to store exception into the database, for doing that, let’s first have a look at table structure where we are going to store it.

API_Error

Image 11

After having a look at table structure, further I have written a simple procedure to store this exception in the table.

Now we have completed the database part, next, let’s add classes and method to write an exception into the database.

APIError Class

C#
public class ApiError
{
    public string Message { get; set; }
    public string RequestMethod { get; set; }
    public string RequestUri { get; set; }
    public DateTime TimeUtc { get; set; }
}

Note: Stored Procedures and table scripts are available for download.

SqlErrorLogging Class

In this part, we are going to write error in the database, in this class, we have InsertErrorLog method which takes the ApiError class as an input parameter.

C#
public class SqlErrorLogging
{
    public void InsertErrorLog(ApiError apiError)
    {
        try
        {
            using (var sqlConnection = new SqlConnection
            (ConfigurationManager.ConnectionStrings
            ["APILoggingConnection"].ConnectionString))
            {
                sqlConnection.Open();
                var cmd =
                    new SqlCommand("API_ErrorLogging", connection: sqlConnection)
                    {
                        CommandType = CommandType.StoredProcedure
                    };
                cmd.Parameters.AddWithValue("@TimeUtc", apiError.TimeUtc);
                cmd.Parameters.AddWithValue("@RequestUri", apiError.RequestUri);
                cmd.Parameters.AddWithValue("@Message", apiError.Message);
                cmd.Parameters.AddWithValue("@RequestMethod", apiError.RequestMethod);

               cmd.ExecuteNonQuery();
            }
        }
        catch (Exception)
        {
            throw;
        }
    }
}

After adding classes and method, next we are going to add class “UnhandledExceptionLogger” which will inherit from “ExceptionLoggerabstract class.

UnhandledExceptionLogger Class

We are going to add a class “UnhandledExceptionLogger” which will inherit from “ExceptionLogger” an abstract class in that we are going to override “Log” method, in this method, we are going to get an exception which has occurred from that exception, we are going to pull information such as Source, StackTrace, TargetSite and assign it to ApiError class for storing in the database.

C#
using System;
using System.Web.Http.ExceptionHandling;
using WebDemoAPI.Models;

namespace WebDemoAPI.CustomHandler
{
    public class UnhandledExceptionLogger : ExceptionLogger
    {
        public override void Log(ExceptionLoggerContext context)
        {
            var ex = context.Exception;

           string strLogText = "";
            strLogText += Environment.NewLine + "Source ---\n{0}" + ex.Source;
            strLogText += Environment.NewLine + "StackTrace ---\n{0}" + ex.StackTrace;
            strLogText += Environment.NewLine + "TargetSite ---\n{0}" + ex.TargetSite;

            if (ex.InnerException != null)
            {
                strLogText += Environment.NewLine + 
                "Inner Exception is {0}" + ex.InnerException;//error prone
            }
            if (ex.HelpLink != null)
            {
                strLogText += Environment.NewLine + "HelpLink ---\n{0}" + 
                              ex.HelpLink;//error prone
            }

            var requestedURi = (string)context.Request.RequestUri.AbsoluteUri;
            var requestMethod = context.Request.Method.ToString();
            var timeUtc = DateTime.Now;

            SqlErrorLogging sqlErrorLogging = new SqlErrorLogging();
            ApiError apiError = new ApiError()
            {
                Message = strLogText,
                RequestUri = requestedURi,
                RequestMethod = requestMethod,
                TimeUtc = DateTime.Now
            };
            sqlErrorLogging.InsertErrorLog(apiError);
        }
    }
}

After creating “UnhandledExceptionLogger” class and writing error into database, next we are going to register this class globally in WebApiConfig class.

C#
//Registering UnhandledExceptionLogger
config.Services.Replace(typeof(IExceptionLogger), new UnhandledExceptionLogger());

Registering UnhandledExceptionLogger in WebApiConfig Class

C#
using System.Web.Http;
using System.Web.Http.ExceptionHandling;
using WebDemoAPI.CustomHandler;

namespace WebDemoAPI
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API configuration and services
            // Web API routes
            config.MapHttpAttributeRoutes();
            //Registering GlobalExceptionHandler
            config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());
            //Registering UnhandledExceptionLogger
            config.Services.Replace(typeof(IExceptionLogger), new UnhandledExceptionLogger());
            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );
        }
    }
}

After registering UnhandledExceptionLogger class, now let’s run the application and see whether it stores exception occurred in the database.

Image 12

After getting the error, we have handled it and displayed a proper error message to the user and also logged the error in the database.

Response after handling exception and Logging exception:

Image 13

Storing Exception

Image 14

After handling and logging Exception, next we are going to log each request and response to Web API.

2. Logging Request and Response

Image 15

In this part, we are going to log each request and response of WEB API.

In doing that, we are going to inherit an abstract class “DelegatingHandler” and override SendAsync method.

Image 16

If you see the below table, you will get a clear idea about what all data we are storing from request and response into the database.

Let’s first start with creating an “API_Log” table where we are going to store this request in response.

Image 17

After creating a table, we have created a simple stored procedure for inserting Log into API_Log table. This stored procedure is available for download.

Next, we are going to add “ApiLog” class to pass data to the stored procedure.

C#
namespace WebDemoAPI.Models
{
    public class ApiLog
    {
        public string Host { get; set; }
        public string Headers { get; set; }
        public string StatusCode { get; set; }
        public string RequestBody { get; set; }
        public string RequestedMethod { get; set; }
        public string UserHostAddress { get; set; }
        public string Useragent { get; set; }
        public string AbsoluteUri { get; set; }
        public string RequestType { get; set; }
    }
}

After adding ApiLog class, next we are going to Add an ApiLogging class. In that class, we are going to add InsertLog method which will take ApiLog class as a parameter and ApiLog class data will be mapped to SQL parameters to insert data into database.

C#
public class ApiLogging
{
    public void InsertLog(ApiLog apiLog)
    {
        try
        {
            using (var sqlConnection = new SqlConnection
            (ConfigurationManager.ConnectionStrings
            ["APILoggingConnection"].ConnectionString))
            {
                sqlConnection.Open();
                var cmd =
                    new SqlCommand("API_Logging", connection: sqlConnection)
                    {
                        CommandType = CommandType.StoredProcedure
                    };
                cmd.Parameters.AddWithValue("@Host", apiLog.Host);
                cmd.Parameters.AddWithValue("@Headers", apiLog.Headers);
                cmd.Parameters.AddWithValue("@StatusCode", apiLog.StatusCode);
                cmd.Parameters.AddWithValue("@RequestBody", apiLog.RequestBody);
                cmd.Parameters.AddWithValue("@RequestedMethod", apiLog.RequestedMethod);
                cmd.Parameters.AddWithValue("@UserHostAddress", apiLog.UserHostAddress);
                cmd.Parameters.AddWithValue("@Useragent", apiLog.Useragent);
                cmd.Parameters.AddWithValue("@AbsoluteUri", apiLog.AbsoluteUri);
                cmd.Parameters.AddWithValue("@RequestType", apiLog.RequestType);
                cmd.ExecuteNonQuery();
            }
        }
        catch (Exception)
        {
            throw;
        }
    }
}

After completing with the adding ApiLogging class, next we are going to write the main heart of this process which is adding the Custom handler.

Creating Custom Handler

We are going to add a class with name “RequestResponseHandler” and then we are going to inherit from DelegatingHandler abstract class and override SendAsync method.

C#
public class RequestResponseHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage>
                       SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
    }
}

Before implementing SendAsync method, I have written a simple class MessageLogging which has two methods in it, IncomingMessageAsync and OutgoingMessageAsync. I have created this method for just assigning Request types and to call both methods separately.

C#
public class MessageLogging
{
    public void IncomingMessageAsync(ApiLog apiLog)
    {
        apiLog.RequestType = "Request";
        var sqlErrorLogging = new ApiLogging();
        sqlErrorLogging.InsertLog(apiLog);
    }

    public void OutgoingMessageAsync(ApiLog apiLog)
    {
        apiLog.RequestType = "Response";
        var sqlErrorLogging = new ApiLogging();
        sqlErrorLogging.InsertLog(apiLog);
    }
}

Now after adding MessageLogging class, next we are going to implement SendAsync method from DelegatingHandler abstract class.

C#
public class RequestResponseHandler: DelegatingHandler
{
    protected override async Task<HttpResponseMessage>
        SendAsync(HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        var requestedMethod = request.Method;
        var userHostAddress = HttpContext.Current != null ?
            HttpContext.Current.Request.UserHostAddress : "0.0.0.0";
        var useragent = request.Headers.UserAgent.ToString();
        var requestMessage = await request.Content.ReadAsByteArrayAsync();
        var uriAccessed = request.RequestUri.AbsoluteUri;

        var responseHeadersString = new StringBuilder();
        foreach (var header in request.Headers)
        {
            responseHeadersString.Append($"{header.Key}:
            {String.Join(", ", header.Value)}{Environment.NewLine}");
        }

        var messageLoggingHandler = new MessageLogging();

        var requestLog = new ApiLog()
        {
            Headers = responseHeadersString.ToString(),
            AbsoluteUri = uriAccessed,
            Host = userHostAddress,
            RequestBody = Encoding.UTF8.GetString(requestMessage),
            UserHostAddress = userHostAddress,
            Useragent = useragent,
            RequestedMethod = requestedMethod.ToString(),
            StatusCode = string.Empty
        };

        messageLoggingHandler.IncomingMessageAsync(requestLog);

        var response = await base.SendAsync(request, cancellationToken);

        byte[] responseMessage;
        if (response.IsSuccessStatusCode)
            responseMessage = await response.Content.ReadAsByteArrayAsync();
        else
            responseMessage = Encoding.UTF8.GetBytes(response.ReasonPhrase);

        var responseLog = new ApiLog()
        {
            Headers = responseHeadersString.ToString(),
            AbsoluteUri = uriAccessed,
            Host = userHostAddress,
            RequestBody = Encoding.UTF8.GetString(responseMessage),
            UserHostAddress = userHostAddress,
            Useragent = useragent,
            RequestedMethod = requestedMethod.ToString(),
            StatusCode = string.Empty
        };

        messageLoggingHandler.OutgoingMessageAsync(responseLog);
        return response;
    }
}

Let’s understand what we have written in the SendAsync method.

Request Method

C#
var requestedMethod = request.Method;

We are storing request method whether it was a POST PUT DELETE or GET.

Image 18

Host Address

C#
var userHostAddress = HttpContext.Current != null ? 
                      HttpContext.Current.Request.UserHostAddress : "0.0.0.0";

We are getting IP Address from where this request came.

Image 19

UserAgent

C#
var useragent = request.Headers.UserAgent.ToString();

UserAgent gives you a raw string about the browser.

Image 20

Request Body

C#
var requestMessage = await request.Content.ReadAsByteArrayAsync();

Image 21

Absolute Uri

C#
var uriAccessed = request.RequestUri.AbsoluteUri;

Image 22

Headers

C#
var responseHeadersString = new StringBuilder();
foreach (var header in request.Headers)
{
    responseHeadersString.Append($"{header.Key}: {String.Join(", ", header.Value)}
                                {Environment.NewLine}");
}

Image 23

Assign Value to ApiLog Class

C#
var messageLoggingHandler = new MessageLogging();

var requestLog = new ApiLog()
{
    Headers = responseHeadersString.ToString(),
    AbsoluteUri = uriAccessed,
    Host = userHostAddress,
    RequestBody = Encoding.UTF8.GetString(requestMessage),
    UserHostAddress = userHostAddress,
    Useragent = useragent,
    RequestedMethod = requestedMethod.ToString(),
    StatusCode = string.Empty
};

Incoming Request Logging

C#
messageLoggingHandler.IncomingMessageAsync(requestLog);

Outgoing Response Logging

C#
messageLoggingHandler.OutgoingMessageAsync(responseLog);

Registering RequestResponseHandler

C#
using System.Web.Http;
using System.Web.Http.ExceptionHandling;
using WebDemoAPI.CustomHandler;

namespace WebDemoAPI
{
    public static class WebApiConfig
    {
        public static void Register(HttpConfiguration config)
        {
            // Web API configuration and services

            // Web API routes
            config.MapHttpAttributeRoutes();

            //Registering GlobalExceptionHandler
            config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());
            
            //Registering UnhandledExceptionLogger
            config.Services.Replace(typeof(IExceptionLogger), new UnhandledExceptionLogger());

            //Registering RequestResponseHandler
            config.MessageHandlers.Add(new RequestResponseHandler());

            config.Routes.MapHttpRoute(
                name: "DefaultApi",
                routeTemplate: "api/{controller}/{id}",
                defaults: new { id = RouteParameter.Optional }
            );
        }
    }
}

Now we got an idea about how this process works. Let's run the application and see whether it works.

Accessing Values API Controller

Image 24

Request and Response Web API Logging

Image 25

3. Versioning

Image 26

Icons made by Freepik from www.flaticon.com is licensed by CC 3.0 BY

It is the most important part of Web API development as we keep refining the application, we keep making changes to application, if we make changes to the API which are already in production and many users are consuming it will break working application, solution for this is to version your APIs such that older users who are consuming your API will not have any effect on it.

Let’s start implementing versioning in the ASP.NET Web API in with simple steps.

First, we are going to add “Microsoft.AspNet.WebApi.Versioning” NuGet package to the application.

Image 27

After installing NuGet Package next, we are going to Register AddApiVersioning method in WebApiConfig.cs file.

The ApiVersioningOptions class allows you to configure, customize, and extend the default behaviors when you add an API versioning to your application.

Image 28

Referenced from: https://github.com/Microsoft/aspnet-api-versioning/wiki/API-Versioning-Options

Code Snippet of AddApiVersioning Method

C#
config.AddApiVersioning(o =>
    {
        o.ReportApiVersions = true;
        o.AssumeDefaultVersionWhenUnspecified = true;
        o.DefaultApiVersion = new ApiVersion(2, 0);
        o.ApiVersionReader = new HeaderApiVersionReader("version");
        o.ApiVersionSelector = new CurrentImplementationApiVersionSelector(o);
    }
);

Complete Code Snippet of WebApiConfig

In this part, we are going to comment default routing and enable attribute base routing.

C#
public static class WebApiConfig
{
    public static void Register(HttpConfiguration config)
    {
        config.AddApiVersioning(o =>
            {
                o.ReportApiVersions = true;
                o.AssumeDefaultVersionWhenUnspecified = true;
                o.DefaultApiVersion = new ApiVersion(2, 0);
                o.ApiVersionReader = new HeaderApiVersionReader("version");
                o.ApiVersionSelector = new CurrentImplementationApiVersionSelector(o);
            }
        );
        // Web API configuration and services

        // Web API routes
        config.MapHttpAttributeRoutes();

        //Registering GlobalExceptionHandler
        config.Services.Replace(typeof(IExceptionHandler), new GlobalExceptionHandler());

        //Registering UnhandledExceptionLogger
        config.Services.Replace(typeof(IExceptionLogger), new UnhandledExceptionLogger());

        //Registering RequestResponseHandler
        config.MessageHandlers.Add(new RequestResponseHandler());

        //config.Routes.MapHttpRoute(
        //    name: "DefaultApi",
        //    routeTemplate: "api/{controller}/{id}",
        //    defaults: new { id = RouteParameter.Optional }
        //);
    }
}

After completing with registering method, next we are going to add another API controller with the name “Values2Controller”.

Adding Values2Controller API Controller

If you see, we have added Values2 name API controller we have added a version in the name of the controller is not mandatory to add but the name must be unique and easy to understand.

C#
public class Values2Controller : ApiController
{
    // GET: api/Values2
    public IEnumerable<string> Get()
    {
        return new string[] { "value1", "value2" };
    }

    // GET: api/Values2/5
    public string Get(int id)
    {
        return "value";
    }

    // POST: api/Values2
    public void Post([FromBody]string value){}

    // PUT: api/Values2/5
    public void Put(int id, [FromBody]string value) {}

    // DELETE: api/Values2/5
    public void Delete(int id) {}
}

After adding Values2 API controller, next we are going to add Route Attributes to both API controller the old one also and the new one also.

Adding ApiVersion Attribute and Route Attribute to Values API Controller

C#
[ApiVersion("1.0")]
[Route("api/values")]
public class ValuesController : ApiController
{
    // GET api/values
    public IEnumerable<string> Get()
    {
        //throw new NotImplementedException("");
        return new string[] { "value1", "value2" };
    }

    // GET api/values/5
    public string Get(int id) {  return "value";}

    // POST api/values
    public void Post([FromBody]string value){}

    // PUT api/values/5
    public void Put(int id, [FromBody]string value){}

    // DELETE api/values/5
    public void Delete(int id) {} 
}

Adding ApiVersion Attribute and Route Attribute to Values2 API Controller

C#
[ApiVersion("2.0")]
[Route("api/values")]
public class Values2Controller : ApiController
{
    // GET api/Values2
    public IEnumerable<string> Get()
    {
        //throw new NotImplementedException("");
        return new string[] { "version2", " version2" };
    }

    // GET api/Values2/5
    public string Get(int id){return "value";}

    // POST api/Values2
    public void Post([FromBody]string value) { }

    // PUT api/Values2/5
    public void Put(int id, [FromBody]string value) { }

    // DELETE api/Values2/5
    public void Delete(int id) { }
}

After adding routes and version attribute, next save and run the application.

Now to call the API, we need to pass an API version from the header and the name of the header is “version”.

We are going to pass header name “version” and value as 1.0 to the calling values controller.

Requesting Values API

After completing with calling version 1.0 values API, next in the same way we are going to call values2 API with version 2.0 header.

We are going to pass header name “version” and value as 2.0 to the calling Values2 controller.

Image 29

After accessing values controller (values2controller) with version 2.0 header, we got a valid response which we were expecting.

Conclusion

In this article, we have learned how to “handle exceptions”, “Log Exceptions” and also learned how to log each incoming and outgoing request and response of web API along with it, how we can do versioning of web API such that I should not break existing working APIs, this all we learned in a step by step manner and in detailed manner such that we can directly integrate with Live Projects.

Thank you! I hope you liked my article.

History

  • 3rd July, 2018: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)


Written By
Technical Lead
India India
Microsoft Most Valuable Professional
Code Project Most Valuable Author
C# Corner Most Valuable Professional

I am Senior Technical lead Working on.Net Web Technology
ASP.NET MVC,.Net Core,ASP.NET CORE, C#, SQL Server, MYSQL, MongoDB, Windows

Comments and Discussions

 
Questiongreat! Pin
Southmountain28-Dec-21 6:13
Southmountain28-Dec-21 6:13 
GeneralMy vote of 5 Pin
Niels Peter Gibe20-Oct-21 2:22
Niels Peter Gibe20-Oct-21 2:22 
GeneralMy vote of 5 Pin
Ștefan-Mihai MOGA19-Oct-21 14:30
professionalȘtefan-Mihai MOGA19-Oct-21 14:30 
Questionsystem.web.http.Exceptionhandling Pin
Member 145532647-Aug-19 5:05
Member 145532647-Aug-19 5:05 
QuestionError with JWT, help me!!! Pin
Tam Nguyen18-Apr-19 23:36
Tam Nguyen18-Apr-19 23:36 
QuestionVersioning question Pin
rekincaid14-Feb-19 4:24
rekincaid14-Feb-19 4:24 
PraiseNice Article Pin
madhan20085-Jul-18 23:04
madhan20085-Jul-18 23:04 
GeneralRe: Nice Article Pin
Saineshwar Bageri8-Jul-18 2:39
Saineshwar Bageri8-Jul-18 2:39 
GeneralRe: Nice Article Pin
madhan20088-Jul-18 15:54
madhan20088-Jul-18 15:54 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.