Skip to content

[V9] New architecture + performance + features - #326

Merged
mishamyte merged 67 commits into
masterfrom
v9
Jun 6, 2026
Merged

[V9] New architecture + performance + features#326
mishamyte merged 67 commits into
masterfrom
v9

Conversation

@mishamyte

Copy link
Copy Markdown
Member

What

V9 is a ground-up rewrite of the sink in F#, keeping a public API that stays idiomatic from C#. The custom delivery stack is replaced with Serilog 4.x native batching and a streaming serialization pipeline.

Highlights

Breaking changes

Full list: Breaking changes · practical checklist: Upgrading from v8 to v9 · also in the README migration section.

Verification

  • 121 unit tests × 3 TFMs — wire format against exact JSON, label pipeline, appsettings.json binding contract, defaults drift guard, uri/tenant validation
  • Integration tests against a real Loki 3.7.2 via Testcontainers (push + LogQL query-back)
  • 3-way benchmark suite (V8 8.3.2 / V9 / YetAnother 4.0.5) with a PR benchmark-comparison workflow
  • Console + ASP.NET Core samples verified end-to-end against docker-compose Loki

After merge

The v9.0.0 tag will be cut manually on master (MinVer derives the package version from it).

🤖 Generated with Claude Code

mishamyte and others added 30 commits June 3, 2026 22:49
- Move all V8 source, tests, samples, build tooling and solution into v8/
  for reference during V9 development
- Add v8/Directory.Packages.props to opt V8 projects out of CPM
- Reset Directory.Build.props for V9 (drop StyleCop, LangVersion; keep
  MinVer, SourceLink, strong-name signing)
- Add Directory.Packages.props with Central Package Management enabled
- Add Serilog.Sinks.Grafana.Loki.slnx (new solution format)
- Add empty src/ and test/ placeholders for V9 projects
- Add V9_SPEC.md design specification

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Serilog.Sinks.Grafana.Loki.fsproj targeting net8.0;net9.0;net10.0
- No conditional PropertyGroups needed — all three TFMs have identical
  API surfaces; complexity of Serilog own csproj conditionals comes
  entirely from legacy TFM support we have dropped
- CPM resolves Serilog 4.3.1 and Serilog.Sinks.PeriodicBatching 5.0.0
- Compile items commented-in order as files are created
- Project added to Serilog.Sinks.Grafana.Loki.slnx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
PooledByteBufferWriter — IBufferWriter<byte> over ArrayPool<byte>.Shared.
Reused across ticks via Clear(); returned to the pool on Dispose().
Grows by doubling when capacity is exceeded.

Utf8TextWriter — TextWriter subclass that encodes directly into the pooled
buffer as UTF-8, eliminating the StringWriter + intermediate string that
the V8 pipeline produced per log event. Write(char) has an ASCII fast path
(single byte, no allocation); Write(string) and Write(ReadOnlySpan<char>)
use Encoding.UTF8.GetBytes directly into the buffer span.

