Skip to content

Commit 9533e37

Browse files
mishamyteclaude
andauthored
[V9] New architecture + performance + features (#326)
* chore: scaffold V9 branch — archive V8, introduce CPM and slnx - 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> * chore: add F# library project scaffold for V9 - 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> * feat: add PooledByteBufferWriter and Utf8TextWriter infrastructure 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> * feat: implement complete V9 sink pipeline 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> * chore: rename test/ to tests/ Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: add UnitTests project with 47 tests across Labels, Grouping, LokiExceptionFormatter 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> * chore: enforce strictest F# compiler settings solution-wide 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> * feat: update docker-compose and add V9 sample projects 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> * chore: rename sample/ to samples/ and update slnx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: wire format unit tests (74) + integration test scaffold 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> * chore: Rider code formatting pass 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> * feat: add StyleCop.Analyzers.Unstable to C# sample projects 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> * chore: disable SA1518 and remove insert_final_newline convention 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> * chore: remove StyleCop.ruleset, reference v8/ copy in editorconfig 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> * chore: remove v8 folder references from code, tests and config 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> * ci: add CI workflow for V9 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> * Revert "ci: add CI workflow for V9" This reverts commit c91665e. * build: add FAKE 6.1.4 build script 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: update ci.yaml for V9; add Solution Items to slnx; format build.fsx 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> * chore: add .gitattributes to enforce LF line endings 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> * chore: add MIT copyright header to all F# source files; fix license expression 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> * chore: remove codeql-analysis.yml 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> * fix: three bugs found during LokiSinkOptions validation against real 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> * refactor: replace two extension overloads with single flat-params method 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> * test: add EnrichTraceId/SpanId unit tests and 2 new integration tests 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> * fix: three Serilog.Settings.Configuration compatibility issues 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> * fix: revert Nullable<TimeSpan> attempt — F# cannot match C# TimeSpan? 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> * chore: complete MIT copyright header in all F# source files 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> * chore: update dependencies via dotnet outdated 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> * fix: wire RetryTimeLimit; drop TimeProvider; switch to Serilog native 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> * chore: remove noise comments from source files Five comments removed that explained WHAT rather than WHY: LokiSink.fs: type annotation already on method signature. LokiJsonTextFormatter.fs: obvious .NET IDisposable contract; caller reference. Serialization.fs: Stream labels / Values array restate WritePropertyName calls; same Dispose contract. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: add blank line after copyright header in all F# source files Matches V8 style: empty line between the last header line and the namespace/module declaration. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: align V9 samples with V8 configuration Console sample: - Remove propertiesAsLabels: [ThreadId] — V8 did not have this; a simple static label is sufficient to demonstrate basic sink usage Web app appsettings.json: - Restore static label app=web_app (was missing in V9) - Restore propertiesAsLabels: [app] matching V8 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: Rider warning fixes and formatting pass Rider fixed warnings and reformatted code across src, tests, samples and build. One introduced error reverted: Rider added namespace Serilog.Sinks.Grafana.Loki.Sample to Person.cs and a matching using to Program.cs, but this creates CS0234 because Serilog.Sinks.Grafana.Loki is already a namespace in the referenced F# library. Person.cs reverted to global namespace (top-level statements stay in global scope). 77 unit tests pass unchanged. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat: auto-format staged F# files on commit via Husky.Net + Fantomas Tools (.config/dotnet-tools.json): fantomas 7.0.5 — F# formatter (same engine Rider uses internally) husky 0.9.1 — git hooks manager Directory.Build.targets: HuskyInstall target runs dotnet husky install after every restore. Contributors get hooks automatically the first time they build — no manual steps required. HUSKY=0 or CI=true skips installation. ContinueOnError=true prevents failures on subsequent restores. .husky/pre-commit (Option B — auto-format + re-stage): 1. Collects staged .fs/.fsx files under src/, tests/, and build.fsx 2. Runs fantomas on them (formats in-place) 3. Re-stages the formatted files — commit proceeds without interruption Fantomas check on src/: all files already clean (exit 0). Fantomas check on tests/: GroupingTests.fs and WireFormatTests.fs needed formatting — formatted and verified (77 tests still pass). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore: simplify HuskyInstall condition; set HUSKY=0 in ci.yaml Removed the AND CI=='' check — CI env variable is not standardised across providers. HUSKY=0 is the explicit, documented opt-out that Husky.Net itself recommends. CI pipelines should set HUSKY=0 explicitly rather than relying on an auto-detected CI variable. Added HUSKY=0 to ci.yaml env section so hook installation is skipped on all GitHub Actions runs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * test: verify Fantomas hook auto-formats on commit * Revert "test: verify Fantomas hook auto-formats on commit" This reverts commit 1576d48. * chore: improve HuskyInstall target following reference pattern - BeforeTargets instead of AfterTargets: hooks installed before packages restore - Exists(.git) condition: Docker/non-git contexts skipped automatically, no config needed - IgnoreExitCode instead of ContinueOnError: truly silent on re-runs, no spurious warnings - StandardErrorImportance=High: errors remain visible for debugging despite IgnoreExitCode - Removed WorkingDirectory from tool restore (upward search finds .config/dotnet-tools.json) - HUSKY=0 condition retained as explicit opt-out alongside the .git check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: harden sink correctness from PR review findings - Labels.fs: use InvariantCulture for label values; apply sanitizeLabelKey to global label keys - LokiExceptionFormatter.fs: add depth-20 guard against cyclic InnerException chains - LokiJsonTextFormatter.fs: null-guard exceptionFormatter in primary constructor - LokiSink.fs: null-coalesce Labels/PropertiesAsLabels arrays; log SelfLog on serialization failure; handle null response.Content; warn when X-Scope-OrgID header cannot be set - LoggerConfigurationExtensions.fs: parse period/retryTimeLimit with InvariantCulture and emit descriptive error messages on invalid input - WireFormatTests.fs: add new tests covering null scalar, non-finite double, sequence value serialization, URI validation, SpanId opt-out, custom formatter path, and HTTP error path Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: three correctness issues found in second review pass - Serialization.fs: restrict fast-path FormatToBuffer to exact LokiJsonTextFormatter type so subclass Format overrides are honoured (isinst matched derived types) - LokiSink.fs: dispose HttpResponseMessage via use! to avoid connection pool leak - LokiSink.fs: guard X-Scope-OrgID header mutation behind ownsClient, consistent with the existing Basic Auth guard (injected clients are pre-configured) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: normalise base URI trailing slash before push endpoint resolution 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> * feat: add GrafanaLoki(LokiSinkOptions) overload per V9 spec 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> * fix: preserve serialization exception stack trace; add real HTTP error 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> * Revert "feat: add GrafanaLoki(LokiSinkOptions) overload per V9 spec" This reverts commit ac0c81f. * chore: simplify redundant namespace qualifier in ErrorHttpHandler Threading.Tasks.Task → Task; namespace already open. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: simplify LokiSink, LoggerConfigurationExtensions and Labels - 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> * docs: replace stale V8 ILokiHttpClient section with V9 DelegatingHandler 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> * Rewrite README for v9 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> * Fix NuGet package icon filename in fsproj 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> * Add appsettings.json binding test for Settings.Configuration 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> * Use Nullable<TimeSpan> for period and retryTimeLimit 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> * Add defaults drift-guard test; restore LokiCredentials object parameter #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> * feat: structured metadata and TraceId/SpanId routing (#255) 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> * perf: reduce allocations and speed up the serialization path 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> * test: add V8/V9/YetAnother benchmark suite and a FAKE target 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> * chore: remove unused Hedgehog dependency and tidy imports/XML 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> * style: pin F# formatting rules, adopt aligned brackets, drop BOMs 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: harden pipelines and add NuGet-baseline benchmark comparison 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> * chore: update LICENSE copyright year range to 2020-2026 Matches the header every source file already carries. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore: add Copyright to package and assembly metadata 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> * feat: ship embedded symbols and full SourceLink metadata in the package 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> * chore: remove the v8/ reference copy 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> * chore: remove V9_SPEC.md 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> * docs: add CLAUDE.md Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs: credit the OG Loki sink and YetAnother in README Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat: validate tenant against Loki tenant ID rules at configuration time 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> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent dd4bf5d commit 9533e37

144 files changed

Lines changed: 6249 additions & 3874 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.config/dotnet-tools.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"version": 1,
3+
"isRoot": true,
4+
"tools": {
5+
"fantomas": {
6+
"version": "7.0.5",
7+
"commands": ["fantomas"]
8+
},
9+
"husky": {
10+
"version": "0.9.1",
11+
"commands": ["husky"]
12+
}
13+
}
14+
}

.editorconfig

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
root = true
2+
3+
[*]
4+
charset = utf-8
5+
end_of_line = lf
6+
indent_style = space
7+
indent_size = 4
8+
insert_final_newline = false
9+
trim_trailing_whitespace = true
10+
11+
# YAML conventionally uses 2-space indentation
12+
[*.{yml,yaml}]
13+
indent_size = 2
14+
15+
# Markdown: a trailing double-space is a hard line break
16+
[*.md]
17+
trim_trailing_whitespace = false
18+
19+
# ── F# — Fantomas ──────────────────────────────────────────────────────────────
20+
# Read by the Fantomas CLI, the Husky pre-commit hook, and Rider's F# formatter.
21+
# max_line_length pins the Fantomas default; aligned brackets (dotnet/fsharp
22+
# compiler style) and the blank-line cap are deliberate choices.
23+
[*.{fs,fsx}]
24+
max_line_length = 120
25+
fsharp_multiline_bracket_style = aligned
26+
fsharp_keep_max_number_of_blank_lines = 1
27+
28+
[*.cs]
29+
# ── StyleCop.Analyzers.Unstable — rule severity ───────────────────────────────
30+
# Rule decisions: documentation and file-header rules off; spacing, layout,
31+
# ordering and naming enforced as errors.
32+
33+
# SA0001/SA0002 — disable to avoid requiring GenerateDocumentationFile everywhere
34+
dotnet_diagnostic.SA0001.severity = none
35+
dotnet_diagnostic.SA0002.severity = none
36+
37+
# SA1003 — symbol spacing (None)
38+
dotnet_diagnostic.SA1003.severity = none
39+
# SA1009 — closing parenthesis spacing (None)
40+
dotnet_diagnostic.SA1009.severity = none
41+
# SA1012 — opening brace spacing (None)
42+
dotnet_diagnostic.SA1012.severity = none
43+
# SA1013 — closing brace spacing (None)
44+
dotnet_diagnostic.SA1013.severity = none
45+
46+
# SA1101 — do NOT require this. prefix (SX1101 requires no this. prefix)
47+
dotnet_diagnostic.SA1101.severity = none
48+
dotnet_diagnostic.SX1101.severity = error
49+
50+
# SA1200 — using directives placement; modern C# allows file-scoped usings
51+
dotnet_diagnostic.SA1200.severity = none
52+
53+
# SA1305/SA1306/SA1309 — naming: allow lowercase private fields, _prefix
54+
dotnet_diagnostic.SA1305.severity = none
55+
dotnet_diagnostic.SA1306.severity = none
56+
dotnet_diagnostic.SA1309.severity = none
57+
58+
# SA1401 — fields may be public (e.g. test fixtures, records)
59+
dotnet_diagnostic.SA1401.severity = none
60+
61+
# SA1412/SA1413 — file encoding / trailing comma (flexible)
62+
dotnet_diagnostic.SA1412.severity = none
63+
dotnet_diagnostic.SA1413.severity = none
64+
65+
# SA1518 — no trailing newline requirement
66+
dotnet_diagnostic.SA1518.severity = none
67+
68+
# SA1506/SA1514 — blank line rules (flexible)
69+
dotnet_diagnostic.SA1506.severity = none
70+
dotnet_diagnostic.SA1514.severity = none
71+
72+
# SA1600–SA1651 — all documentation rules disabled
73+
dotnet_diagnostic.SA1600.severity = none
74+
dotnet_diagnostic.SA1601.severity = none
75+
dotnet_diagnostic.SA1602.severity = none
76+
dotnet_diagnostic.SA1604.severity = none
77+
dotnet_diagnostic.SA1605.severity = none
78+
dotnet_diagnostic.SA1606.severity = none
79+
dotnet_diagnostic.SA1607.severity = none
80+
dotnet_diagnostic.SA1608.severity = none
81+
dotnet_diagnostic.SA1610.severity = none
82+
dotnet_diagnostic.SA1611.severity = none
83+
dotnet_diagnostic.SA1612.severity = none
84+
dotnet_diagnostic.SA1613.severity = none
85+
dotnet_diagnostic.SA1614.severity = none
86+
dotnet_diagnostic.SA1615.severity = none
87+
dotnet_diagnostic.SA1616.severity = none
88+
dotnet_diagnostic.SA1617.severity = none
89+
dotnet_diagnostic.SA1618.severity = none
90+
dotnet_diagnostic.SA1619.severity = none
91+
dotnet_diagnostic.SA1620.severity = none
92+
dotnet_diagnostic.SA1621.severity = none
93+
dotnet_diagnostic.SA1622.severity = none
94+
dotnet_diagnostic.SA1623.severity = none
95+
dotnet_diagnostic.SA1624.severity = none
96+
dotnet_diagnostic.SA1625.severity = none
97+
dotnet_diagnostic.SA1626.severity = none
98+
dotnet_diagnostic.SA1627.severity = none
99+
dotnet_diagnostic.SA1629.severity = none
100+
101+
# SA1633–SA1651 — file header and copyright rules disabled
102+
dotnet_diagnostic.SA1633.severity = none
103+
dotnet_diagnostic.SA1634.severity = none
104+
dotnet_diagnostic.SA1635.severity = none
105+
dotnet_diagnostic.SA1636.severity = none
106+
dotnet_diagnostic.SA1637.severity = none
107+
dotnet_diagnostic.SA1638.severity = none
108+
dotnet_diagnostic.SA1640.severity = none
109+
dotnet_diagnostic.SA1641.severity = none
110+
dotnet_diagnostic.SA1642.severity = none
111+
dotnet_diagnostic.SA1643.severity = none
112+
dotnet_diagnostic.SA1648.severity = none
113+
dotnet_diagnostic.SA1649.severity = error
114+
dotnet_diagnostic.SA1651.severity = none

.gitattributes

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Normalise all text files to LF in the repository and on checkout.
2+
# Eliminates the "LF will be replaced by CRLF" warning on Windows.
3+
* text=auto eol=lf
4+
5+
# Explicitly mark binary assets so git never attempts line-ending conversion.
6+
*.snk binary
7+
*.png binary
8+
*.dll binary
9+
*.exe binary

.github/ISSUE_TEMPLATE/bug_report.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ body:
88
id: sink-version
99
attributes:
1010
label: Which version of Serilog.Sinks.Grafana.Loki are you using?
11-
placeholder: v8.0.0
11+
placeholder: v9.0.0
1212
validations:
1313
required: true
1414
- type: input

.github/dependabot.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,13 @@ updates:
99
directory: "/" # Location of package manifests
1010
schedule:
1111
interval: "daily"
12+
ignore:
13+
# Benchmark baselines are deliberate pins (benchmarks/*/*.fsproj VersionOverride):
14+
# the previously published release the suite measures against, and the third-party
15+
# yardstick. Bump them consciously — together with the recorded numbers in
16+
# benchmarks/README.md — not via bot PRs.
17+
- dependency-name: "Serilog.Sinks.Grafana.Loki"
18+
- dependency-name: "Serilog.Sinks.Loki.YetAnother"
1219

