Skip to content

Spyglass API generation#490

Merged
alexeyzimarev merged 16 commits intodevfrom
spyglass-api-gen
Feb 16, 2026
Merged

Spyglass API generation#490
alexeyzimarev merged 16 commits intodevfrom
spyglass-api-gen

Conversation

@alexeyzimarev
Copy link
Contributor

@alexeyzimarev alexeyzimarev commented Feb 16, 2026

Avoid using reflections for Spyglass API.

Breaking change: current version of Spyglass won't be able to load streams with this version of the API.

@alexeyzimarev
Copy link
Contributor Author

/review

@qodo-free-for-open-source-projects
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 4 🔵🔵🔵🔵⚪
🧪 PR contains tests
🔒 Security concerns

Sensitive information exposure:
MapEventuousSpyglass generates an access key and logs it (Using generated key: {Key}). If logs are shipped to centralized logging or accessible to unauthorized users, this key can be used to access Spyglass endpoints. Consider avoiding logging the full key (masking), or only logging that a key was generated without printing it, and documenting that the API should be protected behind auth/network controls. Additionally, when key is null in Development, the API is effectively unauthenticated—ensure this cannot be exposed publicly.

⚡ Recommended focus areas for review

Concurrency

The registry uses a mutable static List for registrations. Module initializers from multiple referenced assemblies may run in an undefined order and can be triggered concurrently in some hosting/test scenarios, and API requests may read while registrations are being added. Consider making registration thread-safe (lock/ConcurrentBag/ImmutableArray pattern) and/or de-duplicating entries defensively.

public static class SpyglassRegistry {
    static readonly List<SpyglassAggregateInfo> Aggregates = [];

    public static void Register(SpyglassAggregateInfo info) => Aggregates.Add(info);

    public static SpyglassAggregateInfo[] GetAggregates() => [.. Aggregates];

    public static SpyglassAggregateInfo? FindByTypeName(string typeName)
        => Aggregates.FirstOrDefault(x => x.AggregateType                == typeName)
         ?? Aggregates.FirstOrDefault(x => StripStateSuffix(x.StateType) == typeName);
Side Effects

Generated code instantiates states via parameterless constructors to read RegisteredEventTypes. This assumes all discovered State types have a public parameterless ctor and that constructing them has no side effects. If a state requires DI/args or performs work in its ctor, the module initializer will fail or behave unexpectedly. Consider detecting ctor availability at generation time and either skipping with diagnostics or using a safer metadata approach.

if (!aggregates.IsDefaultOrEmpty) {
    foreach (var c in aggregates.Distinct()) {
        sb.AppendLine("        SpyglassRegistry.Register(new SpyglassAggregateInfo(");
        sb.AppendLine($"            \"{Escape(c.AggregateSimpleName)}\",");
        sb.AppendLine($"            \"{Escape(c.StateSimpleName)}\",");
        EmitStringArray(sb, c.Methods);
        sb.AppendLine($"            new {c.StateFqn}().RegisteredEventTypes.Select(t => t.Name).ToArray(),");
        sb.AppendLine("            static async (eventStore, streamName, version) => {");
        sb.AppendLine($"                var aggregate = new {c.AggregateFqn}();");
        sb.AppendLine("                var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);");
        sb.AppendLine("                var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
        sb.AppendLine("                aggregate.Load(selected.Length > 0 ? selected[^1].Revision : -1, selected.Select(x => x.Payload));");
        sb.AppendLine("                return new SpyglassLoadResult(");
        sb.AppendLine("                    aggregate.State,");
        sb.AppendLine("                    events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");
        sb.AppendLine("            }");
        sb.AppendLine("        ));");
    }
}

if (!states.IsDefaultOrEmpty) {
    foreach (var s in states.Distinct()) {
        sb.AppendLine("        SpyglassRegistry.Register(new SpyglassAggregateInfo(");
        sb.AppendLine("            null,");
        sb.AppendLine($"            \"{Escape(s.StateSimpleName)}\",");
        sb.AppendLine("            System.Array.Empty<string>(),");
        sb.AppendLine($"            new {s.StateFqn}().RegisteredEventTypes.Select(t => t.Name).ToArray(),");
        sb.AppendLine("            static async (eventStore, streamName, version) => {");
        sb.AppendLine("                var events = await eventStore.ReadStream(new StreamName(streamName), StreamReadPosition.Start, true, default);");
        sb.AppendLine("                var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
        sb.AppendLine($"                var state = selected.Select(x => x.Payload!).Aggregate(new {s.StateFqn}(), (s, e) => s.When(e));");
        sb.AppendLine("                return new SpyglassLoadResult(");
        sb.AppendLine("                    state,");
        sb.AppendLine("                    events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");
        sb.AppendLine("            }");
        sb.AppendLine("        ));");
    }
}
Possible Issue

The load endpoint derives the type name by slicing streamName up to the first '-' character. If stream names don't contain '-', IndexOf returns -1 and the slice will throw. Consider validating the format (or using a more robust parsing rule) and returning a 400 with a clear message.

builder.MapGet(
        "/spyglass/load/{streamName}",
        async (HttpRequest request, [FromServices] IEventStore eventStore, string streamName, [FromQuery] int version) => {
            if (!Authorized(request)) return Results.Unauthorized();

            var typeName = streamName[..streamName.IndexOf('-')];
            var aggInfo  = SpyglassRegistry.FindByTypeName(typeName);

            if (aggInfo is null) return Results.NotFound($"Aggregate type '{typeName}' not found");

            var result = await aggInfo.LoadDelegate(eventStore, streamName, version);

            return Results.Ok(new { result.State, Events = result.Events.Select(e => new { e.EventType, e.Payload }) });
        }
📄 References
  1. No matching references available

@github-actions
Copy link

github-actions bot commented Feb 16, 2026

Test Results

 57 files  + 40   57 suites  +40   34m 19s ⏱️ + 23m 18s
293 tests + 21  293 ✅ + 21  0 💤 ±0  0 ❌ ±0 
882 runs  +599  882 ✅ +599  0 💤 ±0  0 ❌ ±0 

Results for commit c10e47d. ± Comparison against base commit 7a9b9bd.

This pull request removes 5 and adds 26 tests. Note that renamed tests count towards both.
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 8:35:28 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 8:35:28 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(aed7bafb-ab66-4a64-8a50-b204ca9cfa26)
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 4, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T20:35:28.0123534+00:00 })
Eventuous.Tests.Subscriptions.SequenceTests ‑ ShouldReturnFirstBefore(CommitPosition { Position: 0, Sequence: 1, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 6, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 8, Timestamp: 2026-02-16T20:35:28.0123534+00:00 }, CommitPosition { Position: 0, Sequence: 2, Timestamp: 2026-02-16T20:35:28.0123534+00:00 })
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(0e49128b-c582-4494-8fd6-c5055ed78df5)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:44:08 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:44:08 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:44:13 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:44:13 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:44:20 PM +00:00)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(2/16/2026 9:44:20 PM)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(9039d370-f6c4-436c-bd05-238deaf0a671)
Eventuous.Tests.Azure.ServiceBus.IsSerialisableByServiceBus ‑ Passes(b0dbbb91-0045-4dca-aeb8-fa944e0db10d)
Eventuous.Tests.Spyglass.Generators.SpyglassGeneratorTests ‑ Should_collect_public_methods_from_aggregate
…

♻️ This comment has been updated with latest results.

@alexeyzimarev alexeyzimarev marked this pull request as ready for review February 16, 2026 20:55
@qodo-free-for-open-source-projects
Copy link
Contributor

Review Summary by Qodo

Replace Spyglass reflection with compile-time code generation

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Replace reflection-based Spyglass API with compile-time code generation
• Implement incremental Roslyn source generator for aggregate discovery
• Support standalone state types for functional command services
• Refactor sample apps to use generated registration and simplified patterns
Diagram
flowchart LR
  A["Aggregates & States"] -->|Roslyn Generator| B["SpyglassGenerator"]
  B -->|Discovers at compile-time| C["Module Initializer"]
  C -->|Auto-registers| D["SpyglassRegistry"]
  D -->|Serves| E["Spyglass API"]
  F["InsidePeek<br/>Reflection"] -.->|Removed| G["No Runtime Reflection"]
Loading

Grey Divider

File Changes

1. src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs ✨ Enhancement +259/-0

New incremental source generator for aggregate discovery

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs


2. src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs ✨ Enhancement +11/-0

Generator constants for FQN matching

src/Experimental/gen/Eventuous.Spyglass.Generators/Constants.cs


3. src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs ✨ Enhancement +45/-0

New static registry for discovered aggregates

src/Experimental/src/Eventuous.Spyglass/SpyglassRegistry.cs


View more (36)
4. src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs ✨ Enhancement +27/-20

Refactor API to use registry instead of reflection

src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs


5. src/Experimental/src/Eventuous.Spyglass/RegistrationExtensions.cs ✨ Enhancement +7/-3

Mark DI registration obsolete, no longer needed

src/Experimental/src/Eventuous.Spyglass/RegistrationExtensions.cs


6. src/Experimental/src/Eventuous.Spyglass/Accessor.cs ✨ Enhancement +0/-31

Remove reflection-based member accessor

src/Experimental/src/Eventuous.Spyglass/Accessor.cs


7. src/Experimental/src/Eventuous.Spyglass/InsidePeek.cs ✨ Enhancement +0/-130

Remove runtime reflection-based discovery

src/Experimental/src/Eventuous.Spyglass/InsidePeek.cs


8. src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj ⚙️ Configuration changes +13/-0

New generator project configuration

src/Experimental/gen/Eventuous.Spyglass.Generators/Eventuous.Spyglass.Generators.csproj


9. src/Experimental/gen/Eventuous.Spyglass.Generators/README.md 📝 Documentation +87/-0

Documentation for generator behavior and usage

src/Experimental/gen/Eventuous.Spyglass.Generators/README.md


10. src/Experimental/test/Eventuous.Tests.Spyglass.Generators/CompilationHelper.cs 🧪 Tests +57/-0

Test helper for generator compilation

src/Experimental/test/Eventuous.Tests.Spyglass.Generators/CompilationHelper.cs


11. src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs 🧪 Tests +141/-0

Comprehensive tests for generator discovery logic

src/Experimental/test/Eventuous.Tests.Spyglass.Generators/SpyglassGeneratorTests.cs


12. src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs 🧪 Tests +164/-0

Integration tests for Spyglass API endpoints

src/Experimental/test/Eventuous.Tests.Spyglass/SpyglassApiTests.cs


13. src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj ⚙️ Configuration changes +14/-0

Test project for generator unit tests

src/Experimental/test/Eventuous.Tests.Spyglass.Generators/Eventuous.Tests.Spyglass.Generators.csproj


14. src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj ⚙️ Configuration changes +18/-0

Test project for Spyglass API integration

src/Experimental/test/Eventuous.Tests.Spyglass/Eventuous.Tests.Spyglass.csproj


15. src/Core/src/Eventuous.Domain/State.cs ✨ Enhancement +7/-5

Add method to retrieve registered event types

src/Core/src/Eventuous.Domain/State.cs


16. src/Core/src/Eventuous.Persistence/StreamNameMap.cs 📝 Documentation +25/-0

Add XML documentation for stream name mapping

src/Core/src/Eventuous.Persistence/StreamNameMap.cs


17. samples/kurrentdb/Bookings.Payments/Domain/Payment.cs ✨ Enhancement +2/-8

Simplify aggregate, remove explicit ID type

samples/kurrentdb/Bookings.Payments/Domain/Payment.cs


18. samples/kurrentdb/Bookings.Payments/Application/CommandService.cs ✨ Enhancement +3/-3

Refactor to functional pattern without aggregate

samples/kurrentdb/Bookings.Payments/Application/CommandService.cs


19. samples/kurrentdb/Bookings.Payments/Program.cs ✨ Enhancement +3/-0

Add Spyglass API mapping to application

samples/kurrentdb/Bookings.Payments/Program.cs


20. samples/kurrentdb/Bookings/Bookings.csproj ⚙️ Configuration changes +1/-0

Add Spyglass generator analyzer reference

samples/kurrentdb/Bookings/Bookings.csproj


21. samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj ⚙️ Configuration changes +4/-0

Add Spyglass generator and enable generated files

samples/kurrentdb/Bookings.Payments/Bookings.Payments.csproj


22. samples/Directory.Build.props ⚙️ Configuration changes +1/-1

Expand target frameworks for samples

samples/Directory.Build.props


23. src/Directory.Build.props ⚙️ Configuration changes +1/-0

Add LocalGenRoot property for generator projects

src/Directory.Build.props


24. src/Directory.Build.targets ⚙️ Configuration changes +0/-8

Remove obsolete build targets file

src/Directory.Build.targets


25. src/Directory.Testable.targets ⚙️ Configuration changes +0/-8

Remove obsolete testable targets file

src/Directory.Testable.targets


26. src/Directory.Untestable.targets ⚙️ Configuration changes +0/-3

Remove obsolete untestable targets file

src/Directory.Untestable.targets


27. src/Core/gen/Eventuous.Shared.Generators/Eventuous.Shared.Generators.csproj ⚙️ Configuration changes +0/-9

Remove polyfill compilation exclusions

src/Core/gen/Eventuous.Shared.Generators/Eventuous.Shared.Generators.csproj


28. src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Core/src/Eventuous.Shared/Eventuous.Shared.csproj


29. src/Core/src/Eventuous.Subscriptions/Eventuous.Subscriptions.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Core/src/Eventuous.Subscriptions/Eventuous.Subscriptions.csproj


30. src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Core/test/Eventuous.Tests.Shared.Analyzers/Eventuous.Tests.Shared.Analyzers.csproj


31. src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Core/test/Eventuous.Tests/Eventuous.Tests.csproj


32. src/Extensions/src/Eventuous.Extensions.AspNetCore/Eventuous.Extensions.AspNetCore.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Extensions/src/Eventuous.Extensions.AspNetCore/Eventuous.Extensions.AspNetCore.csproj


33. src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Extensions/test/Eventuous.Sut.AspNetCore/Eventuous.Sut.AspNetCore.csproj


34. src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore.Analyzers/Eventuous.Tests.Extensions.AspNetCore.Analyzers.csproj


35. src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj ⚙️ Configuration changes +1/-1

Use LocalGenRoot property for generator reference

src/Extensions/test/Eventuous.Tests.Extensions.AspNetCore/Eventuous.Tests.Extensions.AspNetCore.csproj


36. src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj ⚙️ Configuration changes +1/-0

Add generator as analyzer dependency

src/Experimental/src/Eventuous.Spyglass/Eventuous.Spyglass.csproj


37. src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs 🧪 Tests +2/-0

Add retry attribute to flaky tests

src/Azure/test/Eventuous.Tests.Azure.ServiceBus/SendAndReceive.cs


38. Directory.Packages.props Dependencies +1/-0

Add IsExternalInit package dependency

Directory.Packages.props


39. Eventuous.slnx ⚙️ Configuration changes +5/-0

Add new generator and test projects to solution

Eventuous.slnx


Grey Divider

Qodo Logo

@qodo-free-for-open-source-projects
Copy link
Contributor

qodo-free-for-open-source-projects bot commented Feb 16, 2026

Code Review by Qodo

🐞 Bugs (5) 📘 Rule violations (1) 📎 Requirement gaps (0)

Grey Divider


Action required

1. key logged in SpyglassApi 📘 Rule violation ⛨ Security
Description
The Spyglass API logs the generated access key at Information level, which can leak a secret into
centralized logs. Anyone with log access could reuse the key to access Spyglass endpoints and
potentially view internal state/events.
Code

src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[R28-32]

+        if (!builder.ServiceProvider.GetRequiredService<IWebHostEnvironment>().IsDevelopment() && key == null) {
+            logger.LogWarning("Insecure Spyglass API is only available in development environment");
+            key = Guid.NewGuid().ToString("N");
+            logger.LogInformation("Using generated key: {Key}", key);
+        }
Evidence
PR Compliance ID 5 forbids logging sensitive data (including secrets). The new code generates an API
key and logs its value via structured logging ({Key}), which directly places the secret into logs.

Rule 5: Generic: Secure Logging Practices
src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[28-32]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Spyglass API logs a generated access key value (`{Key}`), which is a secret and should never be written to application logs.
## Issue Context
This key gates access to Spyglass endpoints; logging it makes it retrievable from log aggregation systems and enables unauthorized use.
## Fix Focus Areas
- src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[28-32]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Versioned load inconsistent 🐞 Bug ✓ Correctness
Description
Generated load delegates rehydrate state using a version-truncated event list but return the full
(untruncated) event list to the API response. This can mislead callers because the returned events
won’t match the returned state when version != -1.
Code

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[R177-183]

+                sb.AppendLine("                var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);");
+                sb.AppendLine("                var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
+                sb.AppendLine("                aggregate.Load(selected.Length > 0 ? selected[^1].Revision : -1, selected.Select(x => x.Payload));");
+                sb.AppendLine("                return new SpyglassLoadResult(");
+                sb.AppendLine("                    aggregate.State,");
+                sb.AppendLine("                    events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");
+                sb.AppendLine("            }");
Evidence
The generator uses selected to load state but uses events (full stream) to build the returned
SpyglassLoadResult.Events. The API then returns that unfiltered result.Events directly.

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-183]
src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[60-72]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The generated Spyglass load delegates apply `version` to state rehydration but still return *all* events from the stream, causing state/events mismatch.
### Issue Context
Spyglass is used for debugging/introspection; returning inconsistent data makes the API misleading.
### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-203]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


✅ 3. Missing stream yields 500 🐞 Bug ⛯ Reliability
Description
The generated load delegates call ReadStream(..., failIfNotFound: true) and Spyglass API doesn’t
handle StreamNotFound, so loading an unknown entity likely becomes an unhandled exception (500)
instead of a 404/empty result.
Code

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[R177-178]

+                sb.AppendLine("                var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);");
+                sb.AppendLine("                var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
Evidence
StoreFunctions.ReadStream throws StreamNotFound unless failIfNotFound is false. The generator
passes true, and SpyglassApi awaits the delegate without catching exceptions, so the exception
will bubble up.

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-178]
src/Core/src/Eventuous.Persistence/EventStore/StoreFunctions.cs[68-95]
src/Core/src/Eventuous.Persistence/EventStore/EventStoreExceptions.cs[12-12]
src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[60-72]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Loading a non-existent stream is likely to throw `StreamNotFound` and surface as a 500 because SpyglassApi doesn’t catch it.
### Issue Context
`StoreFunctions.ReadStream` only swallows `StreamNotFound` when `failIfNotFound` is false.
### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-203]
- src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[60-72]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (1)
✅ 4. Null payload crashes load 🐞 Bug ⛯ Reliability
Description
Generated code null-forces StreamEvent.Payload when folding state and when computing event type
names. Since StreamEvent.Payload is nullable, a null payload can cause NullReferenceException and
break /spyglass/load.
Code

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[R182-202]

+                sb.AppendLine("                    events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");
+                sb.AppendLine("            }");
+                sb.AppendLine("        ));");
+            }
+        }
+
+        if (!states.IsDefaultOrEmpty) {
+            foreach (var s in states.Distinct()) {
+                sb.AppendLine("        SpyglassRegistry.Register(new SpyglassAggregateInfo(");
+                sb.AppendLine("            null,");
+                sb.AppendLine($"            \"{Escape(s.StateSimpleName)}\",");
+                sb.AppendLine("            System.Array.Empty<string>(),");
+                sb.AppendLine($"            new {s.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),");
+                sb.AppendLine($"            static (_, entityId) => StreamName.ForState<{s.StateFqn}>(entityId),");
+                sb.AppendLine("            static async (eventStore, streamName, version) => {");
+                sb.AppendLine("                var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);");
+                sb.AppendLine("                var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
+                sb.AppendLine($"                var state = selected.Select(x => x.Payload!).Aggregate(new {s.StateFqn}(), (s, e) => s.When(e));");
+                sb.AppendLine("                return new SpyglassLoadResult(");
+                sb.AppendLine("                    state,");
+                sb.AppendLine("                    events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");
Evidence
StreamEvent.Payload is declared nullable, and the domain aggregate load path explicitly filters
null events. Spyglass generated code does not filter null payloads and uses Payload! with
GetType() and state folding.

src/Core/src/Eventuous.Persistence/StreamEvent.cs[12-12]
src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[182-183]
src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[199-202]
src/Core/src/Eventuous.Domain/Aggregate.cs[81-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The generated Spyglass load code uses `Payload!` but `StreamEvent.Payload` is nullable. Null payloads can crash the endpoint.
### Issue Context
Domain aggregate load already defensively filters null events.
### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-203]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

5. Bypasses aggregate factories 🐞 Bug ⛯ Reliability
Description
Generated load delegates instantiate aggregates using new TAggregate(), bypassing Eventuous
aggregate factory/DI patterns (AggregateFactoryRegistry/AddAggregate). This limits Spyglass to only
parameterless aggregates and ignores configured factory behavior.
Code

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[R175-180]

+                sb.AppendLine("            static async (eventStore, streamName, version) => {");
+                sb.AppendLine($"                var aggregate = new {c.AggregateFqn}();");
+                sb.AppendLine("                var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);");
+                sb.AppendLine("                var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
+                sb.AppendLine("                aggregate.Load(selected.Length > 0 ? selected[^1].Revision : -1, selected.Select(x => x.Payload));");
+                sb.AppendLine("                return new SpyglassLoadResult(");
Evidence
Eventuous explicitly supports aggregates with dependencies via DI-based factories; SpyglassGenerator
currently hardcodes direct construction in generated code.

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[175-180]
src/Extensions/src/Eventuous.Extensions.DependencyInjection/Registrations/AggregateFactory.cs[14-18]
src/Extensions/src/Eventuous.Extensions.DependencyInjection/Registrations/AggregateFactory.cs[22-24]
src/Core/src/Eventuous.Persistence/AggregateFactory.cs[43-45]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Spyglass generated load code uses `new TAggregate()` and ignores Eventuous aggregate factory/DI patterns, limiting compatibility with aggregates that need DI.
### Issue Context
Eventuous provides AggregateFactoryRegistry and DI extensions for aggregates without parameterless constructors.
### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[175-180]
- src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[60-72]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Generated LINQ using missing 🐞 Bug ✓ Correctness
Description
Generated source uses LINQ (Select, Take, Aggregate, ToArray) but the generator doesn’t emit
using System.Linq;. It works in this repo due to ImplicitUsings enabled, but reduces portability
for consumers/projects that disable implicit usings.
Code

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[R155-160]

+        sb.AppendLine("// <auto-generated/>");
+        sb.AppendLine("#nullable enable");
+        sb.AppendLine("using System.Runtime.CompilerServices;");
+        sb.AppendLine("using Eventuous;");
+        sb.AppendLine("using Eventuous.Spyglass;");
+        sb.AppendLine();
Evidence
SpyglassGenerator explicitly emits only a small set of using directives, yet emits many LINQ
calls. Repo defaults enable ImplicitUsings, masking the issue here.

src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[155-160]
src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[173-179]
src/Directory.Build.props[4-7]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Generated code uses LINQ but doesn’t include `using System.Linq;`, which can break compilation for consumers that disable implicit usings.
### Issue Context
This repo compiles because ImplicitUsings are enabled globally.
### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[155-160]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment on lines +28 to +32
if (!builder.ServiceProvider.GetRequiredService<IWebHostEnvironment>().IsDevelopment() && key == null) {
logger.LogWarning("Insecure Spyglass API is only available in development environment");
key = Guid.NewGuid().ToString("N");
logger.LogInformation("Using generated key: {Key}", key);
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. key logged in spyglassapi 📘 Rule violation ⛨ Security

The Spyglass API logs the generated access key at Information level, which can leak a secret into
centralized logs. Anyone with log access could reuse the key to access Spyglass endpoints and
potentially view internal state/events.
Agent Prompt
## Issue description
The Spyglass API logs a generated access key value (`{Key}`), which is a secret and should never be written to application logs.

## Issue Context
This key gates access to Spyglass endpoints; logging it makes it retrievable from log aggregation systems and enables unauthorized use.

## Fix Focus Areas
- src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[28-32]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It logs the secret deliberately, otherwise the user won't know what secret to use. It only happens in development.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question:

It logs the secret deliberately, otherwise the user won't know what secret to use. It only happens in development.

Answer:

You can reduce (or eliminate) this kind of “dev-only secret logging” finding by guiding Qodo Merge with targeted extra_instructions, and (optionally) by disabling the security subsection/labeling if it’s too noisy for your repo.

Recommended (keep security checks, but allow explicit dev-only exceptions):

[pr_reviewer]
extra_instructions = """\
Security findings:
- Treat secrets in logs as a finding ONLY if they can occur outside Development/local runs.
- If the code is clearly guarded to Development-only (e.g., environment checks / debug-only compilation flags) and cannot run in production, do not flag it as a security violation; instead add a short note recommending masking/redaction and adding an explicit warning comment.
"""

[pr_code_suggestions]
extra_instructions = """\
When suggesting fixes for secrets-in-logs:
- First verify whether the logging happens only in Development/local runs.
- If it is dev-only, do not suggest removing it unconditionally; prefer suggesting safer alternatives (masking, one-time display, explicit warning text, or printing only a prefix/suffix).
"""

If you want to suppress security findings entirely in /review (more aggressive):

[pr_reviewer]
require_security_review = false
# alternatively/also:
enable_review_labels_security = false

If you share whether this finding came from /review or /improve (or /compliance), I can narrow the TOML to just the relevant tool section(s).

Relevant Sources:

Comment on lines 177 to 183

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Versioned load inconsistent 🐞 Bug ✓ Correctness

Generated load delegates rehydrate state using a version-truncated event list but return the full
(untruncated) event list to the API response. This can mislead callers because the returned events
won’t match the returned state when version != -1.
Agent Prompt
### Issue description
The generated Spyglass load delegates apply `version` to state rehydration but still return *all* events from the stream, causing state/events mismatch.

### Issue Context
Spyglass is used for debugging/introspection; returning inconsistent data makes the API misleading.

### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-203]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 177 to 178

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

3. Missing stream yields 500 🐞 Bug ⛯ Reliability

The generated load delegates call ReadStream(..., failIfNotFound: true) and Spyglass API doesn’t
handle StreamNotFound, so loading an unknown entity likely becomes an unhandled exception (500)
instead of a 404/empty result.
Agent Prompt
### Issue description
Loading a non-existent stream is likely to throw `StreamNotFound` and surface as a 500 because SpyglassApi doesn’t catch it.

### Issue Context
`StoreFunctions.ReadStream` only swallows `StreamNotFound` when `failIfNotFound` is false.

### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-203]
- src/Experimental/src/Eventuous.Spyglass/SpyglassApi.cs[60-72]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines 182 to 202
sb.AppendLine(" events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");
sb.AppendLine(" }");
sb.AppendLine(" ));");
}
}

if (!states.IsDefaultOrEmpty) {
foreach (var s in states.Distinct()) {
sb.AppendLine(" SpyglassRegistry.Register(new SpyglassAggregateInfo(");
sb.AppendLine(" null,");
sb.AppendLine($" \"{Escape(s.StateSimpleName)}\",");
sb.AppendLine(" System.Array.Empty<string>(),");
sb.AppendLine($" new {s.StateFqn}().GetRegisteredEventTypes().Select(t => t.Name).ToArray(),");
sb.AppendLine($" static (_, entityId) => StreamName.ForState<{s.StateFqn}>(entityId),");
sb.AppendLine(" static async (eventStore, streamName, version) => {");
sb.AppendLine(" var events = await eventStore.ReadStream(streamName, StreamReadPosition.Start, true, default);");
sb.AppendLine(" var selected = version == -1 ? events : events.Take(version + 1).ToArray();");
sb.AppendLine($" var state = selected.Select(x => x.Payload!).Aggregate(new {s.StateFqn}(), (s, e) => s.When(e));");
sb.AppendLine(" return new SpyglassLoadResult(");
sb.AppendLine(" state,");
sb.AppendLine(" events.Select(x => new SpyglassEventInfo(x.Payload!.GetType().Name, x.Payload)).ToArray());");

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Null payload crashes load 🐞 Bug ⛯ Reliability

Generated code null-forces StreamEvent.Payload when folding state and when computing event type
names. Since StreamEvent.Payload is nullable, a null payload can cause NullReferenceException and
break /spyglass/load.
Agent Prompt
### Issue description
The generated Spyglass load code uses `Payload!` but `StreamEvent.Payload` is nullable. Null payloads can crash the endpoint.

### Issue Context
Domain aggregate load already defensively filters null events.

### Fix Focus Areas
- src/Experimental/gen/Eventuous.Spyglass.Generators/SpyglassGenerator.cs[177-203]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@alexeyzimarev alexeyzimarev merged commit 2bcac37 into dev Feb 16, 2026
5 checks passed
@alexeyzimarev alexeyzimarev deleted the spyglass-api-gen branch February 16, 2026 21:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

Comments