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
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,15 @@ See `CLAUDE.md` for deep architecture documentation, plugin contracts, and key d
methods.
- All runtime service classes require a non-nullable `ILoggerFactory` / `ILogger<T>` constructor parameter — no internal
fallback.
- Each class owns its own `ILogger<Self>` (created from the injected `ILoggerFactory`) so log entries carry the
emitter's category and per-category filtering works as expected. Do not pass a shared `ILogger` instance across
multiple classes.
- Configuration- and lifecycle-time log calls (those added by the `add-tracker-config-logging` change in
`ChangeTrackingBuilder`, `EntityBuilder<T>`, `SharedHandlerBuilder<T>`, `IsolatedHandlerBuilder<T>`, and
`EntityChangeTracker.InitializeAsync`) MUST be guarded with `if (_logger.IsEnabled(<level>)) ...` so
`NullLoggerFactory` produces zero allocations and zero output.
- See [docs/configuration.md#what-gets-logged](docs/configuration.md#what-gets-logged) for the full per-class log
inventory (level, trigger, structured properties).

### Testing Conventions

Expand Down
106 changes: 106 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,112 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

### Added

#### Configuration- and lifecycle-time logging on the tracker builder (`RayTree.Core`, `RayTree.Hosting`)

Before this release the builder and tracker were almost silent during configuration and
startup: operators bringing up a new service had no visible evidence of which plugins were
registered, which entity types were configured, or which defaults were applied.
Misconfigurations (forgotten outbox, wrong consumer factory, mismatched serializer) only
surfaced as runtime errors later that were hard to trace back to the build step.

The change adds structured `Information` / `Debug` / `Warning` logs through the entire
configuration and initialization path. No code change is required to opt in — the new logs
flow automatically through the `ILoggerFactory` already supplied to `ChangeTrackingBuilder`
(directly, or via `AddChangeTracking` from the DI container).

**Builder configuration logs** — emitted from `ChangeTrackingBuilder` and from per-entity
builders (`EntityBuilder<TEntity>`, `SharedHandlerBuilder<TEntity>`,
`IsolatedHandlerBuilder<TEntity>`):

| Level | When | Properties |
|---|---|---|
| `Information` | Each global `Use*` call | `{Plugin}` |
| `Information` | `ForEntity<TEntity>` invocation | `{EntityType}` |
| `Debug` | Default `RayTreeMeter` fallback in `BuildInternal` | — |
| `Information` | "ChangeTracker built" summary at the end of `Build()` / `BuildAsync()` | `{EntityTypes}`, `{Plugins}`, `{HasCustomMeter}`, `{HasCustomDeduplicationStore}`, `{HasCustomLoggerFactory}` |
| `Debug` | Per-entity plugin override (`UseOutbox`, `UsePublisher`, `UseSerializer`, `UseCompressor`, `UseRepository`, `UseConsumer`, `UseConsumerFactory`, `UseSubscriberOptions`) | `{EntityType}`, `{Override}`, `{Plugin}` |
| `Debug` | Handler registration (`OnInsert` / `OnUpdate` / `OnDelete` / `OnChange`, both Shared and Isolated modes) | `{EntityType}`, `{Override}` (e.g. `OnInsert:audit`), `{Plugin}` |

For lambda handlers, `{Plugin}` walks past compiler-generated closure types
(`<>c__DisplayClass…`) and reports the user's outer class — so logs show
`Plugin=MyService` instead of `Plugin=<>c__DisplayClass3_0`.

**Tracker initialization lifecycle** — emitted from `EntityChangeTracker.InitializeAsync`:

| Level | When | Properties |
|---|---|---|
| `Information` | "tracker initialization started" | — |
| `Debug` | Publisher initialization complete | `{EntityTypeCount}` |
| `Debug` | Consumer connections initialized | `{ConsumerCount}` |
| `Information` | "tracker initialization completed" — success | — |
| `Warning` | "tracker initialization aborted" — abort marker on failure; the inner publisher/subscriber/plugin's own `Error` carries the exception payload | — |

The Warning on failure deliberately omits the exception to avoid double-logging — inner
service layers already log the root cause at `Error`. Operators can grep for the abort line
and follow it back to the inner Error immediately above in the log stream.

**Tracker disposal on failed init** — `Build()` and `BuildAsync()` now wrap
`InitializeAsync` in try/catch and call `tracker.Dispose()` before rethrowing.
Previously a partially-initialized tracker (owned `RayTreeMeter`, publisher background
services started inside `ChangePublisher.InitializeAsync`, dedup store, etc.) leaked when
init threw, because the caller never received a reference.

**DI startup log** — emitted from `ChangeTrackingHostedService.StartAsync`:

| Level | When | Properties |
|---|---|---|
| `Information` | "ChangeTracking starting" — once per host instance | `{ConfigurationBound}` |

A new internal `ChangeTrackingDiContext` record captures `configuration != null` at
`AddChangeTracking` registration time and flows it to the hosted service via DI.
Registration is idempotent (`TryAddSingleton`), so calling `AddChangeTracking` twice
no longer appends duplicate registrations.

**Per-category log filtering**

Each builder owns an `ILogger<Self>` (not a shared logger), so the category attached to
every log entry matches the type that actually emitted it. Operators can filter per category
in their log sink — for example `RayTree.Core.Handling.SharedHandlerBuilder*` to see only
handler-registration events, or silence `Debug` from a specific builder without affecting
others.

**Zero overhead under `NullLoggerFactory`**

Every new log call is guarded by `ILogger.IsEnabled(<level>)`. Because
`NullLogger.IsEnabled` always returns `false`, the entire log body — including the
build-summary's `EntityTypes` array and `Plugins` string interpolation — is skipped under
`NullLoggerFactory.Instance`. Callers who opt out of logging pay zero allocation at startup.

**Example output** for a tracker with two entities:

```text
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracking: registered global serializer JsonSerializerPlugin
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracking: configuring entity Order
dbug: RayTree.Core.Tracking.EntityBuilder`1[Order][0]
ChangeTracking: entity override applied EntityType=Order Override=Outbox Plugin=PostgreSqlOutbox`1
dbug: RayTree.Core.Handling.SharedHandlerBuilder`1[Order][0]
ChangeTracking: entity override applied EntityType=Order Override=OnInsert Plugin=MyService
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracker built. EntityTypes=["Order", "Customer"] Plugins=Outbox=<none> Publisher=<none> Serializer=JsonSerializerPlugin Compressor=NoOpCompressorPlugin Repository=<none> HasCustomMeter=False HasCustomDeduplicationStore=False HasCustomLoggerFactory=True
info: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: tracker initialization started
dbug: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: publisher initialized EntityTypeCount=2
dbug: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: consumers initialized ConsumerCount=1
info: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: tracker initialization completed
info: RayTree.Hosting.ChangeTrackingHostedService[0]
ChangeTracking starting. ConfigurationBound=True
```

See [`docs/configuration.md`](docs/configuration.md#what-gets-logged) for the full
log-entry reference.

---

#### Topology wait — opt-in startup retry for externally-owned RabbitMQ topology (`RayTree.Plugins.RabbitMQ`)

In microservice deployments the exchange or queue that a publisher or consumer depends on is
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ All durations are emitted in seconds (`s`) per OTel semantic conventions; bytes
- **Metrics placement rule — required meter, no null fallback**: `RayTreeMeter` is a required non-null constructor parameter on every runtime service that emits metrics (`ChangePublisher`, `OutboxPublisherService`, `ChangeSubscriber`). There is no internal `new RayTreeMeter()` fallback in those classes — the builder layer (`ChangeTrackingBuilder.BuildInternal`) constructs a default meter when the caller didn't supply one via `UseMeter`, then injects it everywhere. This matches the logging rule: callers make a conscious choice at builder/DI level, runtime services have no hidden defaults. `EntityChangeTracker` tracks ownership via an `ownsMeter` flag and disposes the meter only when it created it; caller-supplied meters are left alone. Instrument calls are silent no-ops when no listener is attached, so opting out costs nothing at runtime.
- **OTel SDK isolation via peer assembly**: `RayTree.Core` and `RayTree.Hosting` use only `System.Diagnostics.Metrics` (BCL) — no `OpenTelemetry.*` package references. `RayTree.OpenTelemetry` is a separate assembly with two members (`RayTreeInstrumentation.MeterName` + `AddRayTreeMetrics`) that an application opts into. Applications that don't need OTel receive zero transitive OTel dependencies; applications that do reference exactly one well-versioned dependency. This mirrors the `RayTree.Hosting` / `RayTree.EntityFrameworkCore` split.
- **Logging placement rule**: `NullLoggerFactory.Instance` / `NullLogger<T>.Instance` defaults belong **only** in builders and builder-context extension methods (`ChangeTrackingBuilder`, `ChangePublisherBuilder`, `ChangeSubscriberBuilder`, `KafkaSubscriberExtensions.UseKafka`, `RabbitMqBuilderExtensions.UseRabbitMq`, `RabbitMqSubscriberExtensions.UseRabbitMq`, `BuilderExtensions.UsePostgreSqlOutbox`, `RepositoryExtensions.UsePostgreSqlRepository`). All runtime service classes (`ChangePublisher`, `OutboxPublisherService`, `ChangeSubscriber`, `ChangeTrackingHostedService`, `KafkaConsumer`, `NotificationBasedPublisher`, `PostgreSqlOutbox<TEntity>`, `PostgreSqlRepository<TEntity>`) require a non-nullable logger — no internal fallback. `RabbitMqPublisher` accepts an optional `ILoggerFactory?` (null → `NullLoggerFactory.Instance`) to support topology-wait logging without forcing every caller through DI. **Exception**: `RabbitMqConsumer` intentionally has no logger — message-receive errors silently NACK and requeue, which is the correct broker-level recovery; no useful context is available inside the RabbitMQ delivery callback to produce a meaningful log entry. Consumer-side topology-wait probes therefore pass `logger: null` to `TopologyProbe`, preserving the exception. This ensures that callers always make a conscious choice about whether to produce log output.
- **Configuration & lifecycle logs**: `ChangeTrackingBuilder` emits `Information` for each `Use*` registration (with `{Plugin}` = type name), an `Information` "ChangeTracker built" summary at `BuildInternal` time (with `{EntityTypes}`, `{Plugins}`, `{HasCustomMeter}`, `{HasCustomDeduplicationStore}`, `{HasCustomLoggerFactory}`), and a `Debug` "no meter supplied" note when it creates the default `RayTreeMeter`. `ForEntity<TEntity>` logs `Information` for the entity type; per-entity overrides (`UseOutbox`, `UsePublisher`, `UseSerializer`, `UseCompressor`, `UseRepository`, `UseSubscriberOptions`, `UseConsumer`, `UseConsumerFactory`, `OnInsert`/`OnUpdate`/`OnDelete`/`OnChange`) log at `Debug` with `{EntityType}`, `{Override}` (slot name like `"Outbox"` or `"OnInsert:handlerName"`), and `{Plugin}` (concrete type name). `EntityChangeTracker.InitializeAsync` logs `Information` "tracker initialization started" / "completed" bracketing two `Debug` sub-step entries: "publisher initialized" with `{EntityTypeCount}` and "consumers initialized" with `{ConsumerCount}`; on failure it emits one `Warning` "tracker initialization aborted" (no exception payload — the inner service's own `Error` carries the cause) before rethrowing. `ChangeTrackingHostedService.StartAsync` emits one `Information` "ChangeTracking starting" with `{ConfigurationBound}` (sourced from the `ChangeTrackingDiContext` singleton registered by `AddChangeTracking`). Every call is guarded by `IsEnabled(...)` so `NullLoggerFactory` produces zero allocations.

## Code Style & Conventions

Expand Down
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<WarningsAsErrors>nullable</WarningsAsErrors>

<VersionPrefix>0.0.15</VersionPrefix>
<VersionPrefix>0.0.16</VersionPrefix>
<VersionSuffix>pre-release</VersionSuffix>

<Authors>bitc0der</Authors>
Expand Down
2 changes: 1 addition & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ A modular .NET 10 entity change tracking system with outbox pattern support, que
- **Modular Plugins** - Each serializer and compressor in its own package
- **In-Memory Testing** - Full in-memory implementation for development and testing
- **Auto-Initialization** - Automatic database schema initialization on `Build()` / `BuildAsync()`
- **Structured Logging** - `Microsoft.Extensions.Logging` throughout; pass `ILoggerFactory` to `EntityChangeTracker.Create()` or let `AddChangeTracking` wire it from DI automatically
- **Structured Logging** - `Microsoft.Extensions.Logging` throughout; pass `ILoggerFactory` to `EntityChangeTracker.Create()` or let `AddChangeTracking` wire it from DI automatically. Covers configuration-time events (`Use*` calls, `ForEntity` overrides, build summary), tracker initialization lifecycle (`InitializeAsync` started / sub-steps / completed / aborted), and runtime events (publish retries, dedup hits, handler failures). Every call is guarded by `IsEnabled(...)` for zero overhead under `NullLoggerFactory`. See [Configuration → What gets logged](configuration.md#what-gets-logged).
- **OpenTelemetry Metrics** - `System.Diagnostics.Metrics` instruments on a `"RayTree"` meter for outbox writes, publish/subscribe latency, payload size, queue depth, and retry shape. Zero OTel SDK dependency unless the optional `RayTree.OpenTelemetry` package is referenced. See [OpenTelemetry Metrics Guide](opentelemetry-metrics.md).
- **RabbitMQ Topology Wait** - Opt-in startup retry for `RabbitMqPublisher` and `RabbitMqConsumer` when the exchange or queue is owned by another service. Probes with AMQP passive declares and retries on `NOT_FOUND` until the topology appears, the cancellation token fires, or a configurable timeout elapses.

Expand Down
58 changes: 58 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,31 @@ When using the `.UseKafka(...)` / `.UseRabbitMq(...)` extension methods inside a

### What gets logged

**Configuration & build phase**

| Class | Level | When | Structured properties |
|---|---|---|---|
| `ChangeTrackingBuilder` | `Information` | Each global `Use*` call (`UseOutbox`, `UsePublisher`, `UseSerializer`, `UseCompressor`, `UseRepository`, `UseDeduplicationStore`, `UseMeter`, `UsePublisherOptions`, `UseSubscriberOptions`) | `{Plugin}` |
| `ChangeTrackingBuilder` | `Information` | `ForEntity<TEntity>` invocation | `{EntityType}` |
| `ChangeTrackingBuilder` | `Debug` | `BuildInternal` falls back to a default `RayTreeMeter` (no `UseMeter`) | — |
| `ChangeTrackingBuilder` | `Information` | "ChangeTracker built" summary, once per `Build()` / `BuildAsync()` | `{EntityTypes}`, `{Plugins}`, `{HasCustomMeter}`, `{HasCustomDeduplicationStore}`, `{HasCustomLoggerFactory}` |
| `EntityBuilder<TEntity>` | `Debug` | Per-entity publisher overrides (`UseOutbox`, `UsePublisher`, `UseSerializer`, `UseCompressor`, `UseRepository`, `UseSubscriberOptions`, `UseConsumer`, `UseConsumerFactory`) | `{EntityType}`, `{Override}`, `{Plugin}` |
| `SharedHandlerBuilder<TEntity>` | `Debug` | `OnInsert` / `OnUpdate` / `OnDelete` / `OnChange` handler registrations | `{EntityType}`, `{Override}`, `{Plugin}` |
| `IsolatedHandlerBuilder<TEntity>` | `Debug` | Named handler registrations | `{EntityType}`, `{Override}` (e.g. `OnInsert:audit`), `{Plugin}` |

**Tracker lifecycle**

| Class | Level | When | Structured properties |
|---|---|---|---|
| `EntityChangeTracker` | `Information` | "tracker initialization started" — entered `InitializeAsync` | — |
| `EntityChangeTracker` | `Debug` | Publisher init completed | `{EntityTypeCount}` |
| `EntityChangeTracker` | `Debug` | Consumer connections initialized | `{ConsumerCount}` |
| `EntityChangeTracker` | `Information` | "tracker initialization completed" — success | — |
| `EntityChangeTracker` | `Warning` | "tracker initialization aborted" — abort point marker; inner plugin's `Error` carries the exception payload | — |
| `ChangeTrackingHostedService` | `Information` | "ChangeTracking starting" — DI startup, once per host | `{ConfigurationBound}` |

**Runtime**

| Class | Level | When |
|---|---|---|
| `OutboxPublisherService` | `Information` | Polling loop start / stop |
Expand All @@ -519,6 +544,39 @@ When using the `.UseKafka(...)` / `.UseRabbitMq(...)` extension methods inside a
| `KafkaConsumer` | `Warning` | Consume error; envelope parse failure |
| `RabbitMqConsumer` | `Warning` | Message processing error (before requeue) |

All configuration- and lifecycle-time log calls are guarded by `ILogger.IsEnabled(...)`, so under `NullLoggerFactory.Instance` they produce zero allocations and zero output. Each builder owns an `ILogger<Self>` so per-category filtering works as expected (e.g. silence `Debug` from `IsolatedHandlerBuilder` while keeping `Information` from `ChangeTrackingBuilder`).

#### Example startup output

For a tracker configured with one global serializer + compressor and two entities, the captured log stream looks like:

```text
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracking: registered global serializer JsonSerializerPlugin
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracking: registered global compressor NoOpCompressorPlugin
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracking: configuring entity Order
dbug: RayTree.Core.Tracking.EntityBuilder`1[Order][0]
ChangeTracking: entity override applied EntityType=Order Override=Outbox Plugin=PostgreSqlOutbox`1
dbug: RayTree.Core.Handling.SharedHandlerBuilder`1[Order][0]
ChangeTracking: entity override applied EntityType=Order Override=OnInsert Plugin=MyService
info: RayTree.Core.Tracking.ChangeTrackingBuilder[0]
ChangeTracker built. EntityTypes=["Order", "Customer"] Plugins=Outbox=<none> Publisher=<none> Serializer=JsonSerializerPlugin Compressor=NoOpCompressorPlugin Repository=<none> HasCustomMeter=False HasCustomDeduplicationStore=False HasCustomLoggerFactory=True
info: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: tracker initialization started
dbug: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: publisher initialized EntityTypeCount=2
dbug: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: consumers initialized ConsumerCount=1
info: RayTree.Core.Tracking.EntityChangeTracker[0]
ChangeTracking: tracker initialization completed
info: RayTree.Hosting.ChangeTrackingHostedService[0]
ChangeTracking starting. ConfigurationBound=True
```

On failure, the inner plugin emits the `Error` with the exception, and the tracker emits a single `Warning` marking the abort point so operators can grep back to the cause without losing context.

## Observability — OpenTelemetry Metrics

RayTree emits `System.Diagnostics.Metrics` instruments on a `Meter` named `"RayTree"` (counters, histograms, and an observable gauge for outbox depth). Instrument calls are silent no-ops when no listener is attached, so there is no overhead for consumers that opt out.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-05-22
Loading
Loading