Skip to content
Merged
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
55 changes: 55 additions & 0 deletions docs/eventlog-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Event log design

## What it is

The event log is the primary instance's activity feed: the chronological "what has this instance noticed" list that ServicePulse shows. Message failures, retries, redirects, heartbeats, custom checks and integration failures all surface here.

It is a **projection of domain events, not a log file**. Nothing writes to it directly. Components raise domain events for their own reasons, and the event log turns a chosen subset of those into feed items. An event only appears if someone has declared how it should read, which makes the feed an editorial selection rather than a dump.

Only the primary instance has an event log. Audit and monitoring instances have none.

Four contracts define the whole part: `EventLogItem` (what is written), `EventLogItemView` (what is read), `EventLogMappingDefinition<TEvent>` (how a component declares an event belongs in the feed), and `IEventLogDataStore` (the storage seam). Everything else is machinery behind them.

## What gets recorded

`EventLogItem` is the write contract, and it carries **no identity**. The identity is minted in each persistence seam.

`EventLogItemView` is the same shape plus `Id`, which is assigned by storage rather than by the application.

`RelatedTo` entries are built by helpers with fixed prefixes: `/message/{id}`, `/endpoint/{name}`, `/machine/{name}`, `/host/{guid}`, `/customcheck/{id}`, `/recoverability/groups/{id}`. The helpers only prefix a string, so passing the wrong property yields a link that resolves to nothing.

## Declaring an event

A component makes one of its events visible with two things, and nothing in the event log changes:

1. A class deriving **directly** from `EventLogMappingDefinition<TEvent>`. Derive through an intermediate non-generic base and the definition is silently skipped at registration.
2. A matching `services.AddEventLogMapping<TDefinition>()` in that component's configuration. Two definitions for one event type is an error.

A definition is a **declarative builder, not a handler**: its constructor calls `Description(…)`, and optionally `RaisedAt(…)`, `Severity(…)` / `TreatAsError()` and the `RelatesTo*` helpers, to specify how one row reads. Definitions live with the component that raises the event: **this part owns the machinery, the components own the content**. An event with no declaration is ignored, deliberately and silently.

## Reading the feed

`GET /api/eventlogitems` is the entire HTTP surface, gated on `Permissions.ErrorEventLogView`. It takes `page` and `pageSize`, returns one page of `EventLogItemView` newest first, and sets `Total-Count`, `ETag` and `Link`. There is no write, no delete, no per-item lookup, and no filtering or search: `Category`, `Severity` and `EventType` are returned but cannot be queried on.

Clients discover new items by **polling**; nothing is pushed, and recording an item has no outward effect at all. Because polling is the only path, a caller that echoes its `ETag` back as `If-None-Match` gets `304` with no body, and the request costs a header exchange instead of a page of JSON.

Timestamps are when the thing happened, so an item can land in the middle of the feed rather than at its head.

## The storage seam

`IEventLogDataStore` has two methods, and its XML docs are the binding contract:

- `Add(EventLogItem)` persists one item. **Identity is the store's to assign** and surfaces on `EventLogItemView.Id`. That makes `Id` opaque: a stable key within one store, not something to parse.
- `GetEventLogItems(PagingInfo, knownVersion)` returns a `QueryResult` carrying the page, the total count independent of paging, and an `ETag`. Two obligations: the `ETag` is surfaced **verbatim**, so whatever the client echoes back arrives here unchanged and can be compared, and it **must change when retention removes items**, not only when one is added, since nothing else tells a polling client its cached page has gone stale.

## Retention

Items age out on their own after `EventsRetentionPeriod`, 14 days by default. Nothing a user does removes one and there is no API to try. Enforcement is left to each storage backend and is invisible through the seam, so a change to the setting applies retrospectively on some backends and to new items only on others.

## Failure behaviour

Recording is **in-band, not best-effort**. Domain event dispatch rethrows, so if storage is unreachable the failure propagates to whatever raised the event and that operation fails. There is no retry, no queue and no buffer in front of the write.

## Known limits

- **The feed is per error instance.** A federated deployment gets no aggregation and nothing in the response says which instance answered.
20 changes: 20 additions & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ Transport tests are done by executing the transport test suite for each transpor

Run ServiceControl full version and use the HTTP API to validate results. LearningTransport is used for all tests.

### Windows prerequisite: register the event sources

On Windows, every acceptance test fails on first run with:

```
System.Security.SecurityException : The source ServiceControl was not found on computer .,
but some or all event logs could not be searched. Inaccessible logs: Security.
```

Setup registers a Windows event source, and checking whether one already exists enumerates every event log, including `Security`, which a process without administrator rights cannot read.

Register both sources once, from an **elevated** PowerShell prompt:

```powershell
[System.Diagnostics.EventLog]::CreateEventSource('ServiceControl', 'Application')
[System.Diagnostics.EventLog]::CreateEventSource('ServiceControl.Audit', 'Application')
```

Use the .NET API as shown rather than `New-EventLog`, which is not available in PowerShell 7. Afterwards the tests run normally without elevation, because the lookup finds the registered source before it needs to read `Security`.

## Multi-instance tests

Multi-instance tests validate the interaction between different ServiceControl instances. ServiceControl instances are run in-memory in the same process. LearningTransport is used for all tests.
Expand Down
Comment thread
warwickschroeder marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
namespace ServiceControl.AcceptanceTests.EventLogs
{
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading.Tasks;
using AcceptanceTesting;
using AcceptanceTesting.EndpointTemplates;
using NServiceBus;
using NServiceBus.AcceptanceTesting;
using NUnit.Framework;
using ServiceBus.Management.Infrastructure.Settings;
using ServiceControl.EventLog;

[TestFixture]
class When_the_event_log_is_polled_with_an_etag : AcceptanceTest
{
[Test]
public async Task Should_answer_not_modified_only_for_the_current_etag()
{
string etag = null;
HttpStatusCode currentEtagStatus = default;
HttpStatusCode unknownEtagStatus = default;
var unknownEtagReturnedItems = false;

await Define<ScenarioContext>()
.WithEndpoint<StartingEndpoint>()
.Done(async c =>
{
var first = await this.GetRaw("/api/eventlogitems/");

if (first.StatusCode != HttpStatusCode.OK)
{
return false;
}

var items = await first.Content.ReadFromJsonAsync<List<EventLogItem>>(SerializerOptions);

// Keep polling until the endpoint's startup event has landed. An empty event
// log has a stable ETag of its own and would make the assertions meaningless.
if (items is not { Count: > 0 })
{
return false;
}

// Raw header: an unquoted value fails EntityTagHeaderValue parsing, and this test
// has to observe that rather than throw on it.
if (!first.Headers.TryGetValues("ETag", out var values))
{
return false;
}

etag = values.FirstOrDefault();

if (string.IsNullOrEmpty(etag))
{
return false;
}

currentEtagStatus = (await Poll(etag)).StatusCode;

var unknown = await Poll("\"not-an-etag-this-instance-ever-issued\"");
unknownEtagStatus = unknown.StatusCode;

var unknownBody = await unknown.Content.ReadFromJsonAsync<List<EventLogItem>>(SerializerOptions);
unknownEtagReturnedItems = unknownBody is { Count: > 0 };

return true;
})
.Run();

using (Assert.EnterMultipleScope())
{
Assert.That(etag, Is.Not.Null.And.Not.Empty,
"the endpoint must emit an ETag or nothing downstream can cache it");

Assert.That(currentEtagStatus, Is.EqualTo(HttpStatusCode.NotModified),
"a client holding the current version must be told so, not handed the page again");

Assert.That(unknownEtagStatus, Is.EqualTo(HttpStatusCode.OK),
"an unrecognised validator is a cache miss: this is what stops an unconditional 304 passing");

Assert.That(unknownEtagReturnedItems, Is.True,
"a cache miss must carry the items, not an empty body with a 200");
}
}

Task<HttpResponseMessage> Poll(string ifNoneMatch)
{
var request = new HttpRequestMessage(HttpMethod.Get, "/api/eventlogitems/");
request.Headers.TryAddWithoutValidation("If-None-Match", ifNoneMatch);

return HttpClient.SendAsync(request);
}

public class StartingEndpoint : EndpointConfigurationBuilder
{
public StartingEndpoint() =>
EndpointSetup<DefaultServerWithoutAudit>(c => c.SendHeartbeatTo(Settings.DEFAULT_INSTANCE_NAME));
}
}
}
Loading
Loading