Uso de Graylog y NLog para recopilar registros de aplicaciones de C #. Experiencia personal


KDPV

Habr, bienvenido!

Lo siguiente no es en modo alguno un tutorial o una mejor práctica . Decidí solo agregar y documentar mis logros en la pregunta planteada.

Espero que el contenido de este artículo permita a quienes buscan información sobre el registro aprender algo nuevo o tomar alguna decisión. Y, por supuesto, espero obtener una respuesta constructiva de la comunidad. Da la oportunidad de hacer algo mejor.

¿Para qué? ¿Para quien?


Trataré de describir qué es el registro en mis propias palabras:
Guardar información legible para humanos sobre un evento positivo o negativo durante la ejecución del código del programa, posiblemente con la preservación de indicadores y datos cualitativos o cuantitativos como parte de la información almacenada con referencia al momento en que ocurrió el evento.
Salió una formulación complicada, pero en el caso general describe completamente la tarea de crear y mantener registros. El registro de alta calidad ayuda al desarrollador a controlar el estado interno del código en cualquier etapa de su vida: desde la depuración hasta la instalación en un entorno desconocido de la computadora del consumidor.

Bueno, vamos a mostrar cómo escribo registros.

Herramientas


Omitimos la consideración de iniciar sesión con la ayuda de mensajes de texto poco informativos como "La base de datos no está disponible ", " Error al guardar el archivo " y similares. Los registros de este tipo son fáciles de crear, pero a menudo no son suficientes para comprender la esencia y las fuentes del problema en el código o el entorno. En algún momento, cada desarrollador solo necesita crear un registro estructurado. Como mi buen amigo y mentor de TI me dijo: " Reúna todo, luego excluya innecesarios ... " Los marcos especializados nos ayudarán con esto.

Hay suficientes de ellos. La mayoría de ellos tienen capacidades y limitaciones similares. Se han escrito muchos artículos y reseñas sobre ellos., comparaciones y manuales. Para iniciar sesión en aplicaciones escritas en C #, una vez elegí NLog . Ahora no recuerdo por qué fue él, pero sucedió.

Esta plataforma tiene muy buena documentación y casos de uso extremadamente útiles . Naturalmente, en diferentes momentos y para diferentes proyectos, usé NLog de diferentes maneras. Pero en algún momento nació un código que ahora se usa como un fragmento y prácticamente no cambia.

Implementación


¡A negociar! Muestra el código:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using Newtonsoft.Json;
using NLog;
using NLog.Config;
using NLog.Layouts;
using NLog.Targets;
using NLog.Targets.GraylogHttp;
using NLog.Targets.Wrappers;

namespace BIMLIB.Converter
{
	public static class BimlibLogger
	{
		private static readonly ApplicationSettings _settings = BimlibSettingsManager.Instance.AppSettings;
		private static Lazy<LogFactory> _instance = null;
		private static LoggingConfiguration _logConfig = null;

		private static Lazy<LogFactory> Instance
		{
			get
			{
				return _instance ?? (_instance = new Lazy<LogFactory>(BuildLogFactory));
			}
		}

		public static Logger GetLogger()
		{
			return Instance.Value.GetCurrentClassLogger();
		}

