diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..d9d28de
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,15 @@
+# AGENTS.md
+
+## Critical-journey tests (verify)
+
+This repository's critical journeys are tested in `SW.Logger.IntegrationTests/`; run them with `dotnet test SW.Logger.IntegrationTests`.
+They gate releases through the `verify / result` check.
+
+When you change this repository:
+- If you change something a journey uses (a route, screen, form, API call, auth or data shape),
+ update the affected tests and their mocks or recordings in the same PR, and run them.
+- If you add a feature that becomes a core flow, add a journey for it. Keep 3–5 journeys in all:
+ replace the least important one instead of growing the list.
+- If you remove a flow, remove its test. Never skip, delete or loosen a test just to get green.
+- Same rules as when they were written: never a real environment (dev, staging or production),
+ and never real credentials or personal data in tests, mocks or recordings.
diff --git a/README.md b/README.md
index 5922a7f..c81f7ac 100644
--- a/README.md
+++ b/README.md
@@ -216,6 +216,8 @@ SW-Logger/
│ ├── IHostBuilderExtensions.cs
│ ├── LoggerOptions.cs
│ └── StringExtensions.cs
+├── SW.Logger.IntegrationTests/ # Critical journeys against a throwaway Elasticsearch (Testcontainers)
+├── SW.Logger.UnitTests/ # Unit tests
└── SW.Logger.SampleWeb/ # Sample web application
├── Program.cs
├── Startup.cs
@@ -253,6 +255,18 @@ Supports both basic authentication and certificate-based authentication for Elas
- .NET 8.0 or higher
- For ElasticSearch logging: ElasticSearch cluster (7.x or 8.x)
+## Tests
+
+```sh
+dotnet test SW.Logger.UnitTests
+# Against a throwaway Elasticsearch 8.18 container (needs Docker): logs reach the data stream,
+# and the retention policy is saved and applied.
+dotnet test SW.Logger.IntegrationTests
+```
+
+On Docker Desktop for macOS, if containers fail with `invalid mount config ... docker.raw.sock`,
+run with `TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE=/var/run/docker.sock`.
+
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
diff --git a/SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs b/SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs
new file mode 100644
index 0000000..a99a721
--- /dev/null
+++ b/SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs
@@ -0,0 +1,103 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using SW.Logger.ElasticSerach;
+using Xunit;
+
+namespace SW.Logger.IntegrationTests;
+
+///
+/// What a service relies on SimplyWorks.Logger.ElasticSearch for, against a real
+/// (throwaway) Elasticsearch: its logs land in its data stream, and the retention
+/// policy it is configured with is saved and applied to that stream.
+///
+[Trait("Category", "CriticalJourney")]
+public class ElasticSearchLoggerJourneyTests : IClassFixture
+{
+ private readonly ElasticsearchFixture _elastic;
+
+ public ElasticSearchLoggerJourneyTests(ElasticsearchFixture elastic) => _elastic = elastic;
+
+ [Fact]
+ public async Task Logs_ReachTheDataStream_AtTheConfiguredLevel()
+ {
+ const string app = "journeylogs";
+ var marker = Guid.NewGuid().ToString("N");
+
+ using (var host = BuildHost(app, deleteAfterDays: 30))
+ {
+ await host.StartAsync();
+ var logger = host.Services.GetRequiredService().CreateLogger("Journey");
+ logger.LogInformation("Order {Marker} shipped", marker);
+ logger.LogDebug("Debug {Marker} below the configured level", marker);
+ await host.StopAsync();
+ } // Disposing the host flushes the sink's buffer.
+
+ var stream = $"logs-{app}-development";
+ JsonElement hits = default;
+ await ElasticsearchFixture.Eventually(async () =>
+ {
+ await _elastic.Client.PostAsync($"{stream}/_refresh", null);
+ var search = await _elastic.Client.GetAsync($"{stream}/_search?q={marker}&size=10");
+ if (!search.IsSuccessStatusCode)
+ return false;
+ hits = (await search.Content.ReadFromJsonAsync()).GetProperty("hits").GetProperty("hits");
+ return hits.GetArrayLength() > 0;
+ }, TimeSpan.FromSeconds(60), $"The log never reached {stream}.");
+
+ // Only the Information entry: the Debug one is below the configured level.
+ var log = Assert.Single(hits.EnumerateArray()).GetProperty("_source");
+ Assert.Equal($"Order \"{marker}\" shipped", log.GetProperty("message").GetString());
+ Assert.Equal("Information", log.GetProperty("log.level").GetString());
+ Assert.Equal("Development", log.GetProperty("labels").GetProperty("Environment").GetString());
+ Assert.Equal(marker, log.GetProperty("labels").GetProperty("Marker").GetString());
+ }
+
+ [Fact]
+ public async Task RetentionPolicy_IsSavedAndAppliedToTheDataStream()
+ {
+ const string app = "journeyretention";
+ var stream = $"logs-{app}-development";
+ var policy = $"{stream}-retention";
+
+ // The policy can only be applied to a stream that exists (it is retried daily
+ // otherwise); create it the way the built-in logs-*-* template would.
+ var created = await _elastic.Client.PutAsync($"_data_stream/{stream}", null);
+ Assert.True(created.IsSuccessStatusCode, await created.Content.ReadAsStringAsync());
+
+ using var host = BuildHost(app, deleteAfterDays: 14, replicasAfterRollover: 0);
+ await host.StartAsync();
+
+ await ElasticsearchFixture.Eventually(async () =>
+ {
+ var settings = await _elastic.Client.GetAsync($"{stream}/_settings/index.lifecycle.name");
+ return settings.IsSuccessStatusCode &&
+ (await settings.Content.ReadAsStringAsync()).Contains($"\"{policy}\"");
+ }, TimeSpan.FromSeconds(60), $"{policy} was never applied to {stream}.");
+
+ var saved = await _elastic.Get($"_ilm/policy/{policy}");
+ var phases = saved.GetProperty(policy).GetProperty("policy").GetProperty("phases");
+ Assert.Equal("14d", phases.GetProperty("delete").GetProperty("min_age").GetString());
+ Assert.Equal(0, phases.GetProperty("warm").GetProperty("actions").GetProperty("allocate")
+ .GetProperty("number_of_replicas").GetInt32());
+
+ await host.StopAsync();
+ }
+
+ private IHost BuildHost(string app, int deleteAfterDays, int? replicasAfterRollover = null) =>
+ Host.CreateDefaultBuilder()
+ .UseEnvironment("Development")
+ .UseSwElasticSearchLogger(o =>
+ {
+ o.ApplicationName = app;
+ o.LoggingLevel = 2; // Information
+ o.ElasticsearchUrl = _elastic.Url;
+ o.ElasticsearchUser = "test-user";
+ o.ElasticsearchPassword = "not-a-real-password";
+ o.ElasticsearchDeleteIndexAfterDays = deleteAfterDays;
+ o.ElasticsearchReplicasAfterRollover = replicasAfterRollover;
+ })
+ .Build();
+}
diff --git a/SW.Logger.IntegrationTests/ElasticsearchFixture.cs b/SW.Logger.IntegrationTests/ElasticsearchFixture.cs
new file mode 100644
index 0000000..4459e1d
--- /dev/null
+++ b/SW.Logger.IntegrationTests/ElasticsearchFixture.cs
@@ -0,0 +1,58 @@
+using System.Net.Http.Json;
+using System.Text.Json;
+using DotNet.Testcontainers.Builders;
+using DotNet.Testcontainers.Containers;
+using Xunit;
+
+namespace SW.Logger.IntegrationTests;
+
+///
+/// A throwaway single-node Elasticsearch, pinned to the 8.x line the sink targets
+/// (Elastic.Serilog.Sinks 8.18). Security is off so the tests can read it back over
+/// plain HTTP; nothing here ever talks to a real cluster.
+///
+public sealed class ElasticsearchFixture : IAsyncLifetime
+{
+ private const int Port = 9200;
+
+ private readonly IContainer _container = new ContainerBuilder("elasticsearch:8.18.2")
+ .WithEnvironment("discovery.type", "single-node")
+ .WithEnvironment("xpack.security.enabled", "false")
+ .WithEnvironment("ES_JAVA_OPTS", "-Xms512m -Xmx512m")
+ .WithPortBinding(Port, true)
+ .WithWaitStrategy(Wait.ForUnixContainer().UntilHttpRequestIsSucceeded(r => r
+ .ForPort(Port)
+ .ForPath("/_cluster/health")))
+ .Build();
+
+ public string Url => $"http://{_container.Hostname}:{_container.GetMappedPublicPort(Port)}";
+
+ public HttpClient Client { get; private set; }
+
+ public async Task InitializeAsync()
+ {
+ await _container.StartAsync();
+ Client = new HttpClient { BaseAddress = new Uri(Url) };
+ }
+
+ public async Task DisposeAsync()
+ {
+ Client?.Dispose();
+ await _container.DisposeAsync();
+ }
+
+ public async Task Get(string path) =>
+ await Client.GetFromJsonAsync(path);
+
+ /// Polls until holds, or fails after .
+ public static async Task Eventually(Func> condition, TimeSpan timeout, string because)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!await condition())
+ {
+ if (DateTime.UtcNow > deadline)
+ Assert.Fail(because);
+ await Task.Delay(500);
+ }
+ }
+}
diff --git a/SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj b/SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj
new file mode 100644
index 0000000..7e11926
--- /dev/null
+++ b/SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj
@@ -0,0 +1,21 @@
+
+
+
+ net10.0
+ disable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/SW.Logger.sln b/SW.Logger.sln
index 2d7a829..abb24a1 100644
--- a/SW.Logger.sln
+++ b/SW.Logger.sln
@@ -11,6 +11,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Logger.Console", "SW.Log
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Logger.UnitTests", "SW.Logger.UnitTests\SW.Logger.UnitTests.csproj", "{A2CEEFC3-1E8E-40A7-A224-64DF8697713E}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SW.Logger.IntegrationTests", "SW.Logger.IntegrationTests\SW.Logger.IntegrationTests.csproj", "{60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -69,6 +71,18 @@ Global
{A2CEEFC3-1E8E-40A7-A224-64DF8697713E}.Release|x64.Build.0 = Release|Any CPU
{A2CEEFC3-1E8E-40A7-A224-64DF8697713E}.Release|x86.ActiveCfg = Release|Any CPU
{A2CEEFC3-1E8E-40A7-A224-64DF8697713E}.Release|x86.Build.0 = Release|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x64.Build.0 = Debug|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Debug|x86.Build.0 = Debug|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x64.ActiveCfg = Release|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x64.Build.0 = Release|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x86.ActiveCfg = Release|Any CPU
+ {60FFD8B4-4AB4-4F43-A0D0-14ED64ACBDA6}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE