@@ -2,6 +2,7 @@ module Serilog.Sinks.Grafana.Loki.Tests.WireFormatTests
22
33open System
44open System.Diagnostics
5+ open System.Globalization
56open System.Net
67open System.Net .Http
78open System.Text
@@ -12,6 +13,7 @@ open Swensen.Unquote
1213open Xunit
1314open Serilog.Events
1415open Serilog.Formatting
16+ open Serilog.Formatting .Display
1517open Serilog.Parsing
1618open Serilog.Core
1719open Serilog.Sinks .Grafana .Loki
@@ -147,6 +149,17 @@ let private bodyStringOf (stream: JsonElement) (i: int) =
147149 let entry = stream.GetProperty( " values" )[ i]
148150 entry[ 1 ]. GetString()
149151
152+ /// Serializes a single event through the given formatter and returns the entry body.
153+ let private bodyFrom ( formatter : ITextFormatter ) ( event : LogEvent ) =
154+ task {
155+ let handler , sink = makeSink ( fun o -> { o with TextFormatter = formatter })
156+
157+ use _ = sink
158+ do ! flush sink [ event ]
159+ use doc = handler.LastBodyJson
160+ return bodyStringOf ( streamAt 0 doc) 0
161+ }
162+
150163let private bodyProp ( key : string ) ( stream : JsonElement ) ( i : int ) =
151164 use body = JsonDocument.Parse( bodyStringOf stream i)
152165
@@ -990,22 +1003,170 @@ type private FixedBodyFormatter(text: string) =
9901003 interface ITextFormatter with
9911004 member _.Format ( _ , output ) = output.Write( text)
9921005
1006+ /// Terminates each line with TextWriter.WriteLine rather than an embedded newline.
1007+ type private WriteLineFormatter () =
1008+ interface ITextFormatter with
1009+ member _.Format ( _ , output ) =
1010+ output.WriteLine( " first" )
1011+ output.WriteLine( " last" )
1012+
1013+ /// Renders the event's ` Body ` property verbatim, so events in one batch can differ.
1014+ type private BodyPropertyFormatter () =
1015+ interface ITextFormatter with
1016+ member _.Format ( logEvent , output ) =
1017+ match logEvent.Properties.TryGetValue " Body" with
1018+ | true , (:? ScalarValue as v) -> output.Write( string v.Value)
1019+ | _ -> ()
1020+
9931021[<Fact>]
9941022let ``body : custom ITextFormatter goes through Utf8TextWriter path`` () : Task =
9951023 // When a non-LokiJsonTextFormatter is used, Serialization.fs routes through
9961024 // Utf8TextWriter. Verify the custom body survives unchanged in the Loki payload.
1025+ task {
1026+ let! body = bodyFrom ( FixedBodyFormatter( " CUSTOM_BODY" )) ( mkInfo [])
1027+ test <@ body = " CUSTOM_BODY" @>
1028+ }
1029+
1030+ // ── trailing newline trimming (#347) ──────────────────────────────────────────
1031+
1032+ // A fixed timestamp with an explicit offset makes MessageTemplateTextFormatter's rendering
1033+ // deterministic ({Timestamp} formats the DateTimeOffset as-is, without zone conversion), so
1034+ // these tests can assert on the exact body rather than a suffix.
1035+ let private fixedTs = DateTimeOffset( 2026 , 8 , 8 , 10 , 30 , 15 , TimeSpan.Zero)
1036+
1037+ /// The stock Console/File output template, rendered under the invariant culture so the
1038+ /// exact-body assertions do not depend on the CI agent's locale. #347 reports the same shape
1039+ /// with plain ` {Message} ` ; only the message rendering differs, not the trailing newline.
1040+ let private stockFormatter =
1041+ MessageTemplateTextFormatter(
1042+ " [{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj}{NewLine}{Exception}" ,
1043+ CultureInfo.InvariantCulture
1044+ )
1045+
1046+ let private mkRendered ( ex : exn ) =
1047+ LogEvent(
1048+ fixedTs,
1049+ LogEventLevel.Information,
1050+ ex,
1051+ traceParser.Parse( " Hello {Name}" ),
1052+ [ LogEventProperty( " Name" , ScalarValue( " world" )) ]
1053+ )
1054+
1055+ [<Fact>]
1056+ let ``body : stock output template leaves no trailing newline`` () : Task =
1057+ // {NewLine} terminates the rendered line. That is record framing for Console/File,
1058+ // but a Loki entry is a JSON string value, so it must not survive into the payload.
1059+ task {
1060+ let! body = bodyFrom stockFormatter ( mkRendered null )
1061+ test <@ body = " [10:30:15 INF] Hello world" @>
1062+ }
1063+
1064+ [<Fact>]
1065+ let ``body : rendered exception leaves no trailing newline`` () : Task =
1066+ // {Exception} renders as Exception.ToString() + Environment.NewLine, so a template ending
1067+ // in {Exception} still emits a trailing newline — dropping {NewLine} is not a workaround.
1068+ task {
1069+ let ex = InvalidOperationException( " boom" )
1070+ let! body = bodyFrom stockFormatter ( mkRendered ex)
1071+
1072+ let expected =
1073+ $" [10:30:15 INF] Hello world{Environment.NewLine}System.InvalidOperationException: boom"
1074+
1075+ test <@ body = expected @>
1076+ }
1077+
1078+ [<Theory>]
1079+ [<InlineData( " first\r\n second\n third\r\n " , " first\r\n second\n third" ) >] // interior newlines survive
1080+ [<InlineData( " line\n\r\n\n " , " line" ) >] // a whole trailing run goes, not just the last byte
1081+ [<InlineData( " line\r " , " line" ) >] // lone CR, not preceded by an LF
1082+ [<InlineData( " café 日本語\r\n " , " café 日本語" ) >] // UTF-8 continuations (0x80-0xBF) never look like CR/LF
1083+ [<InlineData( " padded \t " , " padded \t " ) >] // spaces and tabs are content, not framing (matches v8)
1084+ [<InlineData( " \r\n\r\n " , " " ) >] // a body that is entirely framing trims to empty
1085+ let ``body : only trailing CR and LF are trimmed from a formatted body`` ( rendered : string ) ( expected : string ) : Task =
1086+ task {
1087+ let! body = bodyFrom ( FixedBodyFormatter( rendered)) ( mkInfo [])
1088+ test <@ body = expected @>
1089+ }
1090+
1091+ [<Fact>]
1092+ let ``body : a formatter terminating via WriteLine is trimmed`` () : Task =
1093+ // Utf8TextWriter.WriteLine is its own code path (it appends '\n' rather than
1094+ // Environment.NewLine), and it is how CompactJsonFormatter ends its output.
1095+ task {
1096+ let! body = bodyFrom ( WriteLineFormatter()) ( mkInfo [])
1097+ test <@ body = " first\n last" @>
1098+ }
1099+
1100+ [<Fact>]
1101+ let ``body : trimming is per entry across a batch and leaves no stale bytes`` () : Task =
1102+ // The body buffer is reused for every event in a batch. Trimming must not shorten what
1103+ // gets cleared, or a long body's tail would bleed into the next, shorter entry.
9971104 task {
9981105 let handler , sink =
9991106 makeSink ( fun o ->
10001107 { o with
1001- TextFormatter = FixedBodyFormatter ( " CUSTOM_BODY " )
1108+ TextFormatter = BodyPropertyFormatter ( )
10021109 })
10031110
10041111 use _ = sink
1005- do ! flush sink [ mkInfo [] ]
1112+
1113+ // Rendered → expected, so the two sides can never drift apart.
1114+ let cases =
1115+ [
1116+ " a long first body that ends in a newline\r\n " , " a long first body that ends in a newline"
1117+ " short" , " short"
1118+ " third\n " , " third"
1119+ " d" , " d"
1120+ ]
1121+
1122+ let events =
1123+ cases
1124+ |> List.mapi ( fun i ( rendered , _ ) ->
1125+ mkEventAt ( fixedTs.AddSeconds( float i)) LogEventLevel.Information [ " Body" , box rendered ])
1126+
1127+ do ! flush sink events
10061128 use doc = handler.LastBodyJson
1007- let body = bodyStringOf ( streamAt 0 doc) 0
1008- test <@ body = " CUSTOM_BODY" @>
1129+ let s = streamAt 0 doc
1130+ let actual = List.init cases.Length ( bodyStringOf s)
1131+
1132+ test <@ actual = List.map snd cases @>
1133+ }
1134+
1135+ [<Fact>]
1136+ let ``body : a trimmed body still carries structured metadata`` () : Task =
1137+ // The trimmed WriteStringValue hands the writer straight to the metadata element;
1138+ // a shortened body must not disturb the optional 3rd entry element.
1139+ task {
1140+ let handler , sink =
1141+ makeSink ( fun o ->
1142+ { o with
1143+ TextFormatter = FixedBodyFormatter( " meta body\r\n " )
1144+ PropertiesAsStructuredMetadata = [| " RequestId" |]
1145+ })
1146+
1147+ use _ = sink
1148+ do ! flush sink [ mkInfo [ " RequestId" , box " req-42" ] ]
1149+ use doc = handler.LastBodyJson
1150+ let s = streamAt 0 doc
1151+ test <@ bodyStringOf s 0 = " meta body" @>
1152+ test <@ entryElementCount s 0 = 3 @>
1153+ test <@ metadataProp " RequestId" s 0 = Some " req-42" @>
1154+ }
1155+
1156+ [<Fact>]
1157+ let ``body : built - in formatter output is unaffected by trimming`` () : Task =
1158+ // The default path emits JSON closing on '}', so the trim finds nothing to strip and the
1159+ // payload stays byte-identical — properties included, not just the final character.
1160+ task {
1161+ let handler , sink = makeSink id
1162+ use _ = sink
1163+ do ! flush sink [ mkInfo [ " Note" , box " value" ] ]
1164+ use doc = handler.LastBodyJson
1165+ let s = streamAt 0 doc
1166+ let body = bodyStringOf s 0
1167+ // bodyProp parses the body, so it also asserts the payload is still valid JSON.
1168+ test <@ body.EndsWith( " }" ) @>
1169+ test <@ bodyProp " Note" s 0 = Some " value" @>
10091170 }
10101171
10111172// ── HTTP error response path ──────────────────────────────────────────────────
0 commit comments