1320
- package-ecosystem: "github-actions"
1421
directory: "/" # Location of package manifests

.github/workflows/benchmark.yml

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
name: "Benchmarks"
2+
3+
# Compares the benchmark suite of this source tree against the latest published
4+
# NuGet package, so a PR shows whether it changes performance.
5+
#
6+
# - On pull requests touching the library or the benchmarks: runs the end-to-end
7+
# sink group (the regression target) for the NuGet baseline and the PR source,
8+
# posts a comparison table to the run summary and as a sticky PR comment.
9+
# - On manual dispatch: same, plus the YetAnother yardstick and a custom filter.
10+
#
11+
# Reading the numbers: `Allocated` is exact and deterministic even on shared
12+
# runners — that is the regression signal. `Mean` is indicative only (noisy VMs).
13+
14+
on:
15+
pull_request:
16+
paths:
17+
- 'src/**'
18+
- 'benchmarks/**'
19+
- 'Directory.Packages.props'
20+
workflow_dispatch:
21+
inputs:
22+
filter:
23+
description: "BenchmarkDotNet --filter glob"
24+
required: false
25+
default: '*'
26+
include_yetanother:
27+
description: 'Also run the Serilog.Sinks.Loki.YetAnother yardstick'
28+
type: boolean
29+
required: false
30+
default: true
31+
32+
permissions:
33+
contents: read
34+
pull-requests: write # sticky benchmark comment on same-repo PRs
35+
36+
concurrency:
37+
group: bench-${{ github.ref }}
38+
cancel-in-progress: true
39+
40+
env:
41+
DOTNET_CLI_TELEMETRY_OPTOUT: true
42+
DOTNET_NOLOGO: true
43+
HUSKY: 0
44+
# PRs run only the end-to-end sink group to keep the job short; manual runs take the input.
45+
BENCH_FILTER: ${{ github.event_name == 'workflow_dispatch' && inputs.filter || '*Sink*' }}
46+
47+
jobs:
48+
benchmark:
49+
name: Baseline vs source
50+
runs-on: ubuntu-latest
51+
timeout-minutes: 90
52+
steps:
53+
# Full history + tags for MinVer (the V9 project builds ../src).
54+
- uses: actions/checkout@v6
55+
with:
56+
fetch-depth: 0
57+
58+
- name: Setup .NET
59+
uses: actions/setup-dotnet@v5.2.0
60+
with:
61+
dotnet-version: |
62+
8.x
63+
9.x
64+
10.x
65+
66+
- name: Cache NuGet packages
67+
uses: actions/cache@v4
68+
with:
69+
path: ~/.nuget/packages
70+
key: nuget-${{ runner.os }}-${{ hashFiles('Directory.Packages.props', '**/*.fsproj', '**/*.csproj') }}
71+
restore-keys: nuget-${{ runner.os }}-
72+
73+
- name: Resolve baseline version (latest on NuGet)
74+
id: baseline
75+
run: |
76+
# Keep in sync with the BaselineVersion default in the V8 benchmark fsproj.
77+
# The latest NuGet version is used only when it shares that pin's API major:
78+
# a different major means a different public API, and the baseline project's
79+
# Benchmarks.fs must be migrated before the pin can move.
80+
pinned='8.3.2'
81+
latest=$(curl -fsSL https://api.nuget.org/v3-flatcontainer/serilog.sinks.grafana.loki/index.json | jq -r '.versions | last')
82+
if [ "${latest%%.*}" = "${pinned%%.*}" ]; then
83+
version="$latest"
84+
note='latest on NuGet'
85+
else
86+
version="$pinned"
87+
note="pinned; latest on NuGet is $latest, whose API major differs - migrate the baseline benchmark project to move the pin"
88+
fi
89+
echo "version=$version" >> "$GITHUB_OUTPUT"
90+
echo "note=$note" >> "$GITHUB_OUTPUT"
91+
echo "Baseline: Serilog.Sinks.Grafana.Loki $version ($note)"
92+
93+
- name: Run baseline (NuGet ${{ steps.baseline.outputs.version }})
94+
run: >
95+
dotnet run -c Release
96+
--project benchmarks/Serilog.Sinks.Grafana.Loki.Benchmarks.V8
97+
-p:BaselineVersion=${{ steps.baseline.outputs.version }}
98+
-- --filter "$BENCH_FILTER"
99+
100+
- name: Run source
101+
run: >
102+
dotnet run -c Release
103+
--project benchmarks/Serilog.Sinks.Grafana.Loki.Benchmarks.V9
104+
-- --filter "$BENCH_FILTER"
105+
106+
- name: Run YetAnother yardstick
107+
if: github.event_name == 'workflow_dispatch' && inputs.include_yetanother
108+
run: >
109+
dotnet run -c Release
110+
--project benchmarks/Serilog.Sinks.Grafana.Loki.Benchmarks.YetAnother
111+
-- --filter "$BENCH_FILTER"
112+
113+
- name: Build comparison report
114+
env:
115+
BASELINE_VERSION: ${{ steps.baseline.outputs.version }}
116+
BASELINE_NOTE: ${{ steps.baseline.outputs.note }}
117+
run: |
118+
v8="benchmarks/Serilog.Sinks.Grafana.Loki.Benchmarks.V8/bin/Release/net8.0/BenchmarkDotNet.Artifacts"
119+
v9="benchmarks/Serilog.Sinks.Grafana.Loki.Benchmarks.V9/bin/Release/net8.0/BenchmarkDotNet.Artifacts"
120+
ya="benchmarks/Serilog.Sinks.Grafana.Loki.Benchmarks.YetAnother/bin/Release/net8.0/BenchmarkDotNet.Artifacts"
121+
122+
{
123+
echo '## Benchmark comparison'
124+
echo ''
125+
echo "Baseline: \`Serilog.Sinks.Grafana.Loki $BASELINE_VERSION\` ($BASELINE_NOTE) vs **this source**."
126+
echo ''
127+
echo '> `Allocated` is exact and deterministic — treat it as the regression signal.'
128+
echo '> `Mean` on shared CI runners is noisy; deltas within ±10% are not meaningful.'
129+
echo ''
130+
dotnet fsi benchmarks/compare-results.fsx "$v8" "$v9" "v$BASELINE_VERSION" 'source'
131+
if [ -d "$ya" ]; then
132+
echo ''
133+
echo '### vs Serilog.Sinks.Loki.YetAnother (yardstick)'
134+
echo ''
135+
dotnet fsi benchmarks/compare-results.fsx "$ya" "$v9" 'YetAnother' 'source'
136+
fi
137+
} > benchmark-report.md
138+
139+
cat benchmark-report.md >> "$GITHUB_STEP_SUMMARY"
140+
141+
- name: Append full BenchmarkDotNet tables to the run summary
142+
run: |
143+
find benchmarks -path '*BenchmarkDotNet.Artifacts*' -name '*-report-github.md' | sort | while read -r f; do
144+
proj=$(echo "$f" | sed -E 's#.*Benchmarks\.([^/]+)/bin.*#\1#')
145+
{
146+
echo "<details><summary>$proj — $(basename "$f" .md)</summary>"
147+
echo ''
148+
cat "$f"
149+
echo ''
150+
echo '</details>'
151+
echo ''
152+
} >> "$GITHUB_STEP_SUMMARY"
153+
done
154+
155+
- name: Upload raw results
156+
if: always()
157+
uses: actions/upload-artifact@v4
158+
with:
159+
name: benchmark-results
160+
path: benchmarks/**/BenchmarkDotNet.Artifacts/results/*
161+
162+
# Forked PRs get a read-only token and cannot comment; the run summary covers them.
163+
- name: Post sticky PR comment
164+
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
165+
uses: actions/github-script@v7
166+
with:
167+
script: |
168+
const fs = require('fs');
169+
const marker = '<!-- benchmark-report -->';
170+
let body = marker + '\n' + fs.readFileSync('benchmark-report.md', 'utf8');
171+
if (body.length > 65000) {
172+
body = body.slice(0, 65000) + '\n\n_…truncated — see the workflow run summary for the full report._';
173+
}
174+
const { data: comments } = await github.rest.issues.listComments({
175+
owner: context.repo.owner,
176+
repo: context.repo.repo,
177+
issue_number: context.issue.number,
178+
per_page: 100,
179+
});
180+
const existing = comments.find((c) => c.body && c.body.startsWith(marker));
181+
if (existing) {
182+
await github.rest.issues.updateComment({
183+
owner: context.repo.owner,
184+
repo: context.repo.repo,
185+
comment_id: existing.id,
186+
body,
187+
});
188+
} else {
189+
await github.rest.issues.createComment({
190+
owner: context.repo.owner,
191+
repo: context.repo.repo,
192+
issue_number: context.issue.number,
193+
body,
194+
});
195+
}

0 commit comments

Comments
 (0)