mirror of
				https://github.com/Unleash/unleash.git
				synced 2025-10-27 11:02:16 +01:00 
			
		
		
		
	1. Add a test for the failing use case (we can see it [here](https://github.com/Unleash/unleash/actions/runs/5656229196/job/15322845002?pr=4339#step:5:783)): ``` FAIL src/lib/services/client-metrics/metrics-service-v2.test.ts ● process metrics properly even when some names are not url friendly ValidationError: "name" must be URL friendly ``` 2. Fix and handle this gracefully 3. Added a new toggle to silently ignore bad names: filterInvalidClientMetrics Fixes: https://github.com/Unleash/unleash/pull/4193
This commit is contained in:
		
							parent
							
								
									dfd44b9050
								
							
						
					
					
						commit
						e876807f30
					
				| @ -79,6 +79,7 @@ exports[`should create default config 1`] = ` | ||||
|       "embedProxyFrontend": true, | ||||
|       "emitPotentiallyStaleEvents": false, | ||||
|       "featuresExportImport": true, | ||||
|       "filterInvalidClientMetrics": false, | ||||
|       "googleAuthEnabled": false, | ||||
|       "maintenanceMode": false, | ||||
|       "messageBanner": { | ||||
| @ -113,6 +114,7 @@ exports[`should create default config 1`] = ` | ||||
|       "embedProxyFrontend": true, | ||||
|       "emitPotentiallyStaleEvents": false, | ||||
|       "featuresExportImport": true, | ||||
|       "filterInvalidClientMetrics": false, | ||||
|       "googleAuthEnabled": false, | ||||
|       "maintenanceMode": false, | ||||
|       "messageBanner": { | ||||
|  | ||||
							
								
								
									
										116
									
								
								src/lib/services/client-metrics/metrics-service-v2.test.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										116
									
								
								src/lib/services/client-metrics/metrics-service-v2.test.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,116 @@ | ||||
| import ClientMetricsServiceV2 from './metrics-service-v2'; | ||||
| 
 | ||||
| import getLogger from '../../../test/fixtures/no-logger'; | ||||
| 
 | ||||
| import createStores from '../../../test/fixtures/store'; | ||||
| import EventEmitter from 'events'; | ||||
| import { LastSeenService } from './last-seen-service'; | ||||
| import { IUnleashConfig } from 'lib/types'; | ||||
| 
 | ||||
| function initClientMetrics(flagEnabled = true) { | ||||
|     const stores = createStores(); | ||||
| 
 | ||||
|     const eventBus = new EventEmitter(); | ||||
|     eventBus.emit = jest.fn(); | ||||
| 
 | ||||
|     const config = { | ||||
|         eventBus, | ||||
|         getLogger, | ||||
|         flagResolver: { | ||||
|             isEnabled: () => { | ||||
|                 return flagEnabled; | ||||
|             }, | ||||
|         }, | ||||
|     } as unknown as IUnleashConfig; | ||||
| 
 | ||||
|     const lastSeenService = new LastSeenService(stores, config); | ||||
|     lastSeenService.updateLastSeen = jest.fn(); | ||||
| 
 | ||||
|     const service = new ClientMetricsServiceV2(stores, config, lastSeenService); | ||||
|     return { clientMetricsService: service, eventBus, lastSeenService }; | ||||
| } | ||||
| 
 | ||||
| test('process metrics properly', async () => { | ||||
|     const { clientMetricsService, eventBus, lastSeenService } = | ||||
|         initClientMetrics(); | ||||
|     await clientMetricsService.registerClientMetrics( | ||||
|         { | ||||
|             appName: 'test', | ||||
|             bucket: { | ||||
|                 start: '1982-07-25T12:00:00.000Z', | ||||
|                 stop: '2023-07-25T12:00:00.000Z', | ||||
|                 toggles: { | ||||
|                     myCoolToggle: { | ||||
|                         yes: 25, | ||||
|                         no: 42, | ||||
|                         variants: { | ||||
|                             blue: 6, | ||||
|                             green: 15, | ||||
|                             red: 46, | ||||
|                         }, | ||||
|                     }, | ||||
|                     myOtherToggle: { | ||||
|                         yes: 0, | ||||
|                         no: 100, | ||||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             environment: 'test', | ||||
|         }, | ||||
|         '127.0.0.1', | ||||
|     ); | ||||
| 
 | ||||
|     expect(eventBus.emit).toHaveBeenCalledTimes(1); | ||||
|     expect(lastSeenService.updateLastSeen).toHaveBeenCalledTimes(1); | ||||
| }); | ||||
| 
 | ||||
| test('process metrics properly even when some names are not url friendly, filtering out invalid names when flag is on', async () => { | ||||
|     const { clientMetricsService, eventBus, lastSeenService } = | ||||
|         initClientMetrics(); | ||||
|     await clientMetricsService.registerClientMetrics( | ||||
|         { | ||||
|             appName: 'test', | ||||
|             bucket: { | ||||
|                 start: '1982-07-25T12:00:00.000Z', | ||||
|                 stop: '2023-07-25T12:00:00.000Z', | ||||
|                 toggles: { | ||||
|                     'not url friendly ☹': { | ||||
|                         yes: 0, | ||||
|                         no: 100, | ||||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             environment: 'test', | ||||
|         }, | ||||
|         '127.0.0.1', | ||||
|     ); | ||||
| 
 | ||||
|     // only toggle with a bad name gets filtered out
 | ||||
|     expect(eventBus.emit).not.toHaveBeenCalled(); | ||||
|     expect(lastSeenService.updateLastSeen).not.toHaveBeenCalled(); | ||||
| }); | ||||
| 
 | ||||
| test('process metrics properly even when some names are not url friendly, with default behavior when flag is off', async () => { | ||||
|     const { clientMetricsService, eventBus, lastSeenService } = | ||||
|         initClientMetrics(false); | ||||
|     await clientMetricsService.registerClientMetrics( | ||||
|         { | ||||
|             appName: 'test', | ||||
|             bucket: { | ||||
|                 start: '1982-07-25T12:00:00.000Z', | ||||
|                 stop: '2023-07-25T12:00:00.000Z', | ||||
|                 toggles: { | ||||
|                     'not url friendly ☹': { | ||||
|                         yes: 0, | ||||
|                         no: 100, | ||||
|                     }, | ||||
|                 }, | ||||
|             }, | ||||
|             environment: 'test', | ||||
|         }, | ||||
|         '127.0.0.1', | ||||
|     ); | ||||
| 
 | ||||
|     expect(eventBus.emit).toHaveBeenCalledTimes(1); | ||||
|     expect(lastSeenService.updateLastSeen).toHaveBeenCalledTimes(1); | ||||
| }); | ||||
| @ -1,5 +1,5 @@ | ||||
| import { Logger } from '../../logger'; | ||||
| import { IUnleashConfig } from '../../types'; | ||||
| import { IFlagResolver, IUnleashConfig } from '../../types'; | ||||
| import { IUnleashStores } from '../../types'; | ||||
| import { ToggleMetricsSummary } from '../../types/models/metrics'; | ||||
| import { | ||||
| @ -21,7 +21,6 @@ import { LastSeenService } from './last-seen-service'; | ||||
| import { generateHourBuckets } from '../../util/time-utils'; | ||||
| import { ClientMetricsSchema } from 'lib/openapi'; | ||||
| import { nameSchema } from '../../schema/feature-schema'; | ||||
| import { BadDataError } from '../../error'; | ||||
| 
 | ||||
| export default class ClientMetricsServiceV2 { | ||||
|     private config: IUnleashConfig; | ||||
| @ -34,6 +33,8 @@ export default class ClientMetricsServiceV2 { | ||||
| 
 | ||||
|     private lastSeenService: LastSeenService; | ||||
| 
 | ||||
|     private flagResolver: Pick<IFlagResolver, 'isEnabled'>; | ||||
| 
 | ||||
|     private logger: Logger; | ||||
| 
 | ||||
|     constructor( | ||||
| @ -48,6 +49,7 @@ export default class ClientMetricsServiceV2 { | ||||
|         this.logger = config.getLogger( | ||||
|             '/services/client-metrics/client-metrics-service-v2.ts', | ||||
|         ); | ||||
|         this.flagResolver = config.flagResolver; | ||||
| 
 | ||||
|         this.timers.push( | ||||
|             setInterval(() => { | ||||
| @ -62,6 +64,32 @@ export default class ClientMetricsServiceV2 { | ||||
|         ); | ||||
|     } | ||||
| 
 | ||||
|     async filterValidToggleNames(toggleNames: string[]): Promise<string[]> { | ||||
|         const nameValidations: Promise< | ||||
|             PromiseFulfilledResult<{ name: string }> | PromiseRejectedResult | ||||
|         >[] = toggleNames.map((toggleName) => | ||||
|             nameSchema.validateAsync({ name: toggleName }), | ||||
|         ); | ||||
|         const badNames = (await Promise.allSettled(nameValidations)).filter( | ||||
|             (r) => r.status === 'rejected', | ||||
|         ); | ||||
|         if (badNames.length > 0) { | ||||
|             this.logger.warn( | ||||
|                 `Got a few toggles with invalid names: ${JSON.stringify( | ||||
|                     badNames, | ||||
|                 )}`,
 | ||||
|             ); | ||||
| 
 | ||||
|             if (this.flagResolver.isEnabled('filterInvalidClientMetrics')) { | ||||
|                 const justNames = badNames.map( | ||||
|                     (r: PromiseRejectedResult) => r.reason._original.name, | ||||
|                 ); | ||||
|                 return toggleNames.filter((name) => !justNames.includes(name)); | ||||
|             } | ||||
|         } | ||||
|         return toggleNames; | ||||
|     } | ||||
| 
 | ||||
|     async registerBulkMetrics(metrics: IClientMetricsEnv[]): Promise<void> { | ||||
|         this.unsavedMetrics = collapseHourlyMetrics([ | ||||
|             ...this.unsavedMetrics, | ||||
| @ -83,28 +111,30 @@ export default class ClientMetricsServiceV2 { | ||||
|                 ), | ||||
|         ); | ||||
| 
 | ||||
|         for (const toggle of toggleNames) { | ||||
|             if (!(await nameSchema.validateAsync({ name: toggle }))) { | ||||
|                 throw new BadDataError( | ||||
|                     `Invalid feature toggle name "${toggle}"`, | ||||
|                 ); | ||||
|             } | ||||
|         const validatedToggleNames = await this.filterValidToggleNames( | ||||
|             toggleNames, | ||||
|         ); | ||||
| 
 | ||||
|         this.logger.debug( | ||||
|             `Got ${toggleNames.length} (${validatedToggleNames.length} valid) metrics from ${clientIp}`, | ||||
|         ); | ||||
| 
 | ||||
|         if (validatedToggleNames.length > 0) { | ||||
|             const clientMetrics: IClientMetricsEnv[] = validatedToggleNames.map( | ||||
|                 (name) => ({ | ||||
|                     featureName: name, | ||||
|                     appName: value.appName, | ||||
|                     environment: value.environment ?? 'default', | ||||
|                     timestamp: value.bucket.start, //we might need to approximate between start/stop...
 | ||||
|                     yes: value.bucket.toggles[name].yes ?? 0, | ||||
|                     no: value.bucket.toggles[name].no ?? 0, | ||||
|                     variants: value.bucket.toggles[name].variants, | ||||
|                 }), | ||||
|             ); | ||||
|             await this.registerBulkMetrics(clientMetrics); | ||||
| 
 | ||||
|             this.config.eventBus.emit(CLIENT_METRICS, value); | ||||
|         } | ||||
| 
 | ||||
|         this.logger.debug(`got metrics from ${clientIp}`); | ||||
| 
 | ||||
|         const clientMetrics: IClientMetricsEnv[] = toggleNames.map((name) => ({ | ||||
|             featureName: name, | ||||
|             appName: value.appName, | ||||
|             environment: value.environment, | ||||
|             timestamp: value.bucket.start, //we might need to approximate between start/stop...
 | ||||
|             yes: value.bucket.toggles[name].yes, | ||||
|             no: value.bucket.toggles[name].no, | ||||
|             variants: value.bucket.toggles[name].variants, | ||||
|         })); | ||||
|         await this.registerBulkMetrics(clientMetrics); | ||||
| 
 | ||||
|         this.config.eventBus.emit(CLIENT_METRICS, value); | ||||
|     } | ||||
| 
 | ||||
|     async bulkAdd(): Promise<void> { | ||||
|  | ||||
| @ -26,7 +26,8 @@ export type IFlagKey = | ||||
|     | 'newProjectLayout' | ||||
|     | 'slackAppAddon' | ||||
|     | 'emitPotentiallyStaleEvents' | ||||
|     | 'configurableFeatureTypeLifetimes'; | ||||
|     | 'configurableFeatureTypeLifetimes' | ||||
|     | 'filterInvalidClientMetrics'; | ||||
| 
 | ||||
| export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; | ||||
| 
 | ||||
| @ -121,6 +122,10 @@ const flags: IFlags = { | ||||
|         process.env.UNLEASH_EXPERIMENTAL_CONFIGURABLE_FEATURE_TYPE_LIFETIMES, | ||||
|         false, | ||||
|     ), | ||||
|     filterInvalidClientMetrics: parseEnvVarBoolean( | ||||
|         process.env.FILTER_INVALID_CLIENT_METRICS, | ||||
|         false, | ||||
|     ), | ||||
| }; | ||||
| 
 | ||||
| export const defaultExperimentalOptions: IExperimentalOptions = { | ||||
|  | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user