From 681ce3bfd9d5a4dbdd82d38bda1bffc481461075 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 30 Jun 2025 16:00:57 +0300 Subject: [PATCH] feat: start storing every transaction id in events table (#10236) Every time an event gets inserted, we check if there is transaction data, that we can include. --- src/lib/events/index.ts | 2 + src/lib/features/events/event-store.ts | 14 +- src/lib/openapi/spec/event-schema.ts | 12 ++ src/test/e2e/stores/event-store.e2e.test.ts | 157 ++++++++++++++++++++ 4 files changed, 184 insertions(+), 1 deletion(-) diff --git a/src/lib/events/index.ts b/src/lib/events/index.ts index 5f1c95dfbf..cbc576a5c1 100644 --- a/src/lib/events/index.ts +++ b/src/lib/events/index.ts @@ -397,6 +397,8 @@ export interface IEvent extends Omit { id: number; createdAt: Date; ip?: string; + groupType?: string; + groupId?: string; } export interface IEnrichedEvent extends IEvent { diff --git a/src/lib/features/events/event-store.ts b/src/lib/features/events/event-store.ts index 0b08abf52e..8831bb5830 100644 --- a/src/lib/features/events/event-store.ts +++ b/src/lib/features/events/event-store.ts @@ -41,6 +41,8 @@ const EVENT_COLUMNS = [ 'feature_name', 'project', 'environment', + 'group_type', + 'group_id', ] as const; export type IQueryOperations = @@ -97,6 +99,8 @@ export interface IEventTable { environment?: string; tags: ITag[]; ip?: string; + group_type: string | null; + group_id: string | null; } const TABLE = 'events'; @@ -157,7 +161,9 @@ export class EventStore implements IEventStore { async batchStore(events: IBaseEvent[]): Promise { try { - await this.db(TABLE).insert(events.map(this.eventToDbRow)); + await this.db(TABLE).insert( + events.map((event) => this.eventToDbRow(event)), + ); } catch (error: unknown) { this.logger.warn( `Failed to store events: ${JSON.stringify(events)}`, @@ -472,10 +478,14 @@ export class EventStore implements IEventStore { featureName: row.feature_name, project: row.project, environment: row.environment, + groupType: row.group_type || undefined, + groupId: row.group_id || undefined, }; } eventToDbRow(e: IBaseEvent): Omit { + const transactionContext = this.db.userParams; + return { type: e.type, created_by: e.createdBy ?? 'admin', @@ -490,6 +500,8 @@ export class EventStore implements IEventStore { project: e.project, environment: e.environment, ip: e.ip, + group_type: transactionContext?.type || null, + group_id: transactionContext?.id || null, }; } diff --git a/src/lib/openapi/spec/event-schema.ts b/src/lib/openapi/spec/event-schema.ts index 03ad0e740b..e0c38e212a 100644 --- a/src/lib/openapi/spec/event-schema.ts +++ b/src/lib/openapi/spec/event-schema.ts @@ -109,6 +109,18 @@ export const eventSchema = { 'The IP address of the user that created the event. Only available in Enterprise.', example: '192.168.1.1', }, + groupType: { + type: 'string', + description: + 'The type of transaction group this event belongs to, if applicable.', + example: 'change-request', + }, + groupId: { + type: 'string', + description: + 'The unique identifier for the transaction group this event belongs to, if applicable.', + example: '01HQVX5K8P9EXAMPLE123456', + }, }, components: { schemas: { diff --git a/src/test/e2e/stores/event-store.e2e.test.ts b/src/test/e2e/stores/event-store.e2e.test.ts index 81a0def9a2..9e9207cdf5 100644 --- a/src/test/e2e/stores/event-store.e2e.test.ts +++ b/src/test/e2e/stores/event-store.e2e.test.ts @@ -18,6 +18,11 @@ import dbInit, { type ITestDb } from '../helpers/database-init.js'; import getLogger from '../../fixtures/no-logger.js'; import type { IEventStore } from '../../../lib/types/stores/event-store.js'; import type { IAuditUser, IUnleashStores } from '../../../lib/types/index.js'; +import { + withTransactional, + type TransactionUserParams, +} from '../../../lib/db/transaction.js'; +import { EventStore } from '../../../lib/features/events/event-store.js'; import { vi } from 'vitest'; @@ -472,3 +477,155 @@ test('Should return empty result when filtering by non-existent ID', async () => expect(filteredEvents).toHaveLength(0); }); + +test('Should store and retrieve transaction context fields', async () => { + const mockTransactionContext: TransactionUserParams = { + type: 'change-request', + id: '01HQVX5K8P9EXAMPLE123456', + }; + + const eventStoreService = withTransactional( + (db) => new EventStore(db, getLogger), + db.rawDatabase, + ); + + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'test-feature-with-context', + project: 'test-project', + ip: '127.0.0.1', + data: { + name: 'test-feature-with-context', + enabled: true, + strategies: [{ name: 'default' }], + }, + }; + + await eventStoreService.transactional(async (transactionalEventStore) => { + await transactionalEventStore.store(event); + }, mockTransactionContext); + + const events = await eventStore.getAll(); + const storedEvent = events.find( + (e) => e.featureName === 'test-feature-with-context', + ); + + expect(storedEvent).toBeTruthy(); + expect(storedEvent!.groupType).toBe('change-request'); + expect(storedEvent!.groupId).toBe('01HQVX5K8P9EXAMPLE123456'); +}); + +test('Should handle missing transaction context gracefully', async () => { + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'test-feature-no-context', + project: 'test-project', + ip: '127.0.0.1', + data: { + name: 'test-feature-no-context', + enabled: true, + strategies: [{ name: 'default' }], + }, + }; + + await eventStore.store(event); + + const events = await eventStore.getAll(); + const storedEvent = events.find( + (e) => e.featureName === 'test-feature-no-context', + ); + + expect(storedEvent).toBeTruthy(); + expect(storedEvent!.groupType).toBeUndefined(); + expect(storedEvent!.groupId).toBeUndefined(); +}); + +test('Should store transaction context in batch operations', async () => { + const mockTransactionContext: TransactionUserParams = { + type: 'transaction', + id: '01HQVX5K8P9BATCH123456', + }; + + const eventStoreService = withTransactional( + (db) => new EventStore(db, getLogger), + db.rawDatabase, + ); + + const events = [ + { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'batch-feature-1', + project: 'test-project', + ip: '127.0.0.1', + data: { name: 'batch-feature-1' }, + }, + { + type: FEATURE_UPDATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'batch-feature-2', + project: 'test-project', + ip: '127.0.0.1', + data: { name: 'batch-feature-2' }, + }, + ]; + + await eventStoreService.transactional(async (transactionalEventStore) => { + await transactionalEventStore.batchStore(events); + }, mockTransactionContext); + + const allEvents = await eventStore.getAll(); + const batchEvents = allEvents.filter( + (e) => + e.featureName === 'batch-feature-1' || + e.featureName === 'batch-feature-2', + ); + + expect(batchEvents).toHaveLength(2); + batchEvents.forEach((event) => { + expect(event.groupType).toBe('transaction'); + expect(event.groupId).toBe('01HQVX5K8P9BATCH123456'); + }); +}); + +test('Should auto-generate transaction context when none provided', async () => { + const eventStoreService = withTransactional( + (db) => new EventStore(db, getLogger), + db.rawDatabase, + ); + + const event = { + type: FEATURE_CREATED, + createdBy: 'test-user', + createdByUserId: TEST_USER_ID, + featureName: 'test-feature-auto-context', + project: 'test-project', + ip: '127.0.0.1', + data: { + name: 'test-feature-auto-context', + enabled: true, + strategies: [{ name: 'default' }], + }, + }; + + await eventStoreService.transactional(async (transactionalEventStore) => { + await transactionalEventStore.store(event); + }); + + const events = await eventStore.getAll(); + const storedEvent = events.find( + (e) => e.featureName === 'test-feature-auto-context', + ); + + expect(storedEvent).toBeTruthy(); + expect(storedEvent!.groupType).toBe('transaction'); + expect(storedEvent!.groupId).toBeTruthy(); + expect(typeof storedEvent!.groupId).toBe('string'); + expect(storedEvent!.groupId).toMatch(/^[0-9A-HJKMNP-TV-Z]{26}$/); +});