Builds clean across net8.0, net9.0, net10.0 with 0 warnings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Public contract (OOP surface, C#-compatible):
- LokiLabel, LokiCredentials — [<CLIMutable>] records
- ILokiExceptionFormatter — [<AllowNullLiteral>] interface; replaces hardcoded SerializeException
- LokiExceptionFormatter — default impl; pre-encoded JsonEncodedText property names
- LokiJsonTextFormatter — public non-sealed class; virtual Format + SanitizePropertyName;
  internal FormatToBuffer fast path writes directly to PooledByteBufferWriter bypassing
  the ITextFormatter TextWriter round-trip
- LokiSinkOptions — [<CLIMutable>] record + LokiSinkOptions.Defaults extension

Internal functional pipeline ([<AutoOpen>] modules, inline hot-path helpers):
- Labels — inline sanitizeLabelKey, logLevelToLabel, renderLabelValue, toUnixNanoseconds,
  buildLabelSet; label set is an immutable Map<string,string>
- Grouping — groupIntoStreams via Seq.groupBy on structural Map equality
- Serialization — single Utf8JsonWriter forward pass; LokiJsonTextFormatter fast path
  avoids double-encoding; external ITextFormatter falls back to Utf8TextWriter path

Infrastructure:
- LokiPushContent — HttpContent subclass; both SerializeToStreamAsync overloads required
  for net10 compatibility; TryComputeLength avoids chunked encoding
- LokiSink — IBatchedLogEventSink; reusable per-tick PooledByteBufferWriter pair;
  label pipeline precomputed at construction; box coercion for record null-checks

Public API:
- LoggerConfigurationExtensions — two [<Extension>] overloads with C#-compatible
  [<Optional;DefaultParameterValue>] on restrictedToMinimumLevel; URI validated at
  construction time

Builds 0 warnings / 0 errors across net8.0, net9.0, net10.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…kiExceptionFormatter

Test stack: xUnit + Unquote 7.0.1 (quotation-based assertions) + Hedgehog 2.0.3
(property tests — plumbed in, first properties to follow).

Key decisions:
- inline leaf helpers (sanitizeLabelKey, logLevelToLabel, renderLabelValue,
  toUnixNanoseconds) stay inline; F# prohibits cross-assembly inlining of internal
  inline functions even with InternalsVisibleTo
- buildLabelSet and groupIntoStreams: removed inline — too complex for meaningful
  compile-time inline, now testable directly from external assembly
- Inline helpers tested indirectly through buildLabelSet (behaviour contracts)
- toUnixNanoseconds: thin non-inline shim (timestampToNs) added to Labels.fs
  solely for precision tests — comment explains why it exists
- Unquote + use-bound IDisposable: extract values before quotation (FS3155)
- AggregateException in F#: explicit :> exn upcast required for array literals

Coverage: label pipeline (27 tests), stream grouping (11 tests),
exception formatter recursive serialization (14 tests — simple, inner, aggregate, deep).
Builds 0 warnings / 0 errors across net8.0, net9.0, net10.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Directory.Build.props:
- LangVersion=latest (F# 10 on all TFMs via .NET 10 SDK)
- WarnLevel=5 (maximum warning category coverage)
- TreatWarningsAsErrors=true (all FS warnings become errors)

Library (src): already clean at these settings — 0 warnings confirmed.

Test project:
- MSBuildWarningsAsMessages=MSB3277: Hedgehog targets netstandard2.1 and
  transitively pulls FSharp.Core 8.0.403; SDK always provides the correct
  version at runtime on net8+, warning is a false positive
- Remove redundant :> exn upcasts in AggregateException array literals
  (FS0066 → now an error under TreatWarningsAsErrors)

Result: dotnet test passes 47/47 with 0 warnings, 0 errors on
net8.0, net9.0, net10.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
docker-compose.yaml:
- Remove deprecated top-level `version:` field (dropped in Compose spec)
- Pin grafana/loki:3.7.2 and grafana/grafana:13.0.2 (was :latest)

sample/Serilog.Sinks.Grafana.Loki.Sample (console, net10.0):
- Top-level statements, collection expressions, primary constructors
- Uses new LokiSinkOptions API with Labels + PropertiesAsLabels
- Person model as a record, Serilog.Enrichers.Thread + Sinks.Console via CPM

sample/Serilog.Sinks.Grafana.Loki.SampleWebApp (ASP.NET Core, net10.0):
- ReadFrom.Configuration via Serilog.AspNetCore + Settings.Configuration
- appsettings.json uses uri string overload (safe, validated path)
- SerilogController updated to primary constructor injection
- Builds 0 warnings / 0 errors

Directory.Packages.props: add Samples group
  (Serilog.AspNetCore 10.0.0, Serilog.Enrichers.Thread 4.0.0,
   Serilog.Settings.Configuration 10.0.0, Serilog.Sinks.Console 6.0.0)

Serilog.Sinks.Grafana.Loki.slnx: add /sample/ solution folder

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
UnitTests — WireFormatTests.fs (27 new tests, 74 total):
- FakeHttpHandler captures body bytes, auth header, tenant header, URI
- makeSink: injects HttpClient (sink does NOT own it, for structure/label tests)
- makeSinkWithHandler: injects HttpMessageHandler (sink owns client, applies auth)
- 21 test cases: JSON structure, level labels (theory × 6), label behaviour,
  body content, HTTP auth/tenant headers

Library change: add HttpMessageHandler to LokiSinkOptions
- Sink creates its own HttpClient(handler) when HttpMessageHandler is set
- Closes real usability gap: users can inject retry/compression handlers
  without managing the full HttpClient lifecycle

IntegrationTests (new project, Testcontainers 4.12.0):
- LokiContainerFixture: IAsyncLifetime, ContainerBuilder(grafana/loki:3.7.2),
  waits on /ready endpoint before tests run
- LokiQueryClient: query_range API, retry-with-backoff (16 × 300 ms ~5 s)
- LokiIntegrationTests: 8 tests (IClassFixture, unique runId per test)
  basic e2e, label selector, body roundtrip, exception, two batches,
  Fatal=fatal label value, 200-event batch, gzip via DelegatingHandler
- GzipRequestHandler: minimal DelegatingHandler demo; base.SendAsync
  extracted to private method to work around F# closure restriction

AssemblyInfo.fs: add InternalsVisibleTo for IntegrationTests
Serilog.Sinks.Grafana.Loki.slnx: add integration test project

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Normalizes whitespace, indentation and blank lines across all F# source,
test, sample and config files. No logic changes — all 74 unit tests and
8 integration tests pass unchanged after formatting.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
StyleCop.Analyzers.Unstable 1.2.0.556 (latest, supersedes 1.2.0-beta.556 used in V8).
Applied only to C# projects via Directory.Build.props Condition=$(Language)=='C#';
F# projects are unaffected — Roslyn analyzers do not run against F# source.

Configuration:
- .editorconfig at repo root — the primary config mechanism for .Unstable 1.2.x
  (StyleCop.ruleset kept as human-readable reference but not used for enforcement)
- Rule decisions mirror V8 ruleset exactly: all documentation and file-header rules
  disabled, spacing/ordering/naming/layout enforced as errors

StyleCop.ruleset:
- Kept at repo root as a human-readable record of rule decisions
- ToolsVersion bumped 16.0 → 17.0

Sample fixes triggered by StyleCop:
- Person record extracted to its own Person.cs (SA1402 — one type per file);
  no namespace to match Program.cs top-level statements scope
- SA1518 (file must end with newline): added trailing newlines to Program.cs
  and SerilogController.cs in both sample projects

74 unit tests + 8 integration tests pass unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Preference is no trailing newlines at end of files.
- .editorconfig: insert_final_newline = false, SA1518 severity = none
- Reverted trailing newlines added to sample files in previous commit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The .ruleset file was not used for enforcement (StyleCop.Analyzers.Unstable
1.2.x only reads .editorconfig). Removing it to avoid confusion.
Rule decisions are preserved at v8/StyleCop.ruleset and referenced in
.editorconfig.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Clean up all references to the v8 folder path that will be deleted:
- .editorconfig: drop path reference to StyleCop.ruleset in v8
- README.md: remove version tag from formatter description
- WireFormatTests.fs: two test names reworded
- LabelsTests.fs: inline data comment updated
- LokiIntegrationTests.fs: GzipRequestHandler comment updated
- bug_report.yml: placeholder updated to v9.0.0

V9_SPEC.md keeps historical comparisons as design rationale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two jobs:
- build: restore, build (Release), unit tests (net8/9/10), pack, upload .nupkg artifact
- integration: runs after build, requires Docker (ubuntu-latest has it); net10 only

Triggers on push/PR to main, master, v9.
fetch-depth: 0 for MinVer to compute the correct version from git tags.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
build.fsx replaces the V8 Bullseye C# console app with an F#-native
build script using FAKE 6.1.4 inline NuGet refs — self-bootstrapping,
no dotnet-tools.json or shell wrappers required.

Targets:
  Restore → Build → Test → Pack → Default
                  ↘ IntegrationTest
             Pack → Push (requires NUGET_API_KEY env var)

Build compiles the full solution so Test and Pack can use --no-build.

Known limitation: FAKE 6.1.4 uses an MSBuild binary log parser that
only supports up to format v16, while the .NET 10 SDK emits v25.
Fixed by DisableInternalBinLog = true on all MSBuildParams.

Usage: dotnet fsi build.fsx [-- --target <name>]

ci.yaml to be updated separately after review.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ci.yaml:
- .NET 6.0.x/7.0.x/8.0.x -> 8.x/9.x/10.x
- ./build.sh / ./build.ps1 -> dotnet fsi build.fsx -- --target <name>
- Remove dotnet --info noise
- Add integration test job (ubuntu, Docker, runs after build matrix)
- Add publish job (ubuntu, release-only, separated from build matrix)
- Artifact upload per OS in build job

Serilog.Sinks.Grafana.Loki.slnx:
- Add Solution Items virtual folder mirroring V8 convention:
  .gitignore, .editorconfig, LICENSE, README.md, SECURITY.md,
  Directory.Build.props/targets, Directory.Packages.props,
  build.fsx, docker-compose.yaml, grafana-datasources.yaml

build.fsx: Rider formatting pass

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds * text=auto eol=lf to normalise all text files to LF in the repo
and on checkout, eliminating the per-commit CRLF warning on Windows.
Binary assets (.snk, .png, .dll, .exe) marked explicitly.
git add --renormalize applied to fix 29 existing CRLF files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…xpression

Copyright 2020-2026 Mykhailo Shevchuk & Contributors — added to all 15
.fs files in src/Serilog.Sinks.Grafana.Loki/ following the same pattern
used in V8 C# source (sink source only, not tests/samples/build).

PackageLicenseExpression corrected from Apache-2.0 to MIT — the LICENSE
file has always been MIT; the wrong expression was an inherited bug from
a template, never caught because StyleCop SA1633/SA1634 were disabled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
CodeQL does not support F#. The library is entirely F# — the workflow
only analysed the two trivial C# sample files (Program.cs, Person.cs,
SerilogController.cs) while skipping 100% of the actual library code,
giving a false sense of security coverage.

Can be re-added if GitHub CodeQL adds F# support in the future.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…Loki

FSharp.Core not copy-local for C# consumers:
SDK marks FSharp.Core as SDK-only, so the DLL is not in shared runtime
and C# apps fail at runtime with assembly-not-found. Fixed via
DisableImplicitFSharpCoreReference=true plus explicit PackageReference
for FSharp.Core 10.1.300 in CPM, making it a proper transitive dep.

LokiSinkOptions.Defaults not visible from C#:
Optional F# type extensions (in AutoOpen module) compile as methods on
the module class, not on the original type. C# could not call
LokiSinkOptions.Defaults. Fixed by moving Defaults, DefaultBatchSizeLimit
and DefaultQueueLimit into the type body as intrinsic members.

new LokiSinkOptions from C# zero-initialises all fields:
CLIMutable F# records set unset fields to 0/false/null when constructed
with C# new. BatchSizeLimit=0 caused PeriodicBatchingSink to throw.
Console sample updated to use LokiSinkOptions.Defaults as starting point;
doc comment on the type warns against the new pattern and shows correct usage.
GrafanaLoki(options) overload default-merging is tracked as follow-up.

All 74 unit tests pass. 13/13 LokiSinkOptions scenarios validated
against real Loki 3.7.2.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Following the same pattern as V8 and Serilog.Sinks.Seq: one method,
uri is the only required parameter, everything else is optional with
explicit defaults. This makes invalid configuration impossible — all
defaults are declared in the method signature, no zero-initialisation
trap from new LokiSinkOptions { ... }.

Changes:
- GrafanaLoki(options) overload removed
- GrafanaLoki(uri) convenience overload replaced
- New: GrafanaLoki(uri, labels, propertiesAsLabels, handleLogLevelAsLabel,
       credentialsLogin, credentialsPassword, tenant, enrichTraceId,
       enrichSpanId, batchSizeLimit, queueLimit, period,
       eagerlyEmitFirstEvent, retryTimeLimit, textFormatter,
       exceptionFormatter, httpClient, httpMessageHandler, timeProvider,
       restrictedToMinimumLevel)
- LokiCredentials no longer a parameter — split into credentialsLogin +
  credentialsPassword strings (same ergonomics as Seq apiKey)
- Null-default reference params use typed null: T annotation required by F#
- LokiSinkOptions stays public (useful for F# power users and tests)

Console sample updated to use named-parameter flat API.

74 unit tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unit tests (+3, total 77):
- TraceId written when EnrichTraceId=true: uses ActivityTraceId.CreateRandom()
  to create a LogEvent with a known trace ID, verifies it appears in body
- SpanId written when EnrichSpanId=true: same pattern for span ID
- TraceId absent when EnrichTraceId=false (default): confirms opt-in behavior
  Uses mkEventWithTrace helper that bypasses Activity.Current requirement

Integration tests (+2, total 10):
- exceptionFormatter: custom shape survives Loki roundtrip: NoTypeFormatter
  writes only Message, test verifies Type is absent after Loki roundtrip
- batching: 3x4 events across separate EmitBatchAsync calls all arrive:
  simulates batchSizeLimit splitting; verifies no events lost across posts

Decisions from manual S10/eagerlyEmitFirstEvent validation: not added as
automated test — timing-sensitive, flaky under load, already validated
manually. S11/S12 auth enforcement needs Loki auth_enabled=true — covered
by wire format tests for header correctness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1. Extension class must be AbstractClass+Sealed for Settings.Configuration
   Serilog.Settings.Configuration v10 filters types with IsSealed&&IsAbstract
   (the C# static class pattern). F# [<Extension>] type with private () does
   not match. Fix: [<AbstractClass; Sealed; Extension>] produces IsAbstract=True
   IsSealed=True in IL, making the class discoverable.

2. period and retryTimeLimit changed from TimeSpan to string
   [<Optional>] TimeSpan compiles with default=System.Reflection.Missing which
   Serilog.Settings.Configuration cannot use when building the reflection call
   for optional params with no supplied value. String with null default works
   correctly; appsettings.json users pass 00:00:01 format (standard TimeSpan
   string, which Serilog.Settings.Configuration converts automatically).
   Method body parses the string; null/empty = use framework default.

appsettings.json validation results (all via config, zero code changes):
- uri, labels, propertiesAsLabels, handleLogLevelAsLabel         ✓
- credentialsLogin + credentialsPassword (Basic auth)            ✓
- tenant (X-Scope-OrgID)                                        ✓
- enrichTraceId + enrichSpanId (ASP.NET Core Activity present)  ✓
- batchSizeLimit, queueLimit, period, eagerlyEmitFirstEvent      ✓
- ThreadId promoted to stream label (propertiesAsLabels)         ✓
- TraceId and SpanId in log body from HTTP request Activity      ✓
- Exception field present on error events                        ✓

77 unit tests pass unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… semantics

Investigation result:
- C# TimeSpan?=null emits DefaultParameterValue=null (valid for Settings.Configuration)
- F# [<Optional>] Nullable<TimeSpan> emits DefaultParameterValue=Missing (same broken behavior as plain TimeSpan)
- F# has no way to emit DefaultParameterValue=null for value types at compile time

The string workaround remains the correct approach for period/retryTimeLimit.
Code callers pass 00:00:02 format; Settings.Configuration users pass the same.
Comment updated to explain the root cause.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
V9 headers were missing the last 4 disclaimer lines that V8 had.
Applied to all 15 .fs files in src/.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Unused packages audit: all 17 packages in CPM are actively referenced.
FAKE packages (Fake.Core.Target, Fake.DotNet.Cli, Fake.IO.FileSystem): already at latest 6.1.4.

Updated via `dotnet outdated Serilog.Sinks.Grafana.Loki.slnx -u`:
  Microsoft.SourceLink.GitHub   10.0.203 -> 10.0.300  (patch)
  Microsoft.NET.Test.Sdk        17.13.0  -> 18.6.0    (major, VS versioning scheme)
  xunit.runner.visualstudio     3.0.2    -> 3.1.5     (minor)
  Serilog.Sinks.Console         6.0.0    -> 6.1.1     (patch)

77 unit tests pass unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… batching

RetryTimeLimit was stored in LokiSinkOptions but never consumed — PeriodicBatchingSinkOptions
has no RetryTimeLimit property. Fixed by switching from PeriodicBatchingSink to Serilog 4.x
native batching: sinkConfig.Sink(IBatchedLogEventSink, BatchingOptions) where BatchingOptions
has BatchSizeLimit, BufferingTimeLimit, EagerlyEmitFirstEvent, QueueLimit, RetryTimeLimit.

TimeProvider was stored but never read anywhere in the sink. Dropped — no current use case
(useInternalTimestamp was removed; event.Timestamp is always used directly from Serilog).

Side effects of the batching switch:
- Serilog.Core.IBatchedLogEventSink (IReadOnlyCollection<LogEvent>) replaces
  Serilog.Sinks.PeriodicBatching.IBatchedLogEventSink (IEnumerable<LogEvent>)
- Serilog.Sinks.PeriodicBatching removed as library dependency (no longer needed)
- Tests updated: open Serilog.Core instead of Serilog.Sinks.PeriodicBatching;
  Array.ofList used to convert test event lists to IReadOnlyCollection
- LokiSinkOptions.TimeProvider field removed
- timeProvider parameter removed from GrafanaLoki extension method

77 unit tests pass unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
mishamyte and others added 26 commits June 5, 2026 00:55
RFC 3986 §5.2.2 strips everything right of the last '/' in the base path,
so Uri("http://proxy/gateway", "loki/api/v1/push") resolves to the wrong
endpoint. Appending '/' when absent makes path-prefixed proxy URIs work.

Adds regression test for the path-prefix case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds the options-object extension method documented in V9_SPEC.md,
enabling C# callers to use the full options object pattern:

    .WriteTo.GrafanaLoki(new LokiSinkOptions { Uri = "...", ... })

and F# callers to pass a record directly:

    .WriteTo.GrafanaLoki({ LokiSinkOptions.Defaults with Uri = "..." })

Routes to the same `wire` helper as the flat-parameter overload.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…r test

- LokiSink.fs: replace `raise ex` with ExceptionDispatchInfo.Capture().Throw()
  so the original stack trace is preserved when propagating serialization failures
  (`reraise()` is not legal inside task {} CEs)
- WireFormatTests.fs: replace misleading happy-path test with a genuine error-path
  test using ErrorHttpHandler returning 500, and rename to match actual behaviour

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Threading.Tasks.Task → Task; namespace already open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LoggerConfigurationExtensions.fs: extract parseTimeSpan helper to remove
  duplicated period/retryTimeLimit parsing; use Defaults fields as fallbacks
- LokiSink.fs: invert ownsClient branch so injected-client path is the simple
  case; collapse nested credentials null-check; open System.Threading.Tasks
- Labels.fs: replace manual Map.fold key-extraction with Map.keys |> Set.ofSeq

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ler pattern

The V8 ILokiHttpClient / BaseLokiHttpClient / LokiGzipHttpClient hierarchy was
entirely removed in V9. Update the 'Custom HTTP Client' README section to show
the V9 replacement: inject a DelegatingHandler via httpMessageHandler, or a
pre-built HttpClient via httpClient (e.g. IHttpClientFactory).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Update for the F# V9 rewrite: flat GrafanaLoki parameters, configuration reference, labels/auth/trace/batching sections, DelegatingHandler-based custom HttpClient, and a migrating-from-v8 table. Repoint links to serilog-contrib and drop the comparison section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The project referenced assets/icon.png, but the committed icon is assets/serilog-sink-nuget.png, so dotnet pack failed with NU5019. Point PackageIcon and the packaged None item at the real filename.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Builds a logger entirely from JSON and captures the Loki push request on a loopback socket, proving Serilog.Settings.Configuration discovers the F# GrafanaLoki extension method and binds the flat args (uri, labels[]/propertiesAsLabels[] arrays, handleLogLevelAsLabel, batchSizeLimit, string period). Adds Serilog.Settings.Configuration + Microsoft.Extensions.Configuration.Json to the test project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replaces the string workaround. A struct-default DefaultParameterValue (Nullable<TimeSpan>()) emits the same [opt]+HasDefault(nullref) metadata as C#'s TimeSpan? = null, so Serilog.Settings.Configuration binds it: omitted -> null -> sink default; a JSON 'hh:mm:ss' string is converted to TimeSpan. Drops the parseTimeSpan helper. Validated end-to-end with the ASP.NET Core sample (captured the Loki POST with period/retryTimeLimit omitted from appsettings). Updates README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#2: ExtensionDefaultsTests asserts the GrafanaLoki literal defaults (batchSizeLimit, queueLimit, bools) equal LokiSinkOptions.Defaults so the two can't drift. #3: credentials is a single LokiCredentials parameter again (V8 shape), not flat credentialsLogin/credentialsPassword. F# records are not null-admissible (FS0043 on DefaultParameterValue(null)), so LokiCredentials becomes an [<AllowNullLiteral>] class to serve as a nullable optional that Settings.Configuration binds from a JSON object. AppSettingsBindingTests now also asserts the credentials object binds and yields a Basic-auth header. Verified Console and AspNetCore samples end-to-end against a real Loki (docker-compose). Updates README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add LokiFieldDestination (None/Body/StructuredMetadata) with TraceIdMode, SpanIdMode and PropertiesAsStructuredMetadata. Per-line structured metadata is emitted as the optional third element of each push entry, written only when non-empty so default output stays byte-identical and pre-3.0 Loki safe.

Covered by unit + integration tests (98 unit tests green on net8/9/10) and documented in the README. Also registers the BenchmarkDotNet package version and bumps Microsoft.Extensions.Configuration.Json to 10.0.8.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Allocation/throughput pass on the V9 sink, applied one change at a time and
validated against the full test suite. End-to-end at 10k events:
Simple 19.5 -> 1.36 MB, 27 -> 18 ms; Exception 134 -> 66 MB, 188 -> 121 ms.

- group events via IEqualityComparer<LogEvent> instead of a per-event F# Map
  (drops the per-event Map build plus structural-hash boxing)
- read ex.StackTrace/ex.Source once (StackTrace rebuilds on every access)
- render Message via RenderMessage(TextWriter); timestamp via Utf8Formatter
- Events list -> array with a boxing-free sort; reuse the body Utf8JsonWriter
- write structured metadata directly to the writer (no per-event Map)

DateTime/DateTimeOffset body values now render as ISO 8601 / invariant
(previously culture-dependent ToString()).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
BenchmarkDotNet suites comparing V9 against V8 (8.3.2) and the third-party
Serilog.Sinks.Loki.YetAnother (4.0.5), each driven through its public API over
an in-process fake transport with a shared workload, config and entry point.
Three executables because V8 and V9 share an assembly name and cannot coexist
in one process.

- build.fsx: add a cross-platform `Benchmark` target (dotnet fsi), kept out of
  the Default chain and the solution so CI stays lean
- .husky/pre-commit: also Fantomas-format benchmarks/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Hedgehog/Hedgehog.Xunit were added with the initial test project but never
used (no property-based test was ever written): drop both package versions
and references, plus the MSB3277 suppression that existed only to mask
Hedgehog's transitive FSharp.Core 8.x. All three TFMs build clean without it.

Also drop two unused `open`s in LokiSink.fs left over from the perf pass, and
normalize self-closing tag spacing in Directory.Build.targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add an explicit Fantomas section to .editorconfig instead of relying on
implicit defaults (read by the CLI, the Husky hook, and Rider alike):

- fsharp_multiline_bracket_style = aligned (dotnet/fsharp compiler style)
- fsharp_keep_max_number_of_blank_lines = 1; max_line_length = 120 pinned
- [*] gains charset = utf-8, indent_style/size, trim_trailing_whitespace,
  with overrides for YAML (2-space indent) and Markdown (keep hard breaks)

Reformat the 9 files affected by the bracket style and strip the UTF-8 BOMs
from src/ so the repo is uniformly BOM-less (v8/ left as is - slated for
removal). Formatting is idempotent under the new rules; 108/108 tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ci.yaml: least-privilege token (contents: read), concurrency that cancels
superseded PR runs only, job timeouts, workflow_dispatch, and NuGet caching.
lock.yml gets the explicit permissions it needs.

New benchmark.yml compares this source against the latest published package:
on PRs touching src/, benchmarks/ or Directory.Packages.props it runs the
end-to-end sink group for both sides and posts a delta table to the run
summary and a sticky PR comment (Allocated is exact even on shared runners
and serves as the regression signal; Mean is indicative). Manual dispatch
adds a custom --filter and the YetAnother yardstick. The baseline version is
resolved from NuGet at run time and guarded by API major; the V8 benchmark
project gains an overridable BaselineVersion property and the shared config
a brief JSON exporter feeding the new compare-results.fsx (dotnet fsi, in
keeping with the repo's F#-first tooling).

New dependency-review.yml flags vulnerable dependency changes on PRs, and
dependabot now ignores the two deliberately pinned benchmark packages.

Also corrects the integration-test fsproj comment: without Docker the tests
fail, they are not skipped; CI runs them on ubuntu-latest, which has Docker.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Matches the header every source file already carries.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Neither V8 nor V9 ever shipped a copyright field in the nuspec; the property
also flows into AssemblyCopyright. Matches the source headers and LICENSE.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restores what V8 shipped and V9 had lost: the package now carries symbols
and a working SourceLink story.

- DebugType=embedded folds the portable PDB (with its SourceLink map) into
  the assembly: line numbers in consumer stack traces and step-into that
  fetches the exact source from GitHub, with no symbol server involved
- EmbedUntrackedSources covers build-generated files
- PublishRepositoryUrl writes the repository url/branch/commit into the
  nuspec (previously commit-only)
- ContinuousIntegrationBuild makes CI builds deterministic
- PackageReleaseNotes points at the GitHub change log, as V8 did

Verified on the packed output: PE debug directory shows EmbeddedPortablePdb
+ Reproducible, no loose .pdb, nuspec gains repository url and releaseNotes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The V8 source remains fully available on the master branch and the v8.x
release tags; this copy matched it byte-for-byte modulo line-ending
normalization. Pre-deletion audit confirmed everything was migrated or
deliberately dropped per V9_SPEC.md (renaming strategy, HTTP client
hierarchy, hand-rolled batching infra superseded by Serilog 4.x native
batching), test coverage mapped to the V9 suites, samples ported, package
metadata (symbols, SourceLink, copyright) restored. CodeQL stays dropped:
it has no F# support. Also removes the pre-rewrite F# prototype and the
CPM opt-out shim that existed only for this folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The design-time spec served its purpose; the README and the project wiki are
the living documentation now, and the spec had drifted (still described the
EnrichTraceId/EnrichSpanId bools superseded by TraceIdMode/SpanIdMode and
listed #255 as deferred though implemented). History preserves it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An invalid tenant previously surfaced only as a SelfLog warning with the
X-Scope-OrgID header silently dropped - logs would then be 401-rejected or
land under the wrong tenant. Now tenant gets the same fail-fast treatment
as uri: alphanumerics plus !-_.*'(), at most 150 bytes, and not '.' or '..'
(https://grafana.com/docs/loki/latest/operations/multi-tenancy/), throwing
ArgumentException at CreateLogger time. Unlike v8's regex, '..' is only
rejected as the exact value, and the documented 150-byte cap is enforced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jun 6, 2026

Copy link
Copy Markdown

Benchmark comparison

Baseline: Serilog.Sinks.Grafana.Loki 8.3.2 (latest on NuGet) vs this source.

Allocated is exact and deterministic — treat it as the regression signal.
Mean on shared CI runners is noisy; deltas within ±10% are not meaningful.

Benchmark v8.3.2 alloc source alloc Δ alloc v8.3.2 mean source mean Δ mean
SinkBenchmarks.Push(EventCount: 1000, Payload: "Exception") 22.73 MB 7.64 MB −66.4% 🟢 24.47 ms 15.38 ms −37.1% 🟢
SinkBenchmarks.Push(EventCount: 1000, Payload: "Simple") 7.48 MB 137.2 KB −98.2% 🟢 12.73 ms 5.46 ms −57.1% 🟢
SinkBenchmarks.Push(EventCount: 10000, Payload: "Exception") 227.87 MB 76.43 MB −66.5% 🟢 267.73 ms 134.71 ms −49.7% 🟢
SinkBenchmarks.Push(EventCount: 10000, Payload: "Simple") 74.95 MB 1.36 MB −98.2% 🟢 67.97 ms 18.03 ms −73.5% 🟢

@mishamyte mishamyte changed the title V9: ground-up F# rewrite on Serilog 4.x native batching [V9] New architecture + performance + features Jun 6, 2026
@mishamyte
mishamyte merged commit 9533e37 into master Jun 6, 2026
7 checks passed
@mishamyte
mishamyte deleted the v9 branch June 6, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant