program FirstLogger; // Atlas Log quick start. One logger writes to a memory ring buffer, the // console (warnings and above), the debugger, a size-rotated text file and a // JSON-lines file, both files in a temporary folder. The program logs with // message templates, a channel, a scope, a failure and a timer, reads its own // output back, checks it, and deletes the folder. Exit code 0 means every // check passed. {$APPTYPE CONSOLE} uses System.SysUtils, System.Classes, System.IOUtils, System.JSON, Atlas.Log, Atlas.LogSinks; procedure Check(Condition: Boolean; const What: string); begin if not Condition then raise Exception.Create('Check failed: ' + What); Writeln(' ok: ', What); end; // The file sink keeps its file open and denies other writers, not readers. function ReadLines(const FileName: string): TArray; var Stream: TFileStream; Bytes: TBytes; begin Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyNone); try SetLength(Bytes, Stream.Size); if Length(Bytes) > 0 then Stream.ReadBuffer(Bytes[0], Length(Bytes)); finally Stream.Free; end; Result := TEncoding.UTF8.GetString(Bytes).Split([sLineBreak], TStringSplitOptions.ExcludeEmpty); end; procedure Run(const Folder: string); var Log: TAtlasLogger; Memory: TAtlasMemorySink; Console: TAtlasConsoleSink; Debugger: TAtlasDebugSink; Plain, Json: TAtlasFileSink; Orders: TAtlasLogChannel; Scope, Timer: IInterface; Events: TArray; Lines: TArray; Name: string; Parsed: TJSONValue; I, Archive, Archives: Integer; begin Log := TAtlasLogger.Create(nil); try Log.MinLevel := llDebug; // Sinks owned by the logger are freed with it. Memory := TAtlasMemorySink.Create(Log); Memory.Capacity := 100; Memory.Logger := Log; Console := TAtlasConsoleSink.Create(Log); Console.MinLevel := llWarning; Console.Pattern := ' console: [{level:short}] {category} {message}{exception}'; Console.Logger := Log; Debugger := TAtlasDebugSink.Create(Log); Debugger.Logger := Log; Plain := TAtlasFileSink.Create(Log); Plain.FileName := TPath.Combine(Folder, 'app.log'); Plain.Pattern := '[{level:short}] {category} {scope} {message}{exception}{props}'; Plain.MinLevel := llInfo; Plain.MaxBytes := 512; Plain.MaxFiles := 5; Plain.Logger := Log; Json := TAtlasFileSink.Create(Log); Json.FileName := TPath.Combine(Folder, 'app.jsonl'); Json.Layout := loJson; Json.Logger := Log; Writeln('Logging...'); Log.Info('Application started'); Log.Debug('Settings read from {file}', ['settings.json']); Orders := Log.Channel('orders'); Scope := Log.BeginScope('checkout'); Orders.Info('Order {id} placed by {customer} for {total}', [1042, 'Contoso', 1250.5]); Orders.Warning('Stock for {sku} is low: {left} left', ['AX-7', 3]); Scope := nil; try raise EInOutError.Create('Disk quota exceeded'); except on E: Exception do Orders.Failure(E, 'Could not archive the order'); end; Timer := Log.Time('import'); for I := 1 to 20 do Log.Info('Imported row {row} of {rows}', [I, 20]); Timer := nil; Log.Flush; Writeln('Memory sink:'); Events := Memory.Snapshot; Check(Length(Events) = 26, '26 events reached the memory sink, the debug event included'); Check((Events[2].Message = 'Order 1042 placed by Contoso for 1250.5') and (Events[2].Category = 'orders') and (Events[2].Scope = 'checkout') and (Events[2].PropertyValue('customer') = 'Contoso') and (Events[2].Template = 'Order {id} placed by {customer} for {total}'), 'the template filled its holes and kept them as properties'); Check((Events[4].Level = llError) and (Events[4].ExceptionClass = 'EInOutError') and (Events[4].ExceptionMessage = 'Disk quota exceeded'), 'the failure kept the exception apart from the message'); Check((Events[5].Scope = 'import') and (Events[25].Message.StartsWith('import finished in ')) and (StrToIntDef(Events[25].PropertyValue('elapsed_ms'), -1) >= 0), 'the timer named the scope and reported its duration'); for I := 1 to High(Events) do if Events[I].Sequence <> Events[I - 1].Sequence + 1 then Check(False, 'sequence numbers increase by one'); Check(Log.SinkErrors = 0, 'no sink failed'); Writeln('Text file ', Plain.ActiveFileName, ':'); // Oldest archive first, then the active file. Lines := nil; Archives := 0; for Archive := Plain.MaxFiles downto 1 do begin Name := TPath.Combine(Folder, Format('app.%d.log', [Archive])); if not TFile.Exists(Name) then Continue; Inc(Archives); if TFile.GetSize(Name) > Plain.MaxBytes then Check(False, Name + ' stays within MaxBytes'); Lines := Lines + ReadLines(Name); end; Lines := Lines + ReadLines(Plain.ActiveFileName); Check(Archives >= 1, Format('full files were rolled into %d archives', [Archives])); Check(Length(Lines) = 25, 'all 25 events at info and above are in the text files, the debug event is not'); Check(Lines[0] = '[INF] Application started', 'empty tokens leave no double spaces'); Check(Lines[1] = '[INF] orders [checkout] Order 1042 placed by Contoso for 1250.5 {id=1042, customer=Contoso, total=1250.5}', 'category, scope and properties are on the line'); Check(Lines[3] = '[ERR] orders Could not archive the order | EInOutError: Disk quota exceeded', 'the exception follows the message'); Check(Lines[24].StartsWith('[INF] [import] import finished in '), 'the newest line is last in the active file'); for I := 20 to 24 do Writeln(' ', Lines[I]); Writeln('JSON file ', Json.ActiveFileName, ':'); Lines := ReadLines(Json.ActiveFileName); Check(Length(Lines) = 26, 'one JSON object per event'); for I := 0 to High(Lines) do begin Parsed := TJSONObject.ParseJSONValue(Lines[I]); if not (Parsed is TJSONObject) then Check(False, 'every line parses as a JSON object'); Parsed.Free; end; Writeln(' ', Lines[2]); Parsed := TJSONObject.ParseJSONValue(Lines[2]); try Check((Parsed.GetValue('msg') = 'Order 1042 placed by Contoso for 1250.5') and (Parsed.GetValue('category') = 'orders') and (Parsed.GetValue('scope') = 'checkout') and (Parsed.GetValue('template') = 'Order {id} placed by {customer} for {total}') and (Parsed.GetValue('props.total') = '1250.5') and Parsed.GetValue('ts').EndsWith('Z'), 'the JSON object keeps message, template, scope and properties apart, with a UTC time'); finally Parsed.Free; end; Parsed := TJSONObject.ParseJSONValue(Lines[4]); try Check((Parsed.GetValue('level') = 'error') and (Parsed.GetValue('exception.type') = 'EInOutError'), 'the exception is a nested object'); finally Parsed.Free; end; finally // Frees the sinks too, which closes the files. Log.Free; end; end; var Folder: string; begin Folder := TPath.Combine(TPath.GetTempPath, 'AtlasLogFirstLogger'); try try if TDirectory.Exists(Folder) then TDirectory.Delete(Folder, True); Run(Folder); Writeln('All checks passed.'); finally if TDirectory.Exists(Folder) then TDirectory.Delete(Folder, True); end; except on E: Exception do begin Writeln(E.ClassName, ': ', E.Message); ExitCode := 1; end; end; end.