A game framework written with osu! in mind.
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}