A game framework written with osu! in mind.
at master 540 lines 22 kB view raw
1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence. 2// See the LICENCE file in the repository root for full licence text. 3 4using System; 5using System.Collections.Generic; 6using System.Diagnostics; 7using System.Globalization; 8using System.IO; 9using osu.Framework.Platform; 10using System.Linq; 11using System.Threading; 12using osu.Framework.Development; 13using osu.Framework.Statistics; 14using osu.Framework.Threading; 15 16namespace osu.Framework.Logging 17{ 18 /// <summary> 19 /// This class allows statically (globally) configuring and using logging functionality. 20 /// </summary> 21 public class Logger 22 { 23 private static readonly object static_sync_lock = new object(); 24 25 // separate locking object for flushing so that we don't lock too long on the staticSyncLock object, since we have to 26 // hold this lock for the entire duration of the flush (waiting for I/O etc) before we can resume scheduling logs 27 // but other operations like GetLogger(), ApplyFilters() etc. can still be executed while a flush is happening. 28 private static readonly object flush_sync_lock = new object(); 29 30 /// <summary> 31 /// Whether logging is enabled. Setting this to false will disable all logging. 32 /// </summary> 33 public static bool Enabled = true; 34 35 /// <summary> 36 /// The minimum log-level a logged message needs to have to be logged. Default is <see cref="LogLevel.Verbose"/>. Please note that setting this to <see cref="LogLevel.Debug"/> will log input events, including keypresses when entering a password. 37 /// </summary> 38 public static LogLevel Level = DebugUtils.IsDebugBuild ? LogLevel.Debug : LogLevel.Verbose; 39 40 /// <summary> 41 /// An identifier used in log file headers to figure where the log file came from. 42 /// </summary> 43 public static string UserIdentifier = Environment.UserName; 44 45 /// <summary> 46 /// An identifier for the game written to log file headers to indicate where the log file came from. 47 /// </summary> 48 public static string GameIdentifier = @"game"; 49 50 /// <summary> 51 /// An identifier for the version written to log file headers to indicate where the log file came from. 52 /// </summary> 53 public static string VersionIdentifier = @"unknown"; 54 55 private static Storage storage; 56 57 /// <summary> 58 /// The storage to place logs inside. 59 /// </summary> 60 public static Storage Storage 61 { 62 private get => storage; 63 set 64 { 65 storage = value ?? throw new ArgumentNullException(nameof(value)); 66 67 // clear static loggers so they are correctly purged at the new storage location. 68 static_loggers.Clear(); 69 } 70 } 71 72 /// <summary> 73 /// The target for which this logger logs information. This will only be null if the logger has a name. 74 /// </summary> 75 public LoggingTarget? Target { get; } 76 77 /// <summary> 78 /// The name of the logger. 79 /// </summary> 80 public string Name { get; } 81 82 /// <summary> 83 /// Gets the name of the file that this logger is logging to. 84 /// </summary> 85 public string Filename => $@"{Name}.log"; 86 87 private readonly GlobalStatistic<int> logCount; 88 89 private static readonly HashSet<string> reserved_names = new HashSet<string>(Enum.GetNames(typeof(LoggingTarget)).Select(n => n.ToLower())); 90 91 private Logger(LoggingTarget target = LoggingTarget.Runtime) 92 : this(target.ToString(), false) 93 { 94 Target = target; 95 } 96 97 private Logger(string name, bool checkedReserved) 98 { 99 name = name.ToLower(); 100 101 if (string.IsNullOrWhiteSpace(name)) 102 throw new ArgumentException("The name of a logger must be non-null and may not contain only white space.", nameof(name)); 103 104 if (checkedReserved && reserved_names.Contains(name)) 105 throw new ArgumentException($"The name \"{name}\" is reserved. Please use the {nameof(LoggingTarget)}-value corresponding to the name instead."); 106 107 Name = name; 108 logCount = GlobalStatistics.Get<int>(nameof(Logger), Name); 109 } 110 111 /// <summary> 112 /// Add a plain-text phrase which should always be filtered from logs. The filtered phrase will be replaced with asterisks (*). 113 /// Useful for avoiding logging of credentials. 114 /// See also <seealso cref="ApplyFilters(string)"/>. 115 /// </summary> 116 public static void AddFilteredText(string text) 117 { 118 if (string.IsNullOrEmpty(text)) return; 119 120 lock (static_sync_lock) 121 filters.Add(text); 122 } 123 124 /// <summary> 125 /// Removes phrases which should be filtered from logs. 126 /// Useful for avoiding logging of credentials. 127 /// See also <seealso cref="AddFilteredText(string)"/>. 128 /// </summary> 129 public static string ApplyFilters(string message) 130 { 131 lock (static_sync_lock) 132 { 133 foreach (string f in filters) 134 message = message.Replace(f, string.Empty.PadRight(f.Length, '*')); 135 } 136 137 return message; 138 } 139 140 /// <summary> 141 /// Logs the given exception with the given description to the specified logging target. 142 /// </summary> 143 /// <param name="e">The exception that should be logged.</param> 144 /// <param name="description">The description of the error that should be logged with the exception.</param> 145 /// <param name="target">The logging target (file).</param> 146 /// <param name="recursive">Whether the inner exceptions of the given exception <paramref name="e"/> should be logged recursively.</param> 147 public static void Error(Exception e, string description, LoggingTarget target = LoggingTarget.Runtime, bool recursive = false) 148 { 149 error(e, description, target, null, recursive); 150 } 151 152 /// <summary> 153 /// Logs the given exception with the given description to the logger with the given name. 154 /// </summary> 155 /// <param name="e">The exception that should be logged.</param> 156 /// <param name="description">The description of the error that should be logged with the exception.</param> 157 /// <param name="name">The logger name (file).</param> 158 /// <param name="recursive">Whether the inner exceptions of the given exception <paramref name="e"/> should be logged recursively.</param> 159 public static void Error(Exception e, string description, string name, bool recursive = false) 160 { 161 error(e, description, null, name, recursive); 162 } 163 164 private static void error(Exception e, string description, LoggingTarget? target, string name, bool recursive) 165 { 166 log($@"{description}", target, name, LogLevel.Error, e); 167 168 if (recursive && e.InnerException != null) 169 error(e.InnerException, $"{description} (inner)", target, name, true); 170 } 171 172 /// <summary> 173 /// Log an arbitrary string to the specified logging target. 174 /// </summary> 175 /// <param name="message">The message to log. Can include newline (\n) characters to split into multiple lines.</param> 176 /// <param name="target">The logging target (file).</param> 177 /// <param name="level">The verbosity level.</param> 178 /// <param name="outputToListeners">Whether the message should be sent to listeners of <see cref="Debug"/> and <see cref="Console"/>. True by default.</param> 179 public static void Log(string message, LoggingTarget target = LoggingTarget.Runtime, LogLevel level = LogLevel.Verbose, bool outputToListeners = true) 180 { 181 log(message, target, null, level, outputToListeners: outputToListeners); 182 } 183 184 /// <summary> 185 /// Log an arbitrary string to the logger with the given name. 186 /// </summary> 187 /// <param name="message">The message to log. Can include newline (\n) characters to split into multiple lines.</param> 188 /// <param name="name">The logger name (file).</param> 189 /// <param name="level">The verbosity level.</param> 190 /// <param name="outputToListeners">Whether the message should be sent to listeners of <see cref="Debug"/> and <see cref="Console"/>. True by default.</param> 191 public static void Log(string message, string name, LogLevel level = LogLevel.Verbose, bool outputToListeners = true) 192 { 193 log(message, null, name, level, outputToListeners: outputToListeners); 194 } 195 196 private static void log(string message, LoggingTarget? target, string loggerName, LogLevel level, Exception exception = null, bool outputToListeners = true) 197 { 198 try 199 { 200 if (target.HasValue) 201 GetLogger(target.Value).Add(message, level, exception, outputToListeners); 202 else 203 GetLogger(loggerName).Add(message, level, exception, outputToListeners); 204 } 205 catch 206 { 207 } 208 } 209 210 /// <summary> 211 /// Logs a message to the specified logging target and also displays a print statement. 212 /// </summary> 213 /// <param name="message">The message to log. Can include newline (\n) characters to split into multiple lines.</param> 214 /// <param name="target">The logging target (file).</param> 215 /// <param name="level">The verbosity level.</param> 216 public static void LogPrint(string message, LoggingTarget target = LoggingTarget.Runtime, LogLevel level = LogLevel.Verbose) 217 { 218 if (Enabled && DebugUtils.IsDebugBuild) 219 System.Diagnostics.Debug.Print(message); 220 221 Log(message, target, level); 222 } 223 224 /// <summary> 225 /// Logs a message to the logger with the given name and also displays a print statement. 226 /// </summary> 227 /// <param name="message">The message to log. Can include newline (\n) characters to split into multiple lines.</param> 228 /// <param name="name">The logger name (file).</param> 229 /// <param name="level">The verbosity level.</param> 230 public static void LogPrint(string message, string name, LogLevel level = LogLevel.Verbose) 231 { 232 if (Enabled && DebugUtils.IsDebugBuild) 233 System.Diagnostics.Debug.Print(message); 234 235 Log(message, name, level); 236 } 237 238 /// <summary> 239 /// For classes that regularly log to the same target, this method may be preferred over the static Log method. 240 /// </summary> 241 /// <param name="target">The logging target.</param> 242 /// <returns>The logger responsible for the given logging target.</returns> 243 public static Logger GetLogger(LoggingTarget target = LoggingTarget.Runtime) => GetLogger(target.ToString()); 244 245 /// <summary> 246 /// For classes that regularly log to the same target, this method may be preferred over the static Log method. 247 /// </summary> 248 /// <param name="name">The name of the custom logger.</param> 249 /// <returns>The logger responsible for the given logging target.</returns> 250 public static Logger GetLogger(string name) 251 { 252 lock (static_sync_lock) 253 { 254 var nameLower = name.ToLower(); 255 256 if (!static_loggers.TryGetValue(nameLower, out Logger l)) 257 { 258 static_loggers[nameLower] = l = Enum.TryParse(name, true, out LoggingTarget target) ? new Logger(target) : new Logger(name, true); 259 l.clear(); 260 } 261 262 return l; 263 } 264 } 265 266 /// <summary> 267 /// Logs a new message with the <see cref="LogLevel.Debug"/> and will only be logged if your project is built in the Debug configuration. Please note that the default setting for <see cref="Level"/> is <see cref="LogLevel.Verbose"/> so unless you increase the <see cref="Level"/> to <see cref="LogLevel.Debug"/> messages printed with this method will not appear in the output. 268 /// </summary> 269 /// <param name="message">The message that should be logged.</param> 270 [Conditional("DEBUG")] 271 public void Debug(string message = @"") 272 { 273 Add(message, LogLevel.Debug); 274 } 275 276 /// <summary> 277 /// Log an arbitrary string to current log. 278 /// </summary> 279 /// <param name="message">The message to log. Can include newline (\n) characters to split into multiple lines.</param> 280 /// <param name="level">The verbosity level.</param> 281 /// <param name="exception">An optional related exception.</param> 282 /// <param name="outputToListeners">Whether the message should be sent to listeners of <see cref="Debug"/> and <see cref="Console"/>. True by default.</param> 283 public void Add(string message = @"", LogLevel level = LogLevel.Verbose, Exception exception = null, bool outputToListeners = true) => 284 add(message, level, exception, outputToListeners && OutputToListeners); 285 286 private readonly RollingTime debugOutputRollingTime = new RollingTime(50, 10000); 287 288 private void add(string message = @"", LogLevel level = LogLevel.Verbose, Exception exception = null, bool outputToListeners = true) 289 { 290 if (!Enabled || level < Level) 291 return; 292 293 ensureHeader(); 294 295 logCount.Value++; 296 297 message = ApplyFilters(message); 298 299 string logOutput = message; 300 301 if (exception != null) 302 // add exception output to console / logfile output (but not the LogEntry's message). 303 logOutput += $"\n{ApplyFilters(exception.ToString())}"; 304 305 IEnumerable<string> lines = logOutput 306 .Replace(@"\r\n", @"\n") 307 .Split('\n') 308 .Select(s => $@"{DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)} [{level.ToString().ToLower()}]: {s.Trim()}"); 309 310 if (outputToListeners) 311 { 312 NewEntry?.Invoke(new LogEntry 313 { 314 Level = level, 315 Target = Target, 316 LoggerName = Name, 317 Message = message, 318 Exception = exception 319 }); 320 321 if (DebugUtils.IsDebugBuild) 322 { 323 static void consoleLog(string msg) 324 { 325 // fire to all debug listeners (like visual studio's output window) 326 System.Diagnostics.Debug.Print(msg); 327 // fire for console displays (appveyor/CI). 328 Console.WriteLine(msg); 329 } 330 331 bool bypassRateLimit = level >= LogLevel.Verbose; 332 333 foreach (var line in lines) 334 { 335 if (bypassRateLimit || debugOutputRollingTime.RequestEntry()) 336 { 337 consoleLog($"[{Name.ToLower()}] {line}"); 338 339 if (!bypassRateLimit && debugOutputRollingTime.IsAtLimit) 340 consoleLog($"Console output is being limited. Please check {Filename} for full logs."); 341 } 342 } 343 } 344 } 345 346 if (Target == LoggingTarget.Information) 347 // don't want to log this to a file 348 return; 349 350 lock (flush_sync_lock) 351 { 352 // we need to check if the logger is still enabled here, since we may have been waiting for a 353 // flush and while the flush was happening, the logger might have been disabled. In that case 354 // we want to make sure that we don't accidentally write anything to a file after that flush. 355 if (!Enabled) 356 return; 357 358 scheduler.Add(delegate 359 { 360 try 361 { 362 using (var stream = Storage.GetStream(Filename, FileAccess.Write, FileMode.Append)) 363 using (var writer = new StreamWriter(stream)) 364 { 365 foreach (var line in lines) 366 writer.WriteLine(line); 367 } 368 } 369 catch 370 { 371 } 372 }); 373 374 writer_idle.Reset(); 375 } 376 } 377 378 /// <summary> 379 /// Whether the output of this logger should be sent to listeners of <see cref="Debug"/> and <see cref="Console"/>. 380 /// Defaults to true. 381 /// </summary> 382 public bool OutputToListeners { get; set; } = true; 383 384 /// <summary> 385 /// Fires whenever any logger tries to log a new entry, but before the entry is actually written to the logfile. 386 /// </summary> 387 public static event Action<LogEntry> NewEntry; 388 389 /// <summary> 390 /// Deletes log file from disk. 391 /// </summary> 392 private void clear() 393 { 394 lock (flush_sync_lock) 395 { 396 scheduler.Add(() => Storage.Delete(Filename)); 397 writer_idle.Reset(); 398 } 399 } 400 401 private bool headerAdded; 402 403 private void ensureHeader() 404 { 405 if (headerAdded) return; 406 407 headerAdded = true; 408 409 add("----------------------------------------------------------", outputToListeners: false); 410 add($"{Name} Log for {UserIdentifier} (LogLevel: {Level})", outputToListeners: false); 411 add($"Running {GameIdentifier} {VersionIdentifier} on .NET {Environment.Version}", outputToListeners: false); 412 add($"Environment: {RuntimeInfo.OS} ({Environment.OSVersion}), {Environment.ProcessorCount} cores ", outputToListeners: false); 413 add("----------------------------------------------------------", outputToListeners: false); 414 } 415 416 private static readonly List<string> filters = new List<string>(); 417 private static readonly Dictionary<string, Logger> static_loggers = new Dictionary<string, Logger>(); 418 419 private static readonly Scheduler scheduler = new Scheduler(); 420 421 private static readonly ManualResetEvent writer_idle = new ManualResetEvent(true); 422 423 static Logger() 424 { 425 Timer timer = null; 426 427 // timer has a very low overhead. 428 timer = new Timer(_ => 429 { 430 if ((Storage != null ? scheduler.Update() : 0) == 0) 431 writer_idle.Set(); 432 433 // reschedule every 50ms. avoids overlapping callbacks. 434 // ReSharper disable once AccessToModifiedClosure 435 timer?.Change(50, Timeout.Infinite); 436 }, null, 0, Timeout.Infinite); 437 } 438 439 /// <summary> 440 /// Pause execution until all logger writes have completed and file handles have been closed. 441 /// This will also unbind all handlers bound to <see cref="NewEntry"/>. 442 /// </summary> 443 public static void Flush() 444 { 445 lock (flush_sync_lock) 446 { 447 writer_idle.WaitOne(2000); 448 NewEntry = null; 449 } 450 } 451 } 452 453 /// <summary> 454 /// Captures information about a logged message. 455 /// </summary> 456 public class LogEntry 457 { 458 /// <summary> 459 /// The level for which the message was logged. 460 /// </summary> 461 public LogLevel Level; 462 463 /// <summary> 464 /// The target to which this message is being logged, or null if it is being logged to a custom named logger. 465 /// </summary> 466 public LoggingTarget? Target; 467 468 /// <summary> 469 /// The name of the logger to which this message is being logged, or null if it is being logged to a specific <see cref="LoggingTarget"/>. 470 /// </summary> 471 public string LoggerName; 472 473 /// <summary> 474 /// The message that was logged. 475 /// </summary> 476 public string Message; 477 478 /// <summary> 479 /// An optional related exception. 480 /// </summary> 481 public Exception Exception; 482 } 483 484 /// <summary> 485 /// The level on which a log-message is logged. 486 /// </summary> 487 public enum LogLevel 488 { 489 /// <summary> 490 /// Log-level for debugging-related log-messages. This is the lowest level (highest verbosity). Please note that this will log input events, including keypresses when entering a password. 491 /// </summary> 492 Debug, 493 494 /// <summary> 495 /// Log-level for most log-messages. This is the second-lowest level (second-highest verbosity). 496 /// </summary> 497 Verbose, 498 499 /// <summary> 500 /// Log-level for important log-messages. This is the second-highest level (second-lowest verbosity). 501 /// </summary> 502 Important, 503 504 /// <summary> 505 /// Log-level for error messages. This is the highest level (lowest verbosity). 506 /// </summary> 507 Error 508 } 509 510 /// <summary> 511 /// The target for logging. Different targets can have different logfiles, are displayed differently in the LogOverlay and are generally useful for organizing logs into groups. 512 /// </summary> 513 public enum LoggingTarget 514 { 515 /// <summary> 516 /// Logging target for general information. Everything logged with this target will not be written to a logfile. 517 /// </summary> 518 Information, 519 520 /// <summary> 521 /// Logging target for information about the runtime. 522 /// </summary> 523 Runtime, 524 525 /// <summary> 526 /// Logging target for network-related events. 527 /// </summary> 528 Network, 529 530 /// <summary> 531 /// Logging target for performance-related information. 532 /// </summary> 533 Performance, 534 535 /// <summary> 536 /// Logging target for database-related events. 537 /// </summary> 538 Database 539 } 540}