		private static LogFactory BuildLogFactory()
		{
			LoggingConfiguration config = _logConfig ?? new LoggingConfiguration();
			//     .   .
			string headerStr = _settings.LogHeader;
			//    .       - , : https://github.com/drewnoakes/figgle
			Layout header = headerStr;

			//      : https://nlog-project.org/config/?tab=layout-renderers
			//     :
			// -   : 2020-04-24 19:13:51.1620 [BIMLIB.Converter.MainClass.Main] -> I : Service starting...
			// -  : 2020-04-22 09:55:33.1132 [BIMLIB.Converter.Converter.ClearFile] -> E : mscorlib
			//{
			//	"Type":"System.IO.FileNotFoundException", "Message":" 'D:\\path\\to\\file\\file_name.ifc'  .", "FileName":"D:\\path\\to\\file\\file_name.ifc", "Data":{
			//	}, "TargetSite":"Void WinIOError(Int32, System.String)", "StackTrace":"    System.IO.__Error.WinIOError(Int32 errorCode, String maybeFullPath)\r\n    System.IO.FileInfo.get_Length()\r\n    BIMLIB.Converter.Converter.ClearFile(String path)", "Source":"mscorlib", "HResult":-2147024894}

			Layout layout = "${longdate} [${callsite:className=true:includeNamespace=true:fileName=false:includeSourcePath=false:methodName=true:cleanNamesOfAnonymousDelegates=true:cleanNamesOfAsyncContinuations=true}] -> ${level:format=FirstCharacter} : ${message}${onexception:inner=${newline}${exception:format=@}}";

			#region Targets ----------------------------

			#region FileTarget ----------------------------

			Target fileTarget = FileTarget(header, layout).MakeAsyncTarget();
			config.AddTarget(fileTarget);
			config.AddRuleForAllLevels(fileTarget);

			#endregion

			#region ConsoleTarget ----------------------------

			Target consoleTarget = ConsoleTarget(header, layout).MakeAsyncTarget();
			config.AddTarget(consoleTarget);
			config.AddRuleForAllLevels(consoleTarget);

			#endregion

			#region DebuggerTarget ----------------------------

			Target debugTarget = DebuggerTarget(header, layout).MakeAsyncTarget();
			config.AddTarget(debugTarget);
			config.AddRuleForAllLevels(debugTarget);

			#endregion

			#region GelfTarget ----------------------------

			//       Graylog  ,    
			if (_settings.Statistics)
			{
				Target gelfTarget = GelfTarget(headerStr).MakeAsyncTarget();
				config.AddTarget(gelfTarget);
				config.AddRuleForAllLevels(gelfTarget);
			}

			#endregion

			#endregion

			LogFactory logFactory = new LogFactory
			{
				Configuration = config
			};

			try
			{
				// ,           
				config.LoggingRules.ToList().ForEach(r => r.SetLoggingLevels(LogLevel.AllLevels.Min(), LogLevel.AllLevels.Max()));

				_logConfig = config;
			}
			catch (Exception ex)
			{
				//  ,        
				Debug.Write(ex);
			}

			return logFactory;
		}

		#region Target Methods

		private static FileTarget FileTarget(Layout header, Layout layout)
		{
			#region FileTarget ----------------------------

			FileTarget fileTarget = new FileTarget("log_file_target")
			{
				ArchiveAboveSize = 1048576,
				ArchiveDateFormat = "yyyy.MM.dd_HH.mm.ss",
				ArchiveEvery = FileArchivePeriod.Day,
				ArchiveFileName = GetApplicationLogAndArchivePath(false),
				ArchiveNumbering = ArchiveNumberingMode.Date,
				ArchiveOldFileOnStartup = false,
				AutoFlush = true,
				ConcurrentWrites = true,
				DeleteOldFileOnStartup = false,
				EnableArchiveFileCompression = true,
				EnableFileDelete = true,
				Encoding = Encoding.UTF8,
				FileName = GetApplicationLogAndArchivePath(true),
				Header = header,
				Layout = layout,
				MaxArchiveFiles = 100,
				OpenFileCacheTimeout = 30,
				OpenFileFlushTimeout = 30,
				OptimizeBufferReuse = true
			};

			#endregion

			return fileTarget;
		}

		private static ColoredConsoleTarget ConsoleTarget(Layout header, Layout layout)
		{
			#region ConsoleTarget ----------------------------

			ColoredConsoleTarget consoleTarget = new ColoredConsoleTarget("log_console_target")
			{
				Encoding = Encoding.UTF8,
				EnableAnsiOutput = false,

				UseDefaultRowHighlightingRules = true,

				Layout = layout,
				Header = header
			};

			ConsoleWordHighlightingRule dateHighLightRule = new ConsoleWordHighlightingRule
			{
				Regex = @"^(?=\d).+(?=\s\[)",
				ForegroundColor = ConsoleOutputColor.Yellow
			};

			ConsoleWordHighlightingRule methodsHighLightRule = new ConsoleWordHighlightingRule
			{
				Regex = @"(?<=\[).+(?=\])",
				ForegroundColor = ConsoleOutputColor.Blue
			};

			ConsoleWordHighlightingRule levelHighLightRule = new ConsoleWordHighlightingRule
			{
				Regex = @"(?<=>).+(?=\s:)",
				ForegroundColor = ConsoleOutputColor.Red
			};

			ConsoleWordHighlightingRule messageHighLightRule = new ConsoleWordHighlightingRule
			{
				Regex = @"(?<=\s:\s).+",
				ForegroundColor = ConsoleOutputColor.Green
			};

			consoleTarget.WordHighlightingRules.Add(dateHighLightRule);
			consoleTarget.WordHighlightingRules.Add(methodsHighLightRule);
			consoleTarget.WordHighlightingRules.Add(levelHighLightRule);
			consoleTarget.WordHighlightingRules.Add(messageHighLightRule);

			#endregion

			return consoleTarget;
		}

		private static DebuggerTarget DebuggerTarget(Layout header, Layout layout)
		{
			#region DebuggerTarget ----------------------------

			DebuggerTarget debugTarget = new DebuggerTarget("log_debug_target")
			{
				Layout = layout,
				Header = header
			};

			#endregion

			return debugTarget;
		}

		private static GraylogHttpTarget GelfTarget(string header)
		{
			#region GelfTarget ----------------------------

			Layout gelfCommonLayout = "${message}";

			IList<TargetPropertyWithContext> gelfParameterInfos =
				new List<TargetPropertyWithContext>()
				{
					//     : https://nlog-project.org/config/?tab=layout-renderers
					new TargetPropertyWithContext()  { Name = "appdomain", Layout = "${appdomain}" },
					new TargetPropertyWithContext()  { Name = "assembly-version", Layout = "${assembly-version}" },
					new TargetPropertyWithContext()  { Name = "activityid", Layout = "${activityid}" },
					new TargetPropertyWithContext()  { Name = "callsite", Layout = "${callsite}" },
					new TargetPropertyWithContext()  { Name = "callsite-linenumber", Layout = "${callsite-linenumber}" },
					new TargetPropertyWithContext()  { Name = "environment-user", Layout = "${environment-user:userName=true:domain=true}" },
					new TargetPropertyWithContext()  { Name = "exeption_json_data", Layout = "${onexception:inner=${exception:format=@}}" },
					new TargetPropertyWithContext()  { Name = "frameWorkInfo", Layout = $"{RuntimeInformation.FrameworkDescription} ({RuntimeInformation.ProcessArchitecture})" },
					new TargetPropertyWithContext()  { Name = "guid", Layout = "${guid:format=N}" },
					new TargetPropertyWithContext()  { Name = "hostname", Layout = "${hostname}" },
					new TargetPropertyWithContext()  { Name = "identity", Layout = "${identity:authType=true:separator=\n:name=true:isAuthenticated=true}" },
					new TargetPropertyWithContext()  { Name = "level_name", Layout = "${level:format=Name}" },
					new TargetPropertyWithContext()  { Name = "local-ip", Layout = "${local-ip:addressFamily=InterNetwork}" },
					new TargetPropertyWithContext()  { Name = "logger", Layout = "${logger:shortName=false}" },
					new TargetPropertyWithContext()  { Name = "machinename", Layout = "${machinename}" },
					new TargetPropertyWithContext()  { Name = "osInfo", Layout = $"{RuntimeInformation.OSDescription} ({RuntimeInformation.OSArchitecture})" },
					new TargetPropertyWithContext()  { Name = "processid", Layout = "${processid}" },
					new TargetPropertyWithContext()  { Name = "processinfo_MainWindowHandle", Layout = "${processinfo:property=MainWindowHandle}" },
					new TargetPropertyWithContext()  { Name = "processinfo_PagedMemorySize", Layout = "${processinfo:property=PagedMemorySize}" },
					new TargetPropertyWithContext()  { Name = "processname", Layout = "${processname:fullName=true}" },
					new TargetPropertyWithContext()  { Name = "processtime", Layout = "${processtime:invariant=false}" },
					new TargetPropertyWithContext()  { Name = "sequenceid", Layout = "${sequenceid}" },
					new TargetPropertyWithContext()  { Name = "stacktrace", Layout = "${stacktrace:format=Raw:topFrames=3:skipFrames=0:separator=
}" },
					new TargetPropertyWithContext()  { Name = "threadid", Layout = "${threadid}" },
					new TargetPropertyWithContext()  { Name = "threadname", Layout = "${threadname}" },
					new TargetPropertyWithContext()  { Name = "timestamp", Layout = $"{DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()}" },
					new TargetPropertyWithContext()  { Name = "timestamp_local", Layout = @"${date:universalTime=false:format=yyyy-MM-dd HH\:mm\:ss zzz}" },
					new TargetPropertyWithContext()  { Name = "windows-identity", Layout = "${windows-identity:userName=true:domain=true}" }
				};

			GraylogHttpTarget gelfUdpTarget = new GraylogHttpTarget
			{
				AddNLogLevelName = true,
				Facility = header,
				GraylogServer = _settings.LogServerAddress,
				IncludeCallSite = true,
				IncludeCallSiteStackTrace = true,
				IncludeEventProperties = true,
				Layout = gelfCommonLayout,
				Name = "GelfHttp",
				OptimizeBufferReuse = true
			};

			foreach (TargetPropertyWithContext gelfParameterInfo in gelfParameterInfos)
			{
				gelfUdpTarget.ContextProperties.Add(gelfParameterInfo);
			}

			#endregion

			return gelfUdpTarget;
		}

		//   https://github.com/nlog/NLog/wiki/AsyncWrapper-target    
		private static Target MakeAsyncTarget(this Target targ)
		{
			return new AsyncTargetWrapper
			{
				BatchSize = 100,
				ForceLockingQueue = true,
				FullBatchSizeWriteLimit = 5,
				Name = targ.Name,
				OptimizeBufferReuse = true,
				OverflowAction = AsyncTargetWrapperOverflowAction.Grow,
				QueueLimit = 10000,
				TimeToSleepBetweenBatches = 1,
				WrappedTarget = targ
			};
		}

		#endregion

		private static string GetApplicationLogAndArchivePath(bool isLog)
		{
			string addition;

			if (!isLog)
			{
				addition = ".{#}.zip";
			}
			else
			{
				addition = ".log";
			}

			try
			{
				if (!Directory.Exists(_settings.LogsFolder))
				{
					Directory.CreateDirectory(_settings.LogsFolder);
				}

				return Path.Combine(_settings.LogsFolder, _settings.ProductName + addition);
			}
			catch (Exception ex)
			{
				Debug.Write(ex);
				return string.Empty;
			}
		}
}

Ejemplo de uso:
using NLog;
...

public static class Downloader
	{
		...
		private static readonly Logger log = BimlibLogger.GetLogger();
		...
		public static string GetFileToConvert(ConverterRequest.Inputitem inputItem)
		{
			...
			try
			{
				...
				log.Debug($"Downloaded file: {downloadedFile}, size: {new FileInfo(downloadedFile).Length / 1e6}Mb");
			}
			catch (Exception ex)
			{
				log.Error(ex, ex.Source);
				return ...;
			}
		}
	}

Algunos detalles


El código simple, aunque con comentarios, no es genial. Por lo tanto, describiré una serie de convenciones y suposiciones:

