viernes, 25 de noviembre de 2011

Cómo capturar el tráfico de red en ASP.NET

En nuestra empresa hemos desarrollado una aplicación que queremos comercializar en un modelo Saas y desplegarla en Windows Azure.

Uno de los mayores problemas que nos encontramos en este escenario, es el de conocer qué tráfico de red genera cada usuario, para poder después repercutir ciertos costes de la infraestructura al cliente y empresa adecuada.

Siendo así, hemos pensando en desarrollar una herramienta que sea capaz de capturar el tráfico tanto de entrada como de salida de la aplicación y guardar un registro de esta información.

En principio, cualquiera podría pensar que estamos reinventado la rueda y para que obtener esta información existen los log de servidor de IIS, pero el desarrollo ad-hoc de esta aplicación se sustenta en las siguientes premisas:

  • Si desplegamos nuestra aplicación en Windows Azure, aparentemente no tendremos acceso a los logs de IIS (lo cierto es que no estoy seguro de esta afirmación, pero creo estar en lo cierto). Pues me confirman que sí, sí se puede tener acceso a logs de IIS, tanto en la máquina local accediendo a través de Remote Destkop como transfiriendo los logs al Blob Store (gracias a Carlos Alfaya, de Nextel, por la información).
  • Aunque tuviéramos acceso a los log de IIS, resultaría complicado saber qué petición corresponde a qué usuario. Es decir, el usuario actual de la petición (según el sistema de seguridad de Membership), se guarda en la cookie .ASPXAUTH y además esta cookie esta encriptada por defecto, así que probablemente sería harto complicado después desencriptar la cookie para saber a qué usuario pertenece la petición.
  • Como queremos que nuestra aplicación sea un Saas puro (soñar es gratis), sólo queremos tener una instancia de la aplicación para dar soporte a distintas empresas y por ende a usuarios de estas empresas. Siendo así, la información de tráfico que pudiéramos obtener desde Windows Azure sería siempre relativa a la instancia y no podríamos discriminar el consumo por cliente.

Una vez habiendo explicado qué motivos nos han conducido a desarrollar nuestra propia solución, es el momento de explicar que información queremos recabar exactamente:

  • Tamaño de la petición.
  • Tamaño de la respuesta.
  • Usuario que generó la petición y respuesta.

Para lograr esto, hemos optado por crear un módulo HTTP.

El primer problema que nos surge es que queremos capturar todo el tráfico con independencia del tipo de recurso solicitado. Esto es que nos da igual si el usuario solicitó un fichero .aspx o un fichero .jpg, un recurso dinámico o un recurso estático, ambos deberían ser procesados por nuestro módulo.

En esta situación es indispensable conocer la versión de IIS donde será desplegada nuestra aplicación. Esto es así porque sólo con IIS 7.0 o superior y con el modelo de canalización integrada, todas las peticiones y con independencia de su tipo, serán gestionadas por ASP.NET y provocarán la ejecución de nuestro módulo.

Aunque en IIS 6.0 podríamos mapear extensiones al filtro ISAPI de ASP.NET, llegamos a la conclusión de que mejor desplegar nuestra aplicación en un Windows Server 2008 y olvidarnos de esta configuración extra.

Cabe mencionar que en cualquier caso, esto es algo que no debería preocupar a nuestro módulo y que sería un hecho transparente al mismo.

Volviendo a la implementación del módulo, éste sería mucho más sencillo si pudiéramos recuperar la cabecera Content-Length desde ASP.NET, pero parecer ser que esta cabecera la crea automáticamente IIS y ASP.NET no conoce de su existencia.

Igualmente, podríamos haber activado en IIS la compresión HTTP, tanto para recursos estáticos como para recursos dinámicos, y de nuevo este hecho es transparente para ASP.NET que no sabe si el recurso solicitado va a ser o no comprimido por IIS.

A este respecto, cabe mencionar que no deberíamos confundir que el cliente acepte la compresión HTTP (según la cabecera Accept-Encoding), a que, efectivamente, se vaya a comprimir la respuesta. Es decir, yo puedo con mi IE aceptar compresión pero el servidor comprimirá o no, eso yo no lo puedo saber.

Otra afirmación que me gustaría compartir, porque no estoy seguro de la misma, es que un cliente nunca comprime la petición. Es decir, un PostBack nunca sube al servidor comprimido con GZIP, son las respuestas del servidor las que pueden o no estar comprimidas, pero la petición nunca.

Como seguro que ya te estás cansando de la previa, vamos directamente a la implementación.

Lo primero es definir una tabla donde guardar la información:

image

  • BandwidthId.
    • Autonúmerico.
  • ApplicationPath.
    • Request.ApplicationPath.
  • Url.
    • Request.RawUrl
  • UserName.
    • User.Identity.Name
  • Request.
    • Request.ContentLength
  • Response.
    • Calculado. La madre del cordero.
  • Compressed.
    • Indica si la respuesta se devolvió comprimida.
  • CompressedResponse.
    • Calculado. La otra madre del cordero.
  • CreatedDate.
    • Fecha de creación automática.
CREATE TABLE [dbo].[Bandwidth](
	[BandwidthId] [int] IDENTITY(1,1) NOT NULL,
	[ApplicationPath] [nvarchar](256) NOT NULL,
	[Url] [nvarchar](max) NOT NULL,
	[UserName] [nvarchar](256) NULL,
	[Request] [int] NOT NULL,
	[Response] [int] NOT NULL,
	[Compressed] [bit] NOT NULL,
	[CompressedResponse] [int] NOT NULL,
	[CreatedDate] [datetime] NOT NULL,
 CONSTRAINT [PK_Bandwidth] PRIMARY KEY CLUSTERED 
(
	[BandwidthId] ASC
)WITH (PAD_INDEX  = OFF, STATISTICS_NORECOMPUTE  = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS  = ON, ALLOW_PAGE_LOCKS  = ON) ON [PRIMARY]
) ON [PRIMARY]
GO
ALTER TABLE [dbo].[Bandwidth] ADD  CONSTRAINT [DF_Bandwidth_Compressed]  DEFAULT ((0)) FOR [Compressed]
GO
ALTER TABLE [dbo].[Bandwidth] ADD  CONSTRAINT [DF_Bandwidth_CreatedDate]  DEFAULT (getdate()) FOR [CreatedDate]
GO

El código del módulo es el siguiente, donde a grandes rasgos:



  • Crearemos un módulo HTTP para participar del pipeline de ASP.NET.
  • Crearemos un nuevo filtro en la respuesta con el propósito exclusivo de ir acumulando el total de bytes escritos en el OutputStream.
  • Según configuración, también se guardará una copia del OutputStream en un nuevo Stream del tipo MemoryStream que nos permitirá después jugar con ella sin las limitaciones impuestas por OutputStream.
  • Finalmente, grabaremos un registro con la información obtenida.
  1: Imports System.IO
  2: Imports System.IO.Compression
  3: Imports System.Configuration
  4: Imports System.Web
  5: 
  6: Public Class ContentLengthStream
  7:     Inherits MemoryStream
  8: 
  9:     Private output As Stream
 10:     Private copyOutput As Boolean
 11:     Private totalCount As Integer
 12:     Private memoryStream As MemoryStream
 13: 
 14:     Public Sub New(ByVal output As Stream, ByVal copyOutput As Boolean)
 15:         Me.output = output
 16:         Me.copyOutput = copyOutput
 17:         ' Sólo si copyOutput, se crea el objeto memoryStream
 18:         If copyOutput Then
 19:             memoryStream = New MemoryStream()
 20:         End If
 21:     End Sub
 22: 
 23:     Public Overrides Sub Write(buffer() As Byte, offset As Integer, count As Integer)
 24:         totalCount += count
 25:         HttpContext.Current.Items("TotalCount") = totalCount
 26:         ' Sólo si copyOutput, se escribe en el objeto memoryStream
 27:         If copyOutput Then
 28:             memoryStream.Write(buffer, offset, count)
 29:             HttpContext.Current.Items("OutputStream") = memoryStream
 30:         End If
 31:         output.Write(buffer, offset, count)
 32:     End Sub
 33: End Class
 34: 
 35: Public Class Logger
 36:     Implements IHttpModule
 37: 
 38:     Private Function GetCompressionLength() As Integer
 39:         Dim targetStream As New MemoryStream()
 40:         Dim gzip As New GZipStream(targetStream, CompressionMode.Compress)
 41:         Dim outputStream As MemoryStream = CType(HttpContext.Current.Items("OutputStream"), MemoryStream)
 42:         Dim buffer() As Byte = outputStream.ToArray()
 43:         gzip.Write(buffer, 0, buffer.Length)
 44:         Return targetStream.Length
 45:     End Function
 46: 
 47:     Private Function IsCompressedFileExtension() As Boolean
 48:         Dim extension As String = VirtualPathUtility.GetExtension(HttpContext.Current.Request.RawUrl)
 49:         If extension.StartsWith(".") Then
 50:             extension = extension.Substring(1)
 51:         End If
 52:         Dim q As Integer =
 53:             Aggregate r In ConfigurationManager.AppSettings.Item("BandwidthCompressedFileExtensions").Split(",")
 54:             Where String.Equals(r, extension, StringComparison.OrdinalIgnoreCase)
 55:             Into Count()
 56:         Return If(q = 1, True, False)
 57:     End Function
 58: 
 59:     Public Sub Dispose() Implements System.Web.IHttpModule.Dispose
 60:     End Sub
 61: 
 62:     Public Sub Init(context As System.Web.HttpApplication) Implements System.Web.IHttpModule.Init
 63:         AddHandler context.PostReleaseRequestState, AddressOf PostReleaseRequestState
 64:         AddHandler context.PreSendRequestHeaders, AddressOf PreSendRequestHeaders
 65:     End Sub
 66: 
 67:     Protected Sub PostReleaseRequestState(sender As Object, e As System.EventArgs)
 68:         Dim copyOutput As Boolean
 69:         ' Si el fichero va a ser devuelto comprimido y se quiere calcular el tamaño comprimido
 70:         If IsCompressedFileExtension() AndAlso ConfigurationManager.AppSettings.Item("BandwidthComputeCompression") Then
 71:             copyOutput = True
 72:         End If
 73:         HttpContext.Current.Response.Filter = New ContentLengthStream(HttpContext.Current.Response.Filter, copyOutput)
 74:     End Sub
 75: 
 76:     Protected Sub PreSendRequestHeaders(sender As Object, e As System.EventArgs)
 77:         If HttpContext.Current.Response.StatusCode = 200 Then
 78: 
 79:             Dim requestContentLength As Integer = HttpContext.Current.Request.ContentLength
 80:             Dim responseContentLength As Integer = Convert.ToInt32(HttpContext.Current.Items("TotalCount"))
 81:             Dim compressedResponseContentLength As Integer = 0
 82: 
 83:             If requestContentLength > 0 OrElse responseContentLength > 0 Then
 84: 
 85:                 Dim userName As String = String.Empty
 86:                 If HttpContext.Current.User.Identity.IsAuthenticated Then
 87:                     userName = HttpContext.Current.User.Identity.Name
 88:                 End If
 89: 
 90:                 Dim compressed As Boolean = IsCompressedFileExtension()
 91: 
 92:                 If compressed AndAlso ConfigurationManager.AppSettings.Item("BandwidthComputeCompression") AndAlso responseContentLength > 0 Then
 93:                     compressedResponseContentLength = GetCompressionLength()
 94:                 End If
 95: 
 96:                 Using cnn As New SqlClient.SqlConnection(ConfigurationManager.AppSettings.Item("BandwidthConnectionString"))
 97:                     cnn.Open()
 98:                     Dim cmdText As String = "INSERT INTO Bandwidth (ApplicationPath, Url, UserName, Request, Response, Compressed, CompressedResponse) VALUES (@ApplicationPath, @Url, @UserName, @Request, @Response, @Compressed, @CompressedResponse)"
 99:                     Using cmd As New SqlClient.SqlCommand(cmdText, cnn)
100:                         cmd.Parameters.AddWithValue("@ApplicationPath", HttpContext.Current.Request.ApplicationPath)
101:                         cmd.Parameters.AddWithValue("@Url", HttpContext.Current.Request.RawUrl)
102:                         cmd.Parameters.AddWithValue("@UserName", If(userName = String.Empty, Convert.DBNull, userName))
103:                         cmd.Parameters.AddWithValue("@Request", requestContentLength)
104:                         cmd.Parameters.AddWithValue("@Response", responseContentLength)
105:                         cmd.Parameters.AddWithValue("@Compressed", compressed)
106:                         cmd.Parameters.AddWithValue("@CompressedResponse", compressedResponseContentLength)
107:                         cmd.ExecuteNonQuery()
108:                     End Using
109:                 End Using
110:             End If
111:         End If
112: 
113:         If Not HttpContext.Current.Items("OutputStream") Is Nothing Then
114:             CType(HttpContext.Current.Items("OutputStream"), MemoryStream).Dispose()
115:             HttpContext.Current.Items("OutputStream") = Nothing
116:         End If
117:     End Sub
118: End Class

Los parámetros de configuración que utiliza nuestro módulo a través de appSettings del fichero web.config son los siguientes:



  • BanwidthConnectionString.

    • Cadena de conexión contra un servidor Sql Server donde se guardará la información obtenida.

  • BandwidthCompressedFileExtensions.

    • Lista de extensiones separadas por comas, que sabemos van a ser devueltas comprimidas por IIS. Reitero que esta información es necesaria porque desde el módulo de ASP.NET no somos capaces de saber si el recurso va a ser o no devuelto comprimido. Siendo así, hay que especificarlo manualmente.

  • BandwidthComputeCompression.

    • Indica si cuando encontramos un recurso que va ser devuelto comprimido (según BandwidthCompressedFileExtensions), nuestro módulo debe comprimir la copia de OutputStream para grabar el dato de la columna CompressedResponse.

Esta claro que el parámetro más agresivo es BandwidthComputeCompression, que en el caso de estar activo, fuerza al módulo a crear una copia de Response.OutputStream en memoria para después comprimirla con GZip. Exactamente no sabría calibrar el impacto de esta tarea por cada petición, pero está claro que entre hacerlo o no hacerlo, es más óptimo no hacerla.


En cualquier caso, siempre se podría desactivar este parámetro y luego hacer un ratio aproximado de cuanto sería comprimida la respuesta a través de una consulta Sql.


Lo que quiero decir con aproximado es que tengo claro que esta solución no es un capturador de tráfico definitivo por lo siguientes motivos:



  • Sólo captura tráfico del cuerpo de la petición. Esto es que las cabeceras de la respuesta no están incluidas.
  • Si comprimimos la respuesta, he visto con la ayuda de Fiddler, que mi compresión no es el mismo valor que la compresión que realmente devuelve Fidderl, de hecho ni el propio Fiddler arroja el mismo valor si cambiamos entre “No Compression” y “GZip Encoding”. Por ejemplo:

Respuesta original (362 bytes)


image


Ahora pulso en “No Compression” y obtengo 337 bytes


image


Ahora pulso de nuevo en GZIP Encoding y obtengo… 250 bytes! (no 362 como era de esperar)


image


Esta claro que en 2 días no nos vamos a hacer un super-módulo sniffer de tráfico HTTP, pero al menos queremos tener una información muy aproximada de que consumo de tráfico genera que usuario.


En cualquier caso, este módulo está aún en fase beta y el tiempo nos dará o nos quitará la razón.


Por último, un fichero web.config válido para hacer ejecutar el módulo sería el siguiente:

<?xml version="1.0"?>
<configuration>
  <appSettings>
    <add key="BandwidthConnectionString" value="Data Source=(local);Initial Catalog=Bandwidth;Persist Security Info=True;User ID=sa;Password=******"/>
    <add key="BandwidthComputeCompression" value="True"/>
    <add key="BandwidthCompressedFileExtensions" value="aspx,js,htm"/>
  </appSettings>
  <system.web>
    <compilation strict="false" explicit="true" targetFramework="4.0"/>
  </system.web>
  <system.webServer>
    <modules>
      <add name="Bandwidth" type="Bandwidth.Logger"/>
    </modules>
  </system.webServer>
</configuration>

Un saludo!

No hay comentarios:

Publicar un comentario