Repository: Unleash/unleash
Pull request #9879: feat: bulk apps should respect multi projects and multi envs
Author: login: kwasniew
Created: 2025-05-01T10:13:59Z | Merged: 2025-05-02T08:12:41Z

<!-- Thanks for creating a PR! To make it easier for reviewers and everyone else to understand what your changes relate to, please add some relevant content to the headings below. Feel free to ignore or delete sections that you don't think are relevant. Thank you! ❤️ -->

## About the changes

Major improvement to bulk metrics reporting:
* we add support for projects attached to each client application in bulk reporting
* we fixed support for apps with multiple environments. All of them are reported instead of taking the last one from the list

Technical details:
* expand API to accept projects for all reported client applications (both frontend and backend)
* fixed aggregations of envs and projects
* added a test showing the most complex scenario with one app and multiple envs and multiple projects

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally important. Where should a reviewer start looking to get an overview of the changes? Are any files particularly important?  -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged? Got any questions or doubts? -->



Diff:
diff --git a/src/lib/db/client-applications-store.ts b/src/lib/db/client-applications-store.ts
index e6c7d8b922..9994dd923a 100644
--- a/src/lib/db/client-applications-store.ts
+++ b/src/lib/db/client-applications-store.ts
@@ -91,7 +91,7 @@ const reduceRows = (rows: any[]): IClientApplication[] => {
     return Object.values(appsObj);
 };
 
-const remapRow = (input) => {
+const remapRow = (input: Partial<IClientApplication>) => {
     const temp = {
         app_name: input.appName,
         updated_at: input.updatedAt || new Date(),
@@ -152,10 +152,28 @@ export default class ClientApplicationsStore
 
     async bulkUpsert(apps: Partial<IClientApplication>[]): Promise<void> {
         const rows = apps.map(remapRow);
+        const uniqueRows = Object.values(
+            rows.reduce((acc, row) => {
+                if (row.app_name) {
+                    acc[row.app_name] = row;
+                }
+                return acc;
+            }, {}),
+        );
         const usageRows = apps.flatMap(this.remapUsageRow);
-        await this.db(TABLE).insert(rows).onConflict('app_name').merge();
+        const uniqueUsageRows = Object.values(
+            usageRows.reduce((acc, row) => {
+                if (row.app_name) {
+                    acc[`${row.app_name} ${row.project} ${row.environment}`] =
+                        row;
+                }
+                return acc;
+            }, {}),
+        );
+
+        await this.db(TABLE).insert(uniqueRows).onConflict('app_name').merge();
         await this.db(TABLE_USAGE)
-            .insert(usageRows)
+            .insert(uniqueUsageRows)
             .onConflict(['app_name', 'project', 'environment'])
             .merge();
     }
@@ -444,7 +462,7 @@ export default class ClientApplicationsStore
         };
     }
 
-    private remapUsageRow = (input) => {
+    private remapUsageRow = (input: Partial<IClientApplication>) => {
         if (!input.projects || input.projects.length === 0) {
             return [
                 {
diff --git a/src/lib/features/metrics/instance/instance-service.ts b/src/lib/features/metrics/instance/instance-service.ts
index 794019f75f..33682ddff9 100644
--- a/src/lib/features/metrics/instance/instance-service.ts
+++ b/src/lib/features/metrics/instance/instance-service.ts
@@ -173,8 +173,19 @@ export default class ClientInstanceService {
             const uniqueRegistrations = Object.values(this.seenClients);
             const uniqueApps: Partial<IClientApplication>[] = Object.values(
                 uniqueRegistrations.reduce((soFar, reg) => {
-                    // eslint-disable-next-line no-param-reassign
-                    soFar[reg.appName] = reg;
+                    let existingProjects = [];
+                    if (soFar[`${reg.appName} ${reg.environment}`]) {
+                        existingProjects =
+                            soFar[`${reg.appName} ${reg.environment}`]
+                                .projects || [];
+                    }
+                    soFar[`${reg.appName} ${reg.environment}`] = {
+                        ...reg,
+                        projects: [
+                            ...existingProjects,
+                            ...(reg.projects || []),
+                        ],
+                    };
                     return soFar;
                 }, {}),
             );
diff --git a/src/lib/features/metrics/instance/metrics.test.ts b/src/lib/features/metrics/instance/metrics.test.ts
index 07f1c4ae88..8769cd7335 100644
--- a/src/lib/features/metrics/instance/metrics.test.ts
+++ b/src/lib/features/metrics/instance/metrics.test.ts
@@ -61,6 +61,7 @@ afterAll(async () => {
 
 afterEach(async () => {
     await stores.featureToggleStore.deleteAll();
+    await stores.clientApplicationsStore.deleteAll();
 });
 
 test('should validate client metrics', () => {
@@ -125,7 +126,7 @@ test('should accept client metrics with yes/no with metricsV2', async () => {
         })
         .expect(202);
 
-    testRunner.destroy();
+    await testRunner.destroy();
 });
 
 test('should accept client metrics with variants', () => {
@@ -343,6 +344,99 @@ describe('bulk metrics', () => {
         });
     });
 
+    test('should respect project from token', async () => {
+        const frontendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'browser',
+            environment: 'production',
+            sdkVersion: 'unleash-client-js:1.0.0',
+            sdkType: 'frontend',
+            projects: ['project-a', 'project-b'],
+        };
+        const backendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'instance1234',
+            environment: 'development',
+            sdkVersion: 'unleash-client-node',
+            sdkType: 'backend',
+            started: '1952-03-11T12:00:00.000Z',
+            interval: 15000,
+            projects: ['project-b', 'project-c'],
+        };
+        const defaultApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'instance5678',
+            environment: 'development',
+            sdkVersion: 'unleash-client-java',
+            sdkType: null,
+            started: '1952-03-11T12:00:00.000Z',
+            interval: 15000,
+            projects: ['project-c', 'project-d'],
+        };
+        await request
+            .post('/api/client/metrics/bulk')
+            .send({
+                applications: [frontendApp, backendApp, defaultApp],
+                metrics: [],
+            })
+            .expect(202);
+
+        await services.clientInstanceService.bulkAdd();
+        const app = await services.clientInstanceService.getApplication(
+            'application-name-token',
+        );
+
+        expect(app).toMatchObject({
+            appName: 'application-name-token',
+            instances: [
+                {
+                    instanceId: 'instance1234',
+                    sdkVersion: 'unleash-client-node',
+                    environment: 'development',
+                },
+                {
+                    instanceId: 'instance5678',
+                    sdkVersion: 'unleash-client-java',
+                    environment: 'development',
+                },
+                {
+                    instanceId: 'browser',
+                    sdkVersion: 'unleash-client-js:1.0.0',
+                    environment: 'production',
+                },
+            ],
+        });
+
+        const applications =
+            await stores.clientApplicationsStore.getApplications({
+                limit: 10,
+                offset: 0,
+                sortBy: 'name',
+                sortOrder: 'asc',
+            });
+        expect(applications).toMatchObject({
+            applications: [
+                {
+                    usage: [
+                        {
+                            project: 'project-a',
+                            environments: ['production'],
+                        },
+                        {
+                            project: 'project-b',
+                            environments: ['production', 'development'],
+                        },
+                        {
+                            project: 'project-c',
+                            environments: ['development'],
+                        },
+                        { project: 'project-d', environments: ['development'] },
+                    ],
+                },
+            ],
+        });
+    });
+
     test('filters out metrics for environments we do not have access for. No auth setup so we can only access default env', async () => {
         const now = new Date();
 
diff --git a/src/lib/features/metrics/instance/metrics.ts b/src/lib/features/metrics/instance/metrics.ts
index 4d6141f505..183316d72f 100644
--- a/src/lib/features/metrics/instance/metrics.ts
+++ b/src/lib/features/metrics/instance/metrics.ts
@@ -158,6 +158,7 @@ export default class ClientMetricsController extends Controller {
                                 environment: app.environment,
                                 sdkType: app.sdkType,
                                 sdkVersion: app.sdkVersion,
+                                projects: app.projects,
                             });
                         }
                     } else {
diff --git a/src/lib/openapi/spec/bulk-registration-schema.ts b/src/lib/openapi/spec/bulk-registration-schema.ts
index edc1489130..fcce8bac97 100644
--- a/src/lib/openapi/spec/bulk-registration-schema.ts
+++ b/src/lib/openapi/spec/bulk-registration-schema.ts
@@ -64,6 +64,14 @@ export const bulkRegistrationSchema = {
                 type: 'string',
             },
         },
+        projects: {
+            description: 'The list of projects used in the application',
+            type: 'array',
+            example: ['projectA', 'projectB'],
+            items: {
+                type: 'string',
+            },
+        },
         sdkVersion: {
             description:
                 'The version the sdk is running. Typically <client>:<version>',
diff --git a/src/lib/types/model.ts b/src/lib/types/model.ts
index ad5737244e..36600eca56 100644
--- a/src/lib/types/model.ts
+++ b/src/lib/types/model.ts
@@ -478,6 +478,7 @@ export interface IClientApp {
     seenToggles?: string[];
     metricsCount?: number;
     strategies?: string[] | Record<string, string>[];
+    projects?: string[];
     count?: number;
     started?: string | number | Date;
     interval?: number;
diff --git a/src/lib/types/stores/client-applications-store.ts b/src/lib/types/stores/client-applications-store.ts
index e32bac5699..1ae7430377 100644
--- a/src/lib/types/stores/client-applications-store.ts
+++ b/src/lib/types/stores/client-applications-store.ts
@@ -19,6 +19,8 @@ export interface IClientApplication {
     icon: string;
     strategies: string[];
     usage?: IClientApplicationUsage[];
+    projects?: string[];
+    environment?: string;
 }
 
 export interface IClientApplications {


Reviews:
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:15:06Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:15:21Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:15:54Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:16:12Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:16:21Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:16:37Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:17:17Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:17:33Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:17:38Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:18:02Z
user: login: kwasniew
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-01T10:19:04Z
user: login: chriswk
body: Interesting. I think we should have a look at Edge's bulk endpoint as well, but that's not a blocker for an :sunglasses:  approval here
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-02T07:20:51Z

Review comments:
diff_hunk: @@ -152,10 +152,28 @@ export default class ClientApplicationsStore
 
     async bulkUpsert(apps: Partial<IClientApplication>[]): Promise<void> {
         const rows = apps.map(remapRow);
+        const uniqueRows = Object.values(
path: src/lib/db/client-applications-store.ts
user: login: kwasniew
body: onConflict only respects conflicts with existing records but if the new input data has duplicates the insert would fail. By putting dedupe in the store we guarantee it is always correct
created_at: 2025-05-01T10:15:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -152,10 +152,28 @@ export default class ClientApplicationsStore
 
     async bulkUpsert(apps: Partial<IClientApplication>[]): Promise<void> {
         const rows = apps.map(remapRow);
+        const uniqueRows = Object.values(
+            rows.reduce((acc, row) => {
+                if (row.app_name) {
+                    acc[row.app_name] = row;
+                }
+                return acc;
+            }, {}),
+        );
         const usageRows = apps.flatMap(this.remapUsageRow);
-        await this.db(TABLE).insert(rows).onConflict('app_name').merge();
+        const uniqueUsageRows = Object.values(
path: src/lib/db/client-applications-store.ts
user: login: kwasniew
body: same dedupe we did for apps we do for the usage
created_at: 2025-05-01T10:15:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -173,8 +173,19 @@ export default class ClientInstanceService {
             const uniqueRegistrations = Object.values(this.seenClients);
             const uniqueApps: Partial<IClientApplication>[] = Object.values(
                 uniqueRegistrations.reduce((soFar, reg) => {
-                    // eslint-disable-next-line no-param-reassign
-                    soFar[reg.appName] = reg;
path: src/lib/features/metrics/instance/instance-service.ts
user: login: kwasniew
body: this was lossy. It was ignoring same app with multiple different envs and multiple projects
created_at: 2025-05-01T10:15:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -173,8 +173,19 @@ export default class ClientInstanceService {
             const uniqueRegistrations = Object.values(this.seenClients);
             const uniqueApps: Partial<IClientApplication>[] = Object.values(
                 uniqueRegistrations.reduce((soFar, reg) => {
-                    // eslint-disable-next-line no-param-reassign
-                    soFar[reg.appName] = reg;
+                    let existingProjects = [];
+                    if (soFar[`${reg.appName} ${reg.environment}`]) {
+                        existingProjects =
+                            soFar[`${reg.appName} ${reg.environment}`]
+                                .projects || [];
+                    }
+                    soFar[`${reg.appName} ${reg.environment}`] = {
+                        ...reg,
+                        projects: [
path: src/lib/features/metrics/instance/instance-service.ts
user: login: kwasniew
body: dedupe done in the store for safety resons
created_at: 2025-05-01T10:16:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -61,6 +61,7 @@ afterAll(async () => {
 
 afterEach(async () => {
     await stores.featureToggleStore.deleteAll();
+    await stores.clientApplicationsStore.deleteAll();
path: src/lib/features/metrics/instance/metrics.test.ts
user: login: kwasniew
body: this cleanup was missing
created_at: 2025-05-01T10:16:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -125,7 +126,7 @@ test('should accept client metrics with yes/no with metricsV2', async () => {
         })
         .expect(202);
 
-    testRunner.destroy();
+    await testRunner.destroy();
path: src/lib/features/metrics/instance/metrics.test.ts
user: login: kwasniew
body: it was causing flaky tests in our CI before
created_at: 2025-05-01T10:16:37Z
author_association: CONTRIBUTOR
diff_hunk: @@ -158,6 +158,7 @@ export default class ClientMetricsController extends Controller {
                                 environment: app.environment,
                                 sdkType: app.sdkType,
                                 sdkVersion: app.sdkVersion,
+                                projects: app.projects,
path: src/lib/features/metrics/instance/metrics.ts
user: login: kwasniew
body: this is respecting the newly added projects field in edge for each registered application
created_at: 2025-05-01T10:17:17Z
author_association: CONTRIBUTOR
diff_hunk: @@ -343,6 +344,99 @@ describe('bulk metrics', () => {
         });
     });
 
+    test('should respect project from token', async () => {
+        const frontendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'browser',
+            environment: 'production',
path: src/lib/features/metrics/instance/metrics.test.ts
user: login: kwasniew
body: first env
created_at: 2025-05-01T10:17:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -343,6 +344,99 @@ describe('bulk metrics', () => {
         });
     });
 
+    test('should respect project from token', async () => {
+        const frontendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'browser',
+            environment: 'production',
+            sdkVersion: 'unleash-client-js:1.0.0',
+            sdkType: 'frontend',
+            projects: ['project-a', 'project-b'],
+        };
+        const backendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'instance1234',
+            environment: 'development',
path: src/lib/features/metrics/instance/metrics.test.ts
user: login: kwasniew
body: another env
created_at: 2025-05-01T10:17:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -343,6 +344,99 @@ describe('bulk metrics', () => {
         });
     });
 
+    test('should respect project from token', async () => {
+        const frontendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'browser',
+            environment: 'production',
+            sdkVersion: 'unleash-client-js:1.0.0',
+            sdkType: 'frontend',
+            projects: ['project-a', 'project-b'],
path: src/lib/features/metrics/instance/metrics.test.ts
user: login: kwasniew
body: we deliberately have intersecting projects between different reported apps
created_at: 2025-05-01T10:18:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -343,6 +344,99 @@ describe('bulk metrics', () => {
         });
     });
 
+    test('should respect project from token', async () => {
+        const frontendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'browser',
+            environment: 'production',
+            sdkVersion: 'unleash-client-js:1.0.0',
+            sdkType: 'frontend',
+            projects: ['project-a', 'project-b'],
+        };
+        const backendApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'instance1234',
+            environment: 'development',
+            sdkVersion: 'unleash-client-node',
+            sdkType: 'backend',
+            started: '1952-03-11T12:00:00.000Z',
+            interval: 15000,
+            projects: ['project-b', 'project-c'],
+        };
+        const defaultApp: BulkRegistrationSchema = {
+            appName: 'application-name-token',
+            instanceId: 'instance5678',
+            environment: 'development',
+            sdkVersion: 'unleash-client-java',
+            sdkType: null,
+            started: '1952-03-11T12:00:00.000Z',
+            interval: 15000,
+            projects: ['project-c', 'project-d'],
+        };
+        await request
+            .post('/api/client/metrics/bulk')
+            .send({
+                applications: [frontendApp, backendApp, defaultApp],
+                metrics: [],
+            })
+            .expect(202);
+
+        await services.clientInstanceService.bulkAdd();
+        const app = await services.clientInstanceService.getApplication(
+            'application-name-token',
+        );
+
+        expect(app).toMatchObject({
+            appName: 'application-name-token',
+            instances: [
+                {
+                    instanceId: 'instance1234',
+                    sdkVersion: 'unleash-client-node',
+                    environment: 'development',
+                },
+                {
+                    instanceId: 'instance5678',
+                    sdkVersion: 'unleash-client-java',
+                    environment: 'development',
+                },
+                {
+                    instanceId: 'browser',
+                    sdkVersion: 'unleash-client-js:1.0.0',
+                    environment: 'production',
+                },
+            ],
+        });
+
+        const applications =
+            await stores.clientApplicationsStore.getApplications({
+                limit: 10,
+                offset: 0,
+                sortBy: 'name',
+                sortOrder: 'asc',
+            });
+        expect(applications).toMatchObject({
path: src/lib/features/metrics/instance/metrics.test.ts
user: login: kwasniew
body: this assertion verifies that we never loose any projects and environments. This is the most important improvement in this PR over the previous version that always ignored projects ('*') and only respected the last env in the list of client apps with the same name
created_at: 2025-05-01T10:19:04Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-05-01T10:14:03Z
body: [vc]: #PpGjj6Ur93GSSJekR/0KR6Bj57F/8V4GkH4VeG9N/D0=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJ1bmxlYXNoLWRvY3MiLCJyb290RGlyZWN0b3J5Ijoid2Vic2l0ZSIsImxpdmVGZWVkYmFjayI6eyJyZXNvbHZlZCI6MCwidW5yZXNvbHZlZCI6MCwidG90YWwiOjAsImxpbmsiOiIifSwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3VubGVhc2gtdGVhbS91bmxlYXNoLWRvY3MvN3hxcnNpQjVaWUJ0ckt1RGJpNG5QYlJQRW1ndiIsIm5leHRDb21taXRTdGF0dXMiOiJJR05PUkVEIiwicHJldmlld1VybCI6InVubGVhc2gtZG9jcy1naXQtcmVnaXN0ZXItYnVsay1tZXRyaWNzLXJlc3AtMTQwZTMxLXVubGVhc2gtdGVhbS52ZXJjZWwuYXBwIn1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)


<details><summary>1 Skipped Deployment</summary>

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **unleash-docs** | ⬜️ Ignored ([Inspect](https://vercel.com/unleash-team/unleash-docs/7xqrsiB5ZYBtrKuDbi4nPbRPEmgv)) | [Visit Preview](https://unleash-docs-git-register-bulk-metrics-resp-140e31-unleash-team.vercel.app) |  | May 2, 2025 7:41am |
</details>


author_association: NONE
user: login: github-actions[bot]
created_at: 2025-05-01T10:14:13Z
body: <h1>Dependency Review</h1>
✅ No vulnerabilities or license issues or OpenSSF Scorecard issues found.<h2>Scanned Files</h2>
None

<!-- dependency-review-pr-comment-marker -->
author_association: CONTRIBUTOR