  1. _settings : un determinado objeto de configuración. No es demasiado importante cómo se forma exactamente, pero debe recibirse antes de la primera inicialización del registrador.
  2. _logConfig : configuración de NLog, realizada no en un archivo NLog.config separado , sino directamente en el código.

Como dice la documentación :
NLog solo producirá resultados si ha configurado uno (o más) objetivos NLog.
En realidad, los métodos en la región Métodos de destino son responsables de crear estos "objetivos":

FileTarget : para escribir el registro en un archivo.

¿Cómo se ve un archivo de registro?

ColouredConsoleTarget : para enviar mensajes "hermosos" a la consola (si corresponde).

¿Cómo se ve una consola de color?

DebuggerTarget : para enviar mensajes del registrador a la ventana de salida de Visual Studio o a un depurador conectado de terceros en modo de depuración.

¿Cómo se ven los mensajes NLog en el depurador?

GraylogHttpTarget : el objetivo es enviar mensajes al servidor con Graylog instalado allí .

¿Cómo se ve un mensaje de Graylog?

En mi primer artículo sobre Habr, mencioné el uso de Graylog. Me gustaría detenerme en el último gol de la lista. Es ella quien le permite enviar mensajes desde la aplicación al servicio Graylog utilizando la biblioteca NLog. Trataré de decirte cómo personalmente me gustó esta herramienta.

Sobre Graylog (un poco)


La descripción de la instalación y configuración del servicio no se incluirá en este artículo. Solo puedo decir que lo implementé en la ventana acoplable. Esto también se describe en la documentación :

Te doy mi archivo de redacción
version: '2'
services:
  # MongoDB: https://hub.docker.com/_/mongo/
  mongodb:
    image: mongo:4
    restart: always
    volumes:
    - mongo_data:/data/db:rw
    - /etc/localtime:/etc/localtime  
  # Elasticsearch: https://www.elastic.co/guide/en/elasticsearch/reference/6.x/docker.html
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch-oss:6.8.2
    restart: always
    volumes:
    - es_data:/usr/share/elasticsearch/data:rw
    - /etc/localtime:/etc/localtime
    environment:
      - http.host=0.0.0.0
      - transport.host=localhost
      - network.host=0.0.0.0
      - "ES_JAVA_OPTS=-Xms512m -Xmx512m"
    ulimits:
      memlock:
        soft: -1
        hard: -1
    mem_limit: 1g
  # Graylog: https://hub.docker.com/r/graylog/graylog/
  # Graylog: I want to have the lastest
  graylog:
    image: graylog/graylog:3.2
    restart: always
    volumes:
    - graylog_data:/usr/share/graylog/data/journal:rw
    - /etc/localtime:/etc/localtime
# :   .     - 
    - /home/MYUSER/graylog-data/plugin/graylog-plugin-telegram-notification-2.3.0.jar:/usr/share/graylog/plugin/graylog-plugin-telegram-notification-2.3.0.jar
    environment:
      # CHANGE ME (must be at least 16 characters)!
      - GRAYLOG_PASSWORD_SECRET=somepasswordpepper
      # Password: MYPASSWORD
      - GRAYLOG_ROOT_PASSWORD_SHA2=SHA2MYPASSWORDPLEASEINANYONLINESERVICE
      - GRAYLOG_HTTP_BIND_ADDRESS=0.0.0.0:9999
      - GRAYLOG_WEB_LISTEN_URI=http://my.ex.ipa.dr:9999/
      - GRAYLOG_HTTP_EXTERNAL_URI=http://my.ex.ipa.dr:9999/
      - GRAYLOG_HTTP_PUBLISH_URI=http://my.ex.ipa.dr:9999/
      - GRAYLOG_ROOT_TIMEZONE=Europe/Moscow
      - GRAYLOG_PLUGIN_DIR=plugin
    links:
      - mongodb:mongo
      - elasticsearch
    depends_on:
      - mongodb
      - elasticsearch
    ports:
      - 5044:5044
      # Graylog web interface and REST API
      - 9999:9999
      # Syslog TCP
      - 1514:1514
      # Syslog UDP
      - 1514:1514/udp
      # GELF TCP
      - 12201:12201
      # GELF UDP
      - 12201:12201/udp
      #Volumes
volumes:
  mongo_data:
    driver: local
  es_data:
    driver: local
  graylog_data:
    driver: local

También agregaré un ejemplo de configuración de "Entrada" en Graylog:

Graylog escucha el tráfico http en el puerto 12201


Graylog nodes accept data via inputs. Launch or terminate as many inputs as you want here.


my.web.adress.domain 12201 -, . ...

Sobre GELF


GELF - Graylog Extended Log Format. El formato de los mensajes que Graylog percibe.

Para enviar algo en formato GELF a una determinada URL, deberá utilizar un objetivo adicional . Se presentan en la sección Integraciones de la documentación .

Elegí NLog.Targets.GraylogHttp . Nuget instalado en el proyecto y pude usar NLog.Targets.GraylogHttp.GraylogHttpTarget en la configuración de su registrador.

El diseño mismo de los mensajes lo he simplificado a un mensaje , pero llené ContextProperties su objetivo: una amplia gama de campos adicionales:

new TargetPropertyWithContext()  { Name = "someParamName1", Layout = "someStringInfo" },
new TargetPropertyWithContext()  { Name = "someParamName2", Layout = "someNLogLayoutRendered" },
new TargetPropertyWithContext()  { Name = "someParamName3", Layout = (string)SomeMethodThatGetsInfo() }
Usé específicamente código generalizado para aclarar el principio: "nombre arbitrario + valor arbitrario". El valor puede "minarse" con éxito por algún método propio. Por ejemplo, leer la configuración de red o el espacio libre en la unidad del sistema. Lo que sea ...

Prima


Existe una opinión (y no lo cuestiono) de que los registros son más útiles al detectar errores. Las construcciones try / catch se han utilizado con éxito y en la mayoría de los casos se justifican. El "objeto de error" en sí está lleno de información de depuración, datos sobre la cadena de eventos cuando ocurre, y otros. Es conveniente leer que un objeto así no siempre funciona.

Para mí, desarrollé esta solución:

Método de bonificación a la clase de registrador para enviar campos desde objetos a Graylog:
// -. ,    Graylog    - .
		//     objWithProps -            json (  GELF)...
		public static LogEventInfo GetLogEventWithProperties(LogLevel logLevel, string logName, string message, object objWithProps)
		{
			//     ,      
			if (string.IsNullOrEmpty(message))
			{
				message = objWithProps?.GetType()?.Name ?? "custom_json_data";
			}

			LogEventInfo e = new LogEventInfo(logLevel, logName, message);

			object logEventProperty = null;
			//     Newtonsoft.Json: https://www.newtonsoft.com/json
			JsonSerializerSettings jsonSerializerSettings = new JsonSerializerSettings
			{
				ReferenceLoopHandling = ReferenceLoopHandling.Serialize,
				PreserveReferencesHandling = PreserveReferencesHandling.Objects,
				NullValueHandling = NullValueHandling.Include
			};

			try
			{
				//  ""  ()   ...
				logEventProperty = JsonConvert.SerializeObject(objWithProps, Formatting.Indented, jsonSerializerSettings);
			}
			catch (Exception ex)
			{
				Debug.Write(ex);

				try
				{
					// ...,   :  ,   , ...
					IEnumerable<PropertyInfo> objProps = objWithProps?.GetType()?.GetProperties()?.Where(p => p?.GetGetMethod()?.GetParameters()?.Length == 0);

					if (objProps?.Any() == true)
					{
						// ...   ,...
						Dictionary<string, string> objPropsDict =
							objProps
							.ToDictionary(
								x => x?.Name,
								x =>
								{
									string rezVal = string.Empty;
									try
									{
										rezVal = x?.GetValue(objWithProps, null)?.ToString();
									}
									catch (Exception ex0)
									{
										Debug.Write(ex0);
									}
									return rezVal;
								}
							)?
							.OrderBy(x => x.Key)?
							.ToDictionary(obj => obj.Key, obj => obj.Value);
						// ...   Newtonsoft.Json
						logEventProperty = JsonConvert.SerializeObject(objPropsDict, Formatting.Indented, jsonSerializerSettings);
					}
				}
				catch (Exception ex1)
				{
					Debug.Write(ex1);
				}
			}
			//   json-,  Graylog      .
			e.Properties["custom_json_data"] = logEventProperty;
			return e;
		}


Ejemplo de uso:
log.Debug(BimlibLogger.GetLogEventWithProperties(LogLevel.Debug, log.Name, $"Got the task", message));


¿Cómo se ve en Graylog?
exeption_json_dataexception, json.



exception, .



Resultado


Como resultado de aplicar el enfoque descrito y una implementación razonable de las llamadas del registrador en el cuerpo del código, obtenemos una colección centralizada de registros con una interfaz conveniente para leerlos, analizarlos y analizarlos. Tenemos una herramienta conveniente para notificaciones sobre ciertos valores en los registros.
Además, los archivos de registro habituales, que también se pueden volver a leer, sacar conclusiones, no desaparecen. No es un competidor o un reemplazo para los sistemas de monitoreo. Pero ahora la situación "mi aplicación se ejecuta en más de 100 computadoras muy diferentes en más de 3 países de idiomas extranjeros y de repente se rompe en alguna parte" se resuelve un poco más fácil y mucho más rápido.

Es todo?


En principio, le dije todo lo que necesita para un registro relativamente cómodo de sus aplicaciones. Listo para discutir, obtener consejos.

Gracias por leer. Nos vemos en los comentarios.

UPD : se agregaron objetivos AsyncWrapper al trabajo en el consejojustmara

All Articles