Add event metadata support for distributed tracing and auditing
All checks were successful
CI / build (push) Successful in 15s

- Add Metadata field (map[string]string) to Event struct with omitempty
- Add helper methods for common metadata: SetCorrelationID/GetCorrelationID,
  SetCausationID/GetCausationID, SetUserID/GetUserID, SetTraceID/GetTraceID,
  SetSpanID/GetSpanID
- Add WithMetadataFrom helper for copying metadata between events
- Add metadata key constants for standard fields
- Add comprehensive unit tests for metadata serialization and helpers
- Add store tests verifying metadata persistence

Closes #7

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit was merged in pull request #33.
This commit is contained in:
2026-01-09 17:53:05 +01:00
parent 02847bdaf5
commit 7d3acd89ed
3 changed files with 781 additions and 0 deletions

View File

@@ -36,9 +36,103 @@ type Event struct {
CommandID string `json:"commandId,omitempty"` // Correlation ID for command that triggered this event
Version int64 `json:"version"`
Data map[string]interface{} `json:"data"`
Metadata map[string]string `json:"metadata,omitempty"` // Optional metadata for tracing and auditing
Timestamp time.Time `json:"timestamp"`
}
// Common metadata keys for distributed tracing and auditing
const (
// MetadataKeyCorrelationID identifies related events across services
MetadataKeyCorrelationID = "correlationId"
// MetadataKeyCausationID identifies the event that caused this event
MetadataKeyCausationID = "causationId"
// MetadataKeyUserID identifies the user who triggered this event
MetadataKeyUserID = "userId"
// MetadataKeyTraceID for distributed tracing integration (e.g., OpenTelemetry)
MetadataKeyTraceID = "traceId"
// MetadataKeySpanID for distributed tracing integration
MetadataKeySpanID = "spanId"
)
// SetMetadata sets a metadata key-value pair, initializing the map if needed
func (e *Event) SetMetadata(key, value string) {
if e.Metadata == nil {
e.Metadata = make(map[string]string)
}
e.Metadata[key] = value
}
// GetMetadata returns the value for a metadata key, or empty string if not found
func (e *Event) GetMetadata(key string) string {
if e.Metadata == nil {
return ""
}
return e.Metadata[key]
}
// SetCorrelationID sets the correlation ID metadata
func (e *Event) SetCorrelationID(correlationID string) {
e.SetMetadata(MetadataKeyCorrelationID, correlationID)
}
// GetCorrelationID returns the correlation ID metadata
func (e *Event) GetCorrelationID() string {
return e.GetMetadata(MetadataKeyCorrelationID)
}
// SetCausationID sets the causation ID metadata
func (e *Event) SetCausationID(causationID string) {
e.SetMetadata(MetadataKeyCausationID, causationID)
}
// GetCausationID returns the causation ID metadata
func (e *Event) GetCausationID() string {
return e.GetMetadata(MetadataKeyCausationID)
}
// SetUserID sets the user ID metadata
func (e *Event) SetUserID(userID string) {
e.SetMetadata(MetadataKeyUserID, userID)
}
// GetUserID returns the user ID metadata
func (e *Event) GetUserID() string {
return e.GetMetadata(MetadataKeyUserID)
}
// SetTraceID sets the trace ID metadata for distributed tracing
func (e *Event) SetTraceID(traceID string) {
e.SetMetadata(MetadataKeyTraceID, traceID)
}
// GetTraceID returns the trace ID metadata
func (e *Event) GetTraceID() string {
return e.GetMetadata(MetadataKeyTraceID)
}
// SetSpanID sets the span ID metadata for distributed tracing
func (e *Event) SetSpanID(spanID string) {
e.SetMetadata(MetadataKeySpanID, spanID)
}
// GetSpanID returns the span ID metadata
func (e *Event) GetSpanID() string {
return e.GetMetadata(MetadataKeySpanID)
}
// WithMetadataFrom copies metadata from another event (useful for event chaining)
func (e *Event) WithMetadataFrom(source *Event) {
if source == nil || source.Metadata == nil {
return
}
if e.Metadata == nil {
e.Metadata = make(map[string]string)
}
for k, v := range source.Metadata {
e.Metadata[k] = v
}
}
// ActorSnapshot represents a point-in-time state snapshot
type ActorSnapshot struct {
ActorID string `json:"actorId"`

View File

@@ -740,3 +740,471 @@ func TestActorSnapshot_VersionEdgeCases(t *testing.T) {
})
}
}
// Tests for Event Metadata support
func TestEvent_MetadataJSONSerialization(t *testing.T) {
ts := time.Date(2026, 1, 9, 12, 0, 0, 0, time.UTC)
event := &Event{
ID: "evt-meta",
EventType: "OrderPlaced",
ActorID: "order-456",
Version: 1,
Data: map[string]interface{}{"total": 100.50},
Metadata: map[string]string{
"correlationId": "corr-123",
"causationId": "cause-456",
"userId": "user-789",
},
Timestamp: ts,
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event with metadata: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event with metadata: %v", err)
}
if decoded.Metadata == nil {
t.Fatal("expected Metadata to be present after unmarshal")
}
if decoded.Metadata["correlationId"] != "corr-123" {
t.Errorf("correlationId mismatch: got %q, want %q", decoded.Metadata["correlationId"], "corr-123")
}
if decoded.Metadata["causationId"] != "cause-456" {
t.Errorf("causationId mismatch: got %q, want %q", decoded.Metadata["causationId"], "cause-456")
}
if decoded.Metadata["userId"] != "user-789" {
t.Errorf("userId mismatch: got %q, want %q", decoded.Metadata["userId"], "user-789")
}
}
func TestEvent_MetadataOmitEmpty(t *testing.T) {
event := &Event{
ID: "evt-no-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Metadata: nil,
Timestamp: time.Now(),
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event: %v", err)
}
if strings.Contains(string(data), `"metadata"`) {
t.Error("expected metadata to be omitted when nil")
}
}
func TestEvent_EmptyMetadata(t *testing.T) {
event := &Event{
ID: "evt-empty-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Metadata: map[string]string{},
Timestamp: time.Now(),
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event with empty metadata: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event with empty metadata: %v", err)
}
// Empty map should be omitted with omitempty
if strings.Contains(string(data), `"metadata"`) {
t.Error("expected empty metadata to be omitted")
}
}
func TestEvent_SetMetadata(t *testing.T) {
event := &Event{
ID: "evt-set-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// Metadata should be nil initially
if event.Metadata != nil {
t.Error("expected Metadata to be nil initially")
}
// SetMetadata should initialize the map
event.SetMetadata("key1", "value1")
if event.Metadata == nil {
t.Fatal("expected Metadata to be initialized after SetMetadata")
}
if event.Metadata["key1"] != "value1" {
t.Errorf("key1 mismatch: got %q, want %q", event.Metadata["key1"], "value1")
}
// SetMetadata should work with existing map
event.SetMetadata("key2", "value2")
if event.Metadata["key2"] != "value2" {
t.Errorf("key2 mismatch: got %q, want %q", event.Metadata["key2"], "value2")
}
if event.Metadata["key1"] != "value1" {
t.Errorf("key1 was overwritten: got %q", event.Metadata["key1"])
}
}
func TestEvent_GetMetadata(t *testing.T) {
event := &Event{
ID: "evt-get-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// GetMetadata on nil map should return empty string
if got := event.GetMetadata("nonexistent"); got != "" {
t.Errorf("expected empty string for nil Metadata, got %q", got)
}
// Initialize metadata and test
event.Metadata = map[string]string{"key": "value"}
if got := event.GetMetadata("key"); got != "value" {
t.Errorf("GetMetadata mismatch: got %q, want %q", got, "value")
}
// Non-existent key should return empty string
if got := event.GetMetadata("nonexistent"); got != "" {
t.Errorf("expected empty string for nonexistent key, got %q", got)
}
}
func TestEvent_CorrelationIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-corr",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// GetCorrelationID on nil metadata
if got := event.GetCorrelationID(); got != "" {
t.Errorf("expected empty correlation ID, got %q", got)
}
// SetCorrelationID
event.SetCorrelationID("corr-abc-123")
if got := event.GetCorrelationID(); got != "corr-abc-123" {
t.Errorf("GetCorrelationID mismatch: got %q, want %q", got, "corr-abc-123")
}
// Verify it uses the correct metadata key
if event.Metadata[MetadataKeyCorrelationID] != "corr-abc-123" {
t.Errorf("expected correlationId key to be set")
}
}
func TestEvent_CausationIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-cause",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
// GetCausationID on nil metadata
if got := event.GetCausationID(); got != "" {
t.Errorf("expected empty causation ID, got %q", got)
}
// SetCausationID
event.SetCausationID("cause-xyz-789")
if got := event.GetCausationID(); got != "cause-xyz-789" {
t.Errorf("GetCausationID mismatch: got %q, want %q", got, "cause-xyz-789")
}
// Verify it uses the correct metadata key
if event.Metadata[MetadataKeyCausationID] != "cause-xyz-789" {
t.Errorf("expected causationId key to be set")
}
}
func TestEvent_UserIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-user",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
event.SetUserID("user-john-doe")
if got := event.GetUserID(); got != "user-john-doe" {
t.Errorf("GetUserID mismatch: got %q, want %q", got, "user-john-doe")
}
if event.Metadata[MetadataKeyUserID] != "user-john-doe" {
t.Errorf("expected userId key to be set")
}
}
func TestEvent_TraceIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-trace",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
event.SetTraceID("trace-abc-def-ghi")
if got := event.GetTraceID(); got != "trace-abc-def-ghi" {
t.Errorf("GetTraceID mismatch: got %q, want %q", got, "trace-abc-def-ghi")
}
if event.Metadata[MetadataKeyTraceID] != "trace-abc-def-ghi" {
t.Errorf("expected traceId key to be set")
}
}
func TestEvent_SpanIDHelpers(t *testing.T) {
event := &Event{
ID: "evt-span",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
}
event.SetSpanID("span-123-456")
if got := event.GetSpanID(); got != "span-123-456" {
t.Errorf("GetSpanID mismatch: got %q, want %q", got, "span-123-456")
}
if event.Metadata[MetadataKeySpanID] != "span-123-456" {
t.Errorf("expected spanId key to be set")
}
}
func TestEvent_WithMetadataFrom(t *testing.T) {
source := &Event{
ID: "evt-source",
EventType: "SourceEvent",
ActorID: "actor-123",
Version: 1,
Metadata: map[string]string{
"correlationId": "corr-123",
"causationId": "cause-456",
"customKey": "customValue",
},
}
target := &Event{
ID: "evt-target",
EventType: "TargetEvent",
ActorID: "actor-456",
Version: 2,
}
// Copy metadata from source to target
target.WithMetadataFrom(source)
if target.Metadata == nil {
t.Fatal("expected Metadata to be initialized after WithMetadataFrom")
}
if target.Metadata["correlationId"] != "corr-123" {
t.Errorf("correlationId not copied: got %q", target.Metadata["correlationId"])
}
if target.Metadata["causationId"] != "cause-456" {
t.Errorf("causationId not copied: got %q", target.Metadata["causationId"])
}
if target.Metadata["customKey"] != "customValue" {
t.Errorf("customKey not copied: got %q", target.Metadata["customKey"])
}
}
func TestEvent_WithMetadataFromNilSource(t *testing.T) {
target := &Event{
ID: "evt-target",
EventType: "TargetEvent",
ActorID: "actor-456",
Version: 2,
}
// WithMetadataFrom should handle nil source gracefully
target.WithMetadataFrom(nil)
if target.Metadata != nil {
t.Error("expected Metadata to remain nil after WithMetadataFrom(nil)")
}
// WithMetadataFrom should handle source with nil metadata
source := &Event{
ID: "evt-source",
EventType: "SourceEvent",
ActorID: "actor-123",
Version: 1,
Metadata: nil,
}
target.WithMetadataFrom(source)
if target.Metadata != nil {
t.Error("expected Metadata to remain nil when source has nil Metadata")
}
}
func TestEvent_WithMetadataFromPreservesExisting(t *testing.T) {
source := &Event{
ID: "evt-source",
EventType: "SourceEvent",
ActorID: "actor-123",
Version: 1,
Metadata: map[string]string{
"sourceKey": "sourceValue",
},
}
target := &Event{
ID: "evt-target",
EventType: "TargetEvent",
ActorID: "actor-456",
Version: 2,
Metadata: map[string]string{
"existingKey": "existingValue",
},
}
target.WithMetadataFrom(source)
// Both keys should be present
if target.Metadata["existingKey"] != "existingValue" {
t.Errorf("existingKey was lost: got %q", target.Metadata["existingKey"])
}
if target.Metadata["sourceKey"] != "sourceValue" {
t.Errorf("sourceKey not copied: got %q", target.Metadata["sourceKey"])
}
}
func TestEvent_MetadataSpecialCharacters(t *testing.T) {
event := &Event{
ID: "evt-special-meta",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Metadata: map[string]string{
"unicode": "Hello, \u4e16\u754c!",
"newlines": "line1\nline2",
"quotes": `"quoted"`,
"backslash": `path\to\file`,
"empty": "",
"whitespace": " spaces ",
},
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event with special metadata: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event with special metadata: %v", err)
}
for key, expected := range event.Metadata {
if decoded.Metadata[key] != expected {
t.Errorf("Metadata[%q] mismatch: got %q, want %q", key, decoded.Metadata[key], expected)
}
}
}
func TestMetadataKeyConstants(t *testing.T) {
// Verify the constant values are as expected
if MetadataKeyCorrelationID != "correlationId" {
t.Errorf("MetadataKeyCorrelationID wrong: got %q", MetadataKeyCorrelationID)
}
if MetadataKeyCausationID != "causationId" {
t.Errorf("MetadataKeyCausationID wrong: got %q", MetadataKeyCausationID)
}
if MetadataKeyUserID != "userId" {
t.Errorf("MetadataKeyUserID wrong: got %q", MetadataKeyUserID)
}
if MetadataKeyTraceID != "traceId" {
t.Errorf("MetadataKeyTraceID wrong: got %q", MetadataKeyTraceID)
}
if MetadataKeySpanID != "spanId" {
t.Errorf("MetadataKeySpanID wrong: got %q", MetadataKeySpanID)
}
}
func TestEvent_MetadataJSONFieldName(t *testing.T) {
event := &Event{
ID: "evt-field-name",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Metadata: map[string]string{
"key": "value",
},
Timestamp: time.Now(),
}
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event: %v", err)
}
// Check for correct JSON field name (camelCase)
if !strings.Contains(string(data), `"metadata"`) {
t.Errorf("expected 'metadata' JSON field, got: %s", string(data))
}
}
func TestEvent_MetadataAllHelpersRoundTrip(t *testing.T) {
// Test that all helper methods work together in a roundtrip
event := &Event{
ID: "evt-all-helpers",
EventType: "TestEvent",
ActorID: "actor-123",
Version: 1,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
event.SetCorrelationID("corr-1")
event.SetCausationID("cause-2")
event.SetUserID("user-3")
event.SetTraceID("trace-4")
event.SetSpanID("span-5")
data, err := json.Marshal(event)
if err != nil {
t.Fatalf("failed to marshal Event: %v", err)
}
var decoded Event
if err := json.Unmarshal(data, &decoded); err != nil {
t.Fatalf("failed to unmarshal Event: %v", err)
}
if decoded.GetCorrelationID() != "corr-1" {
t.Errorf("GetCorrelationID mismatch: got %q", decoded.GetCorrelationID())
}
if decoded.GetCausationID() != "cause-2" {
t.Errorf("GetCausationID mismatch: got %q", decoded.GetCausationID())
}
if decoded.GetUserID() != "user-3" {
t.Errorf("GetUserID mismatch: got %q", decoded.GetUserID())
}
if decoded.GetTraceID() != "trace-4" {
t.Errorf("GetTraceID mismatch: got %q", decoded.GetTraceID())
}
if decoded.GetSpanID() != "span-5" {
t.Errorf("GetSpanID mismatch: got %q", decoded.GetSpanID())
}
}

View File

@@ -1686,3 +1686,222 @@ func TestConcurrentSaveAndGetSnapshot(t *testing.T) {
t.Errorf("expected latest version %d, got %d", expectedMaxVersion, snapshot.Version)
}
}
// === Event Metadata Persistence Tests ===
func TestSaveEvent_WithMetadata(t *testing.T) {
store := NewInMemoryEventStore()
event := &aether.Event{
ID: "evt-meta",
EventType: "OrderPlaced",
ActorID: "order-456",
Version: 1,
Data: map[string]interface{}{
"total": 100.50,
},
Metadata: map[string]string{
"correlationId": "corr-123",
"causationId": "cause-456",
"userId": "user-789",
"traceId": "trace-abc",
"spanId": "span-def",
},
Timestamp: time.Now(),
}
err := store.SaveEvent(event)
if err != nil {
t.Fatalf("SaveEvent failed: %v", err)
}
// Retrieve and verify metadata is persisted
events, err := store.GetEvents("order-456", 0)
if err != nil {
t.Fatalf("GetEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
retrieved := events[0]
if retrieved.Metadata == nil {
t.Fatal("expected Metadata to be persisted")
}
if retrieved.Metadata["correlationId"] != "corr-123" {
t.Errorf("correlationId mismatch: got %q, want %q", retrieved.Metadata["correlationId"], "corr-123")
}
if retrieved.Metadata["causationId"] != "cause-456" {
t.Errorf("causationId mismatch: got %q, want %q", retrieved.Metadata["causationId"], "cause-456")
}
if retrieved.Metadata["userId"] != "user-789" {
t.Errorf("userId mismatch: got %q, want %q", retrieved.Metadata["userId"], "user-789")
}
if retrieved.Metadata["traceId"] != "trace-abc" {
t.Errorf("traceId mismatch: got %q, want %q", retrieved.Metadata["traceId"], "trace-abc")
}
if retrieved.Metadata["spanId"] != "span-def" {
t.Errorf("spanId mismatch: got %q, want %q", retrieved.Metadata["spanId"], "span-def")
}
}
func TestSaveEvent_WithNilMetadata(t *testing.T) {
store := NewInMemoryEventStore()
event := &aether.Event{
ID: "evt-nil-meta",
EventType: "OrderPlaced",
ActorID: "order-456",
Version: 1,
Data: map[string]interface{}{},
Metadata: nil,
Timestamp: time.Now(),
}
err := store.SaveEvent(event)
if err != nil {
t.Fatalf("SaveEvent failed: %v", err)
}
events, err := store.GetEvents("order-456", 0)
if err != nil {
t.Fatalf("GetEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
// Nil metadata should remain nil
if events[0].Metadata != nil {
t.Errorf("expected nil Metadata, got %v", events[0].Metadata)
}
}
func TestSaveEvent_WithEmptyMetadata(t *testing.T) {
store := NewInMemoryEventStore()
event := &aether.Event{
ID: "evt-empty-meta",
EventType: "OrderPlaced",
ActorID: "order-456",
Version: 1,
Data: map[string]interface{}{},
Metadata: map[string]string{},
Timestamp: time.Now(),
}
err := store.SaveEvent(event)
if err != nil {
t.Fatalf("SaveEvent failed: %v", err)
}
events, err := store.GetEvents("order-456", 0)
if err != nil {
t.Fatalf("GetEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
// Empty metadata should be preserved (as empty map)
if events[0].Metadata == nil {
t.Error("expected empty Metadata map to be preserved, got nil")
}
if len(events[0].Metadata) != 0 {
t.Errorf("expected empty Metadata, got %d entries", len(events[0].Metadata))
}
}
func TestSaveEvent_MetadataWithHelpers(t *testing.T) {
store := NewInMemoryEventStore()
event := &aether.Event{
ID: "evt-helpers",
EventType: "OrderPlaced",
ActorID: "order-456",
Version: 1,
Data: map[string]interface{}{},
Timestamp: time.Now(),
}
// Use helper methods to set metadata
event.SetCorrelationID("corr-helper")
event.SetCausationID("cause-helper")
event.SetUserID("user-helper")
event.SetTraceID("trace-helper")
event.SetSpanID("span-helper")
err := store.SaveEvent(event)
if err != nil {
t.Fatalf("SaveEvent failed: %v", err)
}
events, err := store.GetEvents("order-456", 0)
if err != nil {
t.Fatalf("GetEvents failed: %v", err)
}
if len(events) != 1 {
t.Fatalf("expected 1 event, got %d", len(events))
}
retrieved := events[0]
if retrieved.GetCorrelationID() != "corr-helper" {
t.Errorf("GetCorrelationID mismatch: got %q", retrieved.GetCorrelationID())
}
if retrieved.GetCausationID() != "cause-helper" {
t.Errorf("GetCausationID mismatch: got %q", retrieved.GetCausationID())
}
if retrieved.GetUserID() != "user-helper" {
t.Errorf("GetUserID mismatch: got %q", retrieved.GetUserID())
}
if retrieved.GetTraceID() != "trace-helper" {
t.Errorf("GetTraceID mismatch: got %q", retrieved.GetTraceID())
}
if retrieved.GetSpanID() != "span-helper" {
t.Errorf("GetSpanID mismatch: got %q", retrieved.GetSpanID())
}
}
func TestSaveEvent_MetadataPreservedAcrossMultipleEvents(t *testing.T) {
store := NewInMemoryEventStore()
// Save multiple events with different metadata
for i := 1; i <= 3; i++ {
event := &aether.Event{
ID: fmt.Sprintf("evt-%d", i),
EventType: "OrderUpdated",
ActorID: "order-456",
Version: int64(i),
Data: map[string]interface{}{},
Metadata: map[string]string{
"correlationId": fmt.Sprintf("corr-%d", i),
"eventIndex": fmt.Sprintf("%d", i),
},
Timestamp: time.Now(),
}
if err := store.SaveEvent(event); err != nil {
t.Fatalf("SaveEvent failed for event %d: %v", i, err)
}
}
events, err := store.GetEvents("order-456", 0)
if err != nil {
t.Fatalf("GetEvents failed: %v", err)
}
if len(events) != 3 {
t.Fatalf("expected 3 events, got %d", len(events))
}
// Verify each event has its own metadata
for i, event := range events {
expectedCorr := fmt.Sprintf("corr-%d", i+1)
expectedIdx := fmt.Sprintf("%d", i+1)
if event.Metadata["correlationId"] != expectedCorr {
t.Errorf("event %d correlationId mismatch: got %q, want %q", i+1, event.Metadata["correlationId"], expectedCorr)
}
if event.Metadata["eventIndex"] != expectedIdx {
t.Errorf("event %d eventIndex mismatch: got %q, want %q", i+1, event.Metadata["eventIndex"], expectedIdx)
}
}
}