Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
103 changes: 103 additions & 0 deletions SW.Logger.IntegrationTests/ElasticSearchLoggerJourneyTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
[Trait("Category", "CriticalJourney")]
public class ElasticSearchLoggerJourneyTests : IClassFixture<ElasticsearchFixture>
{
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<ILoggerFactory>().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<JsonElement>()).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();
}
58 changes: 58 additions & 0 deletions SW.Logger.IntegrationTests/ElasticsearchFixture.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
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<JsonElement> Get(string path) =>
await Client.GetFromJsonAsync<JsonElement>(path);

/// <summary>Polls until <paramref name="condition"/> holds, or fails after <paramref name="timeout"/>.</summary>
public static async Task Eventually(Func<Task<bool>> condition, TimeSpan timeout, string because)
{
var deadline = DateTime.UtcNow + timeout;
while (!await condition())
{
if (DateTime.UtcNow > deadline)
Assert.Fail(because);
await Task.Delay(500);
}
}
}
21 changes: 21 additions & 0 deletions SW.Logger.IntegrationTests/SW.Logger.IntegrationTests.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="Testcontainers" Version="4.16.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\SW.Logger.ElasticSearch\SW.Logger.ElasticSearch.csproj" />
</ItemGroup>

</Project>
14 changes: 14 additions & 0 deletions SW.Logger.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading